Build independent base gameplay and source-bound native verification
This commit is contained in:
+16
-5
@@ -21,7 +21,7 @@ except (OSError, ValueError) as 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','rereintroduced-hq.log','headless-launch.log','runner.json']:
|
||||
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)
|
||||
@@ -29,7 +29,18 @@ for capture in sorted(source.glob('err-*.png')):
|
||||
(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)')
|
||||
sys.exit(result.returncode)
|
||||
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)
|
||||
|
||||
+1
-1
@@ -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_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']:
|
||||
for name in ['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_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,392 @@
|
||||
#!/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)
|
||||
ready = header_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, '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())
|
||||
@@ -11,7 +11,7 @@ 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', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui'], default='victory')
|
||||
p.add_argument('--smoke', choices=['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)
|
||||
@@ -20,6 +20,10 @@ 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 == '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')
|
||||
@@ -49,13 +53,13 @@ for proc in Path('/proc').iterdir():
|
||||
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']
|
||||
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'}: 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', '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'} and a.project != 'core': p.error('This fixture requires --project core')
|
||||
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'))
|
||||
|
||||
@@ -11,6 +11,8 @@ SCHEMA={
|
||||
'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')
|
||||
|
||||
@@ -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', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui'],help='Opt-in Workbench test; use a disposable profile')
|
||||
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'} 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', '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']
|
||||
@@ -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', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui'}: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', '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:
|
||||
|
||||
@@ -17,7 +17,7 @@ def analyze(log_text, log_name):
|
||||
'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)],
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user