Separate radio defence and harden player and objective lifecycles

This commit is contained in:
2026-09-19 05:33:36 +02:00
parent e5857b6ddf
commit 16f397e581
237 changed files with 29087 additions and 202 deletions
+12 -5
View File
@@ -1,17 +1,23 @@
#!/usr/bin/env python3
"""Archive an isolated native run and the exact current addon sources; never overwrite evidence.
"""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,hashlib,json,shutil,subprocess,sys
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')
@@ -19,9 +25,10 @@ for name in ['console.log','script.log','error.log','rereintroduced-hq.log','hea
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)
suffixes={'.c','.conf','.meta','.et','.ent','.layer','.gproj','.layout','.emat'}
manifest={str(f.relative_to(ROOT)):hashlib.sha256(f.read_bytes()).hexdigest() for f in sorted((ROOT/'addons').rglob('*')) if f.is_file() and f.suffix in suffixes}
(dest/'source-sha256.json').write_text(json.dumps(manifest,indent=2)+'\n')
(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)
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)
print(f'Archived {dest.relative_to(ROOT)}; native report exit={result.returncode} (nonzero retains failures)')
+1 -1
View File
@@ -59,7 +59,7 @@ def check():
assert set(mapped)==set(inventory['script_paths']), 'Original inventory has untracked or unknown entries'
print(f'PASS: {len(mapped)} original script entries tracked across {len(feature_ids)} unfinished responsibilities')
print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla references')
for name in ['test_mission_rules.py','test_time_policy.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
for name in ['test_mission_rules.py', 'test_player_admission_policy.py','test_time_policy.py','test_radio_defence_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()
+227
View File
@@ -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()
+16 -5
View File
@@ -5,12 +5,14 @@ No desktop rules or existing Wine processes are changed. This is not a dedicated
"""
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=['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'], default='victory')
p.add_argument('--smoke', choices=['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'], 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')
@@ -48,12 +50,13 @@ for proc in Path('/proc').iterdir():
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']
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'}: p.error('Unsupported Core-only fixture')
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'}: 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'} and a.project != 'core': p.error('This fixture requires --project core')
if a.smoke in {'startup-failure', 'site-survey', 'extraction-startup-failure', 'extraction-boundary-failure'} and (a.project != 'core' or a.rounds != 1): p.error('This diagnostic requires --project core --rounds 1')
if a.smoke in {'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui'} 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
@@ -87,6 +90,9 @@ def silence_test_streams():
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()
@@ -99,6 +105,9 @@ with (profile/'headless-launch.log').open('w') as output:
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:
@@ -124,7 +133,9 @@ with (profile/'headless-launch.log').open('w') as output:
# Gamescope's reaper can outlive its parent and ignores SIGTERM.
try: os.killpg(child.pid, signal.SIGKILL)
except ProcessLookupError: pass
summary = {'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'}
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')
+3 -3
View File
@@ -7,13 +7,13 @@ 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=['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'],help='Opt-in Workbench test; use a disposable profile')
p.add_argument('--smoke',choices=['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'],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'} and a.project!='core':p.error('This fixture requires Core')
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'} 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']
@@ -21,7 +21,7 @@ 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'}:p.error('Unsupported Core-only fixture')
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'}:p.error('Unsupported Core-only fixture')
command[command.index('ERR_ValidateWorldPlugin')]='ERR_ValidateBaseWorldPlugin'
command[command.index('-errSmoke')]='-errBaseSmoke'
else:
+137
View File
@@ -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
+41 -10
View File
@@ -1,13 +1,44 @@
#!/usr/bin/env python3
"""Summarize native smoke evidence without confusing it with gameplay acceptance."""
from pathlib import Path
import argparse,json,re,sys
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('log',type=Path)
p.add_argument('--require-completions',type=int,default=1)
a=p.parse_args();s=a.log.read_text(errors='replace')
completed=s.count('ERR SMOKE COMPLETE:')
failures=[l.strip() for l in s.splitlines() if 'ERR SMOKE FAIL:' in l or 'Virtual Machine Exception' in l or re.search(r'\bSCRIPT\s+\(E\)',l) or "won't take damage and wont replicate" in l]
report={'log':str(a.log),'smoke_completions':completed,'assertions_passed':len(re.findall('ERR SMOKE PASS:',s)),'script_or_runtime_failures':failures,'engine_errors':[l.strip() for l in s.splitlines() if re.search(r'\b(?:SCRIPT|WORLD|DEFAULT|RESOURCES|ENTITY|NETWORK|ENGINE)\s+\(E\)',l)],'missing_end_screen':[l.strip() for l in s.splitlines() if 'Could not find' in l and 'endscreen' in l],'scope':'scripted local Workbench integration only; no multiplayer, piloting or FPS claim'}
print(json.dumps(report,indent=2))
sys.exit(0 if completed>=a.require_completions and not failures and not report['engine_errors'] and not report['missing_end_screen'] else 1)
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+\(E\)', 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())
+200 -18
View File
@@ -1,19 +1,201 @@
#!/usr/bin/env python3
"""Refuse release qualification while acceptance evidence is incomplete."""
import json,sys
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
ledger=json.loads((ROOT/'docs/acceptance.json').read_text())
blocked=[]
for name,case in ledger['cases'].items():
if case['status']!='passed' or not case['evidence']:
blocked.append(name)
continue
for evidence in case['evidence']:
path=(ROOT/evidence).resolve()
if not path.is_relative_to(ROOT) or not path.is_file():blocked.append(name);break
if blocked:
print('Not release-qualified; missing passed evidence:')
for name in blocked:print(' - '+name)
sys.exit(1)
print('Acceptance ledger complete. Review provenance, native packages and publication metadata before release.')
"""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())
+29
View File
@@ -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