docs: render passes (API, sandbox, presenter, budget, verification)

docs/RENDER_PASSES.md; FRAME_RATE.md gains HOLD and a link; TCP_COMMANDS.md
describes gl_interp, render_pass_stats and render_pass_dump and regenerates
the command index (which also picks up three commands master had not
indexed yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-09-26 03:05:16 -07:00
parent 82a835b20b
commit 1989056c45
3 changed files with 119 additions and 4 deletions
+3 -1
View File
@@ -29,9 +29,11 @@ netplay session or a later offline one never inherits them.
|---|---|
| `LINEAR` (default) | crossfade `mix(prev, cur, a)` |
| `MOTION_ADAPTIVE` | crossfade small changes, switch large changes at a = 0.5 |
| `HOLD` | no crossfade: repeat the newest frame (for titles whose plugin supplies in-between images with render passes) |
Blending never invents motion: moving objects ghost, and the image is one
source frame behind the newest one.
source frame behind the newest one. True in-between frames need the game's
own draw code: see [RENDER_PASSES.md](RENDER_PASSES.md).
## Blend source: every VBlank or real flips
+108
View File
@@ -0,0 +1,108 @@
# Render passes: true in-between frames
Frame blending ([FRAME_RATE.md](FRAME_RATE.md)) crossfades finished frames; it
cannot show objects in positions the game never drew. A **render pass** lets a
trusted game plugin redraw its scene between two logic ticks with the game's
own draw code, with objects and camera placed part of the way from one tick
to the next. The pass never changes the game: guest time is frozen while it
runs and the machine is restored bit-for-bit afterwards. Its only product is
an image the presenter shows between the game's own frames.
Default off. Nothing happens unless a plugin calls the API below.
## API (mod_plugins.h)
```c
uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks,
uint32_t shown_after_vblanks,
uint32_t *alpha_q16, uint32_t max);
int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass,
PSXModRenderPassFn fn, void *user);
```
Call both from an emulation-thread function-entry hook at the point where
the game has finished the logic of tick n+1 but the display still has to
flip to frame n. For a PsyQ double-buffered loop that is the entry of the
`VSync(0)` that precedes `PutDispEnv`.
1. `psx_mod_render_pass_plan(P, D, a, max)` returns the phases (Q16, in
(0, 1)) at which the presenter will show frame n: its output deadlines
during the P VBlanks the frame stays on screen, starting after D more
VBlank presents. When the host cannot afford them all, an evenly spread
subset is returned. 0 means "no passes this frame" (see Gates).
2. For each phase, `psx_mod_render_pass()` with the display rect the next
flip shows (the DISPENV rect). `fn(cpu, user, alpha)` may write guest RAM
and call guest functions with `psx_dispatch_call()`; it returns nonzero to
keep the image. The first pass after a plan also captures frame n's own
image (phase 0).
The presenter uses `PSX_MOD_FRAME_SOURCE_FLIP` to see the
flip, and a plugin that supplies passes normally selects
`PSX_MOD_FRAME_INTERPOLATION_HOLD`, so wherever no pass image applies the
newest game frame is repeated rather than crossfaded one frame late.
## What a pass may and may not do
While `fn` runs (`g_psx_render_pass_active`):
| Area | Behaviour | Where |
|---|---|---|
| Guest clock | cycles are counted (GTE and mult/div deadlines work) but no device is serviced, no VBlank or device event fires | `psx_cycles.c` freeze |
| Interrupts | never delivered | `interrupts.c` |
| GPU DMA | linked lists and delayed completions finish synchronously | `dma.c` |
| RAM / scratchpad stores | written directly, bypassing code-page tracking, overlay watch, write traces and fingerprints | `memory.c` `render_pass_store` |
| MMIO stores | allowed: GP0, GP1 DMA mode / info, GPU and OTC DMA channels, DPCR/DICR, I_STAT/I_MASK. Dropped and counted: SPU (key-ons), CD, timers, SIO, MDEC, other DMA channels, memory control | `memory.c` |
| VRAM | only the declared rect; writes that bypass the scissor elsewhere are journaled and rolled back (refused under native-wide) | `gpu_gl_renderer.c` |
| Runaway code | an 8 M guest-cycle watchdog rolls the pass back | `render_pass.c` |
After `fn` (success or not) everything is restored: CPU state with the GTE,
2 MiB RAM, scratchpad, I-cache tags, I_STAT/I_MASK, timers, DMA and GPU
registers (without the widescreen side effects of a savestate load), the
VRAM rect (hr colour, mask stencil, raw 16-bit mirror, native-wide band, CPU
VRAM rows), the renderer's coherency bookkeeping, and every clock value.
After 8 faults (watchdog or refused writes) passes stay off for the session.
## Gates
The plan returns 0 in netplay, rollback resimulation, self-check, rewind,
lockstep, an exception, while a GPU DMA list is in flight, on anything but
the OpenGL renderer with FLIP-source interpolation, while the presenter is
suspended (FMV), and when no VBlank was presented since the last plan (debug
turbo, headless).
## Presentation and budget
The presenter keeps two generations of images: the frame on screen and the
one being built for the next flip. A generation becomes current when the
FLIP source sees the display flip to its rect; from that interval's start
its phases map onto host time. At each output deadline the newest image at
or before the deadline's phase is shown, crossfaded into the next one when
passes were shed. Deadlines that fall due while passes run are presented
between passes, so the frame on screen keeps moving.
Passes cost host time inside the game's frame. The budget per frame is
`PSX_RENDER_PASS_BUDGET` percent (default 80) of the presenter's idle time
plus the presents beyond two per frame, learnt from the previous frame, over
a smoothed per-pass cost. Nothing ever slows the guest down: when the budget
runs out, fewer passes are rendered.
Pass images are kept at internal resolution (the size the presenter
captures); two generations fit a 256 MiB budget, which limits passes per
frame at very high internal resolutions.
## Verifying a title
- `render_pass_stats` (TCP): passes, shedding, faults, dropped device
stores, timing split (backup / guest code / capture / restore), presents
made from pass images.
- `render_pass_dump path=<dir> count=<n>`: PNGs of the next n frames' images
(the game's own frame, then each pass in phase order).
- `PSX_RENDER_PASS_VERIFY=1`: hash CPU, RAM, scratchpad, I-cache, interrupt,
timer, DMA and GPU state and read back the VRAM rect before and after every
pass; `verify_mismatch` must stay 0.
- `frame_fingerprint reset_on_load=1` then a savestate load: the per-frame
write/MMIO/cycle fingerprints of a run with passes must equal a run
without them.
Tests: `render_pass_plan_test` and `render_pass_freeze_test` (runtime ctest),
`render_pass_guards` (source guard, recompiler ctest).
+8 -3
View File
@@ -53,6 +53,9 @@ Columns: **N** = native, **D** = DuckStation oracle.
| `screenshot_hires` | ✓ | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists |
| `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) |
| `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates |
| `gl_interp` | ✓ | | — | OpenGL frame-rate presenter ([FRAME_RATE.md](FRAME_RATE.md)): enabled/suspended, host and target Hz, swaps, `source` (`vblank`/`flip`), `flip_period`, `captures` (new source frames) and `duplicates` (VBlanks that re-presented the same frame) |
| `render_pass_stats` | ✓ | | — | Render passes ([RENDER_PASSES.md](RENDER_PASSES.md)): plans, phases wanted/planned (shedding), passes, rollbacks, dropped device stores by class, `verify_mismatch` under `PSX_RENDER_PASS_VERIFY=1`, host-time split per pass, presents made from pass images |
| `render_pass_dump` | ✓ | | `path`, `count` | Write the images (the game's own frame, then each pass in phase order) of the next `count` frames that get passes as `<path>/g<frame>_<index>_a<phase q16>.png` |
| `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate |
| `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history |
| `irq_state` | ✓ | ✓ | — | `I_STAT`, `I_MASK` (both), plus chain state on native |
@@ -336,9 +339,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i
## Complete command index (generated)
**316 commands registered** — 303 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
**318 commands registered** — 305 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
54 of 316 have prose above; **262 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
57 of 318 have prose above; **261 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":"<name>"}` and read the reply, or find its `handle_*` function in the server source.
Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code.
@@ -464,7 +467,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `get_snapshots` | ✓ | ✓ | ✓ |
| `gl_coh_ring` | ✓ | | |
| `gl_fbo_peek` | ✓ | | |
| `gl_interp` | ✓ | | |
| `gl_interp` | ✓ | | ✓ |
| `gl_present_ring` | ✓ | | |
| `gl_vram_diff` | ✓ | | |
| `gl_wide_fast` | ✓ | | |
@@ -553,6 +556,8 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc
| `record_frame` | ✓ | | |
| `record_frame_dump` | ✓ | | |
| `record_reads_dump` | ✓ | | |
| `render_pass_dump` | ✓ | | ✓ |
| `render_pass_stats` | ✓ | | ✓ |
| `restore_trace` | ✓ | | |
| `restore_trace_clear` | ✓ | | |
| `restore_trace_window` | ✓ | | |