Files
EscapistsReintroduced/docs/headless-display-probe.md
T

54 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Read-only private display ownership probe
`tools/headless_display_probe.py` is an opt-in wrapper to place **inside** the existing ordinary-game Gamescope command. It queries X11 ownership only. It does not send keyboard/mouse input, change focus, move a pointer, alter a camera, or establish gameplay acceptance. [The current ordinary run](evidence/game-2026-09-19-163745-observer-display/report.json) verifies the private game window after correcting Proton’s trailing-slash prefix spelling. No input is sent.
Integration shape (the ordinary runner must supply canonical paths and an exclusively new run-ID report filename):
```text
gamescope --backend headless [existing arguments] --
python tools/headless_display_probe.py
--host-display ORIGINAL_HOST_DISPLAY
--prefix PRIVATE_COMPAT/pfx
--game-executable ABS_GAME_EXE
--profile OWNED_PRIVATE_PROFILE
--report OWNED_PRIVATE_PROFILE/display-ownership-RUN_ID.json
--observe-seconds 30
-- ABS_PROTON run ABS_GAME_EXE [unchanged native arguments]
```
This is not a command to run outside the existing runner. The parent retains its prefix lock, muted private stream, source snapshot, overall timeout, process-group and prefix cleanup. Default commands stay unchanged. Capture the original host DISPLAY **before** Gamescope; an empty original value is allowed. The wrapper receives the nested DISPLAY from Gamescope and never falls back to the desktop.
Before starting its child, the wrapper requires an existing canonical `/tmp` profile with the runner's disposable-profile marker, matching inherited WINEPREFIX, explicit local Unix DISPLAY distinct from the host display, and a same-user native Gamescope ancestor with `--backend headless` before `--`. It finds exactly one same-user native Xwayland using that display and refuses `-enable-ei-portal`. It accepts either a verified Gamescope descendant or the fully verified shared-listener relationship described below. Both abstract and filesystem listening socket addresses for that display are checked in `/proc/net/unix`; **every present listener must be held by that Xwayland's file descriptors**. Thus a failed nested-server startup cannot accidentally direct the query to a still-running desktop listener. The PID/start-time, executable/argument stability and socket identities are checked before opening X11; the full returned ownership binding is compared around each observation.
The actual installed Gamescope/Xwayland pair reparents Xwayland to a host reaper, so ancestry alone refused three diagnostic launches before Proton started. In the third retained launch log, `game-launch-c60ac1b5c900422090549f458c39ee0a.log`, Gamescope PID42495/start80226105 and Xwayland PID42503/start80226109 were same-user; Xwayland's PPID was1670. Both independently held **all** selected-display listening inodes66010558 and66010557, both inherited the exact wrapper WINEPREFIX and valid private `application.id=org.escapistsrereintroduced.game.<32 hex>` PULSE_PROP nonce, and both identities remained stable. All evidence was collected from process metadata and socket descriptors, without opening a display. The failed launches remain failures; this evidence only supports a different process-ownership check.
For a reparented process, authorization requires **every** independent check: exact native Xwayland name/display/same user/no portal, candidate start not older than the verified headless Gamescope ancestor, a valid wrapper run nonce, exact prefix and nonce matches in both processes, both processes holding every selected-display listener, the identical nonempty unique inode set, and stable PID/start identities. Missing/unreadable/mismatched metadata fails closed. The complete shared relationship is recomputed at the end of binding and again around the X11 observation. No display/title/prefix/nonce alone is sufficient. The report records `binding.relationship.mode` as `descendant` or `shared-listeners`; the latter includes every verified Boolean and the listener inode list. This is local same-user runtime containment, not a security boundary against a malicious same-user process forging its environment or manipulating the observer.
Queries run in a separate process, each limited to three seconds, within a 1–30 second total observation deadline. The native adapter uses explicit `XOpenDisplay`, a bounded 1024-window tree, bounded title properties, `_NET_WM_PID`, and the XRes server-reported client PID. An accepted window requires agreement between both PIDs, a live same-user process with an exact full game executable `argv[0]` (Unix or Wine Z: spelling), the exact private prefix and nested display, and unchanged process start time. The observer reports only accepted window titles and the count of unverified windows. It does not dump environment variables or unrelated window contents. A Wine client/PE PID namespace discrepancy remains **unverified**, not an excuse to weaken ownership.
The installed libraries are `libX11.so.6` and `libXRes.so.1`; declarations come from installed `Xlib.h`/`XRes.h`. In particular, `XResQueryClientIds` returns `Success` (zero), unlike `XQueryTree`'s Boolean convention. Installed Xwayland documentation explicitly warns nested compositors against the EI input-emulation portal. XTEST/input APIs are deliberately absent from this helper.
The report is created with exclusive creation, no symlink following and mode0600, directly under the marked profile. Existing reports are preserved and refused. Schema1, kind `headless-display-read-only`, includes:
- `owned_game_window_verified`, `read_only: true`, `input_sent: false`, and `release_qualified: false`;
- helper SHA256, wrapper PID/start/UID, exact wrapped command, prefix/profile/executable/report paths;
- nested display, Gamescope/Xwayland PID/start/UID and listening-socket bindings;
- accepted window IDs, server/native PIDs, filtered titles and native process identity;
- a bounded query-failure classification when observation could not establish ownership.
A false/missing/refused report must fail the parent's opt-in diagnostic. It never overrides native error checks. After observation, the wrapper waits for the original child normally and preserves its exit status; INT/TERM/HUP are forwarded to that child, with the parent's existing process-group cleanup still authoritative. The observer does not keep the engine alive longer than the parent allows, kill other prefix processes, reconnect to a different display identity, or convert a successful startup into input/route proof.
`tests/test_headless_display_probe.py` substitutes all native connections and child processes. It covers host-display aliases, wrong ancestor/backend, EI portal rejection, socket ownership and fallback prevention, game PID/prefix/path/display matching, PID reuse, exclusive profile reports, malformed bounds, native XRes status convention, every missing/false/non-Boolean shared-ownership check, prefix/nonce mismatch, missing shared descriptors, older/reused process identity, changed listener evidence, query failure/timeout, signal forwarding and child exit preservation. Native ownership is recorded in [the current ordinary archive](evidence/game-2026-09-19-163745-observer-display/display-ownership.json.txt); earlier diagnostic refusals remain failed.
The fifth actual diagnostic identified matching native game/window/server PID, exact executable and private display, but a false prefix comparison. Installed `Proton - Experimental/proton` (SHA256 `05116cb33e020b6ae6fd8578e7d59f082ffd5b4530d6ae7cdbe948ba96406332`) constructs `prefix_dir = self.path("pfx/")` at line610, returns `self.base_dir + d` at619, and assigns that value to `self.env["WINEPREFIX"]` at1724. Its inherited prefix therefore has a trailing slash. The observer now considers **only the exact expected canonical prefix with zero or more trailing `/` characters** equivalent. It does not resolve process-provided paths or accept neighbouring prefixes, interior duplicate separators, dot/dotdot components, relative paths or symlink spellings. Native executable/display/PID/socket/run-nonce checks remain independent and unchanged. The ordinary runner's `assert_prefix_idle` uses the same narrow spelling rule, so an existing Wine or Workbench process with Proton's `pfx/` environment correctly prevents a second launch. This fixes both a diagnostic false rejection and the previous exact-byte idle-check omission; it does not provide any input or gameplay qualification.
## Separate mapped-window and existing-focus observation
The helper additionally reports `input_target_verified`, `input_target` and bounded `input_target_observations`. These do **not** change `owned_game_window_verified` or the ordinary runner's startup verdict. An owned window can be hidden, clipped away, unfocused or transient; ownership alone never permits a later input action. The current wrapper still stops its observation once game-window ownership is established, so an early unmapped window may yield ownership=true and input-target=false. Such a report remains insufficient for input; no automatic focus repair or implicit input attempt follows it.
An input target requires an exact verified game's native window with Xlib `InputOutput` class and `IsViewable` map state, the same private root, positive protocol-bounded dimensions and a nonempty rectangle clipped to the actual root geometry. Positions come from `XTranslateCoordinates`, not the requested Gamescope dimensions. Existing `XGetInputFocus` must identify that window or a maximum32-level descendant path back to it. Every path node must be mapped, share the original game's server-reported native PID and private root, and retain a nonempty visible intersection through its ancestors. None/PointerRoot, another client, another window tree, a cycle, an overlong path or unavailable attributes fails the separate check. Focus and top-window geometry are sampled again at the end; native game PID/start and the full display binding are also rechecked. Ambiguous owned targets do not qualify; an exact focused owned window takes precedence over its owned ancestors.
The recorded geometry includes actual window/root dimensions, translated origin and clipped bounds; the focus evidence includes existing focus ID, revert mode and the verified owned path. No other client titles are added. This is a bounded point-in-time snapshot, **not** an atomic guarantee for a later input operation, a proof of native UI focus/absence of dialogs, an occlusion test, or proof that Wine consumes XTest/raw mouse events. A future input helper must revalidate immediately before each operation. The separate [opt-in keyboard experiment](headless-input-proof.md) now performs those repeated checks; this display helper remains read-only.
The adapter uses installed `XWindowAttributes`, `XGetWindowAttributes`, `XGetInputFocus` and `XTranslateCoordinates` declarations. Its ABI test compiles a tiny program against installed Xlib headers, comparing struct size and every field offset with ctypes and checking the three function signatures. That program only prints sizeof/offsetof constants; it does not link X11 or open a display. Fake adapters test direct/descendant focus, foreign clients, wrong roots, InputOnly/unmapped windows, zero/oversized dimensions, root/ancestor clipping, transient focus/geometry/process changes and depth limits. Tree, geometry and focus queries share a1024-operation structural budget; existing1024-window,16-target, three-second worker and30-second maximum observation bounds remain. All APIs are read-only; focus, raise, pointer, keyboard and mouse mutation functions remain absent.