mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 10:59:16 -04:00
922e8c5a07
The launcher and studio ship as Retro Launcher and Retro Studio now,
from the RetroPortingToolKit org, so the old product name is gone from
display text, documentation and release asset filenames.
The split is by case, and it is not cosmetic. CamelCase RetComM was
always the product; lowercase retcomm and RETCOMM_ are identifiers --
the C++ namespace, the binaries, the data directories, the CMake and
environment variables, the Cloudflare service names, the readme markers.
Renaming those would orphan existing installs for no benefit, so they
are untouched: 1101 lowercase retcomm and 205 RETCOMM_ still stand in
the launcher alone.
Four things are deliberately still spelled RetComM, and three of them
would have been real breakage:
- RetComM-Data is CamelCase but it is a directory on disk --
<exe_dir>\RetComM-Data\{runtime,config,data} for a portable install.
Renaming it strands every portable user's config, toolchains and
catalog cache.
- TechnicallyComputers/RetComM-Launcher in readme_has_launcher() is the
historical half of a both-slugs test. Renaming it makes it a duplicate
of the line above and sends every port built before the transfer back
to reporting its launcher section missing.
- _LEGACY_LAUNCHER_RE matched "^## RetComM Launcher". A sweep renamed it,
which would have silently stopped it matching every README already out
there; it now accepts both headings.
- The portable-stub lookup gained the pre-rename filenames back as
fallbacks, because old zips stay on the releases page and a rollback
downloads one.
Release assets become Retro-Launcher-* / Retro-Studio-*. That is safe
for installed clients: pick_launcher_asset() gates on *linux*/*windows*/
*macos* plus extension and arch, not on the product name, and the
Windows portable stub is identified by its RCM1 trailer rather than its
filename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013DpaQqVguHFpBuCmdoFYWY
320 lines
13 KiB
Markdown
320 lines
13 KiB
Markdown
# 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.
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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.
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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.
|