#!/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)