#!/usr/bin/env python3 """Private loopback-only paired AI probe; never targets a public server.""" from pathlib import Path import argparse import os,sys,json,shutil,subprocess,time,signal,fcntl,hashlib,socket,selectors ROOT=Path(__file__).resolve().parents[3];sys.path.insert(0,str(ROOT/'tools')) from headless_game import assert_prefix_idle,assert_no_game,cleanup_owned_run from dedicated_native import PROBE parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--runtime',required=True,type=Path,help='Private 1.8 Linux dedicated runtime') parser.add_argument('--indexed-addon',required=True,type=Path,help='Workbench-indexed copy of this exact fixture, containing resourceDatabase.rdb') parser.add_argument('--compat',required=True,type=Path,help='Idle private Proton prefix under /tmp') parser.add_argument('--run',required=True,type=Path,help='New results directory under /tmp') parser.add_argument('--server-only',action='store_true') args=parser.parse_args() steam=Path.home()/'.local/share/Steam';compat=args.compat.resolve();runtime=args.runtime.resolve();run=args.run.resolve();source=run/'source' if not compat.is_relative_to('/tmp') or not run.is_relative_to('/tmp') or run==Path('/tmp'): raise ValueError('Use private test paths below /tmp') fixture=ROOT/'tests/native/ai_query_worker' for rel in ('addon.gproj','Scripts/Game/AID/AID_Probe.c'): if (args.indexed_addon/rel).read_bytes() != (fixture/rel).read_bytes(): raise ValueError('Indexed addon does not match fixture: '+rel) if not (args.indexed_addon/'resourceDatabase.rdb').is_file():raise ValueError('Index with Workbench first') run.mkdir() compat.mkdir(exist_ok=True) if not args.server_only: lock=(compat/'.erri-workbench.lock').open('a');fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB);assert_prefix_idle(compat);assert_no_game() source.mkdir() for rel in ('addon.gproj','Scripts/Game/AID/AID_Probe.c','resourceDatabase.rdb'): dest=source/rel;dest.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(args.indexed_addon/rel,dest) addons=run/'addons';addons.mkdir() (addons/'AID_Probe').symlink_to(source,target_is_directory=True) for name in ('server','client','home','tmp'):(run/name).mkdir() proton=steam/'steamapps/common/Proton - Experimental/proton';game=steam/'steamapps/common/Arma Reforger' server=['/tmp/runtime/ArmaReforgerServer','-server','Missions/21_GM_Eden.conf','-addonsDir','/tmp/work/addons','-addons','D712C85E26071018','-profile','/tmp/work/server','-noBackend','-bindIP','127.0.0.1','-bindPort','2001','-maxFPS','30','-logconsole','-noSound','-noSplash','-noThrow','-disableCrashReporter','-aidProbe'] client=['gamescope','--backend','headless','-W','1280','-H','720','-w','1280','-h','720','-r','30','--',str(proton),'run',str(game/'ArmaReforgerSteam.exe'),'-client','127.0.0.1','-addonsDir','Z:'+str(addons),'-addons','D712C85E26071018','-profile','Z:'+str(run/'client'),'-noBackend','-maxFPS','30','-logconsole','-noSound','-noSplash','-noThrow','-disableCrashReporter','-aidProbe'] env=os.environ.copy();env.update(STEAM_COMPAT_DATA_PATH=str(compat),STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam),WINEPREFIX=str(compat/'pfx'),PULSE_SINK='erri_tests_silent',PROTON_LOG='1',PROTON_LOG_DIR=str(run),SteamGameId='1874880',SteamAppId='1874880') (run/'launch.json').write_text(json.dumps({'server':server,'client':client,'transport':'Only host UDP 127.0.0.1:2001 -> private filesystem Unix socket -> isolated namespace UDP 127.0.0.1:2001. Single client, no external server interface.'},indent=2)) (run/'inner.py').write_text('''#!/usr/bin/env python3 """Private loopback-only paired AI probe; never targets a public server.""" from pathlib import Path import argparse import json,subprocess,time,os,signal,socket,selectors root=Path('/tmp/work');spec=json.loads((root/'launch.json').read_text());p=None unix=socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM);unix.bind(str(root/'inner.sock')) udp=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);udp.bind(('127.0.0.1',0));udp.connect(('127.0.0.1',2001)) sel=selectors.DefaultSelector();sel.register(unix,selectors.EVENT_READ);sel.register(udp,selectors.EVENT_READ) try: with (root/'server-stdout.log').open('w') as log: p=subprocess.Popen(spec['server'],cwd='/tmp/runtime',stdout=log,stderr=subprocess.STDOUT,start_new_session=True) while p.poll() is None and not (root/'stop').exists(): for key,_ in sel.select(.2): if key.fileobj is unix: packet,_=unix.recvfrom(65535);udp.send(packet) else: packet=udp.recv(65535);unix.sendto(packet,str(root/'outer.sock')) finally: if p and p.poll() is None: os.killpg(p.pid,signal.SIGTERM) try:p.wait(timeout=8) except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait(timeout=5) (root/'server-result.json').write_text(json.dumps({'exit':p.returncode if p else None})) ''') (run/'network_probe.py').write_text(PROBE) cmd=['bwrap','--unshare-net','--unshare-pid','--unshare-ipc','--unshare-uts','--die-with-parent','--cap-drop','ALL','--clearenv','--ro-bind','/','/','--tmpfs','/tmp','--proc','/proc','--dev','/dev','--ro-bind',str(runtime),'/tmp/runtime','--bind',str(source),str(source),'--bind',str(run),'/tmp/work','--setenv','HOME','/tmp/work/home','--setenv','TMPDIR','/tmp/work/tmp','--setenv','PATH','/usr/bin:/bin','--setenv','LANG','C.UTF-8','--chdir','/tmp/runtime','/usr/bin/python3','/tmp/work/network_probe.py',os.readlink('/proc/self/ns/net'),'/usr/bin/python3','/tmp/work/inner.py'] (run/'command.json').write_text(json.dumps(cmd,indent=2));(run/'source-before.json').write_text(json.dumps({str(p.relative_to(source)):hashlib.sha256(p.read_bytes()).hexdigest() for p in source.rglob('*') if p.is_file() and p.name!='resourceDatabase.rdb'},indent=2)) def logs(key): files=sorted((run/key/'logs').glob('logs_*/console.log'));return files[-1].read_text(errors='replace') if files else '' udp=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);udp.bind(('127.0.0.1',2001)) unix=socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM);unix.bind(str(run/'outer.sock')) sel=selectors.DefaultSelector();sel.register(udp,selectors.EVENT_READ);sel.register(unix,selectors.EVENT_READ) server_process=None;client_process=None;client_log=None;peer=None;forwarded=0;replies=0;started=time.time();client_start=0;reason='deadline' with (run/'supervisor.log').open('w') as log: try: server_process=subprocess.Popen(cmd,stdout=log,stderr=subprocess.STDOUT,start_new_session=True) while time.time()-started<330: s=logs('server') if server_process.poll() is not None:reason='server-exit';break if args.server_only and s.count('AID SAMPLE side=dedicated')>25:reason='server-baseline-complete';break if "AID FAIL" in s or "Can't compile" in s:reason='compile-failed';break if not args.server_only and client_process is None and 'AID READY' in s: client_log=(run/'client-stdout.log').open('w');client_process=subprocess.Popen(client,cwd=game,env=env,stdout=client_log,stderr=subprocess.STDOUT,start_new_session=True);client_start=time.time() for key,_ in sel.select(.2): if key.fileobj is udp: data,address=udp.recvfrom(65535) if peer is None:peer=address if address!=peer:continue try:unix.sendto(data,str(run/'inner.sock'));forwarded+=1 except (ConnectionRefusedError,FileNotFoundError):reason='server-transport-closed';break else: data,_=unix.recvfrom(65535) if peer:udp.sendto(data,peer);replies+=1 if client_process: if client_process.poll() is not None:reason='client-exit';break if 'AID PROBE COMPLETE' in s:reason='probe-complete';break if time.time()-client_start>200:reason='client-deadline';break finally: if client_process: report=cleanup_owned_run(client_process,proton,env,compat);(run/'cleanup.json').write_text(json.dumps(report,indent=2));print(report) if client_log:client_log.close() (run/'stop').touch() if server_process: try:server_process.wait(timeout=12) except subprocess.TimeoutExpired:os.killpg(server_process.pid,signal.SIGKILL);server_process.wait(timeout=5) udp.close();unix.close() result={'reason':reason,'server_reaped':server_process is not None and server_process.poll() is not None,'forwarded':forwarded,'replies':replies,'seconds':round(time.time()-started,2)} (run/'result.json').write_text(json.dumps(result,indent=2));print(result) print(run)