Improve base guidance and qualify bounded aircraft route and hidden test lifecycle
This commit is contained in:
+301
-9
@@ -12,6 +12,7 @@ import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from native_provenance import snapshot, persist, bindings, seal_run
|
||||
@@ -309,7 +310,7 @@ def prefix_process_candidate(proc):
|
||||
|
||||
|
||||
def assert_prefix_idle(compat, proc_root=Path('/proc')):
|
||||
target = ('WINEPREFIX='+str(compat/'pfx')).encode()
|
||||
target = str(compat/'pfx').encode()
|
||||
failures = []
|
||||
for proc in proc_root.iterdir():
|
||||
if not proc.name.isdigit() or int(proc.name) == os.getpid():
|
||||
@@ -325,7 +326,9 @@ def assert_prefix_idle(compat, proc_root=Path('/proc')):
|
||||
except (OSError, ValueError) as error:
|
||||
failures.append(f'Cannot verify disposable prefix is idle (PID {proc.name}): {error}')
|
||||
continue
|
||||
if target in environment:
|
||||
# Proton assigns pfx/; only trailing separators are equivalent. Do not
|
||||
# resolve untrusted process paths or accept sibling/symlink spellings.
|
||||
if any(value.startswith(b'WINEPREFIX=') and value[len(b'WINEPREFIX='):].rstrip(b'/') == target for value in environment):
|
||||
failures.append(f'Disposable prefix already has a process (PID {proc.name}); leave it untouched')
|
||||
if failures:
|
||||
refuse('\n'.join(failures))
|
||||
@@ -447,6 +450,42 @@ def cleanup(child, proton, env):
|
||||
child.wait(timeout=10)
|
||||
|
||||
|
||||
def cleanup_owned_run(child, proton, env, compat):
|
||||
"""After a launched owned session, while the caller still holds the prefix lock.
|
||||
|
||||
Noncanonical private prefixes retain normal shutdown plus strict idle proof;
|
||||
adopted-helper signalling is restricted to its one explicitly owned prefix.
|
||||
"""
|
||||
report = {'cleanup_confirmed':False, 'idle_verified':False, 'errors':[],
|
||||
'orphan_cleanup':{'attempted':False,'reason':'noncanonical-private-prefix'}}
|
||||
try:
|
||||
cleanup(child,proton,env)
|
||||
except (OSError,ValueError,subprocess.SubprocessError) as error:
|
||||
report['errors'].append('normal shutdown: '+str(error))
|
||||
canonical = Path('/tmp/erri-headless-compat')
|
||||
if Path(compat) == canonical and Path(compat).resolve() == canonical:
|
||||
report['orphan_cleanup'] = {'attempted':True}
|
||||
try:
|
||||
from private_prefix_cleanup import cleanup_private_prefix
|
||||
details = cleanup_private_prefix(compat,proton)
|
||||
report['orphan_cleanup']['report'] = details
|
||||
if not isinstance(details,dict) or details.get('cleared') is not True:
|
||||
raise ValueError('Private helper cleanup did not prove completion')
|
||||
except (OSError,ValueError,ImportError,subprocess.SubprocessError) as error:
|
||||
report['orphan_cleanup']['error'] = str(error)
|
||||
details = getattr(error,'report',None)
|
||||
if details is not None:
|
||||
report['orphan_cleanup']['report'] = details
|
||||
report['errors'].append('private helper cleanup: '+str(error))
|
||||
try:
|
||||
assert_prefix_idle(compat)
|
||||
report['idle_verified'] = True
|
||||
except (OSError,ValueError,subprocess.SubprocessError) as error:
|
||||
report['errors'].append('idle verification: '+str(error))
|
||||
report['cleanup_confirmed'] = report['idle_verified'] and not report['errors']
|
||||
return report
|
||||
|
||||
|
||||
class StartupMonitor:
|
||||
"""Latch failures while allowing a bounded diagnostic drain; no native I/O."""
|
||||
def __init__(self, started, timeout, settle, failure_grace=0):
|
||||
@@ -494,10 +533,240 @@ class StartupMonitor:
|
||||
return final
|
||||
|
||||
|
||||
def display_probe_command(command, root, compat, profile, executable, run_id, host_display):
|
||||
"""Wrap only the existing child of headless Gamescope; preserve Proton argv."""
|
||||
separator = command.index('--')
|
||||
if command[:separator].count('--backend') != 1 or command[command.index('--backend')+1] != 'headless':
|
||||
refuse('Display inspection requires the explicit headless Gamescope boundary')
|
||||
helper = root/'tools/headless_display_probe.py'
|
||||
report = profile/('display-ownership-'+run_id+'.json')
|
||||
if helper.is_symlink() or not helper.is_file() or report.exists() or report.is_symlink():
|
||||
refuse('Display inspection requires a regular helper and a new report')
|
||||
child = command[separator+1:]
|
||||
context = {'report': str(report), 'helper_sha256': hashlib.sha256(helper.read_bytes()).hexdigest(),
|
||||
'prefix': str(compat/'pfx'), 'profile': str(profile), 'game_executable': str(executable),
|
||||
'command': child, 'host_display': host_display}
|
||||
wrapper = [sys.executable, str(helper), '--host-display', host_display,
|
||||
'--prefix', context['prefix'], '--profile', str(profile), '--game-executable', str(executable),
|
||||
'--report', str(report), '--observe-seconds', '30', '--']
|
||||
return command[:separator+1] + wrapper + child, context
|
||||
|
||||
|
||||
def display_probe_result(context, session):
|
||||
"""Retain exact observation bytes after cleanup; an absent/unverified report fails closed."""
|
||||
result = {'requested': True, 'verified': False, 'read_only': True, 'input_sent': False}
|
||||
try:
|
||||
source = Path(context['report'])
|
||||
if source.is_symlink() or not source.is_file() or not 0 < source.stat().st_size <= 1024*1024:
|
||||
refuse('Missing or invalid bounded display ownership report')
|
||||
raw = source.read_bytes()
|
||||
report = json.loads(raw)
|
||||
result['sha256'] = hashlib.sha256(raw).hexdigest()
|
||||
if session:
|
||||
with (session/'display-ownership.json').open('xb') as output:
|
||||
output.write(raw)
|
||||
result['archive_file'] = 'display-ownership.json'
|
||||
if not isinstance(report, dict):
|
||||
refuse('Display ownership report is not an object')
|
||||
for key in ('helper_sha256', 'prefix', 'profile', 'game_executable', 'report', 'command'):
|
||||
if report.get(key) != context[key]:
|
||||
refuse('Display report binding mismatch: '+key)
|
||||
if (report.get('schema') != 1 or report.get('kind') != 'headless-display-read-only'
|
||||
or report.get('read_only') is not True or report.get('input_sent') is not False
|
||||
or report.get('observation_finished') is not True or report.get('owned_game_window_verified') is not True):
|
||||
refuse('Private display game-window ownership was not verified')
|
||||
from headless_display_probe import validate_display, shared_relationship_valid
|
||||
bound = report.get('binding', {})
|
||||
number = validate_display(bound.get('display'), context['host_display'])
|
||||
sockets = bound.get('sockets')
|
||||
allowed = {f'/tmp/.X11-unix/X{number}', f'@/tmp/.X11-unix/X{number}'}
|
||||
if not isinstance(sockets, list) or not sockets or len(sockets) > 2:
|
||||
refuse('Missing private X11 socket ownership')
|
||||
paths = set()
|
||||
for sock in sockets:
|
||||
if not isinstance(sock, dict) or sock.get('path') not in allowed or sock['path'] in paths or type(sock.get('inode')) is not int or sock['inode'] <= 0:
|
||||
refuse('Invalid private X11 socket ownership')
|
||||
paths.add(sock['path'])
|
||||
for identity in [report.get('wrapper'), bound.get('gamescope'), bound.get('xwayland')]:
|
||||
if not isinstance(identity, dict) or identity.get('uid') != os.getuid() or any(type(identity.get(k)) is not int or identity[k] <= 0 for k in ('pid', 'start')):
|
||||
refuse('Missing owned process binding')
|
||||
relationship = bound.get('relationship')
|
||||
if not isinstance(relationship, dict):
|
||||
refuse('Missing private display process relationship')
|
||||
if relationship.get('mode') == 'descendant':
|
||||
if relationship.get('descendant') is not True:
|
||||
refuse('Native descendant relationship was not verified')
|
||||
elif relationship.get('mode') == 'shared-listeners':
|
||||
checks = relationship.get('checks')
|
||||
if (not shared_relationship_valid(checks) or
|
||||
checks['listener_inodes'] != [sock['inode'] for sock in sockets] or
|
||||
bound['xwayland']['start'] < bound['gamescope']['start']):
|
||||
refuse('Shared-listener process relationship was not verified')
|
||||
else:
|
||||
refuse('Unknown private display process relationship')
|
||||
windows = report.get('owned_windows')
|
||||
if not isinstance(windows, list) or not windows:
|
||||
refuse('No verified game window records')
|
||||
for window in windows:
|
||||
identity = window.get('process', {})
|
||||
if (type(window.get('window')) is not int or window['window'] <= 0
|
||||
or type(window.get('server_pid')) is not int or type(identity.get('pid')) is not int
|
||||
or identity.get('uid') != os.getuid() or type(identity.get('start')) is not int or identity['start'] <= 0
|
||||
or type(window.get('pid')) is not int or window['pid'] <= 0
|
||||
or window['pid'] != window.get('server_pid') or window['pid'] != identity.get('pid')):
|
||||
refuse('Window lacks consistent native and server process ownership')
|
||||
result['verified'] = True
|
||||
except (OSError, ValueError, TypeError, AttributeError) as error:
|
||||
result['error'] = str(error)
|
||||
return result
|
||||
|
||||
|
||||
def validate_input_observer(a):
|
||||
if not getattr(a, 'observe_player_input', False):
|
||||
return
|
||||
if a.project == CONTROL or getattr(a, 'packed_core', None) or getattr(a, 'packed_addons', None):
|
||||
refuse('Input observation requires loose Core or Chat, not packed input or vanilla control')
|
||||
if a.settle < 50:
|
||||
refuse('Input observation requires --settle >=50 for the bounded 45-second sampler')
|
||||
|
||||
|
||||
def input_observer_command(command, project, packed, nonce):
|
||||
if project not in {'core', 'chat'} or packed is not None:
|
||||
refuse('Input observation requires loose Core or Chat')
|
||||
if not re.fullmatch(r'[A-Za-z0-9]{1,32}', nonce):
|
||||
refuse('Input observer nonce must contain 1..32 ASCII letters/digits')
|
||||
if any(value.startswith('-errOrdinaryInput') for value in command):
|
||||
refuse('Input observer command already has observer options')
|
||||
return command + ['-errOrdinaryInputObserve', nonce, '-errOrdinaryInputScreenshots', '1']
|
||||
|
||||
|
||||
def input_observer_evidence(content, nonce):
|
||||
"""Strictly summarize native sampler records; never infer input delivery."""
|
||||
import math
|
||||
errors, records, samples = [], [], []
|
||||
prefix = 'ERR INPUT OBSERVE: '
|
||||
number = r'-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?'
|
||||
header = re.compile(r'run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) world=('+number+r') (.+)')
|
||||
sample_pattern = re.compile(r'SAMPLE origin=(.+?) forward=('+number+r') input=(.+?) velocity=(.+?) inventory=(0|1|true|false)')
|
||||
def vector_value(value):
|
||||
if value.startswith('<') and value.endswith('>'):
|
||||
value = value[1:-1]
|
||||
if not re.fullmatch(number+r'(?:\s*,\s*|\s+)'+number+r'(?:\s*,\s*|\s+)'+number, value.strip()):
|
||||
raise ValueError('invalid vector')
|
||||
result = [float(x) for x in re.findall(number, value)]
|
||||
if not all(math.isfinite(x) for x in result):
|
||||
raise ValueError('nonfinite vector')
|
||||
return result
|
||||
lines = [line.split(prefix, 1)[1].strip() for line in content.splitlines() if prefix in line]
|
||||
if not re.fullmatch(r'[A-Za-z0-9]{1,32}', nonce):
|
||||
errors.append('invalid expected nonce')
|
||||
if len(lines) > 460:
|
||||
errors.append('observer record bound exceeded')
|
||||
for line in lines[:460]:
|
||||
match = header.fullmatch(line)
|
||||
if not match:
|
||||
errors.append('malformed observer record')
|
||||
continue
|
||||
actual, index, real, world, event = match.groups()
|
||||
index, real, world = int(index), int(real), float(world)
|
||||
if actual != nonce:
|
||||
errors.append('observer nonce mismatch')
|
||||
continue
|
||||
if not math.isfinite(world) or world < 0 or index > 450 or real > 2147483647:
|
||||
errors.append('observer sample/clock bound invalid')
|
||||
continue
|
||||
record = {'sample': index, 'real_ms': real, 'world_ms': world, 'event': event}
|
||||
if records and (real < records[-1]['real_ms'] or world < records[-1]['world_ms'] or index < records[-1]['sample']):
|
||||
errors.append('observer records reversed')
|
||||
if event.startswith('SAMPLE '):
|
||||
sample = sample_pattern.fullmatch(event)
|
||||
if not sample:
|
||||
errors.append('malformed observer sample')
|
||||
continue
|
||||
origin, forward, movement, velocity, opened = sample.groups()
|
||||
try:
|
||||
value = {**record, 'origin': vector_value(origin), 'forward': float(forward),
|
||||
'input': vector_value(movement), 'velocity': vector_value(velocity),
|
||||
'inventory': opened in {'1', 'true'}}
|
||||
if not math.isfinite(value['forward']):
|
||||
raise ValueError('nonfinite forward')
|
||||
except ValueError:
|
||||
errors.append('invalid observer sample values')
|
||||
continue
|
||||
if index != len(samples)+1:
|
||||
errors.append('observer sample sequence is not contiguous')
|
||||
samples.append(value)
|
||||
elif event not in {'QUEUED', 'READY', 'INVENTORY_OPEN', 'INVENTORY_CLOSED',
|
||||
'CAPTURE_REQUEST baseline', 'CAPTURE_REQUEST open', 'CAPTURE_REQUEST closed',
|
||||
'FINISHED observation-bound', 'FINISHED sample-bound'} and not re.fullmatch(r'CANCELLED [a-z-]+', event):
|
||||
errors.append('unknown observer event')
|
||||
elif index != len(samples):
|
||||
errors.append('observer transition sample does not match last sample')
|
||||
records.append(record)
|
||||
def matching(name):
|
||||
return [r for r in records if r['event'] == name or r['event'].startswith(name+' ')]
|
||||
queued, ready, finished, cancelled = (matching(name) for name in ('QUEUED', 'READY', 'FINISHED', 'CANCELLED'))
|
||||
if len(queued) != 1 or not records or records[0] not in queued or records[0]['sample'] != 0:
|
||||
errors.append('missing/duplicate/misordered QUEUED')
|
||||
if len(ready) != 1:
|
||||
errors.append('missing/duplicate READY')
|
||||
if len(finished) != 1 or not records or records[-1] not in finished:
|
||||
errors.append('missing/duplicate/misordered FINISHED')
|
||||
if cancelled:
|
||||
errors.append('observer cancelled')
|
||||
if not samples:
|
||||
errors.append('no native samples')
|
||||
if queued:
|
||||
start = queued[0]['real_ms']
|
||||
if any(s['real_ms'] < start or s['real_ms']-start >= 45000 for s in samples):
|
||||
errors.append('native sample outside real-time observation window')
|
||||
if ready and (not samples or ready[0]['real_ms'] < samples[0]['real_ms']+2000):
|
||||
errors.append('READY before stationary baseline interval')
|
||||
if finished:
|
||||
end = finished[0]
|
||||
if end['event'] == 'FINISHED observation-bound' and end['real_ms']-start < 45000:
|
||||
errors.append('FINISHED before real-time bound')
|
||||
if end['event'] == 'FINISHED sample-bound' and len(samples) != 450:
|
||||
errors.append('FINISHED before sample bound')
|
||||
opens, closes = matching('INVENTORY_OPEN'), matching('INVENTORY_CLOSED')
|
||||
if len(opens) > 1 or len(closes) > 1:
|
||||
errors.append('duplicate inventory transition')
|
||||
by_index = {s['sample']: s for s in samples}
|
||||
for transition, expected in [(r, True) for r in opens] + [(r, False) for r in closes]:
|
||||
sample = by_index.get(transition['sample'])
|
||||
if not ready or records.index(transition) < records.index(ready[0]) or not sample or sample['inventory'] is not expected:
|
||||
errors.append('inventory transition lacks matching post-READY sample')
|
||||
if closes and (not opens or records.index(closes[0]) <= records.index(opens[0])):
|
||||
errors.append('inventory close lacks prior observed open')
|
||||
captures = matching('CAPTURE_REQUEST')
|
||||
for suffix, transition in [('baseline', ready), ('open', opens), ('closed', closes)]:
|
||||
requested = [r for r in captures if r['event'] == 'CAPTURE_REQUEST '+suffix]
|
||||
if len(requested) > 1 or (requested and (not transition or records.index(requested[0]) <= records.index(transition[0]) or requested[0]['sample'] != transition[0]['sample'])):
|
||||
errors.append('capture request lacks its unique transition')
|
||||
return {'nonce': nonce, 'read_only': True, 'input_sent': False, 'sampler_complete': not errors,
|
||||
'queued': len(queued) == 1, 'ready': len(ready) == 1, 'finished': len(finished) == 1,
|
||||
'cancelled': bool(cancelled), 'sample_count': len(samples), 'record_count': len(lines),
|
||||
'inventory_open_observed': len(opens) == 1, 'inventory_close_observed': len(closes) == 1,
|
||||
'capture_requests': [r['event'].split()[-1] for r in captures],
|
||||
'transitions': [r for r in records if not r['event'].startswith('SAMPLE ')],
|
||||
'errors': list(dict.fromkeys(errors)),
|
||||
'scope': 'native read-only sampler lifecycle only; no generated input, movement success, inventory usability, screenshot file verification, manual playtest or multiplayer claim'}
|
||||
|
||||
|
||||
def input_observer_result(result, content, nonce):
|
||||
evidence = input_observer_evidence(content, nonce)
|
||||
if result == 'game-started' and not evidence['sampler_complete']:
|
||||
result = 'observation-incomplete'
|
||||
return result, evidence
|
||||
|
||||
|
||||
def run(a):
|
||||
validate_input_observer(a)
|
||||
packed = packed_request(a)
|
||||
if getattr(a, 'capture_prison_view', False) and (a.project == CONTROL or packed is not None):
|
||||
refuse('Prison view capture requires loose successor source with its opt-in observer')
|
||||
if getattr(a, 'inspect_headless_display', False) and (a.project == CONTROL or packed is not None):
|
||||
refuse('Display ownership inspection currently requires loose Core or Chat')
|
||||
steam, compat = a.steam.resolve(), a.compat.resolve()
|
||||
profile = packed['session']/'profile' if packed else a.profile.resolve()
|
||||
if not 60 <= a.timeout <= 1800 or not 5 <= a.settle <= 120:
|
||||
@@ -549,6 +818,12 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
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
|
||||
observation_nonce = run_id if getattr(a, 'observe_player_input', False) else None
|
||||
if observation_nonce:
|
||||
command = input_observer_command(command, a.project, packed, observation_nonce)
|
||||
display_context = None
|
||||
if getattr(a, 'inspect_headless_display', False):
|
||||
command, display_context = display_probe_command(command, ROOT, compat, profile, executable, run_id, os.environ.get('DISPLAY', ''))
|
||||
silent_sink = 'erri_game_'+run_id
|
||||
application_id = 'org.escapistsrereintroduced.game.'+run_id
|
||||
env = os.environ.copy()
|
||||
@@ -565,6 +840,7 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
result = 'timeout'
|
||||
runner_error = None
|
||||
cleanup_ok = True
|
||||
cleanup_report = None
|
||||
log_root = profile/'logs'
|
||||
previous = set(log_root.glob('logs_*'))
|
||||
launch_log = profile/('game-launch-'+run_id+'.log')
|
||||
@@ -618,13 +894,12 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
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
|
||||
cleanup_report = cleanup_owned_run(child,proton,env,compat)
|
||||
cleanup_ok = cleanup_report['cleanup_confirmed']
|
||||
if not cleanup_ok:
|
||||
result = 'cleanup-failed'
|
||||
runner_error = str(error)
|
||||
cleanup_error = '; '.join(cleanup_report['errors'])
|
||||
runner_error = (runner_error + '; ' if runner_error else '') + cleanup_error
|
||||
if sink_module is not None:
|
||||
try:
|
||||
subprocess.run(['pactl', 'unload-module', sink_module], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
|
||||
@@ -650,10 +925,20 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
result = 'vanilla-control-started'
|
||||
if packed and result == 'game-started':
|
||||
result = 'packed-'+packed.get('project', 'core')+'-started' if final.get('ready') is True else 'packed-evidence-incomplete'
|
||||
display_result = None
|
||||
startup_result = result
|
||||
if display_context:
|
||||
display_result = display_probe_result(display_context, session)
|
||||
if not display_result['verified'] and result == 'game-started':
|
||||
result = 'display-probe-incomplete'
|
||||
observation_result = None
|
||||
if observation_nonce:
|
||||
result, observation_result = input_observer_result(result, content, observation_nonce)
|
||||
final['input_observation'] = observation_result
|
||||
source_bindings = {} if packed else bindings(captured)
|
||||
summary = {**sealed, **source_bindings, 'started_unix': started, 'result': result,
|
||||
'session': str(session) if session else None, 'project': a.project,
|
||||
'cleanup_confirmed': cleanup_ok, 'runner_error': runner_error,
|
||||
'cleanup_confirmed': cleanup_ok, 'cleanup_report':cleanup_report, '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,
|
||||
@@ -662,6 +947,11 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
'control_only': a.project == CONTROL,
|
||||
'source_manifest_scope': 'none: packed input provenance is separate; loose source manifests cannot qualify this run' if packed else 'successor addon snapshot retained for provenance bookkeeping only; none loaded by vanilla control' if a.project == CONTROL else 'successor addon sources',
|
||||
'scope': final.get('scope', 'ordinary game compile/startup; no scripted fixture, full escape or multiplayer acceptance')}
|
||||
if observation_result is not None:
|
||||
summary['input_observation'] = observation_result
|
||||
if display_result is not None:
|
||||
summary['display_ownership'] = display_result
|
||||
summary['startup_result_before_display_check'] = startup_result
|
||||
if packed:
|
||||
summary['packed_consumption_verified'] = result == 'packed-'+packed.get('project', 'core')+'-started' and cleanup_ok and final.get('ready') is True and sealed['packed_inputs_unchanged_after_run']
|
||||
final['packed_consumption_verified'] = summary['packed_consumption_verified']
|
||||
@@ -686,7 +976,9 @@ def main():
|
||||
packed_options.add_argument('--packed-addons', type=Path, help='Unqualified complete core/ai/intel/map/chat packed directories; requires --project chat')
|
||||
parser.add_argument('--packed-session', type=Path, help='New /tmp/erri-packed-core-<name> or erri-packed-chat-<name> session; owns a fresh input copy and profile')
|
||||
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('--inspect-headless-display', action='store_true', help='Read-only ownership inspection within the hidden display; no input sent, loose Core/Chat only')
|
||||
parser.add_argument('--capture-prison-view', action='store_true', help='Opt-in single genuine local prison screenshot; loose successor source only, no camera or input changes')
|
||||
parser.add_argument('--observe-player-input', action='store_true', help='Read-only native local-input sampler; loose Core/Chat only, requires --settle >=50; sends no input')
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user