Improve base guidance and qualify bounded aircraft route and hidden test lifecycle

This commit is contained in:
2026-09-19 16:41:32 +02:00
parent 6f0e4c5978
commit 5a24eea2f5
192 changed files with 27066 additions and 46 deletions
+726
View File
@@ -0,0 +1,726 @@
#!/usr/bin/env python3
"""Read-only X11 ownership observer, executed INSIDE an owned headless Gamescope.
No input/focus API, no engine launch of its own, no desktop DISPLAY fallback.
"""
import argparse
import ctypes as C
import hashlib
import json
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import time
PROFILE_ID = {'kind': 'erri-ordinary-game-disposable-profile', 'version': 1}
MAX_WINDOWS = 1024
def display_number(value):
match = re.fullmatch(r'(?:unix)?:([0-9]{1,5})(?:\.([0-9]{1,3}))?', value or '')
if not match:
raise ValueError('DISPLAY must be explicit local Unix :N[.screen]')
return int(match[1])
def process(pid, proc_root=Path('/proc')):
p = proc_root / str(pid)
fields = (p/'stat').read_text().rsplit(')', 1)[1].split()
return {'pid': int(pid), 'ppid': int(fields[1]), 'start': int(fields[19]),
'uid': p.stat().st_uid, 'exe': os.readlink(p/'exe'),
'args': [v.decode('utf-8', 'strict') for v in (p/'cmdline').read_bytes().split(b'\0') if v]}
def environment(pid, proc_root=Path('/proc')):
# Only allowlisted values leave this function; never serialize the environment.
values = {}
for value in (proc_root/str(pid)/'environ').read_bytes().split(b'\0'):
key, _, content = value.partition(b'=')
if key in (b'DISPLAY', b'WINEPREFIX', b'PULSE_PROP'):
values[key.decode()] = content.decode('utf-8', 'strict')
return values
def prefix_matches(value, expected):
"""Proton's pfx/ spelling only; do not normalize aliases or path components."""
return isinstance(value, str) and value.rstrip('/') == str(expected)
def identity(p):
return {key: p[key] for key in ('pid', 'start', 'uid')}
def headless_args(args):
options = args[1:args.index('--')] if '--' in args else []
return (options.count('--backend') == 1 and
options[options.index('--backend') + 1:options.index('--backend') + 2] == ['headless'] and
not any(value.startswith('--backend=') for value in options))
def ancestor(pid, read=process):
seen = set()
for _ in range(64):
if pid < 2 or pid in seen:
break
seen.add(pid)
p = read(pid)
if p['uid'] != os.getuid():
raise ValueError('Foreign-user ancestor')
if Path(p['exe']).name == 'gamescope':
if not headless_args(p['args']):
raise ValueError('Gamescope ancestor is not explicitly headless')
return p
pid = p['ppid']
raise ValueError('No owned headless Gamescope ancestor')
def descendant(pid, parent, read=process):
for _ in range(64):
if pid == parent:
return True
if pid < 2:
break
pid = read(pid)['ppid']
return False
def validate_display(display, host_display):
number = display_number(display)
# Host DISPLAY can be remote; never open it. Also reject localhost aliases.
host_match = re.fullmatch(r'(?:unix|localhost)?:(\d+)(?:\.\d+)?', host_display or '')
if display == host_display or (host_match and int(host_match[1]) == number):
raise ValueError('Nested DISPLAY is the supplied host display')
return number
def owned_sockets(number, pid, proc_root=Path('/proc')):
"""Prove every local Xlib connection address for this number is owned, before XOpenDisplay."""
paths = {f'/tmp/.X11-unix/X{number}', f'@/tmp/.X11-unix/X{number}'}
sockets = []
for line in (proc_root/'net/unix').read_text().splitlines()[1:]:
fields = line.split()
if len(fields) < 8 or fields[7] not in paths:
continue
if fields[4] != '0001' or fields[5] != '01' or not int(fields[3], 16) & 0x10000:
continue
sockets.append({'path':fields[7], 'inode':int(fields[6])})
if not sockets or len({item['path'] for item in sockets}) != len(sockets):
raise ValueError('No unique live local X11 listening socket')
fds = set()
for fd in (proc_root/str(pid)/'fd').iterdir():
try:
target = os.readlink(fd)
except FileNotFoundError:
continue
match = re.fullmatch(r'socket:\[(\d+)\]', target)
if match:
fds.add(int(match[1]))
if any(item['inode'] not in fds for item in sockets):
raise ValueError('Local display socket is not held by owned Xwayland')
return sorted(sockets, key=lambda item:item['path'])
def candidate_relationship(p, scope, number, prefix, read=process, env_read=environment, proc_root=Path('/proc')):
"""Independent, sanitized process/socket checks for reparented Xwayland."""
expected = os.environ.get('PULSE_PROP', '')
valid_nonce = bool(re.fullmatch(r'application.id=org\.escapistsrereintroduced\.game\.[0-9a-f]{32}', expected))
result = {'start_not_older_than_gamescope':p['start'] >= scope['start'],
'wrapper_run_nonce_valid':valid_nonce,
'xwayland_prefix_matches':None, 'gamescope_prefix_matches':None,
'xwayland_run_nonce_matches':None, 'gamescope_run_nonce_matches':None,
'xwayland_holds_all_display_listeners':False, 'gamescope_holds_all_display_listeners':False,
'same_listener_inodes':False, 'listener_inodes':[], 'process_identities_stable':False}
for role, pid in (('xwayland',p['pid']),('gamescope',scope['pid'])):
try:
env = env_read(pid)
result[role+'_prefix_matches'] = prefix_matches(env.get('WINEPREFIX'),prefix)
result[role+'_run_nonce_matches'] = valid_nonce and env.get('PULSE_PROP') == expected
except (OSError, ValueError, UnicodeError):
pass
listeners = {}
for role, pid in (('xwayland',p['pid']),('gamescope',scope['pid'])):
try:
listeners[role] = owned_sockets(number,pid,proc_root)
result[role+'_holds_all_display_listeners'] = True
except (OSError, ValueError):
pass
if 'xwayland' in listeners:
result['listener_inodes'] = [item['inode'] for item in listeners['xwayland']]
result['same_listener_inodes'] = bool(listeners.get('xwayland')) and listeners.get('xwayland') == listeners.get('gamescope')
try:
result['process_identities_stable'] = identity(read(p['pid'])) == identity(p) and identity(read(scope['pid'])) == identity(scope)
except (OSError, ValueError):
pass
return result
SHARED_CHECKS = (
'start_not_older_than_gamescope', 'wrapper_run_nonce_valid',
'xwayland_prefix_matches', 'gamescope_prefix_matches',
'xwayland_run_nonce_matches', 'gamescope_run_nonce_matches',
'xwayland_holds_all_display_listeners', 'gamescope_holds_all_display_listeners',
'same_listener_inodes', 'process_identities_stable',
)
def shared_relationship_valid(details):
if not isinstance(details, dict) or not all(details.get(key) is True for key in SHARED_CHECKS):
return False
inodes = details.get('listener_inodes')
return (isinstance(inodes, list) and 1 <= len(inodes) <= 2 and
all(type(inode) is int and inode > 0 for inode in inodes) and len(set(inodes)) == len(inodes))
def binding(host_display, prefix, read=process, env_read=environment, proc_root=Path('/proc')):
display = os.environ.get('DISPLAY', '')
number = validate_display(display, host_display)
if not prefix_matches(os.environ.get('WINEPREFIX'),prefix):
raise ValueError('Wrapper prefix does not match supplied private prefix')
scope = ancestor(os.getppid(), read)
matches = []
candidates = []
candidate_count = 0
unreadable = 0
for entry in proc_root.iterdir():
if not entry.name.isdigit():
continue
try:
p = read(int(entry.name))
if p['uid'] != os.getuid():
continue
basename = Path(p['exe']).name
argv_name = Path(p['args'][0]).name if p['args'] else ''
# Broader names are DIAGNOSTICS ONLY. The authorization check below
# still requires the exact native executable name and an owned relationship.
if 'xwayland' not in basename.lower() and 'xwayland' not in argv_name.lower():
continue
candidate_count += 1
displays = [v for v in p['args'][1:] if re.fullmatch(r':\d+(?:\.\d+)?', v)]
portal = any(v == '-enable-ei-portal' or v.startswith('-enable-ei-portal=') for v in p['args'])
try:
nested = descendant(p['pid'], scope['pid'], read)
except (FileNotFoundError, ProcessLookupError, PermissionError):
nested = None
relationship = None
if len(displays) == 1 and display_number(displays[0]) == number:
relationship = candidate_relationship(p,scope,number,prefix,read,env_read,proc_root)
if len(candidates) < 16:
candidate = {**identity(p), 'ppid':p['ppid'],
'executable_basename':basename[:96],
'display_args':displays[:4], 'display_arg_count':len(displays),
'portal_enabled':portal, 'descendant':nested,
'exact_executable_name':basename == 'Xwayland'}
if relationship is not None:
candidate['selected_display_relationship'] = relationship
candidates.append(candidate)
if basename != 'Xwayland':
continue
if len(displays) != 1 or display_number(displays[0]) != number:
continue
if portal:
raise ValueError('Nested Xwayland has input-emulation portal enabled')
if nested is True:
matches.append((p, {'mode':'descendant', 'descendant':True}))
elif shared_relationship_valid(relationship):
matches.append((p, {'mode':'shared-listeners', 'checks':relationship}))
except (FileNotFoundError, ProcessLookupError, PermissionError):
unreadable += 1
continue
if len(matches) != 1:
diagnostic = {'display':display, 'gamescope':{**identity(scope),'ppid':scope['ppid']},
'matching_candidates':len(matches), 'same_user_xwayland_candidates':candidates,
'candidate_count':candidate_count, 'candidate_limit':16,
'unreadable_process_count':unreadable,
'read_only':True, 'display_opened':False, 'input_sent':False}
raise ValueError('Cannot identify exactly one owned nested Xwayland; preflight=' +
json.dumps(diagnostic,sort_keys=True,separators=(',',':')))
selected, ownership = matches[0]
sockets = owned_sockets(number, selected['pid'], proc_root)
for pinned in (selected, scope):
current = read(pinned['pid'])
if identity(current) != identity(pinned) or current['exe'] != pinned['exe'] or current['args'] != pinned['args']:
raise ValueError('Native process changed during socket ownership check')
if ownership['mode'] == 'shared-listeners':
final_checks = candidate_relationship(selected,scope,number,prefix,read,env_read,proc_root)
if (not shared_relationship_valid(final_checks) or final_checks != ownership['checks'] or
final_checks['listener_inodes'] != [item['inode'] for item in sockets]):
raise ValueError('Shared listener ownership changed during binding')
elif not descendant(selected['pid'],scope['pid'],read):
raise ValueError('Native descendant ownership changed during binding')
return {'display': display, 'gamescope': identity(scope), 'xwayland': identity(selected),
'sockets': sockets, 'relationship': ownership}
def executable_argument(args, executable):
# Match an exact full PE path, not a basename/title/comm guess.
accepted = {str(executable), 'Z:'+str(executable), 'Z:'+str(executable).replace('/', '\\')}
return bool(args) and args[0] in accepted
def owned_window(window, bound, prefix, executable, read=process, env_read=environment):
pid = window.get('pid')
if type(pid) is not int or pid <= 0 or pid != window.get('server_pid'):
return None
try:
before = read(pid)
if before['uid'] != os.getuid() or not executable_argument(before['args'], executable):
return None
env = env_read(pid)
if not prefix_matches(env.get('WINEPREFIX'),prefix) or env.get('DISPLAY') != bound['display']:
return None
if identity(read(pid)) != identity(before):
return None
except (OSError, ValueError, UnicodeError):
return None
return {**window, 'process': identity(before)}
def window_process_diagnostic(pid, bound, prefix, executable, read=process, env_read=environment):
result = {'readable':False, 'same_user':None, 'game_executable_match':False,
'argv0_class':'unreadable', 'argv0_case_insensitive_exact':False,
'prefix_matches':None, 'display_matches':None, 'process_stable':False}
if type(pid) is not int or pid <= 0:
result['argv0_class'] = 'invalid-pid'
return result
try:
before = read(pid)
result['readable'] = True
result['same_user'] = before['uid'] == os.getuid()
argv0 = before['args'][0] if before['args'] else ''
exact = executable_argument(before['args'],executable)
result['game_executable_match'] = exact
if exact:
result['argv0_class'] = 'exact-unix-game' if argv0 == str(executable) else 'exact-wine-z-game'
elif not argv0:
result['argv0_class'] = 'empty'
elif re.split(r'[\\/]',argv0)[-1].lower() == executable.name.lower():
result['argv0_class'] = 'game-basename-noncanonical-path'
elif argv0.lower().endswith('.exe'):
result['argv0_class'] = 'other-windows-executable'
else:
result['argv0_class'] = 'other'
normalized = argv0.replace('\\','/').lower()
result['argv0_case_insensitive_exact'] = normalized in {str(executable).lower(),('Z:'+str(executable)).lower()}
env = env_read(pid)
result['prefix_matches'] = prefix_matches(env.get('WINEPREFIX'),prefix)
result['display_matches'] = env.get('DISPLAY') == bound['display']
result['process_stable'] = identity(read(pid)) == identity(before)
except (OSError, ValueError, UnicodeError):
pass
return result
def window_diagnostic(window, bound, prefix, executable, read=process, env_read=environment):
pid, server = window.get('pid'), window.get('server_pid')
property_process = window_process_diagnostic(pid,bound,prefix,executable,read,env_read)
server_process = property_process if pid == server else window_process_diagnostic(server,bound,prefix,executable,read,env_read)
if type(pid) is not int or pid <= 0:
stage = 'invalid-property-pid'
elif type(server) is not int or server <= 0:
stage = 'server-pid-unavailable'
elif pid != server:
stage = 'property-server-pid-disagreement'
elif not property_process['readable']:
stage = 'native-process-unreadable'
elif property_process['same_user'] is not True:
stage = 'foreign-user'
elif not property_process['game_executable_match']:
stage = 'game-executable-mismatch'
elif property_process['prefix_matches'] is not True:
stage = 'private-prefix-unverified'
elif property_process['display_matches'] is not True:
stage = 'private-display-unverified'
elif not property_process['process_stable']:
stage = 'native-process-changed'
else:
stage = 'checks-passed'
return {'window':window.get('window'), 'pid':pid, 'server_pid':server,
'rejection_stage':stage, 'property_process':property_process, 'server_process':server_process,
'xres_query':window.get('xres_query')}
class WindowAttributes(C.Structure):
# Exact installed Xlib.h layout; Bool is int and XIDs are unsigned long.
_fields_ = [('x',C.c_int),('y',C.c_int),('width',C.c_int),('height',C.c_int),
('border_width',C.c_int),('depth',C.c_int),('visual',C.c_void_p),
('root',C.c_ulong),('window_class',C.c_int),('bit_gravity',C.c_int),
('win_gravity',C.c_int),('backing_store',C.c_int),('backing_planes',C.c_ulong),
('backing_pixel',C.c_ulong),('save_under',C.c_int),('colormap',C.c_ulong),
('map_installed',C.c_int),('map_state',C.c_int),('all_event_masks',C.c_long),
('your_event_mask',C.c_long),('do_not_propagate_mask',C.c_long),
('override_redirect',C.c_int),('screen',C.c_void_p)]
def mapped_geometry(query, window, root):
attrs = query.attributes(window)
root_attrs = query.attributes(root)
for item in (attrs,root_attrs):
if (not isinstance(item,dict) or any(type(item.get(key)) is not int for key in
('root','window_class','map_state','width','height')) or
item['root'] != root or item['window_class'] != 1 or item['map_state'] != 2 or
not 0 < item['width'] <= 65535 or not 0 < item['height'] <= 65535):
raise ValueError('unmapped-or-invalid-window-attributes')
x,y = query.translate(window,root)
if type(x) is not int or type(y) is not int:
raise ValueError('invalid-window-root-coordinates')
clipped = [max(0,x),max(0,y),min(root_attrs['width'],x+attrs['width']),min(root_attrs['height'],y+attrs['height'])]
if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
raise ValueError('window-outside-private-root')
return {'window':window,'root':root,'map_state':attrs['map_state'],'window_class':attrs['window_class'],
'x':x,'y':y,'width':attrs['width'],'height':attrs['height'],
'root_width':root_attrs['width'],'root_height':root_attrs['height'],'clipped_bounds':clipped}
def input_target(query, owner):
"""Point-in-time read-only mapped/focus evidence; never acquires focus."""
result = {'window':owner.get('window'),'verified':False,'reason':'not-observed'}
try:
if any(type(owner.get(key)) is not int or owner[key] <= 1 for key in ('window','pid')):
raise ValueError('invalid-owned-window-identity')
root = query.root()
if type(root) is not int or root <= 1:
raise ValueError('invalid-private-root')
geometry = mapped_geometry(query,owner['window'],root)
result['geometry'] = geometry
focused,revert = query.focus()
if type(focused) is not int or focused <= 1 or type(revert) is not int or revert not in (0,1,2):
raise ValueError('no-concrete-existing-focus')
result['focus_window'] = focused
result['focus_revert_to'] = revert
chain = []; current = focused
clipped = list(geometry['clipped_bounds'])
for _ in range(32):
if current in chain or current == root or current <= 1:
raise ValueError('focus-outside-owned-window-tree')
client_pid = query.server_pid(current)
if type(client_pid) is not int or client_pid != owner['pid']:
raise ValueError('focus-client-not-owned-game')
child_geometry = mapped_geometry(query,current,root)
bounds = child_geometry['clipped_bounds']
clipped = [max(clipped[0],bounds[0]),max(clipped[1],bounds[1]),min(clipped[2],bounds[2]),min(clipped[3],bounds[3])]
if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
raise ValueError('focus-has-no-visible-owned-intersection')
chain.append(current)
if current == owner['window']:
break
tree_root,parent,_ = query.tree(current)
if tree_root != root or type(parent) is not int:
raise ValueError('focus-tree-root-mismatch')
current = parent
else:
raise ValueError('focus-ancestry-budget-exceeded')
if query.focus() != (focused,revert) or mapped_geometry(query,owner['window'],root) != geometry:
raise ValueError('focus-or-geometry-changed-during-observation')
result.update(verified=True,reason='owned-mapped-existing-focus',focus_chain=chain,
focused_clipped_bounds=clipped)
except (OSError,ValueError,TypeError,KeyError) as error:
result['reason'] = str(error)[:160]
return result
class X11:
"""Explicit display connection; all exported methods are read-only queries."""
class Spec(C.Structure):
_fields_ = [('client', C.c_ulong), ('mask', C.c_uint)]
class Client(C.Structure):
pass
def __init__(self, display):
self.x = C.CDLL('libX11.so.6')
self.r = C.CDLL('libXRes.so.1')
self.d = None
self.query_budget = MAX_WINDOWS
signatures = {
'XOpenDisplay': (C.c_void_p, [C.c_char_p]),
'XCloseDisplay': (C.c_int, [C.c_void_p]),
'XDefaultRootWindow': (C.c_ulong, [C.c_void_p]),
'XGetWindowAttributes': (C.c_int,[C.c_void_p,C.c_ulong,C.POINTER(WindowAttributes)]),
'XGetInputFocus': (C.c_int,[C.c_void_p,C.POINTER(C.c_ulong),C.POINTER(C.c_int)]),
'XTranslateCoordinates': (C.c_int,[C.c_void_p,C.c_ulong,C.c_ulong,C.c_int,C.c_int,C.POINTER(C.c_int),C.POINTER(C.c_int),C.POINTER(C.c_ulong)]),
'XInternAtom': (C.c_ulong, [C.c_void_p, C.c_char_p, C.c_int]),
'XFree': (C.c_int, [C.c_void_p]),
'XQueryTree': (C.c_int, [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_ulong), C.POINTER(C.POINTER(C.c_ulong)), C.POINTER(C.c_uint)]),
'XGetWindowProperty': (C.c_int, [C.c_void_p, C.c_ulong, C.c_ulong, C.c_long, C.c_long, C.c_int, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_int), C.POINTER(C.c_ulong), C.POINTER(C.c_ulong), C.POINTER(C.c_void_p)]),
}
for name, (ret, args) in signatures.items():
method = getattr(self.x, name); method.restype = ret; method.argtypes = args
self.r.XResQueryClientIds.argtypes = [C.c_void_p, C.c_long, C.POINTER(self.Spec), C.POINTER(C.c_long), C.POINTER(C.POINTER(self.Client))]
self.r.XResQueryClientIds.restype = C.c_int
self.r.XResGetClientPid.argtypes = [C.POINTER(self.Client)]
self.r.XResGetClientPid.restype = C.c_int
self.r.XResClientIdsDestroy.argtypes = [C.c_long, C.POINTER(self.Client)]
self.r.XResClientIdsDestroy.restype = None
self.d = self.x.XOpenDisplay(display.encode('ascii'))
if not self.d:
raise ValueError('Explicit nested XOpenDisplay failed')
def close(self):
if self.d:
self.x.XCloseDisplay(self.d); self.d = None
def prop(self, window, name):
atom = self.x.XInternAtom(self.d, name.encode(), 1)
if not atom:
return None
kind, fmt, count, after, data = C.c_ulong(), C.c_int(), C.c_ulong(), C.c_ulong(), C.c_void_p()
status = self.x.XGetWindowProperty(self.d, window, atom, 0, 256, 0, 0,
C.byref(kind), C.byref(fmt), C.byref(count), C.byref(after), C.byref(data))
try:
if status or after.value or not data.value:
return None
if name == '_NET_WM_PID':
cardinal = self.x.XInternAtom(self.d, b'CARDINAL', 1)
if fmt.value == 32 and count.value == 1 and kind.value == cardinal:
return C.cast(data, C.POINTER(C.c_ulong))[0]
return None
if fmt.value == 8 and count.value <= 1024:
return C.string_at(data, count.value).decode('utf-8', 'replace')[:256]
return None
finally:
if data.value:
self.x.XFree(data)
def server_pid(self, window):
spec = self.Spec(window, 2) # XRES_CLIENT_ID_PID_MASK, installed XRes.h
count, values = C.c_long(), C.POINTER(self.Client)()
# This installed XRes API returns Success (0), unlike XQueryTree's Bool.
status = self.r.XResQueryClientIds(self.d, 1, C.byref(spec), C.byref(count), C.byref(values))
if not hasattr(self,'pid_diagnostics'):
self.pid_diagnostics = {}
diagnostic = {'status':int(status),'count':count.value,'mask':None}
self.pid_diagnostics[window] = diagnostic
if status != 0:
return None
try:
if count.value == 1 and values:
diagnostic['mask'] = values[0].spec.mask
if count.value != 1 or not values or values[0].spec.mask != 2:
return None
pid = self.r.XResGetClientPid(values)
return pid if pid > 0 else None
finally:
self.r.XResClientIdsDestroy(count, values)
def budget(self):
self.query_budget -= 1
if self.query_budget < 0:
raise ValueError('native-read-query-budget-exceeded')
def root(self):
return self.x.XDefaultRootWindow(self.d)
def attributes(self,window):
self.budget()
attrs = WindowAttributes()
if not self.x.XGetWindowAttributes(self.d,window,C.byref(attrs)):
raise ValueError('native-window-attributes-unavailable')
return {key:getattr(attrs,key) for key in ('root','window_class','map_state','width','height')}
def focus(self):
self.budget()
window,revert = C.c_ulong(),C.c_int()
if not self.x.XGetInputFocus(self.d,C.byref(window),C.byref(revert)):
raise ValueError('native-input-focus-unavailable')
return window.value,revert.value
def translate(self,window,root):
self.budget()
x,y,child = C.c_int(),C.c_int(),C.c_ulong()
if not self.x.XTranslateCoordinates(self.d,window,root,0,0,C.byref(x),C.byref(y),C.byref(child)):
raise ValueError('native-window-root-translation-unavailable')
return x.value,y.value
def tree(self,window):
self.budget()
root,parent,children,count = C.c_ulong(),C.c_ulong(),C.POINTER(C.c_ulong)(),C.c_uint()
if not self.x.XQueryTree(self.d,window,C.byref(root),C.byref(parent),C.byref(children),C.byref(count)):
raise ValueError('Window tree changed during query')
try:
if count.value > MAX_WINDOWS:
raise ValueError('Window tree exceeds bounded query budget')
return root.value,parent.value,[children[i] for i in range(count.value)]
finally:
if children:
self.x.XFree(children)
def windows(self):
pending = [self.x.XDefaultRootWindow(self.d)]; seen = set(); found = []
while pending:
if len(seen) >= MAX_WINDOWS:
raise ValueError('Window tree exceeds bounded query budget')
window = pending.pop()
if window in seen:
raise ValueError('Unexpected repeated window tree entry')
seen.add(window)
pid = self.prop(window, '_NET_WM_PID')
if pid is not None:
server_pid = self.server_pid(window)
found.append({'window': window, 'pid': pid, 'server_pid': server_pid,
'xres_query':self.pid_diagnostics.get(window),
'title': self.prop(window, '_NET_WM_NAME') or self.prop(window, 'WM_NAME') or ''})
_,_,children = self.tree(window)
if len(children) + len(pending) + len(seen) > MAX_WINDOWS:
raise ValueError('Window tree exceeds bounded query budget')
pending.extend(children)
return found
X11.Client._fields_ = [('spec', X11.Spec), ('length', C.c_long), ('value', C.c_void_p)]
def observe(args, query_class=X11):
before = binding(args.host_display, args.prefix)
query = query_class(before['display'])
try:
windows = query.windows()
owned = [match for window in windows if (match := owned_window(window,before,args.prefix,args.game_executable))]
targets = [input_target(query,owner) for owner in owned[:16]]
# Recheck the exact native process after geometry/focus reads. Never
# promote a stale process identity into an input target.
for target,owner in zip(targets,owned):
current = owned_window(owner,before,args.prefix,args.game_executable)
if current is None or current['process'] != owner['process']:
target.update(verified=False,reason='game-process-changed-during-target-observation')
finally:
query.close()
if binding(args.host_display, args.prefix) != before:
raise ValueError('Display ownership changed during read-only query')
diagnostics = [window_diagnostic(window,before,args.prefix,args.game_executable) for window in windows[:16]]
valid_targets = [target for target in targets if target['verified']]
# Nested owned windows can both contain focus. Prefer the exact focused
# owned window; otherwise require exactly one unambiguous owned ancestor.
exact_targets = [target for target in valid_targets if target.get('focus_window') == target['window']]
chosen = exact_targets if exact_targets else valid_targets
return {'binding':before,'owned_windows':owned,'owned_game_window_verified':bool(owned),
'input_target_verified':len(chosen) == 1,'input_target':chosen[0] if len(chosen) == 1 else None,
'input_target_observations':targets,'input_target_limit':16,
'window_count':len(windows),'window_diagnostic_limit':16,'window_diagnostics':diagnostics,
'unverified_window_count':len(windows)-len(owned)}
def validate_paths(args):
for path in (args.prefix, args.game_executable, args.profile):
if not path.is_absolute() or path != path.resolve() or not path.exists():
raise ValueError('Expected existing canonical runtime paths')
if not args.prefix.is_dir() or not args.game_executable.is_file() or not args.profile.is_dir():
raise ValueError('Invalid prefix, executable or profile type')
if not args.profile.is_relative_to('/tmp') or args.profile == Path('/tmp'):
raise ValueError('Report profile must be an owned private /tmp profile')
marker = args.profile/'.erri-game-profile.json'
if marker.is_symlink() or json.loads(marker.read_text()) != PROFILE_ID:
raise ValueError('Profile is not marked as disposable ordinary-game profile')
if (not args.report.is_absolute() or args.report.parent != args.profile or
args.report.name in ('', '.', '..') or args.report.is_symlink() or (args.report.exists() and not args.query_worker)):
raise ValueError('Report must be an exclusively new direct child of private profile')
if args.profile.stat().st_uid != os.getuid() or not 1 <= args.observe_seconds <= 30:
raise ValueError('Invalid report owner or observation bound')
def parse_args(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host-display', required=True)
parser.add_argument('--prefix', type=Path, required=True)
parser.add_argument('--game-executable', type=Path, required=True)
parser.add_argument('--profile', type=Path, required=True)
parser.add_argument('--report', type=Path, required=True)
parser.add_argument('--observe-seconds', type=float, default=20)
parser.add_argument('--query-worker', action='store_true', help=argparse.SUPPRESS)
parser.add_argument('command', nargs=argparse.REMAINDER)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
validate_paths(args)
bound = binding(args.host_display, args.prefix)
if args.query_worker:
print(json.dumps(observe(args)))
return 0
command = args.command[1:] if args.command[:1] == ['--'] else args.command
if (len(command) < 3 or Path(command[0]).name != 'proton' or
not Path(command[0]).is_absolute() or not Path(command[0]).is_file() or
command[1] != 'run' or command[2] != str(args.game_executable)):
raise ValueError('Wrapper command must contain the exact expected game executable')
# A new report is a prerequisite for starting the requested child. No overwrite.
fd = os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
report = {'schema': 1, 'kind': 'headless-display-read-only', 'binding': bound,
'owned_game_window_verified': False, 'input_target_verified': False, 'read_only': True, 'input_sent': False, 'release_qualified': False,
'scope': 'Read-only private X11 process/window ownership; no input or gameplay proof',
'helper_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
'wrapper': identity(process(os.getpid())), 'command': command,
'prefix': str(args.prefix), 'profile': str(args.profile),
'game_executable': str(args.game_executable), 'report': str(args.report)}
report.update(query_attempts=0,successful_queries=0,first_window_count=None,last_window_count=None,
first_query_error=None,last_query_error=None,last_query_status='not-started')
child = None
previous = {}
received = []
def forward(signum, frame):
received.append(signum)
if child is not None and child.poll() is None:
child.send_signal(signum)
try:
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
previous[signum] = signal.signal(signum, forward)
child = subprocess.Popen(command) # inherit existing owned process group and streams
for signum in received:
if child.poll() is None:
child.send_signal(signum)
deadline = time.monotonic() + args.observe_seconds
worker = [sys.executable, str(Path(__file__).resolve()), '--query-worker', '--host-display', args.host_display,
'--prefix', str(args.prefix), '--game-executable', str(args.game_executable),
'--profile', str(args.profile), '--report', str(args.report), '--observe-seconds', str(args.observe_seconds)]
while child.poll() is None and not received and time.monotonic() < deadline:
report['query_attempts'] += 1
try:
result = subprocess.run(worker, capture_output=True, text=True, timeout=min(3, max(0.01, deadline-time.monotonic())))
if result.returncode:
report['last_query_status'] = 'worker-exit'
report['last_query_error'] = 'Read-only worker failed; exit '+str(result.returncode)
if report['first_query_error'] is None:
report['first_query_error'] = report['last_query_error']
else:
observed = json.loads(result.stdout)
if observed.get('binding') != bound:
raise ValueError('Original display process identity changed')
report.update(observed)
report['successful_queries'] += 1
report['last_query_status'] = 'ok'
report['last_window_count'] = observed.get('window_count')
if report['first_window_count'] is None:
report['first_window_count'] = observed.get('window_count')
if report['owned_game_window_verified']:
break
except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError) as error:
report['last_query_status'] = 'query-exception'
report['last_query_error'] = type(error).__name__
if report['first_query_error'] is None:
report['first_query_error'] = report['last_query_error']
time.sleep(0.2)
report['observation_finished'] = True
with os.fdopen(fd, 'w') as stream:
fd = None
json.dump(report, stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
returncode = child.wait() # do not alter engine lifetime; outer runner owns timeout/cleanup
finally:
if fd is not None:
os.close(fd)
for signum, handler in previous.items():
signal.signal(signum, handler)
if returncode < 0:
signum = -returncode
signal.signal(signum, signal.SIG_DFL)
os.kill(os.getpid(), signum)
return returncode
if __name__ == '__main__':
try:
raise SystemExit(main())
except (OSError, ValueError) as error:
print('Display probe refused: '+str(error), file=sys.stderr)
raise SystemExit(2)