#!/usr/bin/env python3 """Run bounded native Workbench smoke tests inside a windowless Gamescope display. Requires Linux, gamescope, Proton, and a disposable COPY of a working Tools compat prefix. No desktop rules or existing Wine processes are changed. This is not a dedicated server. """ import argparse, fcntl, json, os, shutil, signal, subprocess, time from pathlib import Path from native_provenance import snapshot, persist, bindings, seal_run from headless_game import assert_prefix_idle, cleanup_owned_run from external_addons import policy as external_policy, prepare_profile, verify_profile 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('--external-addons', type=Path, help='Installed RHS/WCS/BMP source root; expose only audited closure in a separate Chat profile') 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=['coordinated-ai', 'crash-start', 'crash-geometry', 'normal-escape-route', 'escape-route', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'self-epinephrine', 'starter-backpack', 'speech-download', 'bundled-speech', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'roadblocks', 'hq-local-handoff', 'infantry-transfer', 'hq-body', 'convoy', 'supply-convoys', 'supply-travel', 'mounted-ammo', 'alarm-hearing', 'hq-memory', 'hq-awareness', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory') p.add_argument('--roadblock-probe-count', type=int, choices=range(1, 31), help='Roadblocks fixture target; default remains 30') p.add_argument('--speech-probe-url', help='Loopback-only cached audio fixture; requires --smoke speech-download') p.add_argument('--trace-random-radius', action='store_true', help='Workbench-only read-only zero-radius caller traces; one normal Chat round, capped at sixteen records') p.add_argument('--maintenance-refuel', action='store_true', help='Opt-in normal-route check: original held Southern fuel can transfers finite fuel through its native UAZ action; one arranged tank depletion, no stock creation') p.add_argument('--mounted-swap-eligibility', action='store_true', help='Read-only mounted-ammo variant: one exact finite cargo/mounted magazine CanSwap call; no transfer or radio proof') p.add_argument('--mounted-bmp', action='store_true', help='Read-only original BMP slot4 complete mounted counts and its natural HQ radio delivery; isolates unrelated opening hunters before unlock') p.add_argument('--mine-stock', action='store_true', help='Read-only mounted-ammo check of exact callback-proved finite PMN4 cargo before and after natural radio delivery') p.add_argument('--mounted-courier', action='store_true', help='Mounted-ammo variant: intentionally deplete one original installed magazine, then require natural HQ radio, finite pickup, native exchange and original-seat return') p.add_argument('--mounted-intercept', action='store_true', help='After convoy driving, two synthetic witness samples exercise real HQ radio/planning and original escort detour/hold/expiry/resume') p.add_argument('--convoy-cover', action='store_true', help='After convoy driving, arrange the existing prisoner to verify native sighting/radio HOLD and RESUME') p.add_argument('--field-terrain-audit', action='store_true', help='After a fixed aircraft-field survey refusal, log one read-only 1681-node coarse terrain grid; never selects or qualifies a corridor') p.add_argument('--field-service-cancel', action='store_true', help='Explicit aircraft-field variant: Stop the original retained service during measured cruise; requires physical cancellation landing') p.add_argument('--route-service-cancel', action='store_true', help='Explicit aircraft-route variant: stop the retained service after actual airborne translation and require controlled cancellation landing') p.add_argument('--station-teardown-diagnostics', action='store_true', help='Opt-in bounded native deletion diagnostics; preserves all validation errors') p.add_argument('--flight-autohover', action='store_true', help='Opt-in owned native helicopter autohover experiment; requires Core powered-flight and actor-local action inputs') p.add_argument('--flight-action-inputs', action='store_true', help='Experimental exact-AI ActionManager inputs; Core powered-flight fixture only') p.add_argument('--flight-input-diagnostics', action='store_true', help='Opt-in read-only powered-flight input/application and native rotor signal observations') p.add_argument('--recon-takeover-only', action='store_true', help='Air-recon fixture: only native faction handover/release and explicit aircraft deletion') p.add_argument('--body-recovery-only', action='store_true', help='Air-logistics fixture: only physical corpse inspection and returned document intelligence') p.add_argument('--coordinated-blocked-only', action='store_true', help='Coordinated fixture: native movement, cancellation and unreachable-route failure only; explicitly excludes suppression qualification') 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='Diagnostic fixture family (both load the combined mod)') a = p.parse_args() if a.roadblock_probe_count is not None and a.smoke != 'roadblocks': p.error('--roadblock-probe-count requires --smoke roadblocks') if a.smoke == 'coordinated-ai' and (a.project != 'core' or a.rounds != 1 or a.timeout < 720): p.error('coordinated-ai requires Core, one round and timeout at least720 seconds') if a.coordinated_blocked_only and a.smoke != 'coordinated-ai': p.error('--coordinated-blocked-only requires --smoke coordinated-ai') if a.smoke == 'inventory-hitzone' and (a.project != 'core' or a.rounds != 1 or a.timeout < 240): p.error('inventory-hitzone requires --project core --rounds 1 and timeout at least 240 seconds') if a.smoke == 'crash-start' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 240): p.error('crash-start requires Chat, one round and timeout at least 240 seconds') if a.smoke == 'crash-geometry' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 180): p.error('crash-geometry requires Chat, one round and timeout at least180 seconds') if a.trace_random_radius and (a.smoke != 'normal-escape-route' or a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('--trace-random-radius requires Chat normal-escape-route, one round and timeout at least 420 seconds') if a.smoke == 'objective-map' and (a.project != 'core' or a.rounds != 1): p.error('objective-map requires --project core --rounds 1') if a.smoke in {'unconscious-camera', 'self-epinephrine', 'starter-backpack'} and a.project != 'core': p.error('unconscious-camera/self-epinephrine requires --project core to keep the general smoke fixture inactive') if a.mine_stock: if a.smoke != 'mounted-ammo' or a.project != 'chat' or a.rounds != 1 or a.timeout < 360: p.error('--mine-stock requires --smoke mounted-ammo --project chat --rounds 1 and timeout at least 360 seconds') if a.mounted_courier or a.mounted_swap_eligibility: p.error('--mine-stock cannot combine with --mounted-courier or --mounted-swap-eligibility') if a.maintenance_refuel and (a.smoke != 'normal-escape-route' or a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('--maintenance-refuel requires Chat normal-escape-route, one round and timeout at least 420 seconds') if a.body_recovery_only and (a.smoke != 'air-logistics' or a.project != 'core' or a.rounds != 1): p.error('--body-recovery-only requires Core air-logistics and one round') if a.recon_takeover_only and (a.smoke != 'air-recon' or a.project != 'core' or a.rounds != 1): p.error('--recon-takeover-only requires Core air-recon and one round') 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.field_terrain_audit and a.smoke != 'aircraft-field': p.error('--field-terrain-audit requires --smoke aircraft-field') if a.field_terrain_audit and a.field_service_cancel: p.error('--field-terrain-audit and --field-service-cancel are mutually exclusive') if a.field_service_cancel and a.smoke != 'aircraft-field': p.error('--field-service-cancel requires --smoke aircraft-field') if a.route_service_cancel and a.smoke != 'aircraft-route': p.error('--route-service-cancel requires --smoke aircraft-route') if a.smoke == 'mortar-courier' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 500): p.error('mortar-courier requires --project chat --rounds 1 --timeout at least 500') if a.smoke in {'mortar-refresh', 'mortar-salvo', 'mortar-expiry', 'mortar-transfer', 'mortar-fire'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400): p.error('mortar-refresh/mortar-salvo/mortar-expiry/mortar-transfer/mortar-fire requires --project chat --rounds 1 --timeout at least 400') if a.smoke in {'aircraft-field', 'aircraft-route', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('aircraft-field/aircraft-route/aircraft-control/aircraft-cancel/aircraft-translate requires Chat, one round, and at least 420 seconds') if a.mounted_swap_eligibility and a.smoke != 'mounted-ammo': p.error('--mounted-swap-eligibility requires --smoke mounted-ammo') if a.mounted_courier and (a.smoke != 'mounted-ammo' or a.project != 'chat' or a.rounds != 1 or a.timeout < 780): p.error('--mounted-courier requires --smoke mounted-ammo --project chat --rounds 1 and timeout at least780 seconds') if a.mounted_courier and a.mounted_swap_eligibility: p.error('--mounted-courier and --mounted-swap-eligibility are mutually exclusive') if a.mounted_intercept and (a.smoke != 'convoy' or a.project != 'chat' or a.rounds != 1 or a.timeout < 900): p.error('--mounted-intercept requires Chat --smoke convoy, one round and timeout at least900 seconds') if a.mounted_intercept and a.convoy_cover: p.error('--mounted-intercept and --convoy-cover are mutually exclusive') if a.convoy_cover and a.smoke != 'convoy': p.error('--convoy-cover requires --smoke convoy') if a.convoy_cover and a.timeout < 900: p.error('--convoy-cover requires timeout at least900 seconds') if a.smoke == 'supply-travel' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 480): p.error('supply-travel requires Chat, one round, and at least480 seconds') if a.smoke == 'supply-convoys' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 900): p.error('supply-convoys requires Chat, one round, and at least900 seconds') if a.smoke == 'alarm-hearing' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 360): p.error('alarm-hearing requires Chat, exactly one round, and timeout at least360 seconds') if a.smoke == 'mounted-ammo' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 360): p.error('mounted-ammo requires Chat, exactly one round, and timeout at least360 seconds') if a.mounted_bmp and (a.smoke != 'mounted-ammo' or a.project != 'chat' or a.rounds != 1 or a.timeout < 360 or a.mine_stock or a.mounted_courier or a.mounted_swap_eligibility): p.error('--mounted-bmp requires Chat mounted-ammo, one round, timeout at least360 seconds, without mine/courier/swap flags') if a.smoke == 'convoy' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 600): p.error('convoy requires Chat, exactly one round, and timeout at least600 seconds') if a.smoke == 'infantry-transfer' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 360): p.error('infantry-transfer requires Chat, one round and timeout at least360 seconds') if a.smoke == 'hq-local-handoff' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 360): p.error('hq-local-handoff requires Chat, one round and timeout at least360 seconds') if a.smoke in {'hq-memory', 'hq-awareness', 'hq-body'} and (a.project != 'chat' or a.rounds != 1): p.error('hq-memory/hq-awareness/hq-body requires Chat and exactly one round') if a.smoke == 'map-ownership' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 180): p.error('map-ownership requires Chat, one round, and at least 180 seconds') if a.smoke == 'powered-translate' and (a.project != 'core' or not a.flight_action_inputs or not a.flight_autohover): p.error('powered-translate requires Core with flight-action-inputs and flight-autohover') if a.flight_autohover and (a.project != 'core' or a.smoke not in {'powered-flight', 'powered-translate'} or not a.flight_action_inputs): p.error('flight-autohover requires Core powered-flight with flight-action-inputs') if a.flight_action_inputs and (a.project != 'core' or a.smoke not in {'powered-flight', 'powered-translate'}): p.error('flight-action-inputs requires the Core powered-flight fixture') if a.smoke in {'powered-flight', 'powered-translate'} and (a.project != 'core' or a.rounds != 1 or a.timeout < 420): p.error('powered-flight requires Core, one round, and at least 420 seconds') if a.smoke == 'station-teardown' and (a.project != 'core' or a.rounds != 1): p.error('station-teardown requires --project core --rounds 1') if a.smoke in {'native-end-active', 'native-end-preparing'}: if a.project != 'core' or a.rounds != 2 or a.timeout < 120: p.error('native-end fixtures require Core, exactly two rounds and at least 120 seconds') fixture_names = ['erri-native-end-round.json', 'erri-native-end-round.json.tmp', 'erri-native-end-round.json.bak'] if any((profile/name).exists() or (profile/name).is_symlink() for name in fixture_names): p.error('native-end fixture paths must not preexist; preserve prior evidence before retrying') 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') if a.external_addons and profile == Path('/tmp/erri-workbench-profile'): p.error('Use a separate external-dependency profile; preserve the vanilla test profile') if not a.external_addons: p.error('The combined mod requires --external-addons pointing to installed authorized RHS/WCS/BMP dependencies') prefix_lock = (compat/'.erri-workbench.lock').open('a') try: fcntl.flock(prefix_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: p.error('Another test runner owns this disposable Wine prefix') # The lock must be held before the preflight; never repair a busy prefix here. try: assert_prefix_idle(compat) except (OSError,ValueError) as error: p.error(str(error)) 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) external_inputs = None external_cache = profile/'external-source-cache.json' allowed_profile_addons = {'ArmaReforger', 'saves'} if a.external_addons: try: external_rules = external_policy(ROOT) external_inputs = prepare_profile(addons, a.external_addons, external_rules, external_cache) allowed_profile_addons.update('ERRI_'+guid for guid in external_inputs) except (OSError, ValueError) as error: p.error(str(error)) if link.resolve() != vanilla.resolve() or any(x.name not in allowed_profile_addons for x in addons.iterdir()) or any((addons/'saves').rglob('*.gproj')): p.error('Test profile addons must contain only vanilla and the explicit audited external closure') 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/core/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.roadblock_probe_count is not None: command += ['-errRoadblockProbeCount', str(a.roadblock_probe_count)] if a.smoke == 'speech-download': if a.project != 'core' or not a.speech_probe_url or not a.speech_probe_url.startswith('http://127.0.0.1:'): p.error('speech-download requires Core and an explicit loopback --speech-probe-url') command += ['-errSpeechProbeURL', a.speech_probe_url] elif a.speech_probe_url: p.error('--speech-probe-url requires --smoke speech-download') if a.convoy_cover: command += ['-errConvoyCover', '1'] if a.mounted_intercept: command += ['-errMountedIntercept', '1'] if a.mounted_swap_eligibility: command += ['-errMountedSwapEligibility', '1'] if a.mounted_courier: command += ['-errMountedCourier', '1'] if a.mounted_bmp: command += ['-errMountedBMP', '1'] if a.mine_stock: command += ['-errMineStock', '1'] if a.maintenance_refuel: command += ['-errMaintenanceRefuel', '1'] if a.trace_random_radius: command += ['-errTraceRandomRadius', '1'] if a.field_service_cancel: command += ['-errSmokeFieldServiceCancel', '1'] if a.field_terrain_audit: command += ['-errSmokeFieldTerrainAudit', '1'] if a.route_service_cancel: command += ['-errSmokeRouteServiceCancel', '1'] if a.station_teardown_diagnostics: command += ['-errStationTeardownDiag', '1'] if a.flight_input_diagnostics: command += ['-errFlightInputDiag', '1'] if a.flight_action_inputs: command += ['-errFlightActionInputs', '1'] if a.flight_autohover: command += ['-errFlightAutohover', '1'] if a.recon_takeover_only: command += ['-errReconTakeoverOnly', '1'] if a.body_recovery_only: command += ['-errBodyRecoveryOnly', '1'] if a.coordinated_blocked_only: command += ['-errCoordinatedBlockedOnly', '1'] if a.project == 'core': if a.smoke not in {'coordinated-ai', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'self-epinephrine', 'starter-backpack', 'speech-download', 'bundled-speech', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'roadblocks', 'native-end-active', 'native-end-preparing', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', '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 {'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'} and a.project != 'core': p.error('This fixture requires --project core') if a.smoke in {'startup-failure', 'site-survey', 'transport-survey', 'extraction-startup-failure', 'extraction-boundary-failure'} and (a.project != 'core' or a.rounds != 1): p.error('This diagnostic requires --project core --rounds 1') env = os.environ.copy() env.update(STEAM_COMPAT_DATA_PATH=str(compat), STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam), WINEPREFIX=str(compat/'pfx')) # A test-only sink prevents startup sounds; a stream check also catches a saved # explicit Wine device selection. Never change the user's default sink. silent_sink = 'erri_tests_silent' sinks = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'short', 'sinks'], text=True)) if not any(sink['name'] == silent_sink for sink in sinks): subprocess.run(['pactl', 'load-module', 'module-null-sink', 'sink_name='+silent_sink, 'sink_properties=device.description=ERRI_Silent_Tests'], check=True, stdout=subprocess.DEVNULL) subprocess.run(['pactl', 'set-sink-mute', silent_sink, '1'], check=True) subprocess.run(['pactl', 'set-sink-volume', silent_sink, '0%'], check=True) sinks = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'short', 'sinks'], text=True)) silent_index = next(sink['index'] for sink in sinks if sink['name'] == silent_sink) env.update(PULSE_SINK=silent_sink, PULSE_PROP='application.id=org.escapistsrereintroduced.tests') muted_streams = set() def silence_test_streams(): streams = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'sink-inputs'], text=True)) for stream in streams: props = stream.get('properties', {}) owned = props.get('application.id') == 'org.escapistsrereintroduced.tests' pid = props.get('application.process.id', '') if not owned and pid.isdigit(): try: target = str(compat/'pfx').encode() values = (Path('/proc')/pid/'environ').read_bytes().split(b'\0') owned = any(value.startswith(b'WINEPREFIX=') and value[len(b'WINEPREFIX='):].rstrip(b'/') == target for value in values) except (FileNotFoundError, PermissionError, ProcessLookupError): pass if not owned: continue index = str(stream['index']) if stream.get('sink') != silent_index: subprocess.run(['pactl', 'move-sink-input', index, silent_sink], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if not stream.get('mute'): subprocess.run(['pactl', 'set-sink-input-mute', index, '1'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if index not in muted_streams: muted_streams.add(index) print('Test audio routed to silent output and muted (stream '+index+')', flush=True) # Snapshot addon bytes before launching any engine process. captured_sources = snapshot(ROOT, command) recorded_sessions = set() started = time.time() log_root = profile/'logs' previous = set(log_root.glob('logs_*')) if log_root.exists() else set() result, session, complete_at = 'timeout', None, None runner_error = None cleanup_ok = True cleanup_report = None child = None with (profile/'headless-launch.log').open('w') as output: try: child = subprocess.Popen(command, cwd=wb, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True) while time.time()-started < a.timeout: silence_test_streams() candidates = [x for x in log_root.glob('logs_*') if x not in previous] if candidates: session = max(candidates, key=lambda x:x.stat().st_mtime) if session not in recorded_sessions: persist(session, captured_sources) if external_inputs: (session/'external-addons-inputs.json').write_text(json.dumps(external_inputs, indent=2)+'\n') recorded_sessions.add(session) console = session/'console.log' content = console.read_text(errors='replace') if console.exists() else '' if ('Reintroduced: scenario preparation failed' in content and a.smoke not in {'startup-failure', 'extraction-startup-failure', 'extraction-boundary-failure'}) or 'ERR SMOKE FAIL:' in content or 'Virtual Machine Exception' in content or 'Can\'t compile "Game"' in content: result = 'test-failed'; break if content.count('ERR SMOKE COMPLETE:') >= a.rounds: if complete_at is None: complete_at = time.time() if time.time()-complete_at >= 6: result = 'smoke-completed'; break if child.poll() is not None: result = 'process-exited'; break time.sleep(1) except KeyboardInterrupt: result = 'interrupted' except (OSError,ValueError,subprocess.SubprocessError) as error: result = 'runner-failed'; runner_error = str(error) finally: result_before_cleanup = result if child is not None: cleanup_report = cleanup_owned_run(child,proton,env,compat) cleanup_ok = cleanup_report['cleanup_confirmed'] if not cleanup_ok: result = 'cleanup-failed' cleanup_error = '; '.join(cleanup_report['errors']) runner_error = (runner_error + '; ' if runner_error else '') + cleanup_error external_unchanged = True if external_inputs: try: external_unchanged = verify_profile(external_inputs, a.external_addons, external_rules, external_cache, addons) except (OSError, ValueError): external_unchanged = False if not external_unchanged: result = 'external-inputs-changed' stopped_evidence = seal_run(ROOT, session, captured_sources) if cleanup_ok and session else {} if stopped_evidence and not stopped_evidence['source_unchanged_after_run']: result = 'source-changed' summary = {**stopped_evidence, **bindings(captured_sources), 'external_addons_unchanged_after_run':external_unchanged, 'external_addon_count':len(external_inputs or {}), 'external_addon_inputs':'external-addons-inputs.json' if external_inputs else None, 'started_unix':started, 'result':result, 'result_before_cleanup':result_before_cleanup, 'cleanup_confirmed':cleanup_ok, 'cleanup_report':cleanup_report, 'runner_error':runner_error, 'session':str(session) if session else None, 'rounds_required':a.rounds, 'project':a.project, 'display':'headless Gamescope 1280x720/30Hz', 'dedicated_server':False, 'audio':'silent sink and per-test-stream mute'} if session: (session/'runner.json').write_text(json.dumps(summary, indent=2)+'\n') shutil.copyfile(profile/'headless-launch.log', session/'headless-launch.log') print(json.dumps(summary, indent=2), flush=True) if result != 'smoke-completed': raise SystemExit(1) # All native errors still fail the report even when the scripted sequence completed. raise SystemExit(subprocess.call(['python3', str(ROOT/'tools/native_report.py'), str(session/'console.log'), '--require-completions', str(a.rounds)]))