Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

80 lines
5.7 KiB
Python

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())