#!/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 release_check import audit, read_json, evidence_path ROOT = Path(__file__).resolve().parents[1] BASE = '58D0FB3206B6F859' PROJECTS = { 'core': ('ERR_Core', '2528466DE220D91C', ()), 'ai': ('ERR_Ai', '5192138CB9D5B911', ('core',)), 'intel': ('ERR_Intel', 'D31039C7C53E348A', ('core',)), 'map': ('ERR_Map', '9AAEE9041D79809E', ('core',)), 'chat': ('ERR_Chat', 'FA804F411BDDF2C6', ('core', 'ai', 'intel', 'map')), } 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 Rereintroduced This ZIP contains authored source and editable resources for all five successor addon projects. It is NOT a native .pak, a qualified release, or a published mod. No original Escapists or installed vanilla assets are redistributed. Vanilla asset references require a separately installed, compatible Arma Reforger and Workbench. Extract into a NEW development folder, never over an existing mission/project. Configure Workbench's addon search directory to the extracted addons directory. Open addons/core/addon.gproj for the independent Core project, or addons/chat/addon.gproj for the complete companion dependency set. Every project also declares vanilla Arma Reforger (58D0FB3206B6F859): core; ai -> core; intel -> core; map -> core; chat -> core, ai, intel, map. Core scenario: {AD62322D5213C440}Missions/ERR_Everon_Base.conf Companion 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: vanilla Everon, up to 12 players. 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 = {} 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 five owned projects') for name, (identity, guid, deps) in PROJECTS.items(): text = payload.get(f'addons/{name}/addon.gproj', b'').decode('utf-8') match = re.fullmatch(r'\s*GameProject\s*\{\s*ID "([^"\n]+)"\s*GUID "([A-F0-9]{16})"\s*TITLE "([^"\n]+)"\s*Dependencies\s*\{([\s"A-F0-9]*)\}\s*\}\s*', text) expected = {BASE, *(PROJECTS[d][1] for d in deps)} 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 in {'.meta', '.gproj'}: 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'} and name + '.meta' not in payload: raise ValueError(f'resource lacks successor metadata: {name}') owner = name.split('/')[1] visible = {owner, *PROJECTS[owner][2]} 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: 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.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())