Integrate delivered HQ memory, finite physical convoy and RHS WCS dependencies
This commit is contained in:
@@ -21,7 +21,7 @@ except (OSError, ValueError) as error:
|
||||
dest=ROOT/'docs/evidence'/a.name
|
||||
try:dest.mkdir(parents=True,exist_ok=False)
|
||||
except FileExistsError:p.error('Evidence already exists; refusing to overwrite')
|
||||
for name in ['console.log','script.log','error.log','crash.log','rereintroduced-hq.log','headless-launch.log','runner.json','game-startup-report.json','display-ownership.json','input-proof.json']:
|
||||
for name in ['external-addons-inputs.json','console.log','script.log','error.log','crash.log','rereintroduced-hq.log','headless-launch.log','runner.json','game-startup-report.json','display-ownership.json','input-proof.json']:
|
||||
if (source/name).is_file():shutil.copyfile(source/name,dest/(name+'.txt'))
|
||||
for capture in sorted([*source.glob('err-*.png'), *source.glob('err-*.bmp')]):
|
||||
if capture.is_file():shutil.copyfile(capture,dest/capture.name)
|
||||
|
||||
+11
-5
@@ -2,6 +2,7 @@
|
||||
"""Validate addon/resource isolation and execute policy regressions. Not an engine compiler."""
|
||||
from pathlib import Path
|
||||
import json,re,subprocess,sys
|
||||
from external_addons import policy, ALLOWED, direct_for, resource_allowed
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
BASE='58D0FB3206B6F859'
|
||||
def check():
|
||||
@@ -11,10 +12,14 @@ def check():
|
||||
assert g not in ids,f'Duplicate addon GUID: {p}'
|
||||
ids[g]=p.parent.name;graph[g]=re.findall(r'"([A-F0-9]{16})"',re.search(r'\bDependencies\s*\{([^{}]*)\}',text)[1])
|
||||
assert len(ids)==5,'Expected five addon projects'
|
||||
external=policy(ROOT)
|
||||
for guid,name in ids.items():
|
||||
assert (set(graph[guid]) & set(ALLOWED)) == direct_for(name), f'Unreviewed direct external dependency scope: {name}'
|
||||
for guid,entry in external['addons'].items(): graph[guid]=entry['dependencies']
|
||||
def walk(g,stack):
|
||||
assert g not in stack,'Dependency cycle: '+str(stack+[g])
|
||||
for dep in graph[g]:
|
||||
assert dep==BASE or dep in ids,f'External addon dependency {dep}'
|
||||
assert dep==BASE or dep in ids or dep in ALLOWED,f'External addon dependency {dep}'
|
||||
if dep!=BASE:walk(dep,stack+[g])
|
||||
for g in ids:walk(g,[])
|
||||
for p in (ROOT/'addons').rglob('*.meta'):
|
||||
@@ -27,18 +32,19 @@ def check():
|
||||
resources[g]=path;paths[path]=g
|
||||
owners[g]=addon
|
||||
def visible(g):
|
||||
result={ids[g]}
|
||||
result={ids.get(g,g)}
|
||||
for dep in graph[g]:
|
||||
if dep!=BASE:result.update(visible(dep))
|
||||
return result
|
||||
visibility={ids[g]:visible(g) for g in ids}
|
||||
assert not (visibility['core'] & set(ALLOWED)), 'Core must remain vanilla-only'
|
||||
vanilla=json.loads((ROOT/'docs/vanilla-resources.json').read_text())
|
||||
for p in (ROOT/'addons').rglob('*'):
|
||||
if not p.is_file() or p.suffix not in ['.c','.et','.conf','.ent','.layer','.layout','.emat']:continue
|
||||
s=p.read_text()
|
||||
assert not re.search(r'\b(?:ESCT\w*|SCRTC_\w+|SCRT_\w+|EAI_\w+)\b',s),f'Original ecosystem dependency: {p}'
|
||||
for g,path in re.findall(r'\{([A-F0-9]{16})\}([^"\n]+)',s):
|
||||
assert resources.get(g)==path or vanilla.get(path)==g,f'Unresolved/mismatched resource in {p}: {g} {path}'
|
||||
assert resources.get(g)==path or vanilla.get(path)==g or resource_allowed(external,visibility[p.relative_to(ROOT/'addons').parts[0]],g,path),f'Unresolved/mismatched resource in {p}: {g} {path}'
|
||||
if g in owners:
|
||||
addon=p.relative_to(ROOT/'addons').parts[0]
|
||||
assert owners[g] in visibility[addon],f'Resource outside declared dependency graph in {p}: {g} {path}'
|
||||
@@ -58,8 +64,8 @@ def check():
|
||||
assert len(mapped)==len(set(mapped)), 'Original scripts assigned more than once'
|
||||
assert set(mapped)==set(inventory['script_paths']), 'Original inventory has untracked or unknown entries'
|
||||
print(f'PASS: {len(mapped)} original script entries tracked across {len(feature_ids)} unfinished responsibilities')
|
||||
print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla references')
|
||||
for name in ['test_admin_tick_delivery.py', 'test_project_schema.py', 'test_admin_session.py', 'test_admin_providers.py', 'test_admin_menu.py', 'test_admin_player_tools.py', 'test_intel_admin.py', 'test_intel_reroll.py', 'test_intel_discovery_feedback.py', 'test_hq_report_selection.py', 'test_casualty_feedback.py', 'test_initial_admission_exhaustion.py', 'test_prison_held_action_proof.py', 'test_prison_held_action_evidence.py', 'test_prison_held_input_adapter.py', 'test_prison_held_runner.py', 'test_prison_coarse_facing.py', 'test_aircraft_field_probe.py', 'test_aircraft_field_runner.py', 'test_aircraft_field_rules.py', 'test_aircraft_field_envelope.py', 'test_aircraft_field_flight.py', 'test_command_maintenance.py', 'test_aircraft_service.py', 'test_aircraft_service_cancel_runner.py', 'test_prison_action_selection_proof.py', 'test_prison_action_selection_evidence.py', 'test_ordinary_gate_choices.py', 'test_aircraft_control_bridge.py', 'test_prison_alignment_proof.py', 'test_prison_alignment_evidence.py', 'test_aircraft_route_demand.py', 'test_prison_approach_proof.py', 'test_prison_approach_evidence.py', 'test_ordinary_gate_evidence.py', 'test_ordinary_gate_observer.py', 'test_ordinary_look_observer.py', 'test_ordinary_look_evidence.py', 'test_relative_look_runner.py', 'test_radio_waiting_text.py', 'test_inventory_control_runner.py', 'test_inventory_layout.py', 'test_headless_input_proof.py', 'test_headless_input_exercise_runner.py', 'test_private_prefix_cleanup.py', 'test_runner_private_cleanup.py', 'test_ordinary_input_runner.py', 'test_ordinary_input_observer.py', 'test_aircraft_route_envelope.py', 'test_aircraft_route_policy.py', 'test_aircraft_route_probe.py', 'test_rough_intel_feedback.py', 'test_headless_display_probe.py', 'test_headless_display_runner.py', 'test_preparation_status.py', 'test_hint_delivery.py', 'test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
|
||||
print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla/authorized external references')
|
||||
for name in ['test_external_profile_review.py', 'test_hq_uplink_fairness.py', 'test_actor_group.py', 'test_native_package_external.py', 'test_native_convoy_probe.py', 'test_convoy_placement.py', 'test_convoy_road_plan.py', 'test_hq_convoy_dispatch.py', 'test_convoy_admission.py', 'test_convoy_dispatch.py', 'test_convoy_lifecycle.py', 'test_convoy_budget.py', 'test_hq_body_observer.py', 'test_hq_status_sampler.py', 'test_hq_watch.py', 'test_hq_assignment_lifecycle.py', 'test_force_ledger.py', 'test_enemy_equipment.py', 'test_hq_doctrine.py', 'test_hq_coverage.py', 'test_hq_pursuit.py', 'test_hq_memory.py', 'test_external_addons.py', 'test_admin_tick_delivery.py', 'test_project_schema.py', 'test_admin_session.py', 'test_admin_providers.py', 'test_admin_menu.py', 'test_admin_player_tools.py', 'test_intel_admin.py', 'test_intel_reroll.py', 'test_intel_discovery_feedback.py', 'test_hq_report_selection.py', 'test_casualty_feedback.py', 'test_initial_admission_exhaustion.py', 'test_prison_held_action_proof.py', 'test_prison_held_action_evidence.py', 'test_prison_held_input_adapter.py', 'test_prison_held_runner.py', 'test_prison_coarse_facing.py', 'test_aircraft_field_probe.py', 'test_aircraft_field_runner.py', 'test_aircraft_field_rules.py', 'test_aircraft_field_envelope.py', 'test_aircraft_field_flight.py', 'test_command_maintenance.py', 'test_aircraft_service.py', 'test_aircraft_service_cancel_runner.py', 'test_prison_action_selection_proof.py', 'test_prison_action_selection_evidence.py', 'test_ordinary_gate_choices.py', 'test_aircraft_control_bridge.py', 'test_prison_alignment_proof.py', 'test_prison_alignment_evidence.py', 'test_aircraft_route_demand.py', 'test_prison_approach_proof.py', 'test_prison_approach_evidence.py', 'test_ordinary_gate_evidence.py', 'test_ordinary_gate_observer.py', 'test_ordinary_look_observer.py', 'test_ordinary_look_evidence.py', 'test_relative_look_runner.py', 'test_radio_waiting_text.py', 'test_inventory_control_runner.py', 'test_inventory_layout.py', 'test_headless_input_proof.py', 'test_headless_input_exercise_runner.py', 'test_private_prefix_cleanup.py', 'test_runner_private_cleanup.py', 'test_ordinary_input_runner.py', 'test_ordinary_input_observer.py', 'test_aircraft_route_envelope.py', 'test_aircraft_route_policy.py', 'test_aircraft_route_probe.py', 'test_rough_intel_feedback.py', 'test_headless_display_probe.py', 'test_headless_display_runner.py', 'test_preparation_status.py', 'test_hint_delivery.py', 'test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
|
||||
subprocess.run([sys.executable,str(ROOT/'tests'/name)],check=True)
|
||||
print('All repository checks passed. Native compilation and multiplayer tests are separate requirements.')
|
||||
if __name__=='__main__':check()
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Explicit authorized external dependency policy and private native-profile exposure.
|
||||
Never copy third-party assets or expose the whole workshop tree to Workbench.
|
||||
"""
|
||||
from pathlib import Path, PurePosixPath
|
||||
import hashlib,json,re,stat
|
||||
BASE='58D0FB3206B6F859'
|
||||
DIRECT=('595F2BF2F44836FB','629B2BA37EFFD577','5E524E4FEECCA92B')
|
||||
ALLOWED={
|
||||
'595F2BF2F44836FB':{BASE,'1337C0DE5DABBEEF','BADC0DEDABBEDA5E'},
|
||||
'1337C0DE5DABBEEF':{BASE},'BADC0DEDABBEDA5E':{BASE},
|
||||
'629B2BA37EFFD577':{BASE},'5E389BB9F58B79A6':{BASE,'629B2BA37EFFD577'},
|
||||
'5E524E4FEECCA92B':{BASE,'5E389BB9F58B79A6','629B2BA37EFFD577'},
|
||||
}
|
||||
|
||||
def require(value,message):
|
||||
if not value:raise ValueError(message)
|
||||
|
||||
def parse_project(text):
|
||||
guid=re.search(r'\bGUID\s+"([A-F0-9]{16})"',text)
|
||||
deps=re.search(r'\bDependencies\s*\{([^{}]*)\}',text)
|
||||
require(guid and deps,'Missing addon identity/dependencies')
|
||||
values=re.findall(r'"([A-F0-9]{16})"',deps[1]);require(len(values)==len(set(values)),'Duplicate dependency')
|
||||
return guid[1],set(values)
|
||||
|
||||
def policy(root):
|
||||
value=json.loads((Path(root)/'docs/external-addons.json').read_text())
|
||||
require(value.get('schema_version')==1 and set(value['addons'])==set(ALLOWED),'Unreviewed external addon policy')
|
||||
require(set(value['direct_ai_dependencies'])==set(DIRECT),'Unreviewed direct AI dependencies')
|
||||
for guid,entry in value['addons'].items():
|
||||
require(set(entry['dependencies'])==ALLOWED[guid],'Unreviewed transitive dependency: '+guid)
|
||||
require(re.fullmatch(r'[a-f0-9]{64}',entry['project_sha256']) is not None,'Missing manifest fingerprint')
|
||||
for path,entry in value['resources'].items():
|
||||
require(not PurePosixPath(path).is_absolute() and '..' not in PurePosixPath(path).parts and '\\' not in path,'Unsafe resource path')
|
||||
require(entry['addon'] in ALLOWED and re.fullmatch(r'[A-F0-9]{16}',entry['guid']) is not None,'Unknown resource owner/identity')
|
||||
return value
|
||||
|
||||
def direct_for(project):return set(DIRECT) if project=='ai' else set()
|
||||
|
||||
def resource_allowed(value,visible,guid,path):
|
||||
entry=value['resources'].get(path)
|
||||
return bool(entry and entry['guid']==guid and entry['addon'] in visible)
|
||||
|
||||
def fingerprint(path,cache):
|
||||
path=Path(path);before=path.stat();require(path.is_file() and not path.is_symlink(),'External input must be a regular file: '+str(path))
|
||||
key=str(path);stamp=[before.st_dev,before.st_ino,before.st_size,before.st_mtime_ns,before.st_ctime_ns]
|
||||
old=cache.get(key)
|
||||
if old and old.get('stat')==stamp:return old
|
||||
digest=hashlib.sha256()
|
||||
with path.open('rb') as stream:
|
||||
for block in iter(lambda:stream.read(4*1024*1024),b''):digest.update(block)
|
||||
after=path.stat();require(stamp==[after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns],'External file changed while hashing')
|
||||
result={'stat':stamp,'sha256':digest.hexdigest()};cache[key]=result;return result
|
||||
|
||||
def packed_input_files(folder):
|
||||
# Installed workshop inputs are flat packed addons. A linked folder must not
|
||||
# silently expose loose Scripts/Prefabs or resources outside this fingerprint.
|
||||
files=[]
|
||||
for path in sorted(Path(folder).iterdir()):
|
||||
require(path.is_file() and not path.is_symlink(),'External packed input must be a flat regular file: '+str(path))
|
||||
name=path.name
|
||||
runtime=name in {'addon.gproj','resourceDatabase.rdb'} or re.fullmatch(r'[A-Za-z0-9_.-]+\.pak',name)
|
||||
media=name=='thumbnail.png' or re.fullmatch(r'gallery[0-9]+_[0-9]+x[0-9]+\.(?:jpg|png)',name)
|
||||
sidecar=re.fullmatch(r'(?:addon\.gproj|resourceDatabase\.rdb|[A-Za-z0-9_.-]+\.pak|thumbnail\.png|gallery[0-9]+_[0-9]+x[0-9]+\.(?:jpg|png))_[0-9]+(?:\.[0-9]+)*_manifest\.json',name)
|
||||
require(runtime or media or sidecar or name in {'meta','ServerData.json'},'Unreviewed external packed input: '+str(path))
|
||||
files.append(path)
|
||||
require(any(path.suffix=='.pak' for path in files),'Missing runtime PAK: '+str(folder))
|
||||
return files
|
||||
|
||||
def installed(source,value,cache=None):
|
||||
source=Path(source).resolve();require(source.is_dir(),'External addon source directory missing')
|
||||
cache={} if cache is None else cache
|
||||
selected={}
|
||||
for folder in source.iterdir():
|
||||
if not folder.is_dir() or folder.is_symlink():continue
|
||||
manifest=folder/'addon.gproj'
|
||||
if not manifest.is_file() or manifest.is_symlink():continue
|
||||
text=manifest.read_text();match=re.search(r'\bGUID\s+"([A-F0-9]{16})"',text)
|
||||
if not match or match[1] not in ALLOWED:continue
|
||||
guid=match[1]
|
||||
require(guid not in selected,'Duplicate installed authorized addon: '+guid)
|
||||
selected[guid]=folder
|
||||
require(set(selected)==set(ALLOWED),'Missing installed authorized external addons: '+','.join(sorted(set(ALLOWED)-set(selected))))
|
||||
records={}
|
||||
for guid,folder in sorted(selected.items()):
|
||||
entry=value['addons'][guid];manifest=folder/'addon.gproj';actual,deps=parse_project(manifest.read_text())
|
||||
require(actual==guid and deps==ALLOWED[guid],'Installed dependency closure changed: '+guid)
|
||||
require(hashlib.sha256(manifest.read_bytes()).hexdigest()==entry['project_sha256'],'Installed project manifest changed; audit update required: '+guid)
|
||||
database=folder/'resourceDatabase.rdb';require(database.is_file() and not database.is_symlink(),'Missing installed resource database: '+guid)
|
||||
data=database.read_bytes()
|
||||
for path,resource in value['resources'].items():
|
||||
if resource['addon']!=guid:continue
|
||||
# Observed RDB resource record: NUL-terminated path, six-byte type header,
|
||||
# then little-endian 64-bit resource identity. Bind both name and exact GUID.
|
||||
needle=path.encode()+b'\0'+b'\x06\0\0\0\0\0'+bytes.fromhex(resource['guid'])[::-1]
|
||||
require(data.count(needle)==1,'Installed resource identity missing/ambiguous: '+path)
|
||||
files=packed_input_files(folder)
|
||||
records[guid]={'path':str(folder),'title':entry['title'],'version':entry['version'],'files':{p.name:fingerprint(p,cache) for p in files}}
|
||||
return records
|
||||
|
||||
def prepare_profile(addons,source,value,cache_path):
|
||||
addons=Path(addons);cache_path=Path(cache_path)
|
||||
cache=json.loads(cache_path.read_text()) if cache_path.is_file() else {}
|
||||
records=installed(source,value,cache)
|
||||
names={'ERRI_'+guid for guid in ALLOWED}
|
||||
require(all(p.name in names|{'ArmaReforger','saves'} for p in addons.iterdir()),'Unrelated addon in private external profile')
|
||||
for guid,entry in records.items():
|
||||
link=addons/('ERRI_'+guid);target=Path(entry['path'])
|
||||
if link.exists() or link.is_symlink():require(link.is_symlink() and link.resolve()==target,'Unexpected private dependency link: '+str(link))
|
||||
else:link.symlink_to(target,target_is_directory=True)
|
||||
cache_path.write_text(json.dumps(cache,indent=2)+'\n')
|
||||
return records
|
||||
|
||||
def verify_profile(records,source,value,cache_path,addons):
|
||||
addons=Path(addons)
|
||||
require(addons.is_dir() and not addons.is_symlink(),'Private external addon directory changed')
|
||||
expected={'ERRI_'+guid for guid in records}
|
||||
require(all(path.name in expected|{'ArmaReforger','saves'} for path in addons.iterdir()),'Unrelated addon in private external profile')
|
||||
for guid,entry in records.items():
|
||||
link=addons/('ERRI_'+guid)
|
||||
require(link.is_symlink() and link.resolve()==Path(entry['path']),'Private external dependency link changed: '+str(link))
|
||||
cache=json.loads(Path(cache_path).read_text())
|
||||
return installed(source,value,cache)==records
|
||||
@@ -7,12 +7,14 @@ import argparse, fcntl, json, os, shutil, signal, subprocess, time
|
||||
from pathlib import Path
|
||||
from native_provenance import snapshot, persist, bindings, seal_run
|
||||
from headless_game import assert_prefix_idle, cleanup_owned_run
|
||||
from external_addons import policy as external_policy, prepare_profile, verify_profile
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
||||
p.add_argument('--external-addons', type=Path, help='Installed RHS/WCS/BMP source root; expose only audited closure in a separate Chat profile')
|
||||
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
|
||||
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
|
||||
p.add_argument('--smoke', choices=['aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
|
||||
p.add_argument('--smoke', choices=['convoy', 'hq-memory', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
|
||||
p.add_argument('--field-terrain-audit', action='store_true', help='After a fixed aircraft-field survey refusal, log one read-only 1681-node coarse terrain grid; never selects or qualifies a corridor')
|
||||
p.add_argument('--field-service-cancel', action='store_true', help='Explicit aircraft-field variant: Stop the original retained service during measured cruise; requires physical cancellation landing')
|
||||
p.add_argument('--route-service-cancel', action='store_true', help='Explicit aircraft-route variant: stop the retained service after actual airborne translation and require controlled cancellation landing')
|
||||
@@ -36,6 +38,8 @@ if a.smoke == 'mortar-courier' and (a.project != 'chat' or a.rounds != 1 or a.ti
|
||||
if a.smoke in {'mortar-refresh', 'mortar-salvo', 'mortar-expiry', 'mortar-transfer', 'mortar-fire'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400):
|
||||
p.error('mortar-refresh/mortar-salvo/mortar-expiry/mortar-transfer/mortar-fire requires --project chat --rounds 1 --timeout at least 400')
|
||||
if a.smoke in {'aircraft-field', 'aircraft-route', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('aircraft-field/aircraft-route/aircraft-control/aircraft-cancel/aircraft-translate requires Chat, one round, and at least 420 seconds')
|
||||
if a.smoke == 'convoy' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 600): p.error('convoy requires Chat, exactly one round, and timeout at least600 seconds')
|
||||
if a.smoke == 'hq-memory' and (a.project != 'chat' or a.rounds != 1): p.error('hq-memory requires Chat and exactly one round')
|
||||
if a.smoke == 'map-ownership' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 180): p.error('map-ownership requires Chat, one round, and at least 180 seconds')
|
||||
if a.smoke == 'powered-translate' and (a.project != 'core' or not a.flight_action_inputs or not a.flight_autohover): p.error('powered-translate requires Core with flight-action-inputs and flight-autohover')
|
||||
if a.flight_autohover and (a.project != 'core' or a.smoke not in {'powered-flight', 'powered-translate'} or not a.flight_action_inputs): p.error('flight-autohover requires Core powered-flight with flight-action-inputs')
|
||||
@@ -52,6 +56,9 @@ if a.smoke == 'environment-restart':
|
||||
if any((profile/name).exists() or (profile/name).is_symlink() for name in fixture_names): p.error('environment-restart fixture paths must not preexist; preserve or move prior evidence before retrying')
|
||||
if compat.is_relative_to(steam) or not (compat/'pfx/system.reg').is_file():
|
||||
p.error('--compat must be a disposable cloned prefix outside the Steam installation')
|
||||
if a.external_addons and a.project != 'chat': p.error('External AI dependencies are not permitted in Core-only tests')
|
||||
if a.external_addons and profile == Path('/tmp/erri-workbench-profile'): p.error('Use a separate external-dependency profile; preserve the vanilla test profile')
|
||||
if a.project == 'chat' and not a.external_addons: p.error('Full Chat now requires --external-addons pointing to installed authorized RHS/WCS/BMP dependencies')
|
||||
prefix_lock = (compat/'.erri-workbench.lock').open('a')
|
||||
try: fcntl.flock(prefix_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError: p.error('Another test runner owns this disposable Wine prefix')
|
||||
@@ -71,8 +78,17 @@ addons = profile/'addons'
|
||||
addons.mkdir(exist_ok=True)
|
||||
link = addons/'ArmaReforger'
|
||||
if not link.exists(): link.symlink_to(vanilla, target_is_directory=True)
|
||||
if link.resolve() != vanilla.resolve() or any(x.name not in {'ArmaReforger', 'saves'} for x in addons.iterdir()) or any((addons/'saves').rglob('*.gproj')):
|
||||
p.error('Test profile addons must contain only the intended vanilla link')
|
||||
external_inputs = None
|
||||
external_cache = profile/'external-source-cache.json'
|
||||
allowed_profile_addons = {'ArmaReforger', 'saves'}
|
||||
if a.external_addons:
|
||||
try:
|
||||
external_rules = external_policy(ROOT)
|
||||
external_inputs = prepare_profile(addons, a.external_addons, external_rules, external_cache)
|
||||
allowed_profile_addons.update('ERRI_'+guid for guid in external_inputs)
|
||||
except (OSError, ValueError) as error: p.error(str(error))
|
||||
if link.resolve() != vanilla.resolve() or any(x.name not in allowed_profile_addons for x in addons.iterdir()) or any((addons/'saves').rglob('*.gproj')):
|
||||
p.error('Test profile addons must contain only vanilla and the explicit audited external closure')
|
||||
profile_arg = 'Z:'+str(profile)
|
||||
# Refuse concurrency instead of terminating anybody else's editor.
|
||||
for proc in Path('/proc').iterdir():
|
||||
@@ -152,6 +168,8 @@ with (profile/'headless-launch.log').open('w') as output:
|
||||
session = max(candidates, key=lambda x:x.stat().st_mtime)
|
||||
if session not in recorded_sessions:
|
||||
persist(session, captured_sources)
|
||||
if external_inputs:
|
||||
(session/'external-addons-inputs.json').write_text(json.dumps(external_inputs, indent=2)+'\n')
|
||||
recorded_sessions.add(session)
|
||||
console = session/'console.log'
|
||||
content = console.read_text(errors='replace') if console.exists() else ''
|
||||
@@ -175,9 +193,14 @@ with (profile/'headless-launch.log').open('w') as output:
|
||||
result = 'cleanup-failed'
|
||||
cleanup_error = '; '.join(cleanup_report['errors'])
|
||||
runner_error = (runner_error + '; ' if runner_error else '') + cleanup_error
|
||||
external_unchanged = True
|
||||
if external_inputs:
|
||||
try: external_unchanged = verify_profile(external_inputs, a.external_addons, external_rules, external_cache, addons)
|
||||
except (OSError, ValueError): external_unchanged = False
|
||||
if not external_unchanged: result = 'external-inputs-changed'
|
||||
stopped_evidence = seal_run(ROOT, session, captured_sources) if cleanup_ok and session else {}
|
||||
if stopped_evidence and not stopped_evidence['source_unchanged_after_run']: result = 'source-changed'
|
||||
summary = {**stopped_evidence, **bindings(captured_sources), 'started_unix':started, 'result':result, 'result_before_cleanup':result_before_cleanup, 'cleanup_confirmed':cleanup_ok, 'cleanup_report':cleanup_report, 'runner_error':runner_error, 'session':str(session) if session else None, 'rounds_required':a.rounds, 'project':a.project, 'display':'headless Gamescope 1280x720/30Hz', 'dedicated_server':False, 'audio':'silent sink and per-test-stream mute'}
|
||||
summary = {**stopped_evidence, **bindings(captured_sources), 'external_addons_unchanged_after_run':external_unchanged, 'external_addon_count':len(external_inputs or {}), 'external_addon_inputs':'external-addons-inputs.json' if external_inputs else None, 'started_unix':started, 'result':result, 'result_before_cleanup':result_before_cleanup, 'cleanup_confirmed':cleanup_ok, 'cleanup_report':cleanup_report, 'runner_error':runner_error, 'session':str(session) if session else None, 'rounds_required':a.rounds, 'project':a.project, 'display':'headless Gamescope 1280x720/30Hz', 'dedicated_server':False, 'audio':'silent sink and per-test-stream mute'}
|
||||
if session:
|
||||
(session/'runner.json').write_text(json.dumps(summary, indent=2)+'\n')
|
||||
shutil.copyfile(profile/'headless-launch.log', session/'headless-launch.log')
|
||||
|
||||
@@ -13,8 +13,9 @@ SCHEMA={
|
||||
'timeMultiplier':(1,1,24),
|
||||
'startDateCode':(0,0,1501231),
|
||||
'weatherProfile':(0,0,3),
|
||||
'stingerChanceOneIn':(15,0,10000),
|
||||
}
|
||||
AI_KEYS=('reportIntervalSeconds','supportSquadLimit','responseRadiusMetres')
|
||||
AI_KEYS=('reportIntervalSeconds','supportSquadLimit','responseRadiusMetres','stingerChanceOneIn')
|
||||
INTEL_KEYS=('aiIntelChancePercent','intelRevealChancePercent')
|
||||
def merge(ai=None,intel=None):
|
||||
result={key:spec[0] for key,spec in SCHEMA.items()};deferred=[]
|
||||
|
||||
+148
-5
@@ -20,6 +20,7 @@ import zipfile
|
||||
from source_bundle import build, inventory, PROJECTS
|
||||
from headless_game import assert_no_game, assert_prefix_idle, cleanup_owned_run
|
||||
from native_report import analyze
|
||||
from external_addons import policy as external_policy, installed as external_installed, prepare_profile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ORDER = tuple(PROJECTS)
|
||||
@@ -56,6 +57,89 @@ def project_order(project):
|
||||
return [name for name in ORDER if name in needed]
|
||||
|
||||
|
||||
|
||||
def external_request(project, source, root):
|
||||
needs_external = project in {'ai', 'chat'}
|
||||
if needs_external and source is None:
|
||||
raise ValueError('AI and Chat builds require --external-addons with the audited installed RHS/WCS/BMP closure')
|
||||
if source is not None and not needs_external:
|
||||
raise ValueError('Core, Intel and Map builds must use vanilla-only profiles; --external-addons is not permitted')
|
||||
if source is None:
|
||||
return None
|
||||
source = Path(source).resolve()
|
||||
if not source.is_dir():
|
||||
raise ValueError('External addon source directory missing')
|
||||
policy_path = root/'docs/external-addons.json'
|
||||
if policy_path.is_symlink() or not policy_path.is_file():
|
||||
raise ValueError('External policy must be a regular repository file')
|
||||
before = digest(policy_path)
|
||||
value = external_policy(root)
|
||||
if digest(policy_path) != before:
|
||||
raise ValueError('External policy changed while reading')
|
||||
return {'source': source, 'policy': value, 'policy_path': policy_path,
|
||||
'policy_sha256': before}
|
||||
|
||||
|
||||
def capture_external(context, session):
|
||||
cache = {}
|
||||
context['records'] = external_installed(context['source'], context['policy'], cache)
|
||||
context['cache_path'] = session/'external-source-cache.json'
|
||||
save(context['cache_path'], cache)
|
||||
context['cache_sha256'] = digest(context['cache_path'])
|
||||
save(session/'external-addons-policy.json', context['policy'])
|
||||
save(session/'external-addons-before.json', context['records'])
|
||||
verify_external(context)
|
||||
|
||||
|
||||
def verify_external(context):
|
||||
context['last_records'] = None
|
||||
if context['cache_path'].is_symlink() or digest(context['cache_path']) != context['cache_sha256']:
|
||||
raise ValueError('External fingerprint cache changed during package attempt')
|
||||
if context['policy_path'].is_symlink() or digest(context['policy_path']) != context['policy_sha256']:
|
||||
raise ValueError('External dependency policy changed during package attempt')
|
||||
cache = json.loads(context['cache_path'].read_text())
|
||||
current = external_installed(context['source'], context['policy'], cache)
|
||||
context['last_records'] = current
|
||||
if current != context['records']:
|
||||
raise ValueError('External addon inputs changed during package attempt')
|
||||
return current
|
||||
|
||||
|
||||
def verify_package_profile(profile, steam, records):
|
||||
addons = profile/'addons'
|
||||
expected = {'ArmaReforger', *('ERRI_'+guid for guid in records)}
|
||||
if addons.is_symlink() or not addons.is_dir() or addons.resolve() != addons:
|
||||
raise ValueError('Private package addon directory changed')
|
||||
names = {path.name for path in addons.iterdir()}
|
||||
if names - {'saves'} != expected:
|
||||
raise ValueError('Private package profile contains missing or unrelated addons')
|
||||
saves = addons/'saves'
|
||||
if saves.exists() or saves.is_symlink():
|
||||
if saves.is_symlink() or not saves.is_dir():
|
||||
raise ValueError('Unexpected private package saves entry')
|
||||
targets = {'ArmaReforger': steam/'steamapps/common/Arma Reforger/addons/data'}
|
||||
targets.update({'ERRI_'+guid: Path(entry['path']) for guid, entry in records.items()})
|
||||
for name, target in targets.items():
|
||||
link = addons/name
|
||||
if not link.is_symlink() or link.resolve() != target.resolve():
|
||||
raise ValueError('Private package dependency link changed: '+name)
|
||||
|
||||
|
||||
def expose_external(project, profile, steam, context):
|
||||
records = {}
|
||||
if project in {'ai', 'chat'}:
|
||||
if context is None:
|
||||
raise ValueError('AI and Chat native packaging require audited external inputs')
|
||||
verify_external(context)
|
||||
records = prepare_profile(profile/'addons', context['source'], context['policy'], context['cache_path'])
|
||||
if records != context['records']:
|
||||
raise ValueError('External addon inputs changed before package launch')
|
||||
elif context is not None:
|
||||
raise ValueError('External links are forbidden in vanilla-only package profiles')
|
||||
verify_package_profile(profile, steam, records)
|
||||
return records
|
||||
|
||||
|
||||
def validate_session(root, steam, compat, session):
|
||||
if session.exists() or session.is_symlink():
|
||||
raise ValueError('--session must not already exist; outputs are never reused')
|
||||
@@ -215,7 +299,9 @@ def quiet_streams(application_id, sink, compat):
|
||||
break
|
||||
|
||||
|
||||
def run_project(steam, compat, session, project, timeout, authored, silent_workbench=False, exit_after_init=False):
|
||||
def run_project(steam, compat, session, project, timeout, authored, silent_workbench=False, exit_after_init=False, *, external=None):
|
||||
if (project in {'ai', 'chat'}) != (external is not None):
|
||||
raise ValueError('Only AI/Chat package profiles require and permit audited external inputs')
|
||||
command = command_for(steam, session, project, silent_workbench, exit_after_init)
|
||||
proton = Path(command[command.index('--')+1])
|
||||
executable = Path(command[command.index('--')+3])
|
||||
@@ -225,6 +311,9 @@ def run_project(steam, compat, session, project, timeout, authored, silent_workb
|
||||
for name in ('packages', 'cache', 'evidence'):
|
||||
(session/name/project).mkdir(parents=True)
|
||||
evidence = session/'evidence'/project
|
||||
external_records = expose_external(project, profile, steam, external)
|
||||
if external_records:
|
||||
save(evidence/'external-addons-before.json', external_records)
|
||||
separator = command.index('--')
|
||||
native_command = command[separator+1:]
|
||||
recorder = evidence/'native-child.py'
|
||||
@@ -238,6 +327,9 @@ def run_project(steam, compat, session, project, timeout, authored, silent_workb
|
||||
save(evidence/'launch.json', {'command': command, 'native_command': native_command, 'authored': authored, 'started_unix': time.time(),
|
||||
'executable_sha256': digest(executable), 'proton_sha256': digest(proton),
|
||||
'child_recorder_sha256': digest(recorder), 'native_environment_sha256': digest(environment_path),
|
||||
'external_addon_count': len(external_records),
|
||||
'external_policy_sha256': external['policy_sha256'] if external else None,
|
||||
'external_addon_inputs': 'external-addons-before.json' if external else None,
|
||||
'silent_workbench_experiment': silent_workbench,
|
||||
'exit_after_init_experiment': exit_after_init})
|
||||
run_id = uuid.uuid4().hex
|
||||
@@ -327,10 +419,27 @@ def run_project(steam, compat, session, project, timeout, authored, silent_workb
|
||||
except (OSError, ValueError) as error:
|
||||
logs, packages, unchanged = {}, {}, False
|
||||
errors = ['Output/evidence validation failed: '+str(error)]
|
||||
profile_unchanged, external_unchanged = True, True
|
||||
try:
|
||||
verify_package_profile(profile, steam, external_records)
|
||||
except (OSError, ValueError) as error:
|
||||
profile_unchanged = False
|
||||
errors.append('Private profile validation failed: '+str(error))
|
||||
if external is not None:
|
||||
try:
|
||||
verify_external(external)
|
||||
except (OSError, ValueError) as error:
|
||||
external_unchanged = False
|
||||
errors.append('External input validation failed: '+str(error))
|
||||
if external_records:
|
||||
save(evidence/'external-addons-after.json', {'records': external['last_records'], 'unchanged': external_unchanged})
|
||||
save(evidence/'external-addons-verification.json', {
|
||||
'unchanged': external_unchanged, 'profile_unchanged': profile_unchanged,
|
||||
'policy_sha256': external['policy_sha256'], 'before': 'external-addons-before.json'})
|
||||
native_logs = [name for name in logs if Path(name).name == 'console.log' and logs[name]['bytes'] > 0]
|
||||
native_exit = child_status(status_path, native_command)
|
||||
wrapper_ok = exit_code == 0 or (exit_code is None and result == 'native-child-exited')
|
||||
candidate = wrapper_ok and candidate_ok(native_exit, result, cleanup_ok, unchanged, native_logs, errors, packages)
|
||||
candidate = wrapper_ok and profile_unchanged and external_unchanged and candidate_ok(native_exit, result, cleanup_ok, unchanged, native_logs, errors, packages)
|
||||
report = {'project': project, 'result': 'native-pack-candidate' if candidate else ('validation-failed' if result in {'process-exited', 'native-child-exited'} else result),
|
||||
'candidate_created': candidate, 'native_exit_code': native_exit, 'launcher_exit_code': exit_code,
|
||||
'completion_observed': result, 'display_cleanup_after_native_exit': result == 'native-child-exited',
|
||||
@@ -339,6 +448,11 @@ def run_project(steam, compat, session, project, timeout, authored, silent_workb
|
||||
'native_status_sha256': digest(status_path) if status_path.is_file() and not status_path.is_symlink() else None,
|
||||
'cleanup_confirmed': cleanup_ok, 'cleanup_report': cleanup_report,
|
||||
'copied_authored_unchanged': unchanged, 'runner_error': runner_error,
|
||||
'profile_addons_unchanged': profile_unchanged,
|
||||
'external_addons_unchanged_after_run': external_unchanged,
|
||||
'external_addon_count': len(external_records),
|
||||
'external_policy_sha256': external['policy_sha256'] if external else None,
|
||||
'external_addon_inputs': 'external-addons-before.json' if external else None,
|
||||
'logs': logs, 'errors': errors, 'packages': packages,
|
||||
'package_contents_verified': False, 'native_consumption_verified': False,
|
||||
'tool_completion_semantics_verified': False, 'release_qualified': False,
|
||||
@@ -351,6 +465,7 @@ def run(a, root=ROOT):
|
||||
root, steam, compat = root.resolve(), a.steam.resolve(), a.compat.resolve()
|
||||
session = a.session.absolute() # refuse existing symlinks before resolve
|
||||
validate_session(root, steam, compat, session)
|
||||
external = external_request(a.project, getattr(a, 'external_addons', None), root)
|
||||
if not 30 <= a.timeout <= 1800:
|
||||
raise ValueError('--timeout must be 30..1800 seconds per addon')
|
||||
silent_workbench = getattr(a, 'silent_workbench', False)
|
||||
@@ -371,16 +486,30 @@ def run(a, root=ROOT):
|
||||
raise ValueError('Another native runner owns the disposable prefix')
|
||||
assert_prefix_idle(compat)
|
||||
authored = copy_payload(root, session)
|
||||
if external is not None:
|
||||
capture_external(external, session)
|
||||
reports = []
|
||||
order = project_order(a.project)
|
||||
continue_on_failure = getattr(a, 'continue_on_failure', False)
|
||||
stopped_reason = None
|
||||
for project in order:
|
||||
report = run_project(steam, compat, session, project, a.timeout, authored, silent_workbench, exit_after_init)
|
||||
selected_external = external if project in {'ai', 'chat'} else None
|
||||
report = run_project(steam, compat, session, project, a.timeout, authored, silent_workbench, exit_after_init, external=selected_external)
|
||||
reports.append(report)
|
||||
# Diagnostic continuation never grants authority to reuse an unsafe
|
||||
# prefix or a changed source copy. Timeout/interruption also stop.
|
||||
if not report.get('cleanup_confirmed'):
|
||||
if external is not None:
|
||||
try:
|
||||
verify_external(external)
|
||||
except (OSError, ValueError) as error:
|
||||
stopped_reason = 'external dependency inputs changed or unverifiable: '+str(error)
|
||||
if stopped_reason:
|
||||
break
|
||||
if report.get('profile_addons_unchanged') is False:
|
||||
stopped_reason = 'private addon profile changed or unverifiable'
|
||||
elif selected_external is not None and report.get('external_addons_unchanged_after_run') is not True:
|
||||
stopped_reason = 'external dependency inputs changed or unverifiable'
|
||||
elif not report.get('cleanup_confirmed'):
|
||||
stopped_reason = 'owned cleanup unconfirmed'
|
||||
elif report.get('runner_error'):
|
||||
stopped_reason = 'native runner error'
|
||||
@@ -404,14 +533,27 @@ def run(a, root=ROOT):
|
||||
original_unchanged = original_after == authored
|
||||
except (OSError, ValueError):
|
||||
original_unchanged = False
|
||||
external_unchanged = True
|
||||
if external is not None:
|
||||
try:
|
||||
verify_external(external)
|
||||
except (OSError, ValueError) as error:
|
||||
external_unchanged = False
|
||||
if stopped_reason is None:
|
||||
stopped_reason = 'external dependency final verification failed: '+str(error)
|
||||
save(session/'external-addons-after.json', {'records': external['last_records'], 'unchanged': external_unchanged})
|
||||
summary = {'kind': 'UNQUALIFIED NATIVE PACKAGE ATTEMPT', 'session': str(session),
|
||||
'project_order': order, 'projects': reports,
|
||||
'continue_on_failure': continue_on_failure, 'stopped_reason': stopped_reason,
|
||||
'skipped_projects': order[len(reports):],
|
||||
'source_repository_unchanged': original_unchanged,
|
||||
'external_addons_unchanged_after_run': external_unchanged,
|
||||
'external_addon_count': len(external['records']) if external else 0,
|
||||
'external_addon_inputs': 'external-addons-before.json' if external else None,
|
||||
'external_policy_sha256': external['policy_sha256'] if external else None,
|
||||
'release_qualified': False, 'published': False,
|
||||
'qualification': 'PENDING native contents inspection and packed-only consumption'}
|
||||
summary['all_candidates_created'] = original_unchanged and stopped_reason is None and len(reports) == len(order) and all(r['candidate_created'] for r in reports)
|
||||
summary['all_candidates_created'] = original_unchanged and external_unchanged and stopped_reason is None and len(reports) == len(order) and all(r['candidate_created'] for r in reports)
|
||||
save(session/'package-report.json', summary)
|
||||
print(json.dumps(summary, indent=2))
|
||||
return 0 if summary['all_candidates_created'] else 1
|
||||
@@ -424,6 +566,7 @@ def main(argv=None):
|
||||
parser.add_argument('--compat', type=Path, required=True, help='Idle disposable cloned Tools prefix under /tmp')
|
||||
parser.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
||||
parser.add_argument('--timeout', type=int, default=300, help='30..1800 seconds per addon')
|
||||
parser.add_argument('--external-addons', type=Path, help='Required for AI/Chat: installed audited RHS/WCS/BMP root; only allowlisted links enter private profiles')
|
||||
lifecycle = parser.add_mutually_exclusive_group()
|
||||
lifecycle.add_argument('--silent-workbench', action='store_true', help='Opt-in documented -wbSilent experiment; packing compatibility still requires native evidence')
|
||||
lifecycle.add_argument('--exit-after-init', action='store_true', help='Opt-in documented -exitAfterInit experiment; natural exit and all native error gates remain mandatory')
|
||||
|
||||
@@ -16,6 +16,7 @@ sys.dont_write_bytecode = True
|
||||
|
||||
from source_manifest import addon_manifest
|
||||
from project_schema import parse_project
|
||||
from external_addons import policy as external_policy, direct_for, resource_allowed, ALLOWED
|
||||
from release_check import audit, read_json, evidence_path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -35,7 +36,7 @@ README = '''# DEVELOPMENT SOURCE BUNDLE — Escapists Rereintroduced
|
||||
|
||||
This ZIP contains authored source and editable resources for all five successor
|
||||
addon projects. It is NOT a native .pak, a qualified release, or a published mod.
|
||||
No original Escapists or installed vanilla assets are redistributed. Vanilla asset
|
||||
No original Escapists, installed vanilla or third-party assets are redistributed. Vanilla asset
|
||||
references require a separately installed, compatible Arma Reforger and Workbench.
|
||||
|
||||
Extract into a NEW development folder, never over an existing mission/project.
|
||||
@@ -50,7 +51,7 @@ Companion scenario: {C1E20667DCE324ED}Missions/ERR_Everon.conf
|
||||
Use the installed engine's normal project/scenario workflow. This archive has not
|
||||
been compiled by its bundler; no native caches or binaries are included.
|
||||
|
||||
Target: vanilla Everon, up to 12 players. This remains development source.
|
||||
Target: Everon, up to 12 players; Core is vanilla-only and full AI requires the listed external addons. This remains development source.
|
||||
Manual prison/scavenging/radio/extraction play, remote joins/reconnects and
|
||||
ownership, twelve connected players, aircraft support, repeated companion cleanup,
|
||||
matched-workload FPS improvement and release qualification require separate
|
||||
@@ -120,13 +121,14 @@ def inventory(root):
|
||||
|
||||
def validate(payload, root):
|
||||
projects = {}
|
||||
external = external_policy(root)
|
||||
own_ids = read_json(evidence_path(root, 'docs/addons.json'))
|
||||
if own_ids != {name: item[1] for name, item in PROJECTS.items()}:
|
||||
raise ValueError('successor identity catalog differs from the five owned projects')
|
||||
for name, (identity, guid, deps) in PROJECTS.items():
|
||||
text = payload.get(f'addons/{name}/addon.gproj', b'').decode('utf-8')
|
||||
match = parse_project(text, allow_core_menu=name == 'core')
|
||||
expected = {BASE, *(PROJECTS[d][1] for d in deps)}
|
||||
expected = {BASE, *(PROJECTS[d][1] for d in deps), *direct_for(name)}
|
||||
found = re.findall(r'"([A-F0-9]{16})"', match[4]) if match else []
|
||||
if not match or match[1] != identity or match[2] != guid or set(found) != expected or len(found) != len(expected):
|
||||
raise ValueError(f'identity/dependency closure mismatch: {name}')
|
||||
@@ -156,12 +158,13 @@ def validate(payload, root):
|
||||
raise ValueError(f'resource lacks successor metadata: {name}')
|
||||
owner = name.split('/')[1]
|
||||
visible = {owner, *PROJECTS[owner][2]}
|
||||
if 'ai' in visible: visible.update(ALLOWED)
|
||||
for guid, resource in re.findall(r'\{([A-F0-9]{16})\}([^"\n]+)', text):
|
||||
if guid in resources:
|
||||
path, target = resources[guid]
|
||||
if path != resource or target not in visible:
|
||||
raise ValueError(f'resource outside project dependency closure: {name}')
|
||||
elif vanilla.get(resource) != guid:
|
||||
elif vanilla.get(resource) != guid and not resource_allowed(external,visible,guid,resource):
|
||||
raise ValueError(f'unresolved or nonvanilla resource: {name}')
|
||||
return projects
|
||||
|
||||
@@ -185,7 +188,8 @@ def build(root=ROOT, mode='development'):
|
||||
raise ValueError('Not release-qualified after collection:\n - ' + '\n - '.join(blockers))
|
||||
if inventory(root) != before:
|
||||
raise ValueError('addon tree changed during collection')
|
||||
payload['BUNDLE-README.md'] = README.encode('utf-8')
|
||||
payload['BUNDLE-README.md'] = (README + '\nThe AI/full scenario requires the separately installed authorized addons listed in EXTERNAL-ADDONS.json. Their assets are not bundled. Core remains vanilla-only.\n').encode('utf-8')
|
||||
payload['EXTERNAL-ADDONS.json'] = json.dumps(external_policy(root),indent=2,sort_keys=True).encode('utf-8')
|
||||
manifest = {'schema_version': 1, 'kind': 'DEVELOPMENT SOURCE BUNDLE',
|
||||
'native_build': False, 'release_package': False, 'projects': projects,
|
||||
'files': {name: hashlib.sha256(data).hexdigest() for name, data in sorted(payload.items())}}
|
||||
|
||||
Reference in New Issue
Block a user