Exercise ordinary input in private display and retain inventory UI failure diagnosis
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user