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
+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)