diff --git a/docs/FRAME_RATE.md b/docs/FRAME_RATE.md index e951c0d0..cc9d9482 100644 --- a/docs/FRAME_RATE.md +++ b/docs/FRAME_RATE.md @@ -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 diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md new file mode 100644 index 00000000..516202b2 --- /dev/null +++ b/docs/RENDER_PASSES.md @@ -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= count=`: 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). diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 01eb5d27..558a67ce 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -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 `/g__a.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":""}` 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":""}` 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` | ✓ | | |