289 lines
15 KiB
Python
289 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Preview or write a deterministic DEVELOPMENT SOURCE BUNDLE; never build/publish."""
|
|
import argparse
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import re
|
|
import stat
|
|
import tempfile
|
|
import zipfile
|
|
import sys
|
|
|
|
sys.dont_write_bytecode = True
|
|
|
|
from source_manifest import addon_manifest
|
|
from project_schema import parse_project
|
|
from resource_references import hard_resource_references
|
|
from external_addons import policy as external_policy, direct_for, resource_allowed, ALLOWED
|
|
from release_check import audit, read_json, evidence_path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BASE = '58D0FB3206B6F859'
|
|
PROJECTS = {'core': ('ERR_Core', '2528466DE220D91C', ())}
|
|
# Minimal inherited character action patch, scoped at runtime to Escape. This
|
|
# exact GUID/path/owner is intentional; other vanilla identity reuse stays denied.
|
|
VANILLA_OVERRIDES = {'37578B1666981FCE': ('Prefabs/Characters/Core/Character_Base.et', 'core')}
|
|
|
|
DIRECTORIES = {'Scripts', 'Prefabs', 'Configs', 'Missions', 'Worlds', 'UI', 'Materials', 'AI'}
|
|
SUFFIXES = {'.c', '.et', '.conf', '.ent', '.layer', '.layout', '.emat', '.bt', '.meta', '.gproj', '.imageset', '.png', '.edds'}
|
|
BUNDLED_SPEECH = re.compile(r'addons/core/Sounds/ERR/Speech/(?:welcome|soldier_[a-z0-9_]+|radio_[0-9]{2})\.(?:wav|acp)(?:\.meta)?$')
|
|
# Locally authored atlas, with its editable FBX and native import result.
|
|
# Keep binary-model admission scoped to this reviewed asset, not arbitrary Assets/.
|
|
ATLAS_ROOT = 'addons/core/Assets/Items/ERR_Atlas/'
|
|
ATLAS_FILES = {name + suffix for name in ('ERR_Atlas', 'ERR_AtlasLeaf')
|
|
for suffix in ('.fbx', '.txo', '.xob', '.xob.meta')}
|
|
ATLAS_FILES.update('AtlasTurn' + suffix for suffix in ('.agr', '.agr.meta', '.agf', '.agf.meta'))
|
|
ATLAS_FILES.update(name + suffix for name in ('AtlasPrint', 'AtlasPaper', 'AtlasCover', 'AtlasWire')
|
|
for suffix in ('.emat', '.emat.meta'))
|
|
EXCLUDED = {'.git', '.build', '.local', '__pycache__', 'profiles', 'profile', 'cache',
|
|
'runtime', 'evidence', 'vendor', 'vanilla', 'original', 'node_modules', '.agents', '.codex'}
|
|
README = '''# DEVELOPMENT SOURCE BUNDLE — Co10 Escape Reintroduced
|
|
|
|
Author: Jakub Jim Žáček.
|
|
|
|
This ZIP contains the single Co10 Escape Reintroduced addon, with Core, AI,
|
|
Intel, Map and Chat script folders. It is NOT a native .pak or a published mod.
|
|
Third-party assets are not redistributed.
|
|
|
|
Extract into a NEW development folder and register addons/core/addon.gproj in
|
|
Workbench. Install and register the RHS/WCS dependencies in EXTERNAL-ADDONS.json.
|
|
Open addons/core/addon.gproj and use Missions/ERR_Everon.conf.
|
|
|
|
Scenario: {C1E20667DCE324ED}Missions/ERR_Everon.conf
|
|
Use the installed engine's normal project/scenario workflow. This archive has not
|
|
been compiled by its bundler; no native caches or executable binaries are included.
|
|
Authored UI textures include their PNG sources and Workbench-imported EDDS files.
|
|
The authored atlas includes FBX/TXO model sources and its imported XOB resource.
|
|
|
|
Target: Everon, up to 12 players. The mod requires the listed external addons. This remains development source.
|
|
Manual prison/scavenging/radio/extraction play, remote joins/reconnects and
|
|
ownership, twelve connected players, aircraft support, repeated companion cleanup,
|
|
matched-workload FPS improvement and release qualification require separate
|
|
source-bound validation. Existing scripted fixtures are not those acceptances.
|
|
WORKBENCH-only probes are source, not proof of a test run or normal gameplay.
|
|
|
|
BUNDLE-MANIFEST.json lists each payload file's SHA-256 and project dependencies.
|
|
Verify extracted bytes against it before editing. The manifest excludes its own
|
|
hash to avoid self-reference. These hashes identify bytes, not authenticity,
|
|
license grants or provenance proof. Source review remains required before sharing.
|
|
Evidence, acceptance records, runtime profiles and test tools are intentionally
|
|
absent; consult the maintained repository for current status and retained logs.
|
|
Even --mode release only adds the existing repository qualification audit before
|
|
this same development-source handoff; it does not build/publish a game release.
|
|
'''
|
|
|
|
|
|
def canonical(name):
|
|
p = PurePosixPath(name)
|
|
if (not name or p.is_absolute() or p.as_posix() != name or '\\' in name
|
|
or ':' in name or any(ord(c) < 32 for c in name)
|
|
or any(part in {'.', '..'} or part.endswith((' ', '.')) for part in p.parts)):
|
|
raise ValueError(f'unsafe bundle path: {name!r}')
|
|
return p
|
|
|
|
|
|
def inventory(root):
|
|
"""Reuse the evidence inventory; impose a narrower reviewed handoff schema."""
|
|
# Preflight directories without following links, including otherwise excluded
|
|
# entries themselves. Excluded trees are pruned from this preflight and
|
|
# excluded from the payload; the shared source manifest may still hash them.
|
|
addons = root / 'addons'
|
|
if addons.is_symlink() or not addons.is_dir():
|
|
raise ValueError('addons must be a real directory')
|
|
for base, dirs, files in os.walk(addons, followlinks=False):
|
|
for name in list(dirs) + files:
|
|
path = Path(base) / name
|
|
if path.is_symlink():
|
|
raise ValueError(f'symlink refused: {path.relative_to(root)}')
|
|
if not path.is_dir() and not stat.S_ISREG(path.lstat().st_mode):
|
|
raise ValueError(f'nonregular addon entry: {path.relative_to(root)}')
|
|
dirs[:] = [d for d in dirs if d.lower() not in EXCLUDED]
|
|
original = addon_manifest(root)
|
|
result = {}
|
|
folded = set()
|
|
for name, digest in original.items():
|
|
parts = canonical(name).parts
|
|
if any(part.lower() in EXCLUDED for part in parts):
|
|
continue
|
|
if any(part.startswith('.') for part in parts):
|
|
raise ValueError(f'unreviewed hidden addon content: {name}')
|
|
if len(parts) < 3 or parts[1] not in PROJECTS:
|
|
raise ValueError(f'unknown addon project: {name}')
|
|
path = root / name
|
|
if not stat.S_ISREG(path.lstat().st_mode):
|
|
raise ValueError(f'nonregular addon file: {name}')
|
|
if name.casefold() in folded:
|
|
raise ValueError(f'case-insensitive path collision: {name}')
|
|
folded.add(name.casefold())
|
|
if parts[2] == 'addon.gproj' and len(parts) == 3:
|
|
pass
|
|
elif name.startswith(ATLAS_ROOT) and name[len(ATLAS_ROOT):] in ATLAS_FILES:
|
|
pass
|
|
elif BUNDLED_SPEECH.fullmatch(name):
|
|
pass
|
|
elif len(parts) < 4 or parts[2] not in DIRECTORIES or path.suffix not in SUFFIXES - {'.gproj'}:
|
|
raise ValueError(f'unreviewed addon source/resource format: {name}')
|
|
result[name] = digest
|
|
return result
|
|
|
|
|
|
def validate(payload, root):
|
|
projects = {}
|
|
external = external_policy(root)
|
|
own_ids = read_json(evidence_path(root, 'docs/addons.json'))
|
|
if own_ids != {name: item[1] for name, item in PROJECTS.items()}:
|
|
raise ValueError('successor identity catalog differs from the single owned project')
|
|
for name, (identity, guid, deps) in PROJECTS.items():
|
|
text = payload.get(f'addons/{name}/addon.gproj', b'').decode('utf-8')
|
|
match = parse_project(text, allow_core_menu=name == 'core')
|
|
expected = {BASE, *(PROJECTS[d][1] for d in deps), *direct_for(name)}
|
|
found = re.findall(r'"([A-F0-9]{16})"', match[4]) if match else []
|
|
if not match or match[1] != identity or match[2] != guid or set(found) != expected or len(found) != len(expected):
|
|
raise ValueError(f'identity/dependency closure mismatch: {name}')
|
|
projects[name] = {'id': identity, 'guid': guid, 'dependencies': sorted(found)}
|
|
resources = {}
|
|
for name, data in payload.items():
|
|
if not name.endswith('.meta'):
|
|
continue
|
|
m = re.search(r'Name "\{([A-F0-9]{16})\}([^"\n]+)"', data.decode('utf-8'))
|
|
owner = name.split('/')[1]
|
|
if not m or f'addons/{owner}/{m[2]}.meta' != name or name[:-5] not in payload:
|
|
raise ValueError(f'mismatched/missing resource metadata: {name}')
|
|
if m[1] in resources or m[1] in own_ids.values() or m[1] == BASE:
|
|
raise ValueError(f'duplicate resource identity: {name}')
|
|
resources[m[1]] = (m[2], owner)
|
|
vanilla = read_json(evidence_path(root, 'docs/vanilla-resources.json'))
|
|
for guid in set(resources) & set(vanilla.values()):
|
|
if resources[guid] != VANILLA_OVERRIDES.get(guid):
|
|
raise ValueError('successor resource reuses a vanilla identity')
|
|
for name, data in payload.items():
|
|
suffix = PurePosixPath(name).suffix
|
|
if suffix == '.meta':
|
|
continue
|
|
if suffix == '.wav':
|
|
from welcome_voice import validate_wav
|
|
if not BUNDLED_SPEECH.fullmatch(name) or name + '.meta' not in payload:
|
|
raise ValueError(f'unregistered bundled speech: {name}')
|
|
validate_wav(data)
|
|
continue
|
|
if name.startswith(ATLAS_ROOT) and suffix in {'.fbx', '.xob', '.txo'}:
|
|
stem = str(PurePosixPath(name).with_suffix(''))
|
|
if any(stem + required not in payload for required in ('.xob', '.xob.meta', '.fbx')):
|
|
raise ValueError(f'atlas model lacks source/runtime/metadata: {name}')
|
|
signatures = {'.fbx': b'Kaydara FBX Binary', '.xob': b'FORM', '.txo': b'$object'}
|
|
if not data.startswith(signatures[suffix]) or (suffix == '.xob' and data[8:12] != b'XOB9'):
|
|
raise ValueError(f'invalid atlas model signature: {name}')
|
|
continue
|
|
if suffix in {'.png', '.edds'}:
|
|
if not name.startswith('addons/core/UI/Textures/'):
|
|
raise ValueError(f'image outside authored UI textures: {name}')
|
|
signature = b'\x89PNG\r\n\x1a\n' if suffix == '.png' else b'DDS '
|
|
if not data.startswith(signature):
|
|
raise ValueError(f'invalid UI texture signature: {name}')
|
|
# Workbench attaches PNG import settings to the EDDS runtime resource.
|
|
runtime = str(PurePosixPath(name).with_suffix('.edds'))
|
|
if runtime not in payload or runtime + '.meta' not in payload:
|
|
raise ValueError(f'UI texture lacks imported runtime/metadata: {name}')
|
|
continue
|
|
text = data.decode('utf-8')
|
|
if re.search(r'\b(?:ESCT\w*|SCRTC_\w+|SCRT_\w+|EAI_\w+)\b', text):
|
|
raise ValueError(f'original ecosystem dependency: {name}')
|
|
if suffix not in {'.c', '.layer', '.gproj'} and name + '.meta' not in payload:
|
|
raise ValueError(f'resource lacks successor metadata: {name}')
|
|
owner = name.split('/')[1]
|
|
visible = {owner, *PROJECTS[owner][2]}
|
|
if 'core' in visible: visible.update(ALLOWED)
|
|
for guid, resource in hard_resource_references(name, text):
|
|
if guid in resources:
|
|
path, target = resources[guid]
|
|
if path != resource or target not in visible:
|
|
raise ValueError(f'resource outside project dependency closure: {name}')
|
|
elif vanilla.get(resource) != guid and not resource_allowed(external,visible,guid,resource):
|
|
raise ValueError(f'unresolved or nonvanilla resource: {name}: {{{guid}}}{resource}')
|
|
return projects
|
|
|
|
|
|
def build(root=ROOT, mode='development'):
|
|
root = Path(root).resolve()
|
|
if mode not in {'development', 'release'}:
|
|
raise ValueError('unknown bundle mode')
|
|
if mode == 'release':
|
|
blockers = audit(root)
|
|
if blockers:
|
|
raise ValueError('Not release-qualified:\n - ' + '\n - '.join(blockers))
|
|
before = inventory(root)
|
|
payload = {name: (root / name).read_bytes() for name in before}
|
|
if {name: hashlib.sha256(data).hexdigest() for name, data in payload.items()} != before:
|
|
raise ValueError('addon bytes changed during collection')
|
|
projects = validate(payload, root)
|
|
if mode == 'release':
|
|
blockers = audit(root)
|
|
if blockers:
|
|
raise ValueError('Not release-qualified after collection:\n - ' + '\n - '.join(blockers))
|
|
if inventory(root) != before:
|
|
raise ValueError('addon tree changed during collection')
|
|
payload['BUNDLE-README.md'] = (README + '\nThe combined scenario requires the separately installed authorized addons listed in EXTERNAL-ADDONS.json. Their assets are not bundled.\n').encode('utf-8')
|
|
payload['EXTERNAL-ADDONS.json'] = json.dumps(external_policy(root),indent=2,sort_keys=True).encode('utf-8')
|
|
manifest = {'schema_version': 1, 'kind': 'DEVELOPMENT SOURCE BUNDLE',
|
|
'native_build': False, 'release_package': False, 'projects': projects,
|
|
'files': {name: hashlib.sha256(data).hexdigest() for name, data in sorted(payload.items())}}
|
|
payload['BUNDLE-MANIFEST.json'] = (json.dumps(manifest, indent=2, sort_keys=True) + '\n').encode()
|
|
stream = io.BytesIO()
|
|
with zipfile.ZipFile(stream, 'w', compression=zipfile.ZIP_STORED) as archive:
|
|
for name, data in sorted(payload.items()):
|
|
entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
entry.create_system = 3
|
|
entry.external_attr = (stat.S_IFREG | 0o644) << 16
|
|
archive.writestr(entry, data)
|
|
return stream.getvalue(), manifest
|
|
|
|
|
|
def write_new(output, content, root=ROOT):
|
|
path = Path(output).absolute()
|
|
canonical(path.as_posix().lstrip('/'))
|
|
if path.suffix != '.zip' or path.resolve().is_relative_to(Path(root).resolve()):
|
|
raise ValueError('output must be a new .zip outside the source checkout')
|
|
for part in (path, *path.parents):
|
|
if part.is_symlink():
|
|
raise ValueError('output symlinks are refused')
|
|
if path.exists() or not path.parent.is_dir():
|
|
raise ValueError('output exists or parent directory is missing')
|
|
temporary = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(prefix='.erri-bundle-', dir=path.parent, delete=False) as file:
|
|
temporary = Path(file.name)
|
|
file.write(content)
|
|
file.flush()
|
|
os.fsync(file.fileno())
|
|
# Same-filesystem atomic publication; link refuses an existing destination.
|
|
os.link(temporary, path, follow_symlinks=False)
|
|
finally:
|
|
if temporary is not None:
|
|
temporary.unlink()
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--mode', choices=('development', 'release'), default='development')
|
|
parser.add_argument('--output', type=Path, help='explicit new .zip outside checkout; omitted means read-only preview')
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
content, manifest = build(mode=args.mode)
|
|
if args.output:
|
|
write_new(args.output, content)
|
|
print(json.dumps({'kind': manifest['kind'], 'preview': args.output is None,
|
|
'payload_files': len(manifest['files']), 'archive_bytes': len(content),
|
|
'archive_sha256': hashlib.sha256(content).hexdigest(),
|
|
'projects': manifest['projects']}, indent=2, sort_keys=True))
|
|
return 0
|
|
except (OSError, ValueError, UnicodeError) as error:
|
|
print(f'Bundle refused: {error}')
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|