Files
EscapistsReintroduced/tools/source_bundle.py
T
jakub cab998fb64 Combine Workshop addon and fix prison startup
Merge owned systems into one addon while preserving resource identities. Gate player admission on the replicated prison scene, restore the original framed door, and hold enemy AI until escape. Correct scenario branding and author metadata, include publishing assets, and retain regression and native verification evidence.
2026-09-20 16:42:52 +02:00

244 lines
12 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 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', ())}
DIRECTORIES = {'Scripts', 'Prefabs', 'Configs', 'Missions', 'Worlds', 'UI', 'Materials'}
SUFFIXES = {'.c', '.et', '.conf', '.ent', '.layer', '.layout', '.emat', '.meta', '.gproj'}
EXCLUDED = {'.git', '.build', '.local', '__pycache__', 'profiles', 'profile', 'cache',
'runtime', 'evidence', 'vendor', 'vanilla', 'original', 'node_modules', '.agents', '.codex'}
README = '''# DEVELOPMENT SOURCE BUNDLE — Escapists Reintroduced
Author: Jakub Jim Žáček.
This ZIP contains the single Escapists 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 binaries are included.
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 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'))
if set(resources) & set(vanilla.values()):
raise ValueError('successor resource reuses a vanilla identity')
for name, data in payload.items():
suffix = PurePosixPath(name).suffix
if suffix == '.meta':
continue
text = data.decode('utf-8') # Current reviewed handoff contains text assets only.
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 re.findall(r'\{([A-F0-9]{16})\}([^"\n]+)', 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}')
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())