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.
244 lines
12 KiB
Python
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())
|