from pathlib import Path import fcntl,hashlib,json,os,signal,subprocess,sys,time ROOT=Path('/home/jakub/devel/escapistsrereintroduced') sys.path.insert(0,str(ROOT/'tools')) import external_addons as ext from dedicated_native import PROBE RUN=Path('/tmp/erri-dedicated-packed-20261002-objective-map') PACKAGE=ROOT/'.build/playtest-2026-10-02-objective-map' EXE=Path('/tmp/erri-takeover-dedicated-runtime/ArmaReforgerServer') GAME=Path('/home/jakub/.local/share/Steam/steamapps/common/Arma Reforger/addons') EXTERNAL=Path('/home/jakub/.local/share/Steam/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/My Games/ArmaReforger/addons') def digest(p): h=hashlib.sha256() with p.open('rb') as f: for chunk in iter(lambda:f.read(4*1024*1024),b''):h.update(chunk) return h.hexdigest() def write(name,value): (RUN/name).write_text(json.dumps(value,indent=2)+'\n') def inventory(root): result={} for p in sorted(root.rglob('*')): if p.is_symlink():raise ValueError('Unexpected link '+str(p)) if p.is_file():result[str(p.relative_to(root))]=digest(p) return result # Read-only preservation and exact artifact/binary gates before native preparation. expected=json.loads((ROOT/'docs/evidence/playtest-refresh-2026-10-02-objective-map/candidate-sha256.json').read_text()) assert inventory(PACKAGE)==expected assert digest(EXE)==json.loads((ROOT/'docs/evidence/terrain-takeover-feature-2026-10-01/dedicated-executable-sha256.json').read_text())[EXE.name] policy=ext.policy(ROOT) cache=json.loads((ROOT/'docs/evidence/package-2026-10-02-objective-map/external-source-cache.json').read_text()) records=ext.installed(EXTERNAL,policy,cache) lock=os.open('/tmp/erri-dedicated-native-diagnostic.lock',os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600) fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB) # Never overlap another native game/Tools job. Ignore this Python supervisor itself. for p in Path('/proc').iterdir(): if not p.name.isdigit():continue try: name=(p/'comm').read_text().strip().lower() assert not any(term in name for term in ('armareforger','workbenchapp','gamescope')), 'Another native session exists: '+p.name except (FileNotFoundError,PermissionError,ProcessLookupError):pass RUN.mkdir(mode=0o700) for d in ('home','profile','tmp'):(RUN/d).mkdir() (RUN/'runner.py').write_bytes(Path(__file__).read_bytes()) write('package-before.json',expected);write('external-before.json',records);write('external-policy.json',policy) write('binary.json',{'path':str(EXE),'sha256':digest(EXE)}) base=['bwrap','--unshare-net','--unshare-pid','--unshare-ipc','--unshare-uts','--die-with-parent','--cap-drop','ALL','--clearenv','--ro-bind','/','/','--tmpfs','/home','--tmpfs','/tmp','--tmpfs','/run','--proc','/proc','--dev','/dev','--dir','/tmp/runtime','--ro-bind',str(EXE),'/tmp/runtime/ArmaReforgerServer','--ro-bind',str(GAME),'/tmp/runtime/addons','--dir','/tmp/addons','--ro-bind',str(PACKAGE/'packages/core'),'/tmp/addons/ERR_Core','--bind',str(RUN),'/tmp/work','--setenv','HOME','/tmp/work/home','--setenv','TMPDIR','/tmp/work/tmp','--setenv','XDG_CONFIG_HOME','/tmp/work/home/.config','--setenv','XDG_DATA_HOME','/tmp/work/home/.local/share','--setenv','XDG_CACHE_HOME','/tmp/work/home/.cache','--setenv','PATH','/usr/bin:/bin','--setenv','LANG','C.UTF-8','--chdir','/tmp/runtime'] for guid,record in sorted(records.items()):base+=['--ro-bind',record['path'],'/tmp/addons/ERRI_'+guid] netns=os.readlink('/proc/self/ns/net') preflight=subprocess.run(base+['/usr/bin/python3','-c',PROBE,netns],capture_output=True,text=True,timeout=15) write('isolation-preflight.json',{'exit':preflight.returncode,'stdout':preflight.stdout,'stderr':preflight.stderr}) assert preflight.returncode==0,'Isolation gate failed' engine=['/tmp/runtime/ArmaReforgerServer','-profile','/tmp/work/profile','-noBackend','-noSound','-noSplash','-noThrow','-disableCrashReporter','-bindIP','127.0.0.1','-bindPort','22991','-maxFPS','30','-logStats','5000','-addonsDir','/tmp/addons','-addons','2528466DE220D91C','-server','{C1E20667DCE324ED}Missions/ERR_Everon.conf'] command=base+['/usr/bin/python3','-c',PROBE,netns]+engine write('command.json',command) child=None;start=time.monotonic();reason='deadline';ready_at=None with (RUN/'stdout.log').open('wb') as log: try: child=subprocess.Popen(command,stdout=log,stderr=subprocess.STDOUT,start_new_session=True) while time.monotonic()-start<110: if child.poll() is not None:reason='process-exit';break logs=sorted((RUN/'profile/logs').glob('logs_*/console.log')) if logs: text=logs[-1].read_text(errors='replace') if 'Reintroduced [mission]: phase 0 -> 1' in text: if ready_at is None:ready_at=time.monotonic() if time.monotonic()-ready_at>=15:reason='mission-started-observed-15s';break if "Can't compile" in text or 'Unable to initialize the game' in text:reason='startup-failed';break time.sleep(1) finally: if child and child.poll() is None: os.killpg(child.pid,signal.SIGTERM) try:child.wait(timeout=5) except subprocess.TimeoutExpired:os.killpg(child.pid,signal.SIGKILL);child.wait(timeout=5) write('result.json',{'reason':reason,'exit':child.returncode if child else None,'supervisor_reaped':bool(child and child.poll() is not None),'elapsed_seconds':round(time.monotonic()-start,2),'real_players':0,'multiplayer_proven':False,'release_qualified':False}) after=inventory(PACKAGE);write('package-after.json',after) external_after=ext.installed(EXTERNAL,policy,cache);write('external-after.json',external_after) write('preservation.json',{'package_unchanged':after==expected,'external_unchanged':external_after==records,'binary_unchanged':digest(EXE)==json.loads((RUN/'binary.json').read_text())['sha256']}) assert after==expected and external_after==records print(RUN) print((RUN/'result.json').read_text())