Improve base guidance and qualify bounded aircraft route and hidden test lifecycle

This commit is contained in:
2026-09-19 16:41:32 +02:00
parent 6f0e4c5978
commit 5a24eea2f5
192 changed files with 27066 additions and 46 deletions
+68
View File
@@ -0,0 +1,68 @@
"""Actual immutable corridor geometry and native-survey orchestration with fake traces."""
from policy_harness import ROOT, body, run
P=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftRouteEnvelope.c'
BASE=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftFlight.c'
def cpp(s):
for name in ('Math','vector','ERR_AircraftRouteRules','ERR_AircraftFlightRules','SCR_TerrainHelper','TraceFlags'):
s=s.replace(name+'.',name+'::')
s=s.replace('array<IEntity> excluded = null','void* excluded = nullptr')
s=s.replace('TraceBox corridor','TraceBox* corridor').replace('TraceBox low','TraceBox* low')
for name in ('corridor','low'):s=s.replace(name+'.',name+'->')
return s.replace('GetGame().','GetGame()->').replace('GetWorld().','GetWorld()->').replace(', null)', ', nullptr)')
rules='\n'.join(cpp(body(P,key)) for key in ('static bool Length(', 'static vector Nearest(', 'static bool Contains(', 'static bool Held('))
run(r'''#include <cassert>
#include <cmath>
#include <algorithm>
#include <vector>
#include <cstdio>
struct Math {static float Clamp(float v,float lo,float hi){return std::clamp(v,lo,hi);}static float Min(float a,float b){return std::min(a,b);}static float Max(float a,float b){return std::max(a,b);}static int Ceil(float x){return std::ceil(x);}static float AbsFloat(float x){return std::abs(x);}};
struct vector {float v[3]={};vector(){}vector(float x,float y,float z){v[0]=x;v[1]=y;v[2]=z;}vector(const char*s){sscanf(s,"%f %f %f",v,v+1,v+2);}float&operator[](int i){return v[i];}float operator[](int i)const{return v[i];}vector operator+(vector b)const{return {v[0]+b[0],v[1]+b[1],v[2]+b[2]};}vector operator-(vector b)const{return {v[0]-b[0],v[1]-b[1],v[2]-b[2]};}vector operator*(float n)const{return {v[0]*n,v[1]*n,v[2]*n};}static float Dot(vector a,vector b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}static float DistanceXZ(vector a,vector b){return std::hypot(a[0]-b[0],a[2]-b[2]);}};
vector Vector(float x,float y,float z){return {x,y,z};}
struct ERR_AircraftFlightRules {
'''+cpp(body(BASE,'static bool Hover('))+r'''
};
struct ERR_AircraftRouteRules {
'''+rules+r'''
};
namespace TraceFlags {enum {ENTS=1,WORLD=2};}
struct TraceBox {vector Start,End,Mins,Maxs;int Flags=0;void* ExcludeArray=nullptr;};
struct World {int calls=0;int refuseAt=0;std::vector<TraceBox> seen;float TraceMove(TraceBox* t,void*){calls++;seen.push_back(*t);delete t;return calls==refuseAt?0.5:1;}} world;
struct Game {World* GetWorld(){return &world;}} game;
Game* GetGame(){return &game;}
struct SCR_TerrainHelper {static int samples,kind;static float GetTerrainY(vector){samples++;if(kind==1)return 10.31f;if(kind==2&&samples==60)return 9.69f;if(kind==3)return NAN;return 10;}};
int SCR_TerrainHelper::samples=0;int SCR_TerrainHelper::kind=0;
'''+cpp(body(P,'static bool Survey('))+r'''
int main(){
assert(ERR_AircraftRouteRules::Length(18)&&ERR_AircraftRouteRules::Length(20)&&!ERR_AircraftRouteRules::Length(17.99f)&&!ERR_AircraftRouteRules::Length(20.06f)&&!ERR_AircraftRouteRules::Length(NAN));
vector a={0,10,0},b={20,10,0};
assert(ERR_AircraftRouteRules::Contains(a,b,{10,18,7.99f},1));
assert(!ERR_AircraftRouteRules::Contains(a,b,{10,18,8},1));
assert(ERR_AircraftRouteRules::Contains(a,b,{-7.99f,18,0},1));
assert(!ERR_AircraftRouteRules::Contains(a,b,{-8,18,0},1));
assert(!ERR_AircraftRouteRules::Contains(a,b,{28,18,0},1));
assert(!ERR_AircraftRouteRules::Contains(a,b,{10,28.01f,0},1));
assert(!ERR_AircraftRouteRules::Contains(a,b,{10,18,0},.84f));
assert(!ERR_AircraftRouteRules::Contains(a,b,{NAN,18,0},1));
assert(ERR_AircraftRouteRules::Contains(a,{12,10,16},{6,18,8},1));
assert(ERR_AircraftRouteRules::Held(false,8,0,0,1,0,.75f));
assert(!ERR_AircraftRouteRules::Held(true,8,0,0,1,0,0));
assert(!ERR_AircraftRouteRules::Held(false,8,0,0,1,.25f,0));
int exclusion=1;assert(Survey(a,b,&exclusion));assert(world.calls==2&&SCR_TerrainHelper::samples==33*23);
auto high=world.seen[0],low=world.seen[1];assert(high.Flags==(TraceFlags::ENTS|TraceFlags::WORLD)&&low.Flags==TraceFlags::ENTS);
assert(high.Start[0]==10&&high.Mins[0]==-50&&high.Maxs[0]==50&&high.Mins[2]==-40&&high.Maxs[2]==40);
assert(high.Start[1]+high.Mins[1]==12&&high.End[1]+high.Maxs[1]==71);
assert(high.ExcludeArray==&exclusion&&low.ExcludeArray==&exclusion);
for(int fault=1;fault<=3;fault++){world=World();SCR_TerrainHelper::samples=0;SCR_TerrainHelper::kind=fault;assert(!Survey(a,b));assert(world.calls==0&&SCR_TerrainHelper::samples<=33*23);}
SCR_TerrainHelper::kind=0;
for(int trace=1;trace<=2;trace++){world=World();world.refuseAt=trace;assert(!Survey(a,b));assert(world.calls==trace);}
world=World();assert(!Survey(a,{100,10,0}));assert(world.calls==0);
}
''')
s=P.read_text();qualify=body(P,'static ERR_AircraftRouteEnvelope QualifyRoute(')
assert 'ERR_AircraftFlightEnvelope.Qualify(mode, aircraft, pilot)' in qualify
assert qualify.index('ClearColumn(')<qualify.index('new ERR_AircraftRouteEnvelope')
assert qualify.index('Survey(')<qualify.index('new ERR_AircraftRouteEnvelope')
assert 'm_Home = point.Home()' in qualify and 'm_Radius = point.Radius()' in qualify
assert 'bool Claim(' not in s and 'bool ClaimedBy(' not in s
assert s.count('m_Destination = end')==1
print('PASS: actual corridor caps/height/hold and whole-rectangle survey bounds, terrain refusal, native trace refusal and exact certificate lineage; native route unqualified')
+103
View File
@@ -0,0 +1,103 @@
"""Actual route entry/stage/cancellation bodies with physical/native boundaries stubbed."""
from policy_harness import ROOT, body, run
P=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftRouteFlight.c'
BASE=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftFlight.c'
ENV=ROOT/'addons/ai/Scripts/Game/ERR/ERR_AircraftRouteEnvelope.c'
def cpp(s):
s=s.replace('protected ','').replace('override ','').replace('super.', 'Base::')
for a,b in [('ERR_AircraftFlightState.','State::'),('ERR_AircraftFlightRules.','FlightRules::'),('ERR_AircraftRouteRules.','RouteRules::'),('Math.','Math::'),('vector.','vector::')]:s=s.replace(a,b)
s=s.replace('ERR_AircraftRouteEnvelope route','ERR_AircraftRouteEnvelope* route').replace('SignalsManagerComponent signals','SignalsManagerComponent* signals')
for var in ('route','m_Route','signals'):s=s.replace(var+'.',var+'->')
return s
methods='\n'.join(cpp(body(P,key)) for key in ('bool StartRoute(', 'protected bool RouteContains(', 'protected override bool Grounded(', 'protected override bool Landing(', 'protected override bool BeginTranslation(', 'protected override void CheckTranslation(', 'protected override bool ControlLease('))
run(r'''#include <cassert>
#include <cmath>
#include <algorithm>
#include <string>
#include <initializer_list>
#define PrintFormat(...) ((void)0)
using string=std::string;using ERR_GameMode=int;using Vehicle=int;using SCR_AIGroup=int;using IEntity=int;using PilotCompartmentSlot=int;
namespace State {enum {IDLE,HOOKS,STARTING,POSITIVE,NEGATIVE,CLIMB,LANDING,SHUTDOWN,COMPLETE,CANCELLED,FAILED,RELINQUISHED,TRANSLATING};}
struct Math {static float Clamp(float a,float lo,float hi){return std::clamp(a,lo,hi);}static float Min(float a,float b){return std::min(a,b);}static float AbsFloat(float a){return std::abs(a);}};
struct vector {float x=0,y=0,z=0;float& operator[](int i){return i==0?x:(i==1?y:z);}float operator[](int i)const{return i==0?x:(i==1?y:z);}vector operator-(vector b)const{return {x-b.x,y-b.y,z-b.z};}vector operator+(vector b)const{return {x+b.x,y+b.y,z+b.z};}vector operator*(float n)const{return {x*n,y*n,z*n};}float Length()const{return std::sqrt(x*x+y*y+z*z);}static float Dot(vector a,vector b){return a.x*b.x+a.y*b.y+a.z*b.z;}static float DistanceXZ(vector a,vector b){return std::hypot(a.x-b.x,a.z-b.z);}};
struct FlightRules {
'''+cpp(body(BASE,'static bool Hover('))+cpp(body(BASE,'static int CancelAction('))+r'''
};
struct RouteRules {
'''+''.join(cpp(body(ENV,k)) for k in ('static bool Length(', 'static vector Nearest(', 'static bool Contains(', 'static bool Held('))+r'''
};
struct ERR_AircraftRouteEnvelope {vector home={0,0,0},target={20,0,0};vector Home(){return home;}vector Destination(){return target;}bool Contains(vector p,float upright){return RouteRules::Contains(home,target,p,upright);}};
struct SignalsManagerComponent {bool present=true;int FindSignal(const char*){return present?0:-1;}};
struct Controller {bool hover=true,engine=true;SignalsManagerComponent signals;explicit operator bool()const{return true;}SignalsManagerComponent* GetSignalsManager(){return &signals;}bool GetAutohoverEnabled(){return hover;}bool IsEngineOn(){return engine;}};
struct Simulation {vector velocity;bool contact=false;vector GetVelocity(){return velocity;}bool HasAnyGroundContact(){return contact;}};
struct Aircraft {vector origin;float upright=1;vector GetOrigin(){return origin;}void GetWorldTransform(vector (&t)[4]){t[3]=origin;t[1]={0,upright,0};}};
struct Base {
bool m_StartAttempted=false,m_TranslationRequested=false,m_TranslationStarted=false,m_TranslationProven=false,m_Readback=true,m_Cancelled=false;
vector m_TranslationTarget,m_TranslationOrigin,m_Home;
float m_TranslationDistance=0,m_Stable=-1,m_Deadline=240000,m_StageDeadline=200000,clock=1000;
int m_State=State::CLIMB,m_Token=123,m_TranslationUpdates=10,landings=0,starts=0,failures=0,relinquished=0,restored=0;
bool owner=true,healthy=true,leased=true,grounded=false;
Controller m_Controller;Simulation m_Simulation;Aircraft m_Aircraft;string m_Reason;
bool Start(ERR_GameMode,Vehicle,SCR_AIGroup,IEntity,PilotCompartmentSlot,ERR_AircraftRouteEnvelope* q,int,float){m_StartAttempted=true;starts++;m_Home=q->Home();return true;}
bool Grounded(){return grounded;}bool Landing(float now){landings++;m_State=State::LANDING;m_Stable=-1;m_StageDeadline=Math::Min(m_Deadline,now+65000);return true;}
bool Owns(){return owner;}bool Healthy(){return healthy;}bool Lease(){return leased;}float Now(){return clock;}void Fail(string r){m_Reason=r;failures++;m_State=State::FAILED;}void Relinquish(string r){m_Reason=r;relinquished++;m_State=State::RELINQUISHED;}void RecordState(){}void Release(bool){restored++;}void Shutdown(float){m_State=State::SHUTDOWN;}
};
struct Probe:Base {
ERR_AircraftRouteEnvelope* m_Route=nullptr;vector m_LandingPoint;bool m_RouteArrived=false;
'''+methods+cpp(body(BASE,'void Cancel('))+r'''
};
int main(){
ERR_AircraftRouteEnvelope q;Probe invalid;assert(!invalid.StartRoute(1,1,1,1,1,nullptr,3,200000)&&invalid.starts==0);
q.target={21,0,0};assert(!invalid.StartRoute(1,1,1,1,1,&q,3,200000)&&invalid.starts==0);q.target={20,0,0};
Probe flight;assert(flight.StartRoute(1,1,1,1,1,&q,3,200000)&&flight.starts==1&&flight.m_TranslationRequested);
assert(flight.m_TranslationTarget.x==20&&flight.m_TranslationTarget.y==8&&flight.m_Home.x==0);
assert(!flight.StartRoute(1,1,1,1,1,&q,3,200000)&&flight.starts==1);
assert(!flight.RouteContains({9,8,0},1));assert(flight.BeginTranslation(1000,{2,8,0}));assert(flight.m_StageDeadline==161000&&flight.m_Deadline==240000);
assert(flight.RouteContains({19,8,0},1)&&!flight.RouteContains({10,8,8},1));
flight.m_Aircraft.origin={19.5f,8,0};
flight.CheckTranslation(2000,flight.m_Aircraft.origin,8,0,1);flight.CheckTranslation(5000,{18,8,0},8,0,1);assert(flight.m_Stable==-1);
flight.CheckTranslation(6000,flight.m_Aircraft.origin,8,0,1);flight.CheckTranslation(10000,flight.m_Aircraft.origin,8,0,1);assert(!flight.m_RouteArrived);
flight.CheckTranslation(11000,flight.m_Aircraft.origin,8,0,1);assert(flight.m_RouteArrived&&flight.m_TranslationProven&&flight.m_LandingPoint.x==20&&flight.landings==1);
flight.grounded=true;flight.m_Aircraft.origin={19,0,0};assert(!flight.Grounded());flight.m_Aircraft.origin={19.5f,0,0};assert(flight.Grounded());
Probe limited;limited.m_Deadline=60000;assert(limited.StartRoute(1,1,1,1,1,&q,3,60000));assert(limited.BeginTranslation(1000,{2,8,0}));assert(limited.m_StageDeadline==60000&&limited.m_Deadline==60000);
// Same parent cancellation body calls route Landing: freeze current centre,
// preserve absolute deadline and never move it on repeated cancellation.
Probe cancel;assert(cancel.StartRoute(1,1,1,1,1,&q,3,200000));assert(cancel.BeginTranslation(1000,{2,8,0}));cancel.m_Aircraft.origin={10,8,1};
cancel.Cancel("radio lost");assert(cancel.m_State==State::LANDING&&cancel.m_Cancelled&&cancel.m_LandingPoint.x==10&&cancel.m_LandingPoint.z==1&&cancel.m_Deadline==240000&&cancel.landings==1);
float stage=cancel.m_StageDeadline;cancel.clock=9000;cancel.m_Aircraft.origin={12,8,1};cancel.Cancel("again");assert(cancel.m_LandingPoint.x==10&&cancel.landings==1&&cancel.m_StageDeadline==stage);
// Shared native write gate refuses stale ownership, expiry and corridor escape.
Probe capture;capture.m_Route=&q;capture.owner=false;assert(!capture.ControlLease()&&capture.relinquished==1);
Probe expiry;expiry.m_Route=&q;expiry.clock=240000;assert(!expiry.ControlLease()&&expiry.failures==1);
Probe drift;drift.m_Route=&q;drift.m_TranslationStarted=true;drift.m_State=State::TRANSLATING;drift.m_Aircraft.origin={10,8,8};assert(!drift.ControlLease()&&drift.failures==1);
Probe healthy;healthy.m_Route=&q;healthy.m_TranslationStarted=true;healthy.m_State=State::TRANSLATING;healthy.m_Aircraft.origin={10,8,0};assert(healthy.ControlLease());
Probe ownershipCancel;ownershipCancel.m_Route=&q;ownershipCancel.owner=false;ownershipCancel.Cancel("captured");assert(ownershipCancel.relinquished==1&&ownershipCancel.landings==0&&ownershipCancel.restored==0);
}
''')
s=P.read_text()
assert 'class ERR_AircraftRouteFlight : ERR_AircraftFlight' in s
for unchanged in ('protected bool Owns(', 'protected bool Healthy(', 'protected bool Lease(', 'protected void Release(', 'void Cancel('):assert unchanged not in s
assert 'return super.Start(' in body(P,'bool StartRoute(')
assert 's_Owners' not in s, 'Use parent shared airframe registry, never another exclusivity domain'
prepare=body(P,'override void PrepareActions(')
assert prepare.index('!ControlLease()')<prepare.index('SetActionValue') if 'SetActionValue' in prepare else prepare.index('!ControlLease()')<prepare.index('Pair(')
assert 'target = m_LandingPoint' in prepare and 'ERR_AircraftTranslationRules.Demand(target,' in prepare
assert 'forward = 0; aside = 0;' in prepare
assert 'SetWorldTransform' not in s and 'SetVelocity' not in s and 'SetActive' not in s
# The large native startup/tick sequence is preserved verbatim except route geometry.
expected=body(BASE,'void Tick(').replace('void Tick(', 'override void Tick(',1).replace('ERR_AircraftFlightRules.Envelope(height, vector.DistanceXZ(transform[3], m_Home), transform[1][1])','RouteContains(transform[3], transform[1][1])')
assert body(P,'override void Tick(')==expected
print('PASS: actual route entry, fixed corridor/target, hover hold, cancellation landing freeze, shared lease/capture/deadline gate and unchanged native startup/tick sequence')
# Native compile exposed a getter return-path diagnostic. Keep the actual
# defensive getter executable, including its default pre-start observation.
run(r'''#include <cassert>
#include <cstdio>
struct vector {float x=0,y=0,z=0;vector(float a,float b,float c):x(a),y(b),z(c){}vector(const char* s){sscanf(s,"%f %f %f",&x,&y,&z);}};
struct Route {vector Destination(){return {20,3,4};}};
struct Observer {Route* m_Route=nullptr;
'''+cpp(body(P,'vector Destination('))+r'''
};
int main(){Observer p;auto absent=p.Destination();assert(absent.x==0&&absent.y==0&&absent.z==0);Route q;p.m_Route=&q;auto value=p.Destination();assert(value.x==20&&value.y==3&&value.z==4);}
''')
assert "m_StageDeadline = Math.Min(m_Deadline, now + 160000);" in body(P,"protected override bool BeginTranslation(")
assert "m_Deadline =" not in body(P,"protected override bool BeginTranslation(")
+112
View File
@@ -0,0 +1,112 @@
"""Execute route result policy and audit opt-in finite/native fixture boundaries."""
from policy_harness import ROOT, body, run
P=ROOT/'addons/ai/Scripts/Game/ERR/ERR_NativeAircraftRouteProbe.c'
source=P.read_text()
rules='\n'.join(body(P,k) for k in ('static bool Terminal(', 'static bool TranslateProof(', 'static bool PhysicalFinish(', 'static bool CanDispose(')).replace('ERR_AircraftFlightState.','State::')
run('''#include <cassert>
namespace State {enum {COMPLETE=8,CANCELLED,FAILED,RELINQUISHED};}
'''+rules+'''
int main(){
assert(Terminal(false,State::COMPLETE,0));assert(!Terminal(false,State::CANCELLED,0));assert(!Terminal(false,State::FAILED,0));
assert(TranslateProof(true,true,20,19.5,.5));
assert(!TranslateProof(true,true,20,1,.5));assert(!TranslateProof(true,false,20,20,0));assert(!TranslateProof(false,true,20,20,0));assert(!TranslateProof(true,true,20,20,.76f));
assert(!TranslateProof(true,true,29,29,0));assert(!TranslateProof(true,true,9,9,0));
assert(PhysicalFinish(true,true,true,.1f,1,1,1,101,101));
assert(!PhysicalFinish(true,true,false,.1f,1,1,1,101,101));assert(!PhysicalFinish(true,true,true,0,1,1,1,101,101));assert(!PhysicalFinish(true,true,true,.1f,2,1,1,101,101));assert(!PhysicalFinish(false,true,true,.1f,1,1,1,101,101));
assert(CanDispose(true,true,true,true,true));for(int i=0;i<5;i++)assert(!CanDispose(i!=0,i!=1,i!=2,i!=3,i!=4));
}
''')
assert source.startswith('#ifdef WORKBENCH') and source.rstrip().endswith('#endif')
assert 'outcome == "aircraft-route"' in body(P,'static bool Enabled(')
assert 'outcome == "aircraft-control"' not in source
assert source.count('ERR_Actors.Spawn(AIRCRAFT,')==1 and source.count('SpawnCharacterInCompartment(')==1
assert source.count('physics.SetActive(ActiveState.ACTIVE)')==1
for prohibited in ('SetWorldTransform(', 'SetOrigin(', 'SetVelocity(', '.StartEngine(', '.StopEngine(', 'm_Mode.Own('):assert prohibited not in source
assert 'ERR_AircraftRouteEnvelope.Survey(candidate, destination)' in body(P,'protected bool FindPad(')
tick=body(P,'int Tick(')
assert 'ERR_AircraftRouteEnvelope.QualifyRoute(' in tick and 'm_Flight.StartRoute(' in tick
assert tick.index('ERR_AircraftRouteEnvelope.QualifyRoute(')<tick.index('m_Flight.StartRoute(')
assert 'm_Flight.Arrived() && vector.DistanceXZ(transform[3], m_Destination) <= 0.75' in tick
assert 'm_Flight.ERR_NativeControlDeadline() != m_ControlDeadline' in tick
assert tick.index('m_Flight.Tick();')<tick.index('if (!m_Flight.Finished())')
stop=body(P,'void Stop(')
assert stop.index('s_Current = null')<stop.index('m_Flight.Cancel(')<stop.index('m_Flight = null')<stop.index('ERR_Actors.DeleteSpawned(')
assert 'ERR_NativeAircraftRouteRules.CanDispose(' in stop
assert source.index('probe.PrepareActions(')<source.index('super.OnPrepareControls(owner, am, dt, player);')
start=source.index('protected override event void OnPrepareControls()')
assert source.index('super.OnPrepareControls();',start)<source.index('probe.PrepareHelicopter(this);',start)
print('PASS: finite opt-in route fixture, original native input order, meaningful physical-arrival evidence and guarded cleanup; no native launch')
# Integration is separate from physical route proof. Exercise real early CLI
# refusals against a deliberately absent prefix: no engine or audio can start.
import subprocess
import sys
import tempfile
from pathlib import Path
runner=ROOT/'tools/headless_workbench.py'
cases=[
('core','1','420',[], 'requires Chat, one round, and at least 420 seconds'),
('chat','2','420',[], 'requires Chat, one round, and at least 420 seconds'),
('chat','1','419',[], 'requires Chat, one round, and at least 420 seconds'),
('chat','1','420',['--flight-action-inputs'], 'flight-action-inputs requires the Core powered-flight fixture'),
('chat','1','420',['--flight-autohover'], 'flight-autohover requires Core powered-flight with flight-action-inputs'),
('chat','1','420',[], 'must be a disposable cloned prefix'),
]
for project,rounds,timeout,extra,message in cases:
with tempfile.TemporaryDirectory(prefix='erri-route-parse-') as directory:
temporary=Path(directory)
result=subprocess.run([sys.executable,str(runner),'--compat',str(temporary/'missing-prefix'),'--profile',str(temporary/'missing-profile'),'--project',project,'--smoke','aircraft-route','--rounds',rounds,'--timeout',timeout,*extra],capture_output=True,text=True,timeout=10)
assert result.returncode==2, (project,rounds,timeout,extra,result.stderr)
assert message in result.stderr, (project,rounds,timeout,extra,result.stderr)
assert list(temporary.iterdir())==[], 'Refused route invocation must not create profiles or prefix locks'
chat=ROOT/'addons/chat/Scripts/Game/ERR/ERR_NativeSmoke.c'
text=chat.read_text()
assert 'm_ERRTestAircraftRoute = outcome == "aircraft-route";' in text
assert 'if (m_ERRTestAircraftControl || m_ERRTestAircraftRoute) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 330000;' in text
assert 'OnStop().Insert(ERR_StopHQPatrol);' in text
assert 'if (m_ERRAircraftRoute) m_ERRAircraftRoute.Stop();' in body(chat,'protected void ERR_StopHQPatrol(')
assert 'ERR_StopHQPatrol();' in body(chat,'protected bool ERR_Check(')
def block(text,anchor):
start=text.index(anchor)
opening=text.index('{',start);depth=1;end=opening+1
while depth:
depth+=(text[end]=='{')-(text[end]=='}');end+=1
return text[start:end]
branch=block(body(chat,'protected void ERR_SmokeTick('),'if (m_ERRSmokeStep == 230)')
assert 'int result = m_ERRAircraftRoute.Tick(now);' in branch
assert 'if (result < 0) { ERR_Check(false, "native actual aircraft route fixture"); return; }' in branch
assert 'if (result == 1) { m_ERRSmokeStep = 231; Transition(ERR_Phase.DEFEAT); }' in branch
# Execute the unchanged actual branch to prove pending/failure cannot advance
# to the fixture terminal and successful transition sees the completed step.
run('''#include <cassert>
#include <initializer_list>
namespace ERR_Phase {enum {DEFEAT};}
struct Probe {int result=0,ticks=0;int Tick(float){ticks++;return result;}};
struct Fixture {int m_ERRSmokeStep=230,failures=0,transitions=0;Probe m_ERRAircraftRoute;
void ERR_Check(bool,const char*){failures++;}
void Transition(int phase){assert(phase==ERR_Phase::DEFEAT&&m_ERRSmokeStep==231);transitions++;}
void Tick(float now){
'''+branch.replace('ERR_Phase.','ERR_Phase::')+'''
}
};
int main(){
for(int result:{-1,0,1}){Fixture f;f.m_ERRAircraftRoute.result=result;f.Tick(1000);assert(f.m_ERRAircraftRoute.ticks==1);assert(f.failures==(result<0));assert(f.transitions==(result==1));assert(f.m_ERRSmokeStep==(result==1?231:230));}
}
''')
terminal=block(body(chat,'protected void ERR_SmokePhase('),'if (m_ERRTestAircraftRoute)')
assert 'current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 231' in terminal
assert terminal.index('ERR_StopHQPatrol();')<terminal.index('ERR SMOKE COMPLETE:')
assert terminal.count('ERR SMOKE COMPLETE:')==1
initial=block(body(chat,'protected void ERR_SmokeTick('),'if (m_ERRTestAircraftRoute)')
assert initial.count('new ERR_NativeAircraftRouteProbe(this)')==1
assert 'm_ERRSmokeStep = 230;' in initial and initial.rstrip().endswith('return;\n\t\t\t}')
print('PASS: actual Chat route step result dispatch and scoped completion/cleanup wiring; six real early-parser refusal paths leave no prefix/profile output')
# The added two-second telemetry only observes, and cannot extend/control flight.
diagnostic=body(P,'protected void Diagnostic(')
assert diagnostic.index('now < m_NextLog')<diagnostic.index('m_NextLog = now + 2000')<diagnostic.index('ERR AIRCRAFT ROUTE MOTION:')
for forbidden in ('SetActionValue(', 'SetVelocity(', 'SetWorldTransform(', '.Cancel(', '.Tick(', '.StartRoute(', '.SetAutohoverEnabled('):assert forbidden not in diagnostic
for field in ('position=%2','velocity=%3','along=%4','cross=%5','targetError=%6','computedDemand=%7','projectedForward=%8','projectedAside=%9','stageRemaining=%5','overallRemaining=%6'):assert field in diagnostic
assert body(P,'float ERR_NativeRouteStageDeadline(').strip()=='float ERR_NativeRouteStageDeadline() { return m_StageDeadline; }'
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env python3
"""Private-display observer policy tests; native libraries/processes are substituted."""
import ctypes as C
import json
import os
from pathlib import Path
import signal
import shutil
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
import headless_display_probe as probe
def proc(pid, parent=1, exe='/usr/bin/helper', args=None, start=10):
return {'pid':pid,'ppid':parent,'exe':exe,'args':args or [exe], 'start':start,'uid':os.getuid()}
class FakeWindowQuery:
def __init__(self):
self.root_id=2;self.focused=(20,2)
self.attrs={2:self.attribute(1280,720),20:self.attribute(800,600),21:self.attribute(200,100)}
self.positions={20:(20,30),21:(40,50)};self.parents={20:2,21:20};self.pids={20:90,21:90}
def attribute(self,w,h):return {'root':2,'window_class':1,'map_state':2,'width':w,'height':h}
def root(self):return self.root_id
def attributes(self,window):return dict(self.attrs[window])
def translate(self,window,root):return self.positions[window]
def focus(self):return self.focused
def server_pid(self,window):return self.pids.get(window)
def tree(self,window):return 2,self.parents[window],[]
class DisplayTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory(prefix='erri-display-test-')
self.root=Path(self.temp.name)
self.profile=self.root/'profile';self.profile.mkdir()
(self.profile/'.erri-game-profile.json').write_text(json.dumps(probe.PROFILE_ID))
self.prefix=self.root/'pfx';self.prefix.mkdir()
self.exe=self.root/'ArmaReforgerSteam.exe';self.exe.write_bytes(b'test-fixture-not-executable')
self.proton=self.root/'proton';self.proton.write_text('test-fixture-not-executable')
self.report=self.profile/'display-ownership-fixture.json'
self.bound={'display':':2','gamescope':{'pid':40,'start':10,'uid':os.getuid()},'xwayland':{'pid':41,'start':11,'uid':os.getuid()},'sockets':[{'path':'@/tmp/.X11-unix/X2','inode':100}], 'relationship':{'mode':'descendant','descendant':True}}
self.args=SimpleNamespace(host_display=':0',prefix=self.prefix,game_executable=self.exe,profile=self.profile,
report=self.report,observe_seconds=20,query_worker=False)
def tearDown(self): self.temp.cleanup()
def cli(self):
return ['--host-display',':0','--prefix',str(self.prefix),'--game-executable',str(self.exe),'--profile',str(self.profile),
'--report',str(self.report),'--observe-seconds','20','--',str(self.proton),'run',str(self.exe),'-noBackend']
def test_display_refuses_host_alias_remote_empty_or_malformed(self):
for display,host in [(':0',':0.1'),('unix:0.0',':0'),(':0','localhost:0'),('',':0'),('host:2',':0'),(':2;evil',':0'),(':2/0',':0')]:
with self.subTest(display=display,host=host),self.assertRaises(ValueError):probe.validate_display(display,host)
self.assertEqual(probe.validate_display(':2.1',':0'),2)
self.assertEqual(probe.validate_display('unix:2',''),2)
def test_ancestor_requires_native_same_user_headless_before_separator(self):
table={50:proc(50,40),40:proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--','proton'])}
self.assertEqual(probe.ancestor(50,table.__getitem__)['pid'],40)
for args in (['gamescope','--','--backend','headless'],['gamescope','--backend','wayland','--'],['gamescope','--backend','headless','--backend=drm','--']):
table[40]['args']=args
with self.assertRaises(ValueError):probe.ancestor(50,table.__getitem__)
table[40]['uid']=os.getuid()+1
with self.assertRaises(ValueError):probe.ancestor(50,table.__getitem__)
def binding_fixture(self,extra=None):
table={50:proc(50,40),40:proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--','proton']),
41:proc(41,40,'/usr/bin/Xwayland',['Xwayland',':2','-rootless'],11)}
if extra:extra(table)
folder=self.root/'proc';folder.mkdir(exist_ok=True)
for p in table: (folder/str(p)).mkdir(exist_ok=True)
(folder/'net').mkdir(exist_ok=True)
(folder/'net/unix').write_text('header\n0000: 00000002 00000000 00010000 0001 01 100 @/tmp/.X11-unix/X2\n')
fds=folder/'41/fd';fds.mkdir(exist_ok=True)
if not (fds/'7').is_symlink(): (fds/'7').symlink_to('socket:[100]')
with patch.dict(os.environ,{'DISPLAY':':2','WINEPREFIX':str(self.prefix)}),patch.object(probe.os,'getppid',return_value=50):
return probe.binding(':0',self.prefix,read=table.__getitem__,proc_root=folder)
def test_binding_exact_nested_server_and_portal_refusal(self):
self.assertEqual(self.binding_fixture(),self.bound)
with self.assertRaisesRegex(ValueError,'portal'):self.binding_fixture(lambda t:t[41]['args'].append('-enable-ei-portal'))
with self.assertRaisesRegex(ValueError,'exactly one'):self.binding_fixture(lambda t:t[41].update(ppid=1))
with self.assertRaisesRegex(ValueError,'exactly one'):self.binding_fixture(lambda t:t.update({42:proc(42,40,'/usr/bin/Xwayland',['Xwayland',':2'])}))
def test_failed_binding_reports_sanitized_names_and_ancestry_without_relaxing(self):
def change(table):
table[41]['exe']='/private/runtime/Xwayland-versioned'
table[41]['args']=['Xwayland-versioned',':2','-auth','/private/secret-cookie','--unknown-secret']
with self.assertRaises(ValueError) as raised:
self.binding_fixture(change)
text=str(raised.exception)
details=json.loads(text.split('preflight=',1)[1])
self.assertFalse(details['display_opened']);self.assertFalse(details['input_sent'])
self.assertEqual(details['gamescope']['pid'],40)
item=details['same_user_xwayland_candidates'][0]
self.assertEqual(item['executable_basename'],'Xwayland-versioned')
self.assertTrue(item['descendant']);self.assertFalse(item['exact_executable_name'])
self.assertEqual(item['display_args'],[':2'])
self.assertNotIn('/private',text);self.assertNotIn('secret',text)
with self.assertRaises(ValueError) as raised:
self.binding_fixture(lambda t:t[41].update(ppid=1))
self.assertFalse(json.loads(str(raised.exception).split('preflight=',1)[1])['same_user_xwayland_candidates'][0]['descendant'])
def test_failed_binding_diagnostic_candidates_are_bounded(self):
def change(table):
table[41]['ppid']=1
for pid in range(100,140):
table[pid]=proc(pid,1,'/usr/bin/Xwayland',['Xwayland',':3','-auth','private'])
with self.assertRaises(ValueError) as raised:self.binding_fixture(change)
details=json.loads(str(raised.exception).split('preflight=',1)[1])
self.assertEqual(details['candidate_count'],41)
self.assertEqual(len(details['same_user_xwayland_candidates']),16)
self.assertNotIn('private',str(raised.exception))
def test_reparented_candidate_socket_nonce_diagnostics_require_every_check(self):
self.binding_fixture()
folder=self.root/'proc';(folder/'40/fd').mkdir();(folder/'40/fd/7').symlink_to('socket:[100]')
scope=proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--'])
candidate=proc(41,1,'/usr/bin/Xwayland',['Xwayland',':2'],11)
records={40:scope,41:candidate}
nonce='application.id=org.escapistsrereintroduced.game.'+'a'*32
env={'WINEPREFIX':str(self.prefix),'PULSE_PROP':nonce}
with patch.dict(os.environ,{'PULSE_PROP':nonce}):
report=probe.candidate_relationship(candidate,scope,2,self.prefix,records.__getitem__,lambda _:env,folder)
for key in ('start_not_older_than_gamescope','wrapper_run_nonce_valid','xwayland_prefix_matches','gamescope_prefix_matches',
'xwayland_run_nonce_matches','gamescope_run_nonce_matches','same_listener_inodes','process_identities_stable'):
self.assertTrue(report[key],key)
self.assertEqual(report['listener_inodes'],[100])
self.assertNotIn(nonce,json.dumps(report));self.assertNotIn(str(self.prefix),json.dumps(report))
(folder/'40/fd/7').unlink()
with patch.dict(os.environ,{'PULSE_PROP':'wrong'}):
report=probe.candidate_relationship(candidate,scope,2,self.prefix,records.__getitem__,lambda _:env,folder)
self.assertFalse(report['same_listener_inodes']);self.assertFalse(report['wrapper_run_nonce_valid'])
# Missing Gamescope socket/nonce evidence still cannot substitute for ancestry.
with self.assertRaisesRegex(ValueError,'exactly one'):
self.binding_fixture(lambda t:t[41].update(ppid=1))
def test_shared_relationship_gate_rejects_every_missing_false_nonboolean_and_inode_fault(self):
good={key:True for key in probe.SHARED_CHECKS};good['listener_inodes']=[100,101]
self.assertTrue(probe.shared_relationship_valid(good))
for key in probe.SHARED_CHECKS:
for value in (False,None,1):
bad={**good,key:value};self.assertFalse(probe.shared_relationship_valid(bad),(key,value))
bad=dict(good);del bad[key];self.assertFalse(probe.shared_relationship_valid(bad),key)
for inodes in (None,[],[True],[0],[-1],[100,100],[1,2,3]):
self.assertFalse(probe.shared_relationship_valid({**good,'listener_inodes':inodes}))
def test_actual_reparented_binding_requires_shared_fds_environment_and_stable_checks(self):
self.binding_fixture();folder=self.root/'proc';(folder/'40/fd').mkdir();(folder/'40/fd/7').symlink_to('socket:[100]')
table={50:proc(50,40),40:proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--']),
41:proc(41,1,'/usr/bin/Xwayland',['Xwayland',':2'],11)}
nonce='application.id=org.escapistsrereintroduced.game.'+'b'*32
env={'WINEPREFIX':str(self.prefix),'PULSE_PROP':nonce}
def bind(env_read=lambda _:env,read=table.__getitem__):
with patch.dict(os.environ,{'DISPLAY':':2','WINEPREFIX':str(self.prefix),'PULSE_PROP':nonce}),patch.object(probe.os,'getppid',return_value=50):
return probe.binding(':0',self.prefix,read=read,env_read=env_read,proc_root=folder)
result=bind();self.assertEqual(result['relationship']['mode'],'shared-listeners')
self.assertTrue(probe.shared_relationship_valid(result['relationship']['checks']))
for pid in (40,41):
for key,value in [('WINEPREFIX','/foreign'),('PULSE_PROP',nonce+'mismatch')]:
with self.assertRaises(ValueError):bind(lambda current: {**env,key:value} if current==pid else env)
table[41]['start']=9
with self.assertRaises(ValueError):bind()
table[41]['start']=11
table[41]['args'].append('-enable-ei-portal')
with self.assertRaisesRegex(ValueError,'portal'):bind()
table[41]['args'].pop()
(folder/'40/fd/7').unlink()
with self.assertRaisesRegex(ValueError,'exactly one'):bind()
def test_shared_binding_refuses_changed_listener_evidence_before_connection(self):
self.binding_fixture();folder=self.root/'proc'
table={50:proc(50,40),40:proc(40,1,'/usr/bin/gamescope',['gamescope','--backend','headless','--']),
41:proc(41,1,'/usr/bin/Xwayland',['Xwayland',':2'],11)}
good={key:True for key in probe.SHARED_CHECKS};good['listener_inodes']=[100]
changed={**good,'listener_inodes':[101]}
with patch.dict(os.environ,{'DISPLAY':':2','WINEPREFIX':str(self.prefix)}),patch.object(probe.os,'getppid',return_value=50),\
patch.object(probe,'candidate_relationship',side_effect=[good,changed]):
with self.assertRaisesRegex(ValueError,'changed during binding'):
probe.binding(':0',self.prefix,read=table.__getitem__,proc_root=folder)
def test_relationship_unreadable_environment_and_reused_pid_fail_diagnostics(self):
self.binding_fixture();folder=self.root/'proc'
scope=proc(40);candidate=proc(41,start=1)
def inaccessible(pid):raise PermissionError('sensitive-path-must-not-be-emitted')
result=probe.candidate_relationship(candidate,scope,2,self.prefix,lambda pid:proc(pid,start=99),inaccessible,folder)
self.assertIsNone(result['xwayland_prefix_matches'])
self.assertIsNone(result['gamescope_run_nonce_matches'])
self.assertFalse(result['process_identities_stable']);self.assertFalse(result['start_not_older_than_gamescope'])
self.assertNotIn('sensitive',json.dumps(result))
def test_display_socket_ownership_prevents_accidental_host_query(self):
self.binding_fixture()
folder=self.root/'proc'
self.assertEqual(probe.owned_sockets(2,41,folder),self.bound['sockets'])
with (folder/'net/unix').open('a') as out:
out.write('0000: 00000002 00000000 00010000 0001 01 101 /tmp/.X11-unix/X2\n')
with self.assertRaisesRegex(ValueError,'not held'):probe.owned_sockets(2,41,folder)
(folder/'41/fd/8').symlink_to('socket:[101]')
self.assertEqual(len(probe.owned_sockets(2,41,folder)),2)
(folder/'net/unix').write_text('header\n')
with self.assertRaisesRegex(ValueError,'No unique'):probe.owned_sockets(2,41,folder)
def test_exact_game_window_requires_server_pid_and_native_process_metadata(self):
window={'window':17,'pid':90,'server_pid':90,'title':'Arma Reforger'}
p=proc(90,args=[str(self.exe)])
env={'WINEPREFIX':str(self.prefix),'DISPLAY':':2'}
def owns():return probe.owned_window(window,self.bound,self.prefix,self.exe,lambda _:p,lambda _:env)
self.assertIsNotNone(owns())
for key,value in [('server_pid',91),('pid',None)]:
old=window[key];window[key]=value;self.assertIsNone(owns());window[key]=old
env['WINEPREFIX']='/foreign';self.assertIsNone(owns());env['WINEPREFIX']=str(self.prefix)
env['DISPLAY']=':0';self.assertIsNone(owns());env['DISPLAY']=':2'
p['args']=['ArmaReforgerSteam.exe'];self.assertIsNone(owns())
p['args']=['/usr/bin/foreign',str(self.exe)];self.assertIsNone(owns())
p['args']=['Z:'+str(self.exe).replace('/','\\')];self.assertIsNotNone(owns())
with patch.object(probe,'process'):
self.assertIsNone(probe.owned_window(window,self.bound,self.prefix,self.exe,Mock(side_effect=[p,{**p,'start':99}]),lambda _:env))
def test_observe_closes_native_connection_and_rejects_process_reuse(self):
native=Mock();native.windows.return_value=[]
with patch.object(probe,'binding',side_effect=[self.bound,{**self.bound,'xwayland':{**self.bound['xwayland'],'start':999}}]):
with self.assertRaisesRegex(ValueError,'changed'):probe.observe(self.args,lambda _:native)
native.close.assert_called_once()
native=Mock();native.windows.side_effect=ValueError('query failed')
with patch.object(probe,'binding',return_value=self.bound):
with self.assertRaises(ValueError):probe.observe(self.args,lambda _:native)
native.close.assert_called_once()
def test_new_direct_profile_report_required_before_child(self):
probe.validate_paths(self.args)
for report in (self.root/'elsewhere.json',self.profile/'sub'/'out.json',self.profile/'..'/'out.json'):
self.args.report=report
with self.assertRaises(ValueError):probe.validate_paths(self.args)
self.args.report=self.report;self.report.write_text('preserve historical evidence')
with patch.object(probe.subprocess,'Popen') as popen,patch.object(probe,'binding',return_value=self.bound):
with self.assertRaises(ValueError):probe.main(self.cli())
popen.assert_not_called()
self.assertEqual(self.report.read_text(),'preserve historical evidence')
self.report.unlink();self.report.symlink_to(self.root/'absent')
with self.assertRaises(ValueError):probe.validate_paths(self.args)
def test_marker_alias_nonfinite_and_observation_budget(self):
for seconds in (0,31,float('nan'),float('inf')):
self.args.observe_seconds=seconds
with self.assertRaises(ValueError):probe.validate_paths(self.args)
self.args.observe_seconds=20
marker=self.profile/'.erri-game-profile.json';marker.write_text('{}')
with self.assertRaises(ValueError):probe.validate_paths(self.args)
def test_actual_xres_status_zero_is_success_and_pid_mask_required(self):
native=object.__new__(probe.X11);native.d=123
value=probe.X11.Client(probe.X11.Spec(17,2),4,None)
keep=(probe.X11.Client*1)(value)
def query(display,n,spec,count,out):
C.cast(count,C.POINTER(C.c_long))[0]=1
C.cast(out,C.POINTER(C.POINTER(probe.X11.Client)))[0]=C.cast(keep,C.POINTER(probe.X11.Client))
return 0
native.r=SimpleNamespace(XResQueryClientIds=query,XResGetClientPid=Mock(return_value=90),XResClientIdsDestroy=Mock())
self.assertEqual(native.server_pid(17),90)
native.r.XResClientIdsDestroy.assert_called_once()
keep[0].spec.mask=1;self.assertIsNone(native.server_pid(17))
native.r.XResQueryClientIds=lambda *args:1;self.assertIsNone(native.server_pid(17))
def test_read_only_wrapper_records_binding_preserves_child_exit(self):
child=Mock();child.poll.return_value=None;child.wait.return_value=17
observed={'binding':self.bound,'owned_windows':[{'window':17,'pid':90,'server_pid':90}],
'owned_game_window_verified':True,'unverified_window_count':0,'window_count':1}
worker=SimpleNamespace(returncode=0,stdout=json.dumps(observed))
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'process',return_value=proc(55)),\
patch.object(probe.subprocess,'Popen',return_value=child) as spawn,patch.object(probe.subprocess,'run',return_value=worker) as query,\
patch.object(probe.signal,'signal'),patch.object(probe.time,'sleep'):
self.assertEqual(probe.main(self.cli()),17)
self.assertEqual(spawn.call_args.args[0],[str(self.proton),'run',str(self.exe),'-noBackend'])
self.assertEqual(spawn.call_args.kwargs,{}) # inherit parent's owned process group
self.assertLessEqual(query.call_args.kwargs['timeout'],3)
report=json.loads(self.report.read_text())
self.assertTrue(report['owned_game_window_verified']);self.assertTrue(report['read_only'])
self.assertFalse(report['input_sent']);self.assertFalse(report['release_qualified'])
self.assertEqual(report['command'],spawn.call_args.args[0]);self.assertEqual(len(report['helper_sha256']),64)
self.assertEqual(self.report.stat().st_mode&0o777,0o600)
self.assertEqual(report['first_window_count'],1);self.assertEqual(report['last_window_count'],1)
self.assertEqual(report['successful_queries'],1);self.assertEqual(report['last_query_status'],'ok')
child.terminate.assert_not_called();child.kill.assert_not_called()
def test_query_failure_does_not_become_ownership_or_change_child_exit(self):
child=Mock();child.poll.side_effect=[None,0];child.wait.return_value=0
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'process',return_value=proc(55)),\
patch.object(probe.subprocess,'Popen',return_value=child),patch.object(probe.subprocess,'run',return_value=SimpleNamespace(returncode=2)),\
patch.object(probe.signal,'signal'),patch.object(probe.time,'sleep'):
self.assertEqual(probe.main(self.cli()),0)
self.assertFalse(json.loads(self.report.read_text())['owned_game_window_verified'])
def test_timeout_and_signal_forwarding_leave_native_lifetime_with_parent(self):
child=Mock();child.poll.return_value=None;child.wait.return_value=23
handlers={}
def handle(sig,callback):
old=handlers.get(sig,signal.SIG_DFL);handlers[sig]=callback;return old
def timed_out(*args,**kwargs):
handlers[signal.SIGTERM](signal.SIGTERM,None)
raise probe.subprocess.TimeoutExpired('read-only observer',3)
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'process',return_value=proc(55)),\
patch.object(probe.subprocess,'Popen',return_value=child),patch.object(probe.subprocess,'run',side_effect=timed_out),\
patch.object(probe.signal,'signal',side_effect=handle),patch.object(probe.time,'sleep'):
self.assertEqual(probe.main(self.cli()),23)
child.send_signal.assert_called_once_with(signal.SIGTERM)
self.assertFalse(json.loads(self.report.read_text())['owned_game_window_verified'])
self.assertEqual(handlers[signal.SIGTERM],signal.SIG_DFL)
child.kill.assert_not_called()
def test_foreign_or_malformed_command_refused_without_child_or_report(self):
original=self.cli()
for command in ([str(self.proton),'other',str(self.exe)],['/bin/echo',str(self.exe)],[str(self.proton),'run','foreign.exe']):
args=original[:original.index('--')]+['--']+command
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe.subprocess,'Popen') as spawn:
with self.assertRaises(ValueError):probe.main(args)
spawn.assert_not_called();self.assertFalse(self.report.exists())
def test_window_diagnostics_pin_rejection_without_titles_or_private_values(self):
secret_title='private unrelated window title'
window={'window':17,'pid':90,'server_pid':91,'title':secret_title,'xres_query':{'status':0,'count':1,'mask':2}}
records={90:proc(90,args=['/private/other.exe']),91:proc(91,args=[str(self.exe)])}
environment={'WINEPREFIX':str(self.prefix),'DISPLAY':':2','SECRET':'never emit'}
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,records.__getitem__,lambda _:environment)
self.assertEqual(report['rejection_stage'],'property-server-pid-disagreement')
self.assertFalse(report['property_process']['game_executable_match'])
self.assertTrue(report['server_process']['game_executable_match'])
text=json.dumps(report)
for secret in (secret_title,str(self.exe),str(self.prefix),'/private','never emit'):
self.assertNotIn(secret,text)
window['pid']=91
self.assertEqual(probe.window_diagnostic(window,self.bound,self.prefix,self.exe,records.__getitem__,lambda _:environment)['rejection_stage'],'checks-passed')
records[91]['args']=['z:'+str(self.exe).replace('/','\\')]
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,records.__getitem__,lambda _:environment)
self.assertEqual(report['rejection_stage'],'game-executable-mismatch')
self.assertEqual(report['server_process']['argv0_class'],'game-basename-noncanonical-path')
self.assertTrue(report['server_process']['argv0_case_insensitive_exact'])
self.assertIsNone(probe.owned_window(window,self.bound,self.prefix,self.exe,records.__getitem__,lambda _:environment))
def test_window_diagnostics_unavailable_pid_environment_and_mutation(self):
window={'window':17,'pid':90,'server_pid':None}
def unreadable(_):raise PermissionError('private error detail')
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,unreadable,unreadable)
self.assertEqual(report['rejection_stage'],'server-pid-unavailable')
self.assertFalse(report['property_process']['readable'])
self.assertNotIn('private error',json.dumps(report))
window['server_pid']=90
p=proc(90,args=[str(self.exe)])
env={'WINEPREFIX':str(self.prefix),'DISPLAY':':2'}
for key,value,stage in [('WINEPREFIX','/foreign','private-prefix-unverified'),('DISPLAY',':0','private-display-unverified')]:
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,lambda _:p,lambda _:{**env,key:value})
self.assertEqual(report['rejection_stage'],stage)
changed=Mock(side_effect=[p,{**p,'start':99}])
report=probe.window_diagnostic(window,self.bound,self.prefix,self.exe,changed,lambda _:env)
self.assertEqual(report['rejection_stage'],'native-process-changed')
def test_observe_diagnostics_bounded_and_no_unverified_titles_serialized(self):
native=Mock();native.windows.return_value=[{'window':i,'pid':90,'server_pid':91,'title':'never serialize'} for i in range(20)]
safe={'rejection_stage':'property-server-pid-disagreement'}
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'owned_window',return_value=None),\
patch.object(probe,'window_diagnostic',return_value=safe) as diagnostic:
report=probe.observe(self.args,lambda _:native)
self.assertEqual(diagnostic.call_count,16);self.assertEqual(report['window_count'],20)
self.assertFalse(report['owned_game_window_verified']);self.assertNotIn('never serialize',json.dumps(report))
def test_prefix_equivalence_is_only_exact_spelling_plus_trailing_slashes(self):
alias=self.root/'alias';alias.symlink_to(self.prefix,target_is_directory=True)
for suffix in ('','/','////'):
self.assertTrue(probe.prefix_matches(str(self.prefix)+suffix,self.prefix))
for value in (None,b'',str(self.prefix)+'-other/',str(self.prefix)+'/./',str(self.prefix)+'/../pfx/',
str(alias)+'/',str(self.prefix).replace('/pfx','//pfx'),str(self.prefix)+'\\',str(self.prefix)+' /','pfx/'):
with self.subTest(value=value):self.assertFalse(probe.prefix_matches(value,self.prefix))
def test_actual_window_and_relationship_accept_proton_trailing_slash_only(self):
window={'window':17,'pid':90,'server_pid':90}
p=proc(90,args=[str(self.exe)])
for suffix in ('','/','///'):
env={'WINEPREFIX':str(self.prefix)+suffix,'DISPLAY':':2'}
self.assertIsNotNone(probe.owned_window(window,self.bound,self.prefix,self.exe,lambda _:p,lambda _:env))
self.assertEqual(probe.window_diagnostic(window,self.bound,self.prefix,self.exe,lambda _:p,lambda _:env)['rejection_stage'],'checks-passed')
self.binding_fixture();folder=self.root/'proc'
scope=proc(40);candidate=proc(41,start=11)
nonce='application.id=org.escapistsrereintroduced.game.'+'c'*32
records={40:scope,41:candidate}
for value,expected in ((str(self.prefix)+'///',True),(str(self.prefix)+'-other/',False),(str(self.prefix)+'/../pfx/',False)):
env={'WINEPREFIX':value,'PULSE_PROP':nonce}
with patch.dict(os.environ,{'PULSE_PROP':nonce}):
result=probe.candidate_relationship(candidate,scope,2,self.prefix,records.__getitem__,lambda _:env,folder)
self.assertIs(result['xwayland_prefix_matches'],expected)
self.assertIs(result['gamescope_prefix_matches'],expected)
def test_native_xwindow_attributes_abi_and_signatures_match_installed_headers(self):
compiler=shutil.which('cc')
self.assertIsNotNone(compiler,'Installed C compiler required for native ABI check')
fields=[name for name,_ in probe.WindowAttributes._fields_]
lines=['#include <X11/Xlib.h>','#include <stddef.h>','#include <stdio.h>',
'_Static_assert(__builtin_types_compatible_p(__typeof__(&XGetWindowAttributes), Status (*)(Display*,Window,XWindowAttributes*)), "attributes signature");',
'_Static_assert(__builtin_types_compatible_p(__typeof__(&XGetInputFocus), int (*)(Display*,Window*,int*)), "focus signature");',
'_Static_assert(__builtin_types_compatible_p(__typeof__(&XTranslateCoordinates), Bool (*)(Display*,Window,Window,int,int,int*,int*,Window*)), "translation signature");',
'int main(void) { printf("%zu\\n", sizeof(XWindowAttributes));']
for field in fields:
native='class' if field=='window_class' else field
lines.append('printf("%zu\\n", offsetof(XWindowAttributes, '+native+'));')
lines.append('return 0;}')
source=self.root/'abi.c';binary=self.root/'abi'
source.write_text('\n'.join(lines))
subprocess.run([compiler,'-std=gnu11','-Werror',str(source),'-o',str(binary)],check=True,capture_output=True,timeout=15)
# This tiny binary only prints sizeof/offsetof; it does not link X11 or open a display.
values=[int(value) for value in subprocess.check_output([str(binary)],text=True,timeout=3).splitlines()]
self.assertEqual(values[0],C.sizeof(probe.WindowAttributes))
self.assertEqual(values[1:],[getattr(probe.WindowAttributes,field).offset for field in fields])
def test_ctypes_geometry_focus_adapter_signatures_and_values_without_connection(self):
x=Mock();r=Mock();x.XOpenDisplay.return_value=123
with patch.object(probe.C,'CDLL',side_effect=[x,r]):native=probe.X11(':2')
self.assertEqual(x.XGetWindowAttributes.argtypes,[C.c_void_p,C.c_ulong,C.POINTER(probe.WindowAttributes)])
self.assertEqual(x.XGetInputFocus.argtypes,[C.c_void_p,C.POINTER(C.c_ulong),C.POINTER(C.c_int)])
def attrs(display,window,out):
value=C.cast(out,C.POINTER(probe.WindowAttributes)).contents
value.root=2;value.window_class=1;value.map_state=2;value.width=1280;value.height=720
return 1
def focus(display,out,revert):
C.cast(out,C.POINTER(C.c_ulong))[0]=20;C.cast(revert,C.POINTER(C.c_int))[0]=2;return 1
def translate(display,window,root,sx,sy,dx,dy,child):
C.cast(dx,C.POINTER(C.c_int))[0]=-20;C.cast(dy,C.POINTER(C.c_int))[0]=30;return 1
x.XGetWindowAttributes.side_effect=attrs;x.XGetInputFocus.side_effect=focus;x.XTranslateCoordinates.side_effect=translate
self.assertEqual(native.attributes(20)['width'],1280);self.assertEqual(native.focus(),(20,2))
self.assertEqual(native.translate(20,2),(-20,30))
x.XGetWindowAttributes.side_effect=None;x.XGetWindowAttributes.return_value=0
with self.assertRaises(ValueError):native.attributes(20)
native.query_budget=0
with self.assertRaisesRegex(ValueError,'budget'):native.focus()
native.close();x.XCloseDisplay.assert_called_once_with(123)
def test_exact_mapped_focus_and_owned_descendant_qualify_read_only(self):
query=FakeWindowQuery();owner={'window':20,'pid':90}
direct=probe.input_target(query,owner)
self.assertTrue(direct['verified']);self.assertEqual(direct['focus_chain'],[20])
self.assertEqual(direct['geometry']['clipped_bounds'],[20,30,820,630])
query.focused=(21,2)
child=probe.input_target(query,owner)
self.assertTrue(child['verified']);self.assertEqual(child['focus_chain'],[21,20])
self.assertEqual(child['focused_clipped_bounds'],[40,50,240,150])
query.positions[20]=(-10,-20)
self.assertTrue(probe.input_target(query,owner)['verified'])
self.assertEqual(probe.input_target(query,owner)['geometry']['clipped_bounds'],[0,0,790,580])
def test_unmapped_input_only_invalid_root_geometry_and_foreign_focus_refused(self):
for window,key,value in ((20,'map_state',0),(20,'window_class',2),(20,'width',0),(20,'height',65536),
(20,'root',99),(20,'width',True),(2,'width',0),(2,'window_class',2)):
query=FakeWindowQuery();query.attrs[window][key]=value
with self.subTest(window=window,key=key,value=value):self.assertFalse(probe.input_target(query,{'window':20,'pid':90})['verified'])
for focused in ((0,2),(1,2),(True,2),(20,9),(20,True)):
query=FakeWindowQuery();query.focused=focused
self.assertFalse(probe.input_target(query,{'window':20,'pid':90})['verified'])
query=FakeWindowQuery();query.positions[20]=(2000,0)
self.assertFalse(probe.input_target(query,{'window':20,'pid':90})['verified'])
query=FakeWindowQuery();query.focused=(21,2);query.pids[21]=99
result=probe.input_target(query,{'window':20,'pid':90})
self.assertFalse(result['verified']);self.assertEqual(result['reason'],'focus-client-not-owned-game')
query.pids[21]=90;query.parents[21]=2
self.assertFalse(probe.input_target(query,{'window':20,'pid':90})['verified'])
def test_child_clipping_cycle_transient_focus_or_geometry_refused(self):
owner={'window':20,'pid':90}
query=FakeWindowQuery();query.focused=(21,2);query.positions[21]=(900,50)
self.assertFalse(probe.input_target(query,owner)['verified'])
query=FakeWindowQuery();query.focused=(21,2);query.parents[21]=21
self.assertFalse(probe.input_target(query,owner)['verified'])
query=FakeWindowQuery();query.focus=Mock(side_effect=[(20,2),(21,2)])
self.assertFalse(probe.input_target(query,owner)['verified'])
query=FakeWindowQuery();query.translate=Mock(side_effect=[(20,30),(20,30),(21,30)])
self.assertFalse(probe.input_target(query,owner)['verified'])
query=FakeWindowQuery();query.attributes=Mock(side_effect=ValueError('native-window-attributes-unavailable'))
self.assertFalse(probe.input_target(query,owner)['verified'])
def test_focus_ancestry_has_fixed_depth_bound_and_stale_process_is_not_target(self):
query=FakeWindowQuery();query.focused=(100,2)
for window in range(100,133):
query.attrs[window]=query.attribute(100,100);query.positions[window]=(40,50)
query.parents[window]=window+1;query.pids[window]=90
query.parents[132]=20
result=probe.input_target(query,{'window':20,'pid':90})
self.assertFalse(result['verified']);self.assertEqual(result['reason'],'focus-ancestry-budget-exceeded')
owner={'window':20,'pid':90,'server_pid':90,'process':{'pid':90,'start':100,'uid':os.getuid()}}
query=FakeWindowQuery();query.windows=lambda:[owner];query.close=Mock()
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'owned_window',side_effect=[owner,None]),\
patch.object(probe,'window_diagnostic',return_value={}):
result=probe.observe(self.args,lambda _:query)
self.assertTrue(result['owned_game_window_verified']);self.assertFalse(result['input_target_verified'])
self.assertEqual(result['input_target_observations'][0]['reason'],'game-process-changed-during-target-observation')
def test_observe_preserves_ownership_when_input_target_is_unverified(self):
window={'window':20,'pid':90,'server_pid':90,'process':{'pid':90,'start':100,'uid':os.getuid()}}
query=FakeWindowQuery();query.windows=lambda:[window];query.close=Mock();query.focused=(0,0)
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'owned_window',return_value=window),\
patch.object(probe,'window_diagnostic',return_value={}):
result=probe.observe(self.args,lambda _:query)
self.assertTrue(result['owned_game_window_verified']);self.assertFalse(result['input_target_verified'])
self.assertIsNone(result['input_target']);query.close.assert_called_once()
query.focused=(20,2)
with patch.object(probe,'binding',return_value=self.bound),patch.object(probe,'owned_window',return_value=window),\
patch.object(probe,'window_diagnostic',return_value={}):
result=probe.observe(self.args,lambda _:query)
self.assertTrue(result['input_target_verified']);self.assertEqual(result['input_target']['window'],20)
def test_no_native_input_or_focus_functions_are_bound(self):
source=Path(probe.__file__).read_text()
for forbidden in ('XTestFake','XSendEvent','XSetInputFocus','XWarpPointer','XRaiseWindow','XGrabPointer','XGrabKeyboard'):
self.assertNotIn(forbidden,source)
self.assertIn('XOpenDisplay(display.encode',source)
if __name__=='__main__':unittest.main()
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Runner integration policy tests: fake native processes and no display connections."""
import contextlib
import copy
import hashlib
import io
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
import headless_game as runner
import headless_display_probe as helper
DEFAULT_REPORT = object()
class DisplayRunnerTests(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory(prefix='erri-display-runner-test-')
self.base=Path(self.tmp.name);self.root=self.base/'root';(self.root/'tools').mkdir(parents=True)
(self.root/'tools/headless_display_probe.py').write_text('synthetic helper bytes for provenance test')
self.compat=self.base/'compat';self.profile=self.base/'profile';self.profile.mkdir()
self.steam=self.base/'Steam';self.exe=self.steam/'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe'
self.proton=self.steam/'steamapps/common/Proton - Experimental/proton'
for path in (self.exe,self.proton):path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(b'synthetic file, never launched')
self.command=['gamescope','--backend','headless','-W','1280','--',str(self.proton),'run',str(self.exe),'-noBackend']
self.wrapped,self.context=runner.display_probe_command(self.command,self.root,self.compat,self.profile,self.exe,'fixed-run-id',':0')
self.session=self.base/'session';self.session.mkdir()
def ident(pid):return {'pid':pid,'start':100,'uid':os.getuid()}
self.report={**{k:self.context[k] for k in ('helper_sha256','prefix','profile','game_executable','report','command')},
'schema':1,'kind':'headless-display-read-only','owned_game_window_verified':True,
'read_only':True,'input_sent':False,'observation_finished':True,'wrapper':ident(10),
'binding':{'display':':2','gamescope':ident(20),'xwayland':ident(30),
'sockets':[{'path':'@/tmp/.X11-unix/X2','inode':1000}],
'relationship':{'mode':'descendant','descendant':True}},
'owned_windows':[{'window':12345,'pid':40,'server_pid':40,'process':ident(40),'title':'fixture game'}]}
def tearDown(self):self.tmp.cleanup()
def save(self,value=DEFAULT_REPORT):
raw=(json.dumps(self.report if value is DEFAULT_REPORT else value,indent=3)+'\n').encode()
Path(self.context['report']).write_bytes(raw);return raw
def test_exact_gamescope_boundary_and_unmodified_proton_child(self):
separator=self.command.index('--')
self.assertEqual(self.wrapped[:separator+1],self.command[:separator+1])
self.assertEqual(self.wrapped[-len(self.context['command']):],self.command[separator+1:])
self.assertEqual(self.wrapped[separator+1],sys.executable)
self.assertEqual(self.wrapped[separator+2],str(self.root/'tools/headless_display_probe.py'))
for flag,value in (('--report',self.context['report']),('--host-display',':0'),('--prefix',str(self.compat/'pfx'))):
self.assertEqual(self.wrapped[self.wrapped.index(flag)+1],value)
self.assertEqual(self.command[separator+1],str(self.proton)) # caller array untouched
def test_default_command_has_no_wrapper_and_remains_ordinary(self):
command=runner.command_for(runner.ROOT,self.steam,self.compat,self.profile,'core')
separator=command.index('--')
self.assertEqual(command[separator+1:separator+4],[str(self.proton),'run',str(self.exe)])
self.assertNotIn('--host-display',command);self.assertNotIn('--query-worker',command)
self.assertIn('-noSound',command);self.assertIn('-noBackend',command)
def test_bad_boundary_missing_helper_and_existing_report_refused(self):
wrong=self.command.copy();wrong[2]='wayland'
with self.assertRaises(ValueError):runner.display_probe_command(wrong,self.root,self.compat,self.profile,self.exe,'fixed-run-id',':0')
self.save()
with self.assertRaises(ValueError):runner.display_probe_command(self.command,self.root,self.compat,self.profile,self.exe,'fixed-run-id',':0')
Path(self.context['report']).unlink()
helper=self.root/'tools/headless_display_probe.py';helper.unlink();helper.symlink_to(self.base/'absent')
with self.assertRaises(ValueError):runner.display_probe_command(self.command,self.root,self.compat,self.profile,self.exe,'fixed-run-id',':0')
def test_packed_and_vanilla_reject_before_resolution_or_launch(self):
for project,packed in [('core',{'session':self.base/'packed'}),(runner.CONTROL,None)]:
args=SimpleNamespace(project=project,inspect_headless_display=True)
with patch.object(runner,'packed_request',return_value=packed),patch.object(runner.subprocess,'Popen') as spawn,patch.object(runner,'assert_no_game') as process_check:
with self.assertRaisesRegex(ValueError,'loose Core or Chat'):runner.run(args)
spawn.assert_not_called();process_check.assert_not_called()
def test_exact_bytes_archived_with_hash_without_promoting_gameplay(self):
raw=self.save();result=runner.display_probe_result(self.context,self.session)
self.assertTrue(result['verified']);self.assertTrue(result['read_only']);self.assertFalse(result['input_sent'])
self.assertEqual((self.session/'display-ownership.json').read_bytes(),raw)
self.assertEqual(result['sha256'],hashlib.sha256(raw).hexdigest())
self.assertEqual(result['archive_file'],'display-ownership.json')
self.assertFalse(runner.display_probe_result(self.context,self.session)['verified']) # exclusive archive copy
self.assertEqual((self.session/'display-ownership.json').read_bytes(),raw)
def test_each_launch_binding_mismatch_fails(self):
for key in ('helper_sha256','prefix','profile','game_executable','report','command'):
altered=copy.deepcopy(self.report);altered[key]=['foreign'] if key=='command' else 'foreign'
self.save(altered)
with self.subTest(key=key):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
def test_absent_malformed_unverified_and_input_claims_fail(self):
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
for value in ([],None,{}, {**self.report,'owned_game_window_verified':False}, {**self.report,'read_only':False},
{**self.report,'input_sent':True}, {**self.report,'observation_finished':False}, {**self.report,'schema':2}):
self.save(value);self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
Path(self.context['report']).write_text('{truncated')
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
Path(self.context['report']).unlink();Path(self.context['report']).symlink_to(self.base/'absent')
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
def test_host_display_uid_pid_start_or_window_disagreement_fails(self):
faults=[]
for target in ('wrapper','gamescope','xwayland','window'):
for key,value in [('uid',os.getuid()+1),('pid',0),('start',0),('pid',True)]:
bad=copy.deepcopy(self.report)
item=(bad['wrapper'] if target=='wrapper' else bad['owned_windows'][0]['process'] if target=='window' else bad['binding'][target])
item[key]=value;faults.append(bad)
for display in (':0',':0.1','unix:0','remote:2',''):
bad=copy.deepcopy(self.report);bad['binding']['display']=display;faults.append(bad)
for key,value in [('pid',41),('server_pid',41),('window',0),('window',None),('window',True)]:
bad=copy.deepcopy(self.report);bad['owned_windows'][0][key]=value;faults.append(bad)
bad=copy.deepcopy(self.report);bad['owned_windows']=[];faults.append(bad)
for bad in faults:
self.save(bad)
with self.subTest(report=bad):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
def test_missing_foreign_malformed_duplicate_socket_evidence_fails(self):
for sockets in (None,[],[{'path':'@/tmp/.X11-unix/X0','inode':1000}],
[{'path':'@/tmp/.X11-unix/X2','inode':0}], [{'path':'@/tmp/.X11-unix/X2','inode':True}],
[{'path':'@/tmp/.X11-unix/X2','inode':1000}]*2):
bad=copy.deepcopy(self.report);bad['binding']['sockets']=sockets;self.save(bad)
with self.subTest(sockets=sockets):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
def test_missing_unknown_or_unproven_relationship_refused(self):
for relationship in (None,{}, {'mode':'unknown'}, {'mode':'descendant'},
{'mode':'descendant','descendant':False},{'mode':'descendant','descendant':1},
{'mode':'shared-listeners'}, {'mode':'shared-listeners','checks':{}},'descendant'):
bad=copy.deepcopy(self.report);bad['binding']['relationship']=relationship;self.save(bad)
with self.subTest(relationship=relationship):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
bad=copy.deepcopy(self.report);del bad['binding']['relationship'];self.save(bad)
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
def test_shared_relationship_requires_every_check_and_exact_socket_inodes(self):
checks={key:True for key in helper.SHARED_CHECKS};checks['listener_inodes']=[1000]
good=copy.deepcopy(self.report);good['binding']['relationship']={'mode':'shared-listeners','checks':checks}
self.save(good);self.assertTrue(runner.display_probe_result(self.context,None)['verified'])
for key in helper.SHARED_CHECKS:
for value in (False,None,1):
bad=copy.deepcopy(good);bad['binding']['relationship']['checks'][key]=value;self.save(bad)
with self.subTest(key=key,value=value):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
bad=copy.deepcopy(good);del bad['binding']['relationship']['checks'][key];self.save(bad)
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
for inodes in (None,[],[1001],[True],[1000,1000],[1000,1001]):
bad=copy.deepcopy(good);bad['binding']['relationship']['checks']['listener_inodes']=inodes;self.save(bad)
with self.subTest(inodes=inodes):self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
bad=copy.deepcopy(good);bad['binding']['xwayland']['start']=99;self.save(bad)
self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
good['binding']['sockets'].append({'path':'/tmp/.X11-unix/X2','inode':1001})
good['binding']['relationship']['checks']['listener_inodes']=[1000,1001]
self.save(good);self.assertTrue(runner.display_probe_result(self.context,None)['verified'])
def test_boolean_server_pid_is_not_an_integer_process_identity(self):
bad=copy.deepcopy(self.report);window=bad['owned_windows'][0]
window['pid']=1;window['process']['pid']=1;window['server_pid']=True
self.save(bad);self.assertFalse(runner.display_probe_result(self.context,None)['verified'])
def simulate_run_locked(self,display_ok,ordinary_failed=False):
(self.exe.parent/'addons/data').mkdir(parents=True,exist_ok=True)
args=SimpleNamespace(project='core',capture_prison_view=False,inspect_headless_display=True,timeout=120,settle=15,failure_grace=0)
session=self.profile/'logs/logs_fixture'
child=Mock();child.poll.return_value=None
def spawn(*args,**kwargs):
session.mkdir(parents=True);(session/'console.log').write_text('synthetic ordinary startup fixture')
return child
def output(args,**kwargs):return '[]' if '-f' in args else '11'
monitor=SimpleNamespace(deadline=time.monotonic()+60,started=0,first_failure_at=None,
observe=lambda *args:'game-started',annotate=lambda value:value)
evidence={'ready':not ordinary_failed,'failed':ordinary_failed,'scope':'synthetic test only'}
with contextlib.ExitStack() as stack:
for name in ('assert_no_game','assert_prefix_idle','persist','cleanup'):
stack.enter_context(patch.object(runner,name))
stack.enter_context(patch.object(runner,'snapshot',return_value={}))
stack.enter_context(patch.object(runner,'bindings',return_value={}))
stack.enter_context(patch.object(runner,'seal_run',return_value={'source_unchanged_after_run':True}))
stack.enter_context(patch.object(runner,'StartupMonitor',return_value=monitor))
stack.enter_context(patch.object(runner,'game_evidence',return_value=evidence))
stack.enter_context(patch.object(runner,'display_probe_result',return_value={'verified':display_ok}))
stack.enter_context(patch.object(runner.shutil,'which',return_value='/usr/bin/synthetic'))
stack.enter_context(patch.object(runner.subprocess,'Popen',side_effect=spawn))
stack.enter_context(patch.object(runner.subprocess,'check_output',side_effect=output))
stack.enter_context(patch.object(runner.subprocess,'run'))
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
status=runner.run_locked(args,self.steam,self.compat,self.profile)
return status,json.loads((session/'runner.json').read_text())
def test_missing_display_proof_turns_actual_runner_path_into_failure(self):
status,report=self.simulate_run_locked(False)
self.assertEqual(status,1);self.assertEqual(report['result'],'display-probe-incomplete')
self.assertEqual(report['startup_result_before_display_check'],'game-started')
def test_valid_display_does_not_clear_native_failure(self):
status,report=self.simulate_run_locked(True,ordinary_failed=True)
self.assertEqual(status,1);self.assertEqual(report['result'],'game-failed')
def test_valid_ownership_preserves_clean_startup_only(self):
status,report=self.simulate_run_locked(True)
self.assertEqual(status,0);self.assertEqual(report['result'],'game-started')
self.assertTrue(report['display_ownership']['verified'])
self.assertNotIn('multiplayer_verified',report)
if __name__=='__main__':unittest.main()
+22
View File
@@ -60,6 +60,28 @@ class GameRunnerTests(unittest.TestCase):
runner.assert_prefix_idle(compat, root)
runner.assert_prefix_idle(root/'different', root)
def test_proton_trailing_prefix_slashes_refuse_existing_workbench_and_wine(self):
for command in (b'wineserver\0', b'Z:/Workbench/ArmaReforgerWorkbenchSteamDiag.exe\0'):
for suffix in ('','/','////'):
with self.subTest(command=command,suffix=suffix),tempfile.TemporaryDirectory() as directory:
root=Path(directory);compat=root/'compat';proc=self.process(root,command)
data=('WINEPREFIX='+str(compat/'pfx')+suffix).encode()+b'\0OTHER=unchanged\0'
(proc/'environ').write_bytes(data)
with self.assertRaisesRegex(ValueError,'prefix already has a process'):
runner.assert_prefix_idle(compat,root)
self.assertEqual((proc/'environ').read_bytes(),data)
def test_prefix_idle_does_not_resolve_neighbours_dot_components_or_symlink_spellings(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);compat=root/'compat';prefix=compat/'pfx';prefix.mkdir(parents=True)
alias=root/'alias';alias.symlink_to(prefix,target_is_directory=True)
proc=self.process(root,b'wineserver\0')
for value in (str(prefix)+'-other/',str(prefix)+'/./',str(prefix)+'/../pfx/',str(alias)+'/',
str(prefix).replace('/pfx','//pfx'),str(prefix)+'\\','pfx/',str(prefix)+' /'):
data=('WINEPREFIX='+value).encode()+b'\0';(proc/'environ').write_bytes(data)
with self.subTest(value=value):runner.assert_prefix_idle(compat,root)
self.assertEqual((proc/'environ').read_bytes(),data)
(proc/'environ').write_bytes(('OTHER_WINEPREFIX='+str(prefix)+'/').encode()+b'\0')
runner.assert_prefix_idle(compat,root)
def test_nondumpable_native_service_does_not_require_environment(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
+97
View File
@@ -0,0 +1,97 @@
"""Execute both production monitor bodies; native hint/camera boundaries are fakes."""
from policy_harness import ROOT, body, run
GUIDE=ROOT/'addons/core/Scripts/Game/ERR/ERR_PlayerGuide.c'
OBSERVER=ROOT/'addons/core/Scripts/Game/ERR/ERR_Observer.c'
def cpp(text):
text=text.replace('protected ', '')
for name in ('ERR_GameMode','ERR_Phase','ERR_ObserverReason','ERR_Actors','ERR_ObjectiveText','SCR_HintManagerComponent','ERR_ObserverGuidance','Resource','ETransformMode','SCR_ManualCamera','SCR_RespawnSystemComponent','RplId'):
text=text.replace(name+'.',name+'::')
for typename in ('ERR_GameMode','SCR_HintManagerComponent','Resource','SCR_RespawnSystemComponent','CameraManager'):
text=text.replace(typename+' ',typename+'* ')
for variable in ('mode','hints','resource','respawn','manager'):
text=text.replace(variable+'.',variable+'->')
text=text.replace('GetGame().','GetGame()->').replace('EntitySpawnParams params = new EntitySpawnParams();','EntitySpawnParams params;')
return text
methods=cpp(body(GUIDE,'protected void ERR_UpdateGuide('))+'\n'+cpp(body(OBSERVER,'protected void ERR_UpdateObserverClient('))
guidance=cpp(body(OBSERVER,'static string Message('))+'\n'+cpp(body(OBSERVER,'static bool Refresh('))
run(r'''#include <cassert>
#include <string>
struct string:std::string {using std::string::string;using std::string::operator=;string(std::string s):std::string(s){}bool IsEmpty()const{return empty();}};
namespace ERR_Phase {enum {PREPARING,PRISON,RADIO_SEARCH,RADIO_DEFENCE,EXTRACTION};}
namespace ERR_ObserverReason {enum {NONE,SPENT_LIFE,LATE_EXTRACTION,SPAWN_UNAVAILABLE};}
struct Actor {bool alive=true;bool unconscious=false;} actor;
struct ERR_Actors {static bool Alive(Actor* a){return a&&a->alive;}};
struct ERR_GameMode {int phase=ERR_Phase::PRISON;bool active=true;string message="prison";static ERR_GameMode* current;static ERR_GameMode* Get(){return current;}int Phase(){return phase;}bool Active(){return active;}} mode;
ERR_GameMode* ERR_GameMode::current=&mode;
struct ERR_ObjectiveText {static string Description(ERR_GameMode* m,bool){return m->message;}static string Title(int){return "objective";}};
struct SCR_HintManagerComponent {static SCR_HintManagerComponent* current;bool accepts=true;int attempts=0,shown=0;string last;static SCR_HintManagerComponent* GetInstance(){return current;}bool ShowCustom(string text,string,int){attempts++;if(!accepts)return false;shown++;last=text;return true;}} hints;
SCR_HintManagerComponent* SCR_HintManagerComponent::current=&hints;
struct ERR_ObserverGuidance {
'''+guidance+r'''
};
struct Vec {Vec operator+(const char*)const{return {};}};
struct EntitySpawnParams {int TransformMode=0;Vec Transform[4];};
namespace ETransformMode {enum {WORLD};}
struct Resource {bool valid=true;static Resource* current;static Resource* Load(const char*){return current;}bool IsValid(){return valid;}} resource;
Resource* Resource::current=&resource;
struct SCR_ManualCamera {static SCR_ManualCamera* Cast(SCR_ManualCamera* c){return c;}} camera;
struct CameraManager {SCR_ManualCamera* current=&camera;SCR_ManualCamera* CurrentCamera(){return current;}} manager;
struct SCR_RespawnSystemComponent {int destroys=0;static SCR_RespawnSystemComponent* current;static SCR_RespawnSystemComponent* GetInstance(){return current;}void DestroyLoadingPlaceholder(){destroys++;}} respawn;
SCR_RespawnSystemComponent* SCR_RespawnSystemComponent::current=&respawn;
struct RplId {static int Invalid(){return -1;}};
struct Game {int spawns=0;SCR_ManualCamera* SpawnEntityPrefab(Resource*,int,EntitySpawnParams){spawns++;return &camera;}int GetWorld(){return 1;}CameraManager* GetCameraManager(){return &manager;}} game;
Game* GetGame(){return &game;}
struct Controller {
int m_ERRGuidePhase=-1;string m_ERRGuideMessage;
Actor* controlled=&actor;int m_ERRObserverReason=ERR_ObserverReason::NONE;
SCR_ManualCamera* m_ERRObserverCamera=nullptr;Vec m_ERRObserverAnchor;
int m_ERRObservedId=1;bool m_ERRObserverReady=false;
int m_ERRShownObserverReason=0;bool m_ERRShownObserverAvailable=false;int closes=0;
Actor* GetControlledEntity(){return controlled;}int ERR_ObserverState(){return m_ERRObserverReason;}
void ERR_CloseObserverCamera(){closes++;m_ERRObserverReady=false;m_ERRObserverCamera=nullptr;}
'''+methods+r'''
};
void reset(){mode=ERR_GameMode();ERR_GameMode::current=&mode;hints=SCR_HintManagerComponent();SCR_HintManagerComponent::current=&hints;actor=Actor();Resource::current=&resource;manager.current=&camera;}
int main(){
reset();Controller g;
SCR_HintManagerComponent::current=nullptr;g.ERR_UpdateGuide();assert(g.m_ERRGuidePhase==-1&&hints.attempts==0);
SCR_HintManagerComponent::current=&hints;hints.accepts=false;
g.ERR_UpdateGuide();g.ERR_UpdateGuide();assert(hints.attempts==2&&hints.shown==0&&g.m_ERRGuidePhase==-1&&g.m_ERRGuideMessage.empty());
hints.accepts=true;g.ERR_UpdateGuide();assert(hints.shown==1&&g.m_ERRGuidePhase==mode.phase&&g.m_ERRGuideMessage==mode.message);
g.ERR_UpdateGuide();assert(hints.attempts==3);
mode.message="";g.ERR_UpdateGuide();assert(hints.attempts==3&&g.m_ERRGuideMessage=="prison");
// Same-phase content updates are retryable and do not erase the delivered cache.
mode.message="transmission interrupted";hints.accepts=false;g.ERR_UpdateGuide();assert(g.m_ERRGuideMessage=="prison");
hints.accepts=true;g.ERR_UpdateGuide();assert(hints.shown==2&&g.m_ERRGuideMessage==mode.message);
mode.phase=ERR_Phase::RADIO_SEARCH;g.ERR_UpdateGuide();assert(hints.shown==3);
// Explicit observation, death and absence suppress ordinary phase instructions.
for(int excluded=0;excluded<3;excluded++){
Controller c;if(excluded==0)c.m_ERRObserverReason=ERR_ObserverReason::SPENT_LIFE;
if(excluded==1){actor.alive=false;}if(excluded==2){c.controlled=nullptr;}
int attempts=hints.attempts;c.ERR_UpdateGuide();assert(hints.attempts==attempts&&c.m_ERRGuidePhase==-1);
actor.alive=true;c.controlled=&actor;c.m_ERRObserverReason=ERR_ObserverReason::NONE;c.ERR_UpdateGuide();assert(hints.attempts==attempts+1);
}
// Unconscious living prisoners retain recovery guidance. Preparing needs no body.
Controller unconscious;actor.unconscious=true;unconscious.ERR_UpdateGuide();assert(unconscious.m_ERRGuidePhase==mode.phase);actor.unconscious=false;
Controller preparing;preparing.controlled=nullptr;mode.phase=ERR_Phase::PREPARING;mode.message="preparing";preparing.ERR_UpdateGuide();assert(preparing.m_ERRGuideMessage=="preparing");
ERR_GameMode::current=nullptr;int attempts=hints.attempts;preparing.ERR_UpdateGuide();assert(hints.attempts==attempts);
reset();Controller s;s.controlled=nullptr;s.m_ERRObserverReason=ERR_ObserverReason::SPENT_LIFE;
// Actual camera setup executes; missing manager cannot consume delivery state.
SCR_HintManagerComponent::current=nullptr;s.ERR_UpdateObserverClient();assert(s.m_ERRObserverCamera==&camera&&!s.m_ERRObserverReady);
SCR_HintManagerComponent::current=&hints;hints.accepts=false;s.ERR_UpdateObserverClient();s.ERR_UpdateObserverClient();assert(hints.attempts==2&&!s.m_ERRObserverReady&&s.m_ERRShownObserverReason==0);
hints.accepts=true;s.ERR_UpdateObserverClient();assert(hints.shown==1&&s.m_ERRObserverReady&&s.m_ERRShownObserverReason==ERR_ObserverReason::SPENT_LIFE&&s.m_ERRShownObserverAvailable);
s.ERR_UpdateObserverClient();assert(hints.attempts==3);
// Reason changes and a genuinely empty survivor set retry independently.
s.m_ERRObserverReason=ERR_ObserverReason::LATE_EXTRACTION;hints.accepts=false;s.ERR_UpdateObserverClient();assert(s.m_ERRShownObserverReason==ERR_ObserverReason::SPENT_LIFE);
hints.accepts=true;s.ERR_UpdateObserverClient();assert(s.m_ERRShownObserverReason==ERR_ObserverReason::LATE_EXTRACTION&&hints.shown==2);
s.m_ERRObservedId=RplId::Invalid();hints.accepts=false;s.ERR_UpdateObserverClient();assert(s.m_ERRShownObserverAvailable);
hints.accepts=true;s.ERR_UpdateObserverClient();assert(!s.m_ERRShownObserverAvailable&&hints.last.find("No survivor")!=string::npos);
s.ERR_UpdateObserverClient();assert(hints.shown==3);
// A living body closes observation without attempting a spectator hint.
s.controlled=&actor;attempts=hints.attempts;s.ERR_UpdateObserverClient();assert(s.closes==1&&!s.m_ERRObserverReady&&hints.attempts==attempts);
s.controlled=nullptr;s.ERR_UpdateObserverClient();assert(hints.shown==4&&s.m_ERRObserverReady);
}
''')
for path,signature in ((GUIDE,'protected void ERR_UpdateGuide('),(OBSERVER,'protected void ERR_UpdateObserverClient(')):
assert 'CallLater' not in body(path,signature), 'Delivery retry uses the existing monitor only'
print('PASS: actual guide/observer monitors retry native refusal, cache successful delivery, deduplicate and preserve living/preparing/spectator boundaries; native UI not exercised')
+122
View File
@@ -0,0 +1,122 @@
"""Execute observer policies/lifecycle against native read-boundary adapters, not a game."""
from policy_harness import ROOT, body, run
P=ROOT/'addons/core/Scripts/Game/ERR/ERR_OrdinaryInputObserver.c'
def cpp(s):
s=s.replace('protected ', '').replace('out string nonce','string& nonce')
for t in ('ERR_GameMode','PlayerController','IEntity','ERR_PlayerLife','ERR_OrdinaryInputObserver','PlayerManager','SCR_InventoryMenuUI','Widget'):
s=s.replace(t+' mode',t+'* mode').replace(t+' controller',t+'* controller').replace(t+' actor',t+'* actor').replace(t+' life',t+'* life').replace(t+' request',t+'* request').replace(t+' current',t+'* current').replace(t+' manager',t+'* manager').replace(t+' menu',t+'* menu').replace(t+' root',t+'* root')
for v in ('mode','controller','actor','request','life','current','manager','menu','root','m_Mode','m_World','m_Controller','m_Actor','m_Character','m_Menu','s_Request'):
s=s.replace(v+'.',v+'->')
for scope in ('System','Replication','ERR_Phase','ERR_GameMode','ERR_Actors','SCR_InventoryMenuUI','ERR_OrdinaryInputObserverRules','vector','Math'):
s=s.replace(scope+'.',scope+'::')
s=s.replace('GetGame().','GetGame()->').replace('GetPlayerManager().','GetPlayerManager()->').replace('GetInputManager().','GetInputManager()->').replace('GetMenuManager().','GetMenuManager()->').replace('GetCallqueue().','GetCallqueue()->').replace('Players().','Players()->')
s=s.replace('CharacterControllerComponent.Cast(actor->FindComponent(CharacterControllerComponent))','actor->FindComponent(0)').replace('FindComponent(CharacterControllerComponent)','FindComponent(0)')
s=s.replace('.Insert(request->Cancel)', '.Insert()').replace('.Remove(Cancel)', '.Remove()')
s=s.replace('.CallLater(request->Sample, 100, false)', '.CallLater()').replace('->CallLater(request->Sample, 100, false)', '->CallLater()').replace('->CallLater(Sample, 100, false)', '->CallLater()').replace('->Remove(Sample)', '->Remove()')
s=s.replace('null','nullptr').replace('bool open;', 'bool open = false;')
s=s.replace('nonce.Length()', 'int(nonce.size())').replace('allowed.Contains(nonce[i])', '(allowed.find(nonce[i]) != string::npos)')
return s
rules='\n'.join(cpp(body(P,x)) for x in ('static bool Nonce(', 'static bool Within(', 'static bool Stationary('))
methods='\n'.join(cpp(body(P,x)) for x in ('protected static bool Enabled(', 'static void Queue(', 'protected bool Valid(', 'protected void Record(', 'protected void Capture(', 'protected void Observe(', 'protected void Sample(', 'protected void End(', 'void Cancel(', 'static void ClearWorld('))
run(r'''
#include <cassert>
#include <cmath>
#include <string>
#include <vector>
#include <map>
using string=std::string;using UUID=string;
struct vector {float x=0,y=0,z=0;float Length(){return std::sqrt(x*x+y*y+z*z);}static float Distance(vector a,vector b){return vector{a.x-b.x,a.y-b.y,a.z-b.z}.Length();}};
namespace Math {float AbsFloat(float x){return std::abs(x);}}
namespace System {bool console=false;int now=0;std::map<string,string> cli;std::vector<string> shots;bool IsConsoleApp(){return console;}int GetTickCount(){return now;}bool GetCLIParam(string key,string& value){if(!cli.count(key))return false;value=cli[key];return true;}void MakeScreenshot(string path){shots.push_back(path);}}
namespace Replication {bool server=true;bool IsServer(){return server;}}
namespace ERR_Phase {enum {PRISON,PREPARING,DEFEAT};}
struct BaseWorld {float time=0;float GetWorldTime(){return time;}};
struct CharacterControllerComponent {bool unconscious=false,disabled=false;vector input,velocity;bool IsUnconscious(){return unconscious;}bool GetDisableMovementControls(){return disabled;}vector GetMovementInput(){return input;}vector GetVelocity(){return velocity;}};
struct IEntity {BaseWorld* world=nullptr;CharacterControllerComponent* component=nullptr;bool conscious=true;vector origin;BaseWorld* GetWorld(){return world;}CharacterControllerComponent* FindComponent(int){return component;}vector GetOrigin(){return origin;}};
namespace ERR_Actors {bool CanFight(IEntity* actor){return actor&&actor->conscious;}}
struct PlayerController {int id=1;IEntity* actor=nullptr;int GetPlayerId(){return id;}IEntity* GetControlledEntity(){return actor;}};
struct ERR_PlayerLife {UUID AccountId="private-account";};
struct Roster {IEntity* actor=nullptr;ERR_PlayerLife* life=nullptr;ERR_PlayerLife* CurrentActor(IEntity* a){return a==actor?life:nullptr;}};
struct Invoker {int inserted=0,removed=0;void Insert(){inserted++;}void Remove(){removed++;}};
struct ERR_GameMode {static ERR_GameMode* current;BaseWorld* world=nullptr;bool active=true;int phase=ERR_Phase::PRISON;Roster roster;Invoker stop;static ERR_GameMode* Get(){return current;}BaseWorld* GetWorld(){return world;}bool Active(){return active;}int Phase(){return phase;}Roster* Players(){return &roster;}Invoker& OnStop(){return stop;}};ERR_GameMode* ERR_GameMode::current=nullptr;
struct PlayerManager {PlayerController* controller=nullptr;IEntity* actor=nullptr;PlayerController* GetPlayerController(int){return controller;}IEntity* GetPlayerControlledEntity(int){return actor;}};
struct Widget {bool visible=true;bool IsVisible(){return visible;}};
struct SCR_InventoryMenuUI {static SCR_InventoryMenuUI* current;CharacterControllerComponent* character=nullptr;Widget* root=nullptr;static SCR_InventoryMenuUI* GetInventoryMenu(){return current;}CharacterControllerComponent* GetCharacterController(){return character;}Widget* GetRootWidget(){return root;}};SCR_InventoryMenuUI* SCR_InventoryMenuUI::current=nullptr;
struct MenuManager {SCR_InventoryMenuUI* top=nullptr;SCR_InventoryMenuUI* GetTopMenu(){return top;}};
struct InputManager {float forward=0;float GetActionValue(string name){assert(name=="CharacterForward");return forward;}};
struct Callqueue {int queued=0,removed=0;void CallLater(){queued++;}void Remove(){removed++;}};
struct Game {BaseWorld* world=nullptr;PlayerController* controller=nullptr;PlayerManager manager;MenuManager menus;InputManager input;Callqueue queue;BaseWorld* GetWorld(){return world;}PlayerController* GetPlayerController(){return controller;}PlayerManager* GetPlayerManager(){return &manager;}MenuManager* GetMenuManager(){return &menus;}InputManager* GetInputManager(){return &input;}Callqueue* GetCallqueue(){return &queue;}} game;Game* GetGame(){return &game;}
std::vector<string> events;int samples=0;
void PrintFormat(const char*,string nonce,int,int,float,string detail){assert(nonce=="Run42");events.push_back(detail);}
void PrintFormat(const char*,string nonce,int,int,float,vector,float,vector,vector,bool){assert(nonce=="Run42");samples++;}
struct ERR_OrdinaryInputObserverRules {
'''+rules+r'''
};
struct ERR_OrdinaryInputObserver {
static ERR_OrdinaryInputObserver* s_Request;static bool s_Attempted;
ERR_GameMode* m_Mode=nullptr;BaseWorld* m_World=nullptr;PlayerController* m_Controller=nullptr;IEntity* m_Actor=nullptr;CharacterControllerComponent* m_Character=nullptr;ERR_PlayerLife* m_Life=nullptr;
UUID m_Account;int m_PlayerId=0;string m_Nonce;bool m_Screenshots=false,m_Finished=false,m_Ready=false,m_Opened=false,m_Closed=false;SCR_InventoryMenuUI* m_Menu=nullptr;
int m_Start=0,m_Previous=0,m_Samples=0,m_BaselineSince=-1;vector m_BaselineOrigin;float m_LastWorldTime=0;
'''+methods+r'''
};
ERR_OrdinaryInputObserver* ERR_OrdinaryInputObserver::s_Request=nullptr;bool ERR_OrdinaryInputObserver::s_Attempted=false;
struct Fixture {ERR_GameMode mode;BaseWorld world;PlayerController controller;IEntity actor;CharacterControllerComponent character;ERR_PlayerLife life;Widget widget;SCR_InventoryMenuUI menu;
Fixture(){game={};System::cli={{"errOrdinaryScenario","core"},{"errOrdinaryInputObserve","Run42"},{"errOrdinaryInputScreenshots","1"}};System::now=0;System::console=false;System::shots.clear();Replication::server=true;events.clear();samples=0;ERR_OrdinaryInputObserver::s_Request=nullptr;ERR_OrdinaryInputObserver::s_Attempted=false;SCR_InventoryMenuUI::current=nullptr;ERR_GameMode::current=&mode;mode.world=&world;mode.roster.actor=&actor;mode.roster.life=&life;actor.world=&world;actor.component=&character;controller.actor=&actor;game.world=&world;game.controller=&controller;game.manager.controller=&controller;game.manager.actor=&actor;menu.character=&character;menu.root=&widget;}
ERR_OrdinaryInputObserver* queue(){ERR_OrdinaryInputObserver::Queue(&mode,&controller,&actor);return ERR_OrdinaryInputObserver::s_Request;}
void sample(int ms){System::now=ms;world.time=ms;ERR_OrdinaryInputObserver::s_Request->Sample();}
void baseline(){for(int t=100;t<=2100;t+=100)sample(t);assert(ERR_OrdinaryInputObserver::s_Request->m_Ready);}
void open(){SCR_InventoryMenuUI::current=&menu;game.menus.top=&menu;}
void close(){SCR_InventoryMenuUI::current=nullptr;game.menus.top=nullptr;}
};
int count(string value){int n=0;for(auto event:events)if(event==value)n++;return n;}
int main(){
assert(ERR_OrdinaryInputObserverRules::Nonce("aZ09"));for(string n:std::vector<string>{"","../bad","a-b","a_b","line\n","a b","$logs:x",string(33,'x')})assert(!ERR_OrdinaryInputObserverRules::Nonce(n));
assert(ERR_OrdinaryInputObserverRules::Within(0,44900,44999,449));assert(!ERR_OrdinaryInputObserverRules::Within(0,44900,45000,449));assert(!ERR_OrdinaryInputObserverRules::Within(0,100,99,2));assert(!ERR_OrdinaryInputObserverRules::Within(0,1,2,450));
for(int fault=0;fault<6;fault++){Fixture f;
if(fault==0)System::cli.erase("errOrdinaryInputObserve");
if(fault==1)System::cli["errOrdinaryInputObserve"]="../bad";
if(fault==2)System::cli["errOrdinaryScenario"]="other";
if(fault==3)System::console=true;
if(fault==4)Replication::server=false;
if(fault==5)f.mode.roster.life=nullptr;
assert(!f.queue()&&game.queue.queued==0&&!ERR_OrdinaryInputObserver::s_Attempted);}
for(int fault=0;fault<14;fault++){Fixture f;auto p=f.queue();assert(p&&p->Valid());BaseWorld otherWorld;PlayerController otherController;IEntity otherActor;CharacterControllerComponent otherCharacter;ERR_PlayerLife otherLife;ERR_GameMode otherMode;
if(fault==0)ERR_GameMode::current=&otherMode;
if(fault==1)game.world=&otherWorld;
if(fault==2)f.mode.active=false;
if(fault==3)f.mode.phase=ERR_Phase::DEFEAT;
if(fault==4)game.controller=&otherController;
if(fault==5)f.controller.id=2;
if(fault==6)game.manager.controller=&otherController;
if(fault==7)game.manager.actor=&otherActor;
if(fault==8)f.controller.actor=&otherActor;
if(fault==9)f.actor.conscious=false;
if(fault==10)f.actor.component=&otherCharacter;
if(fault==11)f.character.unconscious=true;
if(fault==12)f.mode.roster.life=&otherLife;
if(fault==13)f.life.AccountId="changed";
assert(!p->Valid());f.sample(100);assert(p->m_Finished&&samples==0&&game.queue.removed==1&&f.mode.stop.removed==1);p->Cancel();assert(game.queue.removed==1);}
{Fixture f;auto p=f.queue();f.baseline();assert(count("READY")==1&&System::shots.size()==1);game.input.forward=1;f.character.input={0,0,1};f.actor.origin={0,0,1};f.sample(2200);assert(samples==22&&!p->m_Opened);game.input.forward=0;f.character.input={};f.character.disabled=true;f.open();f.sample(2300);assert(p->m_Opened&&count("INVENTORY_OPEN")==1&&System::shots.size()==2);f.sample(2400);assert(System::shots.size()==2);f.close();f.sample(2500);assert(p->m_Closed&&count("INVENTORY_CLOSED")==1&&System::shots.size()==3);f.sample(2600);assert(System::shots.size()==3);for(auto path:System::shots)assert(path.find("$logs:err-input-Run42-")==0);p->Cancel();int writes=samples;f.sample(2700);assert(samples==writes);ERR_OrdinaryInputObserver::ClearWorld();assert(ERR_OrdinaryInputObserver::s_Attempted&&!ERR_OrdinaryInputObserver::s_Request);assert(!f.queue());}
{Fixture f;auto p=f.queue();System::cli["errOrdinaryInputScreenshots"]="0";p->m_Screenshots=false;f.open();for(int t=100;t<2300;t+=100)f.sample(t);assert(!p->m_Ready);f.close();for(int t=2300;t<=4300;t+=100)f.sample(t);assert(p->m_Ready&&System::shots.empty());}
for(int fault=0;fault<4;fault++){Fixture f;auto p=f.queue();f.baseline();f.open();
if(fault==0)f.menu.character=nullptr;
if(fault==1)f.menu.root=nullptr;
if(fault==2)f.widget.visible=false;
if(fault==3)game.menus.top=nullptr;
f.sample(2200);assert(p->m_Finished&&!p->m_Opened&&System::shots.size()==1);}
{Fixture f;auto p=f.queue();f.baseline();f.open();f.sample(2200);SCR_InventoryMenuUI replacement=f.menu;SCR_InventoryMenuUI::current=&replacement;game.menus.top=&replacement;f.sample(2300);assert(p->m_Finished&&!p->m_Closed);}
{Fixture f;auto p=f.queue();f.sample(100);f.sample(200);f.sample(1000);assert(p->m_BaselineSince==-1);f.sample(1100);assert(!p->m_Ready);f.sample(45000);assert(p->m_Finished&&count("FINISHED observation-bound")==1);}
{Fixture f;auto p=f.queue();for(int t=1;t<=450;t++)f.sample(t);assert(p->m_Finished&&samples==450&&count("FINISHED sample-bound")==1);}
{Fixture f;auto p=f.queue();f.sample(100);System::now=200;f.world.time=99;p->Sample();assert(p->m_Finished&&count("CANCELLED world-clock-reversed")==1);}
{Fixture f;auto p=f.queue();f.sample(100);System::now=99;f.world.time=101;p->Sample();assert(p->m_Finished);}
}
''')
s=P.read_text()
for forbidden in ('SetActionValue(', 'ActivateContext(', 'OpenMenu(', 'CloseMenu(', 'OpenInventory(', 'CloseInventory(', 'SetCamera(', 'SetOrigin(', 'SetWorldTransform(', 'SetVelocity(', 'SetHealth(', 'SetUnconscious(', 'SetInvulnerable(', 'SpawnEntity'):
assert forbidden not in s,forbidden
assert s.startswith('#ifndef WORKBENCH')
assert 's_Attempted = false' not in body(P,'static void ClearWorld(')
assert s.count('System.MakeScreenshot(')==1
for line in s.splitlines():
if 'PrintFormat(' in line:assert all(x not in line for x in ('m_Account','m_PlayerId','m_Life','m_Controller','m_Actor'))
print('PASS: actual observer nonce, admission pins, baseline/menu sequencing, snapshots, bounded clocks/samples, cleanup and one-attempt latch; native untested')
+132
View File
@@ -0,0 +1,132 @@
"""Ordinary observer runner tests: fake process boundaries; never launch a game."""
import contextlib
import io
import json
from pathlib import Path
import sys
import tempfile
import time
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
import headless_game as runner
NONCE='a1'*16
def records(nonce=NONCE, menus=False):
def line(index,real,event):
return f'SCRIPT : ERR INPUT OBSERVE: run={nonce} sample={index} real={1000+real} world={5000+real} {event}\n'
result=line(0,0,'QUEUED')
for i in range(1,450):
opened=menus and 22<=i<=24
result+=line(i,i*100,f'SAMPLE origin=<1, 2, 3> forward=0 input=<0, 0, 0> velocity=<0, 0, 0> inventory={int(opened)}')
if i==21:result+=line(i,i*100,'READY')+line(i,i*100,'CAPTURE_REQUEST baseline')
if menus and i==22:result+=line(i,i*100,'INVENTORY_OPEN')+line(i,i*100,'CAPTURE_REQUEST open')
if menus and i==25:result+=line(i,i*100,'INVENTORY_CLOSED')+line(i,i*100,'CAPTURE_REQUEST closed')
return result+line(449,45000,'FINISHED observation-bound')
class InputRunnerTests(unittest.TestCase):
def test_default_and_explicit_command_scope(self):
args=(runner.ROOT,Path('/steam'),Path('/compat'),Path('/profile'))
for project in ('core','chat'):
original=runner.command_for(*args,project)
self.assertNotIn('-errOrdinaryInputObserve',original)
command=runner.input_observer_command(original,project,None,NONCE)
self.assertEqual(command[:-4],original)
self.assertEqual(command[-4:],['-errOrdinaryInputObserve',NONCE,'-errOrdinaryInputScreenshots','1'])
self.assertNotIn('-errOrdinaryInputObserve',original)
for project,packed,nonce in ((runner.CONTROL,None,NONCE),('core',{},NONCE),('core',None,'../bad'),('core',None,''),('core',None,'x'*33)):
with self.assertRaises(ValueError):runner.input_observer_command([],project,packed,nonce)
with self.assertRaises(ValueError):runner.input_observer_command(['-errOrdinaryInputObserve',NONCE],'core',None,NONCE)
def test_refusal_before_any_launch_or_profile_work(self):
for changes in ({'project':runner.CONTROL},{'packed_core':Path('/candidate')},{'packed_addons':Path('/candidate')},{'settle':49}):
args=SimpleNamespace(project='core',observe_player_input=True,packed_core=None,packed_addons=None,settle=50)
for key,value in changes.items():setattr(args,key,value)
with patch.object(runner,'packed_request',side_effect=AssertionError('early refusal required')):
with self.assertRaises(ValueError):runner.run(args)
runner.validate_input_observer(SimpleNamespace(observe_player_input=False))
runner.validate_input_observer(SimpleNamespace(observe_player_input=True,project='chat',settle=50))
def test_strict_complete_sampler_is_not_input_success(self):
evidence=runner.input_observer_evidence(records(),NONCE)
self.assertTrue(evidence['sampler_complete'],evidence)
self.assertEqual(evidence['sample_count'],449)
self.assertFalse(evidence['input_sent'])
self.assertFalse(evidence['inventory_open_observed'])
self.assertNotIn('movement_success',evidence)
menu=runner.input_observer_evidence(records(menus=True),NONCE)
self.assertTrue(menu['sampler_complete'],menu)
self.assertTrue(menu['inventory_open_observed'] and menu['inventory_close_observed'])
self.assertEqual(menu['capture_requests'],['baseline','open','closed'])
def test_malformed_missing_reordered_unbounded_and_foreign_records_refused(self):
good=records()
cases=['',good.replace(' QUEUED',' WRONG'),good.replace(' READY',' MISSING'),good.replace(' FINISHED observation-bound',' CANCELLED original-admission-lost'),
good.replace(NONCE,'b'*32,1),good.replace('sample=2 real=1200','sample=3 real=1200',1),good.replace('real=1200','real=1000',1),
good.replace('world=5200','world=4900',1),good.replace('origin=<1, 2, 3>','origin=<NaN, 2, 3>',1),good.replace('forward=0','forward=1e999',1),
good.replace('real=46000 world=50000','real=45999 world=50000'),good.replace('sample=449 real=46000','sample=450 real=46000'),
good.replace('FINISHED observation-bound','FINISHED sample-bound'),good+good.splitlines()[0]+'\n',good.replace('inventory=0','inventory=unknown',1),
good.replace('real=1200','real=46000',1),good.replace('real=3100 world=7100 READY','real=3000 world=7100 READY')]
for candidate in cases:
with self.subTest(candidate=candidate[-150:]):self.assertFalse(runner.input_observer_evidence(candidate,NONCE)['sampler_complete'])
self.assertFalse(runner.input_observer_evidence(good*2,NONCE)['sampler_complete'])
self.assertFalse(runner.input_observer_evidence(good,'../bad')['sampler_complete'])
def test_transition_and_capture_cannot_manufacture_inventory_observation(self):
good=records(menus=True)
for bad in (good.replace('INVENTORY_OPEN','INVENTORY_CLOSED'),good.replace('inventory=1','inventory=0'),good.replace('CAPTURE_REQUEST open','CAPTURE_REQUEST baseline')):
self.assertFalse(runner.input_observer_evidence(bad,NONCE)['sampler_complete'])
def test_prior_failure_results_are_never_replaced(self):
for previous in ('game-failed','cleanup-failed','source-changed','display-probe-incomplete','timeout','process-exited'):
for content in ('',records()):self.assertEqual(runner.input_observer_result(previous,content,NONCE)[0],previous)
self.assertEqual(runner.input_observer_result('game-started','',NONCE)[0],'observation-incomplete')
self.assertEqual(runner.input_observer_result('game-started',records(),NONCE)[0],'game-started')
def simulate(self,observe,content,display_ok=True,native_failed=False):
with tempfile.TemporaryDirectory(prefix='erri-input-runner-test-') as tmp:
root=Path(tmp);steam=root/'steam';compat=root/'compat';profile=root/'profile'
exe=steam/'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe';proton=steam/'steamapps/common/Proton - Experimental/proton'
for path in (exe,proton):path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(b'fake never launched')
(exe.parent/'addons/data').mkdir(parents=True)
session=profile/'logs/logs_fixture';child=Mock();child.poll.return_value=None
def spawn(*args,**kwargs):session.mkdir(parents=True);(session/'console.log').write_text(content);return child
def wrap(command,*args):
if observe:self.assertEqual(command[-4:],['-errOrdinaryInputObserve',NONCE,'-errOrdinaryInputScreenshots','1'])
return command,{'fake':True}
args=SimpleNamespace(project='core',capture_prison_view=False,inspect_headless_display=True,observe_player_input=observe,timeout=180,settle=50,failure_grace=0)
monitor=SimpleNamespace(deadline=time.monotonic()+60,started=0,first_failure_at=None,observe=lambda *args:'game-started',annotate=lambda x:x)
captured=[]
def snapshot(root,command):captured.append(command.copy());return {}
with contextlib.ExitStack() as stack:
for name in ('assert_no_game','assert_prefix_idle','persist','cleanup'):stack.enter_context(patch.object(runner,name))
stack.enter_context(patch.object(runner.uuid,'uuid4',return_value=SimpleNamespace(hex=NONCE)))
stack.enter_context(patch.object(runner,'snapshot',side_effect=snapshot))
stack.enter_context(patch.object(runner,'bindings',return_value={}))
stack.enter_context(patch.object(runner,'seal_run',return_value={'source_unchanged_after_run':True}))
stack.enter_context(patch.object(runner,'StartupMonitor',return_value=monitor))
stack.enter_context(patch.object(runner,'game_evidence',return_value={'ready':not native_failed,'failed':native_failed}))
stack.enter_context(patch.object(runner,'display_probe_command',side_effect=wrap))
stack.enter_context(patch.object(runner,'display_probe_result',return_value={'verified':display_ok}))
stack.enter_context(patch.object(runner.shutil,'which',return_value='/fake'))
stack.enter_context(patch.object(runner.subprocess,'Popen',side_effect=spawn))
stack.enter_context(patch.object(runner.subprocess,'check_output',side_effect=lambda args,**kw:'[]' if '-f' in args else '11'))
stack.enter_context(patch.object(runner.subprocess,'run'))
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
status=runner.run_locked(args,steam,compat,profile)
return status,json.loads((session/'runner.json').read_text()),json.loads((session/'game-startup-report.json').read_text()),captured[0]
def test_actual_report_flow_default_success_missing_and_prior_failure(self):
status,report,final,command=self.simulate(False,'')
self.assertEqual(status,0);self.assertNotIn('input_observation',report);self.assertNotIn('-errOrdinaryInputObserve',command)
for content,display_ok,native_failed,result in ((records(),True,False,'game-started'),('',True,False,'observation-incomplete'),('',False,False,'display-probe-incomplete'),(records(),True,True,'game-failed')):
status,report,final,command=self.simulate(True,content,display_ok,native_failed)
self.assertEqual(report['result'],result)
self.assertEqual(status,0 if result=='game-started' else 1)
self.assertEqual(report['input_observation'],final['input_observation'])
self.assertEqual(report['input_observation']['nonce'],NONCE)
self.assertIn(NONCE,command)
if __name__=='__main__':unittest.main()
+87
View File
@@ -0,0 +1,87 @@
"""Execute loading presentation/cleanup and native observation bodies with UI adapters.
No native layout rendering, multiplayer, or real startup-failure claim.
"""
from policy_harness import ROOT, body, run
SOURCE=ROOT/'addons/core/Scripts/Game/ERR/ERR_PreparationStatus.c'
def cpp(s):
s=s.replace('protected ','').replace('override ','').replace('null','nullptr')
s=s.replace('ERR_GameMode mode = ERR_GameMode.Get();','ERR_GameMode* mode = ERR_GameMode::Get();')
s=s.replace('ERR_GameMode mode,','ERR_GameMode* mode,')
for name in ['System','ERR_GameMode','ERR_Phase','ERR_ObjectiveText']:s=s.replace(name+'.',name+'::')
s=s.replace('TextWidget.Cast(','CastText(').replace('Widget widget)', 'Widget* widget)').replace('widget.', 'widget->')
for name in ['mode','m_wLoadingPlaceholder','m_ERRPreparationSpinner','m_ERRPreparationRoot','m_ERRPreparationTitle','m_ERRPreparationDescription']:
s=s.replace(name+'.',name+'->')
s=s.replace('GetGame().','GetGame()->').replace('GetWorkspace().','GetWorkspace()->').replace('.IsEmpty()','.empty()')
return s.replace('super.','Native::')
methods='\n'.join(cpp(body(SOURCE,key)) for key in ('protected void ERR_ClearPreparationStatus(', 'override void UpdateLoadingPlaceholder(', 'override void DestroyLoadingPlaceholder(', 'protected bool ERR_PreparationWidgetVisible(', 'bool ERR_ProbePreparationVisible('))
run(r'''
#include <cassert>
#include <string>
#include <map>
using string=std::string;
namespace System{bool console=false;bool IsConsoleApp(){return console;}}
namespace ERR_Phase{enum{PREPARING,PRISON};}
struct ERR_GameMode{static ERR_GameMode* current;int phase=ERR_Phase::PREPARING;string error;static ERR_GameMode* Get(){return current;}int Phase(){return phase;}string PreparationError(){return error;}};ERR_GameMode* ERR_GameMode::current=nullptr;
namespace ERR_ObjectiveText{string Description(ERR_GameMode* mode,bool){return mode->error.empty()?"Preparing sites":"Mission failed; ask an administrator to correct and restart";}}
struct Widget{Widget* parent=nullptr;bool visible=true,removed=false,textWidget=false;string text;int textWrites=0,visibilityWrites=0;float width=900,height=170;std::map<string,Widget*> children;
void GetScreenSize(float& w,float& h){w=width;h=height;}Widget* GetParent(){return parent;}void RemoveFromHierarchy(){removed=true;parent=nullptr;}void SetVisible(bool value){visible=value;visibilityWrites++;}bool IsVisible(){return visible;}
Widget* FindAnyWidget(string name){return children.count(name)?children[name]:nullptr;}void SetText(string value){text=value;textWrites++;}string GetText(){return text;}};
Widget* CastText(Widget* w){return w&&w->textWidget?w:nullptr;}
struct Workspace{int creates=0;bool fail=false,missingText=false;Widget* CreateWidgets(const char*,Widget* parent){creates++;if(fail)return nullptr;auto root=new Widget;root->parent=parent;for(string name:{"ERR_PreparationTitle","ERR_PreparationDescription"}){auto child=new Widget;child->parent=root;child->textWidget=true;root->children[name]=child;}if(missingText)root->children.erase("ERR_PreparationTitle");return root;}};
struct Game{Workspace workspace;Workspace* GetWorkspace(){return &workspace;}};Game game;Game* GetGame(){return &game;}
struct Native{Widget* m_wLoadingPlaceholder=nullptr;ERR_GameMode* m_pGameMode=nullptr;int nativeUpdates=0,destroys=0;void UpdateLoadingPlaceholder(float){nativeUpdates++;}void DestroyLoadingPlaceholder(){destroys++;if(m_wLoadingPlaceholder)m_wLoadingPlaceholder->RemoveFromHierarchy();m_wLoadingPlaceholder=nullptr;}};
struct Probe:Native{
Widget *m_ERRPreparationParent=nullptr,*m_ERRPreparationRoot=nullptr,*m_ERRPreparationTitle=nullptr,*m_ERRPreparationDescription=nullptr,*m_ERRPreparationSpinner=nullptr;
bool m_ERRPreparationSpinnerHidden=false,m_ERRPreparationSpinnerWasVisible=false;string m_ERRPreparationLastTitle,m_ERRPreparationLastDescription;
'''+methods+r'''
};
struct Fixture{ERR_GameMode mode;Widget parent,spinner;Probe p;Fixture(){game.workspace={};System::console=false;ERR_GameMode::current=&mode;p.m_pGameMode=&mode;p.m_wLoadingPlaceholder=&parent;parent.children["Spinner"]=&spinner;spinner.parent=&parent;}};
int main(){
{Fixture f;f.p.UpdateLoadingPlaceholder(.1f);assert(f.p.nativeUpdates==1&&game.workspace.creates==1&&f.p.ERR_ProbePreparationVisible(&f.mode,false));
Widget* root=f.p.m_ERRPreparationRoot;auto title=f.p.m_ERRPreparationTitle;auto description=f.p.m_ERRPreparationDescription;
for(int i=0;i<200;i++){f.p.UpdateLoadingPlaceholder(.1f);}assert(game.workspace.creates==1&&title->textWrites==1&&description->textWrites==1);
f.mode.error="invalid catalog";f.p.UpdateLoadingPlaceholder(.1f);assert(f.p.ERR_ProbePreparationVisible(&f.mode,true)&&!f.spinner.visible);
for(int i=0;i<200;i++){f.p.UpdateLoadingPlaceholder(.1f);}assert(f.p.m_ERRPreparationRoot==root&&title->textWrites==2&&description->textWrites==2&&f.spinner.visibilityWrites==1);
title->width=0;assert(!f.p.ERR_ProbePreparationVisible(&f.mode,true));title->width=900;f.mode.error.clear();f.p.UpdateLoadingPlaceholder(.1f);assert(f.spinner.visible&&f.p.ERR_ProbePreparationVisible(&f.mode,false));
f.mode.phase=ERR_Phase::PRISON;f.p.UpdateLoadingPlaceholder(.1f);assert(root->removed&&!f.parent.removed&&f.spinner.visible&&!f.p.m_ERRPreparationRoot);
}
for(int mismatch=0;mismatch<6;mismatch++){Fixture f;ERR_GameMode other;if(mismatch==0)ERR_GameMode::current=nullptr;if(mismatch==1)f.p.m_pGameMode=&other;if(mismatch==2)f.mode.phase=ERR_Phase::PRISON;if(mismatch==3)System::console=true;if(mismatch==4)f.p.m_wLoadingPlaceholder=nullptr;if(mismatch==5)f.p.m_pGameMode=nullptr;f.p.UpdateLoadingPlaceholder(.1f);assert(game.workspace.creates==0&&f.p.nativeUpdates==1&&!f.parent.removed&&f.spinner.visibilityWrites==0);}
{Fixture f;f.mode.error="bad";f.spinner.visible=false;f.p.UpdateLoadingPlaceholder(.1f);assert(f.p.ERR_ProbePreparationVisible(&f.mode,true));f.p.ERR_ClearPreparationStatus();assert(!f.spinner.visible);}
{Fixture f;f.mode.error="bad";f.p.UpdateLoadingPlaceholder(.1f);Widget* old=f.p.m_ERRPreparationRoot;Widget newParent,newSpinner;newParent.children["Spinner"]=&newSpinner;newSpinner.parent=&newParent;f.p.m_wLoadingPlaceholder=&newParent;f.p.UpdateLoadingPlaceholder(.1f);assert(old->removed&&f.spinner.visible&&!newSpinner.visible&&f.p.m_ERRPreparationParent==&newParent&&game.workspace.creates==2);f.p.DestroyLoadingPlaceholder();assert(newParent.removed&&!f.parent.removed&&newSpinner.visible&&!f.p.m_ERRPreparationRoot&&f.p.destroys==1);}
{Fixture f;f.mode.error="bad";f.p.UpdateLoadingPlaceholder(.1f);f.p.m_ERRPreparationParent=nullptr;f.p.m_ERRPreparationRoot=nullptr;f.p.m_ERRPreparationTitle=nullptr;f.p.m_ERRPreparationDescription=nullptr;f.p.m_ERRPreparationSpinner=nullptr;f.p.m_wLoadingPlaceholder=nullptr;f.p.UpdateLoadingPlaceholder(.1f);assert(!f.p.m_ERRPreparationSpinnerHidden);}
{Fixture f;game.workspace.fail=true;f.p.UpdateLoadingPlaceholder(.1f);assert(!f.p.m_ERRPreparationRoot&&f.spinner.visibilityWrites==0);game.workspace.fail=false;f.p.UpdateLoadingPlaceholder(.1f);assert(f.p.ERR_ProbePreparationVisible(&f.mode,false));}
{Fixture f;game.workspace.missingText=true;f.p.UpdateLoadingPlaceholder(.1f);assert(!f.p.m_ERRPreparationRoot&&f.spinner.visibilityWrites==0);}
{Fixture f;f.p.UpdateLoadingPlaceholder(.1f);Widget other;Widget* moved=f.p.m_ERRPreparationRoot;moved->parent=&other;f.p.UpdateLoadingPlaceholder(.1f);assert(moved->removed&&f.p.m_ERRPreparationRoot!=moved&&f.p.m_ERRPreparationParent==&f.parent);}
}
''')
probe=ROOT/'addons/core/Scripts/Game/ERR/ERR_NativePreparationStatusProbe.c'
s=body(probe,'static int Tick(').replace('ERR_GameMode mode,','ERR_GameMode* mode,').replace('ERR_GameMode.Get()','ERR_GameMode::Get()').replace('ERR_Phase.PREPARING','PREPARING').replace('mode.','mode->').replace('.IsEmpty()','.empty()').replace('SCR_RespawnSystemComponent respawn','SCR_RespawnSystemComponent* respawn').replace('SCR_RespawnSystemComponent.GetInstance()','SCR_RespawnSystemComponent::GetInstance()').replace('respawn.','respawn->').replace('System.MakeScreenshot','System::MakeScreenshot')
run(r'''
#include <cassert>
#include <string>
using string=std::string;const int PREPARING=0;
struct ERR_GameMode{static ERR_GameMode* current;int phase=0;string error="bad";static ERR_GameMode* Get(){return current;}int Phase(){return phase;}string PreparationError(){return error;}};ERR_GameMode* ERR_GameMode::current=nullptr;
struct SCR_RespawnSystemComponent{bool visible=true;static SCR_RespawnSystemComponent instance;static SCR_RespawnSystemComponent* GetInstance(){return &instance;}bool ERR_ProbePreparationVisible(ERR_GameMode*,bool failed){assert(failed);return visible;}};SCR_RespawnSystemComponent SCR_RespawnSystemComponent::instance;
namespace System{int captures=0;void MakeScreenshot(const char*){captures++;}}void Print(const char*){}
struct Probe{static ERR_GameMode* s_Mode;static float s_Deadline,s_Since,s_Last,s_CaptureAt;
'''+s+r'''
};ERR_GameMode* Probe::s_Mode=nullptr;float Probe::s_Deadline=0,Probe::s_Since=-1,Probe::s_Last=-1,Probe::s_CaptureAt=-1;
int main(){ERR_GameMode mode;ERR_GameMode::current=&mode;
for(int i=0;i<=33;i++){assert(Probe::Tick(&mode,i*500)==0);}assert(System::captures==0);assert(Probe::Tick(&mode,17000)==0&&System::captures==1);assert(Probe::Tick(&mode,17500)==1&&System::captures==1);
ERR_GameMode next;ERR_GameMode::current=&next;assert(Probe::Tick(&next,20000)==0);assert(Probe::Tick(&next,22000)==0&&Probe::s_Since==22000);SCR_RespawnSystemComponent::instance.visible=false;assert(Probe::Tick(&next,22500)==0&&Probe::s_Since==-1);assert(Probe::Tick(&next,50000)==-1);
}
''')
text=SOURCE.read_text()
assert 'GetOwner(' not in text
assert text.count('m_pGameMode != mode') == 2
# m_pGameMode is the installed vanilla protected owner field, assigned by
# SCR_RespawnSystemComponent.OnInit(IEntity owner). The adapter deliberately
# exposes that field rather than inventing a ScriptComponent.GetOwner API.
for forbidden in ('CallLater(', 'SetCamera(', 'SpawnEntity', 'SetControlledEntity(', 'Transition(', 'PreparationError() ='):
assert forbidden not in text,forbidden
native=probe.read_text()
for forbidden in ('CreateWidgets(', 'UpdateLoadingPlaceholder(', 'SetText(', 'SetVisible('):assert forbidden not in native
base=(ROOT/'addons/core/Scripts/Game/ERR/ERR_NativeBaseSmoke.c').read_text()
assert 'ERR_NativePreparationStatusProbe.Tick(this, now)' in base
assert 'native-end-active' in base and 'native-end-preparing' in base
print('PASS: actual preparation status scope, persistent updates, spinner restoration, placeholder replacement/deletion/destruction, native observation persistence/timeout; rendering remains native-pending')
+129
View File
@@ -0,0 +1,129 @@
"""Fake process/pidfd tests only. Never invoke native cleanup or send a signal."""
import dataclasses
import importlib.util
import signal
import sys
from pathlib import Path
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location('private_prefix_cleanup',ROOT/'tools/private_prefix_cleanup.py')
m=importlib.util.module_from_spec(spec);sys.modules[spec.name]=m;spec.loader.exec_module(m)
UID=1000
EXE='/installed/Proton/files/lib/wine/i386-unix/wine64-preloader'
INODE=(2,44)
PARENT=m.Process(1670,UID,1,100,'S','systemd',m.SYSTEMD_ARGV,None,None)
def proc(pid=10,**changes):
p=m.Process(pid,UID,PARENT.pid,1000+pid,'S','winedevice.exe',m.DEVICE_ARGV,EXE,INODE,m.PREFIX)
return dataclasses.replace(p,**changes)
class Inspector:
def __init__(self,processes=()):self.processes={p.pid:p for p in processes};self.parent=PARENT;self.reads=0
def devices(self):
self.reads+=1;result=[]
for p in self.processes.values():
if p.state in {'Z','X'} or m.prefix_kind(p.prefix)=='other':continue
if m.prefix_kind(p.prefix)=='alias':raise m.CleanupRefused('ambiguous private-prefix spelling')
result.append(m.prove_device(p,self.parent,UID,EXE,INODE))
return result
class FakePidfds:
def __init__(self,inspector,term_exits=True,kill_exits=True):
self.inspector=inspector;self.term_exits=term_exits;self.kill_exits=kill_exits;self.opened={};self.closed=[];self.sent=[];self.waits=[];self.on_open=None;self.on_send=None
def open(self,pid):
if pid not in self.inspector.processes:raise ProcessLookupError()
fd=pid+1000;self.opened[fd]=self.inspector.processes[pid]
if self.on_open:self.on_open(pid)
return fd
def close(self,fd):self.closed.append(fd)
def send(self,fd,sig):
original=self.opened[fd];self.sent.append((original.pid,sig))
if self.on_send:self.on_send(original.pid,sig)
if (sig==signal.SIGTERM and self.term_exits) or (sig==signal.SIGKILL and self.kill_exits):
current=self.inspector.processes.get(original.pid)
if current and current.start==original.start:self.inspector.processes.pop(original.pid)
def wait(self,handles,timeout):
self.waits.append(timeout);finished=set()
for pid,fd in handles.items():
current=self.inspector.processes.get(pid);original=self.opened[fd]
if not current or current.start!=original.start or current.state in {'Z','X'}:finished.add(pid)
return finished
class PrivatePrefixCleanup(unittest.TestCase):
def test_exact_prefix_and_observed_device(self):
self.assertEqual(m.DEVICE_ARGV[0].hex(),'433a5c77696e646f77735c73797374656d33325c77696e656465766963652e657865')
for suffix in (b'',b'/',b'//'):
p=proc(prefix=m.PREFIX+suffix);self.assertEqual(m.prefix_kind(p.prefix),'exact');self.assertEqual(m.prove_device(p,PARENT,UID,EXE,INODE).process.pid,10)
for other in (None,b'/other/pfx',m.PREFIX+b'-sibling',b'/tmp/another/pfx'):
self.assertEqual(m.prefix_kind(other),'other')
self.assertEqual(m.prefix_kind(m.PREFIX+b'/../pfx'),'alias')
def test_identity_and_parent_refusals(self):
mutations=[{'uid':UID+1},{'state':'Z'},{'argv':(*m.DEVICE_ARGV,b'extra')},{'argv':(b'Z:\\winedevice.exe',)},{'comm':'winedbg.exe'},{'exe':'/untrusted/wine64-preloader'},{'executable_id':(2,45)},{'prefix':b'/other/pfx'}]
for mutation in mutations:
with self.subTest(mutation=mutation),self.assertRaises(m.CleanupRefused):m.prove_device(proc(**mutation),PARENT,UID,EXE,INODE)
for mutation in ({'uid':UID+1},{'pid':3},{'ppid':2},{'comm':'bash'},{'argv':(b'/usr/lib/systemd/systemd',)},{'exe':'/tmp/systemd'},{'state':'Z'}):
with self.subTest(parent=mutation),self.assertRaises(m.CleanupRefused):m.prove_device(proc(),dataclasses.replace(PARENT,**mutation),UID,EXE,INODE)
self.assertTrue(m.prove_device(proc(),dataclasses.replace(PARENT,exe='/usr/lib/systemd/systemd'),UID,EXE,INODE))
def test_clean_term_ignores_other_prefix(self):
i=Inspector([proc(10),proc(11,prefix=m.PREFIX+b'/'),proc(12,prefix=b'/other/pfx')]);f=FakePidfds(i)
result=m._cleanup(i,f);self.assertTrue(result['cleared']);self.assertEqual(result['term_sent'],[10,11]);self.assertEqual(result['kill_sent'],[])
self.assertEqual(set(i.processes),{12});self.assertEqual(set(f.closed),set(f.opened));self.assertEqual(max(f.waits),3.0)
self.assertNotIn('argv',str(result));self.assertNotIn('environment',str(result))
def test_other_live_private_process_refuses_before_open_or_signal(self):
for other in (proc(11,comm='ArmaReforger',argv=(b'ArmaReforger.exe',)),proc(11,ppid=42),proc(11,prefix=m.PREFIX+b'/../pfx')):
i=Inspector([proc(),other]);f=FakePidfds(i)
with self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
self.assertEqual(f.opened,{});self.assertEqual(f.sent,[])
def test_pid_reuse_after_open_refuses(self):
i=Inspector([proc()]);f=FakePidfds(i)
f.on_open=lambda pid:i.processes.update({pid:proc(pid,start=8888)})
with self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
self.assertEqual(f.sent,[]);self.assertEqual(f.closed,[1010])
def test_parent_reuse_or_role_change_refuses(self):
for change in ('start','role','prefix','exe'):
i=Inspector([proc()]);f=FakePidfds(i)
def mutate(pid):
if change=='start':i.parent=dataclasses.replace(PARENT,start=999)
elif change=='role':i.processes[pid]=proc(pid,argv=(b'winedbg.exe',))
elif change=='prefix':i.processes[pid]=proc(pid,prefix=b'/other/pfx')
else:i.processes[pid]=proc(pid,executable_id=(9,9))
f.on_open=mutate
with self.subTest(change=change),self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
self.assertEqual(f.sent,[])
def test_bounded_kill_uses_same_pinned_handles(self):
i=Inspector([proc(10),proc(11)]);f=FakePidfds(i,term_exits=False)
result=m._cleanup(i,f)
self.assertEqual(f.sent,[(10,signal.SIGTERM),(11,signal.SIGTERM),(10,signal.SIGKILL),(11,signal.SIGKILL)])
self.assertTrue(result['cleared']);self.assertEqual(result['kill_sent'],[10,11]);self.assertEqual(f.waits.count(3.0),1);self.assertEqual(f.waits.count(1.0),1)
def test_new_active_process_after_partial_term_preserved(self):
i=Inspector([proc(10),proc(11)]);f=FakePidfds(i)
def new_process(pid,sig):
if pid==10:i.processes[99]=proc(99,comm='active-game',argv=(b'game.exe',))
f.on_send=new_process
with self.assertRaises(m.CleanupRefused) as failure:m._cleanup(i,f)
self.assertEqual(f.sent,[(10,signal.SIGTERM)]);self.assertEqual(failure.exception.report['term_sent'],[10]);self.assertFalse(failure.exception.report['cleared']);self.assertIn(11,i.processes);self.assertIn(99,i.processes)
def test_partial_cleanup_failure_has_no_broad_fallback(self):
i=Inspector([proc()]);f=FakePidfds(i,term_exits=False,kill_exits=False)
with self.assertRaises(m.CleanupRefused) as failure:m._cleanup(i,f)
self.assertEqual(failure.exception.report['kill_sent'],[10]);self.assertFalse(failure.exception.report['cleared']);self.assertEqual(f.closed,[1010]);self.assertEqual(len(f.sent),2)
def test_new_adopted_helper_not_in_snapshot_refused(self):
i=Inspector([proc()]);f=FakePidfds(i);f.on_open=lambda pid:i.processes.update({11:proc(11)})
with self.assertRaises(m.CleanupRefused):m._cleanup(i,f)
self.assertEqual(f.sent,[])
def test_production_scope_rejects_other_prefix_before_backend(self):
with patch.object(m,'Pidfds',side_effect=AssertionError('No signal backend permitted')):
for other in ('/tmp/other','/home/user/.wine','/tmp/erri-headless-compat/../other'):
with self.subTest(other=other),self.assertRaises(m.CleanupRefused):m.cleanup_private_prefix(other,'/installed/proton')
def test_exited_zombies_are_not_signalled(self):
i=Inspector([proc(state='Z')]);f=FakePidfds(i);r=m._cleanup(i,f);self.assertTrue(r['cleared']);self.assertEqual(f.opened,{});self.assertEqual(f.sent,[])
def test_metadata_permissions_do_not_hide_runtime_candidates(self):
self.assertTrue(m.native_metadata_proves_unrelated(PARENT))
self.assertFalse(m.native_metadata_proves_unrelated(proc(exe=None,executable_id=None)))
self.assertFalse(m.native_metadata_proves_unrelated(proc(comm='python',argv=(b'/usr/bin/python',b'/runtime/proton'),exe='/usr/bin/python')))
def test_observed_nondumpable_native_services_are_unrelated(self):
agent=proc(comm='ssh-agent',argv=(b'/usr/bin/ssh-agent',b'-D',b'-a',f'/run/user/{UID}/ssh-agent.socket'.encode()),exe=None,executable_id=None)
self.assertTrue(m.native_metadata_proves_unrelated(agent))
for comm,argv in [('kwin_wayland',(b'/usr/bin/kwin_wayland',)),('polkit-kde-auth',(b'/usr/lib/polkit-kde-authentication-agent-1',)),('apache2',(b'apache2',b'-DFOREGROUND'))]:
self.assertTrue(m.native_metadata_proves_unrelated(dataclasses.replace(agent,comm=comm,argv=argv)))
for mutation in ({'comm':'winedevice.exe'},{'argv':agent.argv+(b'proton',)},{'argv':(b'/usr/bin/something-else',)},{'argv':(b'ssh-agent',)},{'comm':'apache2','argv':(b'apache2',b'-unknown')},{'comm':'wine64-preloade','argv':(b'/runtime/wine64-preloader',)}):
self.assertFalse(m.native_metadata_proves_unrelated(dataclasses.replace(agent,**mutation)))
if __name__=='__main__':unittest.main()
+84
View File
@@ -0,0 +1,84 @@
"""Run actual shared objective selection and map grid methods; no native UI claim."""
from policy_harness import ROOT, body, run
source = ROOT / 'addons/core/Scripts/Game/ERR/ERR_ObjectiveText.c'
s = body(source, 'protected static string PhaseDescription(')
s = s.replace('protected ', '').replace('ERR_GameMode mode,', 'ERR_GameMode* mode,')
s = s.replace('ERR_Phase phase', 'int phase').replace('mode.', 'mode->')
s = s.replace('array<ERR_LocationComponent>', 'std::vector<ERR_LocationComponent*>')
s = s.replace('foreach (ERR_LocationComponent location : locations)', 'for (ERR_LocationComponent* location : locations)')
s = s.replace('location.', 'location->').replace('null', 'nullptr')
s = s.replace('bool roughRadio;', 'bool roughRadio = false;') # Enforce default initialization
s = s.replace('.IsEmpty()', '.empty()').replace('string.Format(', 'Format(')
for scope in ('ERR_Phase', 'ERR_LocationKind', 'Math'):
s = s.replace(scope + '.', scope + '::')
coordinate = body(ROOT / 'addons/core/Scripts/Game/ERR/ERR_DiscoveryMap.c', 'static int Coordinate(').replace('Math.', 'Math::')
run(r'''
#include <cassert>
#include <string>
#include <vector>
#include <cmath>
using string = std::string;
namespace ERR_Phase {enum {PREPARING,PRISON,RADIO_SEARCH,RADIO_DEFENCE,EXTRACTION,VICTORY,DEFEAT};}
namespace ERR_LocationKind {enum {PRISON,RADIO,EXTRACTION,OUTPOST,DEPOT};}
namespace Math {float Ceil(float v){return std::ceil(v);}float Floor(float v){return std::floor(v);}float Round(float v){return std::round(v);}}
struct ERR_LocationComponent {int kind,knowledge;int Kind(){return kind;}int Knowledge(){return knowledge;}};
struct ERR_GameMode {
int phase=ERR_Phase::RADIO_SEARCH;string error;bool occupied=false;
std::vector<ERR_LocationComponent*> locations;
int Phase(){return phase;}string PreparationError(){return error;}
void Locations(std::vector<ERR_LocationComponent*>& result){result=locations;}
int RadioFeedback(){return 7;}int DefenceSecondsRemaining(){return 30;}
bool DefenceOccupied(){return occupied;}float EscapeRadius(){return 9000;}
};
string Format(string value,int){return value;}
string RadioFeedbackMessage(int value){assert(value==7);return "radio feedback boundary";}
string Extraction(float value){assert(value==9000);return "extraction boundary";}
struct Actual {
''' + s + '\n' + coordinate + r'''
};
bool has(string text,string part){return text.find(part)!=string::npos;}
int main(){
ERR_GameMode mode;ERR_LocationComponent radio{ERR_LocationKind::RADIO,0};
mode.locations={&radio};
string unknown=Actual::PhaseDescription(&mode,true);
assert(has(unknown,"Choose either marked supply cache")&&!has(unknown,"Intel recovered"));
radio.knowledge=1;
string rough=Actual::PhaseDescription(&mode,true);
assert(rough!=unknown&&has(rough,"Intel recovered")&&has(rough,"500 m search square"));
assert(has(rough,"carried field map")&&has(rough,"different intelligence folder"));
assert(has(rough,"already recovered cannot provide another clue"));
assert(Actual::PhaseDescription(&mode,false)==rough); // Same task and guide copy.
for(int i=0;i<10;i++) assert(Actual::PhaseDescription(&mode,true)==rough);
radio.knowledge=2;
string exact=Actual::PhaseDescription(&mode,true);
assert(exact!=rough&&has(exact,"identified on your map")&&has(exact,"Contact extraction HQ"));
ERR_LocationComponent roughRadio{ERR_LocationKind::RADIO,1};
ERR_LocationComponent depot{ERR_LocationKind::DEPOT,1};
mode.locations={nullptr,&roughRadio,&depot,&radio};
assert(Actual::PhaseDescription(&mode,true)==exact);
mode.locations={&radio,&depot,&roughRadio,nullptr};
assert(Actual::PhaseDescription(&mode,true)==exact);
radio.knowledge=0;mode.locations={&depot,nullptr,&radio};
assert(Actual::PhaseDescription(&mode,true)==unknown);
mode.locations={};assert(Actual::PhaseDescription(&mode,true)==unknown);
mode.locations={&roughRadio};assert(Actual::PhaseDescription(&mode,true)==rough);
// Exact production grid dimensions agree with the player-facing 500 m claim.
assert(Actual::Coordinate(6500.0f,1)==6750);
assert(Actual::Coordinate(6999.9f,1)==6750);
assert(Actual::Coordinate(7000.0f,1)==7250);
assert(Actual::Coordinate(-.1f,1)==-250);
assert(Actual::Coordinate(6501.4f,2)==6501);
assert(Actual::PhaseDescription(nullptr,true).empty());
mode.phase=ERR_Phase::PREPARING;assert(has(Actual::PhaseDescription(&mode,true),"Preparing mission"));
mode.error="bad";assert(has(Actual::PhaseDescription(&mode,true),"could not start"));
mode.phase=ERR_Phase::PRISON;assert(has(Actual::PhaseDescription(&mode,true),"Pick the gate lock"));
mode.phase=ERR_Phase::RADIO_DEFENCE;assert(has(Actual::PhaseDescription(&mode,true),"interrupted"));
mode.occupied=true;assert(has(Actual::PhaseDescription(&mode,true),"in progress"));
assert(Actual::PhaseDescription(&mode,false)=="radio feedback boundary");
mode.phase=ERR_Phase::EXTRACTION;assert(Actual::PhaseDescription(&mode,true)=="extraction boundary");
mode.phase=ERR_Phase::VICTORY;assert(Actual::PhaseDescription(&mode,true).empty());
mode.phase=ERR_Phase::DEFEAT;assert(Actual::PhaseDescription(&mode,true).empty());
}
''')
print('PASS: actual rough-intel objective selection, exact precedence, source grid dimensions and unchanged other-phase dispatch; native UI pending')
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Runner cleanup integration with fake processes; no native launch or real signals."""
import ast
import contextlib
import io
import json
from pathlib import Path
import runpy
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools'))
import headless_game as game
import native_provenance
import private_prefix_cleanup as private
import test_headless_display_runner as display_tests
WB=Path(__file__).resolve().parents[1]/'tools/headless_workbench.py'
class CleanupIntegrationTests(unittest.TestCase):
def test_canonical_cleanup_order_then_idle_verified(self):
order=[];proof={'cleared':True,'term_sent':[12],'kill_sent':[]}
with patch.object(game,'cleanup',side_effect=lambda *a:order.append('normal')),\
patch.object(private,'cleanup_private_prefix',side_effect=lambda *a:(order.append('private') or proof)),\
patch.object(game,'assert_prefix_idle',side_effect=lambda *a:order.append('idle')):
report=game.cleanup_owned_run(Mock(),private.INSTALLED_PROTON,{},private.COMPAT)
self.assertEqual(order,['normal','private','idle']);self.assertTrue(report['cleanup_confirmed'])
self.assertEqual(report['orphan_cleanup']['report'],proof);self.assertTrue(report['idle_verified'])
def test_noncanonical_private_prefix_never_gets_orphan_signalling(self):
with patch.object(game,'cleanup'),patch.object(game,'assert_prefix_idle') as idle,\
patch.object(private,'cleanup_private_prefix') as narrow:
result=game.cleanup_owned_run(Mock(),Path('/other/proton'),{},Path('/tmp/other-private-compat'))
narrow.assert_not_called();idle.assert_called_once_with(Path('/tmp/other-private-compat'))
self.assertTrue(result['cleanup_confirmed']);self.assertFalse(result['orphan_cleanup']['attempted'])
def test_errors_preserve_partial_helper_report_and_still_run_idle_check(self):
partial={'cleared':False,'term_sent':[12],'kill_sent':[]};order=[]
def narrow(*args):order.append('private');raise private.CleanupRefused('remaining identity unverified',partial)
with patch.object(game,'cleanup',side_effect=OSError('normal shutdown failed')),\
patch.object(private,'cleanup_private_prefix',side_effect=narrow),\
patch.object(game,'assert_prefix_idle',side_effect=lambda *a:(order.append('idle') or None)):
result=game.cleanup_owned_run(Mock(),private.INSTALLED_PROTON,{},private.COMPAT)
self.assertEqual(order,['private','idle']);self.assertFalse(result['cleanup_confirmed'])
self.assertTrue(result['idle_verified']);self.assertEqual(result['orphan_cleanup']['report'],partial)
self.assertEqual(len(result['errors']),2)
def test_false_helper_completion_or_busy_final_prefix_never_qualifies(self):
for details in ({'cleared':False},{'cleared':1},None):
with patch.object(game,'cleanup'),patch.object(private,'cleanup_private_prefix',return_value=details),patch.object(game,'assert_prefix_idle'):
self.assertFalse(game.cleanup_owned_run(Mock(),private.INSTALLED_PROTON,{},private.COMPAT)['cleanup_confirmed'])
with patch.object(game,'cleanup'),patch.object(private,'cleanup_private_prefix',return_value={'cleared':True}),\
patch.object(game,'assert_prefix_idle',side_effect=ValueError('private prefix still busy')):
result=game.cleanup_owned_run(Mock(),private.INSTALLED_PROTON,{},private.COMPAT)
self.assertFalse(result['cleanup_confirmed']);self.assertFalse(result['idle_verified'])
def test_game_runner_cleanup_failure_is_recorded_and_nonzero(self):
fixture=display_tests.DisplayRunnerTests();fixture.setUp()
proof={'cleanup_confirmed':False,'idle_verified':False,'errors':['idle verification: still busy'],
'orphan_cleanup':{'attempted':True,'report':{'cleared':False}}}
try:
with patch.object(game,'cleanup_owned_run',return_value=proof):
status,summary=fixture.simulate_run_locked(True)
self.assertEqual(status,1);self.assertEqual(summary['result'],'cleanup-failed')
self.assertFalse(summary['cleanup_confirmed']);self.assertEqual(summary['cleanup_report'],proof)
self.assertIn('idle verification',summary['runner_error'])
self.assertNotIn('source_unchanged_after_run',summary)
finally:fixture.tearDown()
class WorkbenchIntegrationTests(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory(prefix='erri-cleanup-runner-test-');self.base=Path(self.tmp.name)
self.steam=self.base/'Steam';self.compat=self.base/'compat';self.profile=self.base/'profile'
(self.compat/'pfx').mkdir(parents=True);(self.compat/'pfx/system.reg').write_text('synthetic fixture')
self.proton=self.steam/'steamapps/common/Proton - Experimental/proton'
self.exe=self.steam/'steamapps/common/Arma Reforger Tools/Workbench/ArmaReforgerWorkbenchSteamDiag.exe'
for file in (self.proton,self.exe):file.parent.mkdir(parents=True,exist_ok=True);file.write_text('not executable')
(self.steam/'steamapps/common/Arma Reforger/addons/data').mkdir(parents=True)
self.open_locks=[]
original_open=Path.open
def tracked_open(path,*args,**kwargs):
stream=original_open(path,*args,**kwargs)
if path.name=='.erri-workbench.lock':self.open_locks.append(stream)
return stream
self.tracked_open=tracked_open
self.argv=[str(WB),'--steam',str(self.steam),'--compat',str(self.compat),'--profile',str(self.profile),'--project','core','--rounds','1','--timeout','60']
def tearDown(self):
for stream in self.open_locks:stream.close()
self.tmp.cleanup()
def test_busy_prefix_refused_under_lock_before_any_launch_or_audio(self):
events=[]
def busy(*a):events.append('idle');raise ValueError('existing private Workbench')
with patch.object(Path,'open',self.tracked_open),patch.object(sys,'argv',self.argv),patch('fcntl.flock',side_effect=lambda *a:events.append('lock')),\
patch.object(game,'assert_prefix_idle',side_effect=busy),patch.object(game,'cleanup_owned_run') as cleanup,\
patch.object(game.subprocess,'Popen') as launch,patch.object(game.subprocess,'check_output') as audio,\
contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as ended:runpy.run_path(str(WB),run_name='__main__')
self.assertEqual(ended.exception.code,2);self.assertEqual(events,['lock','idle'])
launch.assert_not_called();audio.assert_not_called();cleanup.assert_not_called();self.assertFalse(self.profile.exists())
def execute(self,cleanup_report,native_failure=False,launch_error=False):
events=[];session=self.profile/'logs/logs_fixture';child=Mock();child.pid=12345;child.poll.return_value=None
def spawn(*args,**kwargs):
events.append('launch')
if launch_error:raise OSError('synthetic launch failure')
session.mkdir(parents=True);(session/'console.log').write_text('ERR SMOKE FAIL: fixture failure\n' if native_failure else 'ERR SMOKE COMPLETE:\n')
return child
def audio(args,**kwargs):
if 'sink-inputs' in args:return '[]'
return json.dumps([{'name':'erri_tests_silent','index':7}])
def persist(path,snapshot):events.append('persist');(path/'launch-source.json').write_text('retained launch fixture')
old_iterdir=Path.iterdir
def iterdir(path):return iter(()) if path==Path('/proc') else old_iterdir(path)
clock=[0]
def now():clock[0]+=3;return clock[0]
with contextlib.ExitStack() as stack:
stack.enter_context(patch.object(Path,'open',self.tracked_open))
stack.enter_context(patch.object(sys,'argv',self.argv))
stack.enter_context(patch('fcntl.flock',side_effect=lambda *a:events.append('lock')))
stack.enter_context(patch.object(game,'assert_prefix_idle',side_effect=lambda *a:events.append('idle')))
cleaner=stack.enter_context(patch.object(game,'cleanup_owned_run',side_effect=lambda *a:(events.append('cleanup') or cleanup_report)))
stack.enter_context(patch.object(game.shutil,'which',return_value='/synthetic/installed'))
stack.enter_context(patch.object(game.subprocess,'Popen',side_effect=spawn))
stack.enter_context(patch.object(game.subprocess,'check_output',side_effect=audio))
stack.enter_context(patch.object(game.subprocess,'run',return_value=SimpleNamespace(returncode=0)))
native_report=stack.enter_context(patch.object(game.subprocess,'call',return_value=0))
stack.enter_context(patch.object(Path,'iterdir',iterdir))
stack.enter_context(patch.object(game.time,'time',side_effect=now))
stack.enter_context(patch.object(game.time,'sleep'))
stack.enter_context(patch.object(native_provenance,'snapshot',return_value={'synthetic':True}))
stack.enter_context(patch.object(native_provenance,'bindings',return_value={'launch_binding':'retained'}))
stack.enter_context(patch.object(native_provenance,'persist',side_effect=persist))
seal=stack.enter_context(patch.object(native_provenance,'seal_run',return_value={'source_unchanged_after_run':True}))
stdout=stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
with self.assertRaises(SystemExit) as ended:runpy.run_path(str(WB),run_name='__main__')
summary=json.loads(stdout.getvalue())
return ended.exception.code,summary,events,cleaner.call_count,seal.call_count,native_report.call_count
def test_workbench_success_uses_cleanup_before_sealing_and_retains_report(self):
proof={'cleanup_confirmed':True,'idle_verified':True,'errors':[],'orphan_cleanup':{'attempted':False}}
code,summary,events,cleaned,sealed,native=self.execute(proof)
self.assertEqual(code,0);self.assertTrue(summary['cleanup_confirmed']);self.assertEqual(summary['cleanup_report'],proof)
self.assertEqual(events[:3],['lock','idle','launch']);self.assertEqual(events[-1],'cleanup')
self.assertEqual((cleaned,sealed,native),(1,1,1))
self.assertTrue((self.profile/'logs/logs_fixture/launch-source.json').is_file())
def test_workbench_cleanup_failure_preserves_native_and_launch_evidence_without_seal(self):
proof={'cleanup_confirmed':False,'idle_verified':False,'errors':['idle verification: still busy'],
'orphan_cleanup':{'attempted':True,'report':{'cleared':False,'term_sent':[12]}}}
code,summary,events,cleaned,sealed,native=self.execute(proof)
self.assertEqual(code,1);self.assertEqual(summary['result'],'cleanup-failed')
self.assertEqual(summary['result_before_cleanup'],'smoke-completed');self.assertFalse(summary['cleanup_confirmed'])
self.assertEqual((cleaned,sealed,native),(1,0,0));self.assertEqual(summary['launch_binding'],'retained')
session=self.profile/'logs/logs_fixture'
self.assertIn('ERR SMOKE COMPLETE:',(session/'console.log').read_text())
self.assertTrue((session/'launch-source.json').is_file());self.assertTrue((session/'runner.json').is_file())
def test_workbench_native_failure_stays_failed_despite_successful_cleanup(self):
proof={'cleanup_confirmed':True,'idle_verified':True,'errors':[]}
code,summary,*_=self.execute(proof,native_failure=True)
self.assertEqual(code,1);self.assertEqual(summary['result'],'test-failed')
def test_workbench_launch_failure_does_not_invoke_post_run_cleanup(self):
code,summary,events,cleaned,sealed,native=self.execute({},launch_error=True)
self.assertEqual(code,1);self.assertEqual(summary['result'],'runner-failed')
self.assertEqual((cleaned,sealed,native),(0,0,0));self.assertEqual(summary['runner_error'],'synthetic launch failure')
def test_audio_fallback_accepts_only_trailing_slash_prefix_spelling(self):
tree=ast.parse(WB.read_text());function=next(node for node in tree.body if isinstance(node,ast.FunctionDef) and node.name=='silence_test_streams')
namespace={'json':json,'subprocess':game.subprocess,'Path':Path,'compat':self.compat,'silent_index':7,
'silent_sink':'owned-fixture-sink','muted_streams':set()}
exec(compile(ast.Module(body=[function],type_ignores=[]),str(WB),'exec'),namespace)
stream={'index':8,'sink':0,'mute':False,'properties':{'application.process.id':'555'}}
for value,expected in ((str(self.compat/'pfx')+'/',2),(str(self.compat/'pfx')+'///',2),(str(self.compat/'pfx')+'-foreign/',0)):
with patch.object(game.subprocess,'check_output',return_value=json.dumps([stream])),\
patch.object(Path,'read_bytes',return_value=('WINEPREFIX='+value).encode()+b'\0'),\
patch.object(game.subprocess,'run') as audio,contextlib.redirect_stdout(io.StringIO()):
namespace['silence_test_streams']()
self.assertEqual(audio.call_count,expected)
if __name__=='__main__':unittest.main()