Validate independent packed addons and complete physical mortar salvos
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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=¤t;replacement.WatchActivity();
|
||||
replacement.OnActivityCompleted(&old);assert(replacement.m_ActivityTerminal==State::EVALUATED&¤t.completes==0);
|
||||
replacement.OrderId=3;replacement.OnActivityCompleted(¤t);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')
|
||||
@@ -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(')
|
||||
|
||||
@@ -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(¬Delivered.b,¬Delivered.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')
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user