Exercise ordinary input in private display and retain inventory UI failure diagnosis

This commit is contained in:
2026-09-19 17:06:04 +02:00
parent 5a24eea2f5
commit 5c7719c19c
65 changed files with 9092 additions and 12 deletions
+173
View File
@@ -630,6 +630,18 @@ def validate_input_observer(a):
refuse('Input observation requires --settle >=50 for the bounded 45-second sampler')
def validate_input_exercise(a):
if not getattr(a, 'exercise_player_input', False):
return
if (a.project not in {'core', 'chat'} or getattr(a, 'packed_core', None)
or getattr(a, 'packed_addons', None) or getattr(a, 'packed_session', None)):
refuse('Input exercise requires loose Core or Chat')
if not getattr(a, 'observe_player_input', False) or not getattr(a, 'inspect_headless_display', False):
refuse('Input exercise requires --observe-player-input and --inspect-headless-display')
if a.settle < 50:
refuse('Input exercise requires --settle >=50')
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')
@@ -743,11 +755,35 @@ def input_observer_evidence(content, nonce):
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')
# Native movement proof belongs to the observer, independently of whether
# an external helper claims to have sent keys. Restrict it to READY→open.
movement = {'action_observed': False, 'physical_motion_observed': False,
'released_input_observed': False,
'max_horizontal_displacement': 0.0}
if len(ready) == 1:
baseline = by_index.get(ready[0]['sample'])
end_index = opens[0]['sample'] if len(opens) == 1 else 0
if baseline and end_index:
for sample in samples:
if not ready[0]['sample'] < sample['sample'] <= end_index:
continue
distance = math.hypot(sample['origin'][0]-baseline['origin'][0], sample['origin'][2]-baseline['origin'][2])
movement['max_horizontal_displacement'] = max(movement['max_horizontal_displacement'], distance)
if sample['forward'] > 0.1 and math.hypot(sample['input'][0], sample['input'][2]) > 0.1:
movement['action_observed'] = True
if distance > 0.05 and math.hypot(sample['velocity'][0], sample['velocity'][2]) > 0.05:
movement['physical_motion_observed'] = True
if len(closes) == 1:
movement['released_input_observed'] = any(
sample['sample'] > closes[0]['sample'] and abs(sample['forward']) <= 0.01
and math.sqrt(sum(value*value for value in sample['input'])) <= 0.01
for sample in samples)
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],
'movement': movement,
'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'}
@@ -760,7 +796,132 @@ def input_observer_result(result, content, nonce):
return result, evidence
def input_exercise_command(command, root, compat, profile, executable, run_id, host_display, display_context):
"""Put the input supervisor around the display inspector, inside Gamescope."""
separator = command.index('--')
if command[:separator].count('--backend') != 1 or command[command.index('--backend')+1] != 'headless':
refuse('Input exercise requires the explicit headless Gamescope boundary')
helper = root/'tools/headless_input_proof.py'
ownership = root/'tools/headless_display_probe.py'
child = command[separator+1:]
if (not display_context or len(child) < 3 or child[:2] != [sys.executable, str(ownership)]
or not re.fullmatch(r'[A-Za-z0-9]{1,32}', run_id)):
refuse('Input exercise requires the existing scoped display wrapper and observer nonce')
report = profile/('input-proof-'+run_id+'.json')
if helper.is_symlink() or not helper.is_file() or report.exists() or report.is_symlink():
refuse('Input exercise requires a regular helper and a new report')
context = {'report': str(report), 'helper_sha256': hashlib.sha256(helper.read_bytes()).hexdigest(),
'ownership_helper_sha256': hashlib.sha256(ownership.read_bytes()).hexdigest(),
'prefix': str(compat/'pfx'), 'profile': str(profile), 'game_executable': str(executable),
'command': child, 'nonce': run_id, 'display_report': display_context['report']}
wrapper = [sys.executable, str(helper), '--enable-private-keyboard-proof', '--host-display', host_display,
'--prefix', context['prefix'], '--profile', str(profile), '--game-executable', str(executable),
'--report', str(report), '--nonce', run_id, '--']
return command[:separator+1] + wrapper + child, context
def input_exercise_poll_failure(context):
"""Read only a complete, context-bound failure; an in-progress write is pending."""
if not context:
return False
try:
source = Path(context['report'])
if source.is_symlink() or not source.is_file() or not 0 < source.stat().st_size <= 1024*1024:
return False
with source.open('rb') as stream:
raw = stream.read(1024*1024+1)
if len(raw) > 1024*1024:
return False
report = json.loads(raw)
if not isinstance(report, dict) or report.get('schema') != 1 or report.get('kind') != 'headless-private-keyboard-experiment':
return False
for key in ('helper_sha256', 'ownership_helper_sha256', 'prefix', 'profile', 'game_executable', 'report', 'command', 'nonce'):
if report.get(key) != context[key]:
return False
return bool(report.get('error') or report.get('supervision_failed') is True or (
report.get('sequence_completed') is False
and report.get('whole_process_group_cleanup_verified') is False
and type(report.get('input_sent')) is bool))
except (OSError, ValueError, TypeError, KeyError):
return False
def input_exercise_poll_result(context, outcome, native_failure):
if not input_exercise_poll_failure(context):
return outcome
# Native failures already latched by StartupMonitor remain authoritative,
# even during its diagnostic grace period. Never wait for settle on failure.
if native_failure:
return 'game-failed'
if outcome is not None and outcome != 'game-started':
return outcome
return 'input-exercise-failed'
def input_exercise_result(result, context, session, observation, display_result):
"""Bind immutable helper bytes and require independent native player evidence."""
proof = {'requested': True, 'verified': False, 'input_sent': None, 'gameplay_qualified': False,
'scope': 'bounded private keyboard delivery plus native movement/inventory observations; no full escape or multiplayer qualification'}
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 input report')
raw = source.read_bytes()
proof['sha256'] = hashlib.sha256(raw).hexdigest()
if not session:
refuse('Input experiment lacks an archived native session')
with (session/'input-proof.json').open('xb') as output:
output.write(raw)
proof['archive_file'] = 'input-proof.json'
report = json.loads(raw)
if not isinstance(report, dict):
refuse('Input report is not an object')
for key in ('helper_sha256', 'ownership_helper_sha256', 'prefix', 'profile', 'game_executable', 'report', 'command', 'nonce'):
if report.get(key) != context[key]:
refuse('Input report binding mismatch: '+key)
if type(report.get('input_sent')) is bool:
proof['input_sent'] = report['input_sent']
if (report.get('schema') != 1 or report.get('kind') != 'headless-private-keyboard-experiment'
or report.get('sequence_completed') is not True or report.get('input_sent') is not True
or report.get('hard_supervision') is not True or report.get('supervision_failed')
or report.get('all_attempted_presses_released') is not True
or report.get('error') or report.get('paired_release_errors') != [] or report.get('unconfirmed_held_key') is not None):
refuse('Private keyboard sequence did not complete with confirmed paired release')
import math
events = report.get('events', [])
expected = [('W', True), ('W', False), ('Tab', True), ('Tab', False), ('Tab', True), ('Tab', False)]
if (not isinstance(events, list) or len(events) != len(expected)
or any(not isinstance(e, dict) or type(e.get('down')) is not bool or (e.get('key'), e['down']) != pair or e.get('finally') for e, pair in zip(events, expected))):
refuse('Input experiment does not contain the exact fixed key pairs')
times = [e.get('monotonic') for e in events]
if any(type(t) not in {int, float} or not math.isfinite(t) or t < 0 for t in times) or times != sorted(times) or times[-1]-times[0] > 25:
refuse('Input event clock is invalid or outside its bound')
if not display_result or display_result.get('verified') is not True:
refuse('Input experiment lacks verified private display ownership')
display = json.loads((session/'display-ownership.json').read_bytes())
if report.get('binding') != display.get('binding') or report.get('owner') not in [
{key: window[key] for key in ('window', 'pid', 'server_pid', 'process')} for window in display.get('owned_windows', [])]:
refuse('Input owner does not match the independently observed private game window')
native = report.get('observer', {})
if (report.get('console') != str(session/'console.log')
or native.get('events') != ['READY', 'INVENTORY_OPEN', 'INVENTORY_CLOSED']):
refuse('Input experiment lacks the exact native session/transition handshake')
if (not observation or observation.get('nonce') != context['nonce'] or not observation.get('sampler_complete')
or not observation.get('inventory_open_observed') or not observation.get('inventory_close_observed')
or not observation.get('movement', {}).get('action_observed')
or not observation.get('movement', {}).get('physical_motion_observed')
or not observation.get('movement', {}).get('released_input_observed')):
refuse('Native movement and inventory open/close proof is incomplete')
proof['verified'] = True
except (OSError, ValueError, TypeError, AttributeError, KeyError) as error:
proof['error'] = str(error)
if result == 'game-started' and not proof['verified']:
result = 'input-exercise-incomplete'
return result, proof
def run(a):
validate_input_exercise(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):
@@ -824,6 +985,9 @@ def run_locked(a, steam, compat, profile, packed=None):
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', ''))
exercise_context = None
if getattr(a, 'exercise_player_input', False):
command, exercise_context = input_exercise_command(command, ROOT, compat, profile, executable, run_id, os.environ.get('DISPLAY', ''), display_context)
silent_sink = 'erri_game_'+run_id
application_id = 'org.escapistsrereintroduced.game.'+run_id
env = os.environ.copy()
@@ -878,6 +1042,7 @@ def run_locked(a, steam, compat, profile, packed=None):
content = console.read_text(errors='replace') if console.is_file() else ''
evidence = packed_evidence(content, packed, steam, profile) if packed else game_evidence(content, a.project)
outcome = monitor.observe(evidence, time.monotonic(), child.poll() is not None)
outcome = input_exercise_poll_result(exercise_context, outcome, monitor.first_failure_at is not None)
if outcome is not None:
result = outcome; break
# Grace never extends the original absolute run deadline.
@@ -935,6 +1100,11 @@ def run_locked(a, steam, compat, profile, packed=None):
if observation_nonce:
result, observation_result = input_observer_result(result, content, observation_nonce)
final['input_observation'] = observation_result
exercise_result = None
if exercise_context:
result, exercise_result = input_exercise_result(result, exercise_context, session, observation_result, display_result)
final['input_exercise'] = exercise_result
final['scope'] = 'ordinary game startup with an explicitly requested bounded private keyboard experiment; see input_exercise for sent-input and native-proof results; no full escape, multiplayer or FPS qualification'
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,
@@ -949,6 +1119,8 @@ def run_locked(a, steam, compat, profile, packed=None):
'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 exercise_result is not None:
summary['input_exercise'] = exercise_result
if display_result is not None:
summary['display_ownership'] = display_result
summary['startup_result_before_display_check'] = startup_result
@@ -979,6 +1151,7 @@ def main():
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('--exercise-player-input', action='store_true', help='Explicit bounded private W/Tab keyboard experiment; requires observer, display inspection, loose Core/Chat and settle >=50')
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')