summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/04-implementation/05-graphics-execution-plan.md721
-rw-r--r--tools/guest-diag/guest_pin_stream.py3
-rw-r--r--tools/guest-diag/pin_gr_stream.py3
-rwxr-xr-xtools/guest-diag/pin_supervisor.sh9
-rw-r--r--tools/guest-diag/qga_snip.py41
-rw-r--r--tools/guest-diag/sdl_cap.c229
-rw-r--r--tools/guest-diag/xfocus.c69
-rw-r--r--tools/guest-diag/xgrab.c115
-rw-r--r--tools/guest-diag/xresize.c66
-rw-r--r--tools/guest-diag/xtest_move.c126
10 files changed, 1379 insertions, 3 deletions
diff --git a/docs/04-implementation/05-graphics-execution-plan.md b/docs/04-implementation/05-graphics-execution-plan.md
new file mode 100644
index 0000000..6089548
--- /dev/null
+++ b/docs/04-implementation/05-graphics-execution-plan.md
@@ -0,0 +1,721 @@
+# Graphics execution plan: real GL pixels through the emulated vGPU
+
+> **Status: OPEN (2026-10-28).** This is the implementation-ready plan for the
+> **graphics execution layer**: the component that must execute the guest
+> NVIDIA vGPU driver's OpenGL/3D command streams on the physical GPU so that
+> glxgears and GZDoom's OpenGL renderer produce real pixels through the
+> emulated mdev. Nothing here is implemented yet beyond the pieces cited;
+> every statement carries a code location or a measured session finding.
+>
+> Evidence base: `docs/04-implementation/03-execution-frontier.md` (the CUDA
+> compute chain, proven), `docs/04-implementation/04-real-render-walls.md`
+> (§8-§21, all measured graphics walls), the SESSION 149 R12-R18 round log
+> (`session-149-BRIEF.md`: the M1 C597 recognizer, M2 materialization, M3
+> SKEDCHECK clear, R17 real-SASS execution), and SESSION 144 addenda 9/10/16
+> (the X/GL wait anatomy). The vendored 570.144 wire protocol and the public
+> class headers are the interface authority; no guest driver change is
+> allowed.
+>
+> Hard rules carried from the project: **no other GPU** (virtio-gpu/virgl,
+> VirtualGL, llvmpipe, any second device are banned for this work), **no
+> fake/labelled software rendering** presented as GL, **host-only emulation**
+> (guest runs stock `nvidia.ko`), and **no host reboot** (AGENTS.md hard
+> rule; a reboot needs the handoff update plus explicit human approval).
+
+---
+
+## 0. What "real pixels" means here
+
+The acceptance target is: a stock guest GL application's draw commands are
+executed by the physical GPU's **fixed-function 3D pipeline** (vertex fetch,
+primitive assembly, rasterization, fragment work, ROP), its render target is
+readable by the guest, and that image reaches the capture path through the
+emulated mdev. The pixels must not come from Mesa, llvmpipe, virgl, a
+software rasterizer, or a host display round-trip.
+
+Two consequences drive every milestone below:
+
+1. **A compute launch is not rasterization.** The SESSION 149 M1/M3 work
+ proved the engine will execute a *synthesized* C5C0 compute launch built
+ from the GL stream and will retire it
+ (`poll +500ms qmd_rel=0x600df00d`, R17), but the GL client's draws need
+ the 3D pipeline and the client's own per-draw state; rasterization is
+ fixed-function GR (`session-149-BRIEF.md:288-291`).
+2. **The GL client's own channel is the source of truth.** The C597 section
+ the emulation can capture today comes from a readable channel (the X
+ server side); the GL client's GPFIFO ring (`0xbeef0100` family,
+ `0xbfef0002` observed in SESSION 153) is where its context-init uploads
+ and per-frame draws live. Until that ring is readable, no approach can
+ derive a correct draw (`session-149-BRIEF.md:438-451`).
+
+---
+
+## A. Gap list, ordered by dependency
+
+Each gap lists what exists (with the code location that implements it), what
+is missing, and the measurable gate that closes it. A gap is closed only when
+its gate has been met on a fresh cycle with evidence kept.
+
+### G-A Readability: the GL client's ring and pushes must read
+
+**Exists.** The pump resolves rings through an autopin ladder (notifier-
+derived, USERD-derived, trusted walk, shadow scan, USERD-shape scan) with
+dead-entry rejection: `kernel/gsp_sema_service.c:16534-16602`
+(`svc_ring_entry_dead`, `svc_ring_walk_live`, `svc_gr_ring_candidate`),
+`:17891`, `:17972`, `:18013`, `:18075`, `:18123`, `:18242`, `:18298`. The
+failure is witnessed at `:17633` (`sema-chan: GR ring h=... RAM content scan
+found no candidate in N slice(s)`). Push reads report through `sema-rdw`
+(`:28622`) and `sema-pushdec` (`:28868-28882`). The guest pin stream's client
+selection and windows are `tools/guest-diag/guest_pin_stream.py:1429-1475`,
+`tools/guest-diag/pin_gr_stream.py:134-160`, and the supervisor already
+defaults to the widened GL prefix set
+(`tools/guest-diag/pin_supervisor.sh:70-83`:
+`cdt,va,victory,Xorg,X,glxgears,gzdoom`).
+
+**Missing.** For the GL client channel the ring page is not found by content
+at the scan instants ("GR ring h=0xbfef0002 rank=2 ... no candidate in 12
+slice(s) (va=0x1203cfff0)", SESSION 153 item 3). The client's allocations are
+just above the old 64 MB capture bound in the Vulkan case
+(`docs/04-implementation/04-real-render-walls.md` §14-§15), and the pump's
+ring candidates are shape-based; a client ring written late (after the DMA
+test window) is missed. The guest pagemap stream (`#pid` + `va,pa,n` lines,
+`guest_pin_stream.py:8-12`) is the authoritative VA->PA source but was not
+covering the GL client until SESSION 153; the supervisor default now does.
+
+**Gate G-A.** On a run with glxgears alive for 60 s and the standard X
+recipe: (a) zero new `sema-chan: GR ring h=0x... found no candidate` rows for
+the GL client's handle; (b) every client push logs `sema-pushdec ... src>0
+unreadable=0`; (c) at least one entry is consumed from the client ring
+(pump advances `last_get`; `sema-chan: entry[...] h=0x... eng=0` with a sane
+`va/len`); (d) the X channel (`0x1000013`) stays `plausible=1` (SESSION 153
+item 1).
+
+### G-B Capture: the client's C597 sections and per-frame draws must decode
+
+**Exists.** A gated recognizer (`sema_gl_xlate`, default 0,
+`kernel/gsp_sema_service.c:6812-6815`) opens a section at a `SET_OBJECT`
+class bind of `0xC597` (TURING_A) on the channel subchannel and collects
+observed VA operands: program (`0x2014`/`0x2018` runs),
+tex header/sampler pools (`0x1574`/`0x155c`), vertex substitute (`0x0f84`),
+upload source/destination (`0x180`), and the LAUNCH_DMA/LOAD_INLINE_DATA
+payload (`0x1b0`) — `:6945-7049`, `:11993-12032`. It synthesizes a 64-word
+QMD and captures it through the unchanged GR candidate ring
+(`:7060-7287`, `:6686`). The client's memory-writing uploads are applied by
+a standalone subchannel-0 pair tracker (`:12045-12084`) and the section
+walker (`:7194-7217`) through `svc_grce_inline_write` (`:8979-9060`).
+Completion of the capture is gated by content trust: a plausible stream
+admitted by `cap_trust` (`:28995`, `:29069-29080`), with hash-verified
+rewind replays admitted (`:28513-28517`, `:29106-29119`); the flush guard
+requires `seen/prog/param/rel/trusted` (`:7082-7084`). Witness lines:
+`sema-gl: section captured ...` (`:7248`), `sema-gl: uploads applied=...`
+(`:7213`), `sema-gl-lid: ...` (`:12073`), `sema-gl: M3 code src=...`
+(`:7261`).
+
+**Missing.**
+1. The recognizer's method vocabulary covers init-shaped operands only. The
+ per-draw 3D state and draw-issuing methods (vertex streams/index, render
+ target and ZS, viewport/scissor, blend/depth/ROP state, draw calls) are
+ not decoded (`svc_gl_field` handles six addresses,
+ `:6945-7049`; the C597 stream carries far more).
+2. The GL program/constant delivery is a context-init upload on the client
+ channel and is lost today: R17 measured that no readable push ever writes
+ `prog=0x121201b00` (`session-149-BRIEF.md:276-285`). The synthesized
+ launch therefore only runs a 12-28 B fallback blob or an operator hex
+ program (`sema_gl_prog_hex`, `:6877-6913`) — a development crutch, not
+ pixels.
+3. Capture must survive the "one-shot setup push rewritten in place"
+ class: re-captures come from hash-verified replays now
+ (`:29069-29080`), but the per-frame draws are separate entries and need
+ the same treatment per entry.
+
+**Gate G-B.** For ≥3 consecutive frames of a live GL client: `sema-gl:
+section captured` on the **client's** handle with nonzero `prog/param/rel`,
+one captured section per frame (bounded replays suppressed), and a decoded
+draw record (new `sema-gl-draw` witness) whose vertex/primitive count is
+consistent with the app (e.g. glxgears gear geometry). Program source
+priority shows `trusted-prog` (real bytes) for the context-init upload.
+
+### G-C Execution semantics: the GL work must run on the real 3D engine
+
+This is section **B**; the gap gate is M-GL1/M-GL2/M-GL3.
+
+### G-D Memory and resource mapping: every operand must be reachable by the engine and readable by the guest
+
+**Exists.** The physical backend builds engine-side page tables and shadow
+windows for launch data: Tier-1 identity install or Tier-2 shadow copy
+(`kernel/gsp_phys_booter.c:19654-19724`, `:19734-19785`), a two-bank
+window-0 shadow pool of 8 pages per bank (`:1174`, `:19406-19410`) with a
+24-page shadow chunk (`:1317`), QMD-window relocation
+(`gsp_phys_rmcall_gr_qmdwin`), and a verbatim-prelude replay path that
+relocates the inline-QMD address words and data words
+(`:16878-16921`). Data pages are copied in before the doorbell and copied
+back after the retire wait (`:17693`, `:18243`, `:21966`, `:22242`).
+`svc_grce_inline_write` writes upload bytes to vidmem shadow, guest RAM, or
+the VA shadow with a once-only key (`kernel/gsp_sema_service.c:8979-9060`).
+
+**Missing.**
+1. Address-word relocation is QMD-only (words 32-49 and the indirect-page
+ pointer fixup at `booter:19765-19779`). A 3D method stream names VAs at
+ class-specific offsets; each must be resolved to a shadowed page and the
+ word rewritten (or the page mapped at its guest VA).
+2. The engine-visible page budget is small: 16 pages per request
+ (`kernel/gsp_phys_booter.h:355`), 8 inline records (`:356`), 8 pages per
+ win0 bank (`booter:1174`). A GL frame names program + constants + texture
+ pools + vertex stream + render target + ZS; the shadow pool and the
+ per-request page list must grow or pages must be grouped per buffer.
+3. Render-target writeback: the copy-out tracks only the launch's own data
+ pages; a 3D draw writes the RT, which must be mapped into guest-visible
+ memory (BAR1 shadow or pinned guest RAM) and copied back on the
+ completion edge, exactly like the current bounce/copy-out but for the RT
+ and depth surfaces.
+4. Program/constant bytes: R17's measurement stands — the program page is
+ empty in every host-visible source until the CE/LID delivery executes
+ (`docs/04-implementation/04-real-render-walls.md` §20-§21).
+
+**Gate G-D.** Engine fetch of the 3D program/params/vertex/RT pages with
+`FAULT-PDE count=0` (`vgsp-m91: FAULT-CAP ... code_src=...`,
+`booter:18997-18999`), and a measurable post-launch change in the emulated
+vidmem at the RT VA (>0 changed dwords in the `dump_vidmem map` class,
+`docs/04-implementation/04-real-render-walls.md` §11).
+
+### G-E Completion and sync: the guest must be released by real completions
+
+**Exists.** Engine completion is polled (`qmd_rel/ch_rel`,
+`booter:20814-20825`), the launch's release0 can be redirected to the module
+semaphore and relayed (`booter:19499-19520`), the hold edge and the
+backend-outcome edge author the guest word (`kernel/gsp_sema_service.c:
+21329-21345`, `:21298-21322`), and failures synthesize a client-visible
+channel error (`SYNTH-FAULT`, `:21400-21448`, `GR-COMPLETE-SW`, `:21504`).
+The pre-built ring continuation beyond GP_PUT is swept for releases
+(`sema_gr_ahead`, `:26564-26566`, `svc_gr_ahead_push`, `:26809`).
+
+**Missing.** The guest GL client's waits are still satisfied by the give-up
+ladder or by host pokes: the X server's present wait spins on the X channel
+member/counter pair (S144 addendum 12; the mirror thread `sema_xw_mirror`
+`kernel/main.c:3103-3129`), and the GL client's `PhaseWait` compares
+`obj+0x558` against a word in the shared `memfd:/.nvidia_drv` (+0x8) that the
+emulation never advances (SESSION 149 R3, `session-149-BRIEF.md:115-126`:
+"one u32 write 3 → 128.8 FPS", but only via `/proc/<pid>/mem`). The correct
+long-term fix is the one named in addendum 16b §C.4: **the emulation
+completes the real syncs so the equality is satisfied, with no lying pokes**.
+
+**Gate G-E.** M-GL4 (below): ≥60 fps sustained with no forcing daemon, no
+`sema_xw_mirror` target written, and the guest's waits exiting on a natural
+completion edge.
+
+### G-F Present/frame path: the rendered image must land where the guest and the capture can read it
+
+**Exists.** Guest-side capture tools: `tools/guest-diag/sdl_cap.c` (SDL swap
+hook → `glReadPixels` → GZF2 record stream, `:1-18`, `:188-229`),
+`tools/guest-diag/fbcopy_pub.c` (BAR1 scanout copy → kvmfr publisher),
+`tools/guest-diag/qga_fetch.py` (QGA file fetch), host decoder
+`tools/lg/frame_reader.py` and the resample/encode scripts.
+
+**Missing.** On the emulated-vGPU stack the X screen is black for GL windows
+(DRI3-present path; SESSION 152) and `xwd` hangs (R4/R5; no rendered pixels
+exist anywhere). The present path (the C5B5 copy-class and 902D 2D parts of
+the GL push, R13) is decoded for releases but not executed as a display
+path, and the capture must come from the GL client's swap, not from X.
+
+**Gate G-F.** M-GL2 (glxgears non-black) and M-GL3 (GZDoom).
+
+### G-G Concurrency: two VMs, two mdevs
+
+**Exists (designed in).** The service thread iterates every open device each
+pass (`kernel/gsp_sema_service.c:32119-32236`), with the explicit note that
+"with two, both guests get a pump turn per iteration (hardware forwarding
+stays serialized by the single-inflight slot)" (`:32161-32167`). Per-guest
+sessions exist (`SVC_MAX_GUESTS = 8`, `:728-736`), and the guest-RAM ranges
+are registered per device at boot (`/tmp/opencode/start_vm_keep.sh:210-213`).
+
+**Missing / to verify.** Single-inflight forwarding and holds
+(`:19878`, `:25528-25545`); VA-keyed global tables that assume one guest's VAs
+do not co-reside: the write/read ledger is keyed by VA page only and its own
+comment says collisions "only occur ... across clients whose pages do not
+co-reside in time" (`:7547-7559`), plus `svc_gl` (`:6863`),
+`svc_gr_cand_ring` (`:6493`), `svc_gr_seen` (`:6547`), `svc_grpark` (`:7429`);
+the win0 banks are module-global per submit epoch (`booter:19406-19410`); the
+server-managed VAS relocation windows are a finite global set
+(`SVC_GR_DATA_WINS_MAX 31`, `:6441`). SESSION 151 avoided a second mdev "to
+avoid a second fragile RM init", so a second RM bootstrap on the same module
+is unproven.
+
+**Gate G-G.** M-GL5 (below).
+
+---
+
+## B. Execution-semantics approaches
+
+### (i) 3D method-stream replay on the real engine
+
+Forward the client's captured C597 method stream (class bind + 3D state +
+upload/draw methods + in-stream releases) to the module's real GR channel
+and let the fixed-function pipeline execute it.
+
+What must be mirrored/provided:
+
+| Requirement | Where it stands |
+|---|---|
+| Real execution channel/context | Mode 35 "real-client user lane" replays the 570.144 client's compute-context sequence and executes on V7 (`booter:924`, `:9022-9033`); the promote ladder builds MAIN/PM/PATCH and the golden image. |
+| 3D class object (0xC597) | `GSP_PHYS_RMCALL_CLASS_GR_3D 0xc597` is defined (`booter:396`) and the alloc helper is generic (`gsp_phys_rmcall_alloc_gr_object_class`, `:8936-8964`); it is constructed on the golden lane in several modes (`:9035-9043`, `:9665`), but **not on V7** (mode 35 logs "the capture carries no 0xc597 on the user lane", `:9029-9033`) — a class object must be added on V7 and validated. |
+| 3D context global buffers | Already promoted: BUNDLE_CB / PAGEPOOL / ATTRIBUTE_CB / RTV_CB_GLOBAL (`booter:531-534`, `:605-621`, golden9 sized buffers `:985-1008`). The engine context image therefore contains the 3D subcontext data. |
+| Method block | `gsp_phys_rmcall_build_gr_launch` (`:16834-16947`) emits compute init + inline QMD + PCAS + report-sem; the verbatim path (`:16878-16921`) already replays a captured raw method window and rewrites its address/QMD words. A 3D mode adds `SET_OBJECT 0xC597` + the captured state/upload/draw methods instead of the compute prelude. Note the channel's GR subchannel is 1 (`:442`) and the compute init binds `0xC5C0` (`:16693-16698`); the 3D bind replaces it per section. |
+| Operand pages | The page list is built from QMD words 32-49 (`:19678`, `:6673-6682`); it must be generalized to the recognized 3D operand VAs, with the shadow/alias machinery (`:19654-19785`) reused. |
+| Completion/sync | Keep the stream's own releases and the module release0 redirect (`:19499-19520`); the hold/relay path already authors the guest word on the engine edge (`gsp_sema_service.c:21329-21345`). |
+
+Pros: authentic rasterization is done by the hardware pipeline; no state is
+invented; the engine has already proven it fetches and executes forwarded
+work on this lane (R17 `qmd_rel=0x600df00d`); the completion machinery is
+unchanged.
+
+Cons/risks: the promoted context may still be compute-shaped (class object
+construction on a wired golden channel deadlocked in earlier experiments,
+`booter:816-822`; V7 is the tested path, so construct there); the 3D method
+vocabulary is large (state validation can silently fault); address-word
+relocation must cover every class encoding; ~6-12 operand pages per frame
+exceed today's 8-page win0 bank, requiring grouping/expansion.
+
+Effort: L. Depends on G-A, G-B.
+
+### (ii) Synthesize a "graphics-shaped" launch from the captured C597 stream
+
+On Turing, a QMD is a *compute* launch descriptor; there is no QMD that
+rasterizes. "Graphics-shaped" therefore means: derive the draw inputs and
+author the state + a DRAW method ourselves — program (`0x2014/0x2018`,
+already decoded), constant buffers (bind + LID uploads, partly decoded),
+vertex buffers (SET_VERTEX_STREAM_SUBSTITUTE is decoded only as a pointer,
+`gsp_sema_service.c:6979-6983`; the real stream/index methods are not),
+render targets/ZS (not decoded; the only observed target is the release
+target `0x120001000`, R13), viewport/scissor/blend/depth state (not decoded).
+The vertex count for the draw would be inferred, not read.
+
+Pros: fewer pages to mirror than a full replay if only draw-relevant state is
+bound; still uses the hardware rasterizer.
+
+Cons: we author state the driver owns — any wrong field silently
+mis-renders; the per-draw state lives in the client ring, so G-A/G-B are
+still prerequisites; the program bytes are still lost without G-B; it is a
+subset of (i)'s work plus extra invention.
+
+Effort: L. Risk: high. Not first.
+
+### (iii) Bounded compute translation for the subset GZDoom uses
+
+Replace each GL draw with a compute kernel (or the existing synthesized
+C5C0 launch carrying the client's real SASS) that rasterizes triangles into
+the RT.
+
+What breaks correctness, concretely:
+- **Fixed-function raster and ROP**: depth test, stencil, alpha blending,
+ culling, clipping, perspective-correct interpolation, MSAA — none exist in
+ a compute launch; reimplementing them is writing a GPU.
+- **Sampling**: mipmaps, LOD selection, anisotropic filtering, wrap modes
+ and sRGB conversion are texture-unit behaviour.
+- **Shader semantics**: the client's GLSL is already compiled to SASS by the
+ guest driver; executing that SASS as a whole-SM compute program does not
+ give it vertex inputs, varying interpolation, or per-fragment coverage —
+ the very things the 3D pipeline supplies.
+- **The claim itself**: even GZDoom's first frame uses FBOs
+ (scene → post-process), depth testing, alpha blending and mipmapped
+ textures; output would be a lookalike, not the renderer's pixels, and
+ could not be verified against a reference.
+
+Verdict: acceptable only as a **diagnostic probe** (e.g. a known-pattern
+kernel proving the lane), never as the milestone path, and it must be
+labelled as such. Not first.
+
+### Recommendation
+
+**Implement (i) first.** It is the only approach that yields authentic GL
+pixels; its difficult prerequisites (G-A ring readability, G-B stream
+capture) are shared by every alternative; and the backend already provides
+the three pieces that make it tractable: a real execution lane with a
+validated context (mode 35), a generic 0xC597 class-object allocator, and a
+verbatim method-window replay with address/QMD rewriting plus page shadowing
+and completion relay. (ii) is (i)'s draw-issuing step taken prematurely;
+(iii) trades correctness for nothing.
+
+Order of implementation inside (i): **G-A** → **G-B** → replay the captured
+*context-init* section (M-GL1, proves class bind + uploads + completion on
+the 3D lane) → replay the client's per-frame *draw* sections (M-GL2/3).
+
+---
+
+## C. Milestone ladder
+
+All gates are measured with the artifacts listed in section D; a milestone is
+met only when its gate reproduces on **two consecutive fresh cycles** (three
+for M-GL1) with the module hash recorded.
+
+### M-GL1 — Client context-init work decodes and executes; the client leaves its retry loop
+
+Gate (all in one run):
+1. GL client ring resolved: no `sema-chan: GR ring h=<client> ... found no
+ candidate` row after the first minute; an autopin row exists
+ (`sema-chan: auto-pinned GR ring ...`).
+2. Client pushes read: `sema-pushdec` rows for the client handle with
+ `src>0 unreadable=0 plausible=1`; no `sema-rdw ... rc=-2` for them.
+3. Section captured from the client: `sema-gl: section captured h=<client>`
+ with `prog/param/rel` nonzero and `replay=0` (or the hash-verified replay
+ admission), at least once per client lifetime.
+4. Forwarded and executed: `vgsp-gr: GR LAUNCH submitted h=<client>
+ ... pages>=2 shadow=1`, followed by engine evidence (`vgsp-gr: poll +...
+ qmd_rel=0x600df00d`) and either a natural release or `GR-COMPLETE-SW`.
+5. Client behaviour: glxgears no longer loops on context creation (no
+ repeated init-failure retry; the X server stays alive; `xdpyinfo` OK,
+ `DMAFAIL=0`), and the app reaches its first swap.
+6. Bounded failure hygiene: no SYNTH-FAULT storm for the accepted client
+ (≤ a handful in the window), no guest oops.
+
+Expected failure signatures (test one at a time):
+`GR ring ... found no candidate` (G-A);
+`unreadable=1 first8=00000000` (G-A, the S147 addendum 9 zero-page class);
+`GR request without candidate` / `cand_valid=0`
+(`gsp_sema_service.c:25479-25485`; G-B ordering: capture must precede the
+request);
+`GR submit refused: degenerate data set n_data=N (min M)`
+(`booter:19352`; operand list, G-D);
+`FAULT-PDE` at the program VA + `CODEHASH h0=0`
+(`booter:18806-18883`, `:18997`; G-B/G-D);
+`GR LAUNCH FAILED: timeout ... qmd_rel=...` (`booter:20825`; engine did not
+retire — class/sched fields or completion wiring).
+
+### M-GL2 — glxgears renders non-black frames
+
+Gate:
+1. A GZF2 record stream is captured from the **GL client's swap** (see D for
+ the GLX shim; the repo's `sdl_cap.c` is SDL-only — glxgears does not use
+ SDL).
+2. ≥1 frame with ≥5% of pixels above luminance 8/255 and ≥2 distinct
+ dominant colours; two frames ≥500 ms apart differ in ≥1% of pixels
+ (motion, not a static noise tile).
+3. The same time window contains the per-frame GR launch + completion in
+ dmesg, and the guest's `glxinfo` renderer is the NVIDIA driver (not Mesa).
+4. The first frames are reached without any sync poke. If a diagnostic poke
+ is used to unblock the run, that run is labelled diagnostics and does not
+ satisfy the gate.
+
+Failure signatures: swap hook fires but frames all zero → no rasterization
+(G-C); `xwd`/X path black → expected, do not use; frames show only clear
+colour → draws executed but state/vertex data wrong (G-D);
+`FAULT-CAP ... code_src=guest-hpa code64=000...` → program source lost
+(G-B).
+
+### M-GL3 — GZDoom GL opens a window and presents non-black frames through the mdev
+
+Gate:
+1. GZDoom (`vid_rendermode=4`, `vid_preferbackend=0`) runs on `DISPLAY=:0`
+ backed by the emulated vGPU (no Xvfb, no Mesa overrides).
+2. ≥300 consecutive non-black GZF2 frames from the SDL swap hook, including
+ gameplay (not only the menu); the in-frame FPS counter ≥30.
+3. dmesg shows one GR launch per rendered frame (bounded), no SYNTH-FAULT
+ loop; the X server stays alive for the whole run.
+4. Capture fetched via QGA and decoded offline; a screenshot
+ (`+screenshot`) matches the captured frame within a small pixel delta.
+
+Failure signatures: window opens, swap fires, all frames black → draws not
+executed; `GR submit refused ... n_data` → operand pages; app crashes with
+`Illegal Instruction Encoding` (ESR 0x504730=0x9) → program bytes are not
+SASS (G-B; cf. R18's single-page kernel lesson); `SYNTH-FAULT` per frame →
+forward failed or refused.
+
+### M-GL4 — 60 fps sustained in ONE VM, no forcing
+
+Gate:
+1. glxgears: "N frames in T seconds = X FPS" with X ≥ 60 over a 60 s
+ measurement (`vblank_mode=0 __GL_SYNC_TO_VBLANK=0` for the measurement
+ itself), and GZDoom's counter ≈60.
+2. **No** poke/force/mirror daemon is running and no runtime module param is
+ used to fake progress: `sema_xw_mirror` is `0,0` (stopped,
+ `kernel/main.c:3103-3129`), no `/proc/<pid>/mem` write helper, no
+ strike/deadline forcing.
+3. GZF2 inter-frame deltas: p95 ≤ 17 ms over 10 s (capture path keeps up).
+4. The X present waits exit with member advancing naturally (Xorg log WAIT
+ lines absent or resolving without the kickfix daemon), and the glcore
+ equality word no longer requires the bump.
+5. CUDA regression: `victory_mail` `VADD n=32768 bad=0` / `STATUS OK` on the
+ same boot after the GL run (battery-first ordering).
+
+Failure signature: the two ~11 s glcore waits return (the app's own strike
+ladder; S144 addendum 16b) → G-E; X WAIT with static member → present
+completion not authored; `GR-STALL-backend` per frame → completion is
+software-only and the app's ladder is what "ran".
+
+### M-GL5 — Two VMs, two mdevs concurrently
+
+Verification checklist before the gate:
+1. Second mdev creates and its guest RM bootstraps (the S151 open question:
+ "a second fragile RM init"); both devices appear in the service loop
+ (`gsp_sema_service.c:32119-32236`).
+2. Per-guest isolation of the shared tables: `svc_ledger` VA-only key
+ (`:7547-7559`), `svc_gl` (`:6863`), `svc_gr_cand_ring` (`:6493`),
+ `svc_gr_seen` (`:6547`), `svc_grpark` (`:7429`); either prove guest VAs do
+ not collide in practice or key them by `(vgpu, va)`.
+3. Guest RAM: both VMs' memory-backend ranges registered and resolving
+ (the cycle registers one file per guest; verify the per-device map).
+4. Server VAS relocation: no two guests' data windows alias in the managed
+ region (`SVC_GR_DATA_WINS_MAX 31`, `:6441`; comment `:6436-6439`).
+5. Win0 banks/epochs (`booter:19406-19410`) are safe under serialized
+ submissions; single-inflight holds (`:19878`) do not starve one guest.
+
+Gate: both VMs run glxgears and GZDoom for ≥10 minutes; both present
+non-black frames; each ≥30 fps (60 target); `victory_mail` green in both
+afterwards; no cross-guest data anomalies (`sema-ledger: DIVERGE` /
+`LEDGER-RESET` absent), no host oops, no guest wedge.
+
+Failure signatures: one VM starves the other's forward slot (`GR request
+busy: hold active`, `:25509`); second guest's launches resolve the first
+guest's pages (wrong results / ledger rewrite storms, `:7584-7594`); second
+RM init fails in the boot ladder.
+
+### M-GL6 — Flawless 60 fps split-screen video
+
+Gate: 600+ frames at 60/1 fps, ≥95% unique frames, both halves show real
+GZDoom GL gameplay through the two mdevs, encoded with the existing
+resample/compose pipeline, uploaded with the files-upload skill and
+pull-back-md5 verified. Encode with `libx264` (the R18 path did:
+`/tmp/opencode/r18_final.sh`); NVENC is unavailable while the module owns
+the physical GPU (the host NVIDIA driver is displaced).
+
+---
+
+## D. Experiment recipes
+
+### D.0 Standard cycle (all milestones)
+
+Host, one controlled attempt per cycle (script leaves the VM running):
+
+```
+nohup /tmp/opencode/start_vm_keep.sh rNNN "<EXTRA_ARGS>" \
+ > /tmp/opencode/rNNN-cycle.log 2>&1 &
+# poll until: DONE (no teardown; VM left running) (~70-90 s)
+```
+
+`EXTRA_ARGS` = the SESSION 149 standard profile
+(`session-149-BRIEF.md:65-67`), verbatim:
+
+```
+gsp_phys_gr_nowrite_retries=0 gsp_phys_gr_retry_fast=1
+gsp_phys_gr_first_poll_ms=2500 gsp_sema_winfill=1 gsp_sema_gr_swrel_gate=1
+gsp_phys_rmcall_gr_promote=35 gsp_phys_rmcall_fwclient_mask=0xE3F
+gsp_phys_rmcall_srv_ctxcommit=0 gsp_phys_rmcall_gr_qmdwin=1
+gsp_phys_rmcall_gr_shadow_win0=1 gsp_phys_rmcall_gr_lmem_en=1
+gsp_phys_rmcall_gr_skedrec=1 gsp_phys_rmcall_gr_inst_sys=1
+gsp_phys_rmcall_gr_r1_first=1 gsp_phys_gr_strict_reloc=0
+gsp_phys_fault_relay=1 sema_bigcopy_log=512
+```
+
+GL overlay (M-GL1..M-GL3 runs): append
+
+```
+sema_gl_xlate=1 sema_push_raw=4 gsp_phys_gr_min_data=0
+sema_gr_util_scan=0 sema_dbg=Y
+```
+
+(`sema_gl_xlate` is off by default in-tree, `gsp_sema_service.c:6812`;
+`min_data=0` is the explicit no-external-data bypass validated in S143 and
+used by R15-R17; `util_scan>0` is a measured regression source, keep 0;
+`sema_dbg=Y` raises the bounded witnesses). The base script's ladder already
+passes `gr_promote=25`; the later `=35` wins at insmod and is snapshotted by
+the boot ladder — verify the boot log names mode 35
+(`booter:8612-8613`).
+
+Post-load, before Xorg:
+
+```
+sudo sh -c 'echo 1000 > /sys/module/vgsp/parameters/sema_vas_cand_ms'
+sudo nohup dmesg -w > /tmp/opencode/dmesg_live_rNNN.log 2>&1 &
+```
+
+Guest Xorg (one per boot; ~50 % of boots fail DMA and need a re-cycle):
+
+```
+echo ubuntu | sudo -S setsid bash -c \
+ 'nohup Xorg :0 -ac -noreset -logfile /tmp/xorg.log >/tmp/xorg.out 2>&1 </dev/null &'
+# require: DISPLAY=:0 xdpyinfo -> 1280x720; grep -c "Failed to initialize DMA" /tmp/xorg.log == 0
+```
+
+After Xorg is up:
+
+```
+sudo sh -c 'echo 1 > /sys/module/vgsp/parameters/sema_force_flush'
+```
+
+Evidence per run, always kept: module md5 (`md5sum kernel/vgsp.ko`), the
+echoed insmod args, the full dmesg capture, guest `/tmp/xorg.log`,
+`/tmp/glx*.log`, the GZF2 file and its md5, the decoded frame PPMs, and the
+acceptance-grep output.
+
+### D.1 M-GL1 recipe
+
+Guest: start glxgears and watch the client channel:
+
+```
+DISPLAY=:0 setsid nohup stdbuf -oL glxgears -geometry 800x600+0+0 \
+ > /tmp/glx1.log 2>&1 </dev/null &
+```
+
+Host acceptance greps on the dmesg capture (client handle filled from the
+first `sema-chan` row):
+
+```
+grep -E "auto-pinned GR ring h=0x<client>" # G-A pass
+grep -E "sema-pushdec: h=0x<client>.*unreadable=0" # G-A pass
+grep -E "sema-gl: section captured h=0x<client>" # G-B pass
+grep -E "GR LAUNCH submitted h=0x<client>" # capture->forward
+grep -E "qmd_rel=0x600df00d|GR-COMPLETE-SW" # engine executed/completed
+```
+
+If G-A fails: confirm the guest scanner is running the widened prefixes
+(`/tmp/guest_pin_stream.log`; supervisor env at
+`tools/guest-diag/pin_supervisor.sh:70-83`), force a demand resolve via
+`sema_trace_h=<client handle>` (S148 addendum 10) to see which source fails,
+and use `echo <va> >> /tmp/guest_want_vas.txt` for a targeted pin. If G-B
+fails with `entry_replay=1` keeping `cap_trust=0`, the pass is an un-hashed
+replay: wait one fresh push or use the hash-admitted path
+(`gsp_sema_service.c:29069-29080`) — do not add a bypass.
+
+Expected time-to-first-signal: minutes. Do not proceed to M-GL2 until 3
+cycles pass.
+
+### D.2 M-GL2 recipe (glxgears pixels)
+
+The repo shim is SDL-only (`sdl_cap.c:188-229` hooks `SDL_GL_SwapWindow`,
+`SDL_RenderPresent`, `SDL_UpdateWindowSurface`); glxgears is GLX and calls
+neither. Add a GLX mode to the same tool (hooks `glXSwapBuffers` →
+`glReadPixels`, same GZF2 record layout), build:
+
+```
+gcc -shared -fPIC -O2 -o /tmp/glx_cap.so glx_cap.c -ldl
+```
+
+Guest:
+
+```
+DISPLAY=:0 GLXCAP=/dev/shm/glxframes.bin GZCAP_MAXF=0 \
+ LD_PRELOAD=/tmp/glx_cap.so setsid nohup stdbuf -oL \
+ glxgears -geometry 800x600+0+0 > /tmp/glx2.log 2>&1 </dev/null &
+```
+
+Fetch + decode on the host:
+
+```
+python3 tools/guest-diag/qga_fetch.py /dev/shm/glxframes.bin /tmp/opencode/glxframes.bin
+# parse GZF2 records (tools/lg/frame_reader.py / gz_final_gpu.py's reader),
+# then measure: % pixels > 8/255, colour count, motion between frames.
+```
+
+Do not use `vblank_mode=0` for the first pixel check (sync-on is fine); keep
+the shim's swap hook ordered as "capture then forward" (same as sdl_cap).
+
+### D.3 M-GL3 recipe (GZDoom GL)
+
+Guest (xorg.conf untouched, the emulated GPU as the only GPU):
+
+```
+VK_ICD_FILENAMES=/nonexistent.json VK_DRIVER_FILES=/nonexistent.json \
+GZCAP=/dev/shm/gzframes.bin GZCAP_MAXF=0 LD_PRELOAD=/tmp/sdl_cap.so \
+DISPLAY=:0 setsid nohup ./gzdoom -iwad freedoom2.wad \
+ +vid_rendermode 4 +vid_preferbackend 0 +vid_activeinbackground true \
+ +sv_cheats 1 +noclip > /tmp/gz.log 2>&1 </dev/null &
+# window management with tools/guest-diag/{xresize,xfocus,xtest_move}.c as in
+# SESSION 151; XTEST alone is ignored by SDL here - use the XSendEvent path.
+```
+
+Host: `qga_fetch.py /dev/shm/gzframes.bin /tmp/opencode/gz.bin`, decode, then
+`/tmp/opencode/gz_final_gpu.py` (resample to natural-speed 60 fps) for the
+video. **Do not** set `LIBGL_ALWAYS_SOFTWARE` or the Mesa EGL vendor list:
+that is the S151 software path and would invalidate the milestone.
+
+### D.4 M-GL4 recipe (60 fps, no forcing)
+
+Same as D.1/D.3, plus:
+
+- ensure `sema_xw_mirror` is stopped (`echo 0,0 > .../sema_xw_mirror`) and no
+ forcing/kickfix daemon is running (`pgrep -af` exact names first);
+- measure 60 s windows only: glxgears FPS line, GZDoom counter, GZF2
+ timestamps;
+- keep `sema_gr_grace_ms`/`sema_ce_grace_ms` at their defaults; the R1 A/B
+ proved they are not the pacer (`session-149-BRIEF.md:96-99`);
+- battery-first CUDA regression in the same boot.
+
+### D.5 M-GL5 recipe (two VMs)
+
+Second mdev + second VM (own disk copy, own sockets, own SSH/QGA ports), both
+with the vGPU device this time. Verify in order: second mdev appears; second
+guest's `nvidia-smi`/`glxinfo` sees the vGPU; service log shows both device
+handles per pump pass (`sema-chan` rows for both); then run the D.1/D.3 apps
+in both. Keep per-VM captures separate (two GZF2 files, two QGA sockets).
+
+### D.6 M-GL6 recipe (video)
+
+Per-VM SDL capture → `qga_fetch.py` → `/tmp/opencode/gz_final_gpu.py`
+(timestamp resample + split-screen compose) → `ffmpeg -c:v libx264` (R18
+pattern) → files-upload skill → pull-back md5 verification. 1280x440
+split (640x440 per guest) matches the two delivered videos.
+
+---
+
+## E. What must NOT be done
+
+1. **No other GPUs or virtual GPUs.** No virtio-gpu/virgl, no VirtualGL, no
+ llvmpipe/Mesa software GL, no second physical GPU, no host display
+ round-trip. The SESSION 152 video is explicitly *not* evidence for this
+ plan.
+2. **No fake or mislabelled rendering.** No software rasterizer or compute
+ lookalike presented as GZDoom's OpenGL renderer. A compute-translation
+ probe (approach iii) may exist only as a labelled diagnostic and is never
+ an acceptance artifact.
+3. **No lying sync pokes in accepted runs.** The `/proc/<pid>/mem` counter
+ bump, strike forcing, kickfix and `sema_xw_mirror` are diagnostics; any
+ milestone evidence must state they were absent.
+4. **No host reboot.** AGENTS.md hard rule: handoff update first, explicit
+ human approval, never automatic. Guest VM cycles and Xorg restarts on the
+ test VM are the allowed recovery paths.
+5. **No guest modifications.** Stock guest `nvidia.ko` and stock apps; no
+ injected code, patched firmware or modified driver in the guest; capture
+ shims are LD_PRELOAD observation only and must not alter GL semantics.
+6. **No time/timing claims from unsupported sources.** The physical backend
+ is the execution authority; simulator (L0/L1) findings never substitute
+ for a live engine result.
+7. **No unguarded experiments on the physical GPU.** Every cycle is
+ supervised, single-attempt, evidence-captured (the cycle script's hard
+ abort signatures are not bypassed).
+
+---
+
+## F. Open questions and note-vs-code discrepancies
+
+1. **`sdl_cap.c` is SDL-only.** The milestone recipe in the SESSION 149 brief
+ names it for glxgears, but glxgears is GLX and never calls
+ `SDL_GL_SwapWindow` (`tools/guest-diag/sdl_cap.c:6-9`, `:188`). A GLX
+ variant is required (D.2); `sdl_cap.c` remains the right shim for GZDoom
+ (M-GL3).
+2. **The GL-client pin prefix fix already looks landed.**
+ `tools/guest-diag/pin_supervisor.sh:73-83` defaults
+ `CLIENT_PREFIXES=cdt,va,victory,Xorg,X,glxgears,gzdoom` and the cycle
+ starts the supervisor (`start_vm_keep.sh:242`), while SESSION 153 still
+ lists the "permanent fix" as pending. Verify the running guest scanner
+ actually received the widened env (`/tmp/guest_pin_stream.log`) before
+ treating G-A as closed.
+3. **The logged failure strings differ slightly from the briefs.** The code
+ prints `vgsp-work: GR request without candidate`
+ (`gsp_sema_service.c:25481`), not bare "GR request without candidate"; the
+ ring failure is `sema-chan: GR ring h=... found no candidate`
+ (`:17633`). Grep acceptance scripts for the exact prefixes.
+4. **`n_data` threshold history.** The default is 16
+ (`booter:17709-17717`); S143+ cycles used `min_data=0` as an explicit
+ bypass for the no-external-data launch class, enabled by the r800 guard.
+ The brief's "min 1"/"min 16" variants refer to different experiments; the
+ GL profile below uses 0 and must say so in every evidence record.
+5. **Stale project status row.** `docs/README.md:114` still lists CUDA
+ execution as "no grid has dispatched yet", which predates the proven
+ physical forward and the R18 CUDA video. This plan relies on the newer
+ code and `docs/04-implementation/03-execution-frontier.md`, not that row.
+6. **The 3D context on the execution lane is the main unknown.** Mode 35's
+ capture "carries no 0xc597 on the user lane"
+ (`booter:9022-9033`); golden-lane 3D objects exist (`:9035-9043`) but the
+ execution channel is V7. The first implementation experiment must prove a
+ 0xC597 class object can be constructed on V7 after the accepted promote
+ (same helper, `:8936`) without the wired-golden deadlock class
+ (`:816-822`), before any stream replay work. \ No newline at end of file
diff --git a/tools/guest-diag/guest_pin_stream.py b/tools/guest-diag/guest_pin_stream.py
index 8768f2f..3db61ba 100644
--- a/tools/guest-diag/guest_pin_stream.py
+++ b/tools/guest-diag/guest_pin_stream.py
@@ -1428,7 +1428,8 @@ def pid_line(tag, pid, start):
def cdt_pid():
prefixes = tuple(p for p in os.environ.get("CLIENT_PREFIXES",
- "cdt,va,victory").split(",") if p)
+ "cdt,va,victory,Xorg,X,glxgears,"
+ "gzdoom").split(",") if p)
# Processes that hold a /dev/nvidia* fd but are not CUDA clients; the
# scanner must never commit to them (a transient nvidia-smi at boot was
# picked once and its exited pagemap read back EOF forever).
diff --git a/tools/guest-diag/pin_gr_stream.py b/tools/guest-diag/pin_gr_stream.py
index ff7e299..4289d37 100644
--- a/tools/guest-diag/pin_gr_stream.py
+++ b/tools/guest-diag/pin_gr_stream.py
@@ -136,7 +136,8 @@ def _start_cmd():
b64 = subprocess.run(["base64", "-w0"], stdin=f,
capture_output=True, text=True,
timeout=10).stdout
- prefixes = os.environ.get("CLIENT_PREFIXES", "cdt,va,victory")
+ prefixes = os.environ.get("CLIENT_PREFIXES",
+ "cdt,va,victory,Xorg,X,glxgears,gzdoom")
vma_every = os.environ.get("STREAM_VMA_EVERY", "1")
# Kill EVERY existing scanner, not just the pid-file one: a retry after
# a partially-failed first attempt (pid file not written yet) used to
diff --git a/tools/guest-diag/pin_supervisor.sh b/tools/guest-diag/pin_supervisor.sh
index b87d5c1..72f588f 100755
--- a/tools/guest-diag/pin_supervisor.sh
+++ b/tools/guest-diag/pin_supervisor.sh
@@ -70,7 +70,14 @@ kill_tool() {
start_pin() {
if tool_alive "$PIN"; then return 0; fi
touch "$PIN_HB" 2>/dev/null || true
- ( setsid nohup env SEMFIX_COUNTER_RETRY=10 \
+ # CLIENT_PREFIXES threads through pin_gr_stream.py into the guest
+ # scanner launch (its _start_cmd). GL clients (glxgears, gzdoom) and
+ # the X server must be covered or the GL client's ring/mappings are
+ # never pinned (SESSION 149: the GR ring scan then finds no candidate
+ # for h=0xbfef0002, va=0x1203cfff0).
+ ( setsid nohup env \
+ CLIENT_PREFIXES="${CLIENT_PREFIXES:-cdt,va,victory,Xorg,X,glxgears,gzdoom}" \
+ SEMFIX_COUNTER_RETRY=10 \
python3 "$PIN" >> "$PIN_LOG" 2>&1 & )
pin_started=$(date +%s)
log "START pin_gr_stream"
diff --git a/tools/guest-diag/qga_snip.py b/tools/guest-diag/qga_snip.py
new file mode 100644
index 0000000..cd3b992
--- /dev/null
+++ b/tools/guest-diag/qga_snip.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+# Fetch a byte range from a guest file via the QEMU guest agent.
+# Usage: qga_snip.py <guest_path> <offset> <length> <host_out>
+import base64
+import json
+import socket
+import sys
+
+GUEST, OFF, LEN, HOST = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4]
+import os
+SOCK = os.environ.get("QGA_SOCK", "/tmp/qemu-guest-agent.sock")
+
+
+def recv_line(s):
+ buf = b""
+ while b"\n" not in buf:
+ c = s.recv(65536)
+ if not c:
+ break
+ buf += c
+ return buf.split(b"\n", 1)[0]
+
+
+def cmd(s, obj):
+ s.sendall((json.dumps(obj) + "\r\n").encode())
+ return json.loads(recv_line(s))
+
+
+s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+s.settimeout(30.0)
+s.connect(SOCK)
+h = cmd(s, {"execute": "guest-file-open",
+ "arguments": {"path": GUEST, "mode": "r"}})["return"]
+cmd(s, {"execute": "guest-file-seek",
+ "arguments": {"handle": h, "offset": OFF, "whence": 0}})
+r = cmd(s, {"execute": "guest-file-read",
+ "arguments": {"handle": h, "count": LEN}})
+data = base64.b64decode(r["return"].get("buf-b64", ""))
+open(HOST, "wb").write(data)
+cmd(s, {"execute": "guest-file-close", "arguments": {"handle": h}})
+print(f"GOT {len(data)} bytes @ {OFF} -> {HOST}")
diff --git a/tools/guest-diag/sdl_cap.c b/tools/guest-diag/sdl_cap.c
new file mode 100644
index 0000000..23c83ab
--- /dev/null
+++ b/tools/guest-diag/sdl_cap.c
@@ -0,0 +1,229 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// sdl_cap.c - LD_PRELOAD capture shim: records every frame GZDoom presents
+// through SDL into an append-only file, so the host can harvest the guest's
+// real rendered frames without any X/driver readback path.
+//
+// Hooks:
+// SDL_RenderPresent(SDL_Renderer*) -> SDL_RenderReadPixels(ARGB8888)
+// SDL_UpdateWindowSurface(SDL_Window*) -> SDL_GetWindowSurface pixels
+// Both forward to the real SDL after capturing.
+//
+// File layout (little endian):
+// [0] 8B magic "GZCAPV01" (+ 4088 B reserved zero)
+// [4096] frame records:
+// 4B "GZF2" | u32 w | u32 h | u32 stride | u32 bpp
+// u64 ts_ns (CLOCK_MONOTONIC) | u64 seq | stride*h bytes (top-down)
+// A trailing record with seq = 0xFFFFFFFFFFFFFFFF marks clean stop.
+//
+// Env: GZCAP (path, default /dev/shm/gzframes.bin), GZCAP_MAXF (0=unlimited)
+#define _GNU_SOURCE
+#include <dlfcn.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#define ARGB8888 0x16362004u
+
+typedef struct SDL_Renderer SDL_Renderer;
+typedef struct SDL_Window SDL_Window;
+
+typedef struct SDL_Surface {
+ uint32_t flags;
+ void *format;
+ int w, h;
+ int pitch;
+ void *pixels;
+ void *userdata;
+ int locked;
+ void *list_blitmap;
+ struct { int x, y, w, h; } clip_rect;
+ void *map;
+ int refcount;
+} SDL_Surface;
+
+static int (*real_RenderReadPixels)(SDL_Renderer *, const void *, uint32_t,
+ void *, int);
+static int (*real_RenderPresent)(SDL_Renderer *);
+static SDL_Surface *(*real_GetWindowSurface)(SDL_Window *);
+static int (*real_UpdateWindowSurface)(SDL_Window *);
+static int (*real_GetRendererOutputSize)(SDL_Renderer *, int *, int *);
+static void (*real_GL_SwapWindow)(SDL_Window *);
+static int (*real_GL_GetDrawableSize)(SDL_Window *, int *, int *);
+static void (*real_glReadPixels)(int, int, int, int, unsigned, unsigned, void *);
+
+static FILE *cap_fp;
+static unsigned long long cap_count, cap_max = 0;
+static int cap_stop;
+
+__attribute__((constructor)) static void cap_init(void)
+{
+ const char *p = getenv("GZCAP");
+ const char *m = getenv("GZCAP_MAXF");
+ char hdr[4096];
+
+ if (m)
+ cap_max = strtoull(m, NULL, 0);
+ if (!p)
+ p = "/dev/shm/gzframes.bin";
+ real_RenderReadPixels = dlsym(RTLD_NEXT, "SDL_RenderReadPixels");
+ real_RenderPresent = dlsym(RTLD_NEXT, "SDL_RenderPresent");
+ real_GetWindowSurface = dlsym(RTLD_NEXT, "SDL_GetWindowSurface");
+ real_UpdateWindowSurface = dlsym(RTLD_NEXT, "SDL_UpdateWindowSurface");
+ real_GetRendererOutputSize =
+ dlsym(RTLD_NEXT, "SDL_GetRendererOutputSize");
+ real_GL_SwapWindow = dlsym(RTLD_NEXT, "SDL_GL_SwapWindow");
+ real_GL_GetDrawableSize = dlsym(RTLD_NEXT, "SDL_GL_GetDrawableSize");
+ real_glReadPixels = dlsym(RTLD_NEXT, "glReadPixels");
+ if (!real_glReadPixels)
+ real_glReadPixels = dlsym(RTLD_DEFAULT, "glReadPixels");
+ if (!real_glReadPixels) {
+ void *h = dlopen("libGL.so.1", RTLD_NOW | RTLD_GLOBAL);
+
+ if (!h)
+ h = dlopen("libGLESv2.so.2", RTLD_NOW | RTLD_GLOBAL);
+ if (h)
+ real_glReadPixels = dlsym(h, "glReadPixels");
+ }
+ fprintf(stderr, "[gzcap] init path=%s present=%p updwin=%p swap=%p read=%p gros=%p\n",
+ p, (void *)real_RenderPresent, (void *)real_UpdateWindowSurface,
+ (void *)real_GL_SwapWindow, (void *)real_RenderReadPixels,
+ (void *)real_GetRendererOutputSize, (void *)real_glReadPixels);
+ cap_fp = fopen(p, "wb");
+ if (!cap_fp)
+ return;
+ memset(hdr, 0, sizeof(hdr));
+ memcpy(hdr, "GZCAPV01", 8);
+ fwrite(hdr, 1, sizeof(hdr), cap_fp);
+ fflush(cap_fp);
+}
+
+static void cap_record(const void *px, int w, int h, int stride, int bpp)
+{
+ struct timespec ts;
+ uint64_t tns, seq;
+ uint32_t rh[4];
+ char m[4] = { 'G', 'Z', 'F', '2' };
+
+ if (!cap_fp || cap_stop)
+ return;
+ if (cap_max && cap_count >= cap_max) {
+ uint64_t endseq = 0xFFFFFFFFFFFFFFFFull;
+
+ fwrite(m, 1, 4, cap_fp);
+ fwrite(&endseq, 8, 1, cap_fp);
+ fflush(cap_fp);
+ cap_stop = 1;
+ return;
+ }
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ tns = (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+ seq = cap_count;
+ rh[0] = (uint32_t)w;
+ rh[1] = (uint32_t)h;
+ rh[2] = (uint32_t)stride;
+ rh[3] = (uint32_t)bpp;
+ fwrite(m, 1, 4, cap_fp);
+ fwrite(rh, 4, 4, cap_fp);
+ fwrite(&tns, 8, 1, cap_fp);
+ fwrite(&seq, 8, 1, cap_fp);
+ fwrite(px, 1, (size_t)stride * h, cap_fp);
+ fflush(cap_fp);
+ cap_count++;
+ if ((cap_count & 31) == 0)
+ fprintf(stderr, "[gzcap] frames=%llu\n", cap_count);
+}
+
+int SDL_RenderPresent(SDL_Renderer *r)
+{
+ static int hit;
+
+ if (!hit++)
+ fprintf(stderr, "[gzcap] hook RenderPresent\n");
+ if (cap_fp && !cap_stop && real_RenderReadPixels &&
+ real_GetRendererOutputSize) {
+ int w = 0, h = 0;
+
+ if (real_GetRendererOutputSize(r, &w, &h) == 0 && w > 0 && h > 0 &&
+ w <= 4096 && h <= 4096) {
+ static unsigned char *buf;
+ static size_t bufsz;
+ size_t need = (size_t)w * 4 * h;
+
+ if (bufsz < need) {
+ free(buf);
+ buf = malloc(need);
+ bufsz = buf ? need : 0;
+ }
+ if (buf && real_RenderReadPixels(r, NULL, ARGB8888, buf,
+ w * 4) == 0)
+ cap_record(buf, w, h, w * 4, 32);
+ }
+ }
+ if (real_RenderPresent)
+ return real_RenderPresent(r);
+ return 0;
+}
+
+int SDL_UpdateWindowSurface(SDL_Window *win)
+{
+ static int hit;
+
+ if (!hit++)
+ fprintf(stderr, "[gzcap] hook UpdateWindowSurface\n");
+ if (cap_fp && !cap_stop && real_GetWindowSurface) {
+ SDL_Surface *s = real_GetWindowSurface(win);
+
+ if (s && s->pixels && s->w > 0 && s->h > 0 && s->pitch > 0 &&
+ s->w <= 4096 && s->h <= 4096)
+ cap_record(s->pixels, s->w, s->h, s->pitch, 32);
+ }
+ if (real_UpdateWindowSurface)
+ return real_UpdateWindowSurface(win);
+ return 0;
+}
+
+void SDL_GL_SwapWindow(SDL_Window *win)
+{
+ static int hit;
+ static unsigned char *gbuf;
+ static size_t gbufsz;
+
+ if (!hit++)
+ fprintf(stderr, "[gzcap] hook GLSwapWindow\n");
+ if (cap_fp && !cap_stop && real_glReadPixels && real_GL_GetDrawableSize) {
+ int w = 0, h = 0;
+
+ if (real_GL_GetDrawableSize(win, &w, &h) == 0 && w > 0 && h > 0 &&
+ w <= 4096 && h <= 4096) {
+ size_t need = (size_t)w * 4 * h;
+
+ if (gbufsz < need) {
+ free(gbuf);
+ gbuf = malloc(need);
+ gbufsz = gbuf ? need : 0;
+ }
+ if (gbuf) {
+ real_glReadPixels(0, 0, w, h, 0x1908, 0x1401, gbuf);
+ /* GL rows are bottom-up: flip into a scratch record */
+ {
+ unsigned char *tmp = malloc(need);
+ int y;
+
+ if (tmp) {
+ for (y = 0; y < h; y++)
+ memcpy(tmp + (size_t)y * w * 4,
+ gbuf + (size_t)(h - 1 - y) * w * 4,
+ (size_t)w * 4);
+ cap_record(tmp, w, h, w * 4, 32);
+ free(tmp);
+ }
+ }
+ }
+ }
+ }
+ if (real_GL_SwapWindow)
+ real_GL_SwapWindow(win);
+}
diff --git a/tools/guest-diag/xfocus.c b/tools/guest-diag/xfocus.c
new file mode 100644
index 0000000..e0ec05d
--- /dev/null
+++ b/tools/guest-diag/xfocus.c
@@ -0,0 +1,69 @@
+// xfocus.c - print the current X input focus and the gzdoom window, and try
+// to focus the gzdoom window; then report again. Diagnostic for WM-less X.
+#include <X11/Xlib.h>
+#include <X11/Xutil.h>
+#include <stdio.h>
+#include <string.h>
+
+static Window find_named(Display *d, Window root, const char *sub,
+ char *title, size_t tl)
+{
+ Window r, parent, *kids = NULL;
+ unsigned int nk = 0;
+
+ title[0] = 0;
+ if (!XQueryTree(d, root, &r, &parent, &kids, &nk))
+ return 0;
+ for (unsigned int i = 0; i < nk; i++) {
+ char *name = NULL;
+
+ if (XFetchName(d, kids[i], &name) && name) {
+ if (strstr(name, sub)) {
+ snprintf(title, tl, "%s", name);
+ Window w = kids[i];
+
+ XFree(name);
+ if (kids)
+ XFree(kids);
+ return w;
+ }
+ XFree(name);
+ }
+ }
+ if (kids)
+ XFree(kids);
+ return 0;
+}
+
+int main(void)
+{
+ Display *d = XOpenDisplay(NULL);
+ Window root, focus, w, croot;
+ int rx, ry, wx, wy;
+ unsigned int mask;
+ char title[256];
+
+ if (!d) {
+ printf("no display\n");
+ return 1;
+ }
+ root = DefaultRootWindow(d);
+ w = find_named(d, root, "Freedoom", title, sizeof(title));
+ XGetInputFocus(d, &focus, &rx);
+ printf("gzdoom window: 0x%lx '%s'\n", w, title);
+ printf("input focus: 0x%lx\n", focus);
+ printf("pointer window: ");
+ if (XQueryPointer(d, root, &croot, &rx /*reuse*/, &wx, &wy, &rx, &ry,
+ &mask))
+ printf("0x%lx at %d,%d mask=0x%x\n", rx, wx, wy, mask);
+ else
+ printf("query failed\n");
+ if (w) {
+ XSetInputFocus(d, w, RevertToParent, CurrentTime);
+ XFlush(d);
+ XGetInputFocus(d, &focus, &rx);
+ printf("after set: 0x%lx\n", focus);
+ }
+ XCloseDisplay(d);
+ return 0;
+}
diff --git a/tools/guest-diag/xgrab.c b/tools/guest-diag/xgrab.c
new file mode 100644
index 0000000..eb605d6
--- /dev/null
+++ b/tools/guest-diag/xgrab.c
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// xgrab.c - capture the X screen (root window) into an append-only frame file.
+// Pure client-side XGetImage on a software X server (Xvfb): no GL, no driver,
+// no present-path hooks. Same record format as sdl_cap.c:
+// [0] 8B "GZCAPV01" + zero pad to 4096
+// [4096..] records: 4B "GZF2" | u32 w | u32 h | u32 stride | u32 bpp |
+// u64 ts_ns | u64 seq | stride*h bytes (top-down BGRA)
+// Trailing record seq=0xFFFFFFFFFFFFFFFF on clean stop.
+// Usage: xgrab <display> <out> <maxframes|0> <pace_us|0>
+#include <X11/Xlib.h>
+#include <X11/Xutil.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+int main(int argc, char **argv)
+{
+ const char *dpy = argc > 1 ? argv[1] : ":1";
+ const char *out = argc > 2 ? argv[2] : "/dev/shm/gzframes.bin";
+ long maxf = argc > 3 ? atol(argv[3]) : 0;
+ long pace = argc > 4 ? atol(argv[4]) : 0;
+ int r_x = argc > 5 ? atoi(argv[5]) : -1;
+ int r_y = argc > 6 ? atoi(argv[6]) : 0;
+ int r_w = argc > 7 ? atoi(argv[7]) : 0;
+ int r_h = argc > 8 ? atoi(argv[8]) : 0;
+ Display *d;
+ Window root;
+ XWindowAttributes wa;
+ FILE *f;
+ char hdr[4096];
+ uint64_t seq = 0;
+
+ d = XOpenDisplay(dpy);
+ if (!d) {
+ fprintf(stderr, "xgrab: cannot open display %s\n", dpy);
+ return 1;
+ }
+ root = DefaultRootWindow(d);
+ if (!XGetWindowAttributes(d, root, &wa)) {
+ fprintf(stderr, "xgrab: no root attrs\n");
+ return 1;
+ }
+ if (r_x < 0 || r_w <= 0 || r_h <= 0 || r_x + r_w > wa.width ||
+ r_y + r_h > wa.height) {
+ r_x = 0; r_y = 0; r_w = wa.width; r_h = wa.height;
+ }
+ fprintf(stderr, "xgrab: root %dx%d depth=%d region %d,%d %dx%d\n",
+ wa.width, wa.height, wa.depth, r_x, r_y, r_w, r_h);
+ f = fopen(out, "wb");
+ if (!f) {
+ perror("xgrab: open out");
+ return 1;
+ }
+ memset(hdr, 0, sizeof(hdr));
+ memcpy(hdr, "GZCAPV01", 8);
+ fwrite(hdr, 1, sizeof(hdr), f);
+ fflush(f);
+
+ while (!maxf || (long)seq < maxf) {
+ XImage *im = XGetImage(d, root, r_x, r_y, r_w, r_h, AllPlanes,
+ ZPixmap);
+ struct timespec ts;
+ uint64_t tns;
+ uint32_t rh[4];
+ char m[4] = { 'G', 'Z', 'F', '2' };
+ int y;
+
+ if (!im) {
+ usleep(10000);
+ continue;
+ }
+ if (im->bits_per_pixel != 32) {
+ fprintf(stderr, "xgrab: unexpected bpp %d\n",
+ im->bits_per_pixel);
+ XDestroyImage(im);
+ break;
+ }
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ tns = (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
+ rh[0] = (uint32_t)r_w;
+ rh[1] = (uint32_t)r_h;
+ rh[2] = (uint32_t)r_w * 4;
+ rh[3] = 32;
+ fwrite(m, 1, 4, f);
+ fwrite(rh, 4, 4, f);
+ fwrite(&tns, 8, 1, f);
+ fwrite(&seq, 8, 1, f);
+ for (y = 0; y < r_h; y++)
+ fwrite(im->data + (size_t)y * im->bytes_per_line, 1,
+ (size_t)r_w * 4, f);
+ fflush(f);
+ seq++;
+ XDestroyImage(im);
+ if (pace)
+ usleep(pace);
+ if ((seq & 63) == 0)
+ fprintf(stderr, "xgrab: frames=%llu\n",
+ (unsigned long long)seq);
+ }
+ {
+ char m[4] = { 'G', 'Z', 'F', '2' };
+ uint64_t end = 0xFFFFFFFFFFFFFFFFull;
+
+ fwrite(m, 1, 4, f);
+ fwrite(&end, 8, 1, f);
+ fflush(f);
+ }
+ fclose(f);
+ XCloseDisplay(d);
+ fprintf(stderr, "xgrab: done frames=%llu\n", (unsigned long long)seq);
+ return 0;
+}
diff --git a/tools/guest-diag/xresize.c b/tools/guest-diag/xresize.c
new file mode 100644
index 0000000..877f0e9
--- /dev/null
+++ b/tools/guest-diag/xresize.c
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// xresize.c - find a window by name substring and resize/move it (Xvfb has
+// no WM, so a client-side XMoveResizeWindow is the only way to force the
+// game window onto the screen).
+// Usage: xresize <display> <name-substr> <w> <h> <x> <y>
+#include <X11/Xlib.h>
+#include <X11/Xutil.h>
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+
+static Window find_named(Display *d, Window root, const char *sub)
+{
+ Window r, parent, *kids = NULL;
+ unsigned int nkids = 0;
+
+ if (!XQueryTree(d, root, &r, &parent, &kids, &nkids))
+ return 0;
+ for (unsigned int i = 0; i < nkids; i++) {
+ char *name = NULL;
+
+ if (XFetchName(d, kids[i], &name) && name) {
+ int hit = strstr(name, sub) != NULL;
+
+ XFree(name);
+ if (hit) {
+ Window w = kids[i];
+
+ if (kids)
+ XFree(kids);
+ return w;
+ }
+ }
+ }
+ if (kids)
+ XFree(kids);
+ return 0;
+}
+
+int main(int argc, char **argv)
+{
+ Display *d;
+ Window w;
+
+ if (argc < 6) {
+ fprintf(stderr, "usage: xresize <display> <name> <w> <h> <x> <y>\n");
+ return 2;
+ }
+ d = XOpenDisplay(argv[1]);
+ if (!d) {
+ fprintf(stderr, "no display\n");
+ return 1;
+ }
+ w = find_named(d, DefaultRootWindow(d), argv[2]);
+ if (!w) {
+ fprintf(stderr, "window '%s' not found\n", argv[2]);
+ return 1;
+ }
+ XMoveResizeWindow(d, w, atoi(argv[5]), atoi(argv[6]), atoi(argv[3]),
+ atoi(argv[4]));
+ XFlush(d);
+ printf("resized 0x%lx to %sx%s at %s,%s\n", w, argv[3], argv[4],
+ argv[5], argv[6]);
+ XCloseDisplay(d);
+ return 0;
+}
diff --git a/tools/guest-diag/xtest_move.c b/tools/guest-diag/xtest_move.c
new file mode 100644
index 0000000..8331487
--- /dev/null
+++ b/tools/guest-diag/xtest_move.c
@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: BSD-3-Clause
+// xtest_move.c - drive the game with XSendEvent-synthetic keys (SDL2 on this
+// Xorg ignores XTEST-faked keys; it accepts send_event KeyPress/KeyRelease).
+// Finds the game window by name, then holds forward+fire and alternates
+// turning left/right so the captured footage has constant motion.
+// Usage: xtest_move <display> <seconds>
+#include <X11/Xlib.h>
+#include <X11/keysym.h>
+#include <X11/Xutil.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static Display *d;
+static Window game;
+
+static Window find_named(const char *sub)
+{
+ Window root = DefaultRootWindow(d), r, parent, *kids = NULL;
+ unsigned int nk = 0;
+
+ if (!XQueryTree(d, root, &r, &parent, &kids, &nk))
+ return 0;
+ for (unsigned int i = 0; i < nk; i++) {
+ char *name = NULL;
+
+ if (XFetchName(d, kids[i], &name) && name) {
+ if (strstr(name, sub)) {
+ Window w = kids[i];
+
+ XFree(name);
+ if (kids)
+ XFree(kids);
+ return w;
+ }
+ XFree(name);
+ }
+ }
+ if (kids)
+ XFree(kids);
+ return 0;
+}
+
+static void send_key(KeySym ks, int down)
+{
+ XEvent e;
+ Window root = DefaultRootWindow(d);
+ int rx, ry, wx, wy;
+ unsigned int mask;
+ Window cr, pr;
+
+ if (!game)
+ return;
+ memset(&e, 0, sizeof(e));
+ e.xkey.type = down ? KeyPress : KeyRelease;
+ e.xkey.display = d;
+ e.xkey.window = game;
+ e.xkey.root = root;
+ e.xkey.subwindow = None;
+ e.xkey.time = CurrentTime;
+ XQueryPointer(d, root, &cr, &pr, &rx, &ry, &wx, &wy, &mask);
+ e.xkey.x = wx;
+ e.xkey.y = wy;
+ e.xkey.x_root = rx;
+ e.xkey.y_root = ry;
+ e.xkey.same_screen = True;
+ e.xkey.keycode = XKeysymToKeycode(d, ks);
+ e.xkey.state = 0;
+ XSendEvent(d, game, True, down ? KeyPressMask : KeyReleaseMask, &e);
+ XFlush(d);
+}
+
+int main(int argc, char **argv)
+{
+ int secs = argc > 2 ? atoi(argv[2]) : 12;
+ int t;
+
+ d = XOpenDisplay(argc > 1 ? argv[1] : ":1");
+ if (!d) {
+ fprintf(stderr, "xtest_move: no display\n");
+ return 1;
+ }
+ game = find_named("Freedoom");
+ if (!game)
+ game = find_named("Outer Prison");
+ if (!game)
+ game = find_named("Doom");
+ fprintf(stderr, "xtest_move: game window 0x%lx\n", game);
+ if (!game)
+ return 1;
+ XSetInputFocus(d, game, RevertToParent, CurrentTime);
+ XFlush(d);
+
+ /* Hold forward only (no fire: fire pulls enemies and the player can
+ * die mid-capture; with +noclip from the command line this is a smooth
+ * fly-through). */
+ send_key(XK_Up, 1);
+
+ for (t = 0; t < secs * 10; t++) {
+ static int dir; /* 0 = left held, 1 = right held */
+
+ XSetInputFocus(d, game, RevertToParent, CurrentTime); /* keep focus */
+ /* Re-assert the held forward key: SDL's held state drops between
+ * synthetic events, so forward motion stalls unless re-sent. */
+ if ((t % 4) == 0)
+ send_key(XK_Up, 1);
+ if ((t % 9) == 8) {
+ KeySym off = dir ? XK_Right : XK_Left;
+ KeySym on = dir ? XK_Left : XK_Right;
+
+ send_key(off, 0);
+ usleep(30000);
+ send_key(on, 1);
+ dir = !dir;
+ }
+ XFlush(d);
+ usleep(100000);
+ }
+ send_key(XK_Left, 0);
+ send_key(XK_Right, 0);
+ send_key(XK_Up, 0);
+ XCloseDisplay(d);
+ fprintf(stderr, "xtest_move: done\n");
+ return 0;
+}