Files
psxrecomp/docs/GPU_FRAME_TOOLS.md
2026-10-05 22:52:51 -07:00

15 KiB

GP0 frame tools — who drew this pixel?

A set of tools that answer one question about a rendering bug: which guest function issued this primitive, and with which blend mode?

tools/psx_gpu_frame.py       transport + GP0 decode + attribution (the library)
tools/gpu_frame_capture.py   capture a frame from a running game
tools/gpu_frame_layers.py    render one image per guest function
tools/gpu_frame_diff.py      diff a good frame against a bad one
tools/gpu_parity.py          frame-locked image parity vs DuckStation
tools/tests/test_gpu_frame.py

Retro Studio's Frames tab is a viewer and launcher over exactly these artifacts. It never decodes a GP0 packet itself, so a headless capture and the GUI can never disagree about what a frame contained.


Why this exists

gpu_frame_dump on the debug server has always stamped every GP0 packet with the guest code that issued it — func, pc, ra, plus the linked-list OT rank (handle_gpu_frame_dump in runtime/src/debug_server.c, GpuGp0RingEntry in runtime/include/gpu.h). Nothing consumed that attribution, so "the glow is opaque and the vignette is missing" stayed a description of a screenshot rather than a pointer at a function.


Hidden OpenGL validation

--headless uses the software rasterizer even when the game config requests OpenGL. For an OpenGL rendering investigation, use --headless-opengl with an isolated --debug-port and --memcard-dir. This explicit mode creates a hidden SDL OpenGL context and initializes only SDL video; it does not open an audio device, enumerate controllers, sample host input, or show the launcher.

A failed context/pipeline initialization exits with an error instead of silently using software. Verify the hidden OpenGL headless enabled startup record and a successful gl_fbo_peek query. The existing present_image_ring_stats / present_image_ring_get captures read the native-wide GL surface during gameplay and CPU-synchronized GL VRAM for native 4:3 frames. They contain the displayed band before fitting to a host window; present_shot remains unavailable.

For temporal presentation bugs, freeze capture with capture_mark (op=mark) and inspect oldest_sequence through newest_sequence from present_image_ring_stats. Fetch each image with present_image_ring_get (sequence=N, path="image.png"); the response includes its guest frame. Several extra or held presentations can share that frame number. The legacy frame=N lookup retrieves only one matching image, so it cannot establish that all extra presentations have a stable HUD. Release the mark when done.

The hidden window does not select an adaptive display aspect. Set the desired aspect explicitly with display_aspect for each comparison. A capture that repeats establishes repeatability; correctness still needs appropriate camera coverage and comparison against the reported defect.

The provenance rule

Everything these tools report is observed from one execution of one frame, and is labelled as such. It is never merged into a static claim.

A func in a frame dump proves that code ran and issued a primitive. It says nothing about the call graph — the analyser's static coverage gap (FUNCTION_DISCOVERY.md) stays exactly as wide as it was. Studio shows observed counts and static names in different columns for the same reason.


What is decoded faithfully, and what is not

Faithful:

  • Vertices, including the 11-bit signed coordinate and the running GP0(E5) draw offset — the same offset gp0_exec_* applies in gpu.c.
  • Packet word layouts for every polygon, line, rectangle, fill and copy form.
  • The texpage latch. A textured polygon's own tpage word overwrites draw-mode state (set_tpage_from_poly in gpu.c), and later untextured polygons and rectangles — which carry no tpage word — inherit it. Getting this wrong mislabels precisely the semi-transparency mode you are usually chasing.
  • All four semi-transparency blends, when layers are rendered: 0.5B+0.5F, B+F, B-F, B+0.25F (GPUSTAT bits 5-6).

Not:

  • Textures are never sampled. Texture pages, CLUTs and UVs are decoded and reported, so a wrong-CLUT hypothesis is testable, but a textured primitive renders as its command colour. Re-implementing texture sampling would buy fidelity the question does not need and add a whole new way to be wrong.
  • The ring truncates each packet to GPU_GP0_RING_MAX_WORDS (12). Longer packets are decoded as far as recorded and flagged truncated — never guessed at.

The build must actually have a debug server

Release + -DPSX_DEBUG_TOOLS=ON is the combination you want. Not a Debug build: a Debug build of a recomp is far too slow to reach the frame you are chasing.

runtime/src/debug_server.c is always compiled, but debug_server_init() is only called when PSX_NO_DEBUG_TOOLS is undefined (runtime/src/main.cpp). PSX_DEBUG_TOOLS defaults ON for Debug / RelWithDebInfo and OFF for Release / MinSizeRel (runtime/runtime.cmake), so a plain cmake -DCMAKE_BUILD_TYPE=Release produces a lean binary that opens no port at all. Every "the tool won't connect" starts here.

cmake -S . -B build-release -G Ninja \
      -DCMAKE_BUILD_TYPE=Release -DPSX_DEBUG_TOOLS=ON
cmake --build build-release --target psx-runtime

Check an existing build dir without reconfiguring it:

grep -E '^(PSX_DEBUG_TOOLS|CMAKE_BUILD_TYPE):' build-release/CMakeCache.txt

PSX_DEBUG_TOOLS is an option(), so it lives in the cache: changing CMAKE_BUILD_TYPE on an existing build dir does not flip it. Pass the -D explicitly, or start a fresh build dir.

In Retro Studio this is the Build tab → Debug tools row: a selector that injects the flag, a Configure for debugging button that sets Release + ON in one click, and a status line that reads the configured build dir and says whether the server will be there. The Frames tab shows the same line when it cannot connect, so "not connected" never has to be guessed at.

The port is a runtime setting, not a build flag

The debug port comes from game.toml:

[runtime]
debug_port = 4370

If that key is absent the runtime uses the compiled DEFAULT_DEBUG_PORT (4370 for psx-runtime, 4380 for psx-beetle), and --debug-port <n> on the command line overrides both. Studio's Frames tab defaults its port field to whatever the selected project's game.toml says.


You cannot pause the game, and you do not need to

pause, continue, step and run_to_frame were removed from psx-runtime. runtime/src/debug_server.c still registers them, but only as handlers that return an error explaining the migration — pause-step-read synthesizes a snapshot ("what is state right NOW") instead of reading the history the runtime already records continuously, and in this codebase it forced the runtime into a wait loop where a dropped client looked like a freeze.

The replacement is better for chasing a render bug. The GP0 ring holds 1 << 20 packets (gpu.c, GP0_RING_CAP) — several hundred frames — so the workflow runs the other way round:

Play until the bug is on screen. Then capture that frame, and the frames leading up to it.

Reaching backwards beats freezing forwards, and it is the only thing that works at all for a glitch you cannot reliably stop on.

python3 $P/gpu_frame_capture.py --ring        # what can I still ask for?
# GP0 ring: 812344 packet(s) seen, capacity 1048576, 12 words/packet
#   capturable frames: 40918..41230

gpu_frame_dump on a frame that has been evicted returns an empty dump, not an error — which reads exactly like "that frame drew nothing", a conclusion you might act on. So capture() checks the span first and refuses, and Studio's Frames tab shows the span next to the frame selector and marks an out-of-ring frame in red.

What replaces each removed command:

Gone Use instead
run_to_frame N --frame N against the ring (any frame it still holds)
step --frames N, walking backwards from --frame
pause + read state frame_range / get_frame (per-frame records), fn_entry_dump, wtrace_dump
pause + inspect drawing gpu_frame_dump frame=N — what this whole toolchain does

Savestates are unaffected: savestate is a real handler, and a savestate plus a fixed frame number still makes a run reproducible.


Transport

One request per connection. io_thread_main() in debug_server.c accepts, reads a single line, replies, and closes. A client that holds the socket open and sends a second command gets silence and then EOF, which is indistinguishable from a hung emulator. psx_gpu_frame.DebugConn opens a socket per command; tools/debug_client.py's query() documents the same contract.


Typical session

cd <your recomp project>
P=psxrecomp/tools

# 1. Play until the bug is on screen. Capture the newest frame as the suspect,
#    then reach BACK into the ring for one that still looked right.
python3 $P/gpu_frame_capture.py --tag bad --out analysis/frames --summary
python3 $P/gpu_frame_capture.py --frame 41100 --tag good --out analysis/frames --summary

# 2. What changed?
python3 $P/gpu_frame_diff.py analysis/frames/good.json analysis/frames/bad.json

# 3. Look at the bad frame one function at a time.
python3 $P/gpu_frame_layers.py analysis/frames/bad.json --out analysis/frames/bad-layers

# 4. Did the guest even run the same? (needs a patched DuckStation on 4371)
python3 $P/gpu_parity.py --frame 41230 --out analysis/frames/parity

--frames N also grabs the N-1 frames before --frame, walking backwards through the ring — the frames leading up to the one you noticed are usually the ones that explain it. A savestate plus a fixed frame number still makes the whole thing reproducible.

A screenshot is only written for the live frame: the framebuffer holds the present, not the historical frame you just dumped, and saving it beside an older dump would label it as something it is not.

Artifacts

File Contents
<tag>.json the full dump: every decoded primitive, with func/pc/ra/ot
<tag>.summary.json totals, opcode + blend-mode histograms, per-function attribution
<tag>.opcodes.json the server's own gpu_opcodes histogram
<tag>.png the presented frame
<tag>-layers/composite.png every primitive, in issue order
<tag>-layers/layer-<func>.png one function's contribution, RGBA, alpha = coverage
<tag>-layers/sheet.png labelled contact sheet of all layers
<tag>-layers/layers.json the layer index: per-function stats and file names
diff.json machine-readable diff, --json

A busy frame's dump runs to tens of megabytes. Anything that only wants to know who drew what should read <tag>.summary.json instead — which is what Studio does.

Layers render over grey, on purpose

An isolated layer is drawn over neutral grey (--layer-backdrop, default 128), not black. A B-F subtractive vignette against black is black, and an additive glow against black is just the glow: both blends go invisible in exactly the view meant to show them. The composite still starts from black, as the GPU does.


Reading a diff

The report leads with the two findings that name a bug on their own:

  • a blend mode that disappeared — if B-F drew ten primitives in the good frame and none in the bad one, whatever layer used that blend (a vignette, a shadow) is no longer being drawn at all;
  • a function that stopped drawing — it stopped running, or stopped reaching its emit path.

Then, per function: stopped drawing, lost semi-transparency, started drawing, gained semi-transparency, count changed.

The diff is over counts and flags per (function, opcode), never over exact geometry. An animating effect legitimately moves its vertices every frame; geometry appears only as a sample, for context.


Turning an address into a name

Every function the tools report is an address you can name:

python3 -m project_studio analyze set-symbol --root . --pc 0x8004ABCD \
    --name LandEffect_DrawRays --status guessed \
    --note "observed issuing GP0 primitives (frame capture)"
python3 tools/sync_symbols.py     # regenerates psx_symbols.h / PSX_FN_*

Studio's Frames tab has a Save name box that calls exactly this, so symbols.toml keeps a single writer.


Parity scope

gpu_parity.py compares images, not GP0 streams. Only DuckStation is driven: it implements run_to_frame, psx-runtime does not, so the native side is sampled where it already is and DuckStation is advanced to meet it. A residual frame mismatch is reported rather than papered over. The DuckStation oracle patch (tools/duckstation/psxrecomp_oracle.patch) implements screenshot, read_vram, gpu_state, run_to_frame, step and pause — but not gpu_frame_dump, so there is no packet stream to compare on that side. Adding one to the patch is the obvious next step if image parity keeps saying "the streams must differ" without saying where; until then, tools/cosim.py brackets the divergence.

Getting an oracle on Linux: python3 tools/duckstation_oracle.py all builds and installs a patched DuckStation into ~/.local/share/retcomm/oracle/duckstation/ — outside any game repo, shared by every title — and start --disc <cue> runs it headless on 4371. On Arch-family and NixOS hosts the build runs inside ubuntu:22.04 via podman, because upstream's CMake refuses those hosts outright and its build scripts may not be patched. See tools/duckstation/README.md.

Both instances must be driven identically — same disc, same starting state. Parity between two runs that were not driven the same way means nothing.


24-bit scanout position

gpu_state reports screen_offset_y for 24-bit display output. The runtime derives this value from the GP1(07h) vertical display range.

A positive value moves the decoded scanout down. A negative value moves it up. The reference point is the PAL or NTSC broadcast centre, not the window edge.

The 24-bit presenter stages the visible source rows inside the full PAL or NTSC active canvas. Rows before or after active video are clipped once. Black canvas rows preserve the GP1(07h) position without dropping additional FMV content.

Use present_shot, not screenshot, to validate letterbox placement. screenshot captures the display buffer before the window-fit stage.


Tests

python3 -m unittest discover -s tools/tests -p 'test_gpu_frame.py'

Covers the packet word layouts against gpu.c's command lengths, the signed vertex, the E5 offset, the textured-polygon tpage latch, the four blends, truncation flagging, the attribution rollup, the diff verdicts, and a stub server that reproduces the one-request-per-connection contract.