Verify private relative mouse input against native player camera
This commit is contained in:
+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_radio_waiting_text.py', 'test_inventory_control_runner.py', 'test_inventory_layout.py', 'test_headless_input_proof.py', 'test_headless_input_exercise_runner.py', 'test_private_prefix_cleanup.py', 'test_runner_private_cleanup.py', 'test_ordinary_input_runner.py', 'test_ordinary_input_observer.py', 'test_aircraft_route_envelope.py', 'test_aircraft_route_policy.py', 'test_aircraft_route_probe.py', 'test_rough_intel_feedback.py', 'test_headless_display_probe.py', 'test_headless_display_runner.py', 'test_preparation_status.py', 'test_hint_delivery.py', 'test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','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_vanilla_game_control.py','test_archive_game_control.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_progression_identity.py','test_discovery_map_position.py','test_local_admission.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']:
|
||||
for name in ['test_ordinary_look_observer.py', 'test_ordinary_look_evidence.py', 'test_relative_look_runner.py', 'test_radio_waiting_text.py', 'test_inventory_control_runner.py', 'test_inventory_layout.py', 'test_headless_input_proof.py', 'test_headless_input_exercise_runner.py', 'test_private_prefix_cleanup.py', 'test_runner_private_cleanup.py', 'test_ordinary_input_runner.py', 'test_ordinary_input_observer.py', 'test_aircraft_route_envelope.py', 'test_aircraft_route_policy.py', 'test_aircraft_route_probe.py', 'test_rough_intel_feedback.py', 'test_headless_display_probe.py', 'test_headless_display_runner.py', 'test_preparation_status.py', 'test_hint_delivery.py', 'test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','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_vanilla_game_control.py','test_archive_game_control.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_progression_identity.py','test_discovery_map_position.py','test_local_admission.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()
|
||||
|
||||
+40
-8
@@ -631,6 +631,8 @@ def validate_input_observer(a):
|
||||
|
||||
|
||||
def validate_input_exercise(a):
|
||||
if getattr(a, 'exercise_relative_look', False) and not getattr(a, 'exercise_player_input', False):
|
||||
refuse('Relative look requires the existing explicit keyboard exercise')
|
||||
if not getattr(a, 'exercise_player_input', False):
|
||||
return
|
||||
if (a.project not in {'core', 'chat'} or getattr(a, 'packed_core', None)
|
||||
@@ -642,14 +644,19 @@ def validate_input_exercise(a):
|
||||
refuse('Input exercise requires --settle >=50')
|
||||
|
||||
|
||||
def input_observer_command(command, project, packed, nonce):
|
||||
def input_observer_command(command, project, packed, nonce, look=False):
|
||||
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):
|
||||
if type(look) is not bool:
|
||||
refuse('Relative look selection must be boolean')
|
||||
if any(value.startswith(('-errOrdinaryInput', '-errOrdinaryLook')) for value in command):
|
||||
refuse('Input observer command already has observer options')
|
||||
return command + ['-errOrdinaryInputObserve', nonce, '-errOrdinaryInputScreenshots', '1']
|
||||
options = ['-errOrdinaryInputObserve', nonce, '-errOrdinaryInputScreenshots', '1']
|
||||
if look:
|
||||
options += ['-errOrdinaryLookObserve', nonce]
|
||||
return command + options
|
||||
|
||||
|
||||
def input_observer_evidence(content, nonce):
|
||||
@@ -819,7 +826,7 @@ 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):
|
||||
def input_exercise_command(command, root, compat, profile, executable, run_id, host_display, display_context, look=False):
|
||||
"""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':
|
||||
@@ -830,6 +837,14 @@ def input_exercise_command(command, root, compat, profile, executable, run_id, h
|
||||
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')
|
||||
if type(look) is not bool:
|
||||
refuse('Relative look selection must be boolean')
|
||||
look_options = [i for i,value in enumerate(child) if value.startswith('-errOrdinaryLook')]
|
||||
if look:
|
||||
if len(look_options) != 1 or child[look_options[0]] != '-errOrdinaryLookObserve' or child[look_options[0]+1:look_options[0]+2] != [run_id]:
|
||||
refuse('Relative look requires its exact native observer nonce')
|
||||
elif look_options:
|
||||
refuse('Unexpected native relative-look observer')
|
||||
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')
|
||||
@@ -840,6 +855,9 @@ def input_exercise_command(command, root, compat, profile, executable, run_id, h
|
||||
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, '--']
|
||||
if look:
|
||||
context['look_requested'] = True
|
||||
wrapper.insert(-1, '--enable-private-look-proof')
|
||||
return command[:separator+1] + wrapper + child, context
|
||||
|
||||
|
||||
@@ -861,6 +879,8 @@ def input_exercise_poll_failure(context):
|
||||
for key in ('helper_sha256', 'ownership_helper_sha256', 'prefix', 'profile', 'game_executable', 'report', 'command', 'nonce'):
|
||||
if report.get(key) != context[key]:
|
||||
return False
|
||||
if report.get('look_requested', False) != context.get('look_requested', False):
|
||||
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
|
||||
@@ -902,6 +922,11 @@ def input_exercise_result(result, context, session, observation, display_result)
|
||||
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)
|
||||
look_requested = context.get('look_requested', False)
|
||||
if type(report.get('look_requested', False)) is not bool or report.get('look_requested', False) != look_requested:
|
||||
refuse('Relative look request does not match launch context')
|
||||
if not look_requested and report.get('look_proof') is not None:
|
||||
refuse('Unexpected relative mouse evidence in keyboard-only run')
|
||||
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'
|
||||
@@ -909,7 +934,7 @@ def input_exercise_result(result, context, session, observation, display_result)
|
||||
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')
|
||||
refuse('Private input 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)]
|
||||
@@ -935,6 +960,12 @@ def input_exercise_result(result, context, session, observation, display_result)
|
||||
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')
|
||||
if look_requested:
|
||||
from ordinary_look_evidence import validate_look_evidence
|
||||
proof['look_proof'] = validate_look_evidence((session/'console.log').read_text(errors='replace'), context['nonce'], report.get('look_proof'), events)
|
||||
proof['scope'] = 'bounded private relative mouse and keyboard delivery with native view/movement/inventory evidence; no gate, full escape or multiplayer qualification'
|
||||
if proof['look_proof'].get('verified') is not True:
|
||||
refuse('Independent native relative-look evidence is incomplete')
|
||||
proof['verified'] = True
|
||||
except (OSError, ValueError, TypeError, AttributeError, KeyError) as error:
|
||||
proof['error'] = str(error)
|
||||
@@ -1004,13 +1035,13 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
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)
|
||||
command = input_observer_command(command, a.project, packed, observation_nonce, getattr(a, 'exercise_relative_look', False))
|
||||
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)
|
||||
command, exercise_context = input_exercise_command(command, ROOT, compat, profile, executable, run_id, os.environ.get('DISPLAY', ''), display_context, getattr(a, 'exercise_relative_look', False))
|
||||
silent_sink = 'erri_game_'+run_id
|
||||
application_id = 'org.escapistsrereintroduced.game.'+run_id
|
||||
env = os.environ.copy()
|
||||
@@ -1127,7 +1158,7 @@ def run_locked(a, steam, compat, profile, packed=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'
|
||||
final['scope'] = 'ordinary game startup with an explicitly requested bounded private input 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,
|
||||
@@ -1174,6 +1205,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-relative-look', action='store_true', help='Explicit tiny relative right/return mouse experiment before W/Tab; requires --exercise-player-input and native view proof')
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in bounded private XTest keyboard experiment. Not a launcher or gameplay proof."""
|
||||
"""Opt-in bounded private XTest keyboard and optional relative-look experiment. Not a launcher or gameplay proof."""
|
||||
import argparse
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
@@ -49,17 +49,210 @@ def game_arguments(owner, args):
|
||||
raise Refused('Game process changed')
|
||||
if option(native['args'], '-errOrdinaryInputObserve') != args.nonce:
|
||||
raise Refused('Native observer nonce mismatch')
|
||||
if getattr(args, 'enable_private_look_proof', False) and option(native['args'], '-errOrdinaryLookObserve') != args.nonce:
|
||||
raise Refused('Native look observer nonce mismatch')
|
||||
if option(native['args'], '-errOrdinaryScenario') not in ('core', 'chat'):
|
||||
raise Refused('Not an ordinary successor scenario')
|
||||
if option(native['args'], '-profile') != 'Z:' + str(args.profile):
|
||||
raise Refused('Native profile mismatch')
|
||||
|
||||
|
||||
NUMBER = r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?'
|
||||
VECTOR = r'<\s*(' + NUMBER + r')\s*,\s*(' + NUMBER + r')\s*,\s*(' + NUMBER + r')\s*>'
|
||||
LOOK_HEADER = re.compile(r'ERR LOOK OBSERVE: run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) (.+)$')
|
||||
LOOK_SAMPLE = re.compile('SAMPLE direction=' + VECTOR + ' camera=' + VECTOR + ' body=' + VECTOR + ' bodyForward=' + VECTOR + ' turn=' + VECTOR)
|
||||
INPUT_SAMPLE = re.compile('SAMPLE origin=' + VECTOR + ' forward=(' + NUMBER + ') input=' + VECTOR + ' velocity=' + VECTOR + r' inventory=(0|1|true|false)')
|
||||
|
||||
|
||||
def parse_look_record(line, nonce):
|
||||
"""Strict live handshake parser; final qualification independently reparses logs."""
|
||||
if 'ERR LOOK OBSERVE:' not in line:
|
||||
return None
|
||||
match = LOOK_HEADER.search(line)
|
||||
if not match or match[1] != nonce or len(line) > 2048:
|
||||
raise Refused('Malformed or foreign look observation')
|
||||
sample, real, detail = int(match[2]), int(match[3]), match[4]
|
||||
if not 0 <= sample <= 450 or not 0 <= real <= 2147483647:
|
||||
raise Refused('Look observation bounds invalid')
|
||||
record = {'sample': sample, 'real_ms': real, 'raw': line}
|
||||
if detail == 'BASIS native-player-camera-world-transform':
|
||||
return {**record, 'kind': 'basis'}
|
||||
if re.fullmatch(r'CANCELLED [a-z-]+', detail):
|
||||
return {**record, 'kind': 'cancelled', 'reason': detail.split(' ', 1)[1]}
|
||||
values = LOOK_SAMPLE.fullmatch(detail)
|
||||
if not values:
|
||||
raise Refused('Malformed native look sample')
|
||||
numbers = [float(value) for value in values.groups()]
|
||||
if not all(math.isfinite(value) for value in numbers):
|
||||
raise Refused('Nonfinite native look sample')
|
||||
result = {**record, 'kind': 'sample'}
|
||||
for index, name in enumerate(('direction', 'camera', 'body', 'bodyForward', 'turn')):
|
||||
result[name] = numbers[index*3:index*3+3]
|
||||
for name in ('direction', 'bodyForward'):
|
||||
length = math.sqrt(sum(value*value for value in result[name]))
|
||||
if not .95 <= length <= 1.05 or math.hypot(result[name][0], result[name][2]) <= .1:
|
||||
raise Refused('Native look direction is degenerate or not normalized')
|
||||
return result
|
||||
|
||||
|
||||
def parse_stationary_sample(sample, real, detail):
|
||||
match = INPUT_SAMPLE.fullmatch(detail)
|
||||
if not match:
|
||||
raise Refused('Malformed input sample during look observation')
|
||||
values = [float(value) for value in match.groups()[:-1]]
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
raise Refused('Nonfinite input sample during look observation')
|
||||
return {'sample': sample, 'real_ms': real, 'origin': values[:3], 'forward': values[3],
|
||||
'input': values[4:7], 'velocity': values[7:10], 'inventory': match.groups()[-1] in ('1', 'true')}
|
||||
|
||||
|
||||
def yaw(direction):
|
||||
return math.degrees(math.atan2(direction[0], direction[2]))
|
||||
|
||||
|
||||
def yaw_delta(first, second):
|
||||
return (yaw(second) - yaw(first) + 180) % 360 - 180
|
||||
|
||||
|
||||
class RelativeMouse:
|
||||
def __init__(self, query, keyboard, owner):
|
||||
self.query, self.keyboard, self.owner = query, keyboard, owner
|
||||
self.xt = keyboard.xt
|
||||
self.xt.XTestFakeRelativeMotionEvent.restype = C.c_int
|
||||
self.xt.XTestFakeRelativeMotionEvent.argtypes = [C.c_void_p, C.c_int, C.c_int, C.c_ulong]
|
||||
query.x.XQueryPointer.restype = C.c_int
|
||||
query.x.XQueryPointer.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_ulong),
|
||||
C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_uint)]
|
||||
|
||||
def pointer(self):
|
||||
root, child = C.c_ulong(), C.c_ulong()
|
||||
rx, ry, wx, wy = C.c_int(), C.c_int(), C.c_int(), C.c_int()
|
||||
mask = C.c_uint()
|
||||
if not self.query.x.XQueryPointer(self.query.d, self.owner['window'], C.byref(root), C.byref(child),
|
||||
C.byref(rx), C.byref(ry), C.byref(wx), C.byref(wy), C.byref(mask)):
|
||||
raise Refused('Private pointer is unavailable or on another screen')
|
||||
return {'root': root.value, 'child': child.value, 'root_x': rx.value, 'root_y': ry.value, 'mask': mask.value}
|
||||
|
||||
def validate_pointer(self, target, dx, dy):
|
||||
position = self.pointer()
|
||||
geometry = target['geometry']; left, top, right, bottom = target['focused_clipped_bounds']
|
||||
if position['root'] != geometry['root'] or position['mask'] != 0:
|
||||
raise Refused('Private pointer root or button/modifier state invalid')
|
||||
for x, y in ((position['root_x'], position['root_y']), (position['root_x']+dx, position['root_y']+dy)):
|
||||
if not left <= x < right or not top <= y < bottom:
|
||||
raise Refused('Relative pointer step leaves owned clipped bounds')
|
||||
child = position['child']
|
||||
if child and self.query.server_pid(child) != self.owner['pid']:
|
||||
raise Refused('Pointer child belongs to a foreign native client')
|
||||
|
||||
def move(self, dx, dy, guard):
|
||||
if type(dx) is not int or dx not in (-24, 24) or dy != 0 or type(dy) is not int:
|
||||
raise Refused('Unsupported relative look step')
|
||||
target = guard.check()
|
||||
self.keyboard.require_idle()
|
||||
self.validate_pointer(target, dx, dy)
|
||||
# Recheck process/focus after the pointer roundtrip, with no focus changes.
|
||||
target = guard.check()
|
||||
self.validate_pointer(target, dx, dy)
|
||||
if not self.xt.XTestFakeRelativeMotionEvent(self.query.d, dx, dy, 0):
|
||||
raise Refused('Native relative motion request failed')
|
||||
self.query.x.XFlush(self.query.d)
|
||||
|
||||
|
||||
def perform_look(guard, keyboard, observer, mouse, report, clock=time.monotonic, sleep=time.sleep):
|
||||
proof = {'requested': True, 'completed': False, 'started_monotonic': clock(),
|
||||
'finished_monotonic': None, 'baseline_samples': [], 'response_sample': None,
|
||||
'return_sample': None, 'motions': []}
|
||||
report['look_proof'] = proof
|
||||
deadline = proof['started_monotonic'] + 4
|
||||
notify({'kind': 'look-pending'})
|
||||
ready_sample = observer.ready_sample
|
||||
baseline = None
|
||||
|
||||
def current():
|
||||
if clock() >= deadline:
|
||||
raise Refused('Native look proof exceeded four seconds')
|
||||
observer.fresh(); guard.check(); keyboard.require_idle()
|
||||
if observer.events != ['READY'] or any(r['kind'] == 'cancelled' for r in observer.look_records):
|
||||
raise Refused('Native look context ended or inventory changed')
|
||||
if sum(r['kind'] == 'basis' for r in observer.look_records) != 1:
|
||||
return []
|
||||
poses = [r for r in observer.look_records if r['kind'] == 'sample' and r['sample'] >= ready_sample]
|
||||
inputs = {r['sample']: r for r in observer.input_samples}
|
||||
joined = []
|
||||
for pose in poses:
|
||||
sample = inputs.get(pose['sample'])
|
||||
if sample is None:
|
||||
continue
|
||||
if sample['real_ms'] != pose['real_ms'] or math.dist(sample['origin'], pose['body']) > .01:
|
||||
raise Refused('Native look/input body or clock mismatch')
|
||||
ready_input = inputs.get(ready_sample)
|
||||
if ready_input is None:
|
||||
raise Refused('Missing native READY body sample')
|
||||
origin = ready_input['origin']
|
||||
if (sample['inventory'] or abs(sample['forward']) > .01 or
|
||||
math.sqrt(sum(x*x for x in sample['input'])) > .01 or
|
||||
math.sqrt(sum(x*x for x in sample['velocity'])) > .05 or
|
||||
math.dist(pose['body'], origin) > .05):
|
||||
raise Refused('Player moved before look proof completed')
|
||||
joined.append(pose)
|
||||
for previous, following in zip(joined, joined[1:]):
|
||||
if following['sample'] != previous['sample'] + 1 or not 0 < following['real_ms'] - previous['real_ms'] <= 500:
|
||||
raise Refused('Native look sampling gap or repeated clock')
|
||||
if joined and observer.input_samples[-1]['real_ms'] - joined[-1]['real_ms'] > 500:
|
||||
raise Refused('Native look pose is stale relative to input')
|
||||
return joined
|
||||
|
||||
def wait(predicate):
|
||||
while clock() < deadline:
|
||||
poses = current()
|
||||
if poses and predicate(poses):
|
||||
return poses[-1]
|
||||
sleep(.025)
|
||||
raise Refused('Native view rotation acknowledgement missing')
|
||||
|
||||
def motion(dx, after):
|
||||
poses = current()
|
||||
if not poses:
|
||||
raise Refused('Native look sample unavailable before motion')
|
||||
watermark = poses[-1]['sample']
|
||||
report['input_attempted'] = True
|
||||
mouse.move(dx, 0, guard)
|
||||
proof['motions'].append({'dx': dx, 'dy': 0, 'monotonic': clock(),
|
||||
'after_sample': after['sample'], 'after_real_ms': after['real_ms']})
|
||||
report['input_sent'] = True
|
||||
return watermark
|
||||
|
||||
try:
|
||||
def stable(poses):
|
||||
return len(poses) >= 2 and poses[-1]['sample'] == poses[-2]['sample']+1 and abs(yaw_delta(poses[-2]['direction'], poses[-1]['direction'])) <= .1
|
||||
wait(stable)
|
||||
poses = current()
|
||||
if not stable(poses):
|
||||
raise Refused('Initial native view no longer stable')
|
||||
baseline = poses[-1]
|
||||
proof['baseline_samples'] = [poses[-2]['sample'], baseline['sample']]
|
||||
outbound_after = motion(24, baseline)
|
||||
response = wait(lambda p: p[-1]['sample'] > outbound_after and .25 <= abs(yaw_delta(baseline['direction'], p[-1]['direction'])) <= 30)
|
||||
proof['response_sample'] = response['sample']
|
||||
displacement = abs(yaw_delta(baseline['direction'], response['direction']))
|
||||
return_after = motion(-24, response)
|
||||
returned = wait(lambda p: p[-1]['sample'] > return_after and abs(yaw_delta(baseline['direction'], p[-1]['direction'])) <= .35 and abs(yaw_delta(baseline['direction'], p[-1]['direction'])) < displacement/2)
|
||||
proof['return_sample'] = returned['sample']
|
||||
current()
|
||||
if clock() >= deadline:
|
||||
raise Refused('Native look proof exceeded four seconds')
|
||||
proof['completed'] = True
|
||||
finally:
|
||||
proof['finished_monotonic'] = clock()
|
||||
notify({'kind': 'look-finished'})
|
||||
|
||||
|
||||
class Observer:
|
||||
"""Only records appended after this exact owned console was opened count."""
|
||||
pattern = re.compile(r'ERR INPUT OBSERVE: run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) world=(\d+(?:\.\d+)?) (.+)$')
|
||||
|
||||
def __init__(self, path, nonce, clock=time.monotonic):
|
||||
def __init__(self, path, nonce, clock=time.monotonic, enable_look=False):
|
||||
self.path, self.nonce, self.clock = path, nonce, clock
|
||||
self.fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
info = os.fstat(self.fd)
|
||||
@@ -76,6 +269,8 @@ class Observer:
|
||||
self.raw = []
|
||||
self.bytes = 0
|
||||
self.terminal = False
|
||||
self.enable_look = enable_look
|
||||
self.look_records = []; self.input_samples = []; self.ready_sample = None
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
@@ -94,6 +289,22 @@ class Observer:
|
||||
lines = self.pending.split(b'\n'); self.pending = lines.pop()
|
||||
for raw in lines:
|
||||
line = raw.decode('utf-8', 'strict').rstrip('\r')
|
||||
if self.enable_look and 'ERR LOOK OBSERVE:' in line:
|
||||
record = parse_look_record(line, self.nonce)
|
||||
if len(self.look_records) >= 453:
|
||||
raise Refused('Native look record bound exceeded')
|
||||
if self.look_records and (record['sample'] < self.look_records[-1]['sample'] or record['real_ms'] < self.look_records[-1]['real_ms']):
|
||||
raise Refused('Native look records reversed')
|
||||
if any(r['kind'] == 'cancelled' for r in self.look_records):
|
||||
raise Refused('Native look records after cancellation')
|
||||
if record['kind'] == 'basis' and (self.look_records or record['sample'] != 1):
|
||||
raise Refused('Repeated or misplaced native look basis')
|
||||
if record['kind'] == 'sample' and not self.look_records:
|
||||
raise Refused('Missing native look basis')
|
||||
if record['kind'] == 'sample' and any(r['kind'] == 'sample' and r['sample'] == record['sample'] for r in self.look_records):
|
||||
raise Refused('Duplicate native look sample')
|
||||
self.look_records.append(record)
|
||||
continue
|
||||
if 'ERR INPUT OBSERVE: run=' + self.nonce + ' ' not in line:
|
||||
continue
|
||||
match = self.pattern.search(line)
|
||||
@@ -111,12 +322,15 @@ class Observer:
|
||||
if sample <= self.sample:
|
||||
raise Refused('Repeated native sample')
|
||||
self.last_sample_at = self.clock()
|
||||
if self.enable_look:
|
||||
self.input_samples.append(parse_stationary_sample(sample, real, detail))
|
||||
self.sample, self.real, self.world = sample, real, world
|
||||
self.raw.append(line)
|
||||
if detail == 'READY':
|
||||
if self.events or self.last_sample_at is None:
|
||||
raise Refused('Repeated or unbound READY')
|
||||
self.events.append('READY')
|
||||
self.ready_sample = sample
|
||||
elif detail in ('INVENTORY_OPEN', 'INVENTORY_CLOSED'):
|
||||
expected = ['READY'] if detail == 'INVENTORY_OPEN' else ['READY', 'INVENTORY_OPEN']
|
||||
if self.events != expected:
|
||||
@@ -225,7 +439,7 @@ def confirm_release(guard, keyboard, report, key, clock=time.monotonic, sleep=ti
|
||||
observation['end'] = clock()
|
||||
|
||||
|
||||
def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time.sleep):
|
||||
def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time.sleep, mouse=None):
|
||||
deadline = clock() + 70
|
||||
held = None
|
||||
report.update(events=[], paired_release_errors=[], sequence_completed=False)
|
||||
@@ -281,6 +495,9 @@ def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time
|
||||
deadline = clock() + 12
|
||||
if observer.events != ['READY']:
|
||||
raise Refused('Inventory was already changed before input')
|
||||
if mouse is not None:
|
||||
perform_look(guard, keyboard, observer, mouse, report, clock, sleep)
|
||||
deadline = clock() + 12
|
||||
pulse('W', 0.30)
|
||||
check()
|
||||
pulse('Tab', 0.05)
|
||||
@@ -315,6 +532,7 @@ def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time
|
||||
def arguments(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--enable-private-keyboard-proof', action='store_true', required=True)
|
||||
p.add_argument('--enable-private-look-proof', action='store_true')
|
||||
for name in ('prefix', 'game-executable', 'profile', 'report'):
|
||||
p.add_argument('--' + name, type=Path, required=True)
|
||||
p.add_argument('--console', type=Path, help='Existing exact session; omit only in wrapper mode')
|
||||
@@ -349,6 +567,8 @@ def wrapper_command(args):
|
||||
option(native, '-profile') != 'Z:' + str(args.profile) or
|
||||
option(native, '-errOrdinaryScenario') not in ('core', 'chat')):
|
||||
raise Refused('Wrapped native observer context differs')
|
||||
if args.enable_private_look_proof and option(native, '-errOrdinaryLookObserve') != args.nonce:
|
||||
raise Refused('Wrapped native look observer context differs')
|
||||
return command
|
||||
|
||||
|
||||
@@ -380,6 +600,7 @@ def main(argv=None):
|
||||
console_path(args)
|
||||
bound = display.binding(args.host_display, args.prefix)
|
||||
report = {'schema': 1, 'kind': 'headless-private-keyboard-experiment', 'input_sent': False,
|
||||
'look_requested': args.enable_private_look_proof,
|
||||
'sequence_completed': False, 'release_qualified': False, 'gameplay_qualified': False,
|
||||
'hard_supervision': NOTIFY is not None, 'nonce': args.nonce, 'binding': bound, 'command': command, 'report': str(args.report), 'input_attempted': False,
|
||||
'helper_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
@@ -421,7 +642,7 @@ def main(argv=None):
|
||||
if args.console is None:
|
||||
raise Refused('New native console deadline exceeded')
|
||||
report['console'] = str(args.console)
|
||||
observer = Observer(args.console, args.nonce)
|
||||
observer = Observer(args.console, args.nonce, enable_look=args.enable_private_look_proof)
|
||||
query = display.X11(bound['display'])
|
||||
while time.monotonic() < deadline:
|
||||
if child is not None and child.poll() is not None:
|
||||
@@ -448,7 +669,8 @@ def main(argv=None):
|
||||
guard.check()
|
||||
keyboard = Keyboard(query)
|
||||
keyboard.require_idle()
|
||||
sequence(guard, keyboard, observer, report)
|
||||
mouse = RelativeMouse(query, keyboard, owner) if args.enable_private_look_proof else None
|
||||
sequence(guard, keyboard, observer, report, mouse=mouse)
|
||||
except (OSError, ValueError, UnicodeError, subprocess.SubprocessError) as error:
|
||||
report['error'] = str(error)
|
||||
finally:
|
||||
@@ -456,6 +678,8 @@ def main(argv=None):
|
||||
if observer:
|
||||
report['observer'] = {'inode': observer.identity, 'start_offset': observer.start_offset,
|
||||
'end_offset': observer.offset, 'records': observer.raw, 'events': observer.events}
|
||||
if args.enable_private_look_proof:
|
||||
report['look_observation'] = observer.look_records
|
||||
observer.close()
|
||||
if query:
|
||||
query.close()
|
||||
@@ -549,7 +773,7 @@ def supervise(argv=None):
|
||||
os.kill(child.pid, signum)
|
||||
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
previous[signum] = signal.signal(signum, forward)
|
||||
held_at = None; held_key = None; owned_child = None; report_record = None; proof_finished = False; expired = False
|
||||
look_at = None; held_at = None; held_key = None; owned_child = None; report_record = None; proof_finished = False; expired = False
|
||||
deadline = time.monotonic() + 190
|
||||
try:
|
||||
child.start(); child_pipe.close()
|
||||
@@ -559,7 +783,11 @@ def supervise(argv=None):
|
||||
event = parent.recv()
|
||||
except EOFError:
|
||||
event = None
|
||||
if event and event['kind'] == 'press-pending':
|
||||
if event and event['kind'] == 'look-pending':
|
||||
look_at = time.monotonic()
|
||||
elif event and event['kind'] == 'look-finished':
|
||||
look_at = None
|
||||
elif event and event['kind'] == 'press-pending':
|
||||
held_at = time.monotonic(); held_key = event['key']
|
||||
elif event and event['kind'] == 'release-confirmed':
|
||||
held_at = None; held_key = None
|
||||
@@ -570,7 +798,7 @@ def supervise(argv=None):
|
||||
elif event and event['kind'] == 'proof-finished':
|
||||
proof_finished = event['successful']
|
||||
now = time.monotonic()
|
||||
if received or now >= deadline or (held_at is not None and now - held_at > 1.5):
|
||||
if received or now >= deadline or (held_at is not None and now - held_at > 1.5) or (look_at is not None and now - look_at > 4):
|
||||
expired = True
|
||||
child.terminate() # asks worker finally to release if still provable
|
||||
child.join(1)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Independent, read-only verification of the bounded ordinary relative-look proof.
|
||||
|
||||
Raw native records prove pose changes; helper timestamps only bind requested input
|
||||
ordering. Display/session/process ownership and complete keyboard delivery remain
|
||||
separate runner checks. This module never sends input or opens a native process.
|
||||
"""
|
||||
import math
|
||||
import re
|
||||
|
||||
NUMBER = r'-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?'
|
||||
LOOK = 'ERR LOOK OBSERVE: '
|
||||
INPUT = 'ERR INPUT OBSERVE: '
|
||||
SCOPE = 'two fixed relative mouse requests with native camera yaw response and return; stationary body; no visual, aiming, multiplayer or general input qualification'
|
||||
CANCEL_REASONS = {'menu-opened', 'original-binding-changed', 'sample-or-clock-bound',
|
||||
'native-camera-or-player-context-lost', 'invalid-native-pose', 'explicit-scope-lost'}
|
||||
|
||||
|
||||
def _require(condition, reason):
|
||||
if not condition:
|
||||
raise ValueError(reason)
|
||||
|
||||
|
||||
def _number(value):
|
||||
_require(type(value) in (int, float) and math.isfinite(value) and value >= 0,
|
||||
'invalid nonnegative numeric timestamp')
|
||||
return value
|
||||
|
||||
|
||||
def _index(value):
|
||||
_require(type(value) is int and 1 <= value <= 450, 'invalid sample reference')
|
||||
return value
|
||||
|
||||
|
||||
def _vector(value):
|
||||
if value.startswith('<') and value.endswith('>'):
|
||||
value = value[1:-1]
|
||||
_require(re.fullmatch(NUMBER + r'(?:\s*,\s*|\s+)' + NUMBER + r'(?:\s*,\s*|\s+)' + NUMBER, value.strip()), 'malformed native vector')
|
||||
result = tuple(float(v) for v in re.findall(NUMBER, value))
|
||||
_require(all(math.isfinite(v) and abs(v) <= 10000000 for v in result), 'nonfinite or unbounded native vector')
|
||||
return result
|
||||
|
||||
|
||||
def _norm(v):
|
||||
return math.sqrt(sum(x*x for x in v))
|
||||
|
||||
|
||||
def _distance(a, b):
|
||||
return _norm(tuple(x-y for x, y in zip(a, b)))
|
||||
|
||||
|
||||
def _yaw(v):
|
||||
_require(.95 <= _norm(v) <= 1.05 and math.hypot(v[0], v[2]) > .1,
|
||||
'native camera direction is not unit or has no usable horizontal direction')
|
||||
return math.degrees(math.atan2(v[0], v[2]))
|
||||
|
||||
|
||||
def _delta(a, b):
|
||||
return (a-b+180) % 360-180
|
||||
|
||||
|
||||
def _native(content, nonce):
|
||||
_require(type(content) is str and len(content) <= 32*1024*1024, 'console content exceeds bound or is not text')
|
||||
_require(type(nonce) is str and re.fullmatch(r'[A-Za-z0-9]{1,32}', nonce), 'invalid expected nonce')
|
||||
look_header = re.compile(r'run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) (.+)')
|
||||
input_header = re.compile(r'run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) world=('+NUMBER+r') (.+)')
|
||||
pose_pattern = re.compile(r'SAMPLE direction=(.+?) camera=(.+?) body=(.+?) bodyForward=(.+?) turn=(.+)')
|
||||
input_pattern = re.compile(r'SAMPLE origin=(.+?) forward=('+NUMBER+r') input=(.+?) velocity=(.+?) inventory=(0|1|true|false)')
|
||||
poses, samples = {}, {}
|
||||
basis = None; cancelled = None; ready = None; queued = None
|
||||
last_look = (-1, -1); last_input = (-1, -1, -1)
|
||||
look_count = input_count = 0
|
||||
input_events = set(); input_terminal = False
|
||||
for ordinal, line in enumerate(content.splitlines()):
|
||||
if LOOK.rstrip() in line:
|
||||
_require(LOOK in line, 'malformed look prefix')
|
||||
look_count += 1
|
||||
_require(look_count <= 452 and line.count(LOOK) == 1, 'look record count or prefix invalid')
|
||||
match = look_header.fullmatch(line.split(LOOK, 1)[1].strip())
|
||||
_require(match is not None, 'malformed look record')
|
||||
actual, sample, real, event = match.groups(); sample, real = int(sample), int(real)
|
||||
_require(actual == nonce, 'foreign look nonce')
|
||||
_require(1 <= sample <= 450 and real <= 2147483647, 'look clock/sample outside bound')
|
||||
_require(sample >= last_look[0] and real >= last_look[1], 'look records reversed')
|
||||
_require(cancelled is None, 'look records after cancellation')
|
||||
last_look = sample, real
|
||||
if event == 'BASIS native-player-camera-world-transform':
|
||||
_require(basis is None and not poses and sample == 1, 'duplicate or late native camera acknowledgement')
|
||||
basis = (sample, real, ordinal)
|
||||
elif event.startswith('SAMPLE '):
|
||||
_require(basis is not None, 'missing native camera acknowledgement')
|
||||
match = pose_pattern.fullmatch(event)
|
||||
_require(match is not None, 'malformed native look sample')
|
||||
_require(sample == len(poses)+1, 'look samples missing or duplicated')
|
||||
values = [_vector(v) for v in match.groups()]
|
||||
direction, camera, body, facing, turn = values
|
||||
_yaw(direction)
|
||||
_require(.95 <= _norm(facing) <= 1.05 and math.hypot(facing[0], facing[2]) > .1 and turn[2] == 0, 'invalid body forward or native turn vector')
|
||||
poses[sample] = dict(sample=sample, real_ms=real, ordinal=ordinal,
|
||||
direction=direction, camera=camera, body=body, body_forward=facing, turn=turn)
|
||||
if sample == 1:
|
||||
_require(real == basis[1], 'native acknowledgement does not bind first camera sample')
|
||||
elif event.startswith('CANCELLED '):
|
||||
_require(event[10:] in CANCEL_REASONS, 'unknown look cancellation')
|
||||
cancelled = dict(sample=sample, real_ms=real, reason=event[10:], ordinal=ordinal)
|
||||
else:
|
||||
raise ValueError('unknown look event')
|
||||
if INPUT.rstrip() in line:
|
||||
_require(INPUT in line, 'malformed input prefix')
|
||||
input_count += 1
|
||||
_require(input_count <= 460 and line.count(INPUT) == 1, 'input record count or prefix invalid')
|
||||
match = input_header.fullmatch(line.split(INPUT, 1)[1].strip())
|
||||
_require(match is not None, 'malformed input record')
|
||||
actual, sample, real, world, event = match.groups()
|
||||
_require(not input_terminal, 'input records after terminal event')
|
||||
if not event.startswith('SAMPLE '):
|
||||
_require(event not in input_events, 'duplicate input event')
|
||||
input_events.add(event)
|
||||
sample, real, world = int(sample), int(real), float(world)
|
||||
_require(actual == nonce, 'foreign input nonce')
|
||||
_require(0 <= sample <= 450 and real <= 2147483647 and math.isfinite(world) and world >= 0,
|
||||
'input clock/sample outside bound')
|
||||
_require(sample >= last_input[0] and real >= last_input[1] and world >= last_input[2], 'input records reversed')
|
||||
last_input = sample, real, world
|
||||
if event == 'QUEUED':
|
||||
_require(queued is None and not samples and sample == 0, 'duplicate or late input queued event')
|
||||
queued = (sample, real, world)
|
||||
elif event == 'READY':
|
||||
# Record() reads the process clock again after the SAMPLE/LOOK
|
||||
# writes. Bind READY to the latest emitted sample, not to an
|
||||
# unrealistically identical millisecond. Later sample ordering
|
||||
# still rejects a READY timestamp beyond the next sample.
|
||||
_require(ready is None and sample == len(samples) and sample in samples and
|
||||
0 <= real-samples[sample]['real_ms'] <= 500 and
|
||||
world == samples[sample]['world_ms'], 'duplicate or unbound input READY')
|
||||
ready = dict(sample=sample, real_ms=real, ordinal=ordinal)
|
||||
elif event.startswith('SAMPLE '):
|
||||
_require(queued is not None and sample == len(samples)+1, 'input samples missing, duplicated or unqueued')
|
||||
match = input_pattern.fullmatch(event)
|
||||
_require(match is not None, 'malformed native input sample')
|
||||
origin, forward, movement, velocity, inventory = match.groups()
|
||||
forward = float(forward)
|
||||
_require(math.isfinite(forward), 'nonfinite input forward')
|
||||
samples[sample] = dict(sample=sample, real_ms=real, world_ms=world, ordinal=ordinal,
|
||||
origin=_vector(origin), forward=forward, input=_vector(movement),
|
||||
velocity=_vector(velocity), inventory=inventory in ('1', 'true'))
|
||||
elif event in ('INVENTORY_OPEN', 'INVENTORY_CLOSED', 'CAPTURE_REQUEST baseline',
|
||||
'CAPTURE_REQUEST open', 'CAPTURE_REQUEST closed', 'FINISHED observation-bound'):
|
||||
input_terminal = event == 'FINISHED observation-bound'
|
||||
# Complete menu sequencing is independently checked by the runner.
|
||||
elif event.startswith('CANCELLED '):
|
||||
_require(bool(event[10:].strip()), 'empty input cancellation')
|
||||
input_terminal = True
|
||||
# A later shutdown is outside the look proof, but cannot erase an earlier loss.
|
||||
for pose in poses.values():
|
||||
_require(pose['real_ms'] < real, 'input cancellation overlaps native look samples')
|
||||
else:
|
||||
raise ValueError('unknown input event')
|
||||
_require(basis is not None and poses and ready is not None and queued is not None,
|
||||
'missing native camera acknowledgement, samples or input READY')
|
||||
_require(ready['real_ms']-queued[1] >= 2000, 'input READY has no stationary baseline duration')
|
||||
for index, pose in poses.items():
|
||||
_require(index in samples and samples[index]['real_ms'] == pose['real_ms'], 'native pose/input sample binding mismatch')
|
||||
_require(0 <= pose['real_ms']-queued[1] < 45000, 'native pose outside original observation lifetime')
|
||||
return poses, samples, ready, cancelled
|
||||
|
||||
|
||||
def validate_look_evidence(content, nonce, look_proof, keyboard_events=None):
|
||||
"""Fail closed; recompute native geometry independently of helper claims."""
|
||||
result = {'verified': False, 'errors': [], 'scope': SCOPE}
|
||||
try:
|
||||
_require(type(look_proof) is dict and set(look_proof) == {
|
||||
'requested', 'completed', 'started_monotonic', 'finished_monotonic',
|
||||
'baseline_samples', 'response_sample', 'return_sample', 'motions'}, 'unknown or missing look proof fields')
|
||||
_require(look_proof['requested'] is True and look_proof['completed'] is True, 'look proof not explicitly requested and completed')
|
||||
start = _number(look_proof['started_monotonic']); finish = _number(look_proof['finished_monotonic'])
|
||||
_require(0 < finish-start <= 4, 'helper look interval outside four-second bound')
|
||||
baseline = look_proof['baseline_samples']
|
||||
_require(type(baseline) is list and len(baseline) == 2, 'exactly two baseline sample references required')
|
||||
first, base = [_index(i) for i in baseline]
|
||||
response = _index(look_proof['response_sample']); returned = _index(look_proof['return_sample'])
|
||||
_require(base == first+1 and base < response < returned, 'baseline/response/return sample ordering invalid')
|
||||
poses, samples, ready, cancelled = _native(content, nonce)
|
||||
_require(first >= ready['sample'] and all(i in poses for i in (first, base, response, returned)), 'native ready baseline/response/return missing')
|
||||
_require(cancelled is None or cancelled['sample'] > returned, 'native camera context cancelled before return')
|
||||
motions = look_proof['motions']
|
||||
_require(type(motions) is list and len(motions) == 2, 'exactly two relative motions required')
|
||||
times = []
|
||||
for event, dx, after in zip(motions, (24, -24), (base, response)):
|
||||
_require(type(event) is dict and set(event) == {'dx', 'dy', 'monotonic', 'after_sample', 'after_real_ms'}, 'unknown or missing motion fields')
|
||||
_require(type(event['dx']) is int and event['dx'] == dx and type(event['dy']) is int and event['dy'] == 0, 'motion differs from fixed relative request')
|
||||
_require(type(event['after_sample']) is int and event['after_sample'] == after and
|
||||
type(event['after_real_ms']) is int and event['after_real_ms'] == poses[after]['real_ms'], 'motion native reference mismatch')
|
||||
times.append(_number(event['monotonic']))
|
||||
_require(start <= times[0] < times[1] <= finish, 'motion timestamps reversed or outside proof')
|
||||
_require(0 < poses[returned]['real_ms']-poses[first]['real_ms'] <= 4000, 'native look interval outside four-second bound')
|
||||
_require(poses[response]['real_ms'] > poses[base]['real_ms'] and poses[returned]['real_ms'] > poses[response]['real_ms'], 'native response/return clocks did not advance')
|
||||
for index in range(ready['sample'], returned+1):
|
||||
_require(index in poses and index in samples, 'missing native observation during look proof')
|
||||
pose, sample = poses[index], samples[index]
|
||||
_require(index == ready['sample'] or 0 < pose['real_ms']-poses[index-1]['real_ms'] <= 500, 'native look sampling gap or duplicate clock')
|
||||
_require(not sample['inventory'] and abs(sample['forward']) <= .01 and _norm(sample['input']) <= .01 and _norm(sample['velocity']) <= .05,
|
||||
'body input, velocity or inventory changed during look proof')
|
||||
_require(_distance(sample['origin'], samples[ready['sample']]['origin']) <= .05 and
|
||||
_distance(pose['body'], samples[ready['sample']]['origin']) <= .05 and
|
||||
_distance(pose['body'], sample['origin']) <= .01, 'body moved or native body/input positions disagree')
|
||||
base_yaw = _yaw(poses[base]['direction'])
|
||||
stable = abs(_delta(base_yaw, _yaw(poses[first]['direction'])))
|
||||
response_yaw = _delta(_yaw(poses[response]['direction']), base_yaw)
|
||||
return_yaw = _delta(_yaw(poses[returned]['direction']), base_yaw)
|
||||
_require(stable <= .1, 'camera baseline not stable')
|
||||
_require(.25 <= abs(response_yaw) <= 30, 'native camera yaw did not show bounded response')
|
||||
_require(abs(return_yaw) <= .35 and abs(return_yaw) < abs(response_yaw)/2, 'native camera did not return near baseline')
|
||||
if keyboard_events is not None:
|
||||
_require(type(keyboard_events) is list and 1 <= len(keyboard_events) <= 8, 'invalid keyboard event list')
|
||||
previous = -1; first_w = None
|
||||
for event in keyboard_events:
|
||||
_require(type(event) is dict and set(event) <= {'key', 'down', 'monotonic', 'finally'} and {'key', 'down', 'monotonic'} <= set(event), 'malformed keyboard event')
|
||||
_require(event['key'] in ('W', 'Tab') and type(event['down']) is bool and ('finally' not in event or type(event['finally']) is bool), 'unknown keyboard event')
|
||||
at = _number(event['monotonic']); _require(at >= previous, 'keyboard timestamps reversed'); previous = at
|
||||
_require(at > finish, 'keyboard request overlaps or precedes look completion')
|
||||
if first_w is None and event['key'] == 'W' and event['down']:
|
||||
first_w = at
|
||||
_require(first_w is not None and keyboard_events[0]['key'] == 'W' and keyboard_events[0]['down'] is True, 'first W press missing or not first keyboard event')
|
||||
result.update(verified=True, baseline_samples=baseline, response_sample=response, return_sample=returned,
|
||||
baseline_yaw_delta_degrees=stable, response_yaw_degrees=response_yaw,
|
||||
return_yaw_degrees=return_yaw, native_duration_ms=poses[returned]['real_ms']-poses[first]['real_ms'],
|
||||
motion_count=2, keyboard_order_verified=keyboard_events is not None)
|
||||
except (ValueError, TypeError, KeyError, OverflowError) as error:
|
||||
result['errors'].append(str(error))
|
||||
return result
|
||||
Reference in New Issue
Block a user