Advance standalone admission, finite artillery and native validation
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user