Validate independent packed addons and complete physical mortar salvos

This commit is contained in:
2026-09-19 11:42:13 +02:00
parent 7c53b4bb81
commit dfd7cc49e2
532 changed files with 63902 additions and 96 deletions
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Packed archival verification without native execution or production writes."""
import json
import shutil
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
import archive_packed as archiver
import headless_game as runner
import test_packed_core_game as fixtures
class PackedArchiveTests(unittest.TestCase):
def setUp(self):
self.fixture=fixtures.PackedCoreTests();self.fixture.setUp()
self.session=self.fixture.session;self.root=self.fixture.root/'archive-repository';self.root.mkdir()
runner.prepare_packed(self.fixture.packed)
command=runner.command_for(Path('/unused'),self.fixture.steam,Path('/compat'),self.fixture.profile,'core',self.fixture.packed)
self.raw=runner.packed_snapshot(self.fixture.packed,command,self.fixture.profile)
self.meta=json.loads(self.raw)
self.native=self.fixture.profile/'logs/logs_2026-09-19_11-02-00';self.native.mkdir(parents=True)
(self.native/'console.log').write_text(self.fixture.content())
self.report=runner.packed_seal(self.fixture.packed,self.raw,self.native)
self.report.update(started_unix=self.meta['captured_unix']+0.001,stopped_unix=self.meta['captured_unix']+30,result='packed-core-started',session=str(self.native),project='core',cleanup_confirmed=True,runner_error=None,failure_observed_during_polling=False,executable=command[16],dedicated_server=False,control_only=False,packed_consumption_verified=True)
# The command executable follows Proton/run; avoid an assumed path in fixtures.
self.report['executable']=command[command.index('--')+3]
self.startup={'ready':True,'failed':False,'packed_only':True}
self.write_reports()
def tearDown(self):self.fixture.tearDown()
def write_reports(self):
raw=archiver.encoded(self.report)
(self.session/'packed-runner.json').write_bytes(raw);(self.native/'runner.json').write_bytes(raw)
startup=archiver.encoded(self.startup)
(self.session/'packed-startup-report.json').write_bytes(startup);(self.native/'game-startup-report.json').write_bytes(startup)
(self.native/runner.PACKED_MANIFEST).write_bytes(self.raw)
def update_metadata(self):
self.raw=archiver.encoded(self.meta);(self.session/runner.PACKED_MANIFEST).write_bytes(self.raw)
self.report['packed_input_provenance_sha256']=archiver.digest(self.raw);self.write_reports()
def reseal_console(self,text):
raw=text.encode();(self.native/'console.log').write_bytes(raw);self.report['console_sha256']=archiver.digest(raw);self.write_reports()
def test_exact_artifacts_reports_and_hashes_retained_without_source_claim(self):
dest,status=archiver.archive(self.session,'packed-test',self.root);self.assertEqual(status,0)
self.assertEqual(runner.packed_inventory(dest/'package'),self.meta['artifacts'])
for name in runner.PACKED_FILES:self.assertEqual((dest/'package'/name).read_bytes(),(self.fixture.packed['input']/name).read_bytes())
self.assertEqual((dest/runner.PACKED_MANIFEST).read_bytes(),self.raw)
hashes=json.loads((dest/'archive-sha256.json').read_text())
for name,digest in hashes.items():self.assertEqual(archiver.digest((dest/name).read_bytes()),digest)
self.assertFalse((dest/'source-sha256.json').exists());self.assertFalse((dest/'source-provenance.json').exists())
report=json.loads((dest/'report.json').read_text());self.assertTrue(report['packed_consumption_verified']);self.assertFalse(report['build_qualified']);self.assertFalse(report['release_qualified'])
def test_valid_failed_run_archives_nonzero_and_retains_raw_failure(self):
self.report.update(result='game-failed',packed_consumption_verified=False,failure_observed_during_polling=True)
self.startup={'ready':False,'packed_input_violations':['historical alias parser refusal']};self.write_reports()
dest,status=archiver.archive(self.session,'failed-run',self.root)
self.assertEqual(status,1);self.assertIn('historical alias parser refusal',(dest/'raw-startup-report.json').read_text())
self.assertTrue(json.loads((dest/'report.json').read_text())['runner_failure_latched'])
def test_corrected_ready_parser_cannot_erase_latched_run_failure(self):
self.report.update(result='game-failed',packed_consumption_verified=False,failure_observed_during_polling=True);self.write_reports()
files,report=archiver.inspect(self.session)
self.assertFalse(report['packed_consumption_verified']);self.assertTrue(report['packed_loaded_paths_verified'])
def test_native_raw_error_overrides_claimed_success_and_is_retained(self):
self.reseal_console(self.fixture.content()+'00 SCRIPT (E): actual failure\n')
dest,status=archiver.archive(self.session,'error-run',self.root);self.assertEqual(status,1)
self.assertIn('actual failure',(dest/'console.log.txt').read_text())
def test_auxiliary_errors_fail_but_warnings_do_not(self):
(self.native/'error.log').write_text('00 SCRIPT (W): warning\n')
self.assertTrue(archiver.inspect(self.session)[1]['packed_consumption_verified'])
(self.native/'error.log').write_text('00 SCRIPT (E): failure absent from console\n')
files,report=archiver.inspect(self.session);self.assertFalse(report['packed_consumption_verified']);self.assertTrue(report['auxiliary_native_failure']);self.assertIn('error.log.txt',files)
def test_forged_saved_ready_report_cannot_substitute_native_readiness(self):
self.reseal_console(self.fixture.content().replace('local participant verified','not ready'))
self.assertFalse(archiver.inspect(self.session)[1]['packed_consumption_verified'])
def test_unchanged_flag_and_minimum_duration_required(self):
self.report['packed_inputs_unchanged_after_run']=False;self.write_reports()
self.assertFalse(archiver.inspect(self.session)[1]['packed_consumption_verified'])
self.report['packed_inputs_unchanged_after_run']=True;self.report['stopped_unix']=self.report['started_unix']+14.9;self.write_reports()
self.assertFalse(archiver.inspect(self.session)[1]['packed_consumption_verified'])
def test_tampered_payload_console_or_copy_is_refused_before_destination(self):
payload=self.fixture.packed['input']/'data.pak';original=payload.read_bytes();payload.write_bytes(b'changed')
with self.assertRaisesRegex(ValueError,'package bytes differ'):archiver.archive(self.session,'tampered',self.root)
self.assertFalse((self.root/'docs/evidence/tampered').exists());payload.write_bytes(original)
(self.native/'console.log').write_text('changed')
with self.assertRaisesRegex(ValueError,'console differs'):archiver.inspect(self.session)
def test_root_native_report_disagreement_refused(self):
(self.native/'runner.json').write_text('{}')
with self.assertRaisesRegex(ValueError,'copies disagree'):archiver.inspect(self.session)
def test_manifest_seal_mismatch_and_duplicate_keys_refused(self):
(self.session/runner.PACKED_MANIFEST).write_bytes(self.raw+b' ')
with self.assertRaisesRegex(ValueError,'binding differs'):archiver.inspect(self.session)
(self.session/runner.PACKED_MANIFEST).write_text('{"schema_version":1,"schema_version":1}')
with self.assertRaisesRegex(ValueError,'duplicate JSON key'):archiver.inspect(self.session)
def test_unexpected_command_loose_search_or_server_flag_refused(self):
self.meta['command'].extend(['-addonsDir','Z:/loose/successor/addons']);self.update_metadata()
with self.assertRaisesRegex(ValueError,'Recorded command differs'):archiver.inspect(self.session)
def test_recorded_native_session_escape_refused(self):
self.report['session']='/tmp/foreign/logs_foo';self.write_reports()
with self.assertRaisesRegex(ValueError,'escapes'):archiver.inspect(self.session)
def test_runtime_or_profile_redirection_refused(self):
self.meta['runtime_directory']='/tmp/foreign/core';self.update_metadata()
with self.assertRaisesRegex(ValueError,'do not belong'):archiver.inspect(self.session)
def test_cleanup_unknown_result_and_source_relabel_refused(self):
original=dict(self.report)
for key,value in [('cleanup_confirmed',False),('result','all good'),('source_provenance','launch_snapshot'),('build_qualified',True),('packed_only',False),('packed_inputs_unchanged_after_run',1)]:
self.report=dict(original);self.report[key]=value;self.write_reports()
with self.subTest(key=key),self.assertRaises(ValueError):archiver.inspect(self.session)
def test_canonical_names_no_overwrite_and_symlink_destination(self):
for name in ('../outside','nested/name','.','..','/absolute','bad name','_hidden'):
with self.subTest(name=name),self.assertRaises(ValueError):archiver.archive(self.session,name,self.root)
dest,status=archiver.archive(self.session,'once',self.root)
with self.assertRaisesRegex(ValueError,'overwrite'):archiver.archive(self.session,'once',self.root)
(self.root/'docs/evidence/redirect').symlink_to(dest,target_is_directory=True)
with self.assertRaises(ValueError):archiver.archive(self.session,'redirect',self.root)
def test_symlinked_evidence_refused(self):
path=self.native/'error.log';path.symlink_to(self.native/'console.log')
with self.assertRaisesRegex(ValueError,'symlinked'):archiver.inspect(self.session)
def test_timestamp_and_missing_hash_validation(self):
self.report['started_unix']=self.meta['captured_unix']-1;self.write_reports()
with self.assertRaisesRegex(ValueError,'chronology'):archiver.inspect(self.session)
class PackedChatArchiveTests(unittest.TestCase):
# Reuse the existing Core fixture construction without inheriting/rerunning its
# tests. Convert the private payload to the runner's actual schema-2 closure.
write_reports=PackedArchiveTests.write_reports
update_metadata=PackedArchiveTests.update_metadata
reseal_console=PackedArchiveTests.reseal_console
def setUp(self):
base=PackedArchiveTests();base.setUp();self.__dict__.update(base.__dict__)
runtime=self.session/'input/addons';candidate=self.fixture.root/'chat-candidate';candidate.mkdir()
for project in archiver.ADDON_ORDER:
folder=runtime/project;folder.mkdir(exist_ok=True)
(folder/'addon.gproj').write_bytes((runner.ROOT/'addons'/project/'addon.gproj').read_bytes())
(folder/'data.pak').write_bytes(('fixture-pak-'+project).encode())
(folder/'resourceDatabase.rdb').write_bytes(('fixture-db-'+project).encode())
shutil.copytree(folder,candidate/project)
self.packed={'project':'chat','input':runtime,'candidate':candidate,'session':self.session,'manifest':runner.packed_payload_inventory(runtime,'chat')}
command=runner.command_for(Path('/unused'),self.fixture.steam,Path('/compat'),self.fixture.profile,'chat',self.packed)
self.raw=runner.packed_snapshot(self.packed,command,self.fixture.profile);self.meta=json.loads(self.raw)
(self.native/'console.log').write_text(self.content())
self.report=runner.packed_seal(self.packed,self.raw,self.native)
self.report.update(started_unix=self.meta['captured_unix']+0.001,stopped_unix=self.meta['captured_unix']+30,result='packed-chat-started',session=str(self.native),project='chat',cleanup_confirmed=True,runner_error=None,failure_observed_during_polling=False,executable=command[command.index('--')+3],dedicated_server=False,control_only=False,packed_consumption_verified=True)
self.startup={'ready':True,'failed':False,'packed_only':True};self.write_reports()
def tearDown(self):self.fixture.tearDown()
def content(self):
folder=self.session/'input/addons'
lines=[f"00 ENGINE : dir: '{folder}'", "00 ENGINE : dir: './addons'", "00 ENGINE : dir: '$profile:../addons/'", '00 ENGINE : Loaded addons:', "00 ENGINE : gproj: './addons/core/core.gproj' guid: '5614BBCCBB55ED1C'", "00 ENGINE : gproj: './addons/data/ArmaReforger.gproj' guid: '58D0FB3206B6F859'"]
for project in archiver.ADDON_ORDER:
name,guid,deps=runner.PACKED_PROJECTS[project]
lines.append(f"00 ENGINE : gproj: '{folder/project}/addon.gproj' guid: '{guid}'")
lines.extend(["00 ENGINE : FileSystem: Adding package './addons/core/' (pak count: 2) to filesystem under name core", "00 ENGINE : FileSystem: Adding package './addons/data/' (pak count: 14) to filesystem under name ArmaReforger"])
for i,project in enumerate(archiver.ADDON_ORDER):
name,guid,deps=runner.PACKED_PROJECTS[project]
lines.extend([f"00 ENGINE : FileSystem: Adding package '{folder/project}/' (pak count: 1) to filesystem under name {name}", f"00 RESOURCES : ResourceDB: loading cache (id={i} name={name} path={folder/project}/resourceDatabase.rdb)"])
lines.extend(['00 SCRIPT : Module: GameLib; defines: "PLATFORM_WINDOWS"','00 SCRIPT : Module: Game; defines: "PLATFORM_WINDOWS"','00 SCRIPT : ERR ORDINARY: header verified project=chat systems=MissionSystems','00 SCRIPT : ERR ORDINARY: local participant verified project=chat phase=prison','00 SCRIPT : Rereintroduced [mission]: phase 0 -> 1'])
return '\n'.join(lines)+'\n'
def test_chat_preserves_all_fifteen_artifacts_and_schema(self):
destination,status=archiver.archive(self.session,'packed-chat',self.root);self.assertEqual(status,0)
self.assertEqual(runner.packed_payload_inventory(destination/'package','chat'),self.meta['artifacts'])
self.assertEqual(len(self.meta['artifacts']),15)
for name,info in self.meta['artifacts'].items():self.assertEqual(archiver.digest((destination/'package'/name).read_bytes()),info['sha256'])
kind=json.loads((destination/'archive-kind.json').read_text());self.assertEqual(kind['schema_version'],2);self.assertEqual(kind['kind'],'packed-chat-consumption');self.assertEqual(kind['addon_order'],archiver.ADDON_ORDER)
report=json.loads((destination/'report.json').read_text());self.assertTrue(report['packed_consumption_verified']);self.assertFalse(report['release_qualified'])
self.assertFalse((destination/'source-sha256.json').exists())
def test_missing_extra_or_traversal_manifest_key_refused(self):
original=dict(self.meta['artifacts'])
for operation in ('missing','extra','traversal'):
self.meta['artifacts']=dict(original)
if operation=='missing':del self.meta['artifacts']['map/data.pak']
elif operation=='extra':self.meta['artifacts']['foreign/data.pak']=original['map/data.pak']
else:self.meta['artifacts']['../data.pak']=self.meta['artifacts'].pop('map/data.pak')
self.update_metadata()
with self.subTest(operation=operation),self.assertRaisesRegex(ValueError,'artifact set'):archiver.inspect(self.session)
def test_schema_kind_project_order_and_core_result_cannot_mix(self):
original=dict(self.meta)
for key,value in [('schema_version',1),('kind','packed-core-runtime-input'),('project','core'),('addon_order',['core','chat','ai','intel','map'])]:
self.meta=dict(original);self.meta[key]=value;self.update_metadata()
with self.subTest(key=key),self.assertRaises(ValueError):archiver.inspect(self.session)
self.meta=original;self.update_metadata();self.report['result']='packed-core-started';self.write_reports()
with self.assertRaisesRegex(ValueError,'result'):archiver.inspect(self.session)
def test_wrong_companion_identity_is_not_accepted_as_hash_manifest(self):
path=self.packed['input']/'ai/addon.gproj';path.write_bytes((self.packed['input']/'core/addon.gproj').read_bytes())
data=path.read_bytes();self.meta['artifacts']['ai/addon.gproj']={'bytes':len(data),'sha256':archiver.digest(data)};self.update_metadata()
with self.assertRaises(ValueError):archiver.inspect(self.session)
def test_each_companion_loaded_mount_and_database_evidence_required(self):
for fragment in ("under name ERR_Ai", "name=ERR_Intel path=", "guid: '9AAEE9041D79809E'", 'header verified project=chat', 'local participant verified project=chat'):
self.reseal_console(self.content().replace(fragment,'missing'))
with self.subTest(fragment=fragment):self.assertFalse(archiver.inspect(self.session)[1]['packed_consumption_verified'])
def test_redirected_companion_and_loose_extra_search_fail_native_review(self):
text=self.content().replace(str(self.packed['input']/'ai'),'Z:/loose/ai')
self.reseal_console(text)
self.assertFalse(archiver.inspect(self.session)[1]['packed_consumption_verified'])
self.reseal_console(self.content()+"00 ENGINE : dir: '/loose/source/addons'\n")
self.assertFalse(archiver.inspect(self.session)[1]['packed_consumption_verified'])
def test_valid_failed_chat_run_archives_nonzero(self):
self.report.update(result='timeout',packed_consumption_verified=False);self.write_reports()
destination,status=archiver.archive(self.session,'failed-chat',self.root);self.assertEqual(status,1)
self.assertTrue((destination/'package/ai/data.pak').is_file())
def test_recorded_command_cannot_select_core_or_an_external_addon_parent(self):
original=list(self.meta['command'])
for flag,value in [('-addons','2528466DE220D91C'),('-addonsDir','Z:/loose/addons')]:
self.meta['command']=list(original);self.meta['command'][self.meta['command'].index(flag)+1]=value;self.update_metadata()
with self.subTest(flag=flag),self.assertRaisesRegex(ValueError,'Recorded command differs'):archiver.inspect(self.session)
if __name__=='__main__':unittest.main()
+34
View File
@@ -0,0 +1,34 @@
"""The experimental input mode must refuse unrelated projects before any setup."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT=Path(__file__).resolve().parents[1]
class FlightActionRunner(unittest.TestCase):
def test_scope_refused_without_profile_or_process_setup(self):
for project,smoke in [('core','victory'),('chat','victory'),('chat','powered-flight')]:
with self.subTest(project=project,smoke=smoke),tempfile.TemporaryDirectory() as directory:
root=Path(directory)
result=subprocess.run([sys.executable,str(ROOT/'tools/headless_workbench.py'),'--compat',str(root/'prefix'),'--profile',str(root/'profile'),'--project',project,'--smoke',smoke,'--rounds','1','--timeout','420','--flight-action-inputs'],capture_output=True,text=True)
self.assertNotEqual(result.returncode,0)
self.assertIn('flight-action-inputs requires the Core powered-flight fixture',result.stderr)
self.assertEqual(list(root.iterdir()),[])
def test_autohover_requires_explicit_action_input_fixture(self):
for project,smoke,extra in [('core','victory',[]),('chat','powered-flight',['--flight-action-inputs']),('core','powered-flight',[])]:
with self.subTest(project=project,smoke=smoke,extra=extra),tempfile.TemporaryDirectory() as directory:
root=Path(directory)
result=subprocess.run([sys.executable,str(ROOT/'tools/headless_workbench.py'),'--compat',str(root/'prefix'),'--profile',str(root/'profile'),'--project',project,'--smoke',smoke,'--rounds','1','--timeout','420','--flight-autohover',*extra],capture_output=True,text=True)
self.assertNotEqual(result.returncode,0)
self.assertIn('flight-autohover requires Core powered-flight with flight-action-inputs',result.stderr)
self.assertEqual(list(root.iterdir()),[])
self.assertIn("if a.flight_autohover: command += ['-errFlightAutohover', '1']",(ROOT/'tools/headless_workbench.py').read_text())
def test_valid_scope_still_requires_disposable_prefix(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory)
result=subprocess.run([sys.executable,str(ROOT/'tools/headless_workbench.py'),'--compat',str(root/'prefix'),'--profile',str(root/'profile'),'--project','core','--smoke','powered-flight','--rounds','1','--timeout','420','--flight-action-inputs'],capture_output=True,text=True)
self.assertNotEqual(result.returncode,0)
self.assertIn('must be a disposable cloned prefix',result.stderr)
self.assertEqual(list(root.iterdir()),[])
self.assertIn("if a.flight_action_inputs: command += ['-errFlightActionInputs', '1']",(ROOT/'tools/headless_workbench.py').read_text())
if __name__=='__main__':unittest.main()
+85
View File
@@ -0,0 +1,85 @@
"""Execute actual activity terminal-event ownership, detach and Update methods.
Invoker/engine boundaries are adapters; completion must still be observed natively.
"""
import re
from policy_harness import ROOT, body, run
p=ROOT/'addons/ai/Scripts/Game/ERR/ERR_MortarBattery.c'
def adapt(signature):
s=body(p,signature).replace('protected ','').replace('null','nullptr')
s=s.replace('SCR_AIActionBase action','Action* action').replace('EAIActionState ', 'State ').replace('EAIActionState.', 'State::')
s=s.replace('ERR_MortarSupplyState ', 'Supply ').replace('ERR_MortarSupplyState.', 'Supply::').replace('ERR_MortarSalvo.', 'Salvo::')
s=s.replace('string reason', 'std::string reason')
for name in ['Activity','m_ObservedActivity','m_Transfer','m_Readiness']:
s=s.replace(name+'.',name+'->')
for name in ['OnActivityCompleted','OnActivityFailed']:
s=s.replace('.Insert('+name+')', '.Insert(this, &Battery::'+name+')').replace('.Remove('+name+')', '.Remove(this, &Battery::'+name+')')
s=re.sub(r'^.*(?:Audit|Trace)\([^\n]*$', ';', s, flags=re.M)
return s
methods='\n'.join(adapt(s) for s in ['protected void WatchActivity(', 'protected void DetachActivity(', 'protected void OnActivityCompleted(', 'protected void OnActivityFailed(', 'protected void ObserveActivityTerminal(', 'protected EAIActionState ActivityTerminal(', 'void Update('])
cancel=adapt('void Cancel(')
# Capture the existing cancellation reason before testing its unchanged body.
cancel=cancel.replace('{','{\n lastCancel = reason;',1)
run(r'''
#include <cassert>
#include <string>
enum class State {EVALUATED,RUNNING,SUSPENDED,COMPLETED,FAILED};
enum class Supply {FAILED=-1,PENDING,READY};
struct Salvo {static constexpr float TIMEOUT_MS=120000;};
struct Text:std::string {using std::string::string;bool IsEmpty(){return empty();}};
struct Action;struct Battery;
struct Invoker {Battery* owner=nullptr;void(Battery::*callback)(Action*)=nullptr;int inserts=0,removes=0,calls=0;
void Insert(Battery* b,void(Battery::*f)(Action*)){owner=b;callback=f;inserts++;}
void Remove(Battery* b,void(Battery::*f)(Action*)){if(owner==b&&callback==f){owner=nullptr;callback=nullptr;removes++;}}
void Invoke(Action* a){if(owner&&callback){calls++;(owner->*callback)(a);}}
};
struct Action {State state=State::RUNNING;Invoker m_OnActionCompleted,m_OnActionFailed;int completes=0;
State GetActionState(){return state;}
void Complete(){completes++;state=State::COMPLETED;m_OnActionCompleted.Invoke(this);state=State::EVALUATED;}
void Fail(){state=State::FAILED;m_OnActionFailed.Invoke(this);state=State::EVALUATED;}
};
struct Transfer {int invalidates=0;void Invalidate(){invalidates++;}void Poll(float){}};
struct Readiness {Supply Poll(float){return Supply::READY;}};
struct Battery {
bool m_Stopped=false,CommandOpen=true,StartupActivated=true,operational=true,RefreshRejected=false,Released=false;
Action* Activity=nullptr;Action* m_ObservedActivity=nullptr;int OrderId=1,m_ObservedActivityOrder=0,OrderShots=8,TotalShots=8;
State m_ActivityTerminal=State::EVALUATED;float OrderedAt=0,AimedAt=100,AimValidatedAt=100;
Text SpawnFailure;std::string lastCancel;Readiness readiness;Readiness* m_Readiness=&readiness;Transfer transfer;Transfer* m_Transfer=&transfer;
void* RadioOrder=nullptr;void* PendingRadioReport=nullptr;void* PendingSupplyReport=nullptr;void* Refresh=nullptr;void* Report=this;void* AimSolver=this;void* AimSolution=this;
bool Operational(){return operational;}std::string OperationalReason(){return "physical crew unavailable";}void SpawnFailed(const char*){}void UpdateCrewDetail(float){}void FlushTraces(){}
'''+methods+cancel+r'''
};
int main(){
Action a;Battery b;b.Activity=&a;b.WatchActivity();assert(a.m_OnActionCompleted.inserts==1&&a.m_OnActionFailed.inserts==1);
a.Complete();assert(a.GetActionState()==State::EVALUATED&&b.m_ActivityTerminal==State::COMPLETED);
assert(b.CommandOpen&&b.Activity==&a&&a.completes==1); // no cancellation/reentrancy in callback
b.Update(100000);assert(!b.CommandOpen&&!b.Activity&&b.lastCancel=="native artillery activity COMPLETED (shot count is separate)");
assert(a.m_OnActionCompleted.calls==1&&a.m_OnActionCompleted.removes==1&&a.m_OnActionFailed.removes==1);
assert(a.completes==2&&b.OrderShots==8&&b.TotalShots==8); // cleanup Complete cannot create a second observed event
Action f;Battery failed;failed.Activity=&f;failed.WatchActivity();f.Fail();failed.Update(100000);
assert(failed.lastCancel=="native artillery activity FAILED"&&f.m_OnActionFailed.calls==1&&f.m_OnActionCompleted.calls==0);
Action noEvent;Battery timeout;timeout.Activity=&noEvent;timeout.WatchActivity();noEvent.state=State::EVALUATED;timeout.Update(120001);
assert(timeout.lastCancel=="120s artillery salvo timeout"); // eight physical shots alone are not a completion event
Action early;Battery partial;partial.Activity=&early;partial.OrderShots=2;partial.TotalShots=2;partial.WatchActivity();early.Complete();partial.Update(50000);
assert(partial.OrderShots==2&&partial.TotalShots==2&&partial.lastCancel=="native artillery activity COMPLETED (shot count is separate)"); // fixture must still reject incomplete salvo
Action old,current;Battery replacement;replacement.Activity=&old;replacement.WatchActivity();replacement.Cancel("replace");replacement.OrderId=2;replacement.CommandOpen=true;replacement.Activity=&current;replacement.WatchActivity();
replacement.OnActivityCompleted(&old);assert(replacement.m_ActivityTerminal==State::EVALUATED&&current.completes==0);
replacement.OrderId=3;replacement.OnActivityCompleted(&current);assert(replacement.m_ActivityTerminal==State::EVALUATED); // captured order mismatch
replacement.OrderId=2;current.Complete();replacement.OrderId=3;assert(replacement.ActivityTerminal()==State::EVALUATED); // old latch cannot escape to another order
Action foreign;replacement.OrderId=2;replacement.DetachActivity();replacement.WatchActivity();replacement.OnActivityCompleted(&foreign);assert(replacement.m_ActivityTerminal==State::EVALUATED);
Action cancelled;Battery explicitCancel;explicitCancel.Activity=&cancelled;explicitCancel.WatchActivity();explicitCancel.Cancel("HQ stopped");
assert(cancelled.m_OnActionCompleted.calls==0&&cancelled.m_OnActionCompleted.removes==1&&explicitCancel.m_ActivityTerminal==State::EVALUATED);
explicitCancel.OnActivityCompleted(&cancelled);assert(explicitCancel.m_ActivityTerminal==State::EVALUATED);
Action first;Battery precedence;precedence.Activity=&first;precedence.WatchActivity();first.Fail();precedence.OnActivityCompleted(&first);assert(precedence.ActivityTerminal()==State::FAILED);
Action stopped;Battery halted;halted.Activity=&stopped;halted.WatchActivity();halted.m_Stopped=true;stopped.Complete();assert(halted.m_ActivityTerminal==State::EVALUATED);halted.Update(120001);assert(halted.Activity==&stopped);halted.Cancel("stop cleanup");
Action polled;Battery fallback;fallback.Activity=&polled;fallback.WatchActivity();polled.state=State::COMPLETED;fallback.Update(100000);assert(fallback.lastCancel=="native artillery activity COMPLETED (shot count is separate)");
}
''')
order=body(p,'void Order(');cancel_source=body(p,'void Cancel(')
assert order.index('WatchActivity();')<order.index('AddAction(Activity)')
assert cancel_source.index('DetachActivity();')<cancel_source.index('Activity.Complete()')
assert 'OrderShots++' not in methods and 'TotalShots++' not in methods
assert 'Complete()' not in body(p,'protected void ObserveActivityTerminal(')
assert 'Cancel(' not in body(p,'protected void ObserveActivityTerminal(')
assert 'ACTIVITY EVENT' in body(p,'protected void ObserveActivityTerminal(')
print('PASS: actual native-event latch survives deselection, requires exact activity/order, detaches before cleanup, preserves failure/timeout and never infers completion from physical shot count')
+4 -4
View File
@@ -30,7 +30,7 @@ struct ERR_MortarPrediction {static constexpr float MAX_REPORT_AGE_MS=60000;
struct ERR_MortarAimRules {
'''+eq+'''};
struct Node;struct ERR_MortarAimSolution;
struct ERR_MortarBattery {static inline std::string SHELL="nativeHE";IEntity Gun=this;ERR_MortarAimSolution* AimSolution=nullptr;Node* AimSolver=nullptr;int OrderId=1,OrderShots=0;float AimedAt=1000; void Trace(const char*,const char*){}};
struct ERR_MortarBattery {static inline std::string SHELL="nativeHE";IEntity Gun=this;ERR_MortarAimSolution* AimSolution=nullptr;Node* AimSolver=nullptr;int OrderId=1,OrderShots=0;float AimedAt=1000,AimValidatedAt=1000; void Trace(const char*,const char*){}};
struct ERR_MortarAimSolution {IEntity Gun=nullptr;ERR_HQReport* Report=nullptr;int OrderId=1,Shot=0,Charge=0;vector GunOrigin{},Impact{500,0,0},AimTarget{500,500,0};float FlightTime=30;
'''+matches+'''};
struct ERR_MortarSystem {int rejects=0;bool Withhold(ERR_MortarBattery*,const char*){rejects++;return false;}const char* ContactFailure(ERR_MortarBattery*){return "stale";}};
@@ -46,10 +46,10 @@ struct Fixture {
bool attempt(float now=25000){return node.ERR_RevalidateAim(&battery,&system,&report,now);}
};
int main(){
Fixture stable; assert(stable.attempt()); assert(stable.node.solves==1&&stable.battery.AimedAt==25000);
Fixture stable; assert(stable.attempt()); assert(stable.node.solves==1&&stable.battery.AimedAt==1000&&stable.battery.AimValidatedAt==25000);
assert(stable.report.ObservedAt==0&&stable.battery.AimSolution==&stable.original);
assert(stable.attempt(45000)&&stable.node.solves==2); // fresh recomputation every renewal
assert(!stable.attempt(60001)&&stable.node.solves==2&&stable.battery.AimedAt==45000); // observation never renewed
assert(!stable.attempt(60001)&&stable.node.solves==2&&stable.battery.AimedAt==1000&&stable.battery.AimValidatedAt==45000); // observation never renewed
Fixture limit;assert(limit.attempt(60000));
Fixture future;future.report.ObservedAt=26000;assert(!future.attempt()&&future.node.solves==0);
Fixture gone;gone.battery.AimSolver=nullptr;assert(!gone.attempt()&&gone.node.solves==0);
@@ -76,7 +76,7 @@ source=firing.read_text();system=(r/'ERR_MortarSystem.c').read_text()
assert source.count('system.AllowShot(battery)')==2
assert 'SetVariableOut' not in body(firing,'bool ERR_RevalidateAim(')
assert 'ObservedAt =' not in source
assert 'now - battery.AimedAt > 10000' in system
assert 'now - battery.AimValidatedAt > 10000' in system
assert 'battery.AimSolver.ERR_RevalidateAim(battery, this, report, now)' in system
assert source.count('BallisticTable.GetAimHeightOfProjectileAltitudeFromSource(')==1
assert 'GetAmmoInitialSpeedCoefficients(ammo, coefficients)' in body(firing,'protected ERR_MortarAimSolution ERR_Solve(')
+242
View File
@@ -0,0 +1,242 @@
"""Execute production refresh/adoption methods against adversarial radio/solver outcomes.
The real scheduler, refresh, cancellation and projectile-bookkeeping bodies run.
Native route/ballistic/crew-readiness operations, battery Update and initial-order
progression are boundary adapters; this is not native delivery, death or firing.
"""
import re
from policy_harness import ROOT, body, run
r=ROOT/'addons/ai/Scripts/Game/ERR'
def cpp(path, signature):
code=body(r/path,signature).replace('protected ','')
for typ in ['ERR_MortarBattery','ERR_MortarSystem','ERR_HQReport','ERR_HQRoute','ERR_MortarRefresh','ERR_MortarAimSolution']:
code=re.sub(r'\b'+typ+r' (\w+)',typ+r'* \1',code)
for name in ['battery','report','current','incoming','update','route','commanded','updated','system','m_HQ','newest','selected','target','m_Transfer','Activity']:
code=code.replace(name+'.',name+'->')
# Members are owning/weak script references, both represented as pointers.
for name in ['Route','Report','Refresh','AimSolver']:
code=code.replace('.'+name+'.','.'+name+'->').replace('->'+name+'.','->'+name+'->')
code=code.replace('Route.Advance','Route->Advance')
for name in ['ERR_MortarPrediction','ERR_MortarRefreshRules','ERR_MortarAimRules','ERR_MortarBattery']:
code=code.replace(name+'.',name+'::')
code=code.replace('null','nullptr').replace('IEntity gun;','IEntity gun=nullptr;').replace('ResourceName ammo;','std::string ammo;')
code=code.replace('string reason','std::string reason')
# Only journal formatting is inert; authorization/solver mutations are real bodies.
code=re.sub(r'^.*Audit\([^\n]*$', ';', code, flags=re.M)
code=code.replace('vector.Distance','vector::Distance').replace('Math.AbsFloat','std::fabs')
code=code.replace('array<ref ERR_HQReport> reports = {};','Items<ERR_HQReport*> reports;')
code=re.sub(r'foreach \((ERR_MortarBattery|ERR_HQReport)\* (\w+) : (\w+)\)',r'for (\1* \2 : \3.values)',code)
code=re.sub(r'(ERR_MortarBattery|ERR_HQReport)\* (newest|selected|target);',r'\1* \2=nullptr;',code)
code=code.replace('Replication.IsServer()', 'Replication::IsServer()').replace('float.MAX','1e30f')
return code
constructor=cpp('ERR_MortarRefresh.c','void ERR_MortarRefresh(').replace('void ERR_MortarRefresh(', 'ERR_MortarRefresh(')
newer=cpp('ERR_MortarRefresh.c','static bool Newer(')
owns=cpp('ERR_MortarRefresh.c','bool Owns(')
delivered=cpp('ERR_MortarRefresh.c','bool DeliveredFor(')
fresh=cpp('ERR_MortarPrediction.c','static bool Fresh(')
equivalent=cpp('ERR_MortarAimSolution.c','static bool Equivalent(')
matches=cpp('ERR_MortarAimSolution.c','bool Matches(')
can=cpp('ERR_MortarBattery.c','bool CanRefresh(')
begin=cpp('ERR_MortarBattery.c','bool BeginRefresh(')
reject=cpp('ERR_MortarBattery.c','void RejectRefresh(')
advance=cpp('ERR_MortarSystem.c','protected void AdvanceRefresh(')
adopt=cpp('ERR_MortarFiring.c','bool ERR_AdoptReport(')
cancel=cpp('ERR_MortarBattery.c','void Cancel(')
projectile=cpp('ERR_MortarBattery.c','void ObserveProjectile(')
queue_method=cpp('ERR_MortarSystem.c','protected void QueueRefreshes(')
tick=cpp('ERR_MortarSystem.c','void Tick(')
# Supply owners before methods that dereference them, without replacing method logic.
can=can.replace('bool CanRefresh(', 'bool ERR_MortarBattery::CanRefresh(')
begin=begin.replace('bool BeginRefresh(', 'bool ERR_MortarBattery::BeginRefresh(')
reject=reject.replace('void RejectRefresh(', 'void ERR_MortarBattery::RejectRefresh(')
advance=advance.replace('void AdvanceRefresh(', 'void ERR_MortarSystem::AdvanceRefresh(')
adopt=adopt.replace('bool ERR_AdoptReport(', 'bool Node::ERR_AdoptReport(')
cancel=cancel.replace('void Cancel(', 'void ERR_MortarBattery::Cancel(').replace('std::string reason = "cancel requested"','std::string reason')
projectile=projectile.replace('void ObserveProjectile(', 'void ERR_MortarBattery::ObserveProjectile(')
queue_method=queue_method.replace('void QueueRefreshes(', 'void ERR_MortarSystem::QueueRefreshes(')
tick=tick.replace('void Tick(', 'void ERR_MortarSystem::Tick(')
run(r'''
#include <cassert>
#include <cmath>
#include <set>
#include <string>
#include <vector>
struct vector {float x=0,y=0,z=0;static float Distance(vector a,vector b){return std::sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)+(a.z-b.z)*(a.z-b.z));}static float DistanceXZ(vector a,vector b){a.y=0;b.y=0;return Distance(a,b);}};
using IEntity=void*;
template<class T>struct Items {std::vector<T> values;void Insert(T t){values.push_back(t);}};
struct Replication {static inline bool server=true;static bool IsServer(){return server;}};
struct ERR_HQReport {IEntity Target=this;int MessageId=1;float ObservedAt=0;bool Distress=false;vector Position{};std::string TravelMode="foot";std::string Description(float){return "contact";}ERR_HQReport* Snapshot(){return new ERR_HQReport(*this);}};
struct ERR_MortarPrediction {static constexpr float MAX_REPORT_AGE_MS=60000;
'''+fresh+r'''};
struct ERR_MortarRefreshRules {
'''+newer+r'''};
struct ERR_MortarAimRules {
'''+equivalent+r'''};
struct ERR_MortarBattery;struct ERR_MortarSystem;struct ERR_MortarRefresh;struct ERR_MortarAimSolution;struct Node;
struct ERR_HQRoute {int MessageId=9,state=0,calls=0;int Advance(float,int){calls++;return state;}};
struct HQ {Items<ERR_HQReport*> contacts;ERR_HQRoute* nextRoute=nullptr;int downlinks=0;int Journal(){return 0;}void SupportContacts(Items<ERR_HQReport*>& out,float){out=contacts;}ERR_HQRoute* Downlink(IEntity,const char*,std::string,int){downlinks++;return nextRoute;}};
struct Ledger {std::set<int> claimed;bool Available(int id,float,float){return !claimed.count(id);}bool Claim(int id,float,float){return claimed.insert(id).second;}};
struct FakeActivity {int completions=0;void Complete(){completions++;}};
struct Transfer {int invalidated=0;void Invalidate(){invalidated++;}};
struct ERR_MortarBattery {
static inline std::string SHELL="HE";
IEntity Gun=this,Group=this;int OrderId=1,OrderShots=0,TotalShots=0,RadioId=0;float OrderedAt=500,AimedAt=1000,AimValidatedAt=1000,NextOrder=0;vector Position{};
bool m_Stopped=false,CommandOpen=true,RefreshRejected=false,Rejected=false,Released=false,operational=true,StartupActivated=true;
FakeActivity originalActivity;FakeActivity* Activity=&originalActivity;Transfer transfer;Transfer* m_Transfer=&transfer;
ERR_HQRoute* RadioOrder=nullptr;ERR_HQReport* PendingRadioReport=nullptr;ERR_HQReport* PendingSupplyReport=nullptr;
std::string LastFailure;ERR_HQReport* Report=nullptr;ERR_MortarRefresh* Refresh=nullptr;ERR_MortarAimSolution* AimSolution=nullptr;Node* AimSolver=nullptr;Ledger m_ReportLedger;
bool Operational(){return operational;}
bool CanRefresh(ERR_HQReport*,float);bool BeginRefresh(ERR_HQReport*,ERR_HQRoute*,float);void RejectRefresh(std::string);
void Cancel(std::string reason="cancel requested");void ObserveProjectile(IEntity,IEntity,bool);void FlushTraces(){}void DetachActivity(){}
void Update(float){}bool SupplyPending(){return false;}int CountShells(){return 27;}
bool CanRequest(ERR_HQReport*,float){return false;}bool BeginRequest(ERR_HQReport*,float){assert(false);return false;}
};
struct ERR_MortarRefresh {
IEntity Gun=nullptr,Group=nullptr;int OrderId=0,RadioId=0;ERR_HQReport* Report=nullptr;ERR_HQRoute* Route=nullptr;
'''+constructor+owns+delivered+r'''};
struct ERR_MortarAimSolution {
IEntity Gun=nullptr;ERR_HQReport* Report=nullptr;int OrderId=1,Shot=0,Charge=0;vector GunOrigin{},Impact{500,0,0},AimTarget{500,500,0};float FlightTime=30;
'''+matches+r'''};
struct ERR_MortarSystem {
HQ hq;HQ* m_HQ=&hq;int failures=0;
Items<ERR_MortarBattery*> m_Batteries;bool m_Stopped=false;float m_NextDecision=1e9f,m_NextRefreshDecision=0;
bool Withhold(ERR_MortarBattery* b,const char* why){b->Rejected=true;b->LastFailure=why;failures++;return false;}
void AdvanceRefresh(ERR_MortarBattery*,float);
void QueueRefreshes(float);void Tick(float);void AdvanceOrder(ERR_MortarBattery*,float){}bool OutsideSettlements(vector){return true;}
};
struct Node {
static constexpr int PORT_ARTILLERY_ENTITY=1,PORT_AMMO_PREFAB=2;
IEntity portGun=nullptr;std::string portAmmo=ERR_MortarBattery::SHELL;ERR_MortarAimSolution* calculated=nullptr;int solves=0;
void GetVariableIn(int,IEntity& out){out=portGun;}void GetVariableIn(int,std::string& out){out=portAmmo;}
ERR_MortarAimSolution* ERR_Solve(ERR_MortarBattery*,ERR_MortarSystem*,ERR_HQReport*,float){solves++;return calculated;}
bool ERR_AdoptReport(ERR_MortarBattery*,ERR_MortarSystem*,ERR_HQReport*,float);
};
'''+can+begin+reject+adopt+advance+cancel+projectile+queue_method+tick+r'''
struct F {
ERR_MortarBattery b;ERR_HQReport old,next;ERR_HQRoute route;ERR_MortarSystem system;ERR_MortarAimSolution original,solved;Node node;
F(){next=old;next.MessageId=2;next.ObservedAt=20000;b.Report=&old;original.Gun=b.Gun;original.Report=&old;solved=original;node.portGun=b.Gun;node.calculated=&solved;b.AimSolver=&node;system.m_Batteries.Insert(&b);}
bool queue(){return b.BeginRefresh(&next,&route,25000);}
void aim(){b.AimSolution=&original;}
void delivery(float now=26000){route.state=1;system.AdvanceRefresh(&b,now);}
};
int main(){
F good;assert(good.queue());assert(!good.queue());assert(good.b.m_ReportLedger.claimed.count(2));
good.system.AdvanceRefresh(&good.b,25000);assert(good.b.Report==&good.old&&good.route.calls==1); // no instant HQ knowledge
good.delivery();assert(!good.b.Refresh&&!good.b.RefreshRejected&&good.b.Report->MessageId==2);
assert(good.b.Report->ObservedAt==20000&&good.b.OrderId==1&&good.b.OrderShots==0&&good.b.OrderedAt==500&&good.b.Activity);
assert(!good.queue());good.b.Report=&good.old;assert(!good.queue()); // claimed refresh cannot become replay after original restored
for(int fault=0;fault<10;fault++){
F f;
if(fault==0)f.next.Target=&f;
if(fault==1)f.next.ObservedAt=0;
if(fault==2)f.next.ObservedAt=-1;
if(fault==3)f.next.ObservedAt=25001;
if(fault==4)f.next.MessageId=1;
if(fault==5)f.next.MessageId=0;
if(fault==6)f.next.Distress=true;
if(fault==7)f.next.TravelMode="aircraft";
if(fault==8)f.b.CommandOpen=false;
if(fault==9)f.b.operational=false;
assert(!f.queue()&&f.b.m_ReportLedger.claimed.empty());
}
F absent;assert(!absent.b.BeginRefresh(&absent.next,nullptr,25000)&&absent.b.m_ReportLedger.claimed.empty());
F expired;assert(expired.queue());expired.delivery(80001);assert(expired.b.RefreshRejected&&expired.b.Report==&expired.old);
F failed;assert(failed.queue());failed.route.state=-1;failed.system.AdvanceRefresh(&failed.b,26000);assert(failed.b.RefreshRejected&&!failed.b.Refresh&&failed.b.Report==&failed.old);
F changed;assert(changed.queue());changed.b.Gun=&changed.old;changed.delivery();assert(changed.b.RefreshRejected);
F group;assert(group.queue());group.b.Group=&group.old;group.delivery();assert(group.b.RefreshRejected);
F stale;assert(stale.queue());stale.b.OrderId++;stale.delivery();assert(!stale.b.Refresh&&!stale.b.RefreshRejected&&stale.b.Report==&stale.old&&stale.route.calls==0);
F closed;assert(closed.queue());closed.b.CommandOpen=false;closed.delivery();assert(!closed.b.Refresh&&!closed.b.RefreshRejected&&closed.route.calls==0);
F committed;assert(committed.queue());committed.b.Released=true;committed.delivery();
assert(committed.b.Refresh&&!committed.b.Refresh->Route&&committed.b.Report==&committed.old&&committed.b.Released);
committed.b.Released=false;committed.b.OrderShots=1;committed.system.AdvanceRefresh(&committed.b,27000);
assert(!committed.b.Refresh&&committed.b.Report->MessageId==2&&committed.b.OrderShots==1&&committed.b.OrderId==1);
F failInFlight;assert(failInFlight.queue());failInFlight.b.Released=true;failInFlight.route.state=-1;failInFlight.system.AdvanceRefresh(&failInFlight.b,26000);
assert(failInFlight.b.RefreshRejected&&failInFlight.b.Released&&failInFlight.b.CommandOpen&&failInFlight.b.Report==&failInFlight.old); // callback ownership retained; future load gate closes
F validAim;validAim.aim();assert(validAim.queue());validAim.delivery();
assert(validAim.node.solves==1&&!validAim.b.RefreshRejected&&validAim.b.Report->MessageId==2&&validAim.original.Report==validAim.b.Report);
assert(validAim.b.AimedAt==1000&&validAim.b.AimValidatedAt==26000&&validAim.b.AimSolution==&validAim.original&&validAim.original.Impact.x==500);
for(int fault=0;fault<10;fault++){
F f;f.aim();assert(f.queue());
if(fault==0)f.solved.Impact.x+=.101f;
if(fault==1)f.solved.Charge++;
if(fault==2)f.solved.AimTarget.y+=.101f;
if(fault==3)f.solved.GunOrigin.y+=.051f;
if(fault==4)f.solved.FlightTime+=.051f;
if(fault==5)f.node.portGun=&f.old;
if(fault==6)f.node.portAmmo="other";
if(fault==7)f.b.AimSolver=nullptr;
if(fault==8)f.node.calculated=nullptr;
if(fault==9)f.b.AimedAt=27000;
f.delivery();assert(f.b.RefreshRejected&&f.b.Report==&f.old&&f.original.Report==&f.old&&f.b.AimValidatedAt==1000);
}
F notDelivered;notDelivered.aim();assert(notDelivered.queue());assert(!notDelivered.node.ERR_AdoptReport(&notDelivered.b,&notDelivered.system,notDelivered.b.Refresh->Report,26000));assert(notDelivered.node.solves==0);
F creep;creep.aim();creep.solved.Impact.x+=.06f;assert(creep.queue());creep.delivery();assert(!creep.b.RefreshRejected);
creep.next.MessageId=3;creep.next.ObservedAt=22000;creep.solved.Impact.x+=.06f;assert(creep.queue());creep.delivery(27000);
assert(creep.b.RefreshRejected&&creep.original.Impact.x==500&&creep.b.Report->MessageId==2&&creep.b.AimedAt==1000);
// Execute the actual HQ tick, refresh scheduler, cancellation and projectile
// bookkeeping together. Native route/crew/ballistic operations remain stubs.
F repeated;repeated.system.hq.contacts.Insert(&repeated.next);repeated.system.hq.nextRoute=&repeated.route;
repeated.system.Tick(25000);assert(repeated.system.hq.downlinks==1&&repeated.b.Refresh&&repeated.route.calls==0);
repeated.system.Tick(25001);repeated.system.Tick(30000);assert(repeated.system.hq.downlinks==1&&repeated.route.calls==2);
repeated.route.state=1;repeated.system.Tick(30001);assert(!repeated.b.Refresh&&repeated.b.Report->MessageId==2);
repeated.system.Tick(35000);repeated.system.hq.contacts.Insert(&repeated.old);repeated.system.Tick(40000);
assert(repeated.system.hq.downlinks==1&&repeated.b.OrderId==1&&repeated.b.Activity==&repeated.b.originalActivity);
assert(repeated.b.m_ReportLedger.claimed.size()==1&&repeated.b.Report->ObservedAt==20000);
F snapshot;assert(snapshot.queue());snapshot.next.MessageId=99;snapshot.next.ObservedAt=24999;
snapshot.delivery();assert(snapshot.b.Report->MessageId==2&&snapshot.b.Report->ObservedAt==20000); // in-flight packet is immutable
F newest;ERR_HQReport later=newest.next;later.MessageId=3;later.ObservedAt=22000;
newest.system.hq.contacts.Insert(&newest.next);newest.system.hq.contacts.Insert(&later);newest.system.hq.nextRoute=&newest.route;
newest.system.Tick(25000);assert(newest.b.Refresh->Report->MessageId==3&&newest.b.m_ReportLedger.claimed.count(3)&&!newest.b.m_ReportLedger.claimed.count(2));
F interrupted;assert(interrupted.queue());interrupted.b.Released=true;
interrupted.system.Tick(25000);interrupted.route.state=-1;interrupted.system.Tick(25001);
assert(interrupted.b.RefreshRejected&&interrupted.b.Released&&interrupted.b.CommandOpen&&interrupted.b.Report==&interrupted.old);
interrupted.system.Tick(25002);assert(interrupted.b.RefreshRejected&&interrupted.b.Released&&interrupted.route.calls==2);
interrupted.b.ObserveProjectile(&interrupted.next,nullptr,false); // actual physical-event bookkeeping, not native launch proof
assert(!interrupted.b.Released&&interrupted.b.OrderShots==1&&interrupted.b.TotalShots==1&&interrupted.b.Report==&interrupted.old);
interrupted.system.Tick(25003);assert(!interrupted.b.CommandOpen&&!interrupted.b.Activity&&!interrupted.b.Report&&!interrupted.b.RefreshRejected);
assert(interrupted.b.originalActivity.completions==1&&interrupted.b.transfer.invalidated==1&&interrupted.b.NextOrder==35003);
assert(interrupted.b.OrderShots==1&&interrupted.b.TotalShots==1&&interrupted.b.m_ReportLedger.claimed.count(2));
F noRoute;noRoute.system.hq.contacts.Insert(&noRoute.next);noRoute.b.Released=true;
noRoute.system.Tick(25000);assert(noRoute.system.hq.downlinks==1&&noRoute.b.RefreshRejected&&noRoute.b.Released);
noRoute.system.Tick(30000);assert(noRoute.system.hq.downlinks==1&&noRoute.b.CommandOpen); // latch cannot reset while committed
F deliveredThenRadioDies;assert(deliveredThenRadioDies.queue());deliveredThenRadioDies.b.Released=true;deliveredThenRadioDies.route.state=1;
deliveredThenRadioDies.system.Tick(26000);assert(deliveredThenRadioDies.b.Refresh&&!deliveredThenRadioDies.b.Refresh->Route&&deliveredThenRadioDies.route.calls==1);
deliveredThenRadioDies.route.state=-1; // loss AFTER complete delivery cannot erase the packet already received
deliveredThenRadioDies.b.ObserveProjectile(&deliveredThenRadioDies.next,nullptr,false);
deliveredThenRadioDies.system.Tick(27000);assert(!deliveredThenRadioDies.b.RefreshRejected&&deliveredThenRadioDies.b.Report->MessageId==2&&deliveredThenRadioDies.route.calls==1);
assert(deliveredThenRadioDies.b.AimedAt==-1&&deliveredThenRadioDies.b.AimValidatedAt==-1&&deliveredThenRadioDies.b.OrderShots==1);
F expiresInFlight;assert(expiresInFlight.queue());expiresInFlight.b.Released=true;expiresInFlight.route.state=1;
expiresInFlight.system.Tick(26000);expiresInFlight.system.Tick(80001);
assert(expiresInFlight.b.RefreshRejected&&expiresInFlight.b.Released&&expiresInFlight.b.Report==&expiresInFlight.old);
expiresInFlight.b.ObserveProjectile(&expiresInFlight.next,nullptr,false);expiresInFlight.system.Tick(80002);
assert(!expiresInFlight.b.CommandOpen&&expiresInFlight.b.TotalShots==1&&expiresInFlight.b.OrderShots==1);
F crewLost;assert(crewLost.queue());crewLost.b.operational=false;crewLost.delivery();
assert(crewLost.b.RefreshRejected&&crewLost.route.calls==0&&crewLost.b.Report==&crewLost.old); // readiness loss refuses before delivery
F cancelled;assert(cancelled.queue());ERR_MortarRefresh* cancelledPacket=cancelled.b.Refresh;
cancelled.b.Cancel("explicit cancellation");cancelled.route.state=1;cancelled.system.Tick(26000);
assert(!cancelled.b.Refresh&&!cancelled.b.Report&&cancelled.route.calls==0&&cancelled.b.m_ReportLedger.claimed.count(2));
cancelled.b.OrderId=2;cancelled.b.CommandOpen=true;cancelled.b.Activity=&cancelled.b.originalActivity;cancelled.b.Report=&cancelled.next;
cancelled.b.Refresh=cancelledPacket;cancelled.system.AdvanceRefresh(&cancelled.b,27000); // delayed old-order transaction cannot hit replacement
assert(!cancelled.b.Refresh&&!cancelled.b.RefreshRejected&&cancelled.b.CommandOpen&&cancelled.b.OrderId==2&&cancelled.b.Report==&cancelled.next&&cancelled.route.calls==0);
F stopped;assert(stopped.queue());stopped.system.m_Stopped=true;stopped.system.Tick(26000);assert(stopped.route.calls==0&&stopped.b.Refresh);
stopped.system.m_Stopped=false;Replication::server=false;stopped.system.Tick(26000);assert(stopped.route.calls==0);Replication::server=true;
}
''')
# Boundaries not modeled as native C++ operations: real HQ route construction,
# projectile latch, immutable report timestamps, existing cadence and cleanup.
system=(r/'ERR_MortarSystem.c').read_text(); battery=(r/'ERR_MortarBattery.c').read_text()
queue=body(r/'ERR_MortarSystem.c','protected void QueueRefreshes(')
assert 'm_HQ.SupportContacts(reports, now)' in queue and 'm_HQ.Downlink(' in queue
assert 'new SCR_AIStaticArtilleryActivity' not in queue+begin+advance+adopt
assert 'ObservedAt =' not in queue+begin+advance+adopt
assert 'OrderId++' not in queue+begin+advance+adopt
assert 'OrderedAt =' not in queue+begin+advance+adopt
assert 'battery.RefreshRejected' in body(r/'ERR_MortarSystem.c','bool AllowShot(')
assert '(battery.Rejected || battery.RefreshRejected) && !battery.Released' in system
assert 'Refresh = null;' in body(r/'ERR_MortarBattery.c','void Cancel(')
assert 'AimValidatedAt = -1;' in body(r/'ERR_MortarBattery.c','void ObserveProjectile(')
assert 'battery.AimedAt = now;' not in body(r/'ERR_MortarFiring.c','bool ERR_AdoptReport(')
assert 'battery.AimSolution = updated' not in adopt
print('PASS: actual refresh scheduler/delivery/cancellation/projectile methods reject interrupted or replayed reports, retain committed ownership, preserve immutable snapshots/aim geometry and isolate stopped or replaced orders; native radio/crew behavior remains separate')
+30 -1
View File
@@ -82,6 +82,32 @@ int main(){
assert(unauthorized.Poll(0)==S::FAILED && !unauthorized.m_Resolved);
}
''')
# Execute the production classification helper and its actual delivered-report
# lookup. The freshness adapter supplies only the report/time boundary; no
# inventory retry, reservation release or native firing is modeled here.
system_path=r/'ERR_MortarSystem.c'
classification='\n'.join(body(system_path,signature) for signature in ['ERR_HQReport Latest(', 'string ContactFailure(', 'string SupplyFailure('])
classification=classification.replace('ERR_HQReport Latest(', 'Report* Latest(').replace('ERR_MortarBattery battery','Battery* battery').replace('battery.Report','battery->Report').replace('ERR_MortarPrediction.Fresh','Prediction::Fresh').replace('return null;', 'return nullptr;').replace('string ', 'std::string ')
run('''#include <cassert>
#include <string>
struct Report {float observed=1000;bool valid=true;};
struct Battery {struct Report* Report=nullptr;};
struct Prediction {static bool Fresh(Report* report,float now){return report&&report->valid&&now>=report->observed&&now-report->observed<=60000;}};
struct System {
'''+classification+'''};
int main(){
System system;Battery battery;
assert(system.SupplyFailure(&battery,20000)=="no delivered target report");
Report report;battery.Report=&report;
assert(system.SupplyFailure(&battery,61000)=="next native load lacks confirmed physical gunner ammunition");
assert(system.SupplyFailure(&battery,61001)=="delivered target report expired or invalid");
assert(system.SupplyFailure(&battery,999)=="delivered target report expired or invalid");
report.valid=false;assert(system.SupplyFailure(&battery,20000)=="delivered target report expired or invalid");
report.valid=true;report.observed=20000;
assert(system.SupplyFailure(&battery,30000)=="next native load lacks confirmed physical gunner ammunition");
assert(battery.Report==&report&&report.observed==20000); // classification cannot retimestamp or replace a report
}
''')
# Source contract checks constrain integration that cannot run without the engine.
b=battery.read_text();sys=(r/'ERR_MortarSystem.c').read_text();f=(r/'ERR_MortarFiring.c').read_text();rd=readiness.read_text();tr=transfer.read_text()
update=body(battery,'void Update(float now)')
@@ -96,6 +122,9 @@ assert 'm_AttemptedShells.Insert(item)' in b and 'm_AttemptedShells.Contains(ite
assert 'm_Transfer.CapturedOrder() == OrderId' in b
assert 'battery.PendingSupplyReport' in sys and 'supply == ERR_MortarSupplyState.PENDING) return;' in sys
shoot=f[f.index('modded class SCR_AIStaticArtilleryBehavior_ShootingLogic'):]
assert shoot.index('battery.SupplyState(')<shoot.index('super.EOnTaskSimulate(')
assert shoot.index('battery.SupplyState(') < shoot.index('supply == ERR_MortarSupplyState.PENDING') < shoot.index('supply == ERR_MortarSupplyState.FAILED') < shoot.index('system.SupplyFailure(battery, now)') < shoot.index('super.EOnTaskSimulate(')
assert 'system.Withhold(battery, system.SupplyFailure(battery, now))' in shoot
supply_state=body(battery,'ERR_MortarSupplyState SupplyState(')
assert supply_state.index('m_Transfer.Poll(now)') < supply_state.index('if (!SupplyAuthorized(now))')
assert shoot.count('super.EOnTaskSimulate(')==1 and 'battery.SupplyGunner()' not in shoot
print('PASS: actual startup/generation/ownership/timeout/callback policies and native-counter wait; integration preserves one-shot allocation and pending-crew cancellation')
@@ -16,6 +16,7 @@ class FlightInputDiagnostics(unittest.TestCase):
self.assertNotIn(forbidden,source)
self.assertNotIn('modded class SCR_CharacterControllerComponent',source)
self.assertIn('super.ApplyPilot(controller, owner, dt);',source)
self.assertIn('super.PreparePilotActions(controller, owner, am, dt);',source)
self.assertIn('super.Diagnostic(now);',source)
def test_exact_actor_and_authority_before_observation(self):
+9 -5
View File
@@ -1,7 +1,7 @@
"""Execute actual fixture commit/event/closure gates; not native artillery simulation."""
from policy_harness import ROOT, body, run
p=ROOT/'addons/ai/Scripts/Game/ERR/ERR_NativeMortarFiringProbe.c'
policies='\n'.join(body(p, sig) for sig in ['static bool CommitFresh(', 'static bool EventBound(', 'static bool ExpiredClosure('])
policies='\n'.join(body(p, sig) for sig in ['static bool AimFresh(', 'static bool CommitFresh(', 'static bool EventBound(', 'static bool ExpiredClosure('])
policies=policies.replace('ERR_MortarPrediction.', 'ERR_MortarPrediction::').replace('string failure', 'Text failure')
methods='\n'.join(body(p,sig) for sig in ['void NativeCommit(', 'void NativeClosing(', 'void NativeProjectile('])
methods=methods.replace('ERR_MortarBattery battery','Battery* battery').replace('IEntity gun, IEntity projectile','Entity* gun, Entity* projectile').replace('string reason, string failure','Text reason, Text failure')
@@ -19,7 +19,7 @@ struct ERR_NativeMortarEvidenceRules {
'''+policies+'''};
struct Entity {int GetOrigin(){return 0;}};
struct Report {int MessageId=7;float ObservedAt=1000;};
struct Battery {bool CommandOpen=true,Released=true,stopped=false;int SourceId=7,OrderId=1,OrderShots=0;float AimedAt=19000,FlightTime=20;struct Report report;struct Report* Report=&report;bool Stopped(){return stopped;}};
struct Battery {bool CommandOpen=true,Released=true,stopped=false;int SourceId=7,OrderId=1,OrderShots=0;float AimedAt=19000,AimValidatedAt=19000,FlightTime=20;struct Report report;struct Report* Report=&report;bool Stopped(){return stopped;}};
struct Items {std::vector<Entity*> values;bool Contains(Entity* p){return std::find(values.begin(),values.end(),p)!=values.end();}void Insert(Entity* p){values.push_back(p);}};
template<class... Args>void PrintFormat(Args...args){}
struct Probe {
@@ -29,6 +29,10 @@ struct Probe {
};
int main(){
using Rules=ERR_NativeMortarEvidenceRules;
assert(Rules::AimFresh(1000,19000,20000)); // old actual command, genuinely recent equivalent solve
assert(!Rules::AimFresh(-1,19000,20000));assert(!Rules::AimFresh(21000,21000,20000));
assert(!Rules::AimFresh(19000,18000,20000));assert(!Rules::AimFresh(1000,20001,20000));
assert(!Rules::AimFresh(1000,9999,20000));assert(Rules::AimFresh(1000,10000,20000));
assert(Rules::EventBound(true,1,1,0,0,60999,90000)); // original fresh commit survives delayed native emission
assert(Rules::CommitFresh(1000,1000,61000));assert(!Rules::CommitFresh(1000,1000,61001));
assert(!Rules::CommitFresh(1100,1000,20000));assert(!Rules::CommitFresh(1000,1000,999));
@@ -43,7 +47,7 @@ int main(){
Probe nullEvent;nullEvent.m_Battery=&b;nullEvent.m_Gun=&gun;nullEvent.NativeProjectile(&gun,nullptr);assert(nullEvent.m_BadProjectile&&nullEvent.m_Events==0);
Probe stoppedObserver;stoppedObserver.m_Battery=&b;stoppedObserver.m_Gun=&gun;stoppedObserver.m_Stopped=true;stoppedObserver.NativeCommit(&b,20000);stoppedObserver.NativeProjectile(&gun,&projectile);assert(stoppedObserver.m_Commits==0&&stoppedObserver.m_Events==0&&!stoppedObserver.m_BadProjectile);
// Physical launch after report expiry is legitimate if its commit was fresh.
b=Battery();b.Report=&b.report;b.AimedAt=60500;Probe delayed;delayed.m_Battery=&b;delayed.m_Gun=&gun;delayed.m_Step=3;
b=Battery();b.Report=&b.report;b.AimedAt=60500;b.AimValidatedAt=60500;Probe delayed;delayed.m_Battery=&b;delayed.m_Gun=&gun;delayed.m_Step=3;
delayed.NativeCommit(&b,60999);clockNow=62999;delayed.NativeProjectile(&gun,&projectile);
assert(delayed.m_Events==1&&!delayed.m_BadProjectile);
for(int fault=0;fault<10;++fault){
@@ -52,12 +56,12 @@ int main(){
if(fault==1)b.Released=false;
if(fault==2)b.SourceId=8;
if(fault==3)b.AimedAt=-1;
if(fault==4)b.AimedAt=9999;
if(fault==4)b.AimValidatedAt=9999;
if(fault==5)b.FlightTime=0;
if(fault==6)q.m_Step=2;
if(fault==7)b.report.ObservedAt=1001;
if(fault==8)b.OrderId=2;
if(fault==9){clockNow=61001;b.AimedAt=61000;}
if(fault==9){clockNow=61001;b.AimedAt=61000;b.AimValidatedAt=61000;}
q.NativeCommit(&b,clockNow);assert(q.m_BadProjectile&&!q.m_CommitPending&&q.m_Commits==0);
}
b=Battery();b.Report=&b.report;clockNow=20000;Probe missing;missing.m_Battery=&b;missing.m_Gun=&gun;
+105
View File
@@ -0,0 +1,105 @@
"""Actual WORKBENCH refresh-fixture observation/acceptance policy, not native proof."""
import re
from policy_harness import ROOT, body, run
p=ROOT/'addons/ai/Scripts/Game/ERR/ERR_NativeMortarSalvoProbe.c'
base=ROOT/'addons/ai/Scripts/Game/ERR/ERR_NativeMortarFiringProbe.c'
def adapt(s):
s=s.replace('protected ','').replace('null','nullptr').replace('string kind','std::string kind').replace('string reason, string failure','std::string reason, std::string failure')
for typ in ['ERR_HQReport','ERR_MortarBattery','ERR_NativeMortarRefreshRecord','ERR_MortarRefresh']:
s=re.sub(r'\b'+typ+r' (\w+)',typ+r'* \1',s)
for name in ['battery','report','observation','record','initial','update','m_Battery']:
s=s.replace(name+'.',name+'->')
for name in ['Report','Route','Delivered']:
s=s.replace('.'+name+'.','.'+name+'->').replace('->'+name+'.','->'+name+'->')
s=s.replace('ERR_MortarSalvo.','ERR_MortarSalvo::').replace('ERR_MortarPrediction.','ERR_MortarPrediction::').replace('ERR_NativeMortarEvidenceRules.','ERR_NativeMortarEvidenceRules::').replace('vector.Distance','vector::Distance')
s=re.sub(r'foreach \(ERR_NativeMortarRefreshRecord\* (\w+) : m_Observations\)',r'for (ERR_NativeMortarRefreshRecord* \1 : m_Observations.values)',s)
s=s.replace('].ObservedAt',']->ObservedAt')
return s
rules=adapt(body(p,'static bool QueueUpdate('))
evidence='\n'.join(adapt(body(base,s)) for s in ['static bool CommitFresh(', 'static bool AimFresh('])
matching=adapt(body(p,'bool Matches('))
methods='\n'.join(adapt(body(p,s)) for s in ['protected ERR_NativeMortarRefreshRecord FindObservation(', 'protected bool AddObservation(', 'protected bool ReportKnown(', 'protected bool AllAccepted(', 'protected bool NoReportReplay(', 'void NativeAudit(', 'void NativeCommit(', 'void NativeClosing('])
# A return type is a reference in Enforce, a pointer in this policy adapter.
methods=methods.replace('ERR_NativeMortarRefreshRecord FindObservation(', 'ERR_NativeMortarRefreshRecord* FindObservation(')
run(r'''
#include <cassert>
#include <cmath>
#include <set>
#include <string>
#include <vector>
using IEntity=void*;using SCR_AIGroup=void*;
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();}};
struct ERR_MortarSalvo {static constexpr int ROUNDS=8;};
struct ERR_MortarPrediction {static constexpr float MAX_REPORT_AGE_MS=60000;};
struct Rules {'''+rules+r'''};
struct ERR_NativeMortarEvidenceRules {'''+evidence+r'''};
template<class T>struct Items {std::vector<T> values;int Count(){return values.size();}bool IsEmpty(){return values.empty();}void Insert(T t){values.push_back(t);}T operator[](int n){return values[n];}};
struct ERR_HQReport {int MessageId=1;float ObservedAt=0;IEntity Target=nullptr;SCR_AIGroup Source=nullptr;vector Position{},Velocity{};bool HasVelocity=true,Distress=false;ERR_HQReport* Snapshot(){return new ERR_HQReport(*this);}};
struct Route {bool Downlink=true;int MessageId=3;Items<int> Nodes;Route(){Nodes.Insert(1);}};
struct ERR_MortarBattery;
struct ERR_MortarRefresh {IEntity Gun=nullptr,Group=nullptr;int OrderId=1,RadioId=3;ERR_HQReport* Report=nullptr;struct Route* Route=nullptr;bool Owns(ERR_MortarBattery*);};
struct ERR_MortarBattery {IEntity Gun=this,Group=this,Activity=this;int SourceId=1,OrderId=1,OrderShots=0;bool Released=false,CommandOpen=true,RefreshRejected=false,stopped=false;float AimedAt=1000,AimValidatedAt=24000,FlightTime=20;ERR_HQReport* Report=nullptr;ERR_MortarRefresh* Refresh=nullptr;std::set<int> claimed;bool Stopped(){return stopped;}bool CanRequest(ERR_HQReport* r,float){return !claimed.count(r->MessageId);}};
bool ERR_MortarRefresh::Owns(ERR_MortarBattery* b){return b&&Gun==b->Gun&&Group==b->Group&&OrderId==b->OrderId;}
struct ERR_NativeMortarRefreshRecord {int MessageId=0;float ObservedAt=0;int DownlinkId=0;bool Accepted=false;ERR_HQReport* Delivered=nullptr;
'''+matching+r'''};
template<class...Args>void PrintFormat(Args... args){}
struct Probe {
bool m_RefreshCase=true,m_Stopped=false,m_SawDownlink=true,m_SawActivity=false,m_BadProjectile=false,m_CommitPending=false,m_ClosureSeen=false,m_ClosureComplete=false;
ERR_MortarBattery* m_Battery=nullptr;IEntity m_Target=this,m_Group=this,m_OriginalActivity=nullptr,m_OriginalRTO=this;vector m_Impact{};
int m_Source=1,m_Order=1,m_UpdatesAccepted=0,m_UpdatedCommits=0,m_Commits=0,m_Step=1,m_CommitOrder=0,m_CommitShot=0,m_CommitSource=0,m_Events=0;float m_ObservedAt=0,m_CommittedAt=0,m_CommitObservedAt=0;std::string m_BadReason;ERR_HQReport* m_Delivered=nullptr;
Items<ERR_NativeMortarRefreshRecord*> m_Observations;
'''+methods+r'''};
struct F {
Probe p;ERR_MortarBattery b;ERR_HQReport first,next;ERR_MortarRefresh update;Route route;
F(){p.m_Battery=&b;first.Target=p.m_Target;first.Source=p.m_Group;next=first;next.MessageId=2;next.ObservedAt=20000;b.Report=&first;b.claimed.insert(1);update.Gun=b.Gun;update.Group=b.Group;update.Report=&next;update.Route=&route;}
void initial(){assert(p.AddObservation(1,0));p.FindObservation(1)->DownlinkId=2;p.NativeAudit(&b,"AIM");assert(!p.m_BadProjectile);}
void queued(){assert(p.AddObservation(2,20000));b.Refresh=&update;b.claimed.insert(2);p.NativeAudit(&b,"UPDATE QUEUED");}
void accepted(){update.Route=nullptr;b.Report=&next;p.NativeAudit(&b,"UPDATE RECEIVED");b.Refresh=nullptr;}
};
int main(){
assert(Rules::QueueUpdate(true,false,0,0,1000,20000));assert(!Rules::QueueUpdate(true,false,0,0,1000,19999));
assert(!Rules::QueueUpdate(false,false,0,0,1000,20000));assert(!Rules::QueueUpdate(true,true,0,0,1000,20000));
assert(!Rules::QueueUpdate(true,false,4,0,1000,20000));assert(!Rules::QueueUpdate(true,false,0,0,1000,91000));
assert(!Rules::QueueUpdate(true,false,0,0,22000,20000));
F f;f.initial();assert(f.p.ReportKnown(&f.first));assert(!f.p.ReportKnown(&f.next));assert(!f.p.AllAccepted());
assert(!f.p.AddObservation(1,1));assert(!f.p.AddObservation(2,0));assert(!f.p.AddObservation(0,100));
f.queued();assert(!f.p.ReportKnown(&f.next)&&f.p.ReportKnown(&f.next,false));assert(!f.p.AllAccepted());
f.accepted();assert(!f.p.m_BadProjectile&&f.p.ReportKnown(&f.next)&&f.p.m_UpdatesAccepted==1);
f.b.Released=true;f.p.NativeCommit(&f.b,25000);assert(!f.p.m_BadProjectile&&f.p.m_UpdatedCommits==1&&f.p.m_CommitSource==2&&f.p.m_CommitObservedAt==20000);
assert(f.p.AllAccepted()&&f.p.NoReportReplay(26000));f.b.claimed.erase(2);assert(!f.p.NoReportReplay(26000));f.b.claimed.insert(2);
f.b.OrderShots=8;f.p.m_Events=8;f.p.m_Commits=8;f.p.m_CommitPending=false;f.b.Released=false;
f.p.NativeClosing(&f.b,"native artillery activity COMPLETED (shot count is separate)","");assert(f.p.m_ClosureComplete&&!f.p.m_BadProjectile);
for(int fault=0;fault<7;fault++){
F a;a.initial();a.queued();
if(fault==0)a.update.OrderId=2;
if(fault==1)a.b.Activity=&a.next;
if(fault==2)a.next.ObservedAt=20001;
if(fault==3)a.next.Position.x=.02f;
if(fault==4)a.next.Target=&a.next;
if(fault==5)a.next.Source=&a.next;
if(fault==6)a.next.Velocity.x=1;
a.accepted();assert(a.p.m_BadProjectile&&!a.p.FindObservation(2)->Accepted);
}
F beforeRoute;beforeRoute.initial();beforeRoute.queued();beforeRoute.b.Report=&beforeRoute.next;beforeRoute.p.NativeAudit(&beforeRoute.b,"UPDATE RECEIVED");assert(beforeRoute.p.m_BadProjectile);
F committed;committed.initial();committed.queued();committed.b.Released=true;committed.accepted();assert(committed.p.m_BadProjectile);
F duplicate;duplicate.initial();duplicate.queued();duplicate.accepted();duplicate.b.Refresh=&duplicate.update;duplicate.p.NativeAudit(&duplicate.b,"UPDATE RECEIVED");assert(duplicate.p.m_BadProjectile&&duplicate.p.m_UpdatesAccepted==1);
F recreated;recreated.initial();recreated.p.NativeAudit(&recreated.b,"AIM");assert(recreated.p.m_BadProjectile);
F unaccepted;unaccepted.initial();unaccepted.queued();unaccepted.b.Report=&unaccepted.next;unaccepted.b.Released=true;unaccepted.p.NativeCommit(&unaccepted.b,25000);assert(unaccepted.p.m_BadProjectile&&unaccepted.p.m_Commits==0);
F extra;extra.initial();extra.queued();extra.accepted();extra.p.m_UpdatedCommits=1;assert(extra.p.AddObservation(3,40000));assert(!extra.p.AllAccepted());
F ignored;ignored.p.m_RefreshCase=false;ignored.p.NativeAudit(&ignored.b,"AIM");assert(!ignored.p.m_BadProjectile);assert(ignored.p.ReportKnown(&ignored.first));
}
''')
source=p.read_text();queue=body(p,'int ERR_QueueMortarRefreshReport(')
assert 'observedAt = GetGame().GetWorld().GetWorldTime()' in queue
assert 'm_Network.Route(radio)' in queue and 'unit.Radio != radio' in queue
assert 'unit.PendingReport = report; unit.Uplink = route;' in queue
assert 'm_Contacts.' not in queue and '.Order(' not in queue and 'BeginRefresh(' not in queue
assert 'report.ObservedAt = observedAt' in queue
assert 'outcome == "mortar-salvo" || outcome == "mortar-refresh"' in source
assert 'm_Battery.RadioOperator != m_OriginalRTO' in source
assert 'm_Battery.Report.MessageId != m_CommitSource' in body(p,'void NativeProjectile(')
assert 'ERR_NativeMortarSalvoRules.QueueUpdate(' in body(p,'int Tick(')
assert 'm_Battery.Report =' not in body(p,'int Tick(')
print('PASS: actual refresh-fixture gates pin immutable observations/actual downlinks/original activity, reject unaccepted or relabelled commits, stop queueing early and require all queued contacts claimed before replay')
+91
View File
@@ -0,0 +1,91 @@
"""Execute the actual full-salvo evidence gates; this is not a native firing result."""
from policy_harness import ROOT, body, run
p=ROOT/'addons/ai/Scripts/Game/ERR/ERR_NativeMortarSalvoProbe.c'
base=ROOT/'addons/ai/Scripts/Game/ERR/ERR_NativeMortarFiringProbe.c'
rules='\n'.join(body(base, sig) for sig in ['static bool AimFresh(', 'static bool CommitFresh(', 'static bool EventBound('])
rules=rules.replace('ERR_MortarPrediction.', 'ERR_MortarPrediction::')
complete=body(p,'static bool Complete(').replace('ERR_MortarSalvo.', 'ERR_MortarSalvo::')
methods='\n'.join(body(p,sig) for sig in ['void NativeCommit(', 'void NativeClosing(', 'void NativeProjectile('])
for a,b in [('ERR_MortarBattery battery','Battery* battery'),('IEntity gun, IEntity projectile','Entity* gun, Entity* projectile'),('string reason, string failure','std::string reason, std::string failure'),('m_Battery.','m_Battery->'),('battery.','battery->'),('Report.','Report->'),('projectile.GetOrigin()','projectile->GetOrigin()'),('GetGame().GetWorld().GetWorldTime()','clockNow'),('ERR_NativeMortarEvidenceRules.','ERR_NativeMortarEvidenceRules::'),('ERR_MortarSalvo.','ERR_MortarSalvo::')]: methods=methods.replace(a,b)
run('''#include <cassert>
#include <vector>
#include <algorithm>
#include <string>
float clockNow=20000;
struct ERR_MortarPrediction {static constexpr float MAX_REPORT_AGE_MS=60000;};
struct ERR_MortarSalvo {static constexpr int ROUNDS=8;};
struct ERR_NativeMortarEvidenceRules {'''+rules+'''};
struct Rules {'''+complete+'''};
struct Entity {int GetOrigin(){return 0;}};
struct Report {int MessageId=7;float ObservedAt=1000;};
struct Battery {void* Activity=nullptr;void* Refresh=nullptr;bool RefreshRejected=false;bool CommandOpen=true,Released=true,stopped=false;int SourceId=7,OrderId=1,OrderShots=0;float AimedAt=19000,AimValidatedAt=19000,FlightTime=20;struct Report report;struct Report* Report=&report;bool Stopped(){return stopped;}};
struct Items {std::vector<Entity*> values;bool Contains(Entity* p){return std::find(values.begin(),values.end(),p)!=values.end();}void Insert(Entity* p){values.push_back(p);}};
template<class... Args>void PrintFormat(Args...args){}
struct Probe {
void* m_OriginalActivity=nullptr;bool m_RefreshCase=false;int m_UpdatedCommits=0,m_CommitSource=0;float m_CommitObservedAt=0;
bool m_Stopped=false,m_BadProjectile=false,m_CommitPending=false,m_ClosureSeen=false,m_ClosureComplete=false;
Battery* m_Battery;Entity* m_Gun;Items m_Projectiles;int m_Events=0,m_Step=1,m_Source=7,m_Order=1,m_Commits=0,m_CommitOrder=0,m_CommitShot=0;float m_ObservedAt=1000,m_CommittedAt=0;std::string m_BadReason;
bool ReportKnown(Report* report){return report&&report->MessageId==m_Source&&report->ObservedAt==m_ObservedAt;}
bool AllAccepted(){return !m_RefreshCase;}
'''+methods+'''};
int main(){
assert(Rules::Complete(8,8,8,8,19,false));
for(int count=0;count<8;count++)assert(!Rules::Complete(count,count,count,count,27-count,false));
assert(!Rules::Complete(9,9,9,9,18,false));
assert(!Rules::Complete(8,7,8,8,19,false));
assert(!Rules::Complete(8,8,7,8,19,false));
assert(!Rules::Complete(8,8,8,7,19,false));
assert(!Rules::Complete(8,8,8,8,20,false));
assert(!Rules::Complete(8,8,8,8,18,false));
assert(!Rules::Complete(8,8,8,8,19,true));
Entity gun,foreign,shots[9];Battery b;Probe p;p.m_Battery=&b;p.m_Gun=&gun;
p.NativeProjectile(&foreign,&shots[0]);assert(p.m_Events==0);
for(int i=0;i<8;i++){
b.OrderShots=i;b.AimedAt=clockNow;b.AimValidatedAt=clockNow;
p.NativeCommit(&b,clockNow);assert(p.m_CommitPending&&!p.m_BadProjectile&&p.m_Commits==i+1);
clockNow+=500;p.NativeProjectile(&gun,&shots[i]);assert(p.m_Events==i+1&&!p.m_CommitPending&&!p.m_BadProjectile);
}
b.OrderShots=8;p.NativeClosing(&b,"native artillery activity COMPLETED (shot count is separate)","");
assert(p.m_ClosureSeen&&p.m_ClosureComplete&&!p.m_BadProjectile);
p.NativeCommit(&b,clockNow);assert(p.m_BadProjectile&&p.m_Commits==8); // ninth commit forbidden
for(int fault=0;fault<11;fault++){
Battery f;Probe q;q.m_Battery=&f;q.m_Gun=&gun;clockNow=20000;
if(fault==0)f.report.ObservedAt=1001; // retimestamped contact
if(fault==1)f.OrderId=2;
if(fault==2)f.SourceId=8;
if(fault==3)f.OrderShots=1; // skipped physical event
if(fault==4)q.m_Step=2; // post-completion replay
if(fault==5)q.m_CommitPending=true;
if(fault==6){clockNow=61001;f.AimedAt=61000;f.AimValidatedAt=61000;}
if(fault==7)f.AimValidatedAt=9999;
if(fault==8)f.Released=false;
if(fault==9)f.CommandOpen=false;
if(fault==10)f.FlightTime=0;
q.NativeCommit(&f,clockNow);assert(q.m_BadProjectile&&q.m_Commits==0);
}
Battery f;Probe q;q.m_Battery=&f;q.m_Gun=&gun;clockNow=20000;
q.NativeCommit(&f,clockNow);q.NativeProjectile(&gun,&shots[0]);q.NativeProjectile(&gun,&shots[0]);assert(q.m_BadProjectile&&q.m_Events==1);
for(int fault=0;fault<4;fault++){
Battery f;Probe q;q.m_Battery=&f;q.m_Gun=&gun;q.m_Events=8;q.m_Commits=8;f.OrderShots=8;
std::string reason="native artillery activity COMPLETED (shot count is separate)";
if(fault==0){q.m_Events=4;q.m_Commits=4;f.OrderShots=4;}
if(fault==1)reason="native aim or firing authorization rejected";
if(fault==2)f.stopped=true;
if(fault==3)q.m_CommitPending=true;
q.NativeClosing(&f,reason,"delivered target report expired");assert(q.m_BadProjectile);
}
Battery stopped;Probe quiet;quiet.m_Battery=&stopped;quiet.m_Gun=&gun;quiet.m_Stopped=true;
quiet.NativeCommit(&stopped,20000);quiet.NativeProjectile(&gun,&shots[0]);quiet.NativeClosing(&stopped,"HQ stopped","");
assert(!quiet.m_BadProjectile&&quiet.m_Events==0&&quiet.m_Commits==0&&!quiet.m_ClosureSeen);
}
''')
# Native hooks must count only real events and retain the existing superclass behavior.
s=p.read_text()
assert s.startswith('#ifdef WORKBENCH') and 'outcome == "mortar-salvo"' in s
assert 'super.OnWeaponFired(effectEntity, muzzle, projectileEntity);' in s
assert 'super.ReserveShellAirspace(now);' in s and 'super.Cancel(reason);' in s
assert 'm_StopUntil = now + 25000' in s
assert 'm_Battery.CanRequest(m_Delivered, now)' in s
assert 'm_Battery.Order(' not in s and 'm_Battery.BeginRequest(' not in s
assert 'm_Battery.Stop();' in body(p,'void Stop(')
print('PASS: actual full-salvo gates require eight fresh committed physical launches, natural closure and exact finite inventory; reject partial/extra/replayed events')
+392
View File
@@ -0,0 +1,392 @@
"""Offline packaging orchestration tests. Native child/audio are mocked, never launched."""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT/'tools'))
import native_package as n
class NativePackageTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='erri-package-test-')
self.addCleanup(self.temp.cleanup)
self.home = Path(self.temp.name)
self.source = self.home/'original'
shutil.copytree(ROOT/'addons', self.source/'addons')
(self.source/'docs').mkdir()
for name in ('addons.json', 'vanilla-resources.json', 'acceptance.json'):
shutil.copy2(ROOT/'docs'/name, self.source/'docs'/name)
self.session = self.home/'session'
self.compat = self.home/'compat'
(self.compat/'pfx').mkdir(parents=True)
(self.compat/'pfx/system.reg').write_text('test only')
self.steam = self.home/'steam'
command = n.command_for(self.steam, self.session, 'core')
i = command.index('--')
for path in (Path(command[i+1]), Path(command[i+3]), self.steam/'steamapps/common/Arma Reforger/addons/data/ArmaReforger.gproj'):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('synthetic installed-file double; never executed')
def test_closure_order(self):
self.assertEqual(n.project_order('core'), ['core'])
self.assertEqual(n.project_order('ai'), ['core', 'ai'])
self.assertEqual(n.project_order('chat'), ['core', 'ai', 'intel', 'map', 'chat'])
with self.assertRaises(ValueError): n.project_order('original')
def test_supported_command_is_private_and_never_publishes_or_plays(self):
command = n.command_for(self.steam, self.session, 'core')
self.assertIn('-packAddon', command)
self.assertIn('-wbModule=ResourceManager', command)
self.assertIn('-noBackend', command)
self.assertIn('-disableCrashReporter', command)
self.assertEqual(command[command.index('--backend')+1], 'headless')
for flag in ('-gproj', '-addonsDir', '-profile', '-packAddonDir', '-packAddonCacheDir'):
self.assertTrue(command[command.index(flag)+1].startswith('Z:'+str(self.session)))
for flag in ('-publishAddon', '-wbSilent', '-plugin', '-errPlay', '-load', '-exitAfter'):
self.assertNotIn(flag, command)
def test_copy_validated_payload_and_detect_changed_authored_bits(self):
before = n.inventory(self.source)
(self.source/'addons/core/resourceDatabase.rdb').write_text('generated')
authored = n.copy_payload(self.source, self.session)
self.assertEqual(authored, before)
self.assertFalse((self.session/'source/addons/core/resourceDatabase.rdb').exists())
self.assertFalse((self.session/'source/docs').exists())
self.assertTrue(n.copied_authored_unchanged(self.session/'source', authored))
# Generated native cache changes do not excuse authored changes.
(self.session/'source/addons/core/resourceDatabase.rdb').write_text('new native cache')
self.assertTrue(n.copied_authored_unchanged(self.session/'source', authored))
name = next(iter(authored))
(self.session/'source'/name).write_text('changed')
self.assertFalse(n.copied_authored_unchanged(self.session/'source', authored))
self.assertEqual(n.inventory(self.source), before)
def test_invalid_source_refused_before_session_creation(self):
path = self.source/'addons/core/addon.gproj'
path.write_text(path.read_text().replace('ERR_Core', 'Original'))
with self.assertRaises(ValueError): n.copy_payload(self.source, self.session)
self.assertFalse(self.session.exists())
def test_session_never_reused_or_outside_tmp(self):
fresh = Path('/tmp')/('erri-fresh-'+self.home.name)
n.validate_session(self.source, self.steam, self.compat, fresh)
self.session.mkdir()
with self.assertRaises(ValueError): n.validate_session(self.source, self.steam, self.compat, self.session)
with self.assertRaises(ValueError): n.validate_session(self.source, self.steam, self.compat, Path('/home/not-allowed'))
with self.assertRaises(FileExistsError): n.copy_payload(self.source, self.session)
link = self.home/'link'
link.symlink_to(self.session, target_is_directory=True)
with self.assertRaises(ValueError): n.validate_session(self.source, self.steam, self.compat, link)
def test_raw_native_failures_never_become_success(self):
path = self.home/'console.log'
for content in ['SCRIPT (E): compile failed', 'ENGINE (F): crash', 'Virtual Machine Exception', 'Packing failed', 'Can\'t compile "Game"']:
path.write_text(content)
logs, errors = n.log_evidence([path])
self.assertTrue(errors, content)
self.assertFalse(n.candidate_ok(0, 'process-exited', True, True, logs, errors, {'data.pak': {'bytes': 50}}))
path = self.home/'error.log'
path.write_text('unexpected failure without a category prefix')
self.assertTrue(n.log_evidence([path])[1])
def test_success_requires_clean_exit_logs_cleanup_hashes_and_real_nonempty_file(self):
folder = self.home/'packages'; folder.mkdir()
self.assertFalse(n.candidate_ok(0, 'process-exited', True, True, ['log'], [], n.package_inventory(folder)))
(folder/'data.pak').write_bytes(b'')
self.assertFalse(n.candidate_ok(0, 'process-exited', True, True, ['log'], [], n.package_inventory(folder)))
(folder/'data.pak').write_bytes(b'SYNTHETIC TEST PACKAGE BYTES, NOT NATIVE')
packages = n.package_inventory(folder)
self.assertTrue(n.candidate_ok(0, 'process-exited', True, True, ['log'], [], packages))
for args in [(1,'process-exited',True,True,['log']), (0,'timeout',True,True,['log']),
(0,'process-exited',False,True,['log']), (0,'process-exited',True,False,['log']),
(0,'process-exited',True,True,[])]:
self.assertFalse(n.candidate_ok(*args, [], packages))
self.assertEqual(packages['data.pak']['sha256'], hashlib.sha256((folder/'data.pak').read_bytes()).hexdigest())
(folder/'external.pak').symlink_to(self.source/'addons/core/addon.gproj')
with self.assertRaises(ValueError): n.package_inventory(folder)
def native_double(self, authored, timeout=30, mutate=False, failure=None, native_code=0, write_status=True, launcher_code=0):
child = Mock(); child.poll.return_value = launcher_code
def spawn(*args, **kwargs):
self.assertNotIn('WINEPREFIX', kwargs['env'])
self.assertNotIn('STEAM_COMPAT_DATA_PATH', kwargs['env'])
# Explicitly fake outputs: never evidence for a real native build.
folder = self.session/'profiles/core/logs/logs_test'; folder.mkdir(parents=True)
(folder/'console.log').write_text('SYNTHETIC NATIVE PROCESS DOUBLE\n'+(failure or ''))
(self.session/'packages/core/data.pak').write_bytes(b'SYNTHETIC TEST ONLY')
if write_status:
native_command = n.command_for(self.steam, self.session, 'core')
native_command = native_command[native_command.index('--')+1:]
(self.session/'evidence/core/native-exit.json').write_text(json.dumps({'command': native_command, 'exit_code': native_code}))
if mutate:
(self.session/'source'/next(iter(authored))).write_text('changed authored byte')
return child
def check_output(args, **kwargs):
return '[]' if 'sink-inputs' in args else '987'
with patch.object(n, 'assert_no_game'), patch.object(n, 'assert_prefix_idle'), \
patch.object(n, 'cleanup') as cleanup, patch.object(n.subprocess, 'Popen', side_effect=spawn), \
patch.object(n.subprocess, 'check_output', side_effect=check_output), patch.object(n.subprocess, 'run') as process:
result = n.run_project(self.steam, self.compat, self.session, 'core', timeout, authored)
cleanup.assert_called_once()
self.assertTrue(any(call.args[0] == ['pactl', 'unload-module', '987'] for call in process.call_args_list))
return result
def test_mock_process_candidate_keeps_all_qualification_pending(self):
authored = n.copy_payload(self.source, self.session)
report = self.native_double(authored)
self.assertTrue(report['candidate_created'])
for key in ('release_qualified', 'package_contents_verified', 'native_consumption_verified', 'tool_completion_semantics_verified'):
self.assertFalse(report[key])
self.assertIn('sha256', next(iter(report['logs'].values())))
self.assertTrue((self.session/'evidence/core/launch.json').is_file())
def test_mock_process_authored_mutation_fails_after_clean_exit(self):
authored = n.copy_payload(self.source, self.session)
report = self.native_double(authored, mutate=True)
self.assertFalse(report['candidate_created'])
self.assertFalse(report['copied_authored_unchanged'])
def test_mock_process_timeout_cleans_only_owned_child_and_cannot_qualify(self):
authored = n.copy_payload(self.source, self.session)
report = self.native_double(authored, timeout=0)
self.assertEqual(report['result'], 'timeout')
self.assertFalse(report['candidate_created'])
self.assertIsNone(report['launcher_exit_code'])
def test_mock_process_native_error_with_output_still_fails(self):
authored = n.copy_payload(self.source, self.session)
report = self.native_double(authored, failure='ENGINE (E): synthetic failure')
self.assertFalse(report['candidate_created'])
self.assertTrue(report['errors'])
def test_orphaned_prefix_after_launcher_exit_cannot_qualify(self):
import itertools
authored = n.copy_payload(self.source, self.session)
child = Mock(); child.poll.return_value = 0
probes = 0
def idle(*args):
nonlocal probes
probes += 1
if probes > 2: raise ValueError('synthetic owned prefix child remains')
with patch.object(n, 'assert_no_game'), patch.object(n, 'assert_prefix_idle', side_effect=idle), \
patch.object(n, 'cleanup') as cleanup, patch.object(n.subprocess, 'Popen', return_value=child), \
patch.object(n.subprocess, 'check_output', side_effect=lambda args, **kw: '[]' if 'sink-inputs' in args else '987'), \
patch.object(n.subprocess, 'run'), patch.object(n.time, 'monotonic', side_effect=itertools.count(0, 6)), patch.object(n.time, 'sleep'):
report = n.run_project(self.steam, self.compat, self.session, 'core', 100, authored)
cleanup.assert_called_once()
self.assertEqual(report['result'], 'native-or-launcher-exited-prefix-active')
self.assertFalse(report['candidate_created'])
def test_audio_changes_only_fresh_application_or_owned_prefix(self):
streams = [{'index': 1, 'properties': {'application.id': 'own'}},
{'index': 2, 'properties': {'application.id': 'user-music'}}]
with patch.object(n.subprocess, 'check_output', return_value=json.dumps(streams)), patch.object(n.subprocess, 'run') as run:
n.quiet_streams('own', 'private-sink', self.compat)
self.assertEqual([call.args[0] for call in run.call_args_list],
[['pactl', 'move-sink-input', '1', 'private-sink'], ['pactl', 'set-sink-input-mute', '1', '1']])
def test_orchestration_stops_after_first_failed_dependency_without_launch(self):
import uuid
import contextlib
import io
fresh = Path('/tmp')/('erri-package-orchestration-'+uuid.uuid4().hex)
self.addCleanup(lambda: shutil.rmtree(fresh, ignore_errors=True))
args = argparse.Namespace(steam=self.steam, compat=self.compat, session=fresh, project='chat', timeout=30)
with patch.object(n, 'assert_no_game'), patch.object(n, 'assert_prefix_idle'), patch.object(n.shutil, 'which', return_value='/mock'), \
patch.object(n, 'run_project', return_value={'candidate_created':False}) as run, patch.object(n.subprocess, 'Popen') as child, contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(n.run(args, self.source), 1)
run.assert_called_once()
self.assertEqual(run.call_args.args[3], 'core')
child.assert_not_called()
summary = json.loads((fresh/'package-report.json').read_text())
self.assertFalse(summary['all_candidates_created'])
self.assertFalse(summary['published'])
self.assertTrue(summary['source_repository_unchanged'])
def test_same_prefix_lock_refuses_without_creating_session(self):
import fcntl
import uuid
fresh = Path('/tmp')/('erri-package-lock-'+uuid.uuid4().hex)
args = argparse.Namespace(steam=self.steam, compat=self.compat, session=fresh, project='core', timeout=30)
with (self.compat/'.erri-workbench.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with patch.object(n, 'assert_no_game'), patch.object(n.shutil, 'which', return_value='/mock'), patch.object(n, 'copy_payload') as copy:
with self.assertRaisesRegex(ValueError, 'owns the disposable prefix'): n.run(args, self.source)
copy.assert_not_called()
self.assertFalse(fresh.exists())
def orchestration_double(self, reports, continuation=True, idle=None):
import contextlib
import io
import uuid
fresh = Path('/tmp')/('erri-package-orchestration-'+uuid.uuid4().hex)
self.addCleanup(lambda: shutil.rmtree(fresh, ignore_errors=True))
args = argparse.Namespace(steam=self.steam, compat=self.compat, session=fresh,
project='chat', timeout=30, continue_on_failure=continuation)
with patch.object(n, 'assert_no_game'), patch.object(n, 'assert_prefix_idle', side_effect=idle), \
patch.object(n.shutil, 'which', return_value='/mock'), \
patch.object(n, 'run_project', side_effect=reports) as run, \
patch.object(n.subprocess, 'Popen') as child, contextlib.redirect_stdout(io.StringIO()):
code = n.run(args, self.source)
child.assert_not_called()
return code, json.loads((fresh/'package-report.json').read_text()), run.call_args_list
@staticmethod
def completed_report(success=False, **changes):
report = {'candidate_created': success, 'cleanup_confirmed': True,
'runner_error': None, 'copied_authored_unchanged': True,
'completion_observed': 'native-child-exited',
'errors': [] if success else ['RESOURCES (E): synthetic shutdown leak']}
report.update(changes)
return report
def test_diagnostic_continuation_runs_full_closure_keeps_failures_and_nonzero(self):
reports = [self.completed_report(success=i not in (0, 2)) for i in range(5)]
code, summary, calls = self.orchestration_double(reports)
self.assertEqual([call.args[3] for call in calls], ['core', 'ai', 'intel', 'map', 'chat'])
self.assertEqual(len({str(call.args[2]) for call in calls}), 1) # one private copy/session
self.assertEqual(summary['projects'], reports) # raw failures retained
self.assertEqual(code, 1)
self.assertFalse(summary['all_candidates_created'])
self.assertFalse(summary['release_qualified'])
self.assertEqual(summary['skipped_projects'], [])
self.assertIsNone(summary['stopped_reason'])
def test_default_stops_even_after_safely_completed_validation_failure(self):
code, summary, calls = self.orchestration_double([self.completed_report()], False)
self.assertEqual(code, 1)
self.assertEqual(len(calls), 1)
self.assertFalse(summary['continue_on_failure'])
self.assertEqual(summary['skipped_projects'], ['ai', 'intel', 'map', 'chat'])
self.assertIn('continuation not requested', summary['stopped_reason'])
def test_continuation_cannot_override_cleanup_runner_source_or_completion_failure(self):
for change in [{'cleanup_confirmed': False}, {'runner_error': 'synthetic failure'},
{'copied_authored_unchanged': False}, {'completion_observed': 'timeout'},
{'completion_observed': 'interrupted'},
{'completion_observed': 'native-or-launcher-exited-prefix-active'}]:
with self.subTest(change=change):
code, summary, calls = self.orchestration_double([self.completed_report(**change)])
self.assertEqual(code, 1)
self.assertEqual(len(calls), 1)
self.assertTrue(summary['stopped_reason'])
self.assertEqual(len(summary['skipped_projects']), 4)
def test_prefix_becoming_busy_between_addons_stops_before_next_launch(self):
# First probe owns idle prefix before copy; second is between addons.
code, summary, calls = self.orchestration_double(
[self.completed_report()], idle=[None, ValueError('synthetic prefix still active')])
self.assertEqual(code, 1)
self.assertEqual(len(calls), 1)
self.assertIn('prefix not verifiably idle', summary['stopped_reason'])
def test_all_clean_candidates_still_unqualified_and_flag_defaults_false(self):
code, summary, calls = self.orchestration_double([self.completed_report(True) for _ in range(5)])
self.assertEqual(code, 0)
self.assertTrue(summary['all_candidates_created'])
self.assertFalse(summary['release_qualified'])
with patch.object(n, 'run', return_value=1) as run:
self.assertEqual(n.main(['--session', '/tmp/not-launched', '--compat', str(self.compat)]), 1)
self.assertFalse(run.call_args.args[0].continue_on_failure)
n.main(['--session', '/tmp/not-launched', '--compat', str(self.compat), '--continue-on-failure'])
self.assertTrue(run.call_args.args[0].continue_on_failure)
def test_prefix_symlink_and_traversal_refused(self):
fresh = Path('/tmp')/('erri-unused-'+self.home.name)
shutil.rmtree(self.compat/'pfx')
outside = self.home/'installed-prefix'; outside.mkdir()
(outside/'system.reg').write_text('must not mutate')
(self.compat/'pfx').symlink_to(outside, target_is_directory=True)
with self.assertRaisesRegex(ValueError, 'real pfx'):
n.validate_session(self.source, self.steam, self.compat, fresh)
(self.compat/'pfx').unlink(); (self.compat/'pfx').mkdir()
(self.compat/'pfx/system.reg').write_text('private')
(self.compat/'.erri-workbench.lock').symlink_to(outside/'system.reg')
with self.assertRaisesRegex(ValueError, 'lock must not'):
n.validate_session(self.source, self.steam, self.compat, fresh)
(self.compat/'.erri-workbench.lock').unlink()
with self.assertRaises(ValueError):
n.validate_session(self.source, self.steam, self.compat, self.home/'..'/fresh.name)
def test_added_authored_file_is_not_mistaken_for_generated_cache(self):
authored = n.copy_payload(self.source, self.session)
(self.session/'source/addons/core/Scripts/Game/ERR/Injected.c').write_text('// synthetic new authored content')
self.assertFalse(n.copied_authored_unchanged(self.session/'source', authored))
def test_native_failure_not_masked_by_successful_gamescope_exit(self):
authored = n.copy_payload(self.source, self.session)
report = self.native_double(authored, native_code=7)
self.assertEqual(report['launcher_exit_code'], 0)
self.assertEqual(report['native_exit_code'], 7)
self.assertFalse(report['candidate_created'])
def test_missing_direct_native_exit_cannot_qualify(self):
authored = n.copy_payload(self.source, self.session)
report = self.native_double(authored, write_status=False)
self.assertIsNone(report['native_exit_code'])
self.assertFalse(report['candidate_created'])
def test_private_recorder_captures_actual_subprocess_status_without_shell(self):
script = self.home/'recorder.py'; script.write_text(n.CHILD_RECORDER)
output = self.home/'exit.json'
# Ordinary Python test child only. No Proton, Wine, game or engine.
command = [sys.executable, '-c', 'raise SystemExit(7)']
environment = self.home/'native-env.json'; environment.write_text('{}')
completed = subprocess.run([sys.executable, str(script), str(output), str(environment), *command])
self.assertEqual(completed.returncode, 7)
self.assertEqual(n.child_status(output, command), 7)
self.assertIsNone(n.child_status(output, ['different-command']))
output.write_text('[]'); self.assertIsNone(n.child_status(output, command))
output.write_text('{bad'); self.assertIsNone(n.child_status(output, command))
def test_slow_observation_cannot_turn_expired_attempt_into_success(self):
authored = n.copy_payload(self.source, self.session)
clock = [0]
def slow_audio(*args): clock[0] = 31
with patch.object(n.time, 'monotonic', side_effect=lambda: clock[0]), patch.object(n, 'quiet_streams', side_effect=slow_audio):
report = self.native_double(authored, timeout=30)
self.assertEqual(report['result'], 'timeout')
self.assertFalse(report['candidate_created'])
def test_naturally_exited_native_child_can_close_lingering_owned_display(self):
authored = n.copy_payload(self.source, self.session)
report = self.native_double(authored, launcher_code=None)
self.assertEqual(report['native_exit_code'], 0)
self.assertIsNone(report['launcher_exit_code'])
self.assertTrue(report['display_cleanup_after_native_exit'])
self.assertEqual(report['completion_observed'], 'native-child-exited')
self.assertTrue(report['candidate_created'])
self.assertFalse(report['release_qualified'])
env = json.loads((self.session/'evidence/core/native-prefix-environment.json').read_text())
self.assertEqual(env['WINEPREFIX'], str(self.compat/'pfx'))
def test_native_child_completion_does_not_hide_shutdown_errors(self):
authored = n.copy_payload(self.source, self.session)
report = self.native_double(authored, launcher_code=None, failure='RESOURCES (E): synthetic Resource leaks')
self.assertTrue(report['display_cleanup_after_native_exit'])
self.assertFalse(report['candidate_created'])
self.assertTrue(report['errors'])
def test_wb_silent_is_explicit_and_preserves_packaging_and_error_policy(self):
ordinary = n.command_for(self.steam, self.session, 'core')
silent = n.command_for(self.steam, self.session, 'core', True)
self.assertEqual(silent, ordinary+['-wbSilent'])
self.assertIn('-packAddon', silent)
self.assertIn('-noBackend', silent)
if __name__ == '__main__':
unittest.main()
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Synthetic packed Chat closure evidence only; never launches an engine."""
from pathlib import Path
import contextlib
import hashlib
import io
import json
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
import headless_game as runner
class PackedChatTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='erri-packed-chat-test-')
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.candidate = self.root/'packages'; self.candidate.mkdir()
for key, (name, guid, dependencies) in runner.PACKED_PROJECTS.items():
folder = self.candidate/key; folder.mkdir()
deps = [runner.PACKED_BASE]+[runner.PACKED_PROJECTS[d][1] for d in dependencies]
project = f'GameProject {{ ID "{name}" GUID "{guid}" TITLE "Synthetic {key}" Dependencies {{ '+ ' '.join('"'+d+'"' for d in deps)+' } }\n'
(folder/'addon.gproj').write_text(project)
(folder/'data.pak').write_bytes(b'SYNTHETIC NOT NATIVE '+key.encode())
(folder/'resourceDatabase.rdb').write_bytes(b'SYNTHETIC DATABASE '+key.encode())
self.session_temp = tempfile.TemporaryDirectory(prefix='erri-packed-chat-session-')
self.addCleanup(self.session_temp.cleanup)
self.session = Path(self.session_temp.name); self.session.rmdir()
self.args = SimpleNamespace(packed_core=None, packed_addons=self.candidate, packed_session=self.session,
project='chat', profile=Path('/tmp/erri-game-profile'), settle=15)
self.packed = runner.packed_request(self.args)
self.steam = Path('/Steam'); self.profile = self.session/'profile'
def content(self):
base = self.packed['input']
text = f"""00 ENGINE : Addon dirs:
00 ENGINE : dir: '{base}'
00 ENGINE : dir: './addons'
00 ENGINE : dir: '{self.profile}/addons'
00 ENGINE : Loaded addons:
00 ENGINE : gproj: './addons/core/core.gproj' guid: '5614BBCCBB55ED1C'
00 ENGINE : gproj: './addons/data/ArmaReforger.gproj' guid: '58D0FB3206B6F859'
"""
for key, (_, guid, _) in runner.PACKED_PROJECTS.items():
text += f"00 ENGINE : gproj: '{base/key}/addon.gproj' guid: '{guid}'\n"
text += "00 ENGINE : FileSystem: Adding package './addons/core/' (pak count: 2) to filesystem under name core\n"
text += "00 ENGINE : FileSystem: Adding package './addons/data/' (pak count: 14) to filesystem under name ArmaReforger\n"
for i, (key, (name, _, _)) in enumerate(runner.PACKED_PROJECTS.items()):
text += f"00 ENGINE : FileSystem: Adding package '{base/key}/' (pak count: 1) to filesystem under name {name}\n"
text += f"00 RESOURCES : ResourceDB: loading cache (id={i} name={name} path={base/key}/resourceDatabase.rdb)\n"
return text+'''00 SCRIPT : Module: GameLib; defines: "PLATFORM_WINDOWS"
00 SCRIPT : Module: Game; defines: "PLATFORM_WINDOWS"
00 SCRIPT : ERR ORDINARY: header verified project=chat systems=MissionSystems
00 SCRIPT : ERR ORDINARY: local participant verified project=chat phase=prison
00 SCRIPT : Rereintroduced [mission]: phase 0 -> 1
'''
def evidence(self, content=None):
return runner.packed_evidence(self.content() if content is None else content, self.packed, self.steam, self.profile)
def test_exact_fifteen_artifacts_and_all_addon_identities_dependencies(self):
manifest = runner.packed_payload_inventory(self.candidate, 'chat')
self.assertEqual(set(manifest), {key+'/'+name for key in runner.PACKED_PROJECTS for name in runner.PACKED_FILES})
for key in runner.PACKED_PROJECTS:
p = self.candidate/key/'addon.gproj'; original = p.read_text()
for bad in (original.replace('GUID "', 'GUID "FFFF'), original.replace('ID "ERR_', 'ID "Other_'),
original.replace('Dependencies {', 'Dependencies { "58D0FB3206B6F859"'),
original.replace('"58D0FB3206B6F859"', '"1111111111111111"')):
p.write_text(bad)
with self.subTest(key=key), self.assertRaises(ValueError): runner.packed_payload_inventory(self.candidate, 'chat')
p.write_text(original)
chat = self.candidate/'chat/addon.gproj'; chat.write_text(chat.read_text().replace('"5192138CB9D5B911"', ''))
with self.assertRaises(ValueError): runner.packed_payload_inventory(self.candidate, 'chat')
def test_exact_folder_set_symlinks_traversal_extra_missing_empty_refused(self):
for folder in ('extra', 'Scripts'):
(self.candidate/folder).mkdir()
with self.assertRaises(ValueError): runner.packed_payload_inventory(self.candidate, 'chat')
(self.candidate/folder).rmdir()
original = self.candidate/'map'; original.rename(self.root/'map')
with self.assertRaises(ValueError): runner.packed_payload_inventory(self.candidate, 'chat')
original.symlink_to(self.root/'map', target_is_directory=True)
with self.assertRaises(ValueError): runner.packed_payload_inventory(self.candidate, 'chat')
original.unlink(); (self.root/'map').rename(original)
with self.assertRaises(ValueError): runner.packed_payload_inventory(self.candidate/'..'/'packages', 'chat')
for key in runner.PACKED_PROJECTS:
for name in runner.PACKED_FILES:
p = self.candidate/key/name; original = p.read_bytes(); p.write_bytes(b'')
with self.subTest(key=key, name=name), self.assertRaises(ValueError): runner.packed_payload_inventory(self.candidate, 'chat')
p.unlink(); p.symlink_to(self.root/'missing')
with self.assertRaises(ValueError): runner.packed_payload_inventory(self.candidate, 'chat')
p.unlink(); p.write_bytes(original)
def test_request_requires_mutual_exclusion_chat_session_and_fixed_settle(self):
for key, value in [('packed_core', self.candidate/'core'), ('project', 'core'), ('packed_addons', None),
('packed_session', None), ('settle', 14), ('profile', Path('/different')),
('packed_session', Path('/tmp/erri-packed-core-wrong')),
('packed_session', self.root/'erri-packed-chat-nested')]:
args = SimpleNamespace(**vars(self.args)); setattr(args, key, value)
with self.subTest(key=key), self.assertRaises(ValueError): runner.packed_request(args)
self.session.mkdir()
with self.assertRaises(ValueError): runner.packed_request(self.args)
def test_private_copy_v2_provenance_and_each_of_fifteen_artifacts_sealed(self):
runner.prepare_packed(self.packed)
raw = runner.packed_snapshot(self.packed, ['synthetic'], self.profile); metadata = json.loads(raw)
self.assertEqual((metadata['schema_version'], metadata['kind'], metadata['project']), (2, 'packed-chat-runtime-input', 'chat'))
self.assertEqual(metadata['addon_order'], ['core', 'ai', 'intel', 'map', 'chat'])
self.assertEqual(metadata['artifacts'], self.packed['manifest'])
for name in metadata['artifacts']:
source = self.candidate/name; target = self.packed['input']/name
self.assertNotEqual(source.stat().st_ino, target.stat().st_ino)
self.assertEqual(hashlib.sha256(target.read_bytes()).hexdigest(), metadata['artifacts'][name]['sha256'])
original = target.read_bytes(); target.write_bytes(b'changed')
self.assertFalse(runner.packed_seal(self.packed, raw, None)['packed_inputs_unchanged_after_run'])
target.write_bytes(original)
self.assertTrue(runner.packed_seal(self.packed, raw, None)['packed_inputs_unchanged_after_run'])
(self.candidate/'intel/resourceDatabase.rdb').unlink()
self.assertFalse(runner.packed_seal(self.packed, raw, None)['packed_inputs_unchanged_after_run'])
def test_candidate_changes_before_copy_and_before_launch_refused(self):
p = self.candidate/'ai/data.pak'; original = p.read_bytes(); p.write_bytes(b'changed')
with self.assertRaises(ValueError): runner.prepare_packed(self.packed)
p.write_bytes(original)
# Failed prepare owns its session; it is deliberately not reusable.
self.assertTrue(self.session.exists())
with self.assertRaises(FileExistsError): runner.prepare_packed(self.packed)
def test_command_selects_chat_from_private_closure_never_loose_source(self):
command = runner.command_for(Path('/forbidden-source'), self.steam, Path('/compat'), self.profile, 'chat', self.packed)
self.assertEqual(command[command.index('-addonsDir')+1], 'Z:'+str(self.packed['input']))
self.assertEqual(command[command.index('-addons')+1], runner.PACKED_PROJECTS['chat'][1])
self.assertEqual(command[command.index('-errOrdinaryScenario')+1], 'chat')
self.assertFalse(any('/forbidden-source' in arg or str(self.candidate) in arg for arg in command))
with self.assertRaises(ValueError): runner.command_for(Path('/unused'), self.steam, Path('/compat'), self.profile, 'core', self.packed)
def test_exact_seven_loaded_addons_and_five_mounts_databases_required(self):
self.assertTrue(self.evidence()['ready'])
self.assertFalse(self.evidence()['release_qualified'])
self.assertFalse(self.evidence()['build_qualified'])
source = self.content()
for key, (name, guid, _) in runner.PACKED_PROJECTS.items():
for fragment in (f"gproj: '{self.packed['input']/key}/addon.gproj'", f'to filesystem under name {name}', f'name={name} path='):
broken = '\n'.join(line for line in source.splitlines() if fragment not in line)+'\n'
with self.subTest(key=key, fragment=fragment): self.assertFalse(self.evidence(broken)['ready'])
redirected = source.replace(str(self.packed['input']/key), '/foreign/'+key)
self.assertTrue(self.evidence(redirected)['failed'])
self.assertTrue(self.evidence(source+f"00 ENGINE : FileSystem: Adding relative directory '/loose/{key}' to filesystem under name {name}\n")['failed'])
self.assertTrue(self.evidence(source.replace(f'name={name} path=', f'name=ERR_Foreign path='))['failed'])
self.assertTrue(self.evidence(source.replace('(pak count: 1)', '(pak count: 2)'))['failed'])
self.assertFalse(self.evidence(source.replace('Loaded addons:', 'Available addons:'))['ready'])
self.assertFalse(self.evidence(source.replace('project=chat', 'project=core'))['ready'])
def test_foreign_duplicate_errors_and_workbench_never_masked_by_later_good_block(self):
source = self.content(); guid = runner.PACKED_PROJECTS['ai'][1]
entry = next(line for line in source.splitlines() if "guid: '"+guid+"'" in line)+'\n'
for bad in (source.replace(entry, entry+entry), source.replace(entry, entry.replace(guid, '1111111111111111')),
source+'00 SCRIPT (E): synthetic error\n', source+'ERR SMOKE PASS: forbidden fixture\n',
source.replace('PLATFORM_WINDOWS', 'PLATFORM_WINDOWS,WORKBENCH')):
with self.subTest(bad=bad):
self.assertTrue(self.evidence(bad+source)['failed'])
self.assertFalse(self.evidence(bad+source)['ready'])
def test_mocked_failed_launch_keeps_chat_artifacts_not_source_provenance(self):
steam = self.root/'steam'; compat = self.root/'compat'
(compat/'pfx').mkdir(parents=True); (compat/'pfx/system.reg').write_text('synthetic')
for relative in ['steamapps/common/Proton - Experimental/proton', 'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe']:
p = steam/relative; p.parent.mkdir(parents=True, exist_ok=True); p.write_bytes(b'not executed')
args = SimpleNamespace(**vars(self.args), steam=steam, compat=compat, timeout=60, failure_grace=0)
with patch.object(runner, 'assert_no_game'), patch.object(runner, 'assert_prefix_idle'), patch.object(runner.shutil, 'which', return_value='/mock'), patch.object(runner.subprocess, 'check_output', return_value='11'), patch.object(runner.subprocess, 'run'), patch.object(runner.subprocess, 'Popen', side_effect=OSError('synthetic failed launch')), patch.object(runner, 'snapshot', side_effect=AssertionError('source snapshot forbidden')), patch.object(runner, 'seal_run', side_effect=AssertionError('source seal forbidden')), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(runner.run(args), 1)
result = json.loads((self.session/'packed-runner.json').read_text())
self.assertEqual(result['project'], 'chat'); self.assertEqual(result['result'], 'runner-failed')
self.assertFalse(result['packed_consumption_verified']); self.assertNotIn('launch_source_sha256', result)
metadata = json.loads((self.session/runner.PACKED_MANIFEST).read_text())
self.assertEqual(metadata['schema_version'], 2); self.assertEqual(len(metadata['artifacts']), 15)
if __name__ == '__main__': unittest.main()
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Packed input, path and provenance regression; never launches a native process."""
from pathlib import Path
import contextlib
import hashlib
import io
import json
import shutil
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'tools'))
import headless_game as runner
PROJECT = '''GameProject {
ID "ERR_Core"
GUID "2528466DE220D91C"
TITLE "Escapists Rereintroduced - Core"
Dependencies { "58D0FB3206B6F859" }
}
'''
class PackedCoreTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='erri-packed-core-test-')
self.root = Path(self.temp.name)
self.candidate = self.root/'candidate';self.candidate.mkdir()
for name,data in {'addon.gproj':PROJECT.encode(),'data.pak':b'fixture-package-bytes','resourceDatabase.rdb':b'fixture-database'}.items():
(self.candidate/name).write_bytes(data)
self.session_temp = tempfile.TemporaryDirectory(prefix='erri-packed-core-session-')
self.session = Path(self.session_temp.name);self.session.rmdir()
self.args = SimpleNamespace(packed_core=self.candidate,packed_session=self.session,project='core',profile=Path('/tmp/erri-game-profile'),settle=15)
self.packed = runner.packed_request(self.args)
self.steam = Path('/Steam');self.profile=self.session/'profile'
def tearDown(self):
self.session_temp.cleanup();self.temp.cleanup()
def content(self):
core=self.packed['input'];game=self.steam/'steamapps/common/Arma Reforger'
return f'''00 ENGINE : Addon dirs:
00 ENGINE : dir: '{core.parent}'
00 ENGINE : dir: './addons'
00 ENGINE : dir: '{self.profile}/addons'
00 ENGINE : Loaded addons:
00 ENGINE : gproj: './addons/core/core.gproj' guid: '5614BBCCBB55ED1C'
00 ENGINE : gproj: './addons/data/ArmaReforger.gproj' guid: '58D0FB3206B6F859'
00 ENGINE : gproj: '{core}/addon.gproj' guid: '2528466DE220D91C'
00 ENGINE : FileSystem: Adding package './addons/core/' (pak count: 2) to filesystem under name core
00 ENGINE : FileSystem: Adding package './addons/data/' (pak count: 14) to filesystem under name ArmaReforger
00 ENGINE : FileSystem: Adding package '{core}/' (pak count: 1) to filesystem under name ERR_Core
00 RESOURCES : ResourceDB: loading cache (id=0 name=ERR_Core path={core}/resourceDatabase.rdb)
00 SCRIPT : Module: GameLib; defines: "PLATFORM_WINDOWS"
00 SCRIPT : Module: Game; defines: "PLATFORM_WINDOWS"
00 SCRIPT : ERR ORDINARY: header verified project=core systems=MissionSystems
00 SCRIPT : ERR ORDINARY: local participant verified project=core phase=prison
00 SCRIPT : Rereintroduced [mission]: phase 0 -> 1
'''
def evidence(self,content=None):
return runner.packed_evidence(self.content() if content is None else content,self.packed,self.steam,self.profile)
def test_exact_three_files_hash_pak_and_rdb(self):
inventory=runner.packed_inventory(self.candidate)
self.assertEqual(set(inventory),runner.PACKED_FILES)
self.assertEqual(inventory['data.pak']['sha256'],hashlib.sha256(b'fixture-package-bytes').hexdigest())
before=inventory['resourceDatabase.rdb'];(self.candidate/'resourceDatabase.rdb').write_bytes(b'changed')
self.assertNotEqual(before,runner.packed_inventory(self.candidate)['resourceDatabase.rdb'])
def test_missing_extra_empty_and_symlink_refused(self):
for name in runner.PACKED_FILES:
with self.subTest(name=name):
p=self.candidate/name;data=p.read_bytes();p.unlink()
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
p.write_bytes(b'')
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
p.unlink();p.symlink_to(self.root/'absent')
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
p.unlink();p.write_bytes(data)
extra=self.candidate/'Scripts';extra.mkdir()
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
def test_wrong_guid_dependency_and_project_identity_refused(self):
for bad in (PROJECT.replace('2528466DE220D91C','FA804F411BDDF2C6'),PROJECT.replace('58D0FB3206B6F859','5192138CB9D5B911'),PROJECT.replace('ERR_Core','ERR_Chat'),PROJECT.replace('Dependencies {','Dependencies { "5192138CB9D5B911"')):
(self.candidate/'addon.gproj').write_text(bad)
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate)
def test_candidate_symlink_or_alias_refused(self):
alias=self.root/'alias';alias.symlink_to(self.candidate,target_is_directory=True)
with self.assertRaises(ValueError):runner.packed_inventory(alias)
with self.assertRaises(ValueError):runner.packed_inventory(self.candidate/'..'/'candidate')
def test_request_requires_new_core_session_and_canonical_settle(self):
for key,value in [('project','chat'),('packed_core',None),('packed_session',None),('settle',5),('profile',Path('/other')),('packed_session',Path('/tmp/unsafe')),('packed_session',self.root/'erri-packed-core-nested')]:
args=SimpleNamespace(**vars(self.args));setattr(args,key,value)
with self.subTest(key=key,value=value),self.assertRaises(ValueError):runner.packed_request(args)
self.session.mkdir()
with self.assertRaises(ValueError):runner.packed_request(self.args)
def test_ordinary_request_untouched(self):
self.assertIsNone(runner.packed_request(SimpleNamespace(project='core')))
def test_copy_is_exclusive_regular_independent_and_exact(self):
runner.prepare_packed(self.packed)
self.assertEqual(runner.packed_inventory(self.packed['input']),self.packed['manifest'])
for name in runner.PACKED_FILES:
self.assertNotEqual((self.candidate/name).stat().st_ino,(self.packed['input']/name).stat().st_ino)
with self.assertRaises(FileExistsError):runner.prepare_packed(self.packed)
def test_changed_candidate_before_copy_refused(self):
(self.candidate/'data.pak').write_bytes(b'new')
with self.assertRaisesRegex(ValueError,'changed'):runner.prepare_packed(self.packed)
def test_command_only_private_input_never_repository(self):
command=runner.command_for(Path('/never-loose-repo'),self.steam,Path('/compat'),self.profile,'core',self.packed)
self.assertEqual(command[command.index('-addonsDir')+1],'Z:'+str(self.packed['input'].parent))
self.assertEqual(command[command.index('-addons')+1],runner.PACKED_GUID)
self.assertNotIn('-gproj',command);self.assertNotIn('-server',command)
self.assertFalse(any('/never-loose-repo' in part or str(self.candidate) in part for part in command))
for flag in ('-noSplash','-noBackend','-noSound','-errOrdinaryScenario'):self.assertIn(flag,command)
def test_exact_native_paths_modules_and_readiness_required(self):
report=self.evidence();self.assertTrue(report['ready']);self.assertTrue(report['packed_only']);self.assertFalse(report['failed'])
self.assertFalse(report['build_qualified']);self.assertFalse(report['release_qualified'])
for missing in ('Loaded addons:','FileSystem: Adding package','ResourceDB: loading cache','Module: GameLib;','local participant verified','header verified'):
with self.subTest(missing=missing):self.assertFalse(self.evidence(self.content().replace(missing,'missing'))['ready'])
def test_available_addons_cannot_substitute_loaded_evidence(self):
self.assertFalse(self.evidence(self.content().replace('Loaded addons:','Available addons:'))['ready'])
def test_redirected_same_guid_foreign_duplicate_or_loose_mount_fails(self):
source=self.content();entry=f"00 ENGINE : gproj: '{self.packed['input']}/addon.gproj' guid: '2528466DE220D91C'\n"
faults=[source.replace(entry,entry.replace(str(self.packed['input']),'/loose/core')),source.replace(entry,entry+entry),source.replace(entry,entry+"00 ENGINE : gproj: '/foreign/addon.gproj' guid: 'invalid'\n"),source.replace(entry,entry+"00 ENGINE : gproj: '/foreign/addon.gproj' guid: '5192138CB9D5B911'\n"),source+"00 ENGINE : FileSystem: Adding relative directory '/loose/core' to filesystem under name ERR_Core\n",source.replace('(pak count: 1)','(pak count: 2)'),source+"00 ENGINE : dir: '/loose/addons'\n",source.replace(f'path={self.packed["input"]}/resourceDatabase.rdb','path=/foreign/resourceDatabase.rdb')]
for content in faults:
with self.subTest(content=content):self.assertTrue(self.evidence(content)['failed']);self.assertFalse(self.evidence(content)['ready'])
def test_one_wrong_loaded_block_cannot_be_hidden_by_later_good_block(self):
bad=self.content().replace("guid: '2528466DE220D91C'","guid: '5192138CB9D5B911'")
self.assertTrue(self.evidence(bad+self.content())['failed'])
def test_exact_native_profile_addons_alias_only(self):
path=str(self.profile/'addons')
good=self.content().replace("dir: '"+path+"'", "dir: '$profile:../addons/'")
self.assertTrue(self.evidence(good)['ready'])
for alias in ('$profile:../../addons/', '$profile:../addons/../evil', '$other:../addons/', '$profile:../addons', '$profile:../addons//', '$profile:../addons/../../source'):
bad=self.content().replace("dir: '"+path+"'", "dir: '"+alias+"'")
with self.subTest(alias=alias):self.assertTrue(self.evidence(bad)['failed'])
def test_native_windows_path_and_trailing_slash_normalize(self):
content=self.content().replace(str(self.packed['input']),'Z:'+str(self.packed['input']).replace('/','\\'))
self.assertTrue(self.evidence(content)['ready'])
def test_raw_errors_fixture_and_workbench_fail(self):
for content in (self.content()+'00 SCRIPT (E): example failure\n',self.content()+'ERR SMOKE PASS: unexpected\n',self.content().replace('PLATFORM_WINDOWS','PLATFORM_WINDOWS,WORKBENCH')):
self.assertTrue(self.evidence(content)['failed'])
def test_canonical_ready_settle_and_error_latch(self):
monitor=runner.StartupMonitor(0,180,15,0)
self.assertIsNone(monitor.observe(self.evidence(),1));self.assertIsNone(monitor.observe(self.evidence(),15))
self.assertEqual(monitor.observe(self.evidence(),16),'game-started')
bad=self.evidence(self.content()+'00 SCRIPT (E): late\n')
self.assertEqual(monitor.observe(bad,16.1),'game-failed')
self.assertTrue(monitor.annotate(self.evidence())['failed'])
def test_packed_manifest_seals_exact_copy_candidate_and_console(self):
runner.prepare_packed(self.packed)
raw=runner.packed_snapshot(self.packed,['native-command'],self.profile)
metadata=json.loads(raw);self.assertEqual(metadata['artifacts'],self.packed['manifest'])
self.assertEqual(metadata['schema_version'],1);self.assertEqual(metadata['kind'],'packed-core-runtime-input')
self.assertNotIn('project',metadata);self.assertNotIn('addon_order',metadata)
self.assertEqual((self.session/runner.PACKED_MANIFEST).read_bytes(),raw)
log=self.session/'log';log.mkdir();(log/'console.log').write_text(self.content())
result=runner.packed_seal(self.packed,raw,log)
self.assertTrue(result['packed_inputs_unchanged_after_run']);self.assertEqual(result['console_sha256'],hashlib.sha256((log/'console.log').read_bytes()).hexdigest())
self.assertNotIn('source_provenance',result);self.assertNotIn('source_unchanged_after_run',result)
(self.packed['input']/'data.pak').write_bytes(b'changed')
self.assertFalse(runner.packed_seal(self.packed,raw,log)['packed_inputs_unchanged_after_run'])
def test_missing_input_after_run_is_failure_evidence_not_exception(self):
runner.prepare_packed(self.packed);raw=runner.packed_snapshot(self.packed,[],self.profile)
(self.candidate/'resourceDatabase.rdb').unlink();result=runner.packed_seal(self.packed,raw,None)
self.assertFalse(result['packed_inputs_unchanged_after_run']);self.assertIsNotNone(result['packed_input_error'])
def test_changed_inputs_before_launch_refuse(self):
runner.prepare_packed(self.packed);(self.candidate/'data.pak').write_bytes(b'changed')
with self.assertRaises(ValueError):runner.packed_snapshot(self.packed,[],self.profile)
def test_failed_native_launch_retains_packed_manifest_without_source_snapshot(self):
steam=self.root/'steam';compat=self.root/'compat';(compat/'pfx').mkdir(parents=True);(compat/'pfx/system.reg').write_text('test')
for relative in ['steamapps/common/Proton - Experimental/proton','steamapps/common/Arma Reforger/ArmaReforgerSteam.exe']:
file=steam/relative;file.parent.mkdir(parents=True,exist_ok=True);file.write_bytes(b'not executed')
args=SimpleNamespace(**vars(self.args),steam=steam,compat=compat,timeout=60,failure_grace=0)
with patch.object(runner,'assert_no_game'),patch.object(runner,'assert_prefix_idle'),patch.object(runner.shutil,'which',return_value='/mock/tool'),patch.object(runner.subprocess,'check_output',return_value='11'),patch.object(runner.subprocess,'run'),patch.object(runner.subprocess,'Popen',side_effect=OSError('explicit test launch failure')),patch.object(runner,'snapshot',side_effect=AssertionError('loose source provenance forbidden')),patch.object(runner,'seal_run',side_effect=AssertionError('loose source seal forbidden')),contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(runner.run(args),1)
report=json.loads((self.session/'packed-runner.json').read_text())
self.assertEqual(report['result'],'runner-failed');self.assertTrue(report['packed_only']);self.assertFalse(report['packed_consumption_verified'])
self.assertNotIn('launch_source_sha256',report);self.assertTrue((self.session/runner.PACKED_MANIFEST).is_file())
self.assertTrue((self.session/'profile/addons/ArmaReforger').is_symlink())
self.assertEqual(runner.packed_inventory(self.packed['input']),self.packed['manifest'])
if __name__=='__main__':unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Synthetic malformed PAK tests; no engine, extracted assets or native-build claims."""
from dataclasses import replace
import hashlib
import io
import json
import os
from pathlib import Path
import struct
import sys
import tempfile
import unittest
import zlib
from contextlib import redirect_stdout
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT/'tools'))
import pak_inventory as p
def chunk(tag, data): return tag+struct.pack('>I', len(data))+data
def entry(name=b'file.txt', offset=56, stored=3, expanded=3, flags=b'\0\0', reserved=b'\0'*6, kind=1):
return bytes([kind, len(name)])+name+struct.pack('<III', offset, stored, expanded)+reserved+flags+b'\0'*4
def archive(data=b'abc', tree=None, head=None):
if tree is None: tree=b'\0\0'+struct.pack('<I', 1)+entry(stored=len(data), expanded=len(data))
if head is None: head=b'\x03\0\x01\0'+b'\0'*24
body=b'PAC1'+chunk(b'HEAD',head)+chunk(b'DATA',data)+chunk(b'FILE',tree)
return b'FORM'+struct.pack('>I',len(body))+body
class PakTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory(prefix='erri-pak-test-')
self.addCleanup(self.temp.cleanup)
self.home=Path(self.temp.name);self.path=self.home/'synthetic.pak'
def inspect(self, data, limits=p.Limits()):
self.path.write_bytes(data)
return p.inventory(self.path, limits)
def refused(self, data, limits=p.Limits()):
with self.assertRaises((p.PakError,OSError)): self.inspect(data,limits)
def test_raw_and_compressed_nested_hashes_without_extraction(self):
text=b'synthetic text '*200
compressed=zlib.compress(text)
tree=b'\0\0'+struct.pack('<I',2)+entry()
tree+=b'\0\x06folder'+struct.pack('<I',1)+entry(b'data.c',59,len(compressed),len(text),b'\x01\x06')
data=archive(b'abc'+compressed,tree)
report=self.inspect(data)
self.assertEqual(report['entry_count'],2)
self.assertEqual(report['tree_node_count'],3)
self.assertEqual(report['archive_sha256'],hashlib.sha256(data).hexdigest())
rows={r['path']:r for r in report['entries']}
self.assertEqual(rows['folder/data.c']['sha256'],hashlib.sha256(text).hexdigest())
self.assertFalse(report['platform_or_resource_semantics_verified'])
self.assertFalse(report['release_qualified'])
self.assertEqual(list(self.home.iterdir()),[self.path])
def test_truncation_at_every_byte_is_rejected(self):
original=archive()
for length in range(len(original)):
with self.subTest(length=length): self.refused(original[:length])
def test_header_versions_chunks_lengths_and_trailing_bytes(self):
original=archive()
for offset in (0,4,8,12,16,48):
corrupt=bytearray(original);corrupt[offset]^=1;self.refused(bytes(corrupt))
self.refused(archive(head=b'\x04\0\x01\0'+b'\0'*24))
self.refused(archive(head=b'\x03\0\x01\0'+b'\0'*23+b'\1'))
self.refused(archive(head=b'\0'))
self.refused(original+b'extra')
self.refused(archive(tree=b'\0\0'+struct.pack('<I',1)+entry()+b'extra'))
def test_paths_duplicates_case_and_file_directory_conflicts(self):
for name in (b'..',b'.',b'',b'/root',b'a/b',b'a\\b',b'C:x',b'a\0b',b'a\nb',b'a.',b'a ',b'CON.txt',b'\xff', 'e\u0301'.encode()):
with self.subTest(name=name):self.refused(archive(tree=b'\0\0'+struct.pack('<I',1)+entry(name)))
for first,second in ((b'file',b'file'),(b'FILE',b'file')):
tree=b'\0\0'+struct.pack('<I',2)+entry(first)+entry(second,59)
self.refused(archive(b'abcdef',tree))
tree=b'\0\0'+struct.pack('<I',2)+entry(b'folder')+b'\0\x06folder'+struct.pack('<I',0)
self.refused(archive(tree=tree))
def test_descriptor_bounds_overlap_gaps_and_unsupported_flags(self):
variants=[entry(offset=0),entry(offset=58),entry(stored=4),entry(stored=3,expanded=2),
entry(flags=b'\1\x09'),entry(reserved=b'\1'+b'\0'*5),entry(kind=2)]
for raw in variants:self.refused(archive(tree=b'\0\0'+struct.pack('<I',1)+raw))
self.refused(archive(b'abcdef',b'\0\0'+struct.pack('<I',2)+entry(b'a')+entry(b'b',56)))
self.refused(archive(b'abcdef',b'\0\0'+struct.pack('<I',1)+entry()))
self.refused(archive(tree=b'\1\0'+struct.pack('<I',1)+entry()))
def test_zlib_exact_sizes_checksums_trailing_and_truncated_streams(self):
original=b'x'*1000;compressed=zlib.compress(original)
for data,size in ((compressed[:-1],1000),(compressed+b'extra',1000),(compressed+compressed,2000),
(compressed,999),(compressed,1001),(b'bad-stream',1000)):
tree=b'\0\0'+struct.pack('<I',1)+entry(stored=len(data),expanded=size,flags=b'\1\6')
self.refused(archive(data,tree))
damaged=bytearray(compressed);damaged[-1]^=1
tree=b'\0\0'+struct.pack('<I',1)+entry(stored=len(damaged),expanded=1000,flags=b'\1\6')
self.refused(archive(bytes(damaged),tree))
def test_large_decompression_streamed_in_bounded_blocks(self):
text=b'abcd'*(p.BLOCK//2+17);data=zlib.compress(text)
tree=b'\0\0'+struct.pack('<I',1)+entry(stored=len(data),expanded=len(text),flags=b'\1\6')
report=self.inspect(archive(data,tree),replace(p.Limits(),expansion_ratio=2048))
self.assertEqual(report['entries'][0]['sha256'],hashlib.sha256(text).hexdigest())
def test_preflight_limits_and_small_declared_bomb(self):
self.refused(archive(),replace(p.Limits(),archive_bytes=20))
self.refused(archive(),replace(p.Limits(),tree_bytes=1))
self.refused(archive(),replace(p.Limits(),entry_bytes=2))
self.refused(archive(),replace(p.Limits(),total_bytes=2))
tree=b'\0\0'+struct.pack('<I',2)+entry(b'a')+entry(b'b',59)
self.refused(archive(b'abcdef',tree),replace(p.Limits(),entries=1))
tree=b'\0\0'+struct.pack('<I',1)+b'\0\1a'+struct.pack('<I',1)+b'\0\1b'+struct.pack('<I',1)+entry()
self.refused(archive(tree=tree),replace(p.Limits(),depth=1))
payload=zlib.compress(b'x'*2_000_000)
tree=b'\0\0'+struct.pack('<I',1)+entry(stored=len(payload),expanded=2_000_000,flags=b'\1\6')
self.refused(archive(payload,tree),replace(p.Limits(),expansion_ratio=2))
tree=b'\0\0'+struct.pack('<I',1)+entry(stored=len(payload),expanded=3,flags=b'\1\6')
self.refused(archive(payload,tree)) # output limited to declared size + one, not 2 MB
def test_fifo_and_symlink_inputs_refused_without_blocking(self):
os.mkfifo(self.path)
with self.assertRaises(p.PakError):p.inventory(self.path)
self.path.unlink();self.path.symlink_to(self.home/'missing')
with self.assertRaises(OSError):p.inventory(self.path)
def test_json_output_is_new_and_no_partial_report_on_failure(self):
self.path.write_bytes(archive());output=self.home/'report.json'
with redirect_stdout(io.StringIO()):self.assertEqual(p.main([str(self.path),'--output',str(output)]),0)
before=output.read_bytes()
with redirect_stdout(io.StringIO()):self.assertEqual(p.main([str(self.path),'--output',str(output)]),1)
self.assertEqual(output.read_bytes(),before)
bad=self.home/'bad.json';self.path.write_bytes(b'bad')
with redirect_stdout(io.StringIO()):self.assertEqual(p.main([str(self.path),'--output',str(bad)]),1)
self.assertFalse(bad.exists())
def test_changed_archive_during_hashing_is_refused(self):
self.path.write_bytes(archive())
original=p.content_hash
def change(stream, item):
result=original(stream,item)
with self.path.open('ab') as output:output.write(b'changed')
return result
with patch.object(p,'content_hash',side_effect=change):
with self.assertRaisesRegex(p.PakError,'changed during inspection'):p.inventory(self.path)
if __name__=='__main__':unittest.main()
+147 -4
View File
@@ -37,7 +37,7 @@ int main(){
''')
def test_native_input_only_and_local_hooks(self):
source=SOURCE.read_text()
for forbidden in ('SetVelocity(', 'SetAngularVelocity(', 'SetWorldTransform(', 'SetOrigin(', 'SetAngles(', '.Teleport(', 'ForceStartEngine(', '.EngineStart(', 'RotorSetForce', 'RotorSetTorque', 'SetHealth(', 'SetFuel(', 'SetGlobalFuelConsumptionScale', 'GetInputManager('):
for forbidden in ('SetVelocity(', 'SetAngularVelocity(', 'SetWorldTransform(', 'SetOrigin(', 'SetAngles(', '.Teleport(', 'ForceStartEngine(', '.EngineStart(', 'RotorSetForce', 'RotorSetTorque', 'SetHealth(', 'SetFuel(', 'SetGlobalFuelConsumptionScale'):
self.assertNotIn(forbidden,source)
self.assertTrue(source.startswith('#ifdef WORKBENCH'))
self.assertIn('outcome == "powered-flight"',source)
@@ -110,11 +110,46 @@ int main(){
for(int i=-100;i<=100;i++) {float command=NativeCollective(i/100.f); assert(command>=0 && command<=1 && command>=previous); previous=command;}
}
''')
demand=body(SOURCE,'protected bool ComputePilotDemand(')
self.assertIn('ERR_PoweredFlightRules.CollectiveCommand(collective, m_ActionInputs, m_Stage)',demand)
pilot=body(SOURCE,'void ApplyPilot(')
self.assertLess(pilot.index('ERR_PoweredFlightRules.NativeCollective(collective)'),pilot.index('m_Input.SetVehicleCollective(collective)'))
self.assertLess(pilot.index('ComputePilotDemand('),pilot.index('m_Input.SetVehicleCollective(collective)'))
self.assertIn('m_Input.GetVehicleCollective() == 0',SOURCE.read_text())
def test_actual_signed_collective_encoding_and_negative_gate(self):
methods='\n'.join(body(SOURCE,key).replace('Math.','Math::') for key in ['static float ClampInput(', 'static float NativeCollective(', 'static float CollectiveCommand(', 'static bool NegativeCollectiveObserved('])
run('#include <cassert>\n#include <algorithm>\n#include <cmath>\nstruct Math {static float Clamp(float x,float a,float b){return std::clamp(x,a,b);} static float AbsFloat(float x){return std::abs(x);}};\n'+methods+"""
int main(){
for(int stage=0;stage<11;stage++)for(float demand: {-2.f,-1.f,-.05f,0.f,.5f,2.f}){
assert(CollectiveCommand(demand,false,stage)==NativeCollective(demand));
if(stage>=4&&stage<=6)assert(CollectiveCommand(demand,true,stage)==ClampInput(demand));
else if(stage==9)assert(CollectiveCommand(demand,true,stage)==.05f);
else if(stage==10)assert(CollectiveCommand(demand,true,stage)==-.05f);
else if(stage==7)assert(CollectiveCommand(demand,true,stage)==-1);
else assert(CollectiveCommand(demand,true,stage)==0);
}
assert(NegativeCollectiveObserved(-.05f,-.05f));
for(float bad: {0.f,.05f,-1.f,NAN})assert(!NegativeCollectiveObserved(-.05f,bad));
assert(!NegativeCollectiveObserved(0,0));assert(!NegativeCollectiveObserved(.05f,.05f));
assert(!NegativeCollectiveObserved(NAN,-.05f));
}
""")
source=SOURCE.read_text();tick=body(SOURCE,'int Tick(')
gate=tick.split('if (m_Stage == 10)',1)[1].split('if (m_Stage == 4 ||',1)[0]
for required in ('now >= m_StageDeadline', 'm_Controller.GetCollectiveMode() == CollectiveMode.Direct', '!m_Controller.GetAutohoverEnabled()', 'm_ControlUpdates >= m_NegativeGateUpdates + 3', 'now - m_NegativeSignalSince < 500'):
self.assertIn(required,gate)
self.assertLess(gate.index('negativeIndex >= 0'),gate.index('GetSignalValue(negativeIndex)'))
self.assertLess(gate.index('now - m_NegativeSignalSince < 500'),gate.index('m_SignedCollectiveProven = true'))
self.assertLess(gate.index('m_SignedCollectiveProven = true'),gate.index('Check(BeginAutohover()'))
self.assertEqual(source.count('m_SignedCollectiveProven = true'),1)
self.assertIn('m_ActionInputs && m_Stage >= 4 && m_Stage <= 6 && !m_SignedCollectiveProven',body(SOURCE,'protected bool ComputePilotDemand('))
self.assertIn('m_NegativeSignalSince = -1;',tick.split('now - m_PreviousTick > 1500',1)[1].split('\n',1)[0])
def test_heading_uses_explicit_yaw_not_xyz_pitch(self):
source=SOURCE.read_text()
self.assertNotIn('m_Aircraft.GetAngles()[0]',source)
self.assertIn('m_Heading = m_Aircraft.GetYawPitchRoll()[0]',source)
self.assertIn('float headingError = m_Heading - m_Aircraft.GetYawPitchRoll()[0]',source)
def test_control_and_flight_geometry_use_actual_world_pose(self):
for signature in ('void ApplyPilot(', 'protected void Diagnostic(', 'int Tick('):
for signature in ('protected bool ComputePilotDemand(', 'protected void Diagnostic(', 'int Tick('):
method=body(SOURCE,signature)
self.assertIn('m_Aircraft.GetWorldTransform(transform)',method)
self.assertNotIn('m_Simulation.GetWorldTransform(transform)',method)
@@ -140,6 +175,33 @@ int main(){
method=body(SOURCE,'static bool CollectiveObserved(').replace('Math.','Math::')
run('#include <cassert>\n#include <cmath>\nstruct Math { static float AbsFloat(float x){return std::abs(x);} };\n'+method+'''
int main(){assert(CollectiveObserved(.05f,.05f));assert(!CollectiveObserved(.05f,0));assert(!CollectiveObserved(0,0));assert(!CollectiveObserved(.05f,1));assert(!CollectiveObserved(.5f,.5f));assert(!CollectiveObserved(.05f,NAN));}
''')
def test_actor_action_mode_is_exclusive_owned_and_precedes_native_super(self):
source=SOURCE.read_text()
native=body(SOURCE,'override void OnPrepareControls(')
self.assertLess(native.index('probe.PreparePilotActions('),native.index('super.OnPrepareControls('))
apply=body(SOURCE,'void ApplyPilot(')
self.assertLess(apply.index('if (m_ActionInputs) return;'),apply.index('ComputePilotDemand('))
prepare=body(SOURCE,'void PreparePilotActions(')
self.assertIn('!m_ActionInputs || !m_Bound',prepare)
self.assertIn('controller != m_Character || owner != m_Pilot || !PoweredAuthority()',prepare)
self.assertLess(prepare.index('!OwnActionManager(am)'),prepare.index('am.ActivateContext('))
self.assertNotIn('.SetVehicle',prepare)
ownership=body(SOURCE,'protected bool OwnActionManager(')
self.assertIn('am == m_PilotAgent.GetActionManager()',ownership)
self.assertIn('am != GetGame().GetInputManager()',ownership)
self.assertEqual(source.count('GetInputManager()'),1)
self.assertIn('System.GetCLIParam("errFlightActionInputs", actionInputs)',source)
for axis in ('HelicopterCollectiveIncrease', 'HelicopterCollectiveDecrease', 'HelicopterCyclicForward', 'HelicopterCyclicBack', 'HelicopterCyclicRight', 'HelicopterCyclicLeft', 'HelicopterAntiTorqueRight', 'HelicopterAntiTorqueLeft'):
self.assertIn('"'+axis+'"',prepare)
self.assertIn('"'+axis+'"',body(SOURCE,'void Stop()'))
stop=body(SOURCE,'void Stop()')
self.assertIn('ours && m_ActionInputs && OwnActionManager(m_OwnedActions)',stop)
self.assertIn('ours && !m_ActionInputs && m_Input',stop)
def test_actual_directional_action_writer_zeros_opposite_and_bounds_values(self):
method=body(SOURCE,'bool WriteActionPair(').replace('ActionManager am','ActionManager& am').replace('Math.','Math::')
run('#include <cassert>\n#include <cmath>\n#include <algorithm>\n#include <map>\n#include <string>\nusing string=std::string;\nstruct Math {static float Clamp(float x,float a,float b){return std::clamp(x,a,b);} static float AbsFloat(float x){return std::abs(x);}};\nstruct ActionManager {std::map<string,float> values; void SetActionValue(string key,float value){values[key]=value;} float GetActionValue(string key){return values[key];}};\n'+method+'''
int main(){ActionManager owned; owned.values["back"]=1;assert(WriteActionPair(owned,"forward","back",.3f));assert(owned.values["forward"]==.3f&&owned.values["back"]==0);assert(WriteActionPair(owned,"forward","back",-.4f));assert(owned.values["forward"]==0&&owned.values["back"]==.4f);assert(WriteActionPair(owned,"forward","back",0));assert(owned.values["forward"]==0&&owned.values["back"]==0);assert(WriteActionPair(owned,"forward","back",4));assert(owned.values["forward"]==1&&owned.values["back"]==0);}
''')
def test_stop_and_engine_shutdown_are_bounded(self):
source=SOURCE.read_text();stop=body(SOURCE,'void Stop()')
@@ -147,8 +209,89 @@ int main(){assert(CollectiveObserved(.05f,.05f));assert(!CollectiveObserved(.05f
self.assertIn('s_Current = null',stop)
self.assertNotIn('StopEngine',stop) # no engine kill on midair fail/capture
self.assertNotIn('DeleteSpawned',stop)
self.assertIn('((m_Stage >= 4 && m_Stage <= 6) || m_Stage == 9) && !m_Controller.IsEngineOn()',source)
self.assertIn('((m_Stage >= 4 && m_Stage <= 6) || m_Stage == 9 || m_Stage == 10) && !m_Controller.IsEngineOn()',source)
self.assertIn('m_Fuel.GetTotalFuel() < m_InitialFuel - 0.05',source)
self.assertIn('m_Controller.StopEngine(false)',source)
self.assertIn('ERR_PoweredFlightRules.Landed(m_Simulation.HasAnyGroundContact()',source)
def test_actual_autohover_configuration_readback_and_owned_restore(self):
methods='\n'.join(body(SOURCE,key) for key in ['bool SetAutohoverMode(', 'protected bool BeginAutohover(', 'protected bool AutohoverReady(', 'protected void RestoreAutohover('])
methods=methods.replace('protected ', '').replace('m_Controller.', 'm_Controller->').replace(' = null;', ' = nullptr;').replace('LogLevel.', 'LogLevel::')
run('''
#include <cassert>
#include <initializer_list>
template<class... T> void PrintFormat(T...) {}
struct LogLevel {static const int ERROR=1;};
int nativeErrors=0;
void Print(const char*,int){nativeErrors++;}
struct Controller { void* owner=nullptr; bool enabled=false, accept=true; int writes=0;
void* GetOwner(){return owner;} bool GetAutohoverEnabled(){return enabled;}
void SetAutohoverEnabled(bool value){writes++; if(accept)enabled=value;}
};
struct Probe {
int m_Stage=1;
bool m_Started=false,m_Stopped=false,m_Complete=false,m_Bound=false;
bool m_AutohoverMode=false,m_AutohoverSaved=false,m_OriginalAutohover=false,authority=true;
void* m_Aircraft=nullptr; Controller* m_Controller=nullptr; Controller* m_AutohoverController=nullptr;
bool PoweredAuthority(){return authority;}
'''+methods+'''
void Bind(Controller& c){m_Controller=&c;m_Aircraft=c.owner;m_Bound=true;m_Started=true;}
};
int main(){int airframe, foreign;
Controller c;c.owner=&airframe;
Probe manual;manual.Bind(c);assert(manual.BeginAutohover()&&manual.AutohoverReady());
manual.RestoreAutohover(true);assert(c.writes==0); // default is observational
Probe selected;assert(selected.SetAutohoverMode(true));selected.Bind(c);
assert(!selected.SetAutohoverMode(false));assert(selected.BeginAutohover());
assert(c.enabled&&c.writes==1&&selected.AutohoverReady());
Probe waiting;waiting.SetAutohoverMode(true);waiting.Bind(c);
for(int stage: {2,3,9,10}){waiting.m_Stage=stage;assert(waiting.AutohoverReady());}
for(int stage: {1,4,5,6,7}){waiting.m_Stage=stage;assert(!waiting.AutohoverReady());}
assert(!selected.BeginAutohover()&&c.writes==1); // no retry or timestamp-like renewal
c.enabled=false;assert(!selected.AutohoverReady());c.enabled=true;
selected.RestoreAutohover(true);assert(!c.enabled&&c.writes==2);
selected.RestoreAutohover(true);assert(c.writes==2); // idempotent cleanup
Probe alreadyEnabled;c.enabled=true;assert(alreadyEnabled.SetAutohoverMode(true));alreadyEnabled.Bind(c);
assert(alreadyEnabled.BeginAutohover());alreadyEnabled.RestoreAutohover(true);assert(c.enabled);
Probe captured;assert(captured.SetAutohoverMode(true));captured.Bind(c);assert(captured.BeginAutohover());
int before=c.writes;captured.RestoreAutohover(false);assert(c.writes==before&&c.enabled);
Probe refused;c.enabled=false;c.accept=false;assert(refused.SetAutohoverMode(true));refused.Bind(c);
assert(!refused.BeginAutohover()&&!refused.AutohoverReady());assert(!refused.BeginAutohover());
c.accept=true;refused.RestoreAutohover(true);assert(!c.enabled);
Probe mismatch;assert(mismatch.SetAutohoverMode(true));mismatch.Bind(c);mismatch.m_Aircraft=&foreign;
before=c.writes;assert(!mismatch.BeginAutohover()&&c.writes==before);
Probe inactive;assert(inactive.SetAutohoverMode(true));inactive.Bind(c);inactive.authority=false;
assert(!inactive.BeginAutohover()&&c.writes==before);
Probe changed;assert(changed.SetAutohoverMode(true));changed.Bind(c);assert(changed.BeginAutohover());
Controller other;other.owner=&airframe;changed.m_Controller=&other;
assert(!changed.AutohoverReady());before=c.writes;changed.RestoreAutohover(true);
assert(c.writes==before&&other.writes==0);
Probe restoreFailed;c.enabled=false;assert(restoreFailed.SetAutohoverMode(true));restoreFailed.Bind(c);
assert(restoreFailed.BeginAutohover());c.accept=false;restoreFailed.RestoreAutohover(true);
assert(nativeErrors==1&&c.enabled); // rejected restore is retained as native failure
for(int i=0;i<5;i++){Probe frozen;if(i==0){frozen.m_Started=true;}if(i==1){frozen.m_Stopped=true;}
if(i==2){frozen.m_Complete=true;}if(i==3){frozen.m_Bound=true;}if(i==4){frozen.m_Aircraft=&airframe;}
assert(!frozen.SetAutohoverMode(true));}
}
''')
def test_autohover_neutralizes_only_custom_cyclic_and_keeps_flight_gates(self):
neutral=body(SOURCE,'if (m_AutohoverMode)')
run('#include <cassert>\nvoid controls(bool m_AutohoverMode,float& forward,float& aside){'+neutral+'''}
int main(){float f=.1f,r=-.15f;controls(false,f,r);assert(f==.1f&&r==-.15f);
controls(true,f,r);assert(f==0&&r==0);}
''')
demand=body(SOURCE,'protected bool ComputePilotDemand(')
self.assertIn('!AutohoverReady()',demand)
self.assertIn('- angular[0] * 1.2',demand)
self.assertIn('+ angular[2] * 1.2',demand)
self.assertIn('headingError * 0.01 - angular[1] * 0.3',demand)
tick=body(SOURCE,'int Tick(')
self.assertIn('!m_ReadbackOK || !AutohoverReady()',tick)
self.assertLess(tick.index('m_Bound = true'),tick.index('Check(BeginAutohover()'))
self.assertLess(tick.index('m_Controller.StartEngine()'),tick.index('Check(BeginAutohover()'))
self.assertLess(tick.index('positive collective persists in existing native rotor signal'),tick.index('Check(BeginAutohover()'))
self.assertIn('now - m_Stable >= 5000',tick)
stop=body(SOURCE,'void Stop()')
self.assertIn('m_Seat.GetOccupant() == m_Pilot',stop)
self.assertIn('CompartmentAccessComponent.GetVehicleIn(m_Pilot) == m_Aircraft',stop)
self.assertIn('RestoreAutohover(ownSeat)',stop)
if __name__=='__main__': unittest.main()
+46
View File
@@ -0,0 +1,46 @@
"""Execute the actual spectator message/refresh policy; no native UI claim."""
from policy_harness import ROOT,body,run
p=ROOT/'addons/core/Scripts/Game/ERR/ERR_Observer.c'
source=body(p,'static string Message(')+'\n'+body(p,'static bool Refresh(')
source=source.replace('ERR_ObserverReason.','ERR_ObserverReason::')
run('''#include <cassert>
#include <string>
using string=std::string;
namespace ERR_ObserverReason {enum {NONE,SPENT_LIFE,LATE_EXTRACTION,SPAWN_UNAVAILABLE};}
'''+source+'''
int main(){
for(int reason=0;reason<5;reason++)for(bool available:{false,true}){
auto message=Message(reason,available);
if(reason==0){assert(message.empty());continue;}
assert(message.find("next round")!=string::npos);
assert((message.find("/esc spectate next")!=string::npos)==available);
assert((message.find("/esc spectate previous")!=string::npos)==available);
assert((message.find("No survivor is currently available")!=string::npos)==!available);
assert((message.find("when one becomes available")!=string::npos)==!available);
}
assert(Message(1,true).find("life for this round has been used")!=string::npos);
assert(Message(2,true).find("Extraction has started")!=string::npos);
assert(Message(3,true).find("could not be spawned")!=string::npos);
for(bool shown:{false,true})for(int previous=0;previous<5;previous++)for(int reason=0;reason<5;reason++)for(bool oldAvailable:{false,true})for(bool available:{false,true}){
bool expected=reason!=0&&(!shown||previous!=reason||oldAvailable!=available);
assert(Refresh(shown,previous,reason,oldAvailable,available)==expected);
}
// Stable monitoring must not repeat. Availability and reason changes do refresh.
assert(!Refresh(true,1,1,true,true));assert(Refresh(true,1,1,true,false));
assert(Refresh(true,1,1,false,true));assert(Refresh(true,1,2,true,true));
// Camera close/reopen resets shown; missing hint manager must not consume it.
assert(Refresh(false,1,1,true,true));assert(Refresh(false,1,1,false,false));
}
''')
monitor=body(p,'protected void ERR_UpdateObserverClient(')
assert 'bool survivorAvailable = m_ERRObservedId != RplId.Invalid();' in monitor
assert 'if (!hints) return;' in monitor
assert monitor.index('if (!hints) return;')<monitor.index('hints.ShowCustom(')<monitor.index('m_ERRObserverReady = true;')
assert 'm_ERRShownObserverReason = m_ERRObserverReason;' in monitor and 'm_ERRShownObserverAvailable = survivorAvailable;' in monitor
assert 'm_ERRObserverReady = false;' in body(p,'protected void ERR_CloseObserverCamera(')
assert 'CallLater' not in monitor, 'No additional timer belongs to guidance'
commands=body(ROOT/'addons/core/Scripts/Game/ERR/ERR_PlayerAdminCommands.c','protected void ERR_OnCommand(')
assert commands.index('ERR_CycleObserver(direction);')<commands.index('Rpc(ERR_Request, arguments);')
admin=body(ROOT/'addons/core/Scripts/Game/ERR/ERR_AdminService.c','void Execute(')
assert 'if (!authorized) { replies.Insert("Administrator access required."); return; }' in admin
print('PASS: actual reason/availability guidance and refresh matrix; no-target copy omits cycling, hint retry retained, existing command/admin paths unchanged; native UI pending')