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:
2026-10-13 22:52:53 +02:00
parent a951a96edc
commit 6a667d8deb
8104 changed files with 2124945 additions and 2061 deletions
+67
View File
@@ -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();
}
}
+6
View File
@@ -0,0 +1,6 @@
GameProject {
ID "AID_OwnershipProbe"
GUID "D712C85E26071018"
TITLE "AI ownership diagnostic (isolated)"
Dependencies { "58D0FB3206B6F859" }
}
+108
View File
@@ -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)
+42
View File
@@ -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))
+22
View File
@@ -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();
}
}
+6
View File
@@ -0,0 +1,6 @@
GameProject {
ID "AID_OwnershipProbe"
GUID "D712C85E26071018"
TITLE "AI ownership diagnostic (isolated)"
Dependencies { "58D0FB3206B6F859" }
}
+108
View File
@@ -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)
+22
View File
@@ -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();
}
}
+6
View File
@@ -0,0 +1,6 @@
GameProject {
ID "AID_OwnershipProbe"
GUID "D712C85E26071018"
TITLE "AI ownership diagnostic (isolated)"
Dependencies { "58D0FB3206B6F859" }
}
+108
View File
@@ -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)