Build independent Everon successor foundation and native regressions

This commit is contained in:
2026-09-19 01:37:41 +02:00
commit 2550bc0423
155 changed files with 15098 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Archive an isolated native run and the exact current addon sources; 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
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')
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')
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','rereintroduced-hq.log','headless-launch.log','runner.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)
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')
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)')
sys.exit(result.returncode)
+41
View File
@@ -0,0 +1,41 @@
#!/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={}
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
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 p.suffix in ['.et','.conf','.ent','.layout','.emat']:
assert p.with_name(p.name+'.meta').exists(),f'Missing metadata: {p}'
print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla references')
for name in ['test_mission_rules.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()
+40
View File
@@ -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);
+115
View File
@@ -0,0 +1,115 @@
#!/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, json, os, shutil, signal, subprocess, time
from pathlib import Path
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'], default='victory')
p.add_argument('--rounds', type=int, default=2)
p.add_argument('--timeout', type=int, default=360)
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 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')
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/chat/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']
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)
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)
console = session/'console.log'
content = console.read_text(errors='replace') if console.exists() else ''
if '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)
finally:
# 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()
summary = {'result':result, 'session':str(session) if session else None, 'rounds_required':a.rounds, '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)]))
+55
View File
@@ -0,0 +1,55 @@
#!/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),
}
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()
+28
View File
@@ -0,0 +1,28 @@
#!/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=['victory','lost-helicopter','player-death', 'radio-network', 'hq-response'],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')
a=p.parse_args()
if a.smoke and a.mode!='workbench':p.error('Scripted smoke is compiled only for Workbench')
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/chat/addon.gproj'),'-wbModule=WorldEditor','-load','Worlds/ERR_Everon.ent']
if a.smoke:command+=['-plugin','ERR_ValidateWorldPlugin','-errPlay','1','-errSmoke','1','-errSmokeOutcome',a.smoke]
else:
if 'server' not in a.exe.name.lower():p.error('Use an actual dedicated server binary for background tests.')
command+=['-addons','ERR_Chat','-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))
+13
View File
@@ -0,0 +1,13 @@
#!/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)
+21
View File
@@ -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')
+19
View File
@@ -0,0 +1,19 @@
#!/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.')