Advance standalone admission, finite artillery and native validation

This commit is contained in:
2026-09-19 09:02:31 +02:00
parent af5146a317
commit 40a474934c
532 changed files with 56456 additions and 199 deletions
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Archive an isolated native run and its verified launch-time addon snapshot; never overwrite evidence.
Run only after stopping the test and before editing/copying its source tree again.
"""
from pathlib import Path
import argparse,json,shutil,subprocess,sys
from native_provenance import archive_source, encoded, MANIFEST, METADATA, PROVENANCE
ROOT=Path(__file__).resolve().parents[1]
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('session',type=Path)
p.add_argument('name',help='New evidence directory name, e.g. native-2026-09-19-003231')
p.add_argument('--allow-legacy-archive-time-source', action='store_true', help='Explicit historical-only import for sessions without a pre-launch snapshot')
a=p.parse_args()
if Path(a.name).name!=a.name or a.name in {'.','..'}:p.error('Name must be one directory component')
source=a.session.resolve()
if not (source/'console.log').is_file():p.error('Session has no console.log')
try:
manifest_bytes, provenance, launched = archive_source(source, ROOT, a.allow_legacy_archive_time_source)
except (OSError, ValueError) as error:
p.error(str(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']:
if (source/name).is_file():shutil.copyfile(source/name,dest/(name+'.txt'))
for capture in sorted(source.glob('err-*.png')):
if capture.is_file():shutil.copyfile(capture,dest/capture.name)
(dest/'source-sha256.json').write_bytes(manifest_bytes)
(dest/PROVENANCE).write_bytes(encoded(provenance))
if launched:
for name in (MANIFEST, METADATA): shutil.copyfile(source/name, dest/name)
if (source/'game-startup-report.json').is_file():
# Recompute from the sealed console rather than trusting the saved summary.
# Ordinary startup has no scripted completion and cannot qualify as an escape.
from headless_game import game_evidence
runner=json.loads((source/'runner.json').read_text())
report=game_evidence((dest/'console.log.txt').read_text(errors='replace'),runner.get('project'))
report['log']=str((dest/'console.log.txt').relative_to(ROOT))
(dest/'report.json').write_bytes(encoded(report))
status=0 if report['ready'] and runner.get('result')=='game-started' and runner.get('cleanup_confirmed') is True else 1
else:
with (dest/'report.json').open('w') as report:
result=subprocess.run([sys.executable,str(ROOT/'tools/native_report.py'),str((dest/'console.log.txt').relative_to(ROOT))],cwd=ROOT,stdout=report)
status=result.returncode
print(f'Archived {dest.relative_to(ROOT)}; native report exit={status} (nonzero retains failures)')
sys.exit(status)
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Validate addon/resource isolation and execute policy regressions. Not an engine compiler."""
from pathlib import Path
import json,re,subprocess,sys
ROOT=Path(__file__).resolve().parents[1]
BASE='58D0FB3206B6F859'
def check():
ids={};graph={};resources={};paths={};owners={}
for p in sorted((ROOT/'addons').glob('*/addon.gproj')):
text=p.read_text();g=re.search(r'GUID "([A-F0-9]{16})"',text)[1]
assert g not in ids,f'Duplicate addon GUID: {p}'
ids[g]=p.parent.name;graph[g]=re.findall(r'"([A-F0-9]{16})"',text[text.index('Dependencies'):])
assert len(ids)==5,'Expected five addon projects'
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}'
if dep!=BASE:walk(dep,stack+[g])
for g in ids:walk(g,[])
for p in (ROOT/'addons').rglob('*.meta'):
m=re.search(r'Name "\{([A-F0-9]{16})\}([^"\n]+)"',p.read_text())
assert m,f'Malformed metadata: {p}'
g,path=m.groups();assert g not in resources,f'Duplicate resource GUID {g}'
addon=p.relative_to(ROOT/'addons').parts[0]
assert p.with_suffix('')==ROOT/'addons'/addon/path, f'Resource path mismatch: {p}'
assert p.with_suffix('').is_file(),f'Missing resource for {p}'
resources[g]=path;paths[path]=g
owners[g]=addon
def visible(g):
result={ids[g]}
for dep in graph[g]:
if dep!=BASE:result.update(visible(dep))
return result
visibility={ids[g]:visible(g) for g in ids}
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}'
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}'
if p.suffix in ['.et','.conf','.ent','.layout','.emat']:
assert p.with_name(p.name+'.meta').exists(),f'Missing metadata: {p}'
inventory=json.loads((ROOT/'docs/original-resource-inventory.json').read_text())
coverage=json.loads((ROOT/'docs/original-feature-coverage.json').read_text())
mapped=[];feature_ids=set()
for feature in coverage['features']:
assert feature['id'] not in feature_ids,'Duplicate feature responsibility'
feature_ids.add(feature['id'])
assert feature['status'] in {'pending','partial'},'Completion requires a separately reviewed acceptance update'
assert feature['remaining'],'Unfinished responsibilities must describe remaining work'
mapped.extend(feature['original_scripts'])
for path in feature['replacement_files']:
assert (ROOT/path).is_file(),f'Missing replacement reference: {path}'
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_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_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
subprocess.run([sys.executable,str(ROOT/'tests'/name)],check=True)
print('All repository checks passed. Native compilation and multiplayer tests are separate requirements.')
if __name__=='__main__':check()
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Compare declared equivalent frame/tick recordings; never grants acceptance."""
import argparse
import csv
import hashlib
import json
import math
from pathlib import Path
import statistics
COLUMNS = ['frame_index', 'elapsed_ms', 'frame_time_ms', 'players', 'ai', 'vehicles', 'entities', 'ai_simulation_ms']
POPULATION = ['players', 'ai', 'vehicles', 'entities']
def require(condition, message):
if not condition:
raise ValueError(message)
def keys(value, expected, label):
require(isinstance(value, dict) and set(value) == set(expected), f'{label}: expected fields {expected}')
def number(value, label, minimum=0):
require(type(value) in (float, int) and math.isfinite(value) and value >= minimum, f'{label}: invalid number')
return value
def text(value, label):
require(isinstance(value, str) and bool(value.strip()), f'{label}: required nonempty string')
def integer(value, label, minimum=0):
require(type(value) is int and value >= minimum, f'{label}: invalid integer')
def finite_json(value):
if isinstance(value, float):
require(math.isfinite(value), 'Non-finite JSON number')
elif isinstance(value, dict):
for item in value.values():
finite_json(item)
elif isinstance(value, list):
for item in value:
finite_json(item)
def percentile(values, fraction):
"""Nearest rank, not interpolation; slow-tail frame times use upper ranks."""
return sorted(values)[max(0, math.ceil(len(values) * fraction) - 1)]
def metrics(frames, ai_load):
fps = [1000 / value for value in frames]
result = {
'median_frame_ms': statistics.median(frames),
'p95_frame_ms': percentile(frames, .95),
'p99_frame_ms': percentile(frames, .99),
'median_fps': statistics.median(fps),
'p05_fps': percentile(fps, .05),
'p01_fps': percentile(fps, .01),
'throughput_fps': 1000 * len(frames) / sum(frames),
'median_ai_simulation_ms': statistics.median(ai_load),
'p95_ai_simulation_ms': percentile(ai_load, .95),
}
finite_json(result)
return result
def load_samples(path, run):
workload = run['workload']
warmup = workload['warmup_seconds'] * 1000
end = warmup + workload['duration_seconds'] * 1000
frames, ai_load = [], []
boundary_frames = []
previous = 0.0
total_frame_ms = 0.0
row_count = 0
with path.open(newline='', encoding='utf-8') as stream:
reader = csv.DictReader(stream)
require(reader.fieldnames == COLUMNS, f'{path}: expected exact CSV header {COLUMNS}')
for index, row in enumerate(reader, 1):
label = f'{path}: row {index + 1}'
require(set(row) == set(COLUMNS) and all(v is not None for v in row.values()), f'{label}: incomplete/extra columns')
try:
frame_index = int(row['frame_index'])
elapsed = float(row['elapsed_ms'])
frame = float(row['frame_time_ms'])
ai_ms = float(row['ai_simulation_ms'])
population = {key: int(row[key]) for key in POPULATION}
except (ValueError, TypeError) as error:
raise ValueError(f'{label}: invalid numeric sample') from error
require(frame_index == index, f'{label}: missing/duplicate/out-of-order frame index')
require(all(math.isfinite(v) for v in (elapsed, frame, ai_ms)) and frame >= .001 and elapsed > previous and ai_ms >= 0, f'{label}: invalid frame/time/load')
require(abs((elapsed - previous) - frame) <= .1, f'{label}: elapsed delta differs from frame time by over 0.1 ms')
total_frame_ms += frame
require(abs(total_frame_ms - elapsed) <= .1, f'{label}: cumulative elapsed time differs from recorded frames')
require(previous < end, f'{label}: samples continue beyond the declared capture window')
for key in POPULATION:
require(population[key] == workload[key], f'{label}: {key} workload changed')
if previous >= warmup and elapsed <= end:
frames.append(frame)
ai_load.append(ai_ms)
else:
overlap = max(0, min(elapsed, end) - max(previous, warmup))
if overlap > 0:
boundary_frames.append({'frame_index': frame_index,
'frame_time_ms': frame,
'measurement_overlap_ms': overlap})
previous = elapsed
row_count += 1
require(previous >= end, f'{path}: incomplete recording; warmup plus measurement duration not covered')
measured_ms = sum(frames)
coverage = measured_ms / (workload['duration_seconds'] * 1000)
require(coverage >= .99, f'{path}: only {coverage:.3%} of declared measurement duration has complete frames; boundary stalls/intervals exclude over 1%')
require(len(frames) >= 120, f'{path}: need at least 120 complete measurement frames/ticks')
return {'samples_total': row_count, 'samples_measured': len(frames),
'measured_frame_seconds': measured_ms / 1000,
'measurement_coverage_fraction': coverage,
'boundary_frames_excluded': boundary_frames,
'csv_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'metrics': metrics(frames, ai_load)}
def validate_run(run):
keys(run, ['variant', 'repeat_id', 'artifact_id', 'addon_ids', 'engine_version', 'hardware', 'workload', 'capture', 'sample_file'], 'run')
require(run['variant'] in ('baseline', 'successor'), 'run: variant must be baseline or successor')
for key in ['repeat_id', 'artifact_id', 'engine_version', 'sample_file']:
text(run[key], key)
require(isinstance(run['addon_ids'], list) and bool(run['addon_ids']), 'addon_ids: nonempty list required')
for addon in run['addon_ids']:
text(addon, 'addon id')
require(len(set(run['addon_ids'])) == len(run['addon_ids']), 'addon_ids: duplicates')
keys(run['hardware'], ['cpu', 'gpu', 'ram_gib', 'os', 'driver'], 'hardware')
for key in ['cpu', 'gpu', 'os', 'driver']:
text(run['hardware'][key], f'hardware.{key}')
number(run['hardware']['ram_gib'], 'hardware.ram_gib', 1)
workload = run['workload']
keys(workload, ['map', 'route', *POPULATION, 'settings', 'warmup_seconds', 'duration_seconds'], 'workload')
text(workload['map'], 'workload.map')
text(workload['route'], 'workload.route')
for key in POPULATION:
integer(workload[key], f'workload.{key}', 1 if key == 'players' else 0)
require(workload['entities'] >= workload['players'] + workload['ai'] + workload['vehicles'], 'workload.entities: smaller than declared actors/vehicles')
require(isinstance(workload['settings'], dict) and bool(workload['settings']), 'workload.settings: nonempty object required')
number(workload['warmup_seconds'], 'workload.warmup_seconds', 30)
number(workload['duration_seconds'], 'workload.duration_seconds', 60)
keys(run['capture'], ['kind', 'environment', 'tool', 'version'], 'capture')
require(run['capture']['kind'] in ('client_frame', 'server_tick'), 'capture.kind: expected client_frame or server_tick')
require(run['capture']['environment'] in ('client', 'dedicated_server', 'workbench_headless'), 'capture.environment: unsupported environment')
require(run['capture']['kind'] != 'client_frame' or run['capture']['environment'] != 'dedicated_server', 'dedicated server cannot supply client frames')
for key in ['tool', 'version']:
text(run['capture'][key], f'capture.{key}')
def reject_constant(value):
raise ValueError(f'Non-finite JSON value: {value}')
def unique_object(pairs):
result = {}
for key, value in pairs:
require(key not in result, f'Duplicate JSON field: {key}')
result[key] = value
return result
def compare(manifest_path):
manifest_path = Path(manifest_path)
raw = manifest_path.read_bytes()
data = json.loads(raw, parse_constant=reject_constant, object_pairs_hook=unique_object)
finite_json(data)
keys(data, ['schema_version', 'sample_origin', 'runs'], 'manifest')
require(type(data['schema_version']) is int and data['schema_version'] == 1, 'schema_version: expected 1')
require(data['sample_origin'] in ('synthetic', 'measured'), 'sample_origin: expected synthetic or measured')
require(isinstance(data['runs'], list) and len(data['runs']) >= 6, 'At least three paired baseline/successor repeats required')
paired, paths, reference = {}, set(), None
outputs = []
artifacts = {}
for run in data['runs']:
validate_run(run)
comparable = {key: run[key] for key in ['hardware', 'workload', 'capture', 'engine_version']}
if reference is None:
reference = comparable
require(comparable == reference, f"{run['repeat_id']}/{run['variant']}: mismatched hardware, workload, capture settings or engine version")
artifact = (run['artifact_id'], sorted(run['addon_ids']))
require(run['variant'] not in artifacts or artifacts[run['variant']] == artifact, 'Artifact/addon set changed between repeats of one variant')
artifacts[run['variant']] = artifact
pair = paired.setdefault(run['repeat_id'], {})
require(run['variant'] not in pair, f"Duplicate repeat/variant: {run['repeat_id']}/{run['variant']}")
path = (manifest_path.parent / run['sample_file']).resolve()
require(path not in paths, f'{path}: sample file reused for different runs')
paths.add(path)
result = {'variant': run['variant'], 'repeat_id': run['repeat_id'], 'artifact_id': run['artifact_id'], 'addon_ids': run['addon_ids'], 'sample_file': run['sample_file'], **load_samples(path, run)}
pair[run['variant']] = result
outputs.append(result)
require(len(paired) >= 3 and all(set(pair) == {'baseline', 'successor'} for pair in paired.values()), 'Every repeat needs exactly one baseline and one successor recording')
metric_names = outputs[0]['metrics']
aggregate = {variant: {key: statistics.median([item['metrics'][key] for item in outputs if item['variant'] == variant]) for key in metric_names} for variant in ['baseline', 'successor']}
deltas = []
for repeat_id, pair in sorted(paired.items()):
baseline, successor = pair['baseline']['metrics'], pair['successor']['metrics']
deltas.append({'repeat_id': repeat_id,
'median_frame_reduction_percent': 100 * (baseline['median_frame_ms'] - successor['median_frame_ms']) / baseline['median_frame_ms'],
'p99_frame_reduction_percent': 100 * (baseline['p99_frame_ms'] - successor['p99_frame_ms']) / baseline['p99_frame_ms'],
'throughput_fps_increase_percent': 100 * (successor['throughput_fps'] - baseline['throughput_fps']) / baseline['throughput_fps']})
return {'schema_version': 1, 'sample_origin': data['sample_origin'], 'acceptance': 'not_evaluated',
'scope': 'Offline arithmetic on caller-supplied recordings and metadata; no authenticity, multiplayer, statistical-significance or release claim. Synthetic samples are not performance evidence. Headless Workbench is not equivalent to ordinary clients.',
'manifest_sha256': hashlib.sha256(raw).hexdigest(), 'matched_conditions': reference,
'percentile_method': 'nearest_rank', 'aggregate_method': 'median of per-run metrics; repeats equally weighted',
'runs': sorted(outputs, key=lambda item: (item['repeat_id'], item['variant'])),
'aggregate': aggregate, 'paired_deltas': deltas,
'median_paired_deltas': {key: statistics.median([delta[key] for delta in deltas]) for key in deltas[0] if key != 'repeat_id'}}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('manifest', type=Path)
arguments = parser.parse_args()
try:
report = compare(arguments.manifest)
except (ValueError, OSError, csv.Error) as error:
parser.exit(2, f'Performance comparison rejected: {error}\n')
print(json.dumps(report, indent=2, allow_nan=False))
if __name__ == '__main__':
main()
@@ -0,0 +1,40 @@
// Session-local placement for Enfusion/Reforger test windows only.
var targetName = "HDMI-A-1";
var placing = false;
function isTestWindow(w) {
var app = String(w.resourceClass).toLowerCase();
return app === "steam_app_1874910" || app.indexOf("armareforger") >= 0 || String(w.caption).indexOf("Enfusion Workbench") >= 0;
}
function place(w) {
if (placing || !isTestWindow(w)) return;
placing = true;
var outputs = workspace.screens;
for (var i = 0; i < outputs.length; i++) {
if (outputs[i].name !== targetName) continue;
w.fullScreen = false;
w.setMaximize(false, false);
workspace.sendClientToScreen(w, outputs[i]);
var a = outputs[i].geometry;
var g = w.frameGeometry;
w.frameGeometry = {x: a.x + 20, y: a.y + 30,
width: Math.min(g.width, a.width - 40), height: Math.min(g.height, a.height - 60)};
print("ERRI_DISPLAY " + w.resourceClass + " -> " + w.output.name + " " + w.frameGeometry.x);
placing = false;
return;
}
w.minimized = true;
placing = false;
print("ERRI_DISPLAY target unavailable; test window minimized");
}
workspace.windowAdded.connect(function(w) {
place(w);
w.captionChanged.connect(function() { place(w); });
w.frameGeometryChanged.connect(function() { place(w); });
w.fullScreenChanged.connect(function() { place(w); });
});
var windows = workspace.windowList();
// Existing user editors are deliberately left untouched.
for (var i = 0; i < windows.length; i++) {
if (String(windows[i].caption).indexOf("Rereintroduced") >= 0) place(windows[i]);
}
print("ERRI_DISPLAY guard active for " + targetName);
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""Bounded ordinary-game startup check on a private hidden and muted display.
No Workbench plugin, scripted smoke fixture, Steam profile, or existing process is used.
"""
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import time
import uuid
from native_provenance import snapshot, persist, bindings, seal_run
from native_report import analyze
ROOT = Path(__file__).resolve().parents[1]
PROFILE_MARKER = '.erri-game-profile.json'
PROFILE_ID = {'kind': 'erri-ordinary-game-disposable-profile', 'version': 1}
GAME = re.compile(r'^armareforgersteam(?:diag)?(?:\.exe)?$', re.I)
def refuse(message):
raise ValueError(message)
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():
if not proc.name.isdigit():
continue
try:
args = (proc/'cmdline').read_bytes().split(b'\0')
comm = (proc/'comm').read_text().strip()
except (FileNotFoundError, ProcessLookupError):
continue # process exited during enumeration
except OSError as error:
refuse(f'Cannot exclude an existing game process {proc.name}: {error}')
names = [re.split(r'[\\/]', arg.decode(errors='replace'))[-1] for arg in args]
if any(GAME.fullmatch(name) for name in names) or comm.lower().startswith('armareforgerste'):
refuse(f'An Arma Reforger game process already exists (PID {proc.name}); leave it untouched')
def prefix_process_candidate(proc):
"""Classify readable process metadata before accessing protected environ.
Linux services such as systemd can be non-dumpable even to their UID. Their
environment is irrelevant. Windows/Wine/runtime candidates still fail closed.
"""
args = [value.decode(errors='replace') for value in (proc/'cmdline').read_bytes().split(b'\0') if value]
comm = (proc/'comm').read_text().strip()
try:
executable = os.readlink(proc/'exe')
except (FileNotFoundError, PermissionError):
executable = ''
values = args + [comm, executable]
for value in values:
name = re.split(r'[\\/]', value)[-1].lower()
if (name.endswith('.exe') or name.startswith(('wine', 'proton', 'gamescope'))
or name in {'reaper', 'pressure-vessel-wrap', 'pv-bwrap'}
or name.startswith('armareforger')):
return True
# systemd's PAM child overwrites both names, and its non-dumpable exe is
# inaccessible. This exact native helper is positively identified by both.
if comm == '(sd-pam)' and args == ['(sd-pam)']:
return False
# Observed host Apache workers are also non-dumpable; accept only their
# exact native foreground-service signature, after all runtime checks.
if comm == 'apache2' and args == ['apache2', '-DFOREGROUND']:
return False
# A readable native executable is conclusive; otherwise require an absolute
# Linux argv[0] corroborated by the kernel's (possibly truncated) comm name.
if executable.startswith('/'):
return False
if args and args[0].startswith('/'):
basename = Path(args[0]).name
if comm and (comm == basename or comm == basename[:15]):
return False
argv0 = args[0] if args else ''
refuse(f'Cannot classify possible Wine/runtime process {proc.name} (comm={comm!r}, argv0={argv0!r})')
def assert_prefix_idle(compat, proc_root=Path('/proc')):
target = ('WINEPREFIX='+str(compat/'pfx')).encode()
failures = []
for proc in proc_root.iterdir():
if not proc.name.isdigit() or int(proc.name) == os.getpid():
continue
try:
if proc.stat().st_uid != os.getuid():
continue
if not prefix_process_candidate(proc):
continue
environment = (proc/'environ').read_bytes().split(b'\0')
except (FileNotFoundError, ProcessLookupError):
continue
except (OSError, ValueError) as error:
failures.append(f'Cannot verify disposable prefix is idle (PID {proc.name}): {error}')
continue
if target in environment:
failures.append(f'Disposable prefix already has a process (PID {proc.name}); leave it untouched')
if failures:
refuse('\n'.join(failures))
def validate_paths(root, steam, compat, profile):
if compat.is_relative_to(steam) or not (compat/'pfx/system.reg').is_file():
refuse('--compat must be an existing disposable cloned prefix outside Steam')
if any(profile.is_relative_to(path) or path.is_relative_to(profile) for path in (root, steam, compat)):
refuse('--profile must be separate from source, Steam and disposable prefix trees')
if profile.exists():
marker = profile/PROFILE_MARKER
if marker.is_symlink() or not marker.is_file() or json.loads(marker.read_text()) != PROFILE_ID:
refuse('Existing profile is not marked as this runner\'s disposable game profile')
for path in profile.rglob('*'):
if path.is_symlink() and path != profile/'addons/ArmaReforger':
refuse(f'Unexpected profile symlink: {path}')
def command_for(root, steam, compat, profile, project):
project_text = (root/'addons'/project/'addon.gproj').read_text()
guid = re.search(r'\bGUID "([A-F0-9]{16})"', project_text)[1]
return ['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'),
'-addonsDir', 'Z:'+str(root/'addons'), '-addons', guid,
'-profile', 'Z:'+str(profile), '-errOrdinaryScenario', project,
'-noBackend', '-noSound', '-noSplash', '-noThrow', '-disableCrashReporter', '-logconsole']
def game_evidence(content, project=None):
report = analyze(content, 'console.log')
defines = re.findall(r'Module: Game;[^\n]*defines:\s*"([^"]*)"', content)
non_workbench = bool(defines) and all(not {'WORKBENCH', 'ENF_WB'}.intersection(value.split(',')) for value in defines)
headers = re.findall(r'ERR ORDINARY: header verified project=(core|chat) systems=MissionSystems', content)
header_verified = bool(headers) and (project is None or project in headers)
participants = re.findall(r'ERR ORDINARY: local participant verified project=(core|chat) phase=prison', content)
participant_verified = bool(participants) and (project is None or project in participants)
ready = header_verified and participant_verified and 'Rereintroduced [mission]: phase 0 -> 1' in content
unexpected_fixture = 'ERR SMOKE ' in content
failed = (bool(report['engine_errors'] or report['script_or_runtime_failures'])
or 'Rereintroduced: scenario preparation failed' in content
or 'Can\'t compile "Game"' in content or unexpected_fixture
or any({'WORKBENCH', 'ENF_WB'}.intersection(value.split(',')) for value in defines))
return {**report, 'local_participant_verified': participant_verified, 'scenario_header_verified': header_verified, 'verified_header_projects': headers, 'game_module_defines': defines, 'workbench_undefined': non_workbench,
'normal_mission_ready': ready, 'unexpected_smoke_fixture': unexpected_fixture,
'failed': failed, 'ready': non_workbench and ready and not failed,
'scope': 'ordinary local game startup only; no player input, full escape, multiplayer or FPS proof'}
def cleanup(child, proton, env):
# Caller owns the prefix lock and verified no pre-existing prefix processes.
try:
subprocess.run([str(proton.parent/'files/bin/wineserver'), '-k'], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
except (OSError, subprocess.TimeoutExpired):
pass
try:
os.killpg(child.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
child.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
finally:
try:
os.killpg(child.pid, signal.SIGKILL)
except ProcessLookupError:
pass
child.wait(timeout=10)
class StartupMonitor:
"""Latch failures while allowing a bounded diagnostic drain; no native I/O."""
def __init__(self, started, timeout, settle, failure_grace=0):
if not 0 <= failure_grace <= 30:
refuse('Use failure-grace 0..30 seconds')
self.started = started
self.deadline = started + timeout
self.settle = settle
self.failure_grace = failure_grace
self.first_failure_at = None
self.ready_at = None
def observe(self, evidence, now, process_exited=False):
if evidence.get('failed') and self.first_failure_at is None:
self.first_failure_at = now
if self.first_failure_at is not None:
# Repeated errors and subsequent readiness cannot renew this lease.
cutoff = min(self.deadline, self.first_failure_at + self.failure_grace)
if process_exited or now >= cutoff:
return 'game-failed'
return None
if now >= self.deadline:
return 'timeout'
if process_exited:
return 'process-exited'
if evidence.get('ready'):
if self.ready_at is None:
self.ready_at = now
if now - self.ready_at >= self.settle:
return 'game-started'
else:
self.ready_at = None
return None
def timeout_result(self):
return 'game-failed' if self.first_failure_at is not None else 'timeout'
def annotate(self, final):
# Keep raw final error counts intact, but never lose an earlier failure
# if the process truncated/replaced the log during the grace interval.
final['failure_observed_during_polling'] = self.first_failure_at is not None
if self.first_failure_at is not None:
final['failed'] = True
final['ready'] = False
return final
def run(a):
steam, compat, profile = a.steam.resolve(), a.compat.resolve(), 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:
refuse('Use failure-grace 0..30 seconds')
assert_no_game()
validate_paths(ROOT, steam, compat, profile)
# Use the SAME prefix lock as headless_workbench.py: never run them together.
with (compat/'.erri-workbench.lock').open('a') as prefix_lock:
try:
fcntl.flock(prefix_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
refuse('Another test runner owns this disposable prefix')
assert_prefix_idle(compat)
return run_locked(a, steam, compat, profile)
def run_locked(a, steam, compat, profile):
command = command_for(ROOT, steam, compat, profile, a.project)
separator = command.index("--")
proton, executable = Path(command[separator+1]), Path(command[separator+3])
if not proton.is_file() or not executable.is_file():
refuse('Installed ordinary ArmaReforgerSteam.exe and Proton Experimental are required')
if not shutil.which('gamescope') or not shutil.which('pactl'):
refuse('gamescope and pactl are required')
# Refuse launcher/profile reuse; repeat immediately before any launch setup.
assert_no_game()
profile.mkdir(parents=True, exist_ok=True)
marker = profile/PROFILE_MARKER
if not marker.exists():
marker.write_text(json.dumps(PROFILE_ID)+'\n')
addons = profile/'addons'
addons.mkdir(exist_ok=True)
vanilla = steam/'steamapps/common/Arma Reforger/addons/data'
link = addons/'ArmaReforger'
if not link.exists() and not link.is_symlink():
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')):
refuse('Private profile addons must contain only the installed vanilla link')
run_id = uuid.uuid4().hex
silent_sink = 'erri_game_'+run_id
application_id = 'org.escapistsrereintroduced.game.'+run_id
env = os.environ.copy()
env.update(STEAM_COMPAT_DATA_PATH=str(compat), STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam),
WINEPREFIX=str(compat/'pfx'), PULSE_SINK=silent_sink,
PULSE_PROP='application.id='+application_id)
sink_module = None
child = None
session = None
captured = snapshot(ROOT, command)
started = time.time()
monitor = StartupMonitor(time.monotonic(), a.timeout, a.settle, a.failure_grace)
result = 'timeout'
runner_error = None
cleanup_ok = True
log_root = profile/'logs'
previous = set(log_root.glob('logs_*'))
launch_log = profile/('game-launch-'+run_id+'.log')
try:
sink_module = subprocess.check_output(['pactl', 'load-module', 'module-null-sink',
'sink_name='+silent_sink], text=True).strip()
subprocess.run(['pactl', 'set-sink-mute', silent_sink, '1'], check=True)
subprocess.run(['pactl', 'set-sink-volume', silent_sink, '0%'], check=True)
assert_no_game()
with launch_log.open('x') as output:
child = subprocess.Popen(command, cwd=executable.parent, env=env, stdout=output,
stderr=subprocess.STDOUT, start_new_session=True)
while time.monotonic() < monitor.deadline:
# -noSound prevents game audio; this catches a Proton startup stream
# or a saved explicit Wine device. Only our fresh application ID.
streams = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'sink-inputs'], text=True))
for stream in streams:
if stream.get('properties', {}).get('application.id') != application_id:
continue
index = str(stream['index'])
subprocess.run(['pactl', 'move-sink-input', index, silent_sink], check=True)
subprocess.run(['pactl', 'set-sink-input-mute', index, '1'], check=True)
candidates = [x for x in log_root.glob('logs_*') if x not in previous]
if len(candidates) > 1:
result = 'unexpected-multiple-sessions'; break
evidence = {}
if candidates:
if session is None:
session = candidates[0]
persist(session, captured)
console = session/'console.log'
content = console.read_text(errors='replace') if console.is_file() else ''
evidence = game_evidence(content, a.project)
outcome = monitor.observe(evidence, time.monotonic(), child.poll() is not None)
if outcome is not None:
result = outcome; break
# Grace never extends the original absolute run deadline.
cutoff = monitor.deadline
if monitor.first_failure_at is not None:
cutoff = min(cutoff, monitor.first_failure_at + a.failure_grace)
time.sleep(max(0, min(1, cutoff - time.monotonic())))
else:
result = monitor.timeout_result()
except KeyboardInterrupt:
result = 'interrupted'
except (OSError, ValueError, subprocess.SubprocessError) as error:
result = 'runner-failed'
runner_error = str(error)
finally:
if child is not None:
try:
cleanup(child, proton, env)
assert_prefix_idle(compat)
except (OSError, ValueError, subprocess.SubprocessError) as error:
cleanup_ok = False
result = 'cleanup-failed'
runner_error = str(error)
if sink_module is not None:
try:
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 {}
final = monitor.annotate(final)
if final.get('failed'):
result = 'game-failed'
if sealed and not sealed['source_unchanged_after_run']:
result = 'source-changed'
summary = {**sealed, **bindings(captured), '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,
'failure_observed_during_polling': monitor.first_failure_at is not None,
'first_failure_elapsed_seconds': (monitor.first_failure_at - monitor.started) if monitor.first_failure_at is not None else None,
'executable': str(executable), 'dedicated_server': False,
'display': 'headless Gamescope', 'audio': '-noSound plus own silent sink',
'scope': 'ordinary game compile/startup; no scripted fixture, full escape or multiplayer acceptance'}
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 == 'game-started' else 1
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
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'], default='core')
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)
parser.add_argument('--failure-grace', type=int, default=0, help='Keep a failed run alive for diagnostic logs only, at most 0..30 seconds within the original timeout')
args = parser.parse_args()
try:
if args.inspect_processes:
report = {'read_only': True, 'game_process_check': 'clear', 'prefix_process_check': 'clear'}
for key, check in [('game_process_check', assert_no_game), ('prefix_process_check', lambda: assert_prefix_idle(args.compat.resolve()))]:
try:
check()
except (OSError, ValueError) as error:
report[key] = str(error)
print(json.dumps(report, indent=2))
return 0 if report['game_process_check'] == report['prefix_process_check'] == 'clear' else 1
return run(args)
except (OSError, ValueError, subprocess.SubprocessError) as error:
parser.error(str(error))
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Run bounded native Workbench smoke tests inside a windowless Gamescope display.
Requires Linux, gamescope, Proton, and a disposable COPY of a working Tools compat prefix.
No desktop rules or existing Wine processes are changed. This is not a dedicated server.
"""
import argparse, fcntl, json, os, shutil, signal, subprocess, time
from pathlib import Path
from native_provenance import snapshot, persist, bindings, seal_run
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('--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-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('--rounds', type=int, default=2)
p.add_argument('--timeout', type=int, default=360)
p.add_argument('--project', choices=['core', 'chat'], default='chat', help='Core-only base or full companion integration')
a = p.parse_args()
steam, compat, profile = a.steam.resolve(), a.compat.resolve(), a.profile.resolve()
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-fire' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400):
p.error('mortar-fire requires --project chat --rounds 1 --timeout at least 400')
if a.smoke == 'environment-restart':
if a.project != 'core' or a.rounds != 2: p.error('environment-restart requires --project core --rounds 2')
fixture_names = ['erri-environment-restart.json', 'erri-environment-restart.json.bak', 'erri-environment-restart.json.tmp', 'erri-environment-restart-round.json']
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')
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')
if profile.is_relative_to(ROOT) or profile.is_relative_to(steam):
p.error('--profile must be outside source and Steam directories')
if not shutil.which('gamescope') or not shutil.which('pactl'): p.error('gamescope and pactl are required')
wb = steam/'steamapps/common/Arma Reforger Tools/Workbench'
proton = steam/'steamapps/common/Proton - Experimental/proton'
vanilla = steam/'steamapps/common/Arma Reforger/addons/data'
if not proton.is_file() or not (wb/'ArmaReforgerWorkbenchSteamDiag.exe').is_file():
p.error('Stable Reforger Tools and Proton Experimental must already be installed')
profile.mkdir(parents=True, exist_ok=True)
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')
profile_arg = 'Z:'+str(profile)
# Refuse concurrency instead of terminating anybody else's editor.
for proc in Path('/proc').iterdir():
if not proc.name.isdigit(): continue
try:
args = (proc/'cmdline').read_bytes().split(b'\0')
if profile_arg.encode() in args and any(x.endswith(b'ArmaReforgerWorkbenchSteamDiag.exe') for x in args):
p.error('Another Workbench already uses this test profile')
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
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.project == 'core':
if a.smoke not in {'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'
command[command.index('-errSmoke')] = '-errBaseSmoke'
if a.smoke in {'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'} and a.project != 'core': p.error('This fixture requires --project core')
if a.smoke in {'startup-failure', 'site-survey', 'transport-survey', 'extraction-startup-failure', 'extraction-boundary-failure'} and (a.project != 'core' or a.rounds != 1): p.error('This diagnostic requires --project core --rounds 1')
env = os.environ.copy()
env.update(STEAM_COMPAT_DATA_PATH=str(compat), STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam), WINEPREFIX=str(compat/'pfx'))
# A test-only sink prevents startup sounds; a stream check also catches a saved
# explicit Wine device selection. Never change the user's default sink.
silent_sink = 'erri_tests_silent'
sinks = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'short', 'sinks'], text=True))
if not any(sink['name'] == silent_sink for sink in sinks):
subprocess.run(['pactl', 'load-module', 'module-null-sink', 'sink_name='+silent_sink, 'sink_properties=device.description=ERRI_Silent_Tests'], check=True, stdout=subprocess.DEVNULL)
subprocess.run(['pactl', 'set-sink-mute', silent_sink, '1'], check=True)
subprocess.run(['pactl', 'set-sink-volume', silent_sink, '0%'], check=True)
sinks = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'short', 'sinks'], text=True))
silent_index = next(sink['index'] for sink in sinks if sink['name'] == silent_sink)
env.update(PULSE_SINK=silent_sink, PULSE_PROP='application.id=org.escapistsrereintroduced.tests')
muted_streams = set()
def silence_test_streams():
streams = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'sink-inputs'], text=True))
for stream in streams:
props = stream.get('properties', {})
owned = props.get('application.id') == 'org.escapistsrereintroduced.tests'
pid = 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 not owned: continue
index = str(stream['index'])
if stream.get('sink') != silent_index:
subprocess.run(['pactl', 'move-sink-input', index, silent_sink], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if not stream.get('mute'):
subprocess.run(['pactl', 'set-sink-input-mute', index, '1'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if index not in muted_streams:
muted_streams.add(index)
print('Test audio routed to silent output and muted (stream '+index+')', flush=True)
# Snapshot addon bytes before launching any engine process.
captured_sources = snapshot(ROOT, command)
recorded_sessions = set()
started = time.time()
log_root = profile/'logs'
previous = set(log_root.glob('logs_*')) if log_root.exists() else set()
result, session, complete_at = 'timeout', None, None
with (profile/'headless-launch.log').open('w') as output:
child = subprocess.Popen(command, cwd=wb, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
try:
while time.time()-started < a.timeout:
silence_test_streams()
candidates = [x for x in log_root.glob('logs_*') if x not in previous]
if candidates:
session = max(candidates, key=lambda x:x.stat().st_mtime)
if session not in recorded_sessions:
persist(session, captured_sources)
recorded_sessions.add(session)
console = session/'console.log'
content = console.read_text(errors='replace') if console.exists() else ''
if ('Rereintroduced: scenario preparation failed' in content and a.smoke not in {'startup-failure', 'extraction-startup-failure', 'extraction-boundary-failure'}) or 'ERR SMOKE FAIL:' in content or 'Virtual Machine Exception' in content or 'Can\'t compile "Game"' in content:
result = 'test-failed'; break
if content.count('ERR SMOKE COMPLETE:') >= a.rounds:
if complete_at is None: complete_at = time.time()
if time.time()-complete_at >= 6: result = 'smoke-completed'; break
if child.poll() is not None: result = 'process-exited'; break
time.sleep(1)
except KeyboardInterrupt:
result = 'interrupted'
finally:
# Stop Wine in this disposable prefix only. Proton's reaper may create a
# separate process group, so killing Gamescope alone can leave Wine alive.
wine_server = proton.parent/'files/bin/wineserver'
subprocess.run([str(wine_server), '-k'], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
# Only this fresh process group belongs to the runner and disposable prefix.
try: os.killpg(child.pid, signal.SIGTERM)
except ProcessLookupError: pass
try: child.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL); child.wait()
# Gamescope's reaper can outlive its parent and ignores SIGTERM.
try: os.killpg(child.pid, signal.SIGKILL)
except ProcessLookupError: pass
stopped_evidence = seal_run(ROOT, session, captured_sources) if session else {}
if session and not stopped_evidence['source_unchanged_after_run']: result = 'source-changed'
summary = {**stopped_evidence, **bindings(captured_sources), 'started_unix':started, 'result':result, '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')
print(json.dumps(summary, indent=2), flush=True)
if result != 'smoke-completed': raise SystemExit(1)
# All native errors still fail the report even when the scripted sequence completed.
raise SystemExit(subprocess.call(['python3', str(ROOT/'tools/native_report.py'), str(session/'console.log'), '--require-completions', str(a.rounds)]))
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Import supported legacy settings without modifying either source or an existing destination."""
import argparse,json,os,tempfile,sys
from pathlib import Path
SCHEMA={
'reportIntervalSeconds':(10,5,60),
'supportSquadLimit':(4,0,8),
'responseRadiusMetres':(2000,500,5000),
'aiIntelChancePercent':(60,0,100),
'intelRevealChancePercent':(15,0,100),
'radioDefenceSeconds':(180,30,1800),
'startTimeMinutes':(840,0,1439),
'timeMultiplier':(1,1,24),
'startDateCode':(0,0,1501231),
'weatherProfile':(0,0,3),
}
AI_KEYS=('reportIntervalSeconds','supportSquadLimit','responseRadiusMetres')
INTEL_KEYS=('aiIntelChancePercent','intelRevealChancePercent')
def merge(ai=None,intel=None):
result={key:spec[0] for key,spec in SCHEMA.items()};deferred=[]
for label,data,keys in [('ai',ai,AI_KEYS),('intel',intel,INTEL_KEYS)]:
if data is None:continue
if not isinstance(data,dict):raise ValueError(label+' settings must be a JSON object')
if 'configVersion' in data and (type(data['configVersion']) not in (int,float) or data['configVersion']!=1):
raise ValueError(label+' configVersion must be 1')
for key in keys:
if key not in data:continue
value=data[key];_,lo,hi=SCHEMA[key]
if type(value) not in (int,float) or not lo<=value<=hi or int(value)!=value:
raise ValueError(f'{key} must be an integer in [{lo},{hi}]')
result[key]=int(value)
deferred.extend(label+':'+key for key in data if key not in keys and key!='configVersion')
return result,deferred
def write_new(path,text):
path=Path(path)
with tempfile.NamedTemporaryFile(mode='w',dir=path.parent,prefix='.rereintroduced-',delete=False) as f:
tmp=Path(f.name)
try:
f.write(text);f.flush();os.fsync(f.fileno())
# Atomic installation that cannot overwrite an existing file, even in a race.
os.link(tmp,path)
finally:tmp.unlink(missing_ok=True)
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--ai',type=Path,help='Read-only escapists-ai.json source')
p.add_argument('--intel',type=Path,help='Read-only escapists-intel.json source')
p.add_argument('--out',type=Path,help='New rereintroduced.json; omit to preview on stdout')
a=p.parse_args()
if not a.ai and not a.intel:p.error('Supply --ai and/or --intel')
try:
result,deferred=merge(json.loads(a.ai.read_text()) if a.ai else None,json.loads(a.intel.read_text()) if a.intel else None)
text=json.dumps(result,indent=2,allow_nan=False)+'\n'
if a.out:write_new(a.out,text)
else:print(text,end='')
if deferred:print('Settings awaiting controller migration (source files retained): '+', '.join(deferred),file=sys.stderr)
except (ValueError,OSError) as e:p.error(str(e))
if __name__=='__main__':main()
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Print native test commands; never launch a window implicitly."""
import argparse,json,subprocess
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('mode',choices=['workbench','server'])
p.add_argument('--exe',required=True,type=Path)
p.add_argument('--profile',required=True,type=Path)
p.add_argument('--smoke',choices=['patrol-hq', 'victory','lost-helicopter','player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'cache-loss', 'startup-failure', 'site-survey', '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', 'environment', 'patrols'],help='Opt-in Workbench test; use a disposable profile')
p.add_argument('--execute',action='store_true',help='Deliberately execute the command; Workbench opens a window')
p.add_argument('--visible-output',help='User-approved spare display; required when executing Workbench')
p.add_argument('--project',choices=['core','chat'],default='chat')
a=p.parse_args()
if a.smoke and a.mode!='workbench':p.error('Scripted smoke is compiled only for Workbench')
if a.smoke in {'cache-loss', 'startup-failure', 'site-survey', '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', 'environment', 'patrols'} and a.project!='core':p.error('This fixture requires Core')
if a.execute and a.mode=='workbench' and not a.visible_output:
p.error('Workbench opens windows. Select/configure a spare display and supply --visible-output first.')
command=[str(a.exe),'-addonsDir',str(ROOT/'addons'),'-profile',str(a.profile),'-logconsole']
if a.mode=='workbench':
command+=['-gproj',str(ROOT/'addons'/a.project/'addon.gproj'),'-wbModule=WorldEditor','-load','Worlds/ERR_Everon.ent']
if a.smoke:command+=['-plugin','ERR_ValidateWorldPlugin','-errPlay','1','-errSmoke','1','-errSmokeOutcome',a.smoke]
if a.project=='core' and a.smoke:
if a.smoke not in {'victory', 'cache-loss', 'startup-failure', 'site-survey', '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', 'environment', 'patrols'}:p.error('Unsupported Core-only fixture')
command[command.index('ERR_ValidateWorldPlugin')]='ERR_ValidateBaseWorldPlugin'
command[command.index('-errSmoke')]='-errBaseSmoke'
else:
if 'server' not in a.exe.name.lower():p.error('Use an actual dedicated server binary for background tests.')
command+=['-addons',{'core':'ERR_Core','chat':'ERR_Chat'}[a.project],'-server','Worlds/ERR_Everon.ent','-maxFPS','60','-logStats','1000']
print(json.dumps(command,indent=2))
if a.execute:
if not a.exe.is_file():p.error('Executable not found')
# This helper does not move windows or change desktop settings.
raise SystemExit(subprocess.call(command))
@@ -0,0 +1,137 @@
"""Launch-time source snapshots shared by the runner, archiver and release audit."""
import hashlib
import json
import math
from pathlib import Path
import re
import time
import uuid
from source_manifest import addon_manifest
MANIFEST = 'launch-source-sha256.json'
METADATA = 'launch-source.json'
PROVENANCE = 'source-provenance.json'
def require(condition, message):
if not condition:
raise ValueError(message)
def strict_json(raw):
def pairs(items):
result = {}
for key, value in items:
require(key not in result, f'duplicate JSON key: {key}')
result[key] = value
return result
def constant(value):
raise ValueError(f'invalid JSON number: {value}')
return json.loads(raw, object_pairs_hook=pairs, parse_constant=constant)
def encoded(value):
return (json.dumps(value, indent=2, allow_nan=False) + '\n').encode()
def snapshot(root, command):
"""Call synchronously before Popen; never reconstruct this after a run."""
manifest = encoded(addon_manifest(root))
metadata = {'schema_version': 1, 'launch_id': str(uuid.uuid4()),
'captured_unix': time.time(), 'source_root': str(Path(root).resolve()),
'command': list(command), 'manifest_sha256': hashlib.sha256(manifest).hexdigest()}
return manifest, encoded(metadata)
def persist(session, captured):
"""Persist the pre-launch bytes once a new engine session directory exists."""
for name, raw in zip((MANIFEST, METADATA), captured):
with (Path(session) / name).open('xb') as stream:
stream.write(raw)
def bindings(captured):
metadata = strict_json(captured[1])
return {'source_provenance': 'launch_snapshot', 'launch_id': metadata['launch_id'],
'launch_source_manifest': MANIFEST, 'launch_source_metadata': METADATA,
'launch_source_sha256': metadata['manifest_sha256']}
def seal_run(root, session, captured):
"""After process cleanup, bind final sources and exact raw log to this launch."""
metadata = strict_json(captured[1])
final_sources = encoded(addon_manifest(root))
final_digest = hashlib.sha256(final_sources).hexdigest()
return {'stopped_unix': time.time(), 'console_sha256': hashlib.sha256(read_file(Path(session) / 'console.log')).hexdigest(),
'end_source_sha256': final_digest,
'source_unchanged_after_run': final_digest == metadata['manifest_sha256']}
def read_file(path):
require(path.is_file() and not path.is_symlink(), f'missing or symlinked launch evidence: {path.name}')
return path.read_bytes()
def read_launch(session, runner_name='runner.json', log_name='console.log'):
session = Path(session)
raw_manifest = read_file(session / MANIFEST)
raw_metadata = read_file(session / METADATA)
manifest, metadata = strict_json(raw_manifest), strict_json(raw_metadata)
runner = strict_json(read_file(session / runner_name))
require(isinstance(manifest, dict) and bool(manifest), 'launch manifest must be nonempty')
require(all(isinstance(path, str) and isinstance(digest, str) and re.fullmatch('[0-9a-f]{64}', digest)
for path, digest in manifest.items()), 'invalid launch source hash')
require(isinstance(metadata, dict) and set(metadata) == {'schema_version', 'launch_id', 'captured_unix', 'source_root', 'command', 'manifest_sha256'}, 'invalid launch metadata fields')
require(type(metadata['schema_version']) is int and metadata['schema_version'] == 1, 'invalid launch metadata version')
require(isinstance(metadata['launch_id'], str), 'invalid launch id')
try:
uuid.UUID(metadata['launch_id'])
except (ValueError, AttributeError) as error:
raise ValueError('invalid launch id') from error
require(isinstance(metadata['source_root'], str) and bool(metadata['source_root']), 'missing launch source root')
require(isinstance(metadata['command'], list) and bool(metadata['command']) and all(isinstance(arg, str) for arg in metadata['command']), 'invalid launch command')
captured = metadata['captured_unix']
require(type(captured) in (int, float) and math.isfinite(captured) and captured > 0, 'invalid launch capture time')
require(hashlib.sha256(raw_manifest).hexdigest() == metadata['manifest_sha256'], 'launch manifest byte hash disagrees with launch metadata')
require(isinstance(runner, dict), 'runner metadata must be an object')
for key, value in bindings((raw_manifest, raw_metadata)).items():
require(runner.get(key) == value, f'runner launch binding disagrees: {key}')
started = runner.get('started_unix')
require(type(started) in (int, float) and math.isfinite(started) and started >= captured, 'source snapshot must precede process launch')
stopped = runner.get('stopped_unix')
require(type(stopped) in (int, float) and math.isfinite(stopped) and stopped >= started, 'invalid stopped runner timestamp')
require(runner.get('source_unchanged_after_run') is True and runner.get('end_source_sha256') == metadata['manifest_sha256'], 'addon sources changed between launch and stopped-run snapshot')
require(runner.get('console_sha256') == hashlib.sha256(read_file(session / log_name)).hexdigest(), 'raw native log differs from stopped runner log hash')
return manifest, metadata, runner
def archive_source(session, root, allow_legacy=False):
"""Refuse changed launch sources; legacy provenance requires explicit opt-in."""
session = Path(session)
runner_path = session / 'runner.json'
runner = strict_json(read_file(runner_path)) if runner_path.exists() else {}
marked = isinstance(runner, dict) and any(key in runner for key in ('source_provenance', 'launch_id', 'launch_source_sha256'))
present = (session / MANIFEST).exists() or (session / METADATA).exists() or marked
current = addon_manifest(root)
if present:
manifest, metadata, runner = read_launch(session)
require(manifest == current, 'current addon sources differ from pre-launch snapshot; refusing to relabel an old run')
return read_file(session / MANIFEST), {'schema_version': 1, 'kind': 'launch_snapshot'}, True
require(allow_legacy, 'session has no pre-launch source snapshot; use --allow-legacy-archive-time-source only for historical evidence')
return encoded(current), {'schema_version': 1, 'kind': 'archive_time_only',
'qualification': 'historical_only',
'reason': 'No pre-launch snapshot; hashes describe archive-time files, not proven launch files.'}, False
def qualify_archive(archive, current):
archive = Path(archive)
provenance = strict_json(read_file(archive / PROVENANCE))
require(provenance == {'schema_version': 1, 'kind': 'launch_snapshot'}
and type(provenance.get('schema_version')) is int, 'native qualification requires launch_snapshot provenance; archive-time-only evidence is historical')
manifest, metadata, runner = read_launch(archive, 'runner.json.txt', 'console.log.txt')
require(manifest == current, 'launch source snapshot does not match current addon sources/resources')
require(read_file(archive / 'source-sha256.json') == read_file(archive / MANIFEST), 'archived source manifest differs from exact launch snapshot bytes')
require(runner.get('result') == 'smoke-completed', 'native qualification requires a completed runner session')
rounds = runner.get('rounds_required')
require(type(rounds) is int and 1 <= rounds <= 10, 'invalid completed runner round requirement')
return rounds
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Summarize native smoke evidence without confusing it with gameplay acceptance."""
from pathlib import Path
import argparse
import json
import re
def analyze(log_text, log_name):
failures = [line.strip() for line in log_text.splitlines()
if 'ERR SMOKE FAIL:' in line or 'Virtual Machine Exception' in line
or re.search(r'\bSCRIPT\s+\(E\)', line)
or "won't take damage and wont replicate" in line]
return {
'log': str(log_name),
'smoke_completions': log_text.count('ERR SMOKE COMPLETE:'),
'assertions_passed': len(re.findall('ERR SMOKE PASS:', log_text)),
'script_or_runtime_failures': failures,
'engine_errors': [line.strip() for line in log_text.splitlines()
if re.search(r'\b[A-Z][A-Z0-9_]*\s+\([EF]\)', line)],
'missing_end_screen': [line.strip() for line in log_text.splitlines()
if 'Could not find' in line and 'endscreen' in line],
'scope': 'scripted local Workbench integration only; no multiplayer, piloting or FPS claim',
}
def clean(report, require_completions=1):
return (report['smoke_completions'] >= require_completions
and not report['script_or_runtime_failures']
and not report['engine_errors'] and not report['missing_end_screen'])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('log', type=Path)
parser.add_argument('--require-completions', type=int, default=1)
args = parser.parse_args()
report = analyze(args.log.read_text(errors='replace'), args.log)
print(json.dumps(report, indent=2))
return 0 if clean(report, args.require_completions) else 1
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Snapshot/verify reference sources without writing to the reference workspace."""
import argparse,hashlib,json,sys
from pathlib import Path
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('mode',choices=['snapshot','verify'])
p.add_argument('reference',type=Path)
p.add_argument('manifest',type=Path)
a=p.parse_args();ref=a.reference.resolve();manifest=a.manifest.resolve()
if manifest.is_relative_to(ref):p.error('Manifest must be outside the read-only reference')
suffixes={'.c','.et','.ent','.layer','.conf','.gproj','.meta','.layout','.emat','.md','.py'}
current={str(f.relative_to(ref)):hashlib.sha256(f.read_bytes()).hexdigest() for f in sorted(ref.rglob('*')) if f.is_file() and f.suffix in suffixes and not any(part in {'.git','.agents','.codex','__pycache__'} for part in f.relative_to(ref).parts)}
if a.mode=='snapshot':
if manifest.exists():p.error('Refusing to overwrite an existing baseline')
manifest.write_text(json.dumps(current,indent=2)+'\n');print(f'Recorded {len(current)} reference source hashes')
else:
before=json.loads(manifest.read_text())
changed=sorted(k for k in before.keys()|current.keys() if before.get(k)!=current.get(k))
if changed:
print('Reference changes detected; no files were reverted:');print('\n'.join(changed));sys.exit(1)
print(f'PASS: {len(current)} reference sources unchanged')
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Audit release qualification; never change acceptance, build or publish anything."""
import json
import re
from pathlib import Path, PurePosixPath
from native_report import analyze, clean
from source_manifest import addon_manifest
from native_provenance import qualify_archive, MANIFEST, METADATA, PROVENANCE
ROOT = Path(__file__).resolve().parents[1]
REQUIRED_CASES = (
'independent_native_build', 'prison_geometry_and_spawn', 'escape_loop',
'join_late', 'reconnect_identity', 'death_unconsciousness_revive',
'respawn_and_spectators', 'partial_escape', 'defeat_restart',
'interrupted_reports', 'stale_contacts', 'support_losses',
'ammunition_depletion', 'cleanup_no_duplicate_orders_or_spawns',
'twelve_players', 'equivalent_workload_fps_improvement',
'own_identity_release_package',
)
STATUSES = {'pending', 'passed', 'failed'}
def strict_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f'duplicate JSON key: {key}')
result[key] = value
return result
def read_json(path):
def invalid_constant(value):
raise ValueError(f'invalid JSON number: {value}')
return json.loads(path.read_text(encoding='utf-8'), object_pairs_hook=strict_object,
parse_constant=invalid_constant)
def evidence_path(root, name):
if not isinstance(name, str) or not name or '\\' in name or any(ord(c) < 32 for c in name):
raise ValueError('evidence path must be a nonempty repository-relative POSIX path')
path = PurePosixPath(name)
if (path.is_absolute() or path.as_posix() != name or ':' in name
or any(part in {'.', '..', '.git'} for part in path.parts)):
raise ValueError(f'unsafe or noncanonical evidence path: {name}')
full = root / name
try:
resolved = full.resolve()
except (OSError, RuntimeError) as error:
raise ValueError(f'evidence path cannot be resolved: {name}') from error
if not resolved.is_relative_to(root):
raise ValueError(f'evidence leaves repository: {name}')
cursor = root
for part in path.parts:
cursor /= part
if cursor.is_symlink():
raise ValueError(f'evidence symlinks are not supported: {name}')
if not full.is_file():
raise ValueError(f'evidence is not an existing regular file: {name}')
with full.open('rb') as stream:
if not any(chunk.strip() for chunk in iter(lambda: stream.read(65536), b'')):
raise ValueError(f'evidence is empty: {name}')
return full
def audit(root=ROOT):
root = Path(root).resolve()
blocked = []
try:
ledger = read_json(evidence_path(root, 'docs/acceptance.json'))
except (OSError, ValueError) as error:
return [f'docs/acceptance.json: {error}']
if not isinstance(ledger, dict):
return ['acceptance ledger must be an object']
if not isinstance(ledger.get('target'), str) or not ledger['target'].strip():
blocked.append('target must be a nonempty string')
cases = ledger.get('cases')
if not isinstance(cases, dict):
blocked.append('cases must be an object containing the fixed first-release requirements')
cases = {}
for name in REQUIRED_CASES:
if name not in cases:
blocked.append(f'cases.{name}: required first-release case is missing')
for name in sorted(set(cases) - set(REQUIRED_CASES)):
blocked.append(f'cases.{name}: unknown first-release case')
development = ledger.get('development_checks')
if not isinstance(development, dict):
blocked.append('development_checks must be an object')
development = {}
if 'clean_native_runtime' not in development:
blocked.append('development_checks.clean_native_runtime: required clean-runtime gate is missing')
def validate_entry(label, entry, required):
if not isinstance(entry, dict):
blocked.append(f'{label}: entry must be an object')
return
status = entry.get('status')
if not isinstance(status, str) or status not in STATUSES:
blocked.append(f'{label}: unknown or malformed status {status!r}')
elif required and status != 'passed':
blocked.append(f'{label}: status is {status}, not passed')
evidence = entry.get('evidence')
if not isinstance(evidence, list):
blocked.append(f'{label}: evidence must be a list of file paths')
return
if status == 'passed' and not evidence:
blocked.append(f'{label}: passed status requires nonempty evidence')
seen = set()
for name in evidence:
try:
evidence_path(root, name)
if name in seen:
raise ValueError(f'duplicate evidence path: {name}')
seen.add(name)
except (OSError, ValueError) as error:
blocked.append(f'{label}: {error}')
for name, entry in cases.items():
validate_entry(f'cases.{name}', entry, True)
for name, entry in development.items():
validate_entry(f'development_checks.{name}', entry, name == 'clean_native_runtime')
current = None
try:
current = addon_manifest(root)
except (OSError, ValueError) as error:
blocked.append(f'current addon source/resource manifest: {error}')
def native_qualification(label, entry):
if not isinstance(entry, dict) or entry.get('status') != 'passed':
return
paths = {}
for field in ('source_manifest', 'native_report'):
try:
paths[field] = evidence_path(root, entry.get(field))
if not isinstance(entry.get('evidence'), list) or entry[field] not in entry['evidence']:
raise ValueError(f'{field} must also appear in this entry\'s evidence list')
except (OSError, ValueError) as error:
blocked.append(f'{label}.{field}: {error}')
if len(paths) != 2:
return
if paths['source_manifest'].parent != paths['native_report'].parent:
blocked.append(f'{label}: native report and manifest must belong to the same evidence archive')
try:
manifest = read_json(paths['source_manifest'])
if (not isinstance(manifest, dict) or not manifest
or any(not isinstance(digest, str) or not re.fullmatch(r'[0-9a-f]{64}', digest)
for digest in manifest.values())):
raise ValueError('manifest must be a nonempty path-to-SHA256 object')
if current is not None and manifest != current:
missing = sorted(set(current) - set(manifest))
extra = sorted(set(manifest) - set(current))
changed = sorted(path for path in set(current) & set(manifest) if current[path] != manifest[path])
raise ValueError(f'manifest does not match current addon sources/resources '
f'(missing={len(missing)}, extra={len(extra)}, changed={len(changed)})')
except (OSError, ValueError) as error:
blocked.append(f'{label}.source_manifest: {error}')
required_rounds = 1
try:
archive = paths['native_report'].parent
for name in (MANIFEST, METADATA, PROVENANCE, 'runner.json.txt', 'source-sha256.json'):
evidence_path(root, (archive / name).relative_to(root).as_posix())
required_rounds = qualify_archive(archive, current)
except (OSError, ValueError) as error:
blocked.append(f'{label}.launch_provenance: {error}')
try:
report = read_json(paths['native_report'])
if not isinstance(report, dict):
raise ValueError('native report must be an object')
log = evidence_path(root, report.get('log'))
if log.parent != paths['native_report'].parent:
raise ValueError('native raw log must belong to the same evidence archive')
if log.name != 'console.log.txt':
raise ValueError('native report must analyze the exact console.log.txt sealed by the stopped runner')
actual = analyze(log.read_text(errors='replace'), report['log'])
# Exact JSON types matter: True must not masquerade as one completion.
if json.dumps(report, sort_keys=True) != json.dumps(actual, sort_keys=True):
raise ValueError('native report does not match analysis of its archived raw log')
if not clean(actual, required_rounds) or actual['assertions_passed'] <= 0:
raise ValueError('native raw log must contain successful completion/assertions and no script, engine or end-screen errors')
except (OSError, ValueError) as error:
blocked.append(f'{label}.native_report: {error}')
native_qualification('cases.independent_native_build', cases.get('independent_native_build'))
native_qualification('development_checks.clean_native_runtime', development.get('clean_native_runtime'))
return blocked
def main():
blocked = audit()
if blocked:
print('Not release-qualified:')
for reason in blocked:
print(' - ' + reason)
return 1
print('Release qualification audit passed. This audit does not build or publish a release.')
return 0
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,29 @@
"""Hash authored addon files, excluding generated Workbench/build state.
Keep this shared by native evidence archival and release qualification. Unknown
resource formats are included automatically, rather than silently omitted.
"""
import hashlib
from pathlib import Path
GENERATED_DIRECTORIES = {'.build', '.local', '__pycache__', 'profiles'}
def addon_manifest(root):
root = Path(root).resolve()
addons = root / 'addons'
if not addons.is_dir() or addons.is_symlink():
raise ValueError('addons must be a real directory inside the repository')
manifest = {}
for path in sorted(addons.rglob('*')):
relative = path.relative_to(root)
if any(part in GENERATED_DIRECTORIES for part in relative.parts[1:]):
continue
if (len(relative.parts) == 3 and path.name.startswith('resourceDatabase.rdb')) or path.suffix in {'.pak', '.log'}:
continue
if path.is_symlink():
raise ValueError(f'addon source/resource symlink is not supported: {relative.as_posix()}')
if path.is_file():
manifest[relative.as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest()
if not manifest:
raise ValueError('addon source/resource manifest is empty')
return manifest