Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Reforger 1.8 AI ownership probe
|
||||
|
||||
Standalone diagnostic addon. Do not include this folder in Escape's Workshop build.
|
||||
It depends only on vanilla Reforger. It uses vanilla Game Master Everon and creates
|
||||
one US soldier in one empty group. Run with `-aidProbe` on both machines.
|
||||
|
||||
The dedicated process advances through five phases, each at least 20 seconds:
|
||||
|
||||
1. Server-owned baseline after the worker acknowledges both replicated entities.
|
||||
2. Soldier hierarchy transferred with `GiveExt(workerIdentity, true)`.
|
||||
3. Group hierarchy also transferred.
|
||||
4. Worker explicitly attempts `AIControlComponent.ActivateAI()` and group activation.
|
||||
5. Ownership returned to the dedicated process and AI explicitly reactivated.
|
||||
|
||||
Phase 4 is a separate intervention, not a consequence attributed to ownership.
|
||||
A static MP observer and explicit per-connection streaming put the soldier within
|
||||
worker replication coverage. This diagnostic assumes exactly one worker connection.
|
||||
Both processes must have the same mod and game version. LOD 0 is forced on the
|
||||
server agent/group to make an idle one-soldier baseline observable.
|
||||
|
||||
Each side logs replication flags, component/agent presence, activation flags,
|
||||
LOD, native perception simulation count, and actual engine-entered soldier/group
|
||||
behavior decision callbacks. Overrides call the original implementation once.
|
||||
No behavior callback is invoked manually. An owner-directed PlayerController RPC
|
||||
runs a small deterministic script calculation and returns an acknowledgement;
|
||||
this proves worker script execution, not expensive AI migration or CPU savings.
|
||||
|
||||
## Local hidden runner
|
||||
|
||||
First copy this addon's `addon.gproj` and `Scripts` to a disposable directory and
|
||||
open that copy in matching 1.8 Workbench. This creates `resourceDatabase.rdb`;
|
||||
a loose script-only folder without an index was silently omitted by the Linux
|
||||
runtime in the initial attempt. Do not index or change the production mod.
|
||||
Workbench needs vanilla `ArmaReforger.gproj` available in its addon search path.
|
||||
|
||||
The Linux helper requires the repository's existing `tools` helpers, bubblewrap,
|
||||
gamescope, Steam/Proton Experimental, a private idle test prefix, and a private
|
||||
1.8 Linux dedicated runtime. Close the normal game first. Example:
|
||||
|
||||
```sh
|
||||
python tests/native/ai_delegation/run_local.py \
|
||||
--runtime /tmp/erri-takeover-dedicated-runtime \
|
||||
--indexed-addon /tmp/aid-indexed \
|
||||
--compat /tmp/erri-takeover-client-compat \
|
||||
--run /tmp/aid-new-run
|
||||
```
|
||||
|
||||
The run path must not exist. `--server-only` checks baseline without starting a
|
||||
client. The full run refuses an existing game and uses a private hidden, muted
|
||||
display/profile. The dedicated server has a private network namespace; a
|
||||
loopback-only UDP/Unix datagram bridge admits one local worker. No production
|
||||
server, server configuration or mission files are changed. A deadline and owned
|
||||
process cleanup bound the experiment. Results and frozen inputs stay in the run
|
||||
folder; source hashes are saved before launch.
|
||||
|
||||
```sh
|
||||
python tests/native/ai_delegation/summarize.py \
|
||||
/tmp/aid-new-run/server/logs/logs_*/console.log \
|
||||
/tmp/aid-new-run/client/logs/logs_*/console.log
|
||||
```
|
||||
|
||||
The summary discards the first and last two samples of each phase before calculating
|
||||
counter deltas. Missing native perception components use `-1`, not zero. Require
|
||||
positive server baseline deltas and client acknowledgements before interpreting
|
||||
any zero worker counters. This is an execution-locality experiment, not a CPU
|
||||
benchmark. A single idle soldier cannot establish scalability, combat behavior,
|
||||
vehicle behavior or exclusive native subsystem costs.
|
||||
@@ -0,0 +1,187 @@
|
||||
// Standalone diagnostic addon; never include in the Escape Workshop package.
|
||||
class AID_Probe
|
||||
{
|
||||
static IEntity Body;
|
||||
static SCR_AIGroup Group;
|
||||
static int Stage;
|
||||
static int Started;
|
||||
static int Last;
|
||||
static bool Ready;
|
||||
static bool Finished;
|
||||
static bool ObserverInserted;
|
||||
static ref map<IEntity, int> BT = new map<IEntity, int>();
|
||||
static ref map<IEntity, int> Utility = new map<IEntity, int>();
|
||||
static ref map<IEntity, int> GroupBT = new map<IEntity, int>();
|
||||
static void Count(map<IEntity, int> counts, IEntity entity)
|
||||
{
|
||||
if (!entity) return;
|
||||
int count = counts.Get(entity); counts.Set(entity, count + 1);
|
||||
}
|
||||
static RplComponent Rpl(IEntity entity)
|
||||
{
|
||||
if (!entity) return null;
|
||||
return RplComponent.Cast(entity.FindComponent(RplComponent));
|
||||
}
|
||||
static IEntity Resolve(RplId id)
|
||||
{
|
||||
RplComponent rpl = RplComponent.Cast(Replication.FindItem(id));
|
||||
if (rpl) return rpl.GetEntity();
|
||||
return null;
|
||||
}
|
||||
static void Sample(string side, IEntity entity, string kind)
|
||||
{
|
||||
RplComponent rpl = Rpl(entity);
|
||||
if (!rpl) { PrintFormat("AID SAMPLE side=%1 stage=%2 kind=%3 missing=1", side, Stage, kind); return; }
|
||||
string row = string.Format("AID SAMPLE side=%1 stage=%2 kind=%3 role=%4 owner=%5 master=%6", side, Stage, kind, rpl.Role(), rpl.IsOwner(), rpl.IsMaster());
|
||||
row += string.Format(" ownerProxy=%1 remoteProxy=%2 rpl=%3", rpl.IsOwnerProxy(), rpl.IsRemoteProxy(), rpl.Id());
|
||||
AIControlComponent control = AIControlComponent.Cast(entity.FindComponent(AIControlComponent));
|
||||
AIAgent agent;
|
||||
if (control) agent = control.GetControlAIAgent();
|
||||
else agent = AIAgent.Cast(entity);
|
||||
bool controlActive, agentActive;
|
||||
int lod = -1;
|
||||
if (control) controlActive = control.IsAIActivated();
|
||||
if (agent) { agentActive = agent.IsAIActivated(); lod = agent.GetLOD(); }
|
||||
PerceptionComponent perception = PerceptionComponent.Cast(entity.FindComponent(PerceptionComponent));
|
||||
int simulations = -1;
|
||||
if (perception) simulations = perception.GetSimulateCounter();
|
||||
row += string.Format(" control=%1 agent=%2 controlActive=%3 agentActive=%4 lod=%5 perception=%6", control != null, agent != null, controlActive, agentActive, lod, simulations);
|
||||
row += string.Format(" bt=%1 utility=%2 groupBT=%3 pos=%4 wall=%5", BT.Get(entity), Utility.Get(entity), GroupBT.Get(entity), entity.GetOrigin(), System.GetTickCount());
|
||||
Print(row);
|
||||
}
|
||||
static void Spawn()
|
||||
{
|
||||
vector position = "6090 0 7160";
|
||||
position[1] = GetGame().GetWorld().GetSurfaceY(position[0], position[2]) + 0.2;
|
||||
EntitySpawnParams params = new EntitySpawnParams(); params.TransformMode = ETransformMode.WORLD; params.Transform[3] = position;
|
||||
SCR_AIGroup.IgnoreSpawning(true);
|
||||
Group = SCR_AIGroup.Cast(GetGame().SpawnEntityPrefab(Resource.Load("{EACD97CF4A702FAE}Prefabs/Groups/BLUFOR/Group_US_Base.et"), GetGame().GetWorld(), params));
|
||||
SCR_AIGroup.IgnoreSpawning(false);
|
||||
Body = GetGame().SpawnEntityPrefab(Resource.Load("{E398E44759DA1A43}Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_TL.et"), GetGame().GetWorld(), params);
|
||||
if (!Body || !Group) { Print("AID FAIL spawn"); Finished = true; return; }
|
||||
AIControlComponent control = AIControlComponent.Cast(Body.FindComponent(AIControlComponent));
|
||||
AIAgent agent;
|
||||
if (control) agent = control.GetControlAIAgent();
|
||||
if (!agent) { Print("AID FAIL server agent absent"); Finished = true; return; }
|
||||
Group.AddAgent(agent); Group.SetPermanentLOD(0); Group.ActivateAI(); agent.SetPermanentLOD(0); agent.ActivateAI();
|
||||
Rpl(Body).EnableStreaming(true); Rpl(Group).EnableStreaming(true);
|
||||
Stage = 1; Started = System.GetTickCount();
|
||||
PrintFormat("AID READY groupAgents=%1 body=%2 group=%3", Group.GetAgentsCount(), Rpl(Body).Id(), Rpl(Group).Id());
|
||||
}
|
||||
static void Tick()
|
||||
{
|
||||
if (Finished || !System.IsCLIParam("aidProbe") || !GetGame().GetGameMode()) return;
|
||||
int now = System.GetTickCount();
|
||||
if (now - Last < 1000) return;
|
||||
Last = now;
|
||||
if (!Replication.IsServer())
|
||||
{
|
||||
if (Body) { Sample("worker", Body, "body"); Sample("worker", Group, "group"); }
|
||||
return;
|
||||
}
|
||||
if (!Body) { Spawn(); return; }
|
||||
Sample("dedicated", Body, "body"); Sample("dedicated", Group, "group");
|
||||
array<int> players = {}; GetGame().GetPlayerManager().GetPlayers(players);
|
||||
foreach (int player : players)
|
||||
{
|
||||
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(player));
|
||||
if (!controller) continue;
|
||||
RplIdentity identity = controller.GetRplIdentity();
|
||||
if (!identity.IsValid() || identity == RplIdentity.Local()) continue;
|
||||
if (!ObserverInserted)
|
||||
{
|
||||
ObserversSystem observers = ObserversSystem.Cast(GetGame().GetWorld().FindSystem(ObserversSystem));
|
||||
if (observers) observers.InsertObserverMP(identity, Body.GetOrigin()[0], Body.GetOrigin()[2], null);
|
||||
Rpl(Body).EnableStreamingConNode(identity, true); Rpl(Group).EnableStreamingConNode(identity, true);
|
||||
ObserverInserted = true;
|
||||
}
|
||||
controller.AID_Ping(Rpl(Body).Id(), Rpl(Group).Id(), Stage);
|
||||
if (!Ready || now - Started < 20000) return;
|
||||
if (Stage == 1) { Rpl(Body).GiveExt(identity, true); Stage = 2; }
|
||||
else if (Stage == 2) { Rpl(Group).GiveExt(identity, true); Stage = 3; }
|
||||
else if (Stage == 3) { Stage = 4; } // separate explicit worker activation trial
|
||||
else if (Stage == 4)
|
||||
{
|
||||
Rpl(Body).GiveExt(RplIdentity.Local(), true); Rpl(Group).GiveExt(RplIdentity.Local(), true);
|
||||
AIControlComponent control = AIControlComponent.Cast(Body.FindComponent(AIControlComponent));
|
||||
if (control) control.ActivateAI();
|
||||
Group.ActivateAI(); Stage = 5;
|
||||
}
|
||||
else if (Stage == 5) { Print("AID PROBE COMPLETE"); Finished = true; return; }
|
||||
Started = now; PrintFormat("AID TRANSITION stage=%1 wall=%2", Stage, now);
|
||||
return;
|
||||
}
|
||||
}
|
||||
static void Reset()
|
||||
{
|
||||
Body = null; Group = null; Stage = 0; Started = 0; Last = 0; Ready = false; Finished = false; ObserverInserted = false;
|
||||
BT.Clear(); Utility.Clear(); GroupBT.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_AIDecideBehavior
|
||||
{
|
||||
override ENodeResult EOnTaskSimulate(AIAgent owner, float dt)
|
||||
{
|
||||
AID_Probe.Count(AID_Probe.BT, owner.GetControlledEntity());
|
||||
return super.EOnTaskSimulate(owner, dt);
|
||||
}
|
||||
}
|
||||
modded class SCR_AIDecideActivity
|
||||
{
|
||||
override ENodeResult EOnTaskSimulate(AIAgent owner, float dt)
|
||||
{
|
||||
AID_Probe.Count(AID_Probe.GroupBT, owner);
|
||||
return super.EOnTaskSimulate(owner, dt);
|
||||
}
|
||||
}
|
||||
modded class SCR_AIUtilityComponent
|
||||
{
|
||||
override SCR_AIBehaviorBase EvaluateBehavior(BaseTarget unknownTarget)
|
||||
{
|
||||
AID_Probe.Count(AID_Probe.Utility, m_OwnerEntity);
|
||||
return super.EvaluateBehavior(unknownTarget);
|
||||
}
|
||||
}
|
||||
modded class SCR_PlayerController
|
||||
{
|
||||
void AID_Ping(RplId body, RplId group, int stage) { Rpc(AID_RpcWorker, body, group, stage); }
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
|
||||
void AID_RpcWorker(RplId body, RplId group, int stage)
|
||||
{
|
||||
if (Replication.IsServer()) return;
|
||||
AID_Probe.Body = AID_Probe.Resolve(body); AID_Probe.Group = SCR_AIGroup.Cast(AID_Probe.Resolve(group));
|
||||
if (stage == 4 && AID_Probe.Stage != 4 && AID_Probe.Body)
|
||||
{
|
||||
AIControlComponent control = AIControlComponent.Cast(AID_Probe.Body.FindComponent(AIControlComponent));
|
||||
bool activated;
|
||||
if (control) activated = control.ActivateAI();
|
||||
if (AID_Probe.Group) { AID_Probe.Group.SetPermanentLOD(0); AID_Probe.Group.ActivateAI(); }
|
||||
PrintFormat("AID EXPLICIT WORKER ACTIVATE control=%1 result=%2", control != null, activated);
|
||||
}
|
||||
AID_Probe.Stage = stage;
|
||||
// Small deterministic script job proves actual worker-side computation/RPC.
|
||||
int result; for (int i = 1; i <= 100; i++) result += i;
|
||||
PrintFormat("AID RPC worker stage=%1 body=%2 group=%3 computed=%4", stage, AID_Probe.Body != null, AID_Probe.Group != null, result);
|
||||
Rpc(AID_RpcAck, AID_Probe.Body != null && AID_Probe.Group != null, stage, result);
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
|
||||
void AID_RpcAck(bool ready, int stage, int result)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
if (ready && result == 5050 && !AID_Probe.Ready) { AID_Probe.Ready = true; AID_Probe.Started = System.GetTickCount(); }
|
||||
PrintFormat("AID RPC dedicated stage=%1 resolved=%2 result=%3", stage, ready, result);
|
||||
}
|
||||
}
|
||||
modded class ArmaReforgerScripted
|
||||
{
|
||||
override void OnUpdate(BaseWorld world, float timeslice)
|
||||
{
|
||||
super.OnUpdate(world, timeslice);
|
||||
if (world) AID_Probe.Tick();
|
||||
}
|
||||
override protected void OnBeforeWorldCleanup()
|
||||
{
|
||||
AID_Probe.Reset(); super.OnBeforeWorldCleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
GameProject {
|
||||
ID "AID_OwnershipProbe"
|
||||
GUID "D712C85E26071018"
|
||||
TITLE "AI ownership diagnostic (isolated)"
|
||||
Dependencies { "58D0FB3206B6F859" }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/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_delegation'
|
||||
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)
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize measured callback deltas, not estimated CPU savings."""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def summarize(paths):
|
||||
groups = {}
|
||||
for path in paths:
|
||||
for line in path.read_text(errors='replace').splitlines():
|
||||
if 'AID SAMPLE ' not in line or 'missing=1' in line:
|
||||
continue
|
||||
fields = dict(re.findall(r'(\w+)=([^\s\']+)', line))
|
||||
key = (fields['side'], int(fields['stage']), fields['kind'])
|
||||
groups.setdefault(key, []).append(fields)
|
||||
result = []
|
||||
for (side, stage, kind), samples in sorted(groups.items()):
|
||||
# Trim both edges: ownership and the phase-label RPC can arrive on different frames.
|
||||
stable = samples[2:-2]
|
||||
if len(stable) < 2:
|
||||
continue
|
||||
first, last = stable[0], stable[-1]
|
||||
row = dict(side=side, stage=stage, kind=kind, samples=len(stable),
|
||||
wall_ms=int(last['wall'])-int(first['wall']))
|
||||
for field in ('role','owner','master','ownerProxy','remoteProxy','control','agent','controlActive','agentActive','lod'):
|
||||
row[field] = sorted({int(s[field]) for s in stable})
|
||||
for field in ('perception','bt','utility','groupBT'):
|
||||
values = [int(s[field]) for s in stable]
|
||||
row[field] = dict(first=values[0], last=values[-1],
|
||||
delta=values[-1]-values[0] if min(values)>=0 else None,
|
||||
monotonic=all(a<=b for a,b in zip(values,values[1:])))
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('logs', nargs='+', type=Path)
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(summarize(args.logs), indent=2))
|
||||
@@ -0,0 +1,22 @@
|
||||
# Reforger 1.8 workaround probe: ai_query_worker
|
||||
|
||||
Runs batches of 256 native world-collision traces through the worker RPC. Phase 1 compares server/worker results; phase 2 performs the batches only on the worker and applies a returned candidate as an authoritative waypoint; phase 3 deliberately silences the worker to exercise timeout fallback.
|
||||
|
||||
This is an isolated experiment, **not a production Escape feature**. It deliberately
|
||||
reuses the ownership probe addon GUID and script layout for its resource index.
|
||||
Load exactly one probe variant at a time, with no Escape addon.
|
||||
|
||||
Follow [the original probe's hidden-run prerequisites](../ai_delegation/README.md).
|
||||
Index a disposable copy of this variant with matching Workbench; then run this
|
||||
folder's `run_local.py` with `--runtime`, `--indexed-addon`, `--compat`, and `--run`.
|
||||
The indexed script must match this fixture exactly. Keep tests hidden and leave the
|
||||
normal game closed; the runner refuses an existing game. It uses an isolated
|
||||
loopback dedicated server and a private client profile. No published mission changes.
|
||||
|
||||
The logs contain `AID` markers. `AID PROBE COMPLETE` means the observation phases
|
||||
finished, not that native AI offloading succeeded. Preserve native counters and
|
||||
actual returned flags when interpreting a result. All runtime scripts must identify
|
||||
as 1.8; these fixtures were tested on 1.8.0.13.
|
||||
|
||||
See [the workaround report](../../../docs/ai-delegation-workarounds-1.8.md) for measured
|
||||
results and limitations.
|
||||
@@ -0,0 +1,260 @@
|
||||
// Standalone diagnostic addon; never include in the Escape Workshop package.
|
||||
class AID_Probe
|
||||
{
|
||||
static int Job;
|
||||
static int PendingJob;
|
||||
static int SentAt;
|
||||
static int QueryCount;
|
||||
static int Accepted;
|
||||
static int LastAcceptedAt;
|
||||
static bool Ordered;
|
||||
static bool DroppedLogged;
|
||||
static vector JobOrigin;
|
||||
static ref array<float> Reference;
|
||||
static IEntity Body;
|
||||
static SCR_AIGroup Group;
|
||||
static int Stage;
|
||||
static int Started;
|
||||
static int Last;
|
||||
static bool Ready;
|
||||
static bool Finished;
|
||||
static bool ObserverInserted;
|
||||
static ref map<IEntity, int> BT = new map<IEntity, int>();
|
||||
static ref map<IEntity, int> Utility = new map<IEntity, int>();
|
||||
static ref map<IEntity, int> GroupBT = new map<IEntity, int>();
|
||||
static void Count(map<IEntity, int> counts, IEntity entity)
|
||||
{
|
||||
if (!entity) return;
|
||||
int count = counts.Get(entity); counts.Set(entity, count + 1);
|
||||
}
|
||||
static RplComponent Rpl(IEntity entity)
|
||||
{
|
||||
if (!entity) return null;
|
||||
return RplComponent.Cast(entity.FindComponent(RplComponent));
|
||||
}
|
||||
static IEntity Resolve(RplId id)
|
||||
{
|
||||
RplComponent rpl = RplComponent.Cast(Replication.FindItem(id));
|
||||
if (rpl) return rpl.GetEntity();
|
||||
return null;
|
||||
}
|
||||
static void Sample(string side, IEntity entity, string kind)
|
||||
{
|
||||
RplComponent rpl = Rpl(entity);
|
||||
if (!rpl) { PrintFormat("AID SAMPLE side=%1 stage=%2 kind=%3 missing=1", side, Stage, kind); return; }
|
||||
string row = string.Format("AID SAMPLE side=%1 stage=%2 kind=%3 role=%4 owner=%5 master=%6", side, Stage, kind, rpl.Role(), rpl.IsOwner(), rpl.IsMaster());
|
||||
row += string.Format(" ownerProxy=%1 remoteProxy=%2 rpl=%3", rpl.IsOwnerProxy(), rpl.IsRemoteProxy(), rpl.Id());
|
||||
AIControlComponent control = AIControlComponent.Cast(entity.FindComponent(AIControlComponent));
|
||||
AIAgent agent;
|
||||
if (control) agent = control.GetControlAIAgent();
|
||||
else agent = AIAgent.Cast(entity);
|
||||
bool controlActive, agentActive;
|
||||
int lod = -1;
|
||||
if (control) controlActive = control.IsAIActivated();
|
||||
if (agent) { agentActive = agent.IsAIActivated(); lod = agent.GetLOD(); }
|
||||
PerceptionComponent perception = PerceptionComponent.Cast(entity.FindComponent(PerceptionComponent));
|
||||
int simulations = -1;
|
||||
if (perception) simulations = perception.GetSimulateCounter();
|
||||
row += string.Format(" control=%1 agent=%2 controlActive=%3 agentActive=%4 lod=%5 perception=%6", control != null, agent != null, controlActive, agentActive, lod, simulations);
|
||||
row += string.Format(" bt=%1 utility=%2 groupBT=%3 pos=%4 wall=%5", BT.Get(entity), Utility.Get(entity), GroupBT.Get(entity), entity.GetOrigin(), System.GetTickCount());
|
||||
Print(row);
|
||||
}
|
||||
static void Spawn()
|
||||
{
|
||||
vector position = "6090 0 7160";
|
||||
position[1] = GetGame().GetWorld().GetSurfaceY(position[0], position[2]) + 0.2;
|
||||
EntitySpawnParams params = new EntitySpawnParams(); params.TransformMode = ETransformMode.WORLD; params.Transform[3] = position;
|
||||
SCR_AIGroup.IgnoreSpawning(true);
|
||||
Group = SCR_AIGroup.Cast(GetGame().SpawnEntityPrefab(Resource.Load("{EACD97CF4A702FAE}Prefabs/Groups/BLUFOR/Group_US_Base.et"), GetGame().GetWorld(), params));
|
||||
SCR_AIGroup.IgnoreSpawning(false);
|
||||
Body = GetGame().SpawnEntityPrefab(Resource.Load("{E398E44759DA1A43}Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_TL.et"), GetGame().GetWorld(), params);
|
||||
if (!Body || !Group) { Print("AID FAIL spawn"); Finished = true; return; }
|
||||
AIControlComponent control = AIControlComponent.Cast(Body.FindComponent(AIControlComponent));
|
||||
AIAgent agent;
|
||||
if (control) agent = control.GetControlAIAgent();
|
||||
if (!agent) { Print("AID FAIL server agent absent"); Finished = true; return; }
|
||||
Group.AddAgent(agent); Group.SetPermanentLOD(0); Group.ActivateAI(); agent.SetPermanentLOD(0); agent.ActivateAI();
|
||||
Rpl(Body).EnableStreaming(true); Rpl(Group).EnableStreaming(true);
|
||||
Stage = 1; Started = System.GetTickCount();
|
||||
PrintFormat("AID READY groupAgents=%1 body=%2 group=%3", Group.GetAgentsCount(), Rpl(Body).Id(), Rpl(Group).Id());
|
||||
}
|
||||
static void Tick()
|
||||
{
|
||||
if (Finished || !System.IsCLIParam("aidProbe") || !GetGame().GetGameMode()) return;
|
||||
int now = System.GetTickCount();
|
||||
if (now - Last < 1000) return;
|
||||
Last = now;
|
||||
if (!Replication.IsServer())
|
||||
{
|
||||
if (Body) { Sample("worker", Body, "body"); Sample("worker", Group, "group"); }
|
||||
return;
|
||||
}
|
||||
if (!Body) { Spawn(); return; }
|
||||
Sample("dedicated", Body, "body"); Sample("dedicated", Group, "group");
|
||||
if (Stage == 3 && Ready && now - LastAcceptedAt > 2500)
|
||||
{
|
||||
array<float> fallback = CastBatch(Body.GetOrigin());
|
||||
PrintFormat("AID QUERY FALLBACK stage=%1 casts=%2 accepted=%3", Stage, QueryCount, Accepted);
|
||||
}
|
||||
array<int> players = {}; GetGame().GetPlayerManager().GetPlayers(players);
|
||||
foreach (int player : players)
|
||||
{
|
||||
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(player));
|
||||
if (!controller) continue;
|
||||
RplIdentity identity = controller.GetRplIdentity();
|
||||
if (!identity.IsValid() || identity == RplIdentity.Local()) continue;
|
||||
if (!ObserverInserted)
|
||||
{
|
||||
ObserversSystem observers = ObserversSystem.Cast(GetGame().GetWorld().FindSystem(ObserversSystem));
|
||||
if (observers) observers.InsertObserverMP(identity, Body.GetOrigin()[0], Body.GetOrigin()[2], null);
|
||||
Rpl(Body).EnableStreamingConNode(identity, true); Rpl(Group).EnableStreamingConNode(identity, true);
|
||||
ObserverInserted = true;
|
||||
}
|
||||
controller.AID_Ping(Rpl(Body).Id(), Rpl(Group).Id(), Stage);
|
||||
if (Ready && (!PendingJob || now - SentAt > 2500))
|
||||
{
|
||||
Job++; PendingJob = Job; SentAt = now; JobOrigin = Body.GetOrigin();
|
||||
Reference = null;
|
||||
if (Stage == 1) Reference = CastBatch(JobOrigin);
|
||||
controller.AID_Query(Job, Stage, JobOrigin);
|
||||
PrintFormat("AID QUERY SENT stage=%1 job=%2 localCasts=%3", Stage, Job, QueryCount);
|
||||
}
|
||||
if (!Ready || now - Started < 20000) return;
|
||||
if (Stage == 1) Stage = 2;
|
||||
else if (Stage == 2) Stage = 3;
|
||||
else if (Stage == 3) { Print("AID PROBE COMPLETE"); Finished = true; return; }
|
||||
PendingJob = 0;
|
||||
Started = now; PrintFormat("AID TRANSITION stage=%1 wall=%2", Stage, now);
|
||||
return;
|
||||
}
|
||||
}
|
||||
static vector Endpoint(vector origin, int index)
|
||||
{
|
||||
if (index == 0) return origin - "0 20 0";
|
||||
if (index == 1) return origin + "0 40 0";
|
||||
float angle = (index - 2) * Math.PI2 / 254;
|
||||
return origin + Vector(Math.Cos(angle) * 40, 0.5, Math.Sin(angle) * 40);
|
||||
}
|
||||
static array<float> CastBatch(vector origin)
|
||||
{
|
||||
array<float> results = {};
|
||||
TraceParam trace = new TraceParam(); trace.Flags = TraceFlags.WORLD; trace.Exclude = Body;
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
trace.Start = origin + "0 1.6 0"; trace.End = Endpoint(origin, i);
|
||||
results.Insert(GetGame().GetWorld().TraceMove(trace, null)); QueryCount++;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
static void Accept(int job, int phase, array<float> results)
|
||||
{
|
||||
int now = System.GetTickCount();
|
||||
if (Finished || job != PendingJob || phase != Stage || now - SentAt > 2500 || !results || results.Count() != 256) return;
|
||||
foreach (float value : results) { if (value != value || value < 0 || value > 1) return; }
|
||||
// Down ray must hit terrain; sky ray must be clear. Refuse unready collision data.
|
||||
if (results[0] >= 1 || results[1] < 0.99) { Print("AID QUERY REJECT scene-not-ready"); PendingJob = 0; return; }
|
||||
int mismatches;
|
||||
if (Reference) for (int k = 0; k < 256; k++) { if (Math.AbsFloat(Reference[k] - results[k]) > 0.005) mismatches++; }
|
||||
Accepted++; PendingJob = 0; LastAcceptedAt = now;
|
||||
PrintFormat("AID QUERY ACCEPT stage=%1 job=%2 rttMs=%3 mismatches=%4 serverCasts=%5 accepted=%6", Stage, job, now - SentAt, mismatches, QueryCount, Accepted);
|
||||
if (Stage == 2 && !Ordered)
|
||||
{
|
||||
int chosen = -1;
|
||||
for (int j = 2; j < 256; j++) { if (results[j] >= 0.99) { chosen = j; break; } }
|
||||
if (chosen >= 0)
|
||||
{
|
||||
vector destination = Endpoint(JobOrigin, chosen);
|
||||
destination[1] = GetGame().GetWorld().GetSurfaceY(destination[0],destination[2]);
|
||||
EntitySpawnParams params = new EntitySpawnParams(); params.TransformMode = ETransformMode.WORLD; params.Transform[3] = destination;
|
||||
AIWaypoint waypoint = AIWaypoint.Cast(GetGame().SpawnEntityPrefab(Resource.Load("{750A8D1695BD6998}Prefabs/AI/Waypoints/AIWaypoint_Move.et"), GetGame().GetWorld(), params));
|
||||
if (waypoint) { Group.AddWaypoint(waypoint); Ordered = true; PrintFormat("AID QUERY APPLIED destination=%1 serverRole=%2", destination, Rpl(Body).Role()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
static void Reset()
|
||||
{
|
||||
Body = null; Group = null; Stage = 0; Started = 0; Last = 0; Ready = false; Finished = false; ObserverInserted = false;
|
||||
Job = 0; PendingJob = 0; QueryCount = 0; Accepted = 0; Ordered = false; DroppedLogged = false; Reference = null; LastAcceptedAt = 0;
|
||||
BT.Clear(); Utility.Clear(); GroupBT.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_AIDecideBehavior
|
||||
{
|
||||
override ENodeResult EOnTaskSimulate(AIAgent owner, float dt)
|
||||
{
|
||||
AID_Probe.Count(AID_Probe.BT, owner.GetControlledEntity());
|
||||
return super.EOnTaskSimulate(owner, dt);
|
||||
}
|
||||
}
|
||||
modded class SCR_AIDecideActivity
|
||||
{
|
||||
override ENodeResult EOnTaskSimulate(AIAgent owner, float dt)
|
||||
{
|
||||
AID_Probe.Count(AID_Probe.GroupBT, owner);
|
||||
return super.EOnTaskSimulate(owner, dt);
|
||||
}
|
||||
}
|
||||
modded class SCR_AIUtilityComponent
|
||||
{
|
||||
override SCR_AIBehaviorBase EvaluateBehavior(BaseTarget unknownTarget)
|
||||
{
|
||||
AID_Probe.Count(AID_Probe.Utility, m_OwnerEntity);
|
||||
return super.EvaluateBehavior(unknownTarget);
|
||||
}
|
||||
}
|
||||
modded class SCR_PlayerController
|
||||
{
|
||||
void AID_Query(int job, int phase, vector origin) { Rpc(AID_RpcQuery, job, phase, origin); }
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
|
||||
void AID_RpcQuery(int job, int phase, vector origin)
|
||||
{
|
||||
if (Replication.IsServer()) return;
|
||||
if (phase == 3)
|
||||
{
|
||||
if (!AID_Probe.DroppedLogged) { Print("AID QUERY WORKER deliberately silent for failure test"); AID_Probe.DroppedLogged = true; }
|
||||
return;
|
||||
}
|
||||
int start = System.GetTickCount();
|
||||
array<float> results = AID_Probe.CastBatch(origin);
|
||||
PrintFormat("AID QUERY WORKER stage=%1 job=%2 casts=%3 wallMs=%4 ground=%5 sky=%6", phase, job, AID_Probe.QueryCount, System.GetTickCount() - start, results[0], results[1]);
|
||||
Rpc(AID_RpcResult, job, phase, results);
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
|
||||
void AID_RpcResult(int job, int phase, array<float> results)
|
||||
{
|
||||
if (Replication.IsServer()) AID_Probe.Accept(job, phase, results);
|
||||
}
|
||||
|
||||
void AID_Ping(RplId body, RplId group, int stage) { Rpc(AID_RpcWorker, body, group, stage); }
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
|
||||
void AID_RpcWorker(RplId body, RplId group, int stage)
|
||||
{
|
||||
if (Replication.IsServer()) return;
|
||||
AID_Probe.Body = AID_Probe.Resolve(body); AID_Probe.Group = SCR_AIGroup.Cast(AID_Probe.Resolve(group));
|
||||
AID_Probe.Stage = stage;
|
||||
// Small deterministic script job proves actual worker-side computation/RPC.
|
||||
int result; for (int i = 1; i <= 100; i++) result += i;
|
||||
PrintFormat("AID RPC worker stage=%1 body=%2 group=%3 computed=%4", stage, AID_Probe.Body != null, AID_Probe.Group != null, result);
|
||||
Rpc(AID_RpcAck, AID_Probe.Body != null && AID_Probe.Group != null, stage, result);
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
|
||||
void AID_RpcAck(bool ready, int stage, int result)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
if (ready && result == 5050 && !AID_Probe.Ready) { AID_Probe.Ready = true; AID_Probe.Started = System.GetTickCount(); }
|
||||
PrintFormat("AID RPC dedicated stage=%1 resolved=%2 result=%3", stage, ready, result);
|
||||
}
|
||||
}
|
||||
modded class ArmaReforgerScripted
|
||||
{
|
||||
override void OnUpdate(BaseWorld world, float timeslice)
|
||||
{
|
||||
super.OnUpdate(world, timeslice);
|
||||
if (world) AID_Probe.Tick();
|
||||
}
|
||||
override protected void OnBeforeWorldCleanup()
|
||||
{
|
||||
AID_Probe.Reset(); super.OnBeforeWorldCleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
GameProject {
|
||||
ID "AID_OwnershipProbe"
|
||||
GUID "D712C85E26071018"
|
||||
TITLE "AI ownership diagnostic (isolated)"
|
||||
Dependencies { "58D0FB3206B6F859" }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Reforger 1.8 workaround probe: ai_worker_local
|
||||
|
||||
Creates a worker-local authoritative soldier and group, attempts activation, adds a move waypoint and records real AI callbacks, perception counters and navmesh access.
|
||||
|
||||
This is an isolated experiment, **not a production Escape feature**. It deliberately
|
||||
reuses the ownership probe addon GUID and script layout for its resource index.
|
||||
Load exactly one probe variant at a time, with no Escape addon.
|
||||
|
||||
Follow [the original probe's hidden-run prerequisites](../ai_delegation/README.md).
|
||||
Index a disposable copy of this variant with matching Workbench; then run this
|
||||
folder's `run_local.py` with `--runtime`, `--indexed-addon`, `--compat`, and `--run`.
|
||||
The indexed script must match this fixture exactly. Keep tests hidden and leave the
|
||||
normal game closed; the runner refuses an existing game. It uses an isolated
|
||||
loopback dedicated server and a private client profile. No published mission changes.
|
||||
|
||||
The logs contain `AID` markers. `AID PROBE COMPLETE` means the observation phases
|
||||
finished, not that native AI offloading succeeded. Preserve native counters and
|
||||
actual returned flags when interpreting a result. All runtime scripts must identify
|
||||
as 1.8; these fixtures were tested on 1.8.0.13.
|
||||
|
||||
See [the workaround report](../../../docs/ai-delegation-workarounds-1.8.md) for measured
|
||||
results and limitations.
|
||||
@@ -0,0 +1,216 @@
|
||||
// Standalone diagnostic addon; never include in the Escape Workshop package.
|
||||
class AID_Probe
|
||||
{
|
||||
static IEntity Shadow;
|
||||
static SCR_AIGroup ShadowGroup;
|
||||
static bool ShadowAttempted;
|
||||
static IEntity Body;
|
||||
static SCR_AIGroup Group;
|
||||
static int Stage;
|
||||
static int Started;
|
||||
static int Last;
|
||||
static bool Ready;
|
||||
static bool Finished;
|
||||
static bool ObserverInserted;
|
||||
static ref map<IEntity, int> BT = new map<IEntity, int>();
|
||||
static ref map<IEntity, int> Utility = new map<IEntity, int>();
|
||||
static ref map<IEntity, int> GroupBT = new map<IEntity, int>();
|
||||
static void Count(map<IEntity, int> counts, IEntity entity)
|
||||
{
|
||||
if (!entity) return;
|
||||
int count = counts.Get(entity); counts.Set(entity, count + 1);
|
||||
}
|
||||
static RplComponent Rpl(IEntity entity)
|
||||
{
|
||||
if (!entity) return null;
|
||||
return RplComponent.Cast(entity.FindComponent(RplComponent));
|
||||
}
|
||||
static IEntity Resolve(RplId id)
|
||||
{
|
||||
RplComponent rpl = RplComponent.Cast(Replication.FindItem(id));
|
||||
if (rpl) return rpl.GetEntity();
|
||||
return null;
|
||||
}
|
||||
static void Sample(string side, IEntity entity, string kind)
|
||||
{
|
||||
RplComponent rpl = Rpl(entity);
|
||||
if (!rpl) { PrintFormat("AID SAMPLE side=%1 stage=%2 kind=%3 missing=1", side, Stage, kind); return; }
|
||||
string row = string.Format("AID SAMPLE side=%1 stage=%2 kind=%3 role=%4 owner=%5 master=%6", side, Stage, kind, rpl.Role(), rpl.IsOwner(), rpl.IsMaster());
|
||||
row += string.Format(" ownerProxy=%1 remoteProxy=%2 rpl=%3", rpl.IsOwnerProxy(), rpl.IsRemoteProxy(), rpl.Id());
|
||||
AIControlComponent control = AIControlComponent.Cast(entity.FindComponent(AIControlComponent));
|
||||
AIAgent agent;
|
||||
if (control) agent = control.GetControlAIAgent();
|
||||
else agent = AIAgent.Cast(entity);
|
||||
bool controlActive, agentActive;
|
||||
int lod = -1;
|
||||
if (control) controlActive = control.IsAIActivated();
|
||||
if (agent) { agentActive = agent.IsAIActivated(); lod = agent.GetLOD(); }
|
||||
PerceptionComponent perception = PerceptionComponent.Cast(entity.FindComponent(PerceptionComponent));
|
||||
int simulations = -1;
|
||||
if (perception) simulations = perception.GetSimulateCounter();
|
||||
row += string.Format(" control=%1 agent=%2 controlActive=%3 agentActive=%4 lod=%5 perception=%6", control != null, agent != null, controlActive, agentActive, lod, simulations);
|
||||
row += string.Format(" bt=%1 utility=%2 groupBT=%3 pos=%4 wall=%5", BT.Get(entity), Utility.Get(entity), GroupBT.Get(entity), entity.GetOrigin(), System.GetTickCount());
|
||||
Print(row);
|
||||
}
|
||||
static void Spawn()
|
||||
{
|
||||
vector position = "6090 0 7160";
|
||||
position[1] = GetGame().GetWorld().GetSurfaceY(position[0], position[2]) + 0.2;
|
||||
EntitySpawnParams params = new EntitySpawnParams(); params.TransformMode = ETransformMode.WORLD; params.Transform[3] = position;
|
||||
SCR_AIGroup.IgnoreSpawning(true);
|
||||
Group = SCR_AIGroup.Cast(GetGame().SpawnEntityPrefab(Resource.Load("{EACD97CF4A702FAE}Prefabs/Groups/BLUFOR/Group_US_Base.et"), GetGame().GetWorld(), params));
|
||||
SCR_AIGroup.IgnoreSpawning(false);
|
||||
Body = GetGame().SpawnEntityPrefab(Resource.Load("{E398E44759DA1A43}Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_TL.et"), GetGame().GetWorld(), params);
|
||||
if (!Body || !Group) { Print("AID FAIL spawn"); Finished = true; return; }
|
||||
AIControlComponent control = AIControlComponent.Cast(Body.FindComponent(AIControlComponent));
|
||||
AIAgent agent;
|
||||
if (control) agent = control.GetControlAIAgent();
|
||||
if (!agent) { Print("AID FAIL server agent absent"); Finished = true; return; }
|
||||
Group.AddAgent(agent); Group.SetPermanentLOD(0); Group.ActivateAI(); agent.SetPermanentLOD(0); agent.ActivateAI();
|
||||
Rpl(Body).EnableStreaming(true); Rpl(Group).EnableStreaming(true);
|
||||
Stage = 1; Started = System.GetTickCount();
|
||||
PrintFormat("AID READY groupAgents=%1 body=%2 group=%3", Group.GetAgentsCount(), Rpl(Body).Id(), Rpl(Group).Id());
|
||||
}
|
||||
static void Tick()
|
||||
{
|
||||
if (Finished || !System.IsCLIParam("aidProbe") || !GetGame().GetGameMode()) return;
|
||||
int now = System.GetTickCount();
|
||||
if (now - Last < 1000) return;
|
||||
Last = now;
|
||||
if (!Replication.IsServer())
|
||||
{
|
||||
if (Body)
|
||||
{
|
||||
if (!ShadowAttempted) SpawnShadow();
|
||||
Sample("worker", Body, "body"); Sample("worker", Group, "group");
|
||||
Sample("worker", Shadow, "shadow"); Sample("worker", ShadowGroup, "shadowGroup");
|
||||
SampleNavigation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!Body) { Spawn(); return; }
|
||||
Sample("dedicated", Body, "body"); Sample("dedicated", Group, "group");
|
||||
array<int> players = {}; GetGame().GetPlayerManager().GetPlayers(players);
|
||||
foreach (int player : players)
|
||||
{
|
||||
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(player));
|
||||
if (!controller) continue;
|
||||
RplIdentity identity = controller.GetRplIdentity();
|
||||
if (!identity.IsValid() || identity == RplIdentity.Local()) continue;
|
||||
if (!ObserverInserted)
|
||||
{
|
||||
ObserversSystem observers = ObserversSystem.Cast(GetGame().GetWorld().FindSystem(ObserversSystem));
|
||||
if (observers) observers.InsertObserverMP(identity, Body.GetOrigin()[0], Body.GetOrigin()[2], null);
|
||||
Rpl(Body).EnableStreamingConNode(identity, true); Rpl(Group).EnableStreamingConNode(identity, true);
|
||||
ObserverInserted = true;
|
||||
}
|
||||
controller.AID_Ping(Rpl(Body).Id(), Rpl(Group).Id(), Stage);
|
||||
if (!Ready || now - Started < 20000) return;
|
||||
if (Stage == 1) Stage = 2;
|
||||
else if (Stage == 2) Stage = 3;
|
||||
else if (Stage == 3) { Print("AID PROBE COMPLETE"); Finished = true; return; }
|
||||
Started = now; PrintFormat("AID TRANSITION stage=%1 wall=%2", Stage, now);
|
||||
return;
|
||||
}
|
||||
}
|
||||
static void SpawnShadow()
|
||||
{
|
||||
ShadowAttempted = true;
|
||||
vector position = Body.GetOrigin() + "5 0 0";
|
||||
position[1] = GetGame().GetWorld().GetSurfaceY(position[0],position[2]) + 0.2;
|
||||
EntitySpawnParams params = new EntitySpawnParams(); params.TransformMode = ETransformMode.WORLD; params.Transform[3] = position;
|
||||
SCR_AIGroup.IgnoreSpawning(true);
|
||||
ShadowGroup = SCR_AIGroup.Cast(GetGame().SpawnEntityPrefabLocal(Resource.Load("{EACD97CF4A702FAE}Prefabs/Groups/BLUFOR/Group_US_Base.et"), GetGame().GetWorld(), params));
|
||||
SCR_AIGroup.IgnoreSpawning(false);
|
||||
Shadow = GetGame().SpawnEntityPrefabLocal(Resource.Load("{E398E44759DA1A43}Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_TL.et"), GetGame().GetWorld(), params);
|
||||
AIControlComponent control;
|
||||
if (Shadow) control = AIControlComponent.Cast(Shadow.FindComponent(AIControlComponent));
|
||||
AIAgent agent;
|
||||
if (control) { control.ActivateAI(); agent = control.GetControlAIAgent(); }
|
||||
if (agent && ShadowGroup) { ShadowGroup.AddAgent(agent); agent.SetPermanentLOD(0); agent.ActivateAI(); ShadowGroup.SetPermanentLOD(0); ShadowGroup.ActivateAI(); }
|
||||
PrintFormat("AID LOCAL SPAWN body=%1 group=%2 control=%3 agent=%4 aiWorld=%5", Shadow != null, ShadowGroup != null, control != null, agent != null, GetGame().GetAIWorld() != null);
|
||||
params.Transform[3] = position + "35 0 0";
|
||||
params.Transform[3][1] = GetGame().GetWorld().GetSurfaceY(params.Transform[3][0],params.Transform[3][2]);
|
||||
AIWaypoint waypoint = AIWaypoint.Cast(GetGame().SpawnEntityPrefabLocal(Resource.Load("{750A8D1695BD6998}Prefabs/AI/Waypoints/AIWaypoint_Move.et"), GetGame().GetWorld(), params));
|
||||
if (waypoint && ShadowGroup) ShadowGroup.AddWaypoint(waypoint);
|
||||
}
|
||||
static void SampleNavigation()
|
||||
{
|
||||
AIWorld world = GetGame().GetAIWorld();
|
||||
NavmeshWorldComponent mesh;
|
||||
if (world) mesh = world.GetNavmeshWorldComponent("Soldiers");
|
||||
vector position = Body.GetOrigin();
|
||||
vector reachable;
|
||||
bool loaded, valid, queried;
|
||||
if (mesh)
|
||||
{
|
||||
mesh.LoadTileIn(position); loaded = mesh.IsTileLoaded(position); valid = mesh.IsTileValid(position);
|
||||
if (loaded && valid) queried = mesh.GetReachablePoint(position, 25, reachable);
|
||||
}
|
||||
PrintFormat("AID NATIVE QUERY mesh=%1 loaded=%2 valid=%3 success=%4 position=%5", mesh != null, loaded, valid, queried, reachable);
|
||||
}
|
||||
static void Reset()
|
||||
{
|
||||
Shadow = null; ShadowGroup = null; ShadowAttempted = false; Body = null; Group = null; Stage = 0; Started = 0; Last = 0; Ready = false; Finished = false; ObserverInserted = false;
|
||||
BT.Clear(); Utility.Clear(); GroupBT.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_AIDecideBehavior
|
||||
{
|
||||
override ENodeResult EOnTaskSimulate(AIAgent owner, float dt)
|
||||
{
|
||||
AID_Probe.Count(AID_Probe.BT, owner.GetControlledEntity());
|
||||
return super.EOnTaskSimulate(owner, dt);
|
||||
}
|
||||
}
|
||||
modded class SCR_AIDecideActivity
|
||||
{
|
||||
override ENodeResult EOnTaskSimulate(AIAgent owner, float dt)
|
||||
{
|
||||
AID_Probe.Count(AID_Probe.GroupBT, owner);
|
||||
return super.EOnTaskSimulate(owner, dt);
|
||||
}
|
||||
}
|
||||
modded class SCR_AIUtilityComponent
|
||||
{
|
||||
override SCR_AIBehaviorBase EvaluateBehavior(BaseTarget unknownTarget)
|
||||
{
|
||||
AID_Probe.Count(AID_Probe.Utility, m_OwnerEntity);
|
||||
return super.EvaluateBehavior(unknownTarget);
|
||||
}
|
||||
}
|
||||
modded class SCR_PlayerController
|
||||
{
|
||||
void AID_Ping(RplId body, RplId group, int stage) { Rpc(AID_RpcWorker, body, group, stage); }
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
|
||||
void AID_RpcWorker(RplId body, RplId group, int stage)
|
||||
{
|
||||
if (Replication.IsServer()) return;
|
||||
AID_Probe.Body = AID_Probe.Resolve(body); AID_Probe.Group = SCR_AIGroup.Cast(AID_Probe.Resolve(group));
|
||||
AID_Probe.Stage = stage;
|
||||
// Small deterministic script job proves actual worker-side computation/RPC.
|
||||
int result; for (int i = 1; i <= 100; i++) result += i;
|
||||
PrintFormat("AID RPC worker stage=%1 body=%2 group=%3 computed=%4", stage, AID_Probe.Body != null, AID_Probe.Group != null, result);
|
||||
Rpc(AID_RpcAck, AID_Probe.Body != null && AID_Probe.Group != null, stage, result);
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
|
||||
void AID_RpcAck(bool ready, int stage, int result)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
if (ready && result == 5050 && !AID_Probe.Ready) { AID_Probe.Ready = true; AID_Probe.Started = System.GetTickCount(); }
|
||||
PrintFormat("AID RPC dedicated stage=%1 resolved=%2 result=%3", stage, ready, result);
|
||||
}
|
||||
}
|
||||
modded class ArmaReforgerScripted
|
||||
{
|
||||
override void OnUpdate(BaseWorld world, float timeslice)
|
||||
{
|
||||
super.OnUpdate(world, timeslice);
|
||||
if (world) AID_Probe.Tick();
|
||||
}
|
||||
override protected void OnBeforeWorldCleanup()
|
||||
{
|
||||
AID_Probe.Reset(); super.OnBeforeWorldCleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
GameProject {
|
||||
ID "AID_OwnershipProbe"
|
||||
GUID "D712C85E26071018"
|
||||
TITLE "AI ownership diagnostic (isolated)"
|
||||
Dependencies { "58D0FB3206B6F859" }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/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_worker_local'
|
||||
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)
|
||||
Reference in New Issue
Block a user