Integrate delivered HQ memory, finite physical convoy and RHS WCS dependencies

This commit is contained in:
2026-09-20 02:55:30 +02:00
parent e7b4141f69
commit a2aab17171
230 changed files with 37976 additions and 139 deletions
+1 -1
View File
@@ -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
View File
@@ -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()
+122
View File
@@ -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
+27 -4
View File
@@ -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')
+2 -1
View File
@@ -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
View File
@@ -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')
+9 -5
View File
@@ -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())}}