Validate independent packed addons and complete physical mortar salvos

This commit is contained in:
2026-09-19 11:42:13 +02:00
parent 7c53b4bb81
commit dfd7cc49e2
532 changed files with 63902 additions and 96 deletions
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Archive a stopped packed-only Core or Chat run without attaching loose-source identity."""
import argparse
import hashlib
import json
import math
from pathlib import Path
import re
import sys
import uuid
import headless_game
from headless_game import PACKED_FILES, PACKED_MANIFEST, command_for, packed_evidence, packed_inventory
from native_provenance import encoded, strict_json
from native_report import analyze
ROOT = Path(__file__).resolve().parents[1]
ADDON_ORDER = ['core', 'ai', 'intel', 'map', 'chat']
RESULTS = {'packed-core-started', 'packed-evidence-incomplete', 'packed-input-changed',
'cleanup-failed', 'unexpected-multiple-sessions', 'process-exited',
'interrupted', 'runner-failed', 'game-failed', 'timeout'}
def require(condition, message):
if not condition:
raise ValueError(message)
def read(path):
require(path == path.resolve() and path.is_file() and not path.is_symlink(), 'Missing, noncanonical or symlinked evidence: '+str(path))
return path.read_bytes()
def digest(raw):
return hashlib.sha256(raw).hexdigest()
def timestamp(value):
return type(value) in (int, float) and math.isfinite(value) and value > 0
def schema(metadata):
fields = {'schema_version', 'kind', 'launch_id', 'captured_unix', 'candidate_directory', 'runtime_directory', 'profile', 'command', 'artifacts', 'build_qualification'}
require(isinstance(metadata, dict), 'Invalid packed launch metadata object')
version = metadata.get('schema_version')
require(type(version) is int, 'Invalid packed schema version')
if version == 1:
require(set(metadata) == fields and metadata.get('kind') == 'packed-core-runtime-input', 'Invalid packed Core metadata fields/kind')
return 'core'
require(version == 2 and set(metadata) == fields | {'project', 'addon_order'}
and metadata.get('kind') == 'packed-chat-runtime-input' and metadata.get('project') == 'chat'
and metadata.get('addon_order') == ADDON_ORDER, 'Invalid packed Chat metadata fields/kind/dependency order')
return 'chat'
def payload_inventory(folder, project):
# Preserve the original Core schema and exact-three-file validation. Chat
# uses the runner's exact-five-addon closure validator, not an arbitrary tree.
if project == 'core':
return packed_inventory(folder)
return headless_game.packed_payload_inventory(folder, project)
def inspect(session):
"""Read and verify first; no writes and no engine/process operation."""
session = Path(session)
require(session.is_absolute() and session == session.resolve() and session.is_dir(), 'Use the canonical stopped packed session directory')
raw = read(session/PACKED_MANIFEST)
metadata = strict_json(raw)
project = schema(metadata)
expected_result = 'packed-'+project+'-started'
require(metadata['build_qualification'] == 'unqualified candidate; packaging verdict is unchanged', 'Packed metadata cannot assert build qualification')
require(isinstance(metadata['launch_id'], str), 'Invalid packed launch ID')
try:
uuid.UUID(metadata['launch_id'])
except ValueError as error:
raise ValueError('Invalid packed launch UUID') from error
runtime = session/'input/addons/core' if project == 'core' else session/'input/addons'
profile = session/'profile'
require(metadata['runtime_directory'] == str(runtime) and metadata['profile'] == str(profile), 'Packed runtime/profile paths do not belong to this session')
candidate = metadata['candidate_directory']
require(isinstance(candidate, str) and Path(candidate).is_absolute() and '..' not in Path(candidate).parts, 'Invalid recorded candidate path')
artifacts = metadata['artifacts']
expected_files = PACKED_FILES if project == 'core' else {addon+'/'+name for addon in ADDON_ORDER for name in PACKED_FILES}
require(isinstance(artifacts, dict) and set(artifacts) == expected_files, 'Packed provenance must hash the exact complete runtime artifact set')
for name, info in artifacts.items():
require(isinstance(info, dict) and set(info) == {'bytes', 'sha256'} and type(info['bytes']) is int and info['bytes'] > 0
and isinstance(info['sha256'], str) and re.fullmatch('[0-9a-f]{64}', info['sha256']), 'Invalid packed artifact hash/size: '+name)
require(payload_inventory(runtime, project) == artifacts, 'Runtime package bytes differ from launch manifest; refusing replacement payload')
runner_raw = read(session/'packed-runner.json')
runner = strict_json(runner_raw)
require(isinstance(runner, dict), 'Invalid stopped runner report')
require(runner.get('packed_only') is True and runner.get('project') == project and runner.get('dedicated_server') is False and runner.get('control_only') is False, 'Runner is not the recorded packed-only ordinary project')
require(not any(key in runner for key in ('source_provenance', 'launch_source_sha256', 'launch_source_manifest', 'launch_source_metadata', 'source_unchanged_after_run', 'end_source_sha256')), 'Packed run cannot carry loose-source qualification')
require(runner.get('build_qualified') is False and runner.get('release_qualified') is False, 'Packed consumption cannot qualify a build or release')
require(runner.get('packed_input_provenance') == PACKED_MANIFEST and runner.get('packed_input_provenance_sha256') == digest(raw) and runner.get('packed_launch_id') == metadata['launch_id'], 'Runner packed manifest/launch binding differs')
require(runner.get('cleanup_confirmed') is True, 'Archive only after owned cleanup is confirmed')
require('packed_input_error' in runner and (runner['packed_input_error'] is None or isinstance(runner['packed_input_error'], str)), 'Missing/malformed packed input diagnostic')
require(type(runner.get('packed_inputs_unchanged_after_run')) is bool and type(runner.get('packed_consumption_verified')) is bool, 'Missing packed input/consumption verdicts')
require(type(runner.get('failure_observed_during_polling')) is bool, 'Missing polling failure latch')
allowed_results = (RESULTS - {'packed-core-started'}) | {expected_result}
require(isinstance(runner.get('result'), str) and runner['result'] in allowed_results, 'Unknown packed runner result')
require(timestamp(metadata['captured_unix']) and timestamp(runner.get('started_unix')) and timestamp(runner.get('stopped_unix'))
and metadata['captured_unix'] <= runner['started_unix'] <= runner['stopped_unix'], 'Invalid snapshot/start/stop chronology')
command = metadata['command']
require(isinstance(command, list) and len(command) > 16 and all(isinstance(arg, str) for arg in command), 'Invalid recorded native command')
executable = runner.get('executable')
require(isinstance(executable, str) and Path(executable).is_absolute(), 'Invalid recorded executable')
exe = Path(executable)
require(exe.parts[-4:] == ('steamapps', 'common', 'Arma Reforger', 'ArmaReforgerSteam.exe'), 'Recorded executable is not installed ordinary Arma Reforger')
steam = exe.parents[3]
packed_context = {'input': runtime, 'project': project}
expected = command_for(Path('/unused-source'), steam, Path('/unused-prefix'), profile, project, packed_context)
require(command == expected, 'Recorded command differs from exact packed-only ordinary launch; unexpected flags/search roots')
native = runner.get('session')
require(isinstance(native, str), 'No sealed native log session was produced')
native = Path(native)
require(native.parent == profile/'logs' and re.fullmatch(r'logs_[A-Za-z0-9_-]+', native.name) and native == native.resolve(), 'Native log session escapes the recorded private profile')
require(read(native/PACKED_MANIFEST) == raw and read(native/'runner.json') == runner_raw, 'Native and root packed manifest/runner copies disagree')
startup_raw = read(session/'packed-startup-report.json')
require(read(native/'game-startup-report.json') == startup_raw, 'Native and root raw startup reports disagree')
require(isinstance(strict_json(startup_raw), dict), 'Invalid raw startup report')
console = read(native/'console.log')
require(runner.get('console_sha256') == digest(console), 'Raw console differs from stopped runner seal')
files = {PACKED_MANIFEST: raw, 'runner.json.txt': runner_raw, 'raw-startup-report.json': startup_raw, 'console.log.txt': console}
auxiliary = {}
for name in ('script.log', 'error.log', 'crash.log', 'headless-launch.log', 'rereintroduced-hq.log'):
if (native/name).exists() or (native/name).is_symlink():
data = read(native/name)
files[name+'.txt'] = data
auxiliary[name] = analyze(data.decode(errors='replace'), name)
for name in sorted(expected_files):
data = read(runtime/name)
require(len(data) == artifacts[name]['bytes'] and digest(data) == artifacts[name]['sha256'], 'Packed artifact changed during archive read: '+name)
files['package/'+name] = data
# Recompute from the console, not the saved ready flag; auxiliary errors also
# remain failures. Warnings in error.log are not mislabeled as native errors.
evidence = packed_evidence(console.decode(errors='replace'), packed_context, steam, profile)
auxiliary_failed = any(report['engine_errors'] or report['script_or_runtime_failures'] or report['missing_end_screen'] for report in auxiliary.values())
settled_claim = (runner['result'] == expected_result and runner['stopped_unix'] - runner['started_unix'] >= 15)
passed = (evidence['ready'] is True and not auxiliary_failed and settled_claim
and runner['packed_inputs_unchanged_after_run'] is True and runner.get('packed_input_error') is None
and runner['packed_consumption_verified'] is True and runner['failure_observed_during_polling'] is False and runner.get('runner_error') is None)
report = {**evidence, 'packed_consumption_verified': passed, 'archived_packed_input_verified': True,
'runner_result': runner['result'], 'runner_failure_latched': runner['failure_observed_during_polling'],
'packed_inputs_unchanged_after_run': runner['packed_inputs_unchanged_after_run'],
'auxiliary_native_reports': auxiliary, 'auxiliary_native_failure': auxiliary_failed,
'runner_settle_claim_consistent': settled_claim,
'settle_evidence': 'Native readiness recomputed; 15-second continuous settling is the sealed runner verdict, not an independently recorded polling timeline.',
'build_qualified': False, 'release_qualified': False,
'qualification': 'packed-only ordinary startup consumption only; original package build verdict and current source acceptance remain separate'}
report['failed'] = not passed
report['ready'] = passed
report['verifier_sha256'] = {name: digest(read(Path(__file__).resolve().parent/name)) for name in ('archive_packed.py', 'headless_game.py', 'native_report.py')}
files['report.json'] = encoded(report)
archive_kind = {'schema_version': metadata['schema_version'], 'kind': 'packed-'+project+'-consumption', 'packed_launch_id': metadata['launch_id'],
'build_qualified': False, 'release_qualified': False}
if project == 'chat':
archive_kind.update(project=project, addon_order=ADDON_ORDER)
files['archive-kind.json'] = encoded(archive_kind)
return files, report
def archive(session, name, root=ROOT):
require(isinstance(name, str) and re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}', name) is not None, 'Archive name must be one safe ASCII component')
root = Path(root)
evidence = root/'docs/evidence'
require(root == root.resolve() and evidence == evidence.resolve(), 'Evidence destination must not redirect through symlinks')
destination = evidence/name
require(not destination.exists() and not destination.is_symlink(), 'Evidence already exists; refusing overwrite')
files, report = inspect(session)
# No mutation until every provenance binding and artifact byte has passed.
destination.mkdir(parents=True, exist_ok=False)
for relative, data in files.items():
target = destination/relative
target.parent.mkdir(parents=True, exist_ok=True)
with target.open('xb') as output:
output.write(data)
hashes = {name: digest(raw) for name, raw in sorted(files.items())}
with (destination/'archive-sha256.json').open('xb') as output:
output.write(encoded(hashes))
metadata = strict_json(files[PACKED_MANIFEST])
require(payload_inventory(destination/'package', schema(metadata)) == metadata['artifacts'], 'Archived packed payload verification failed')
return destination, 0 if report['packed_consumption_verified'] else 1
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('session', type=Path, help='Stopped /tmp/erri-packed-core-* or erri-packed-chat-* session root, not its logs subdirectory')
parser.add_argument('name', help='New docs/evidence directory name; never overwritten')
args = parser.parse_args(argv)
try:
destination, status = archive(args.session, args.name)
except (OSError, ValueError) as error:
parser.error(str(error))
print(f'Archived {destination}; packed consumption exit={status} (nonzero retains failed evidence)')
return status
if __name__ == '__main__':
sys.exit(main())
+1 -1
View File
@@ -59,7 +59,7 @@ def check():
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_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_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']:
for name in ['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()
+271 -15
View File
@@ -4,6 +4,7 @@ No Workbench plugin, scripted smoke fixture, Steam profile, or existing process
"""
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
@@ -29,6 +30,227 @@ def refuse(message):
raise ValueError(message)
PACKED_FILES = {'addon.gproj', 'data.pak', 'resourceDatabase.rdb'}
PACKED_GUID = '2528466DE220D91C'
PACKED_MANIFEST = 'packed-input-provenance.json'
PACKED_BASE = '58D0FB3206B6F859'
PACKED_PROJECTS = {
'core': ('ERR_Core', PACKED_GUID, ()),
'ai': ('ERR_Ai', '5192138CB9D5B911', ('core',)),
'intel': ('ERR_Intel', 'D31039C7C53E348A', ('core',)),
'map': ('ERR_Map', '9AAEE9041D79809E', ('core',)),
'chat': ('ERR_Chat', 'FA804F411BDDF2C6', ('core', 'ai', 'intel', 'map')),
}
def packed_inventory(folder, project='core'):
"""Exact runtime payload, including files deliberately excluded by source manifests."""
if project not in PACKED_PROJECTS:
refuse('Unknown packed addon project')
folder = Path(folder)
if folder.is_symlink() or not folder.is_dir() or folder != folder.resolve():
refuse('Packed candidate must be a canonical real directory')
if {p.name for p in folder.iterdir()} != PACKED_FILES:
refuse('Packed addon requires exactly addon.gproj, data.pak and resourceDatabase.rdb')
result = {}
for name in sorted(PACKED_FILES):
path = folder/name
if path.is_symlink() or not path.is_file() or path.stat().st_size <= 0:
refuse('Packed input must be a nonempty regular file: '+name)
digest = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024*1024), b''):
digest.update(chunk)
result[name] = {'bytes': path.stat().st_size, 'sha256': digest.hexdigest()}
name, guid, dependencies = PACKED_PROJECTS[project]
text = (folder/'addon.gproj').read_text()
match = re.fullmatch(r'\s*GameProject\s*\{\s*ID\s+"'+name+r'"\s+GUID\s+"'+guid+r'"\s+TITLE\s+"[^"\r\n]+"\s+Dependencies\s*\{((?:\s*"[0-9A-F]{16}"\s*)+)\}\s*\}\s*', text)
actual = re.findall(r'"([0-9A-F]{16})"', match[1]) if match else []
expected = {PACKED_BASE, *(PACKED_PROJECTS[key][1] for key in dependencies)}
if not match or set(actual) != expected or len(actual) != len(expected):
refuse('Packed project identity or exact declared dependencies do not match '+project)
return result
def packed_payload_inventory(folder, project='core'):
"""Core keeps its v1 three-file map; Chat binds all fifteen closure artifacts."""
if project == 'core':
return packed_inventory(folder)
if project != 'chat':
refuse('Packed runtime supports only Core or the complete Chat closure')
folder = Path(folder)
if folder.is_symlink() or not folder.is_dir() or folder != folder.resolve():
refuse('Packed closure must be a canonical real directory')
if {p.name for p in folder.iterdir()} != set(PACKED_PROJECTS):
refuse('Packed Chat requires exactly core, ai, intel, map and chat directories')
return {project+'/'+name: record for project in PACKED_PROJECTS
for name, record in packed_inventory(folder/project, project).items()}
def packed_directories(packed):
if packed.get('project', 'core') == 'core':
return {'core': packed['input']}
if packed.get('project') != 'chat':
refuse('Unknown packed runtime project')
return {project: packed['input']/project for project in PACKED_PROJECTS}
def packed_request(a):
candidate, session = getattr(a, 'packed_core', None), getattr(a, 'packed_session', None)
addons = getattr(a, 'packed_addons', None)
if candidate is None and addons is None and session is None:
return None
if candidate is not None and addons is not None:
refuse('--packed-core and --packed-addons are mutually exclusive')
project = 'chat' if addons is not None else 'core'
candidate = addons if addons is not None else candidate
if candidate is None or session is None or a.project != project:
refuse('Packed input and --packed-session require their matching --project '+project)
if a.profile != Path('/tmp/erri-game-profile'):
refuse('Packed mode owns --packed-session/profile; do not supply --profile')
if a.settle != 15:
refuse('Packed mode requires the canonical 15-second readiness settling window')
session = Path(session)
if (not session.is_absolute() or session.parent != Path('/tmp') or session != session.resolve()
or not re.fullmatch('erri-packed-'+project+r'-[A-Za-z0-9_-]+', session.name)
or session.exists() or session.is_symlink()):
refuse('Packed session must be a new canonical /tmp/erri-packed-'+project+'-<name> directory')
candidate = Path(candidate)
manifest = packed_payload_inventory(candidate, project)
target = session/'input/addons' if project == 'chat' else session/'input/addons/core'
return {'candidate': candidate, 'session': session, 'input': target, 'manifest': manifest, 'project': project}
def prepare_packed(packed):
"""Called only while holding the ordinary runner's existing prefix lock."""
packed['session'].mkdir(exist_ok=False)
target = packed['input']
target.mkdir(parents=True, exist_ok=False)
project = packed.get('project', 'core')
if packed_payload_inventory(packed['candidate'], project) != packed['manifest']:
refuse('Packed candidate changed before private copy')
for name in sorted(packed['manifest']):
# Exclusive creation; never hardlink the candidate or redirect the copy.
(target/name).parent.mkdir(parents=True, exist_ok=True)
with (packed['candidate']/name).open('rb') as source, (target/name).open('xb') as output:
shutil.copyfileobj(source, output)
if packed_payload_inventory(target, project) != packed['manifest'] or packed_payload_inventory(packed['candidate'], project) != packed['manifest']:
refuse('Private packed copy or candidate changed during copy')
def packed_snapshot(packed, command, profile):
project = packed.get('project', 'core')
manifest = packed_payload_inventory(packed['input'], project)
if manifest != packed['manifest'] or packed_payload_inventory(packed['candidate'], project) != manifest:
refuse('Packed input changed before launch')
value = {'schema_version': 1, 'kind': 'packed-core-runtime-input', 'launch_id': str(uuid.uuid4()),
'captured_unix': time.time(), 'candidate_directory': str(packed['candidate']),
'runtime_directory': str(packed['input']), 'profile': str(profile),
'command': list(command), 'artifacts': manifest,
'build_qualification': 'unqualified candidate; packaging verdict is unchanged'}
if project == 'chat':
value.update(schema_version=2, kind='packed-chat-runtime-input', project='chat', addon_order=list(PACKED_PROJECTS))
raw = (json.dumps(value, indent=2)+'\n').encode()
(packed['session']/PACKED_MANIFEST).write_bytes(raw)
return raw
def packed_seal(packed, raw, session):
"""Fail closed on changed/deleted inputs, retaining a useful failure record."""
metadata = json.loads(raw)
unchanged, error = False, None
try:
project = packed.get('project', 'core')
unchanged = (packed_payload_inventory(packed['input'], project) == metadata['artifacts']
and packed_payload_inventory(packed['candidate'], project) == metadata['artifacts'])
except (OSError, ValueError) as caught:
error = str(caught)
console = session/'console.log' if session else None
return {'packed_only': True, 'packed_input_provenance': PACKED_MANIFEST,
'packed_input_provenance_sha256': hashlib.sha256(raw).hexdigest(),
'packed_launch_id': metadata['launch_id'], 'stopped_unix': time.time(),
'packed_inputs_unchanged_after_run': unchanged, 'packed_input_error': error,
'console_sha256': hashlib.sha256(console.read_bytes()).hexdigest() if console and console.is_file() else None,
'build_qualified': False, 'release_qualified': False}
def native_path(value, game_root):
if value.startswith('$'):
return value # Unknown engine aliases must not normalize into allowed paths.
value = value.replace('\\', '/')
if value[:3].lower() == 'z:/':
value = value[2:]
if not value.startswith('/'):
value = str(game_root/value)
return os.path.normpath(value)
def packed_evidence(content, packed, steam, profile):
project = packed.get('project', 'core')
report = game_evidence(content, project)
directories = packed_directories(packed)
game_root = steam/'steamapps/common/Arma Reforger'
expected = {'5614BBCCBB55ED1C': str(game_root/'addons/core/core.gproj'),
'58D0FB3206B6F859': str(game_root/'addons/data/ArmaReforger.gproj')}
expected.update({PACKED_PROJECTS[key][1]: str(folder/'addon.gproj') for key, folder in directories.items()})
blocks = re.findall(r"Loaded addons:\r?\n((?:[^\n]*ENGINE[^\n]*gproj:[^\n]*\n)+)", content)
complete = False
violations = []
for block in blocks:
entries = re.findall(r"gproj: '([^']+)' guid: '([0-9A-Fa-f]{16})'", block)
if len(entries) != len(block.splitlines()):
violations.append('Malformed loaded addon entry')
seen = set()
for path, guid in entries:
guid = guid.upper()
if guid in seen or guid not in expected or native_path(path, game_root) != expected.get(guid):
violations.append('Unexpected, duplicate or redirected loaded addon: '+path+' '+guid)
seen.add(guid)
complete = seen == set(expected)
mounts = re.findall(r"FileSystem: Adding package '([^']+)' \(pak count: (\d+)\) to filesystem under name ([^\s]+)", content)
expected_mounts = {'core': str(game_root/'addons/core'), 'ArmaReforger': str(game_root/'addons/data')}
expected_databases = {PACKED_PROJECTS[key][0]: str(folder/'resourceDatabase.rdb') for key, folder in directories.items()}
expected_mounts.update({PACKED_PROJECTS[key][0]: str(folder) for key, folder in directories.items()})
mounted = set()
for path, count, name in mounts:
if name not in expected_mounts or native_path(path, game_root) != expected_mounts.get(name) or int(count) < 1 or (name in expected_databases and int(count) != 1):
violations.append('Unexpected packed mount: '+path+' '+name)
else:
mounted.add(name)
for path, name in re.findall(r"FileSystem: Adding relative directory '([^']+)' to filesystem under name ([^\s]+)", content):
if name.startswith('ERR_'):
violations.append('Loose successor directory mount: '+path)
addon_root = packed['input'] if project == 'chat' else packed['input'].parent
allowed_dirs = {str(addon_root), str(game_root/'addons'), str(profile/'addons')}
dirs = re.findall(r"ENGINE\s*:\s*dir: '([^']+)'", content)
for path in dirs:
# Native $profile: maps to <CLI profile>/profile, so this exact observed
# engine alias denotes <CLI profile>/addons. No general alias/traversal
# expansion is accepted here or in loaded project/package paths.
resolved = str(profile/'addons') if path == '$profile:../addons/' else native_path(path, game_root)
if resolved not in allowed_dirs:
violations.append('Unexpected addon search directory: '+path)
database_paths = re.findall(r'ResourceDB: loading cache \(id=\d+ name=(ERR_[^\s]+) path=([^\r\n]+)\)', content)
databases = set()
for name, path in database_paths:
if name not in expected_databases or native_path(path, game_root) != expected_databases.get(name):
violations.append('Unexpected or redirected successor resource database: '+name+' '+path)
else:
databases.add(name)
database_ok = databases == set(expected_databases)
modules = re.findall(r'Module: (GameLib|Game);[^\n]*defines:\s*"([^"]*)"', content)
normal_modules = {name for name, flags in modules} == {'GameLib', 'Game'} and all(not {'WORKBENCH', 'ENF_WB'}.intersection(flags.split(',')) for _, flags in modules)
if any({'WORKBENCH', 'ENF_WB'}.intersection(flags.split(',')) for _, flags in modules):
violations.append('Workbench module defines in ordinary packed run')
path_proof = complete and mounted == set(expected_mounts) and database_ok and bool(dirs)
failed = report['failed'] or bool(violations)
return {**report, 'packed_only': True, 'packed_loaded_paths_verified': path_proof,
'packed_input_violations': violations, 'packed_normal_modules': normal_modules,
'failed': failed, 'ready': report['ready'] and path_proof and normal_modules and not failed,
'build_qualified': False, 'release_qualified': False,
'scope': 'packed-only '+project+' ordinary local startup; explicit noSplash; no packaging, visible UI, escape, multiplayer or release qualification'}
def assert_no_game(proc_root=Path('/proc')):
"""Read-only and fail closed; also recognizes Wine's truncated comm name."""
for proc in proc_root.iterdir():
@@ -123,14 +345,19 @@ def validate_paths(root, steam, compat, profile):
refuse(f'Unexpected profile symlink: {path}')
def command_for(root, steam, compat, profile, project):
def command_for(root, steam, compat, profile, project, packed=None):
if project not in {'core', 'chat', CONTROL}:
refuse('Unsupported ordinary game project/control')
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720',
'-w', '1280', '-h', '720', '-r', '30', '--',
str(steam/'steamapps/common/Proton - Experimental/proton'), 'run',
str(steam/'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe')]
if project != CONTROL:
if packed is not None:
if project != packed.get('project', 'core') or project not in {'core', 'chat'}:
refuse('Packed input project does not match requested ordinary scenario')
addon_root = packed['input'] if project == 'chat' else packed['input'].parent
command += ['-addonsDir', 'Z:'+str(addon_root), '-addons', PACKED_PROJECTS[project][1]]
elif project != CONTROL:
project_text = (root/'addons'/project/'addon.gproj').read_text()
guid = re.search(r'\bGUID "([A-F0-9]{16})"', project_text)[1]
command += ['-addonsDir', 'Z:'+str(root/'addons'), '-addons', guid]
@@ -264,7 +491,9 @@ class StartupMonitor:
def run(a):
steam, compat, profile = a.steam.resolve(), a.compat.resolve(), a.profile.resolve()
packed = packed_request(a)
steam, compat = a.steam.resolve(), a.compat.resolve()
profile = packed['session']/'profile' if packed else a.profile.resolve()
if not 60 <= a.timeout <= 1800 or not 5 <= a.settle <= 120:
refuse('Use timeout 60..1800 seconds and settle 5..120 seconds')
if not 0 <= a.failure_grace <= 30:
@@ -284,11 +513,11 @@ def run(a):
except BlockingIOError:
refuse('Another test runner owns this disposable prefix')
assert_prefix_idle(compat)
return run_locked(a, steam, compat, profile)
return run_locked(a, steam, compat, profile, packed)
def run_locked(a, steam, compat, profile):
command = command_for(ROOT, steam, compat, profile, a.project)
def run_locked(a, steam, compat, profile, packed=None):
command = command_for(ROOT, steam, compat, profile, a.project, packed)
separator = command.index("--")
proton, executable = Path(command[separator+1]), Path(command[separator+3])
if not proton.is_file() or not executable.is_file():
@@ -297,6 +526,8 @@ def run_locked(a, steam, compat, profile):
refuse('gamescope and pactl are required')
# Refuse launcher/profile reuse; repeat immediately before any launch setup.
assert_no_game()
if packed:
prepare_packed(packed)
profile.mkdir(parents=True, exist_ok=True)
marker = profile/PROFILE_MARKER
if not marker.exists():
@@ -321,7 +552,8 @@ def run_locked(a, steam, compat, profile):
sink_module = None
child = None
session = None
captured = snapshot(ROOT, command)
captured = None if packed else snapshot(ROOT, command)
packed_raw = packed_snapshot(packed, command, profile) if packed else None
started = time.time()
monitor = StartupMonitor(time.monotonic(), a.timeout, a.settle, a.failure_grace)
result = 'timeout'
@@ -356,10 +588,13 @@ def run_locked(a, steam, compat, profile):
if candidates:
if session is None:
session = candidates[0]
persist(session, captured)
if packed:
(session/PACKED_MANIFEST).write_bytes(packed_raw)
else:
persist(session, captured)
console = session/'console.log'
content = console.read_text(errors='replace') if console.is_file() else ''
evidence = game_evidence(content, a.project)
evidence = packed_evidence(content, packed, steam, profile) if packed else game_evidence(content, a.project)
outcome = monitor.observe(evidence, time.monotonic(), child.poll() is not None)
if outcome is not None:
result = outcome; break
@@ -389,16 +624,28 @@ def run_locked(a, steam, compat, profile):
subprocess.run(['pactl', 'unload-module', sink_module], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
except (OSError, subprocess.SubprocessError):
pass # Never replace the user's audio defaults to compensate.
sealed = seal_run(ROOT, session, captured) if cleanup_ok and session and (session/'console.log').is_file() else {}
final = game_evidence((session/'console.log').read_text(errors='replace'), a.project) if session and (session/'console.log').is_file() else {}
if packed:
sealed = packed_seal(packed, packed_raw, session)
else:
sealed = seal_run(ROOT, session, captured) if cleanup_ok and session and (session/'console.log').is_file() else {}
content = (session/'console.log').read_text(errors='replace') if session and (session/'console.log').is_file() else ''
if packed:
final = packed_evidence(content, packed, steam, profile)
else:
final = game_evidence(content, a.project) if session and (session/'console.log').is_file() else {}
final = monitor.annotate(final)
if final.get('failed'):
result = 'game-failed'
if sealed and not sealed['source_unchanged_after_run']:
if packed and not sealed['packed_inputs_unchanged_after_run']:
result = 'packed-input-changed'
if not packed and sealed and not sealed['source_unchanged_after_run']:
result = 'source-changed'
if a.project == CONTROL and result == 'game-started':
result = 'vanilla-control-started'
summary = {**sealed, **bindings(captured), 'started_unix': started, 'result': result,
if packed and result == 'game-started':
result = 'packed-'+packed.get('project', 'core')+'-started' if final.get('ready') is True else 'packed-evidence-incomplete'
source_bindings = {} if packed else bindings(captured)
summary = {**sealed, **source_bindings, 'started_unix': started, 'result': result,
'session': str(session) if session else None, 'project': a.project,
'cleanup_confirmed': cleanup_ok, 'runner_error': runner_error,
'failure_grace_seconds': a.failure_grace,
@@ -407,14 +654,19 @@ def run_locked(a, steam, compat, profile):
'executable': str(executable), 'dedicated_server': False,
'display': 'headless Gamescope', 'audio': '-noSound plus own silent sink',
'control_only': a.project == CONTROL,
'source_manifest_scope': 'successor addon snapshot retained for provenance bookkeeping only; none loaded by vanilla control' if a.project == CONTROL else 'successor addon sources',
'source_manifest_scope': 'none: packed input provenance is separate; loose source manifests cannot qualify this run' if packed else 'successor addon snapshot retained for provenance bookkeeping only; none loaded by vanilla control' if a.project == CONTROL else 'successor addon sources',
'scope': final.get('scope', 'ordinary game compile/startup; no scripted fixture, full escape or multiplayer acceptance')}
if packed:
summary['packed_consumption_verified'] = result == 'packed-'+packed.get('project', 'core')+'-started' and cleanup_ok and final.get('ready') is True and sealed['packed_inputs_unchanged_after_run']
final['packed_consumption_verified'] = summary['packed_consumption_verified']
(packed['session']/'packed-runner.json').write_text(json.dumps(summary, indent=2)+'\n')
(packed['session']/'packed-startup-report.json').write_text(json.dumps(final, indent=2)+'\n')
if session:
(session/'runner.json').write_text(json.dumps(summary, indent=2)+'\n')
(session/'game-startup-report.json').write_text(json.dumps(final, indent=2)+'\n')
shutil.copyfile(launch_log, session/'headless-launch.log')
print(json.dumps(summary, indent=2), flush=True)
return 0 if result in {'game-started', 'vanilla-control-started'} else 1
return 0 if result in {'game-started', 'vanilla-control-started', 'packed-core-started', 'packed-chat-started'} else 1
def main():
@@ -423,6 +675,10 @@ def main():
parser.add_argument('--compat', type=Path, required=True)
parser.add_argument('--profile', type=Path, default=Path('/tmp/erri-game-profile'))
parser.add_argument('--project', choices=['core', 'chat', CONTROL], default='core')
packed_options = parser.add_mutually_exclusive_group()
packed_options.add_argument('--packed-core', type=Path, help='Unqualified Core candidate directory containing exactly addon.gproj, data.pak and resourceDatabase.rdb')
packed_options.add_argument('--packed-addons', type=Path, help='Unqualified complete core/ai/intel/map/chat packed directories; requires --project chat')
parser.add_argument('--packed-session', type=Path, help='New /tmp/erri-packed-core-<name> or erri-packed-chat-<name> session; owns a fresh input copy and profile')
parser.add_argument('--inspect-processes', action='store_true', help='Read-only process preflight report; never launch, lock, write a profile or configure audio')
parser.add_argument('--timeout', type=int, default=180)
parser.add_argument('--settle', type=int, default=15)
+9 -3
View File
@@ -11,8 +11,10 @@ p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
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=['mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', '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=['mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', '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('--station-teardown-diagnostics', action='store_true', help='Opt-in bounded native deletion diagnostics; preserves all validation errors')
p.add_argument('--flight-autohover', action='store_true', help='Opt-in owned native helicopter autohover experiment; requires Core powered-flight and actor-local action inputs')
p.add_argument('--flight-action-inputs', action='store_true', help='Experimental exact-AI ActionManager inputs; Core powered-flight fixture only')
p.add_argument('--flight-input-diagnostics', action='store_true', help='Opt-in read-only powered-flight input/application and native rotor signal observations')
p.add_argument('--rounds', type=int, default=2)
p.add_argument('--timeout', type=int, default=360)
@@ -23,8 +25,10 @@ if not 1 <= a.rounds <= 10 or not 60 <= a.timeout <= 1800:
p.error('Use 1..10 rounds and a 60..1800 second timeout')
if a.smoke == 'mortar-courier' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 500):
p.error('mortar-courier requires --project chat --rounds 1 --timeout at least 500')
if a.smoke in {'mortar-expiry', 'mortar-transfer', 'mortar-fire'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400):
p.error('mortar-expiry/mortar-transfer/mortar-fire requires --project chat --rounds 1 --timeout at least 400')
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.flight_autohover and (a.project != 'core' or a.smoke != 'powered-flight' or not a.flight_action_inputs): p.error('flight-autohover requires Core powered-flight with flight-action-inputs')
if a.flight_action_inputs and (a.project != 'core' or a.smoke != 'powered-flight'): p.error('flight-action-inputs requires the Core powered-flight fixture')
if a.smoke == 'powered-flight' and (a.project != 'core' or a.rounds != 1 or a.timeout < 420): p.error('powered-flight requires Core, one round, and at least 420 seconds')
if a.smoke == 'station-teardown' and (a.project != 'core' or a.rounds != 1): p.error('station-teardown requires --project core --rounds 1')
if a.smoke == 'environment-restart':
@@ -63,6 +67,8 @@ for proc in Path('/proc').iterdir():
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-w', '1280', '-h', '720', '-r', '30', '--', str(proton), 'run', str(wb/'ArmaReforgerWorkbenchSteamDiag.exe'), '-gproj', 'Z:'+str(ROOT/'addons'/a.project/'addon.gproj'), '-addonsDir', 'Z:'+str(ROOT/'addons'), '-profile', profile_arg, '-logconsole', '-wbModule=WorldEditor', '-load', 'Worlds/ERR_Everon.ent', '-plugin=ERR_ValidateWorldPlugin', '-errPlay', '1', '-errSmoke', '1', '-errSmokeOutcome', a.smoke, '-noThrow', '-disableCrashReporter']
if a.station_teardown_diagnostics: command += ['-errStationTeardownDiag', '1']
if a.flight_input_diagnostics: command += ['-errFlightInputDiag', '1']
if a.flight_action_inputs: command += ['-errFlightActionInputs', '1']
if a.flight_autohover: command += ['-errFlightAutohover', '1']
if a.project == 'core':
if a.smoke not in {'station-teardown', 'prison-breach', 'powered-flight', 'local-admission', 'victory', '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'}: p.error('Unsupported Core-only fixture')
command[command.index('-plugin=ERR_ValidateWorldPlugin')] = '-plugin=ERR_ValidateBaseWorldPlugin'
+406
View File
@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""Build unqualified native PAK candidates in a fresh private /tmp session.
Never publish. Native package contents and consumption remain separate checks.
"""
import argparse
import fcntl
import hashlib
import io
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
import uuid
import zipfile
from source_bundle import build, inventory, PROJECTS
from headless_game import assert_no_game, assert_prefix_idle, cleanup
from native_report import analyze
ROOT = Path(__file__).resolve().parents[1]
ORDER = tuple(PROJECTS)
# Record the direct Proton/Wine status independently of Gamescope's exit status.
# This helper lives only in the owned session and never interprets shell text.
CHILD_RECORDER = """import json, os, subprocess, sys
from pathlib import Path
command = sys.argv[3:]
environment = os.environ.copy()
environment.update(json.loads(Path(sys.argv[2]).read_text()))
code = subprocess.call(command, env=environment)
with Path(sys.argv[1]).open('x') as output:
json.dump({'command': command, 'exit_code': code}, output)
sys.exit(code)
"""
def digest(path):
h = hashlib.sha256()
with path.open('rb') as stream:
for block in iter(lambda: stream.read(1024 * 1024), b''):
h.update(block)
return h.hexdigest()
def save(path, value):
path.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n')
def project_order(project):
if project not in PROJECTS:
raise ValueError('Unknown successor project')
needed = {project, *PROJECTS[project][2]}
return [name for name in ORDER if name in needed]
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')
if session != session.resolve() or session.parent != Path('/tmp') or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,119}', session.name):
raise ValueError('--session must be a fresh direct child of /tmp')
if any(session.is_relative_to(p) or p.is_relative_to(session) for p in (root, steam, compat)):
raise ValueError('Session must be separate from source, Steam and prefix')
if (not compat.is_relative_to(Path('/tmp')) or compat.is_relative_to(root)
or compat.is_relative_to(steam) or (compat/'pfx').is_symlink()
or (compat/'pfx/system.reg').is_symlink() or not (compat/'pfx/system.reg').is_file()):
raise ValueError('--compat must be a disposable cloned prefix under /tmp, outside source and Steam, with a real pfx')
if (compat/'.erri-workbench.lock').is_symlink():
raise ValueError('Disposable prefix lock must not be a symlink')
def copy_payload(root, session):
# Same reviewed authored schema, resource identities and dependency checks as
# source_bundle; no caches, original content, logs or vanilla bytes copied.
archive, manifest = build(root)
authored = {name: sha for name, sha in manifest['files'].items() if name.startswith('addons/')}
session.mkdir(mode=0o700) # atomic exclusive ownership; never exist_ok
source = session/'source'
with zipfile.ZipFile(io.BytesIO(archive)) as bundle:
for name, sha in authored.items():
data = bundle.read(name)
if hashlib.sha256(data).hexdigest() != sha:
raise ValueError('Validated bundle hash mismatch')
destination = source/name
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open('xb') as output:
output.write(data)
save(session/'authored-before.json', authored)
return authored
def copied_authored_snapshot(source, authored):
result = {}
for name in authored:
path = source/name
result[name] = digest(path) if path.is_file() and not path.is_symlink() and path.resolve().is_relative_to(source.resolve()) else None
# The shared inventory rejects unreviewed additions and ignores only its
# explicit generated-cache schema. Added authored scripts/resources matter.
discovered = inventory(source)
for name, sha in discovered.items():
if name not in result:
result[name] = sha
return result
def copied_authored_unchanged(source, authored):
return copied_authored_snapshot(source, authored) == authored
def command_for(steam, session, project, silent_workbench=False):
if project not in PROJECTS:
raise ValueError('Unknown successor project')
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720',
'-w', '1280', '-h', '720', '-r', '30', '--',
str(steam/'steamapps/common/Proton - Experimental/proton'), 'run',
str(steam/'steamapps/common/Arma Reforger Tools/Workbench/ArmaReforgerWorkbenchSteamDiag.exe'),
'-gproj', 'Z:'+str(session/'source/addons'/project/'addon.gproj'),
'-addonsDir', 'Z:'+str(session/'source/addons'),
'-profile', 'Z:'+str(session/'profiles'/project),
'-logconsole', '-noBackend', '-noSound', '-disableCrashReporter',
'-wbModule=ResourceManager', '-packAddon',
'-packAddonDir', 'Z:'+str(session/'packages'/project),
'-packAddonCacheDir', 'Z:'+str(session/'cache'/project)]
if silent_workbench:
command.append('-wbSilent')
return command
def child_status(path, expected):
if path.is_symlink() or not path.is_file():
return None
try:
record = json.loads(path.read_text())
except (OSError, ValueError):
return None
if not isinstance(record, dict) or record.get('command') != expected or type(record.get('exit_code')) is not int:
return None
return record['exit_code']
def log_evidence(paths):
records, failures = {}, []
for path in sorted(paths):
if path.is_symlink() or not path.is_file():
raise ValueError('Unexpected nonregular native log')
content = path.read_text(errors='replace')
report = analyze(content, path)
errors = report['engine_errors'] + report['script_or_runtime_failures'] + report['missing_end_screen']
errors += [line.strip() for line in content.splitlines()
if re.search(r"can't compile|packing (?:has )?failed|packAddon.*failed|fatal error", line, re.I)]
if path.name in {'error.log', 'crash.log'} and content.strip():
errors.append('Nonempty native '+path.name)
records[str(path)] = {'sha256': digest(path), 'bytes': path.stat().st_size, 'errors': errors}
failures.extend(errors)
return records, failures
def package_inventory(folder):
records = {}
if folder.is_symlink():
raise ValueError('Native package root is a symlink')
if not folder.is_dir():
return records
for path in folder.rglob('*'):
if path.is_symlink() or not (path.is_file() or path.is_dir()):
raise ValueError('Nonregular native package output')
if path.is_file():
records[path.relative_to(folder).as_posix()] = {'bytes': path.stat().st_size, 'sha256': digest(path)}
return records
def candidate_ok(exit_code, result, cleanup_ok, unchanged, logs, errors, packages):
return (exit_code == 0 and result in {'process-exited', 'native-child-exited'} and cleanup_ok and unchanged
and bool(logs) and not errors
and any(name.lower().endswith('.pak') and info['bytes'] > 0 for name, info in packages.items())
and all(info['bytes'] > 0 for name, info in packages.items() if name.lower().endswith('.pak')))
def quiet_streams(application_id, sink, compat):
streams = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'sink-inputs'], text=True, timeout=5))
for stream in streams:
props = stream.get('properties', {})
owned = props.get('application.id') == application_id
pid = str(props.get('application.process.id', ''))
if not owned and pid.isdigit():
try:
owned = ('WINEPREFIX='+str(compat/'pfx')).encode() in (Path('/proc')/pid/'environ').read_bytes().split(b'\0')
except (FileNotFoundError, PermissionError, ProcessLookupError):
pass
if owned:
for args in [['move-sink-input', str(stream['index']), sink], ['set-sink-input-mute', str(stream['index']), '1']]:
subprocess.run(['pactl', *args], check=True, timeout=5, stdout=subprocess.DEVNULL)
def run_project(steam, compat, session, project, timeout, authored, silent_workbench=False):
command = command_for(steam, session, project, silent_workbench)
proton = Path(command[command.index('--')+1])
executable = Path(command[command.index('--')+3])
profile = session/'profiles'/project
(profile/'addons').mkdir(parents=True)
(profile/'addons/ArmaReforger').symlink_to(steam/'steamapps/common/Arma Reforger/addons/data', target_is_directory=True)
for name in ('packages', 'cache', 'evidence'):
(session/name/project).mkdir(parents=True)
evidence = session/'evidence'/project
separator = command.index('--')
native_command = command[separator+1:]
recorder = evidence/'native-child.py'
recorder.write_text(CHILD_RECORDER)
status_path = evidence/'native-exit.json'
environment_path = evidence/'native-prefix-environment.json'
prefix_environment = {'STEAM_COMPAT_DATA_PATH': str(compat), 'STEAM_COMPAT_CLIENT_INSTALL_PATH': str(steam),
'WINEPREFIX': str(compat/'pfx')}
save(environment_path, prefix_environment)
command = command[:separator+1] + [sys.executable, str(recorder), str(status_path), str(environment_path), *native_command]
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),
'silent_workbench_experiment': silent_workbench})
run_id = uuid.uuid4().hex
sink, application_id = 'erri_pack_'+run_id, 'org.escapistsrereintroduced.pack.'+run_id
env = os.environ.copy()
env.update(prefix_environment)
env.update(PULSE_SINK=sink, PULSE_PROP='application.id='+application_id)
# Prefix-idle checks must not mistake a lingering display wrapper for Wine.
# Only the recorder's direct Proton child receives these three overrides.
display_env = {key: value for key, value in env.items() if key not in prefix_environment}
child = None
module = None
exit_code = None
result, cleanup_ok, runner_error = 'timeout', True, None
launch_log = evidence/'launch.log'
deadline = time.monotonic()+timeout
launcher_exited_at = None
try:
assert_no_game()
assert_prefix_idle(compat)
module = subprocess.check_output(['pactl', 'load-module', 'module-null-sink', 'sink_name='+sink], text=True, timeout=5).strip()
for args in [['set-sink-mute', sink, '1'], ['set-sink-volume', sink, '0%']]:
subprocess.run(['pactl', *args], check=True, timeout=5)
assert_prefix_idle(compat)
with launch_log.open('x') as output:
child = subprocess.Popen(command, cwd=executable.parent, env=display_env, stdout=output,
stderr=subprocess.STDOUT, start_new_session=True)
while time.monotonic() < deadline:
quiet_streams(application_id, sink, compat)
if time.monotonic() >= deadline:
break # audio/process observation must not extend the deadline
exit_code = child.poll()
recorded_native_exit = child_status(status_path, native_command)
if exit_code is not None or recorded_native_exit is not None:
# Gamescope exit alone cannot prove Workbench ended.
# Never qualify output completed by forcibly killing a child.
if launcher_exited_at is None:
launcher_exited_at = time.monotonic()
try:
assert_prefix_idle(compat)
if time.monotonic() >= deadline:
break
result = 'process-exited' if exit_code is not None else 'native-child-exited'
break
except ValueError:
if time.monotonic()-launcher_exited_at >= 10:
result = 'native-or-launcher-exited-prefix-active'
break
time.sleep(min(.5, max(0, deadline-time.monotonic())))
except KeyboardInterrupt:
result = 'interrupted'
except (OSError, ValueError, subprocess.SubprocessError) as error:
result, runner_error = 'runner-failed', str(error)
finally:
if child is not None:
try:
# Reuse guarded ordinary runner cleanup, only after owning the
# shared prefix lock and proving that prefix was initially idle.
cleanup(child, proton, env)
assert_prefix_idle(compat)
except (OSError, ValueError, subprocess.SubprocessError) as error:
cleanup_ok, runner_error = False, str(error)
if module is not None:
try:
subprocess.run(['pactl', 'unload-module', module], check=True, timeout=5, stdout=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError) as error:
cleanup_ok, runner_error = False, str(error)
paths = list((profile/'logs').rglob('*.log'))
if launch_log.is_file():
paths.append(launch_log)
try:
if any(not path.resolve().is_relative_to(session.resolve()) for path in paths):
raise ValueError('Native log escaped owned session')
logs, errors = log_evidence(paths)
packages = package_inventory(session/'packages'/project)
after = copied_authored_snapshot(session/'source', authored)
save(evidence/'authored-after.json', after)
unchanged = after == authored
except (OSError, ValueError) as error:
logs, packages, unchanged = {}, {}, False
errors = ['Output/evidence validation failed: '+str(error)]
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)
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',
'silent_workbench_experiment': silent_workbench,
'native_status_sha256': digest(status_path) if status_path.is_file() and not status_path.is_symlink() else None,
'cleanup_confirmed': cleanup_ok,
'copied_authored_unchanged': unchanged, 'runner_error': runner_error,
'logs': logs, 'errors': errors, 'packages': packages,
'package_contents_verified': False, 'native_consumption_verified': False,
'tool_completion_semantics_verified': False, 'release_qualified': False,
'qualification': 'PENDING: inspect native contents and load packed-only addon closure; no release claim'}
save(evidence/'report.json', report)
return report
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)
if not 30 <= a.timeout <= 1800:
raise ValueError('--timeout must be 30..1800 seconds per addon')
command = command_for(steam, session, a.project)
separator = command.index('--')
for path in (Path(command[separator+1]), Path(command[separator+3]), steam/'steamapps/common/Arma Reforger/addons/data/ArmaReforger.gproj'):
if not path.is_file():
raise ValueError('Installed runtime dependency missing: '+str(path))
if not shutil.which('gamescope') or not shutil.which('pactl'):
raise ValueError('gamescope and pactl required')
assert_no_game()
descriptor = os.open(compat/'.erri-workbench.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
with os.fdopen(descriptor, 'a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise ValueError('Another native runner owns the disposable prefix')
assert_prefix_idle(compat)
authored = copy_payload(root, 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, getattr(a, 'silent_workbench', False))
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'):
stopped_reason = 'owned cleanup unconfirmed'
elif report.get('runner_error'):
stopped_reason = 'native runner error'
elif not report.get('copied_authored_unchanged'):
stopped_reason = 'copied authored source changed or unverifiable'
elif report.get('completion_observed') not in {'process-exited', 'native-child-exited'}:
stopped_reason = 'native completion not safely observed'
elif not report['candidate_created'] and not continue_on_failure:
stopped_reason = 'candidate failed; continuation not requested'
if stopped_reason:
break
if len(reports) < len(order):
try:
assert_prefix_idle(compat)
except (OSError, ValueError, subprocess.SubprocessError) as error:
stopped_reason = 'prefix not verifiably idle before next addon: '+str(error)
break
try:
original_after = inventory(root)
save(session/'repository-after.json', original_after)
original_unchanged = original_after == authored
except (OSError, ValueError):
original_unchanged = False
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,
'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)
save(session/'package-report.json', summary)
print(json.dumps(summary, indent=2))
return 0 if summary['all_candidates_created'] else 1
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--project', choices=PROJECTS, default='core', help='Build selected addon and its successor dependencies in order')
parser.add_argument('--session', type=Path, required=True, help='New direct child of /tmp; never overwritten')
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('--silent-workbench', action='store_true', help='Opt-in documented -wbSilent experiment; packing compatibility still requires native evidence')
parser.add_argument('--continue-on-failure', action='store_true', help='Diagnostic only: continue completed, safely cleaned addon attempts after validation failures; any failure keeps overall exit nonzero and unqualified')
args = parser.parse_args(argv)
try:
return run(args)
except (ValueError, OSError, subprocess.SubprocessError) as error:
print(json.dumps({'result': 'refused-or-failed', 'error': str(error), 'release_qualified': False}))
return 1
if __name__ == '__main__':
raise SystemExit(main())
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Read-only strict inventory of the observed Enfusion FORM/PAC1 PAK format.
Names, declared sizes and verified content hashes only; never extract payloads.
"""
import argparse
from dataclasses import dataclass
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import struct
import unicodedata
import zlib
BLOCK = 1024 * 1024
class PakError(ValueError):
pass
@dataclass(frozen=True)
class Limits:
archive_bytes: int = 4 * 1024**3
tree_bytes: int = 32 * 1024**2
entries: int = 250000 # files plus directories
depth: int = 64
entry_bytes: int = 1024**3
total_bytes: int = 8 * 1024**3
expansion_ratio: int = 1024
def validate(self):
if any(type(value) is not int or value <= 0 for value in vars(self).values()):
raise PakError('All limits must be positive integers')
if self.depth > 128:
raise PakError('Maximum supported tree depth is 128')
def exact(stream, count):
data = stream.read(count)
if len(data) != count:
raise PakError('Truncated archive')
return data
def component(raw):
try:
name = raw.decode('utf-8')
except UnicodeDecodeError as error:
raise PakError('Non-UTF-8 entry name') from error
if (not name or name in {'.', '..'} or any(char in name for char in '/\\:')
or any(ord(char) < 32 or ord(char) == 127 for char in name)
or name.endswith((' ', '.')) or unicodedata.normalize('NFC', name) != name
or re.fullmatch(r'(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?', name, re.I)):
raise PakError('Unsafe or noncanonical entry name')
return name
def parse_tree(tree, data_start, data_end, limits):
if len(tree) < 6 or tree[:2] != b'\0\0':
raise PakError('Unsupported FILE root header')
offset = 6
seen = set()
entries = []
count_seen = 0
total = 0
def take(size):
nonlocal offset
if offset + size > len(tree):
raise PakError('Truncated FILE entry')
result = tree[offset:offset+size]
offset += size
return result
def walk(parent, count, depth):
nonlocal count_seen, total
if depth > limits.depth or count > limits.entries - count_seen:
raise PakError('Directory depth/count limit exceeded')
for _ in range(count):
count_seen += 1
if count_seen > limits.entries:
raise PakError('Entry count limit exceeded')
kind, length = take(2)
name = component(take(length))
path = parent + name
key = path.casefold()
if key in seen:
raise PakError('Duplicate or case-colliding file/directory path: '+path)
seen.add(key)
if kind == 0:
children = struct.unpack('<I', take(4))[0]
walk(path+'/', children, depth+1)
elif kind == 1:
raw = take(24)
start, stored, size = struct.unpack_from('<III', raw)
flags = (raw[18], raw[19])
if raw[12:18] != b'\0'*6 or flags not in {(0, 0), (1, 6)}:
raise PakError('Unsupported FILE descriptor flags/reserved bytes: '+path)
if start < data_start or start > data_end or stored > data_end-start:
raise PakError('Payload outside DATA bounds: '+path)
if flags == (0, 0) and stored != size:
raise PakError('Raw payload stored/expanded size mismatch: '+path)
if flags == (1, 6) and stored == 0:
raise PakError('Empty compressed stream: '+path)
if size > limits.entry_bytes or size > max(1, stored)*limits.expansion_ratio:
raise PakError('Entry expansion/size limit exceeded: '+path)
total += size
if total > limits.total_bytes:
raise PakError('Aggregate expanded-data limit exceeded')
entries.append({'path': path, 'offset': start, 'stored_bytes': stored,
'content_bytes': size, 'compression': 'zlib' if flags[0] else 'raw',
'opaque_tail_hex': raw[20:24].hex()})
else:
raise PakError('Unsupported FILE node kind')
walk('', struct.unpack_from('<I', tree, 2)[0], 0)
if offset != len(tree):
raise PakError('Unparsed trailing FILE bytes')
# Observed archives have contiguous, non-overlapping, completely indexed DATA.
# Refuse sparse/unindexed/deduplicated variants instead of guessing semantics.
cursor = data_start
for entry in sorted(entries, key=lambda row: (row['offset'], row['stored_bytes'])):
if entry['offset'] != cursor:
raise PakError('Overlapping or unindexed DATA extent')
cursor += entry['stored_bytes']
if cursor != data_end:
raise PakError('Unindexed trailing DATA bytes')
return entries, total, count_seen
def content_hash(stream, entry):
stream.seek(entry['offset'])
remaining = entry['stored_bytes']
expected = entry['content_bytes']
produced = 0
hasher = hashlib.sha256()
decoder = zlib.decompressobj() if entry['compression'] == 'zlib' else None
def accept(data):
nonlocal produced
produced += len(data)
if produced > expected:
raise PakError('Expanded data exceeds declared size: '+entry['path'])
hasher.update(data)
try:
while remaining:
data = exact(stream, min(BLOCK, remaining))
remaining -= len(data)
if decoder is None:
accept(data)
continue
while data:
previous = data
output = decoder.decompress(data, min(BLOCK, expected-produced+1))
accept(output)
if decoder.unused_data or (decoder.eof and remaining):
raise PakError('Trailing/concatenated compressed payload: '+entry['path'])
data = decoder.unconsumed_tail
if data == previous and not output:
raise PakError('Compressed decoder made no progress')
if decoder is not None:
while not decoder.eof:
output = decoder.decompress(b'', min(BLOCK, expected-produced+1))
if not output:
break
accept(output)
if not decoder.eof or decoder.unused_data or decoder.unconsumed_tail:
raise PakError('Incomplete/extra compressed payload: '+entry['path'])
except zlib.error as error:
raise PakError('Invalid zlib stream: '+entry['path']) from error
if produced != expected:
raise PakError('Expanded size mismatch: '+entry['path'])
return hasher.hexdigest()
def inventory(path, limits=Limits()):
limits.validate()
path = Path(path)
descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
with os.fdopen(descriptor, 'rb') as stream:
before = os.fstat(stream.fileno())
if not stat.S_ISREG(before.st_mode) or before.st_size > limits.archive_bytes:
raise PakError('Input must be a regular archive within its size limit')
header = exact(stream, 12)
if header[:4] != b'FORM' or header[8:] != b'PAC1' or struct.unpack_from('>I', header, 4)[0] != before.st_size-8:
raise PakError('Unsupported or inconsistent FORM/PAC1 header')
offset = 12
chunks = {}
for expected in (b'HEAD', b'DATA', b'FILE'):
stream.seek(offset)
tag, size = struct.unpack('>4sI', exact(stream, 8))
if tag != expected or size > before.st_size-offset-8:
raise PakError('Unsupported chunk order or out-of-bounds chunk')
chunks[tag] = (offset+8, size)
offset += 8+size
if offset != before.st_size:
raise PakError('Unknown trailing archive chunks/bytes')
start, size = chunks[b'HEAD']
if size != 28:
raise PakError('Unsupported HEAD size')
stream.seek(start)
head = exact(stream, size)
if head[:4] != b'\x03\x00\x01\x00' or head[24:] != b'\0'*4:
raise PakError('Unsupported HEAD version/flags')
start, size = chunks[b'FILE']
if size > limits.tree_bytes:
raise PakError('FILE metadata exceeds size limit')
stream.seek(start)
tree = exact(stream, size)
data_start, data_size = chunks[b'DATA']
entries, expanded, nodes = parse_tree(tree, data_start, data_start+data_size, limits)
for entry in entries:
entry['sha256'] = content_hash(stream, entry)
stream.seek(0)
archive_hash = hashlib.sha256()
for block in iter(lambda: stream.read(BLOCK), b''):
archive_hash.update(block)
after = os.fstat(stream.fileno())
if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
raise PakError('Archive changed during inspection')
return {'format': 'FORM/PAC1 observed HEAD 03000100', 'archive': str(path),
'archive_bytes': before.st_size, 'archive_sha256': archive_hash.hexdigest(),
'head_hex': head.hex(), 'entry_count': len(entries), 'tree_node_count': nodes,
'expanded_bytes': expanded, 'entries': sorted(entries, key=lambda row: row['path']),
'limits': vars(limits), 'structural_inventory_verified': True,
'platform_or_resource_semantics_verified': False, 'release_qualified': False,
'scope': 'Bounded structural/content-hash inspection only; no extraction, native load or gameplay proof'}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('archive', type=Path)
parser.add_argument('--output', type=Path, help='New JSON report only; never overwritten')
args = parser.parse_args(argv)
try:
if args.output and (args.output.exists() or args.output.is_symlink()):
raise PakError('Output already exists; refusing overwrite')
report = inventory(args.archive)
text = json.dumps(report, indent=2, sort_keys=True)+'\n'
if args.output:
with args.output.open('x') as output:
output.write(text)
else:
print(text, end='')
return 0
except (OSError, ValueError, struct.error) as error:
print(json.dumps({'structural_inventory_verified': False, 'error': str(error), 'release_qualified': False}))
return 1
if __name__ == '__main__':
raise SystemExit(main())