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..6275f333
--- /dev/null
+++ b/docs/RENDER_PASSES.md
@@ -0,0 +1,213 @@
+# 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);
+uint32_t psx_mod_render_pass_status(void);
+```
+
+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`.
+
+Register that hook with `psx_mod_register_function_entry_plugin()` under the
+plugin's manifest `[[plugin]]` id (see [MOD_PACKAGES.md](MOD_PACKAGES.md)):
+it then runs only while the resolved mod plan activates the plugin, so never
+while the package is disabled or in netplay. Register once, for example from
+the plugin's constructor; a repeated id and address returns 0. Entry hooks
+also fire for the guest functions a pass itself calls, the plugin's own
+included, so a hook that plans passes must ignore entries made inside one.
+
+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.
+
+### When passes are unavailable
+
+`psx_mod_render_pass_status()` says why a plan would be empty, the host-time
+budget aside (an empty plan while it says `READY` was shed for time):
+
+| Status | Meaning | Lasts |
+|---|---|---|
+| `READY` (0) | passes can run | |
+| `NO_PRESENTER` (1) | not OpenGL, interpolation off or suspended (FMV), or not the FLIP source | until the presenter changes |
+| `BACKEND` (2) | the renderer declines passes in its current mode | while that mode lasts |
+| `DISABLED` (3) | switched off after repeated faults | the session |
+| `SESSION` (4) | netplay, rollback, rewind, load/save replay, self-check resimulation | transient |
+| `FAST_FORWARD` (5) | manual fast-forward, turbo-through-loads, FMV auto-skip, TCP turbo | transient |
+| `BUSY` (6) | inside an exception, a pass or a GPU DMA walk, or no frame captured since the presenter's history restarted (a display mode change) | transient |
+
+A plugin that relies on passes should not leave the player on stock-rate
+frames while a lasting reason holds: it can switch the presenter to a
+crossfade with `psx_mod_set_frame_interpolation_blend()`, which may be called
+from a hook at any time and takes effect at the next present, and switch
+back to `HOLD` when the status is `READY` again. A backend mode that cannot
+host passes (for example a renderer path whose VRAM is not one texture) should
+report `BACKEND` from `gl_renderer_pass_unavailable()`.
+
+## 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 (`psx_cycle_freeze.h`, runtime-only: the codegen-hashed `psx_cycles.h` is untouched) |
+| 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; RAM addresses fold through the live geometry (2 MiB mirrored, or 8 MiB with the 8 MB RAM mod), as outside a pass | `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 (fills, copies, uploads, pokes: never a native-wide surface) are journaled and rolled back | `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,
+all of main RAM at its live size (2 MiB, or 8 MiB with the opt-in 8 MB RAM
+mod; `psx_memory.h`), 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.
+A watchdog abort leaves by longjmp from inside guest code, skipping the
+exits of the frames it leaves, so the host nesting those frames own is put
+back from the checkpoint too: the cycle-deferral depth, the native overlay
+unit depth, shard stack and cycle-flush hook, the DMA execution depth, and
+the interpreter's active/phase/precise flags, resume latch and pending load
+(`render_pass_abort_test`). A plugin must likewise not keep state that only
+its callback's normal return resets.
+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, during manual
+fast-forward, turbo-through-loads, FMV auto-skip and TCP turbo, on anything
+but the OpenGL renderer with FLIP-source interpolation, while the presenter
+is suspended (FMV), after repeated faults, and when no VBlank was presented
+since the last plan (headless). `psx_mod_render_pass()` refuses under the
+same conditions.
+
+## 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. A frame that stays on
+screen longer than the plan's period (a lagging tick: three VBlanks instead
+of two) holds its newest image until the next flip, as a late stock frame
+holds; it never steps back to the game's own image of the frame, which is
+older (`late_presents` in `render_pass_stats`). Only a game that stops
+flipping for four frame lengths expires the frame's images (`expired`), and
+the presenter then shows its own newest capture.
+
+Consecutive passes of one frame reuse the VRAM backup the first one took:
+each pass's restore leaves the rect exactly as backed up, and no guest code
+runs in between (the clock and guest store count are unchanged), so only the
+first pass copies the rect out (`backups_reused` in `render_pass_stats`).
+
+Passes cost host time inside the game's frame: they run on the emulation
+thread. 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. When the budget runs
+out, fewer passes are rendered, and a pass that costs more than the budget is
+not planned at all. The cost is measured per presented image size: until
+three passes have been measured at the current size (the first plans, and
+after an aspect or internal-resolution change) a plan asks for one pass, so
+an expensive size costs at most one pass per frame while it is learnt. The
+three seed the average with their median, so one slow pass on a busy host
+does not price passes out. A pass that creates pass textures or
+framebuffers (the first frames at a size, or a slot filled for the first
+time) is not a cost sample, at most eight in a row. Only passes that run
+are measured, and the first passes of a race can all run in a transient (a
+busy host, code run for the first time) that costs several times the steady
+state; an estimate that high prices every plan out, so no pass would ever
+correct it. An estimate that no pass has been measured against for 30
+plans that wanted passes (about a second of a 30 Hz game) is measured again:
+the warm-up restarts, with one pass per plan (`cost_rewarms` in
+`render_pass_stats`). When the new median is not at least a quarter below
+the old estimate, the old one was right (a size that is truly too
+expensive, or a host at its limit), and the next wait doubles, up to 960
+plans, so these one-pass warm-ups stay rare; a re-measure that finds the
+old estimate stale keeps the wait at 30. A plugin can show a crossfade while
+passes are shed (see "When passes are unavailable").
+
+Pass images are kept at internal resolution (the size the presenter
+captures). Their textures are made as the slots fill, never more slots than
+two generations fit in 256 MiB, which limits passes per frame at very high
+internal resolutions; a size change frees the old set.
+
+## 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, and `status`.
+- `render_pass_refuse on=1` (TCP) or `PSX_RENDER_PASS_REFUSE=1`: the backend
+ declines passes (`BACKEND`), to test a plugin's fallback.
+- `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, and check that a pass that returned normally left the host nesting
+ balanced; `verify_mismatch` must stay 0.
+- `PSX_RENDER_PASS_WATCHDOG=`: lower the watchdog (default
+ 8 M) below a title's pass size to drive real passes through the rollback
+ path; with fingerprints (below) the run must still match one without
+ passes.
+- `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 (runtime ctest unless noted):
+
+- `render_pass_plan_test`: phase planning per rate, shedding (one pass while
+ the cost is unknown), the cost warm-up and allocation rule, re-measuring a
+ stale estimate (and backing off when it stays too expensive), selection,
+ holding the newest image when the next flip is late, the MMIO allow-list.
+- `render_pass_freeze_test`: 10^6 frozen cycles, exact clock restore,
+ watchdog.
+- `render_pass_sandbox_test`: the store policy `memory.c` routes every pass
+ store through (SPU, CD, timer, other-DMA, GP1 and other device stores
+ dropped and counted per class, never delivered; GP0, I_STAT/I_MASK and
+ GPU/OTC DMA delivered); a real pass over `render_pass.c`, `psx_cycles.c`
+ and `timers.c` that makes those stores and runs 10^6 cycles while a timer
+ is armed to interrupt every 1000 (no device advances, no interrupt, RAM,
+ scratchpad, I_STAT/I_MASK, timers and clock restored); status gates; the
+ same store folding and restore with 8 MiB RAM live (unique addresses up to
+ 0x7FFFFF, restored after a pass, and a retail pass after it folding again);
+ the out-of-rect VRAM journal policy and its exact rollback of the CPU VRAM
+ rows.
+- `render_pass_abort_test`: a watchdog abort from nested frames.
+- `render_pass_guards` (source guard, recompiler ctest): the choke points
+ above, and that the codegen-hashed headers do not carry the pass API.
+
+The GPU half of the VRAM transaction (hr colour, stencil, raw mirror blits)
+needs an OpenGL context and is checked at runtime by
+`PSX_RENDER_PASS_VERIFY=1`.
diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md
index caa7c7f7..d8e92cbf 100644
--- a/docs/TCP_COMMANDS.md
+++ b/docs/TCP_COMMANDS.md
@@ -53,6 +53,10 @@ 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 (`nesting_repairs`: watchdog aborts whose skipped frame exits the restore undid), dropped device stores by class, `verify_mismatch` under `PSX_RENDER_PASS_VERIFY=1`, host-time split per pass, smoothed pass cost (`cost_us`; `cost_rewarms`: estimates no pass had run on for a while, measured again), presents made from pass images (`late_presents`: held past the frame's planned end because the next flip was late; `expired`: frames whose images stopped showing after several frame lengths without a flip), pass image textures allocated (`image_textures`, `image_bytes`), `status` (`psx_mod_render_pass_status`: 0 ready, 1 no presenter, 2 backend, 3 disabled, 4 session, 5 fast-forward, 6 busy), `backups_reused` (passes that reused the previous pass's VRAM backup) |
+| `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` |
+| `render_pass_refuse` | ✓ | | `on` | Make the OpenGL backend decline render passes (`status` 2, BACKEND), as a renderer mode without them would; tests a plugin's fallback. `PSX_RENDER_PASS_REFUSE=1` does the same from start |
| `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 |
@@ -391,9 +395,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`).
+**319 commands registered** — 306 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`).
-55 of 316 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.
+59 of 319 have prose above; **260 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.
@@ -519,7 +523,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` | ✓ | | |
@@ -608,6 +612,9 @@ 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_refuse` | ✓ | | ✓ |
+| `render_pass_stats` | ✓ | | ✓ |
| `restore_trace` | ✓ | | |
| `restore_trace_clear` | ✓ | | |
| `restore_trace_window` | ✓ | | |
diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt
index 87c23ba5..d959cf03 100644
--- a/recompiler/CMakeLists.txt
+++ b/recompiler/CMakeLists.txt
@@ -900,6 +900,7 @@ if(BUILD_TESTING)
overlay_init_guard
overlay_segment_gate
recomp_ui_root_override
+ render_pass_guards
pal_cadence_host_refresh
sdl3_main_single_include
spu_sample_scheduler_default
diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt
index 8312ede7..a09f3e38 100644
--- a/runtime/CMakeLists.txt
+++ b/runtime/CMakeLists.txt
@@ -605,6 +605,51 @@ if(BUILD_TESTING)
add_test(NAME frame_interpolation_schedule_test
COMMAND frame_interpolation_schedule_test)
+ add_executable(render_pass_plan_test
+ tests/test_render_pass_plan.c
+ src/render_pass_plan.c)
+ target_include_directories(render_pass_plan_test PRIVATE include)
+ if(NOT MSVC)
+ target_link_libraries(render_pass_plan_test PRIVATE m)
+ endif()
+ add_test(NAME render_pass_plan_test COMMAND render_pass_plan_test)
+
+ # Render-pass time freeze against mock devices (psx_cycles.c only).
+ add_executable(render_pass_freeze_test
+ tests/test_render_pass_freeze.c
+ src/psx_cycles.c)
+ target_include_directories(render_pass_freeze_test PRIVATE include)
+ add_test(NAME render_pass_freeze_test COMMAND render_pass_freeze_test)
+
+ # Render-pass watchdog abort from nested guest frames: the real
+ # render_pass.c and psx_cycles.c against mock memory, devices and
+ # presenter (the frames use GCC/Clang cleanup handlers, as generated
+ # code does).
+ if(NOT MSVC)
+ add_executable(render_pass_abort_test
+ tests/test_render_pass_abort.c
+ src/render_pass.c
+ src/psx_cycles.c)
+ target_include_directories(render_pass_abort_test PRIVATE include)
+ add_test(NAME render_pass_abort_test COMMAND render_pass_abort_test)
+ endif()
+
+ # Render-pass sandbox: the store policy memory.c uses, a real pass
+ # (render_pass.c, psx_cycles.c, timers.c) that makes device stores and
+ # runs frozen cycles, and the out-of-rect VRAM journal.
+ add_executable(render_pass_sandbox_test
+ tests/test_render_pass_sandbox.c
+ src/render_pass.c
+ src/render_pass_plan.c
+ src/psx_cycles.c
+ src/timers.c
+ src/psx_video_timing.c)
+ target_include_directories(render_pass_sandbox_test PRIVATE include)
+ if(NOT MSVC)
+ target_link_libraries(render_pass_sandbox_test PRIVATE m)
+ endif()
+ add_test(NAME render_pass_sandbox_test COMMAND render_pass_sandbox_test)
+
add_executable(gpu_primitive_reject_test tests/test_gpu_primitive_reject.c)
target_include_directories(gpu_primitive_reject_test PRIVATE include)
add_test(NAME gpu_primitive_reject_test COMMAND gpu_primitive_reject_test)
diff --git a/runtime/include/debug_server.h b/runtime/include/debug_server.h
index c4b512f5..27a3d2b9 100644
--- a/runtime/include/debug_server.h
+++ b/runtime/include/debug_server.h
@@ -255,6 +255,9 @@ void debug_server_freeze_dump_dirty_block_json(FILE *f, uint32_t max_count);
void debug_server_freeze_dump_dirty_insn_json(FILE *f, uint32_t max_count);
void debug_server_freeze_dump_evcb_json(FILE *f, uint32_t max_count);
+/* frame_fingerprint reset_on_load: called when a savestate load completes. */
+void debug_server_note_savestate_loaded(void);
+
#ifdef __cplusplus
}
#endif
diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h
index d96cc5c6..3d661036 100644
--- a/runtime/include/dirty_ram_interp.h
+++ b/runtime/include/dirty_ram_interp.h
@@ -53,6 +53,18 @@ void dirty_ram_ld_delay_flush(CPUState* cpu);
* on the next interp step (selfcheck MotK v0=countdown vs v0=1 forks). */
void dirty_ram_ld_delay_discard(void);
+/* Save / put back the deferred load-delay writeback exactly. For a landing
+ * that longjmps out of interpreted code and then resumes the interrupted
+ * code (the render-pass watchdog abort, render_pass.c): the pass's pending
+ * load must not reach the resumed timeline, and the interrupted one's must
+ * survive. */
+typedef struct DirtyRamLoadDelay {
+ uint32_t rt, val, age;
+ int armed;
+} DirtyRamLoadDelay;
+void dirty_ram_ld_delay_save(DirtyRamLoadDelay *out);
+void dirty_ram_ld_delay_restore(const DirtyRamLoadDelay *in);
+
/* Re-anchor host-only dirty IRQ pump ambient (entry-poll stride + 4096-insn
* gap) after snap restore. Not in boot_state — peers that drifted through FMV
* otherwise entered the post-FMV dirty wait on opposite poll phases. */
diff --git a/runtime/include/dma.h b/runtime/include/dma.h
index 203501a7..d6565acf 100644
--- a/runtime/include/dma.h
+++ b/runtime/include/dma.h
@@ -41,6 +41,7 @@ uint32_t dma_cycles_to_deliverable_irq(uint32_t i_mask);
uint32_t dma_get_dicr(void);
uint32_t dma_get_dpcr(void);
int dma_cdrom_transfer_active(void);
+int dma_gpu_linked_list_active(void);
typedef struct DMAChannelDebugState {
uint32_t madr;
diff --git a/runtime/include/frame_interpolation.h b/runtime/include/frame_interpolation.h
index ea72b2b2..4e80efc5 100644
--- a/runtime/include/frame_interpolation.h
+++ b/runtime/include/frame_interpolation.h
@@ -44,6 +44,16 @@ int frame_interpolation_schedule_begin_phase(FrameInterpolationSchedule *schedul
double source_hz, double target_hz,
double phase_lo, double phase_hi);
+/* Render passes present between guest VBlanks. The next output deadline, if
+ * it is already due (<= now) and not later than `horizon` (host ticks), with
+ * stale deadlines coalesced to the newest one as begin() does. It is not
+ * consumed: call frame_interpolation_schedule_consume() once it is shown, so
+ * the next source interval simply continues after it. */
+int frame_interpolation_schedule_due(FrameInterpolationSchedule *schedule,
+ uint64_t now, double horizon,
+ uint64_t *deadline);
+void frame_interpolation_schedule_consume(FrameInterpolationSchedule *schedule);
+
uint64_t frame_interpolation_schedule_end(
const FrameInterpolationSchedule *schedule);
void frame_interpolation_schedule_reset(FrameInterpolationSchedule *schedule);
diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h
index cb0cbb2d..fd87e4da 100644
--- a/runtime/include/gpu.h
+++ b/runtime/include/gpu.h
@@ -46,6 +46,10 @@ typedef struct {
} GpuDisplayInfo;
void gpu_get_display_info(GpuDisplayInfo* out);
+/* Render-pass GPU register checkpoint (render_pass.c). One at a time. */
+int gpu_pass_checkpoint_save(void);
+void gpu_pass_checkpoint_restore(void);
+uint64_t gpu_pass_state_hash(void);
/* Perspective-correct UV arming rate, per condition. armed/attempts is the
* real perspective coverage: attempts counts only textured triangles, so it is
diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h
index 7cf709f8..62c37868 100644
--- a/runtime/include/gpu_gl_renderer.h
+++ b/runtime/include/gpu_gl_renderer.h
@@ -33,12 +33,59 @@ void gl_renderer_set_swap_interval(int interval);
void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz,
double source_hz, int blend_mode);
void gl_renderer_set_interpolation_suspended(int suspended);
+/* Change only the blend mode (0 linear, 1 change-adaptive, 2 hold), keeping
+ * the history and schedule; takes effect at the next present. */
+void gl_renderer_set_interpolation_blend(int blend_mode);
/* Blend source: 0 = every guest VBlank is a source frame (default),
* 1 = only real display flips are (psx_mod_set_frame_interpolation_source). */
void gl_renderer_set_interpolation_source(int source);
void gl_renderer_interpolation_source_diag(int *source, uint32_t *flip_period,
uint64_t *captures,
uint64_t *duplicates);
+
+/* Render passes (render_pass.c; docs/RENDER_PASSES.md). The presenter is
+ * ready for passes: GL raster, interpolation live with the FLIP source. */
+int gl_renderer_pass_ready(void);
+/* Why not: 0 ready, PSX_MOD_RENDER_PASS_NO_PRESENTER or _BACKEND
+ * (mod_plugins.h). A backend mode that cannot host passes reports BACKEND. */
+uint32_t gl_renderer_pass_unavailable(void);
+/* Debug: make the backend decline passes (PSX_RENDER_PASS_REFUSE=1 at start,
+ * render_pass_refuse over TCP), as a renderer mode without them would. */
+void gl_renderer_pass_force_refuse(int on);
+/* Phases the presenter will show for the frame the next flip displays. */
+uint32_t gl_renderer_pass_plan(uint32_t period_vblanks,
+ uint32_t shown_after_vblanks,
+ uint32_t *alpha_q16, uint32_t max,
+ uint32_t *wanted);
+/* Open the VRAM transaction for rect; open_gen first captures the game's own
+ * image of it as a new generation. reuse_backup: the caller guarantees that
+ * no guest code ran since the previous pass ended (its restore left VRAM as
+ * backed up), so the backup of that pass is reused when it covers the same
+ * rect at the same scale. Returns 0 when refused (nothing changed). */
+int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen,
+ uint32_t period_vblanks, int reuse_backup);
+/* Capture the drawn rect at alpha_q16 (keep) and roll the rect back. */
+void gl_renderer_pass_end(uint32_t alpha_q16, int keep);
+uint32_t gl_renderer_pass_leaks(void);
+int gl_renderer_pass_verify_vram(void);
+void gl_renderer_pass_note_cost(uint64_t ticks);
+/* Present output deadlines that fell due while guest code ran. */
+void gl_renderer_pass_service_presents(void);
+/* promotions, presents, blended presents, expired, unmatched flips, presents
+ * made between VBlanks, smoothed pass cost (us), images in the shown frame,
+ * presents past the frame's planned end (the next flip was late; the newest
+ * image held), stale pass-cost estimates measured again. */
+void gl_renderer_pass_diag(uint64_t out[10]);
+/* Out-of-rect VRAM writes journaled and rolled back (lifetime count). */
+uint64_t gl_renderer_pass_journaled(void);
+/* Passes that reused the previous pass's VRAM backup (lifetime count). */
+uint64_t gl_renderer_pass_backups_reused(void);
+/* Pass image textures allocated now (both generations) and their bytes. */
+uint32_t gl_renderer_pass_image_textures(uint64_t *bytes);
+/* Debug: dump the images of the next `generations` shown frames as PNGs. */
+void gl_renderer_pass_dump_arm(const char *dir, int generations);
+uint64_t gl_renderer_perf_ticks(void);
+uint64_t gl_renderer_perf_frequency(void);
int gl_renderer_interpolation_owns_cadence(void);
void gl_renderer_interpolation_diag(int *enabled, int *suspended,
int *history_frames,
diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h
index 9288b945..471041bd 100644
--- a/runtime/include/mod_plugins.h
+++ b/runtime/include/mod_plugins.h
@@ -236,8 +236,17 @@ int psx_mod_set_frame_interpolation(uint32_t frames_per_second);
*/
enum {
PSX_MOD_FRAME_INTERPOLATION_LINEAR = 0,
- PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE = 1
+ PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE = 1,
+ /* No crossfade: every output frame repeats the newest game frame. For a
+ * plugin that supplies its own in-between images with render passes
+ * (below); wherever it has none, the output matches stock timing and
+ * nothing is shown later than the game shows it. */
+ PSX_MOD_FRAME_INTERPOLATION_HOLD = 2
};
+/* Usually called from activation. It may also be called later from the
+ * emulation thread (a function-entry hook or VBlank callback), e.g. to swap
+ * HOLD for a crossfade while render passes are unavailable; the OpenGL
+ * presenter then uses the new mode from its next present. */
int psx_mod_set_frame_interpolation_blend(uint32_t blend_mode);
/*
* Choose what the OpenGL presenter treats as a new source frame. VBLANK (the
@@ -254,6 +263,82 @@ enum {
PSX_MOD_FRAME_SOURCE_FLIP = 1
};
int psx_mod_set_frame_interpolation_source(uint32_t source);
+
+/*
+ * Host-timed render passes: true in-between frames for a game whose logic
+ * runs slower than the presentation rate. Default off: nothing happens unless
+ * a trusted plugin calls these. OpenGL, frame interpolation enabled with the
+ * FLIP source, never in netplay, rollback, rewind, fast-forward (manual,
+ * turbo-through-loads, FMV auto-skip) or while the presenter is suspended
+ * (FMV); psx_mod_render_pass_plan() returns 0 then, and
+ * psx_mod_render_pass_status() says why.
+ *
+ * Call both from an emulation-thread function-entry hook placed where the
+ * game has finished its logic for game frame N+1 but the display still has to
+ * flip to frame N (for a PsyQ double-buffered loop: the VSync(0) that precedes
+ * PutDispEnv). Each pass runs `fn`, which may call guest functions with
+ * psx_dispatch_call() to draw an intermediate image of the scene. While it
+ * runs, guest time is frozen: cycles are counted but no device advances, no
+ * interrupt is delivered and GPU DMA completes synchronously; SPU, CD, timer
+ * and other device stores are dropped (counted). Afterwards CPU state
+ * (including the GTE), RAM, scratchpad, I-cache tags, interrupt, timer, DMA
+ * and GPU registers, and the VRAM rect are exactly as before: the pass's only
+ * product is the image of the rect, which the presenter shows at `alpha_q16`
+ * of the way through frame N's time on screen (Q16, 0 = frame N's own image).
+ * The display rect is the one the next flip shows (its DISPENV); the pass may
+ * draw only inside it.
+ */
+/* Returns nonzero to keep the image, 0 to discard it (state is restored
+ * either way). */
+typedef int (*PSXModRenderPassFn)(struct CPUState* cpu, void* user,
+ uint32_t alpha_q16);
+typedef struct PSXModRenderPass {
+ uint32_t struct_size; /* sizeof(PSXModRenderPass) */
+ uint32_t alpha_q16; /* a phase returned by the plan */
+ uint16_t x, y, w, h; /* VRAM display rect the pass draws */
+} PSXModRenderPass;
+/*
+ * Phases (Q16, ascending, excluding 0) at which the presenter will actually
+ * show frame N: its output deadlines during the `period_vblanks` guest VBlanks
+ * the frame stays on screen, starting after `shown_after_vblanks` more
+ * VBlank presents (R4 at its VSync(0) entry: 1 -- the next VBlank still shows
+ * the previous frame). When the host cannot afford them all, an evenly spread
+ * subset is returned and the presenter crossfades the gaps. The first
+ * psx_mod_render_pass() after a plan captures frame N's own image. Returns 0
+ * when passes are unavailable or unaffordable.
+ */
+uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks,
+ uint32_t shown_after_vblanks,
+ uint32_t* alpha_q16, uint32_t max);
+/* Returns 1 when the pass ran and its image was queued, 0 when it was refused
+ * or rolled back (state is restored either way). */
+int psx_mod_render_pass(struct CPUState* cpu, const PSXModRenderPass* pass,
+ PSXModRenderPassFn fn, void* user);
+/*
+ * Why passes cannot run right now, the host-time budget aside (a plan that
+ * returns 0 while this says READY was shed for time). A plugin that relies on
+ * passes uses it to fall back, e.g. to a crossfade with
+ * psx_mod_set_frame_interpolation_blend(), while the reason lasts.
+ * NO_PRESENTER, BACKEND and DISABLED persist; the others are transient.
+ */
+enum {
+ PSX_MOD_RENDER_PASS_READY = 0,
+ /* Not OpenGL, interpolation off or suspended (FMV), or not the FLIP
+ * source. */
+ PSX_MOD_RENDER_PASS_NO_PRESENTER = 1,
+ /* The renderer declines passes in its current mode. */
+ PSX_MOD_RENDER_PASS_BACKEND = 2,
+ /* Switched off for this session after repeated faults. */
+ PSX_MOD_RENDER_PASS_DISABLED = 3,
+ /* Netplay, rollback, rewind, load/save replay or self-check resim. */
+ PSX_MOD_RENDER_PASS_SESSION = 4,
+ /* Fast-forward, turbo-through-loads or FMV auto-skip is running. */
+ PSX_MOD_RENDER_PASS_FAST_FORWARD = 5,
+ /* Inside an exception, a pass or a GPU DMA walk, or the presenter has not
+ * captured a frame since its history restarted (display mode change). */
+ PSX_MOD_RENDER_PASS_BUSY = 6
+};
+uint32_t psx_mod_render_pass_status(void);
int psx_mod_set_auto_skip_fmv(int enabled);
/*
* Draw still artwork behind the game image in OpenGL letterbox/pillarbox
diff --git a/runtime/include/overlay_loader.h b/runtime/include/overlay_loader.h
index abb94489..053442b8 100644
--- a/runtime/include/overlay_loader.h
+++ b/runtime/include/overlay_loader.h
@@ -161,6 +161,11 @@ void overlay_loader_get_ci_skip_diag(uint64_t *unit, uint64_t *supp,
uint64_t *none, uint64_t *sr,
uint64_t *deliv, uint64_t *enter);
int overlay_loader_call_unit_depth(void);
+/* Native-shard nesting (active-candidate stack depth, in-progress entry),
+ * saved before and restored after a landing that longjmps out of a shard
+ * and resumes the interrupted code (render_pass.c watchdog abort). */
+void overlay_loader_native_nesting(int *active_depth, uint32_t *inprogress);
+void overlay_loader_set_native_nesting(int active_depth, uint32_t inprogress);
#ifdef __cplusplus
}
diff --git a/runtime/include/psx_cycle_freeze.h b/runtime/include/psx_cycle_freeze.h
new file mode 100644
index 00000000..ba300bb0
--- /dev/null
+++ b/runtime/include/psx_cycle_freeze.h
@@ -0,0 +1,49 @@
+#ifndef PSX_CYCLE_FREEZE_H
+#define PSX_CYCLE_FREEZE_H
+
+/* Render-pass time freeze (render_pass.c, docs/RENDER_PASSES.md).
+ *
+ * Runtime-only: generated and overlay code never sees this header. It is kept
+ * out of psx_cycles.h on purpose, because psx_cycles.h is part of the
+ * overlay codegen hash (runtime/codegen_hash_sources.cmake) and any change to
+ * it invalidates every title's overlay cache and savestates. The freeze is
+ * enforced in psx_cycles.c's out-of-line service paths, which generated code
+ * already reaches through psx_cycles.h.
+ *
+ * While g_psx_render_pass_active is set, guest cycles are still counted (GTE
+ * and mult/div deadlines keep working) but devices are never serviced, no
+ * VBlank or device event fires and no interrupt is delivered; end() puts
+ * every clock value back exactly, so the pass consumed no guest time. A pass
+ * that runs longer than `watchdog_cycles` calls `overrun` (which must not
+ * return into the guest; render_pass.c longjmps out of the pass). */
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+extern int g_psx_render_pass_active;
+typedef struct PsxCycleFreeze {
+ uint64_t cycle_count;
+ uint64_t next_service;
+ uint64_t fast_limit;
+ uint32_t batch;
+ uint32_t batch_limit;
+ uint32_t *local_acc;
+ uint32_t local_acc_value;
+ int in_device_service;
+ /* Generated functions bump g_psx_cyc_bb_defer on entry and drop it in a
+ * cleanup handler, which a longjmp out of the pass (the watchdog) skips:
+ * end() puts the interrupted code's depth back. */
+ int bb_defer;
+} PsxCycleFreeze;
+int psx_cycle_freeze_begin(PsxCycleFreeze *save, uint64_t watchdog_cycles,
+ void (*overrun)(void));
+void psx_cycle_freeze_end(const PsxCycleFreeze *save);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/runtime/include/render_pass.h b/runtime/include/render_pass.h
new file mode 100644
index 00000000..d1b9a84c
--- /dev/null
+++ b/runtime/include/render_pass.h
@@ -0,0 +1,61 @@
+#ifndef PSX_RENDER_PASS_H
+#define PSX_RENDER_PASS_H
+
+/* Host-timed render passes: runtime side of psx_mod_render_pass_plan() and
+ * psx_mod_render_pass() (mod_plugins.h). See docs/RENDER_PASSES.md. */
+
+#include
+
+#include "psx_cycle_freeze.h" /* g_psx_render_pass_active, the time freeze */
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Stores a pass may not make (memory.c render_pass_store): they would escape
+ * the restore, so they are dropped and counted per device class. */
+enum {
+ RENDER_PASS_DROP_SPU = 0,
+ RENDER_PASS_DROP_CD,
+ RENDER_PASS_DROP_TIMER,
+ RENDER_PASS_DROP_DMA, /* DMA channels other than GPU (2) and OTC (6) */
+ RENDER_PASS_DROP_GPU, /* GP1 commands other than DMA mode / info */
+ RENDER_PASS_DROP_OTHER, /* SIO, MDEC, memory control, mod arenas, ... */
+ RENDER_PASS_DROP_CLASSES
+};
+extern uint64_t g_render_pass_dropped_writes[RENDER_PASS_DROP_CLASSES];
+
+typedef struct RenderPassStats {
+ uint64_t plans; /* psx_mod_render_pass_plan calls that planned >= 1 */
+ uint64_t planned; /* phases planned (after shedding) */
+ uint64_t wanted; /* phases wanted before shedding */
+ uint64_t refused; /* plan calls that returned 0 while enabled */
+ uint64_t passes; /* passes run to completion and presented */
+ uint64_t aborted; /* passes rolled back by a fault */
+ uint64_t discarded; /* passes whose plugin declined the image */
+ uint64_t watchdog; /* of which: guest-cycle watchdog overruns */
+ uint64_t vram_leaks; /* GPU writes outside the declared rect (dropped) */
+ uint64_t nesting_repairs; /* aborts whose skipped exits the restore undid */
+ uint64_t verify_checks; /* PSX_RENDER_PASS_VERIFY comparisons */
+ uint64_t verify_mismatch; /* ... that found a difference */
+ uint64_t dropped[RENDER_PASS_DROP_CLASSES];
+ double last_pass_ms; /* host time of the last pass (sandbox + draw) */
+ double avg_pass_ms; /* smoothed */
+ double avg_begin_ms; /* smoothed: VRAM backup + checkpoint */
+ double avg_guest_ms; /* smoothed: the plugin's guest draw code */
+ double avg_end_ms; /* smoothed: GPU flush, capture, VRAM restore */
+ double avg_restore_ms; /* smoothed: machine state restore */
+ uint64_t guest_cycles_last; /* guest cycles the last pass executed */
+ int disabled; /* sticky: passes disabled after repeated faults */
+ int watchdog_flag; /* internal: the current pass overran */
+} RenderPassStats;
+
+void render_pass_get_stats(RenderPassStats *out);
+/* Clears counters and the sticky fault latch (new mod session). */
+void render_pass_reset_session(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h
new file mode 100644
index 00000000..294efee6
--- /dev/null
+++ b/runtime/include/render_pass_plan.h
@@ -0,0 +1,185 @@
+#ifndef PSX_RENDER_PASS_PLAN_H
+#define PSX_RENDER_PASS_PLAN_H
+
+/* Pure planning and selection math for host-timed render passes
+ * (render_pass.c, gpu_gl_renderer.c). No runtime state; unit-tested by
+ * runtime/tests/test_render_pass_plan.c.
+ *
+ * Vocabulary. A game frame F is first presented at host tick `frame_start`
+ * and stays on screen for `frame_length` host ticks (its flip period in guest
+ * VBlanks times the presenter's source period). Phase p in [0, 1) is the
+ * position inside that window. Phase 0 is the game's own image of F; a pass
+ * rendered at phase a shows the game state a of the way from F to the next
+ * frame. Phases are carried as Q16 (65536 = 1). */
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define RENDER_PASS_MAX_PHASES 16u
+
+typedef struct RenderPassPlanInput {
+ double next_deadline; /* the presenter's next output deadline (ticks) */
+ double target_period; /* ticks per output frame; <= 0 = unknown */
+ double frame_start; /* predicted first-present tick of the frame */
+ double frame_length; /* ticks the frame stays on screen */
+ double pass_cost; /* smoothed host ticks per pass; 0 = unknown:
+ with a budget, one pass is planned until one
+ has been measured */
+ double budget; /* host ticks the passes may use; < 0 = unlimited */
+ uint32_t max; /* caller's array capacity */
+} RenderPassPlanInput;
+
+/* Fill alpha_q16[] with the ascending phases the presenter will show during
+ * the frame (the output deadlines that fall strictly inside it, excluding
+ * ones within 1/64 of phase 0, which the game's own image already covers).
+ * When the budget cannot pay for all of them, an evenly spread subset is
+ * returned; while the pass cost is unknown that subset is one phase, since
+ * passes run on the emulation thread. *wanted (optional) receives the
+ * unshed count. Returns the count (0 when nothing is wanted or affordable). */
+uint32_t render_pass_plan_phases(const RenderPassPlanInput *in,
+ uint32_t *alpha_q16, uint32_t *wanted);
+
+/* Pick what to show at phase p (0..1+) from a frame's rendered items.
+ * phases[0..n) are ascending Q16 phases, phases[0] == 0 being the game's
+ * image. Sets *lo and *hi (item indices) and *t (blend weight of hi, 0..1):
+ * inside [phases[i], phases[i+1]] the two neighbours are blended; past the
+ * last item it is held (nothing newer exists yet). Returns 0 when n == 0. */
+int render_pass_select(const uint32_t *phases, uint32_t n, double p,
+ uint32_t *lo, uint32_t *hi, float *t);
+
+/* How long a promoted generation stays on screen without the next flip, in
+ * frame lengths (phase units), before it expires. */
+#define RENDER_PASS_GEN_HOLD_MAX 4.0
+
+/* What a promoted generation shows at phase p: render_pass_select, except
+ * that it expires (returns 0) past RENDER_PASS_GEN_HOLD_MAX or for a p that
+ * is not a number. Phase 1 and beyond means the next flip is late (the game
+ * frame lasts longer than the plan assumed, e.g. a lagging tick): the newest
+ * image holds, as a late stock frame would, and the presenter never falls
+ * back to its own capture of the frame, which is older (phase 0). Only a
+ * game that stops flipping for several frame lengths gets that back. */
+int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p,
+ uint32_t *lo, uint32_t *hi, float *t);
+
+/* Exponential moving average used for the per-pass host cost. */
+double render_pass_ema(double current, double sample);
+
+/* Smoothed host cost of one pass at one presented image size (the renderer
+ * starts a new one when the size changes). Fed one sample per pass.
+ *
+ * - `allocated`: the pass created textures or framebuffers (first use, a
+ * size change). That one-time cost says nothing about the next pass -- at
+ * a high internal resolution the first passes cost several times the
+ * steady state -- and a plan that sheds every pass for time never
+ * measures again, so such a sample is left out, at most
+ * RENDER_PASS_ALLOC_SKIPS times in a row (any kept sample resets that).
+ * - Warm-up: the first RENDER_PASS_COST_WARMUP kept samples seed the average
+ * with their median, so one slow pass (a busy host at the first race
+ * frame) cannot price passes out. Until then the estimate is 0 (unknown),
+ * for which a plan asks for one pass (render_pass_plan_phases).
+ * - Then an exponential moving average (render_pass_ema).
+ * - Re-measuring (render_pass_cost_note_plan): only passes that run are
+ * measured, so an estimate that prices every plan out is never corrected
+ * by one -- and the first passes of a race can all run in a transient
+ * (a busy host, code seen for the first time) that costs several times the
+ * steady state. An estimate no pass has been measured against for
+ * `rewarm_after` plans (RENDER_PASS_REWARM_MIN, about a second of 30 Hz
+ * frames) restarts the warm-up. When the new median is more than
+ * RENDER_PASS_REWARM_STALE of the old estimate, the old one was right -- a
+ * size that is truly too expensive, or a machine at its limit -- and the
+ * next wait doubles, up to RENDER_PASS_REWARM_MAX; a stale estimate found
+ * keeps the wait at the minimum. */
+#define RENDER_PASS_ALLOC_SKIPS 8u
+#define RENDER_PASS_COST_WARMUP 3u
+#define RENDER_PASS_REWARM_MIN 30u
+#define RENDER_PASS_REWARM_MAX 960u
+#define RENDER_PASS_REWARM_STALE 0.75
+typedef struct RenderPassCost {
+ double ema;
+ double warm[RENDER_PASS_COST_WARMUP];
+ unsigned kept; /* kept samples, saturating at WARMUP */
+ unsigned skips; /* allocating samples left out in a row */
+ unsigned unsampled; /* plans since the last kept sample */
+ unsigned rewarm_after; /* 0 = RENDER_PASS_REWARM_MIN */
+ double rewarm_from; /* estimate being re-measured; 0 = none */
+} RenderPassCost;
+void render_pass_cost_add(RenderPassCost *cost, double sample, int allocated);
+/* Host ticks per pass for planning; 0 while the cost is unknown. */
+double render_pass_cost_estimate(const RenderPassCost *cost);
+/* Call once per plan that wanted passes at this cost's image size. Returns 1
+ * when the plan found the estimate stale and restarted the warm-up. */
+int render_pass_cost_note_plan(RenderPassCost *cost);
+
+/* Next frame's pass budget from the last frame: the host time the presenter
+ * spent idle-waiting plus the time passes used, scaled by `share` (0..1) and
+ * clamped to [0, frame_length]. No history (both zero) -> share of the frame. */
+double render_pass_budget(double idle_ticks, double pass_ticks,
+ double frame_length, double share);
+
+/* Store policy inside a pass (memory.c): -1 = the MMIO store may reach its
+ * device (GP0; GP1 DMA mode 0x04 / info 0x10; GPU and OTC DMA channels;
+ * DPCR/DICR; I_STAT/I_MASK -- all restored after the pass), otherwise the
+ * RENDER_PASS_DROP_* class it is dropped and counted under (render_pass.h). */
+int render_pass_mmio_class(uint32_t phys, uint32_t val, uint32_t width);
+
+/* One guest store made inside a pass (memory.c render_pass_store): RAM and
+ * scratchpad are written directly, bypassing every live-timeline observer;
+ * MMIO reaches `mmio_write` only when render_pass_mmio_class allows it; KSEG2
+ * (cache control), mod memory, expansion and ROM are dropped. Returns -1 when
+ * the store was made (or absorbed: an isolated-cache store changes nothing a
+ * pass restores), else the RENDER_PASS_DROP_* class it was dropped under,
+ * which the caller counts. */
+typedef struct RenderPassStoreTarget {
+ uint8_t *ram; /* main RAM backing store */
+ uint32_t ram_size; /* LIVE geometry, a power of two: 2 MiB retail
+ (mirrored 4x in the 8 MiB window) or 8 MiB
+ (psx_ram_live_bytes()) */
+ uint8_t *scratchpad;
+ uint32_t scratchpad_size;
+ int isolate_cache; /* COP0 SR IsC is set */
+ void (*mmio_write)(uint32_t phys, uint32_t val, uint32_t width);
+} RenderPassStoreTarget;
+int render_pass_store_to(const RenderPassStoreTarget *t, uint32_t addr,
+ uint32_t val, uint32_t width);
+
+/* VRAM journal for a pass's writes outside its display rect
+ * (gpu_gl_renderer.c pass_refuse_write): the CPU-side policy and the CPU VRAM
+ * rows. The renderer keeps the matching GPU copies per entry. */
+#define RENDER_PASS_JOURNAL_MAX 16
+enum {
+ RENDER_PASS_VRAM_ALLOW = 0, /* inside the rect, empty, or already journaled */
+ RENDER_PASS_VRAM_JOURNAL = 1, /* outside: back up, then allow */
+ RENDER_PASS_VRAM_REFUSE = 2 /* outside and cannot be journaled */
+};
+typedef struct RenderPassJournal {
+ int n;
+ int x[RENDER_PASS_JOURNAL_MAX], y[RENDER_PASS_JOURNAL_MAX];
+ int w[RENDER_PASS_JOURNAL_MAX], h[RENDER_PASS_JOURNAL_MAX];
+ uint16_t *rows[RENDER_PASS_JOURNAL_MAX];
+ size_t cap[RENDER_PASS_JOURNAL_MAX];
+} RenderPassJournal;
+/* Clip the write (*x, *y, *w, *h) to vram_w x vram_h and decide it against
+ * the pass rect (px, py, pw, ph) and the journal. can_journal = 0 refuses
+ * every outside write. */
+int render_pass_vram_policy(const RenderPassJournal *j, int px, int py,
+ int pw, int ph, int vram_w, int vram_h,
+ int can_journal, int *x, int *y, int *w, int *h);
+/* Record the CPU rows of rect (x, y, w, h) of vram (vram_w halfwords per
+ * row) as the next entry. Returns its index, or -1 when full / out of
+ * memory (nothing recorded). */
+int render_pass_journal_add(RenderPassJournal *j, const uint16_t *vram,
+ int vram_w, int x, int y, int w, int h);
+/* Put every entry's rows back, newest first, and empty the journal. */
+void render_pass_journal_rollback(RenderPassJournal *j, uint16_t *vram,
+ int vram_w);
+void render_pass_journal_free(RenderPassJournal *j);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake
index 2410aabd..a2c23366 100644
--- a/runtime/runtime.cmake
+++ b/runtime/runtime.cmake
@@ -350,6 +350,8 @@ set(PSXRECOMP_RUNTIME_SOURCES
${PSXRECOMP_ROOT}/runtime/src/psx_video_timing.c
${PSXRECOMP_ROOT}/runtime/src/frame_pacing.c
${PSXRECOMP_ROOT}/runtime/src/frame_interpolation.c
+ ${PSXRECOMP_ROOT}/runtime/src/render_pass.c
+ ${PSXRECOMP_ROOT}/runtime/src/render_pass_plan.c
${PSXRECOMP_ROOT}/runtime/src/host_time.c
${PSXRECOMP_ROOT}/runtime/src/psx_fiber.c
${PSXRECOMP_ROOT}/runtime/src/sio.c
diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c
index 3b9def17..2515296a 100644
--- a/runtime/src/debug_server.c
+++ b/runtime/src/debug_server.c
@@ -47,6 +47,8 @@
#include "card_data_writes.h"
#include "crash_trace.h"
#include "gpu_gl_renderer.h"
+#include "render_pass.h"
+#include "mod_plugins.h" /* psx_mod_render_pass_status */
#include "lockstep.h"
#include "guest_tty.h"
#include "frame_fingerprint.h"
@@ -4988,8 +4990,27 @@ static void handle_frame(int id, const char *json)
/* Longest entry the format below can produce: every hash at 16 hex digits and
* every counter at 20 decimal digits comes to 284 bytes with the separator. */
#define FP_JSON_ENTRY_MAX 320
+/* frame_fingerprint reset_on_load=1: restart the rolling hashes and the ring
+ * when the next savestate load completes, so two runs that load the same state
+ * (for example with and without a presentation mod) compare frame by frame
+ * from that guest point on, independent of what happened before the load. */
+static int s_fp_reset_on_load = 0;
+void debug_server_note_savestate_loaded(void)
+{
+ if (!s_fp_reset_on_load) return;
+ s_fp_reset_on_load = 0;
+ s_fp = (PsxFrameFingerprint)PSX_FRAME_FINGERPRINT_INIT;
+ s_fp_head = 0;
+ s_fp_total = 0;
+}
+
static void handle_frame_fingerprint(int id, const char *json)
{
+ if (json_get_int(json, "reset_on_load", 0)) {
+ s_fp_reset_on_load = 1;
+ send_fmt("{\"id\":%d,\"ok\":true,\"armed\":1}", id);
+ return;
+ }
int count = json_get_int(json, "count", 1024);
if (count < 1) count = 1;
if (count > FP_RING_CAP) count = FP_RING_CAP;
@@ -7915,6 +7936,87 @@ static void handle_gl_interp(int id, const char *json)
(unsigned long long)duplicates);
}
+/* render_pass_stats: host-timed render passes (docs/RENDER_PASSES.md).
+ * Counters are per mod session; `dropped` counts device stores a pass tried
+ * to make (SPU key-ons, CD, timers, ...), which never reach the device. */
+static void handle_render_pass_stats(int id, const char *json)
+{
+ (void)json;
+ RenderPassStats st;
+ uint64_t gd[10], image_bytes = 0;
+ uint32_t image_textures;
+ render_pass_get_stats(&st);
+ gl_renderer_pass_diag(gd);
+ image_textures = gl_renderer_pass_image_textures(&image_bytes);
+ send_fmt("{\"id\":%d,\"ok\":true,\"plans\":%llu,\"planned\":%llu,"
+ "\"wanted\":%llu,\"refused\":%llu,\"passes\":%llu,"
+ "\"aborted\":%llu,\"discarded\":%llu,\"watchdog\":%llu,\"vram_leaks\":%llu,"
+ "\"nesting_repairs\":%llu,"
+ "\"verify_checks\":%llu,\"verify_mismatch\":%llu,"
+ "\"dropped\":{\"spu\":%llu,\"cd\":%llu,\"timer\":%llu,"
+ "\"dma\":%llu,\"gpu\":%llu,\"other\":%llu},"
+ "\"last_pass_ms\":%.3f,\"avg_pass_ms\":%.3f,"
+ "\"avg_begin_ms\":%.3f,\"avg_guest_ms\":%.3f,"
+ "\"avg_end_ms\":%.3f,\"avg_restore_ms\":%.3f,"
+ "\"guest_cycles_last\":%llu,\"disabled\":%d,"
+ "\"promotions\":%llu,\"pass_presents\":%llu,"
+ "\"blended_presents\":%llu,\"expired\":%llu,\"late_presents\":%llu,"
+ "\"unmatched_flips\":%llu,\"early_presents\":%llu,"
+ "\"cost_us\":%llu,\"cost_rewarms\":%llu,\"frame_images\":%llu,"
+ "\"journaled\":%llu,"
+ "\"image_textures\":%u,\"image_bytes\":%llu,\"status\":%u,"
+ "\"backups_reused\":%llu}",
+ id, (unsigned long long)st.plans, (unsigned long long)st.planned,
+ (unsigned long long)st.wanted, (unsigned long long)st.refused,
+ (unsigned long long)st.passes, (unsigned long long)st.aborted,
+ (unsigned long long)st.discarded, (unsigned long long)st.watchdog, (unsigned long long)st.vram_leaks,
+ (unsigned long long)st.nesting_repairs,
+ (unsigned long long)st.verify_checks,
+ (unsigned long long)st.verify_mismatch,
+ (unsigned long long)st.dropped[RENDER_PASS_DROP_SPU],
+ (unsigned long long)st.dropped[RENDER_PASS_DROP_CD],
+ (unsigned long long)st.dropped[RENDER_PASS_DROP_TIMER],
+ (unsigned long long)st.dropped[RENDER_PASS_DROP_DMA],
+ (unsigned long long)st.dropped[RENDER_PASS_DROP_GPU],
+ (unsigned long long)st.dropped[RENDER_PASS_DROP_OTHER],
+ st.last_pass_ms, st.avg_pass_ms,
+ st.avg_begin_ms, st.avg_guest_ms, st.avg_end_ms, st.avg_restore_ms,
+ (unsigned long long)st.guest_cycles_last, st.disabled,
+ (unsigned long long)gd[0], (unsigned long long)gd[1],
+ (unsigned long long)gd[2], (unsigned long long)gd[3],
+ (unsigned long long)gd[8],
+ (unsigned long long)gd[4], (unsigned long long)gd[5],
+ (unsigned long long)gd[6], (unsigned long long)gd[9],
+ (unsigned long long)gd[7],
+ (unsigned long long)gl_renderer_pass_journaled(),
+ (unsigned)image_textures, (unsigned long long)image_bytes,
+ (unsigned)psx_mod_render_pass_status(),
+ (unsigned long long)gl_renderer_pass_backups_reused());
+}
+
+/* render_pass_refuse on=<0|1>: make the OpenGL backend decline render passes
+ * (status BACKEND), as a renderer mode without them would; for testing a
+ * plugin's fallback. Same as PSX_RENDER_PASS_REFUSE=1 at start. */
+static void handle_render_pass_refuse(int id, const char *json)
+{
+ int on = json_get_int(json, "on", 1);
+ gl_renderer_pass_force_refuse(on);
+ send_fmt("{\"id\":%d,\"ok\":true,\"on\":%d,\"status\":%u}", id, on ? 1 : 0,
+ (unsigned)psx_mod_render_pass_status());
+}
+
+/* render_pass_dump path= count=: write the images (the game's own
+ * frame, then each render pass in phase order) of the next n frames that get
+ * render passes, as /g__a.png. */
+static void handle_render_pass_dump(int id, const char *json)
+{
+ char dir[400];
+ int count = json_get_int(json, "count", 1);
+ if (!json_get_str(json, "path", dir, sizeof dir)) { send_err(id, "missing path"); return; }
+ gl_renderer_pass_dump_arm(dir, count);
+ send_fmt("{\"id\":%d,\"ok\":true,\"count\":%d}", id, count);
+}
+
/* gl_wide_fast on=<0|1>: native-wide centre-blit fast path. 1 (default) = skip
* the redundant centre mirror and copy the canonical 4:3 frame into the wide
* surface centre at present (fast). 0 = re-rasterize the whole wide surface
@@ -9594,6 +9696,9 @@ void debug_server_trace_write_check(uint32_t phys, uint32_t old_val,
(void)phys; (void)old_val; (void)new_val; (void)width;
return;
#endif
+ /* Render passes are rolled back; keep them out of the live timeline's
+ * fingerprints and write traces (docs/RENDER_PASSES.md). */
+ if (g_psx_render_pass_active) return;
if (s_fmv_quiet) {
if (psx_fp_write_eligible(phys, psx_ram_live_bytes())) psx_fp_note_quiet(&s_fp);
return;
@@ -9622,6 +9727,7 @@ void debug_server_trace_write_check(uint32_t phys, uint32_t old_val,
/* MMIO write trace — called from memory.c mmio_write32/16/8. */
void debug_server_trace_mmio_write(uint32_t addr, uint32_t val, uint8_t width)
{
+ if (g_psx_render_pass_active) return; /* rolled back: not live */
#ifdef PSX_NO_DEBUG_TOOLS
(void)addr; (void)val; (void)width;
return;
@@ -13832,6 +13938,9 @@ static const CmdEntry s_commands[] = {
{ "frame_perf", handle_frame_perf },
{ "gl_ws_ablate", handle_gl_ws_ablate },
{ "gl_interp", handle_gl_interp },
+ { "render_pass_stats", handle_render_pass_stats },
+ { "render_pass_dump", handle_render_pass_dump },
+ { "render_pass_refuse", handle_render_pass_refuse },
{ "gl_wide_fast", handle_gl_wide_fast },
{ "synth_recurse", handle_synth_recurse },
{ "gl_fbo_peek", handle_gl_fbo_peek },
diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c
index 7e443c5f..ef6158b7 100644
--- a/runtime/src/dirty_ram_interp.c
+++ b/runtime/src/dirty_ram_interp.c
@@ -349,6 +349,22 @@ void dirty_ram_ld_delay_discard(void) {
s_ld_pend_val = 0;
}
+void dirty_ram_ld_delay_save(DirtyRamLoadDelay *out) {
+ if (!out) return;
+ out->rt = s_ld_pend_rt;
+ out->val = s_ld_pend_val;
+ out->age = s_ld_pend_age;
+ out->armed = s_ld_pend_armed;
+}
+
+void dirty_ram_ld_delay_restore(const DirtyRamLoadDelay *in) {
+ if (!in) return;
+ s_ld_pend_rt = in->rt;
+ s_ld_pend_val = in->val;
+ s_ld_pend_age = in->age;
+ s_ld_pend_armed = in->armed;
+}
+
void dirty_ram_irq_ambient_resync_after_restore(void) {
/* Re-anchor host-only IRQ pump ambient at the restored timeline so both
* peers take the first post-load dirty entry poll from the same phase. */
diff --git a/runtime/src/dma.c b/runtime/src/dma.c
index 85ea2e05..a75dc88b 100644
--- a/runtime/src/dma.c
+++ b/runtime/src/dma.c
@@ -23,6 +23,7 @@
#include "overlay_capture.h"
#include "psx_cycles.h"
#include "psx_memory.h"
+#include "psx_cycle_freeze.h"
#include "spu.h"
#include "audio_trace.h"
#include "event_ring.h"
@@ -437,7 +438,9 @@ static void cancel_async_transfer(int ch) {
static void schedule_delayed_complete(int ch, uint32_t total_words,
uint32_t cycles_per_word) {
- if (total_words == 0 || cycles_per_word == 0) {
+ /* Render passes run in frozen guest time: a delayed completion would
+ * never arrive, so finish now (the pass restores DMA state after). */
+ if (total_words == 0 || cycles_per_word == 0 || g_psx_render_pass_active) {
complete_transfer(ch);
return;
}
@@ -968,6 +971,13 @@ static void try_execute(int ch) {
if ((channels[2].chcr & 1u) != 0u &&
((channels[2].chcr >> 9) & 3u) == 2u) {
start_async_gpu_linked_list();
+ /* Render pass: devices never advance in frozen time, so walk
+ * the whole list now. The walk is the same event-driven code,
+ * only with an unlimited budget; completion flags and the IRQ
+ * latch it raises are rolled back with the pass. */
+ while (g_psx_render_pass_active && gpu_linked_list.active)
+ dma_gpu_ll_advance(&gpu_linked_list, UINT32_MAX,
+ &gpu_ll_ops, NULL);
} else {
schedule_delayed_complete(2, execute_ch2_gpu(),
DMA_GPU_CYCLES_PER_WORD);
@@ -1002,6 +1012,8 @@ static void try_execute(int ch) {
/* ---- Public interface ---- */
uint32_t dma_get_dicr(void) { return dicr_read_value(dicr); }
+/* A GPU linked-list walk is in flight (render passes refuse to start then). */
+int dma_gpu_linked_list_active(void) { return gpu_linked_list.active != 0; }
uint32_t dma_get_dpcr(void) { return dpcr; }
int dma_cdrom_transfer_active(void) {
return cdrom_async.active &&
diff --git a/runtime/src/frame_interpolation.c b/runtime/src/frame_interpolation.c
index 7fbe890d..0b4576ed 100644
--- a/runtime/src/frame_interpolation.c
+++ b/runtime/src/frame_interpolation.c
@@ -95,6 +95,28 @@ int frame_interpolation_schedule_next(FrameInterpolationSchedule *schedule,
return 1;
}
+int frame_interpolation_schedule_due(FrameInterpolationSchedule *schedule,
+ uint64_t now, double horizon,
+ uint64_t *deadline) {
+ double now_d = (double)now;
+ if (!schedule || schedule->target_period <= 0.0 ||
+ schedule->next_present_deadline <= 0.0)
+ return 0;
+ while (schedule->next_present_deadline + schedule->target_period <= now_d &&
+ schedule->next_present_deadline + schedule->target_period <= horizon)
+ schedule->next_present_deadline += schedule->target_period;
+ if (schedule->next_present_deadline > now_d ||
+ schedule->next_present_deadline > horizon)
+ return 0;
+ if (deadline) *deadline = (uint64_t)(schedule->next_present_deadline + 0.5);
+ return 1;
+}
+
+void frame_interpolation_schedule_consume(FrameInterpolationSchedule *schedule) {
+ if (schedule && schedule->target_period > 0.0)
+ schedule->next_present_deadline += schedule->target_period;
+}
+
uint64_t frame_interpolation_schedule_end(
const FrameInterpolationSchedule *schedule) {
if (!schedule || schedule->frame_end <= 0.0) return 0;
diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c
index 6438da3a..af9f4a90 100644
--- a/runtime/src/gpu.c
+++ b/runtime/src/gpu.c
@@ -6299,5 +6299,66 @@ int gpu_snapshot_read(const uint8_t *p, uint32_t len) {
ws_nw_sync_target();
return 1;
}
+/* ---- Render-pass GPU checkpoint (render_pass.c) ---------------------------
+ * The same register set as a savestate (gpu_snap_emit), restored WITHOUT
+ * gpu_snapshot_read's side effects (widescreen scene history and HUD anchor
+ * tags belong to the live frame, which a pass must not reset). After the
+ * restore the renderer's mirrored draw state (area, offset, texture window,
+ * mask bits, native-wide target) is re-synced exactly as GP0(E2..E6) would. */
+static uint8_t s_pass_regs[512];
+static uint32_t s_pass_regs_len;
+static uint32_t s_pass_poll_count;
+static int32_t s_pass_doff_min, s_pass_doff_max;
+static uint32_t s_pass_doff_cnt;
+static GpuVerticalSplitTrace s_pass_split_this;
+
+int gpu_pass_checkpoint_save(void) {
+ PstW w;
+ uint32_t n = gpu_snapshot_bytes();
+ if (n > sizeof s_pass_regs) return 0;
+ pst_w_init(&w, s_pass_regs, n);
+ if (!gpu_snap_emit(&w)) return 0;
+ s_pass_regs_len = n;
+ s_pass_poll_count = gpustat_poll_count;
+ s_pass_doff_min = g_doff_min_this;
+ s_pass_doff_max = g_doff_max_this;
+ s_pass_doff_cnt = g_doff_cnt_this;
+ s_pass_split_this = split_trace_this;
+ return 1;
+}
+
+void gpu_pass_checkpoint_restore(void) {
+ PstR r;
+ if (!s_pass_regs_len) return;
+ pst_r_init(&r, s_pass_regs, s_pass_regs_len);
+ (void)gpu_snap_parse(&r);
+ gpustat_poll_count = s_pass_poll_count;
+ g_doff_min_this = s_pass_doff_min;
+ g_doff_max_this = s_pass_doff_max;
+ g_doff_cnt_this = s_pass_doff_cnt;
+ split_trace_this = s_pass_split_this;
+ gr_set_texture_window(texture_window_value);
+ gr_set_draw_area((int)draw_area_left, (int)draw_area_top,
+ (int)draw_area_right, (int)draw_area_bottom);
+ gr_set_draw_offset(draw_offset_x, draw_offset_y);
+ gr_set_mask_bits((int)set_mask_bit, (int)check_mask_bit);
+ ws_nw_sync_target();
+ s_pass_regs_len = 0;
+}
+
+/* FNV-1a over the checkpointed register set (PSX_RENDER_PASS_VERIFY). */
+uint64_t gpu_pass_state_hash(void) {
+ uint8_t buf[512];
+ PstW w;
+ uint64_t h = 1469598103934665603ULL;
+ uint32_t n = gpu_snapshot_bytes();
+ if (n > sizeof buf) return 0;
+ pst_w_init(&w, buf, n);
+ if (!gpu_snap_emit(&w)) return 0;
+ for (uint32_t i = 0; i < n; i++) h = (h ^ buf[i]) * 1099511628211ULL;
+ h = (h ^ gpustat_poll_count) * 1099511628211ULL;
+ return h;
+}
+
uint16_t* gpu_get_vram_ptr(void){ return vram; }
uint32_t gpu_get_vram_bytes(void){ return (uint32_t)sizeof(vram); }
diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c
index 4d393687..06300725 100644
--- a/runtime/src/gpu_gl_renderer.c
+++ b/runtime/src/gpu_gl_renderer.c
@@ -69,6 +69,8 @@
#include "gpu_gl_renderer.h"
#include "mod_texture_banks.h"
#include "frame_interpolation.h"
+#include "render_pass_plan.h"
+#include "mod_plugins.h" /* PSX_MOD_RENDER_PASS_* reasons */
#include "host_osd.h"
#include "psx_savestate_menu.h"
#include "host_time.h"
@@ -364,10 +366,56 @@ static FrameFlipTracker s_interp_flip;
static int s_interp_origin_x = -1, s_interp_origin_y = -1;
static double s_interp_phase_lo = 0.0, s_interp_phase_hi = 1.0;
static uint64_t s_interp_duplicates = 0;
+/* PSX_MOD_FRAME_INTERPOLATION_HOLD: no crossfade, repeat the newest frame
+ * wherever render passes supply no in-between image. */
+static int s_interp_hold = 0;
+static void pass_gens_invalidate(void);
+static void pass_note_new_frame(int origin_x, int origin_y, int source_path,
+ int pw, int ph);
+static void pass_apply_promotion(void);
+static int pass_gen_present(uint64_t deadline);
+static uint64_t s_idle_ticks_accum_fwd(uint64_t add);
+/* Render-pass VRAM transaction (see "Render passes" below). While a pass is
+ * open, GPU writes are confined to its rect: the scissor is intersected with
+ * it and writes that bypass the scissor are refused and counted. */
+static int s_pass_active = 0;
+/* Textures/framebuffers made for passes (pass_make_color_fbo, pass image
+ * slots), and the count when the current pass began: a pass that allocated
+ * is left out of the pass-cost average (render_pass_cost_add). */
+static uint32_t s_pass_allocs = 0, s_pass_allocs_begin = 0;
+static int s_pass_x = 0, s_pass_y = 0, s_pass_w = 0, s_pass_h = 0;
+static uint32_t s_pass_leaks = 0;
+/* Policy and CPU rows of the out-of-rect journal (render_pass_plan.c,
+ * render_pass_sandbox_test); the GPU copies live in s_pj below. */
+static RenderPassJournal s_pj_cpu;
+static int pass_journal_protect(int x, int y, int w, int h);
+/* A fill, copy or upload outside the pass rect (e.g. a game that moves a
+ * few pixels of VRAM as part of every frame) is journaled -- its destination
+ * backed up and restored at the end of the pass -- or, when the journal is
+ * full, refused and counted (the pass is then rolled back without an image).
+ * Native-wide needs nothing more: fills, copies, uploads and pokes write the
+ * hr surface, the raw mirror and the CPU rows only, never a wide surface
+ * (the wide margins come from mirrored draws, which the pass scissor keeps
+ * inside the pass band). */
+static int pass_refuse_write(const char *what, int x, int y, int w, int h) {
+ int policy;
+ if (!s_pass_active) return 0;
+ policy = render_pass_vram_policy(&s_pj_cpu, s_pass_x, s_pass_y, s_pass_w,
+ s_pass_h, VRAM_W, VRAM_H, 1,
+ &x, &y, &w, &h);
+ if (policy == RENDER_PASS_VRAM_ALLOW) return 0;
+ if (policy == RENDER_PASS_VRAM_JOURNAL && pass_journal_protect(x, y, w, h))
+ return 0;
+ if (s_pass_leaks < 8)
+ fprintf(stderr, "psxrecomp: render pass refused %s %d,%d %dx%d outside "
+ "%d,%d %dx%d\n", what, x, y, w, h, s_pass_x, s_pass_y,
+ s_pass_w, s_pass_h);
+ s_pass_leaks++;
+ return 1;
+}
static void interp_reset_history(void);
static int interp_present(float alpha);
static void interp_present_source_interval(void);
-static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh);
static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh);
static int s_raster_ok = 0; /* full GPU pipeline available */
@@ -1348,9 +1396,16 @@ static void hr_begin(int clip_to_draw_area) {
glViewport(0, 0, VRAM_W * s_scale, VRAM_H * s_scale);
glEnable(GL_SCISSOR_TEST);
if (clip_to_draw_area) {
- int sw = s_area_x2 - s_area_x1 + 1, sh = s_area_y2 - s_area_y1 + 1;
+ int ax1 = s_area_x1, ay1 = s_area_y1, ax2 = s_area_x2, ay2 = s_area_y2;
+ if (s_pass_active) {
+ if (ax1 < s_pass_x) ax1 = s_pass_x;
+ if (ay1 < s_pass_y) ay1 = s_pass_y;
+ if (ax2 > s_pass_x + s_pass_w - 1) ax2 = s_pass_x + s_pass_w - 1;
+ if (ay2 > s_pass_y + s_pass_h - 1) ay2 = s_pass_y + s_pass_h - 1;
+ }
+ int sw = ax2 - ax1 + 1, sh = ay2 - ay1 + 1;
if (sw < 0) sw = 0; if (sh < 0) sh = 0;
- glScissor(s_area_x1 * s_scale, s_area_y1 * s_scale,
+ glScissor(ax1 * s_scale, ay1 * s_scale,
sw * s_scale, sh * s_scale);
}
}
@@ -1652,7 +1707,12 @@ static void wide_target_begin(int dx, GLint uXoff, GLint uXhalf) {
glViewport(0, 0, g_wide_w * s_scale, VRAM_H * s_scale);
glEnable(GL_SCISSOR_TEST);
{
- int sy = s_area_y1, sh = s_area_y2 - s_area_y1 + 1;
+ int y1 = s_area_y1, y2 = s_area_y2;
+ if (s_pass_active) { /* render pass: stay inside the backed-up band */
+ if (y1 < s_pass_y) y1 = s_pass_y;
+ if (y2 > s_pass_y + s_pass_h - 1) y2 = s_pass_y + s_pass_h - 1;
+ }
+ int sy = y1, sh = y2 - y1 + 1;
if (sy < 0) { sh += sy; sy = 0; }
if (sy + sh > VRAM_H) sh = VRAM_H - sy;
if (sh < 0) sh = 0;
@@ -2472,7 +2532,8 @@ static void glb_set_perspective_triangle(int enabled, float q0, float q1, float
s_pq[0] = q0; s_pq[1] = q1; s_pq[2] = q2;
sw_set_perspective_triangle(enabled, q0, q1, q2);
}
-static void glb_set_draw_area(int x1,int y1,int x2,int y2) { flush_flat_batch(); flush_tex_batch(); s_area_x1=x1; s_area_y1=y1; s_area_x2=x2; s_area_y2=y2; sw_set_draw_area(x1,y1,x2,y2); }
+static void glb_set_draw_area(int x1,int y1,int x2,int y2) {
+ flush_flat_batch(); flush_tex_batch(); s_area_x1=x1; s_area_y1=y1; s_area_x2=x2; s_area_y2=y2; sw_set_draw_area(x1,y1,x2,y2); }
static void glb_get_draw_area(int *x1,int *y1,int *x2,int *y2) { sw_get_draw_area(x1,y1,x2,y2); }
static void glb_set_draw_offset(int x,int y) { flush_flat_batch(); flush_tex_batch(); s_off_x=x; s_off_y=y; sw_set_draw_offset(x,y); }
@@ -2499,12 +2560,14 @@ static void glb_draw_gouraud_triangle(int x0,int y0,uint16_t c0,int x1,int y1,ui
precise_consumed();
}
static void glb_fill_rect(int x,int y,int w,int h,uint16_t c){
+ if (pass_refuse_write("fill", x, y, w, h)) return;
if (s_cpu_auth_dual || !s_raster_ok)
sw_fill_rect(x,y,w,h,c);
if (!s_raster_ok) return;
gpu_fill(x,y,w,h,c);
}
static void glb_copy_rect(int sx,int sy,int dx,int dy,int w,int h){
+ if (pass_refuse_write("copy", dx, dy, w, h)) return;
if (s_cpu_auth_dual || !s_raster_ok)
sw_copy_rect(sx,sy,dx,dy,w,h);
if (!s_raster_ok) return;
@@ -2686,6 +2749,7 @@ static void depth24_upload_policy(void) {
}
static void glb_vram_write(int x,int y,uint16_t px){
+ if (pass_refuse_write("poke", x & (VRAM_W-1), y & (VRAM_H-1), 1, 1)) return;
sw_vram_write(x,y,px);
depth24_upload_policy();
/* Point pokes are never MDEC frames — always stage to FBO. */
@@ -2693,6 +2757,7 @@ static void glb_vram_write(int x,int y,uint16_t px){
}
static uint16_t glb_vram_read(int x,int y){ ensure_cpu(); return sw_vram_read(x,y); }
static void glb_vram_transfer_in(int x,int y,int w,int h,const uint16_t *d){
+ if (pass_refuse_write("upload", x, y, w, h)) return;
sw_vram_transfer_in(x,y,w,h,d);
depth24_upload_policy();
if (s_depth24_skip_up && depth24_is_fb_transfer(x, y, w, h)) {
@@ -3211,7 +3276,10 @@ void gl_renderer_set_swap_interval(int interval) {
}
}
+static void pass_resources_release(void);
+
void gl_renderer_shutdown(void) {
+ pass_resources_release();
if (s_ctx) {
for (unsigned i = 1; i < 65536u; ++i)
if (s_bank_tex[i]) glDeleteTextures(1, &s_bank_tex[i]);
@@ -4038,6 +4106,7 @@ static void interp_reset_history_unlocked(void) {
s_interp_origin_x = s_interp_origin_y = -1;
s_interp_phase_lo = 0.0;
s_interp_phase_hi = 1.0;
+ pass_gens_invalidate();
}
static void interp_reset_history(void) {
@@ -4060,17 +4129,25 @@ void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz
s_interp_target_hz = active ? effective_hz : 0.0;
s_interp_source_hz = active ? source_hz : 0.0;
s_interp_blend_mode = blend_mode == 1 ? 1 : 0;
+ s_interp_hold = blend_mode == 2 ? 1 : 0;
if (active)
fprintf(stdout, "psxrecomp: GL temporal frame blending enabled: %.1f "
"presents/s from %.3f guest frames/s on the render thread "
- "(%s blend; no motion vectors)\n",
+ "(%s; no motion vectors)\n",
effective_hz, source_hz,
- s_interp_blend_mode ? "change-adaptive" : "linear");
+ s_interp_hold ? "hold, in-between frames from render passes"
+ : s_interp_blend_mode ? "change-adaptive blend"
+ : "linear blend");
else
fprintf(stdout, "psxrecomp: GL temporal frame blending disabled "
"(host %.1f Hz)\n", host_hz);
}
+void gl_renderer_set_interpolation_blend(int blend_mode) {
+ s_interp_blend_mode = blend_mode == 1 ? 1 : 0;
+ s_interp_hold = blend_mode == 2 ? 1 : 0;
+}
+
void gl_renderer_set_interpolation_suspended(int suspended) {
suspended = suspended ? 1 : 0;
if (suspended != s_interp_suspended) interp_reset_history_unlocked();
@@ -4135,6 +4212,7 @@ static int interp_capture(GLuint fbo, int x, int y, int w, int h,
s_interp_duplicates++;
return 1;
}
+ pass_note_new_frame(origin_x, origin_y, source_path, pw, ph);
} else {
s_interp_phase_lo = 0.0;
s_interp_phase_hi = 1.0;
@@ -4173,23 +4251,23 @@ static int interp_capture(GLuint fbo, int x, int y, int w, int h,
return 1;
}
-static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) {
- int prev = s_interp_prev, curr = s_interp_cur;
+static void interp_draw_textures(GLuint prev_tex, GLuint curr_tex, float alpha,
+ int blend_mode, int lx, int ly, int lw, int lh) {
p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0);
glViewport(lx, ly, lw, lh);
p_glActiveTexture(PSXGL_TEXTURE0);
- glBindTexture(GL_TEXTURE_2D, s_interp_tex[prev]);
+ glBindTexture(GL_TEXTURE_2D, prev_tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST);
p_glActiveTexture(PSXGL_TEXTURE0 + 1);
- glBindTexture(GL_TEXTURE_2D, s_interp_tex[curr]);
+ glBindTexture(GL_TEXTURE_2D, curr_tex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, s_interp_linear ? GL_LINEAR : GL_NEAREST);
p_glUseProgram(s_interp_prog);
p_glUniform1i(s_interp_uPrev, 0);
p_glUniform1i(s_interp_uCurr, 1);
p_glUniform1f(s_interp_uAlpha, alpha);
- p_glUniform1i(s_interp_uBlendMode, s_interp_blend_mode);
+ p_glUniform1i(s_interp_uBlendMode, blend_mode);
present_set_gamma(s_interp_uGamma, 1);
p_glUniform4f(s_interp_uUvRect, 0.f, 0.f, 1.f, 1.f);
/* Interp textures hold exactly the display rect (uv_rect is 0..1), so pitch
@@ -4202,9 +4280,11 @@ static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) {
p_glActiveTexture(PSXGL_TEXTURE0);
}
-static int interp_present(float alpha) {
+static uint64_t s_present_ticks_accum_fwd(uint64_t add);
+static int interp_present_pair(GLuint a, GLuint b, float t, int blend_mode) {
if (!s_ctx || !s_interp_enabled || s_interp_suspended || s_interp_valid < 1)
return 0;
+ uint64_t present_t0 = SDL_GetPerformanceCounter();
int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh);
int lx, ly, lw, lh;
if (s_interp_force_4_3)
@@ -4218,20 +4298,33 @@ static int interp_present(float alpha) {
glClear(GL_COLOR_BUFFER_BIT);
}
present_bezel(ww, wh, lx, ly, lw, lh);
- interp_draw_quad(alpha, lx, ly, lw, lh);
+ interp_draw_textures(a, b, t, blend_mode, lx, ly, lw, lh);
pres_record(GL_PRES_INTERP, 0, 0, s_interp_w, s_interp_h,
lx, ly, lw, lh);
gl_swap_with_osd();
s_interp_swaps++;
+ (void)s_present_ticks_accum_fwd(SDL_GetPerformanceCounter() - present_t0);
return 1;
}
+static int interp_present(float alpha) {
+ if (s_interp_hold) alpha = 1.0f;
+ return interp_present_pair(s_interp_tex[s_interp_prev],
+ s_interp_tex[s_interp_cur], alpha,
+ s_interp_blend_mode);
+}
+
static void interp_wait_until(uint64_t deadline, uint64_t frequency) {
- uint64_t now;
+ uint64_t now, start;
if (!deadline || !frequency) return;
+ start = SDL_GetPerformanceCounter();
for (;;) {
now = SDL_GetPerformanceCounter();
- if (now >= deadline) return;
+ if (now >= deadline) {
+ /* Idle host time feeds the render-pass budget. */
+ (void)s_idle_ticks_accum_fwd(now - start);
+ return;
+ }
uint64_t remain = deadline - now;
uint32_t ms = (uint32_t)((remain * 1000u) / frequency);
if (ms > 1) psx_host_sleep_ms(ms - 1);
@@ -4250,13 +4343,15 @@ static void interp_present_source_interval(void) {
s_interp_source_hz, s_interp_target_hz,
s_interp_phase_lo, s_interp_phase_hi))
return;
+ pass_apply_promotion();
while (frame_interpolation_schedule_next(
&s_interp_schedule, SDL_GetPerformanceCounter(),
&deadline, &alpha)) {
interp_wait_until(deadline, frequency);
latency_ring_mark(LAT_SWAP_BEGIN);
- (void)interp_present(alpha);
+ if (!pass_gen_present(deadline))
+ (void)interp_present(alpha);
latency_ring_mark(LAT_SWAP_END);
}
interp_wait_until(frame_interpolation_schedule_end(&s_interp_schedule),
@@ -4281,6 +4376,738 @@ static void interp_present_source_interval(void) {
}
}
+/* ==== Render passes (render_pass.c, docs/RENDER_PASSES.md) ==================
+ *
+ * VRAM transaction. A pass draws into the guest's own display rect, which
+ * still holds the finished stock image the next flip shows. Begin backs that
+ * rect up on the GPU (hr color + mask stencil, the raw 16-bit mirror, the
+ * native-wide band) and in the CPU VRAM array, plus the renderer's coherency
+ * bookkeeping; end captures the drawn rect into a pass slot and puts all of it
+ * back. While a pass is open, GPU writes outside the rect are refused and
+ * counted (the pass is then rolled back without an image).
+ *
+ * Presentation. A generation holds one game frame's images: slot 0 is the
+ * game's own image (captured when the generation opens, before the first
+ * pass draws), then one slot per pass phase. It opens at the plugin's pass
+ * point and becomes current when the FLIP source sees the display flip to its
+ * rect; its phases then map onto host time from that interval's start. At
+ * each output deadline the presenter shows the newest image at or before the
+ * deadline's phase, crossfading to the next one when passes were shed. Two
+ * generations exist: the one on screen and the one being built for the next
+ * flip. */
+
+#define PASS_SLOTS (RENDER_PASS_MAX_PHASES + 1u)
+typedef struct PassGen {
+ int valid;
+ int promoted;
+ int x, y, w, h; /* guest VRAM rect */
+ int tex_w, tex_h; /* slot size (hr pixels; wide band if wide) */
+ int source_path; /* GL_PRES_VRAM or GL_PRES_WIDE */
+ uint32_t n; /* images: [0] = the game's own */
+ uint32_t phase[PASS_SLOTS]; /* Q16, ascending */
+ uint32_t period; /* guest VBlanks the frame stays on screen */
+ double t_start, t_len; /* host ticks, set on promotion */
+} PassGen;
+static PassGen s_pgen[2];
+/* Slot textures are made as slots fill: [0, s_pgen_alloc_n) exist, all at
+ * s_pgen_alloc_w x h. A generation never fills past pass_slot_cap, so two
+ * generations stay inside its budget whatever the internal scale. */
+static GLuint s_pgen_tex[2][PASS_SLOTS];
+static uint32_t s_pgen_alloc_n[2];
+static int s_pgen_alloc_w[2], s_pgen_alloc_h[2];
+static int s_pgen_cur = 0;
+static int s_pgen_promote = 0;
+static uint64_t s_pgen_promotions = 0, s_pgen_presents = 0, s_pgen_blends = 0;
+static uint64_t s_pgen_expired = 0, s_pgen_unmatched = 0, s_pgen_early = 0;
+static uint64_t s_pgen_late = 0; /* presents past the frame's planned end */
+
+static GLuint s_pb_hr_tex = 0, s_pb_hr_rb = 0, s_pb_hr_fbo = 0;
+static int s_pb_hr_w = 0, s_pb_hr_h = 0;
+static GLuint s_pb_raw_tex = 0, s_pb_raw_fbo = 0;
+static int s_pb_raw_w = 0, s_pb_raw_h = 0;
+static GLuint s_pb_wide_tex = 0, s_pb_wide_rb = 0, s_pb_wide_fbo = 0;
+static int s_pb_wide_w = 0, s_pb_wide_h = 0;
+static GLuint s_pb_wide_src = 0;
+/* The backup above still equals VRAM (the last pass restored it): rect and
+ * scale it was taken at. A later pass of the same frame reuses it. */
+static int s_pb_valid = 0;
+static int s_pb_x = 0, s_pb_y = 0, s_pb_w = 0, s_pb_h = 0, s_pb_scale = 0;
+static uint64_t s_pb_reused = 0;
+static uint16_t *s_pb_cpu = NULL;
+static size_t s_pb_cpu_cap = 0;
+static DirtyRect s_pb_cpu_dirty, s_pb_pack_dirty, s_pb_up_rects[UP_RECTS_MAX];
+static int s_pb_up_n = 0, s_pb_gpu_dirty = 0, s_pb_stencil_valid = 1;
+static uint64_t s_pb_present_dirty[PRES_ROWS];
+static int s_pb_force_present = 0, s_pb_last_path = -1;
+static int s_pb_last_dx = 0, s_pb_last_dy = 0, s_pb_last_dw = 0, s_pb_last_dh = 0;
+
+/* Per-pass host cost (render_pass_plan.h RenderPassCost), and the presented
+ * image size it was measured at. Another size (an aspect or internal-
+ * resolution change) starts it over: pass cost does not scale with pixels
+ * alone, and while it is unknown a plan asks for one pass. */
+static RenderPassCost s_pass_cost;
+static int s_pass_cost_w = 0, s_pass_cost_h = 0;
+static uint64_t s_pass_cost_rewarms = 0; /* stale estimates re-measured */
+static uint64_t s_pass_ticks_accum = 0, s_idle_ticks_accum = 0;
+static uint64_t s_pass_ticks_last = 0, s_idle_ticks_last = 0;
+static uint64_t s_present_ticks_accum = 0, s_present_ticks_last = 0;
+static double s_present_cost_ema = 0.0; /* host ticks per present */
+static uint32_t s_intervals_since_plan = 0;
+static int s_pass_budget_pct = -1;
+
+static int s_pass_verify = -1;
+static uint8_t *s_pv_hr = NULL, *s_pv_raw = NULL;
+static size_t s_pv_hr_cap = 0, s_pv_raw_cap = 0;
+static int s_pv_ok = 1;
+
+uint64_t gl_renderer_perf_ticks(void) { return SDL_GetPerformanceCounter(); }
+uint64_t gl_renderer_perf_frequency(void) { return SDL_GetPerformanceFrequency(); }
+
+static void pass_gens_invalidate(void) {
+ s_pgen[0].valid = s_pgen[1].valid = 0;
+ s_pgen[0].promoted = s_pgen[1].promoted = 0;
+ s_pgen_promote = 0;
+}
+
+static int s_pass_force_refuse = -1; /* -1: read PSX_RENDER_PASS_REFUSE */
+
+void gl_renderer_pass_force_refuse(int on) {
+ s_pass_force_refuse = on ? 1 : 0;
+}
+
+uint32_t gl_renderer_pass_unavailable(void) {
+ if (s_pass_force_refuse < 0) {
+ const char *e = getenv("PSX_RENDER_PASS_REFUSE");
+ s_pass_force_refuse = (e && e[0] && e[0] != '0') ? 1 : 0;
+ if (s_pass_force_refuse)
+ fprintf(stderr, "psxrecomp: render passes refused by the backend "
+ "(PSX_RENDER_PASS_REFUSE, debug)\n");
+ }
+ if (!s_ctx || !s_raster_ok || !s_interp_enabled || s_interp_suspended ||
+ s_interp_source != 1 || !(s_interp_source_hz > 0.0))
+ return PSX_MOD_RENDER_PASS_NO_PRESENTER;
+ /* Dual raster (netplay CPU-authoritative VRAM) or a debug refusal. */
+ if (s_cpu_auth_dual || s_pass_force_refuse)
+ return PSX_MOD_RENDER_PASS_BACKEND;
+ /* The history restarts when the display mode changes (e.g. title ->
+ * race); passes wait for its first frame, a VBlank or two. */
+ if (s_interp_valid <= 0)
+ return PSX_MOD_RENDER_PASS_BUSY;
+ return PSX_MOD_RENDER_PASS_READY;
+}
+
+int gl_renderer_pass_ready(void) {
+ return gl_renderer_pass_unavailable() == PSX_MOD_RENDER_PASS_READY;
+}
+
+/* Slots per generation that fit a 256 MiB budget for both generations. */
+static uint32_t pass_slot_cap(int tex_w, int tex_h) {
+ double bytes = (double)tex_w * (double)tex_h * 4.0 * 2.0;
+ uint32_t cap = bytes > 0.0 ? (uint32_t)((256.0 * 1024.0 * 1024.0) / bytes)
+ : PASS_SLOTS;
+ if (cap > PASS_SLOTS) cap = PASS_SLOTS;
+ return cap;
+}
+
+uint32_t gl_renderer_pass_plan(uint32_t period_vblanks,
+ uint32_t shown_after_vblanks,
+ uint32_t *alpha_q16, uint32_t max,
+ uint32_t *wanted) {
+ RenderPassPlanInput in;
+ double freq, sp;
+ uint32_t cap;
+ int live;
+
+ if (wanted) *wanted = 0;
+ /* One plan per game frame: close the previous frame's host-time books. */
+ s_idle_ticks_last = s_idle_ticks_accum;
+ s_pass_ticks_last = s_pass_ticks_accum;
+ s_present_ticks_last = s_present_ticks_accum;
+ s_idle_ticks_accum = s_pass_ticks_accum = s_present_ticks_accum = 0;
+ live = s_intervals_since_plan > 0; /* turbo/headless present nothing */
+ s_intervals_since_plan = 0;
+ if (!gl_renderer_pass_ready() || !live || !alpha_q16 || max == 0) return 0;
+ if (s_interp_schedule.target_period <= 0.0 ||
+ s_interp_schedule.source_deadline <= 0.0)
+ return 0;
+ if (s_pass_budget_pct < 0) {
+ const char *e = getenv("PSX_RENDER_PASS_BUDGET");
+ int v = e ? atoi(e) : 0;
+ s_pass_budget_pct = (v >= 5 && v <= 100) ? v : 80;
+ }
+ freq = (double)SDL_GetPerformanceFrequency();
+ sp = freq / s_interp_source_hz;
+ cap = pass_slot_cap(s_interp_w, s_interp_h);
+ if (cap < 2) return 0;
+ memset(&in, 0, sizeof in);
+ in.next_deadline = s_interp_schedule.next_present_deadline;
+ in.target_period = s_interp_schedule.target_period;
+ in.frame_start = s_interp_schedule.source_deadline +
+ (double)shown_after_vblanks * sp;
+ in.frame_length = (double)period_vblanks * sp;
+ in.pass_cost = (s_pass_cost_w == s_interp_w && s_pass_cost_h == s_interp_h)
+ ? render_pass_cost_estimate(&s_pass_cost) : 0.0;
+ {
+ /* Presents beyond two per frame are traded for passes: a shed
+ * present is coalesced, a shed pass is a missing motion sample. */
+ double spare = (double)s_idle_ticks_last + (double)s_present_ticks_last -
+ 2.0 * s_present_cost_ema;
+ if (spare < 0.0) spare = 0.0;
+ in.budget = render_pass_budget(spare, (double)s_pass_ticks_last,
+ in.frame_length,
+ (double)s_pass_budget_pct / 100.0);
+ }
+ in.max = max < cap - 1u ? max : cap - 1u;
+ {
+ uint32_t want = 0, n = render_pass_plan_phases(&in, alpha_q16, &want);
+ if (wanted) *wanted = want;
+ /* An estimate no pass has confirmed for a while is measured again
+ * (render_pass_cost_note_plan): its plans then ask for one pass. */
+ if (want && s_pass_cost_w == s_interp_w && s_pass_cost_h == s_interp_h &&
+ render_pass_cost_note_plan(&s_pass_cost)) {
+ s_pass_cost_rewarms++;
+ in.pass_cost = 0.0;
+ n = render_pass_plan_phases(&in, alpha_q16, NULL);
+ }
+ return n;
+ }
+}
+
+void gl_renderer_pass_note_cost(uint64_t ticks) {
+ if (s_pass_cost_w != s_interp_w || s_pass_cost_h != s_interp_h) {
+ memset(&s_pass_cost, 0, sizeof s_pass_cost);
+ s_pass_cost_w = s_interp_w;
+ s_pass_cost_h = s_interp_h;
+ }
+ /* A pass that made pass textures or framebuffers (first use, a size
+ * change) is not a cost sample: see render_pass_cost_add(). */
+ render_pass_cost_add(&s_pass_cost, (double)ticks,
+ s_pass_allocs != s_pass_allocs_begin);
+ s_pass_ticks_accum += ticks;
+}
+
+uint32_t gl_renderer_pass_leaks(void) { return s_pass_leaks; }
+int gl_renderer_pass_verify_vram(void) { return s_pv_ok; }
+
+
+static GLuint pass_wide_fbo_for(int base_x) {
+ if (g_wide_w <= 0) return 0;
+ for (int i = 0; i < WIDE_MAX_SURF; i++)
+ if (s_wide_fbo[i] && s_wide_base[i] == base_x) return s_wide_fbo[i];
+ return 0;
+}
+
+static int pass_make_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo,
+ int *cur_w, int *cur_h, int w, int h,
+ GLenum internal, GLenum fmt, GLenum type) {
+ if (*fbo && *cur_w == w && *cur_h == h) return 1;
+ if (*fbo) p_glDeleteFramebuffers(1, fbo);
+ if (*tex) glDeleteTextures(1, tex);
+ if (rb && *rb) p_glDeleteRenderbuffers(1, rb);
+ *fbo = 0; *tex = 0; if (rb) *rb = 0;
+ s_pass_allocs++;
+ *tex = make_tex(internal, w, h, fmt, type);
+ if (rb) {
+ p_glGenRenderbuffers(1, rb);
+ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, *rb);
+ p_glRenderbufferStorage(PSXGL_RENDERBUFFER, PSXGL_DEPTH24_STENCIL8, w, h);
+ p_glBindRenderbuffer(PSXGL_RENDERBUFFER, 0);
+ }
+ if (!make_fbo(fbo, *tex, rb ? *rb : 0)) {
+ *cur_w = *cur_h = 0;
+ return 0;
+ }
+ *cur_w = w; *cur_h = h;
+ return 1;
+}
+
+static void pass_gen_release(int gi) {
+ if (s_ctx && s_pgen_alloc_n[gi])
+ glDeleteTextures((GLsizei)s_pgen_alloc_n[gi], s_pgen_tex[gi]);
+ memset(s_pgen_tex[gi], 0, sizeof s_pgen_tex[gi]);
+ s_pgen_alloc_n[gi] = 0;
+ s_pgen_alloc_w[gi] = s_pgen_alloc_h[gi] = 0;
+}
+
+/* Make sure slots [0, need) of generation gi exist at w x h. A size change
+ * frees the old set first (the slot cap depends on the size only). */
+static int pass_gen_reserve(int gi, uint32_t need, int w, int h) {
+ if (need > PASS_SLOTS || need > pass_slot_cap(w, h)) return 0;
+ if (s_pgen_alloc_w[gi] != w || s_pgen_alloc_h[gi] != h) {
+ pass_gen_release(gi);
+ s_pgen_alloc_w[gi] = w;
+ s_pgen_alloc_h[gi] = h;
+ }
+ while (s_pgen_alloc_n[gi] < need) {
+ GLuint *t = &s_pgen_tex[gi][s_pgen_alloc_n[gi]];
+ glGenTextures(1, t);
+ if (!*t) return 0;
+ glBindTexture(GL_TEXTURE_2D, *t);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA,
+ GL_UNSIGNED_BYTE, NULL);
+ s_pgen_alloc_n[gi]++;
+ s_pass_allocs++;
+ }
+ return 1;
+}
+
+/* Copy the rect as the presenter would see it into `tex`. Native-wide first
+ * refreshes the band's canonical centre, as the wide present does. */
+static void pass_capture_into(GLuint tex, const PassGen *g) {
+ int S = s_scale;
+ if (g->source_path == GL_PRES_WIDE) {
+ GLuint wf = pass_wide_fbo_for(g->x);
+ int native_w = g_wide_w - 2 * g_wide_off;
+ if (!wf) return;
+ if (s_wide_fast && native_w > 0) {
+ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo);
+ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, wf);
+ glDisable(GL_SCISSOR_TEST);
+ p_glBlitFramebuffer(g->x * S, g->y * S, (g->x + native_w) * S,
+ (g->y + g->h) * S, g_wide_off * S, g->y * S,
+ (g_wide_off + native_w) * S, (g->y + g->h) * S,
+ GL_COLOR_BUFFER_BIT, GL_NEAREST);
+ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0);
+ }
+ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, wf);
+ glBindTexture(GL_TEXTURE_2D, tex);
+ glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, g->y * S,
+ g_wide_w * S, g->h * S);
+ } else {
+ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo);
+ glBindTexture(GL_TEXTURE_2D, tex);
+ glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, g->x * S, g->y * S,
+ g->w * S, g->h * S);
+ }
+ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0);
+}
+
+static void pass_blit(GLuint src, GLuint dst, int sx, int sy, int dx, int dy,
+ int w, int h, GLbitfield mask) {
+ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, src);
+ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, dst);
+ glDisable(GL_SCISSOR_TEST);
+ p_glBlitFramebuffer(sx, sy, sx + w, sy + h, dx, dy, dx + w, dy + h, mask,
+ GL_NEAREST);
+ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0);
+ p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0);
+}
+
+static void pass_verify_read(uint8_t **hr, size_t *hr_cap, uint8_t **raw,
+ size_t *raw_cap) {
+ int S = s_scale;
+ size_t hn = (size_t)s_pass_w * S * (size_t)s_pass_h * S * 4u;
+ size_t rn = (size_t)s_pass_w * (size_t)s_pass_h * 2u;
+ if (*hr_cap < hn) { free(*hr); *hr = (uint8_t *)malloc(hn); *hr_cap = *hr ? hn : 0; }
+ if (*raw_cap < rn) { free(*raw); *raw = (uint8_t *)malloc(rn); *raw_cap = *raw ? rn : 0; }
+ if (!*hr || !*raw) return;
+ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_hr_fbo);
+ glReadPixels(s_pass_x * S, s_pass_y * S, s_pass_w * S, s_pass_h * S,
+ GL_RGBA, GL_UNSIGNED_BYTE, *hr);
+ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, s_raw_fbo);
+ glReadPixels(s_pass_x, s_pass_y, s_pass_w, s_pass_h, PSXGL_RED_INTEGER,
+ GL_UNSIGNED_SHORT, *raw);
+ p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0);
+}
+
+/* VRAM journal for out-of-rect writes during a pass (see pass_refuse_write). */
+#define PASS_JOURNAL_MAX RENDER_PASS_JOURNAL_MAX
+typedef struct PassJournal {
+ GLuint hr_tex, hr_rb, hr_fbo, raw_tex, raw_fbo;
+ int hr_w, hr_h, raw_w, raw_h;
+} PassJournal;
+static PassJournal s_pj[PASS_JOURNAL_MAX];
+static uint64_t s_pj_total = 0;
+
+static void pass_free_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo,
+ int *w, int *h) {
+ if (s_ctx) {
+ if (*fbo) p_glDeleteFramebuffers(1, fbo);
+ if (*tex) glDeleteTextures(1, tex);
+ if (rb && *rb) p_glDeleteRenderbuffers(1, rb);
+ }
+ *fbo = 0; *tex = 0;
+ if (rb) *rb = 0;
+ *w = *h = 0;
+}
+
+/* Context teardown (gl_renderer_shutdown): free the pass images, backups
+ * and journal, and forget their names so a new context makes fresh ones. */
+static void pass_resources_release(void) {
+ pass_gen_release(0);
+ pass_gen_release(1);
+ pass_gens_invalidate();
+ pass_free_color_fbo(&s_pb_hr_tex, &s_pb_hr_rb, &s_pb_hr_fbo,
+ &s_pb_hr_w, &s_pb_hr_h);
+ pass_free_color_fbo(&s_pb_raw_tex, NULL, &s_pb_raw_fbo,
+ &s_pb_raw_w, &s_pb_raw_h);
+ pass_free_color_fbo(&s_pb_wide_tex, &s_pb_wide_rb, &s_pb_wide_fbo,
+ &s_pb_wide_w, &s_pb_wide_h);
+ for (int i = 0; i < PASS_JOURNAL_MAX; i++) {
+ PassJournal *e = &s_pj[i];
+ pass_free_color_fbo(&e->hr_tex, &e->hr_rb, &e->hr_fbo, &e->hr_w, &e->hr_h);
+ pass_free_color_fbo(&e->raw_tex, NULL, &e->raw_fbo, &e->raw_w, &e->raw_h);
+ }
+ render_pass_journal_free(&s_pj_cpu);
+ s_pb_valid = 0;
+}
+
+static int pass_journal_protect(int x, int y, int w, int h) {
+ int S = s_scale, i = s_pj_cpu.n;
+ PassJournal *j;
+ if (i >= PASS_JOURNAL_MAX) return 0;
+ j = &s_pj[i];
+ if (!pass_make_color_fbo(&j->hr_tex, &j->hr_rb, &j->hr_fbo, &j->hr_w,
+ &j->hr_h, w * S, h * S, GL_RGBA8, GL_RGBA,
+ GL_UNSIGNED_BYTE) ||
+ !pass_make_color_fbo(&j->raw_tex, NULL, &j->raw_fbo, &j->raw_w,
+ &j->raw_h, w, h, PSXGL_R16UI, PSXGL_RED_INTEGER,
+ GL_UNSIGNED_SHORT))
+ return 0;
+ flush_flat_batch();
+ flush_tex_batch();
+ flush_cpu_upload();
+ if (render_pass_journal_add(&s_pj_cpu, s_vram, VRAM_W, x, y, w, h) != i)
+ return 0;
+ pass_blit(s_hr_fbo, j->hr_fbo, x * S, y * S, 0, 0, w * S, h * S,
+ GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
+ pass_blit(s_raw_fbo, j->raw_fbo, x, y, 0, 0, w, h, GL_COLOR_BUFFER_BIT);
+ s_pj_total++;
+ return 1;
+}
+
+static void pass_journal_rollback(void) {
+ int S = s_scale;
+ for (int i = s_pj_cpu.n - 1; i >= 0; i--) {
+ PassJournal *j = &s_pj[i];
+ int x = s_pj_cpu.x[i], y = s_pj_cpu.y[i];
+ int w = s_pj_cpu.w[i], h = s_pj_cpu.h[i];
+ pass_blit(j->hr_fbo, s_hr_fbo, 0, 0, x * S, y * S, w * S, h * S,
+ GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
+ pass_blit(j->raw_fbo, s_raw_fbo, 0, 0, x, y, w, h, GL_COLOR_BUFFER_BIT);
+ }
+ render_pass_journal_rollback(&s_pj_cpu, s_vram, VRAM_W);
+}
+
+int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen,
+ uint32_t period_vblanks, int reuse_backup) {
+ int S = s_scale, gi, wide, tw, th;
+ PassGen *g;
+ if (!gl_renderer_pass_ready() || s_pass_active) return 0;
+ if (x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > VRAM_W || y + h > VRAM_H)
+ return 0;
+ s_pass_allocs_begin = s_pass_allocs;
+ flush_flat_batch();
+ flush_tex_batch();
+ flush_cpu_upload();
+
+ wide = s_interp_source_path == GL_PRES_WIDE && g_wide_w > 0 &&
+ pass_wide_fbo_for(x) != 0;
+ tw = (wide ? g_wide_w : w) * S;
+ th = h * S;
+ gi = 1 - s_pgen_cur;
+ g = &s_pgen[gi];
+ if (open_gen) {
+ if (tw != s_interp_w || th != s_interp_h) return 0; /* not what is presented */
+ if (!pass_gen_reserve(gi, 1u, tw, th)) return 0;
+ memset(g, 0, sizeof *g);
+ g->x = x; g->y = y; g->w = w; g->h = h;
+ g->tex_w = tw; g->tex_h = th;
+ g->source_path = wide ? GL_PRES_WIDE : GL_PRES_VRAM;
+ g->period = period_vblanks ? period_vblanks : 1u;
+ pass_capture_into(s_pgen_tex[gi][0], g); /* the game's own image */
+ g->phase[0] = 0;
+ g->n = 1;
+ g->valid = 1;
+ } else if (!g->valid || g->promoted || g->x != x || g->y != y ||
+ g->w != w || g->h != h) {
+ return 0;
+ }
+
+ /* The previous pass of this frame restored exactly this backup and no
+ * guest code ran since: VRAM and the coherency state already equal it. */
+ if (reuse_backup && s_pb_valid && s_pb_x == x && s_pb_y == y &&
+ s_pb_w == w && s_pb_h == h && s_pb_scale == S &&
+ s_pb_wide_src == (g_wide_w > 0 ? pass_wide_fbo_for(x) : 0)) {
+ s_pb_reused++;
+ goto backed_up;
+ }
+ s_pb_valid = 0;
+ /* Back up the rect: hr color + stencil, raw mirror, wide band, CPU rows. */
+ if (!pass_make_color_fbo(&s_pb_hr_tex, &s_pb_hr_rb, &s_pb_hr_fbo,
+ &s_pb_hr_w, &s_pb_hr_h, w * S, h * S,
+ GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE) ||
+ !pass_make_color_fbo(&s_pb_raw_tex, NULL, &s_pb_raw_fbo,
+ &s_pb_raw_w, &s_pb_raw_h, w, h, PSXGL_R16UI,
+ PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT))
+ return 0;
+ pass_blit(s_hr_fbo, s_pb_hr_fbo, x * S, y * S, 0, 0, w * S, h * S,
+ GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
+ pass_blit(s_raw_fbo, s_pb_raw_fbo, x, y, 0, 0, w, h, GL_COLOR_BUFFER_BIT);
+ s_pb_wide_src = g_wide_w > 0 ? pass_wide_fbo_for(x) : 0;
+ if (s_pb_wide_src) {
+ if (!pass_make_color_fbo(&s_pb_wide_tex, &s_pb_wide_rb, &s_pb_wide_fbo,
+ &s_pb_wide_w, &s_pb_wide_h, g_wide_w * S,
+ h * S, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE))
+ return 0;
+ pass_blit(s_pb_wide_src, s_pb_wide_fbo, 0, y * S, 0, 0, g_wide_w * S,
+ h * S, GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
+ }
+ if (s_pb_cpu_cap < (size_t)w * (size_t)h) {
+ free(s_pb_cpu);
+ s_pb_cpu = (uint16_t *)malloc((size_t)w * (size_t)h * sizeof(uint16_t));
+ s_pb_cpu_cap = s_pb_cpu ? (size_t)w * (size_t)h : 0;
+ if (!s_pb_cpu) return 0;
+ }
+ for (int row = 0; row < h; row++)
+ memcpy(s_pb_cpu + (size_t)row * w, s_vram + (size_t)(y + row) * VRAM_W + x,
+ (size_t)w * sizeof(uint16_t));
+ s_pb_cpu_dirty = s_cpu_dirty;
+ s_pb_pack_dirty = s_pack_dirty;
+ memcpy(s_pb_up_rects, s_up_rects, sizeof s_pb_up_rects);
+ s_pb_up_n = s_up_nrects;
+ s_pb_gpu_dirty = s_gpu_dirty;
+ s_pb_stencil_valid = s_stencil_valid;
+ memcpy(s_pb_present_dirty, s_present_dirty, sizeof s_pb_present_dirty);
+ s_pb_force_present = s_force_present_remaining;
+ s_pb_last_path = s_last_present_path;
+ s_pb_last_dx = s_last_dx; s_pb_last_dy = s_last_dy;
+ s_pb_last_dw = s_last_dw; s_pb_last_dh = s_last_dh;
+ s_pb_x = x; s_pb_y = y; s_pb_w = w; s_pb_h = h; s_pb_scale = S;
+
+backed_up:
+ s_pass_x = x; s_pass_y = y; s_pass_w = w; s_pass_h = h;
+ if (s_pass_verify < 0) {
+ const char *e = getenv("PSX_RENDER_PASS_VERIFY");
+ s_pass_verify = (e && e[0] && e[0] != '0') ? 1 : 0;
+ }
+ if (s_pass_verify) pass_verify_read(&s_pv_hr, &s_pv_hr_cap, &s_pv_raw, &s_pv_raw_cap);
+ s_pj_cpu.n = 0;
+ s_pass_active = 1;
+ return 1;
+}
+
+void gl_renderer_pass_end(uint32_t alpha_q16, int keep) {
+ int S = s_scale, gi = 1 - s_pgen_cur;
+ PassGen *g = &s_pgen[gi];
+ if (!s_pass_active) return;
+ flush_flat_batch();
+ flush_tex_batch();
+ flush_cpu_upload();
+ if (keep && alpha_q16 && g->valid && !g->promoted && g->n < PASS_SLOTS &&
+ g->n < pass_slot_cap(g->tex_w, g->tex_h) &&
+ pass_gen_reserve(gi, g->n + 1u, g->tex_w, g->tex_h)) {
+ /* Passes arrive in ascending phase; keep the list sorted anyway. */
+ uint32_t slot = g->n, at = g->n;
+ GLuint t;
+ pass_capture_into(s_pgen_tex[gi][slot], g);
+ while (at > 1 && g->phase[at - 1] > alpha_q16) at--;
+ t = s_pgen_tex[gi][slot];
+ for (uint32_t i = slot; i > at; i--) {
+ s_pgen_tex[gi][i] = s_pgen_tex[gi][i - 1];
+ g->phase[i] = g->phase[i - 1];
+ }
+ s_pgen_tex[gi][at] = t;
+ g->phase[at] = alpha_q16;
+ g->n++;
+ }
+ /* Roll the journal, then the rect back. */
+ pass_journal_rollback();
+ pass_blit(s_pb_hr_fbo, s_hr_fbo, 0, 0, s_pass_x * S, s_pass_y * S,
+ s_pass_w * S, s_pass_h * S,
+ GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
+ pass_blit(s_pb_raw_fbo, s_raw_fbo, 0, 0, s_pass_x, s_pass_y, s_pass_w,
+ s_pass_h, GL_COLOR_BUFFER_BIT);
+ if (s_pb_wide_src && s_pb_wide_src == pass_wide_fbo_for(s_pass_x))
+ pass_blit(s_pb_wide_fbo, s_pb_wide_src, 0, 0, 0, s_pass_y * S,
+ g_wide_w * S, s_pass_h * S,
+ GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
+ for (int row = 0; row < s_pass_h; row++)
+ memcpy(s_vram + (size_t)(s_pass_y + row) * VRAM_W + s_pass_x,
+ s_pb_cpu + (size_t)row * s_pass_w,
+ (size_t)s_pass_w * sizeof(uint16_t));
+ s_cpu_dirty = s_pb_cpu_dirty;
+ s_pack_dirty = s_pb_pack_dirty;
+ memcpy(s_up_rects, s_pb_up_rects, sizeof s_up_rects);
+ s_up_nrects = s_pb_up_n;
+ s_gpu_dirty = s_pb_gpu_dirty;
+ s_stencil_valid = s_pb_stencil_valid;
+ memcpy(s_present_dirty, s_pb_present_dirty, sizeof s_present_dirty);
+ s_force_present_remaining = s_pb_force_present;
+ s_last_present_path = s_pb_last_path;
+ s_last_dx = s_pb_last_dx; s_last_dy = s_pb_last_dy;
+ s_last_dw = s_pb_last_dw; s_last_dh = s_pb_last_dh;
+ s_pass_active = 0;
+ s_pb_valid = 1;
+
+ if (s_pass_verify) {
+ static uint8_t *after_hr = NULL, *after_raw = NULL;
+ static size_t after_hr_cap = 0, after_raw_cap = 0;
+ size_t hn = (size_t)s_pass_w * S * (size_t)s_pass_h * S * 4u;
+ size_t rn = (size_t)s_pass_w * (size_t)s_pass_h * 2u;
+ pass_verify_read(&after_hr, &after_hr_cap, &after_raw, &after_raw_cap);
+ s_pv_ok = s_pv_hr && after_hr && s_pv_raw && after_raw &&
+ memcmp(s_pv_hr, after_hr, hn) == 0 &&
+ memcmp(s_pv_raw, after_raw, rn) == 0;
+ }
+}
+
+/* Draw two presented images crossfaded (t = weight of b) into the window. */
+static int interp_present_pair(GLuint a, GLuint b, float t, int blend_mode);
+
+static int pass_gen_present(uint64_t deadline) {
+ PassGen *g = &s_pgen[s_pgen_cur];
+ uint32_t lo = 0, hi = 0;
+ float t = 0.0f;
+ double p;
+ if (!g->valid || !g->promoted || !(g->t_len > 0.0)) return 0;
+ if (g->source_path != s_interp_source_path || g->tex_w != s_interp_w ||
+ g->tex_h != s_interp_h)
+ return 0;
+ p = ((double)deadline - g->t_start) / g->t_len;
+ /* Past phase 1 the next flip is late (a lagging tick): the newest image
+ * holds until it comes. Only a game that stops flipping for several
+ * frame lengths expires the generation. */
+ if (!render_pass_gen_select(g->phase, g->n, p, &lo, &hi, &t)) {
+ g->valid = 0;
+ s_pgen_expired++;
+ return 0;
+ }
+ if (!interp_present_pair(s_pgen_tex[s_pgen_cur][lo],
+ s_pgen_tex[s_pgen_cur][hi], t, 0))
+ return 0;
+ s_pgen_presents++;
+ if (lo != hi) s_pgen_blends++;
+ /* A frame's own last deadline falls at p = 1 (the end of its planned
+ * VBlanks); one past it by more than 1/64 is in a VBlank it was not
+ * planned to cover. */
+ if (p > 1.0 + 1.0 / 64.0) s_pgen_late++;
+ return 1;
+}
+
+void gl_renderer_pass_service_presents(void) {
+ uint64_t deadline;
+ double sp;
+ if (!gl_renderer_pass_ready() || s_pass_active) return;
+ sp = (double)SDL_GetPerformanceFrequency() / s_interp_source_hz;
+ while (frame_interpolation_schedule_due(
+ &s_interp_schedule, SDL_GetPerformanceCounter(),
+ s_interp_schedule.frame_end + sp, &deadline)) {
+ latency_ring_mark(LAT_SWAP_BEGIN);
+ if (!pass_gen_present(deadline)) { latency_ring_mark(LAT_SWAP_END); break; }
+ latency_ring_mark(LAT_SWAP_END);
+ frame_interpolation_schedule_consume(&s_interp_schedule);
+ s_pgen_early++;
+ }
+}
+
+void gl_renderer_pass_diag(uint64_t out[10]) {
+ out[0] = s_pgen_promotions;
+ out[1] = s_pgen_presents;
+ out[2] = s_pgen_blends;
+ out[3] = s_pgen_expired;
+ out[4] = s_pgen_unmatched;
+ out[5] = s_pgen_early;
+ out[6] = (uint64_t)(render_pass_cost_estimate(&s_pass_cost) * 1e6 /
+ (double)SDL_GetPerformanceFrequency()); /* us */
+ out[7] = (uint64_t)s_pgen[s_pgen_cur].n;
+ out[8] = s_pgen_late;
+ out[9] = s_pass_cost_rewarms;
+}
+
+uint64_t gl_renderer_pass_journaled(void) { return s_pj_total; }
+uint64_t gl_renderer_pass_backups_reused(void) { return s_pb_reused; }
+
+uint32_t gl_renderer_pass_image_textures(uint64_t *bytes) {
+ uint32_t n = s_pgen_alloc_n[0] + s_pgen_alloc_n[1];
+ if (bytes) {
+ *bytes = 0;
+ for (int gi = 0; gi < 2; gi++)
+ *bytes += (uint64_t)s_pgen_alloc_n[gi] * (uint64_t)s_pgen_alloc_w[gi] *
+ (uint64_t)s_pgen_alloc_h[gi] * 4u;
+ }
+ return n;
+}
+
+static uint64_t s_idle_ticks_accum_fwd(uint64_t add) {
+ s_idle_ticks_accum += add;
+ return s_idle_ticks_accum;
+}
+
+static uint64_t s_present_ticks_accum_fwd(uint64_t add) {
+ s_present_ticks_accum += add;
+ s_present_cost_ema = render_pass_ema(s_present_cost_ema, (double)add);
+ return s_present_ticks_accum;
+}
+
+/* FLIP source saw a new frame: it is the flip a pending generation was built
+ * for (same rect, same presented geometry) or a frame without passes. */
+static void pass_note_new_frame(int origin_x, int origin_y, int source_path,
+ int pw, int ph) {
+ PassGen *pend = &s_pgen[1 - s_pgen_cur];
+ if (pend->valid && !pend->promoted && pend->x == origin_x &&
+ pend->y == origin_y && pend->source_path == source_path &&
+ pend->tex_w == pw && pend->tex_h == ph) {
+ s_pgen_promote = 1;
+ } else {
+ s_pgen_promote = 0;
+ if (s_pgen[s_pgen_cur].valid) s_pgen_unmatched++;
+ s_pgen[s_pgen_cur].valid = 0;
+ s_pgen[s_pgen_cur].promoted = 0;
+ }
+}
+
+/* render_pass_dump (debug server): write every image of the next promoted
+ * generations as PNGs, /g__a.png. */
+static char s_pdump_dir[400];
+static int s_pdump_left = 0;
+static uint64_t s_pdump_gen = 0;
+void gl_renderer_pass_dump_arm(const char *dir, int generations) {
+ if (!dir || !*dir || generations <= 0) { s_pdump_left = 0; return; }
+ snprintf(s_pdump_dir, sizeof s_pdump_dir, "%s", dir);
+ s_pdump_left = generations;
+}
+static void pass_dump_generation(int gi) {
+ PassGen *g = &s_pgen[gi];
+ size_t n = (size_t)g->tex_w * (size_t)g->tex_h * 3u;
+ uint8_t *rgb = (uint8_t *)malloc(n);
+ if (!rgb) return;
+ glPixelStorei(GL_PACK_ALIGNMENT, 1);
+ for (uint32_t i = 0; i < g->n; i++) {
+ char path[512];
+ FILE *f;
+ glBindTexture(GL_TEXTURE_2D, s_pgen_tex[gi][i]);
+ glGetTexImage(GL_TEXTURE_2D, 0, GL_RGB, GL_UNSIGNED_BYTE, rgb);
+ snprintf(path, sizeof path, "%s/g%03llu_%02u_a%05u.png", s_pdump_dir,
+ (unsigned long long)s_pdump_gen, (unsigned)i,
+ (unsigned)g->phase[i]);
+ f = fopen(path, "wb");
+ if (f) { (void)png_write_rgb(f, rgb, (uint32_t)g->tex_w, (uint32_t)g->tex_h); fclose(f); }
+ }
+ glBindTexture(GL_TEXTURE_2D, 0);
+ free(rgb);
+ s_pdump_gen++;
+ s_pdump_left--;
+}
+
+static void pass_apply_promotion(void) {
+ s_intervals_since_plan++;
+ if (!s_pgen_promote) return;
+ s_pgen_promote = 0;
+ s_pgen[s_pgen_cur].valid = 0;
+ s_pgen[s_pgen_cur].promoted = 0;
+ s_pgen_cur = 1 - s_pgen_cur;
+ {
+ PassGen *g = &s_pgen[s_pgen_cur];
+ double sp = s_interp_schedule.frame_end - s_interp_schedule.frame_start;
+ g->promoted = 1;
+ g->t_start = s_interp_schedule.frame_start;
+ g->t_len = (double)g->period * sp;
+ s_pgen_promotions++;
+ if (s_pdump_left > 0) pass_dump_generation(s_pgen_cur);
+ }
+}
+
/* Draw one host OSD ARGB image into the default framebuffer at (vx,vy)
* in top-left window coordinates (y down). Bitmap is ow×oh; viewport is
* dw×dh (may upscale for HiDPI / large windows). */
diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c
index 6aa7a4a7..43b7525e 100644
--- a/runtime/src/interrupts.c
+++ b/runtime/src/interrupts.c
@@ -37,6 +37,7 @@
#include "event_ring.h"
#include "lockstep.h"
#include "psx_cycles.h"
+#include "psx_cycle_freeze.h"
#include "psx_scheduler.h"
#include "psx_video_timing.h"
#include "spu.h"
@@ -1067,6 +1068,8 @@ void psx_interrupt_check_path_diag(uint64_t *entry, uint64_t *fast_sr,
}
int psx_interrupt_delivery_needed(const CPUState* cpu) {
+ /* Render passes run in frozen guest time: nothing is delivered. */
+ if (g_psx_render_pass_active) return 0;
if (s_defer_switch_pending) { s_need_defer++; return 1; }
if ((i_stat & i_mask) == 0) { s_skip_none++; return 0; }
@@ -1091,6 +1094,9 @@ int psx_interrupt_delivery_needed(const CPUState* cpu) {
}
void psx_check_interrupts(CPUState* cpu) {
+ /* Render passes run in frozen guest time (psx_cycles.h): no delivery, and
+ * no check-boundary bookkeeping that the stock timeline would not do. */
+ if (g_psx_render_pass_active) return;
psx_cyc_batch_flush();
extern int g_ls_suppress_record;
extern int psx_netplay_active(void);
diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp
index 6800a9f1..df7baa18 100644
--- a/runtime/src/main.cpp
+++ b/runtime/src/main.cpp
@@ -44,6 +44,7 @@ extern "C" void psx_event_step_conservative_env_init(void);
#include "gpu_sw_renderer.h"
#include "gpu_render.h"
#include "gpu_gl_renderer.h"
+#include "render_pass.h"
/* Declarations only: STB_IMAGE_IMPLEMENTATION lives in psx_window_icon.cpp. */
#define STBI_NO_STDIO
#include "../third_party/stb_image.h"
@@ -1017,6 +1018,9 @@ extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok)
extern "C" void psx_frontend_on_savestate_loaded(void) {
mod_runtime_on_savestate_loaded();
+#ifndef PSX_NO_DEBUG_TOOLS
+ debug_server_note_savestate_loaded();
+#endif
s_disabled_frame_presented = false;
s_force_present_after_load = true;
smooth_60_reset();
@@ -1268,6 +1272,12 @@ static int g_frame_interpolation_blend =
PSX_MOD_FRAME_INTERPOLATION_LINEAR;
static int g_frame_interpolation_blend_default =
PSX_MOD_FRAME_INTERPOLATION_LINEAR;
+/* Presentation is sped up this VBlank (manual fast-forward, turbo-through-
+ * loads, FMV auto-skip, TCP turbo). Render passes are refused meanwhile. */
+static int s_presentation_fast_forward = 0;
+extern "C" int psx_presentation_fast_forward(void) {
+ return s_presentation_fast_forward;
+}
/* Mod-owned blend source; reset_mod_owned_presentation() sets VBLANK. */
static int g_frame_interpolation_source =
PSX_MOD_FRAME_SOURCE_VBLANK;
@@ -1469,6 +1479,9 @@ static void reset_mod_owned_presentation(void) {
g_bezel_path.clear();
g_frame_interpolation_blend = g_frame_interpolation_blend_default;
g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK;
+ /* Render-pass counters, the disabled-after-faults latch and any open
+ * plan generation belong to the session that made them. */
+ render_pass_reset_session();
}
/* The disc a session mounts: a disc-patching mod's private patched image when
@@ -1555,16 +1568,23 @@ extern "C" int psx_mod_set_frame_interpolation(
extern "C" int psx_mod_set_frame_interpolation_blend(
uint32_t blend_mode) {
if (blend_mode != PSX_MOD_FRAME_INTERPOLATION_LINEAR &&
- blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE) {
+ blend_mode != PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE &&
+ blend_mode != PSX_MOD_FRAME_INTERPOLATION_HOLD) {
std::fprintf(stderr,
"psxrecomp: mod rejected invalid frame-interpolation blend %u\n",
(unsigned)blend_mode);
return 0;
}
g_frame_interpolation_blend = (int)blend_mode;
+ /* Live when the presenter is already configured (a later call from a
+ * hook); before that, session start hands it over with the rates. */
+ gl_renderer_set_interpolation_blend(g_frame_interpolation_blend);
std::fprintf(stdout, "psxrecomp: frame-interpolation blend = %s\n",
blend_mode == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE
- ? "motion-adaptive clarity" : "linear crossfade");
+ ? "motion-adaptive clarity"
+ : blend_mode == PSX_MOD_FRAME_INTERPOLATION_HOLD
+ ? "hold (render passes supply in-between frames)"
+ : "linear crossfade");
return 1;
}
@@ -7078,6 +7098,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
}
#endif
+ /* Render passes (render_pass.c) stay off while presentation is sped up. */
+ s_presentation_fast_forward =
+ (turbo_loads_active || fmv_skip_active) ? 1 : 0;
+
if (g_headless) {
ep.skip_pace = 1;
return ep;
@@ -7090,6 +7114,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
* presentation and wall-clock pacing. */
#ifndef PSX_NO_DEBUG_TOOLS
if (debug_server_turbo_enabled()) {
+ s_presentation_fast_forward = 1;
ep.skip_pace = 1;
return ep;
}
@@ -7118,6 +7143,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) {
const int mult = manual_fast_forward_multiplier();
const int present_every = (mult < 0) ? 4 : (mult <= 4 ? 2 : 4);
manual_turbo_active = true;
+ s_presentation_fast_forward = 1;
if (!turbo_was_down && !g_manual_turbo_latched) {
char msg[40];
if (mult < 0)
diff --git a/runtime/src/memory.c b/runtime/src/memory.c
index 3e2bf7ea..6f63240e 100644
--- a/runtime/src/memory.c
+++ b/runtime/src/memory.c
@@ -26,6 +26,8 @@
#include "guest_tty.h"
#include "psx_cycles.h"
#include "psx_memory.h"
+#include "render_pass.h"
+#include "render_pass_plan.h"
#include "starvation_ring.h"
#include
#include
@@ -1703,10 +1705,45 @@ static inline void d44_note(uint32_t phys, uint32_t old, uint32_t val) {
e->frame = (uint32_t)s_frame_count;
}
+/* ---- Render-pass stores (render_pass.c, docs/RENDER_PASSES.md) -------------
+ * A render pass runs guest draw code in frozen time and afterwards restores
+ * RAM, scratchpad, CPU, GPU and DMA state byte-for-byte. Its stores therefore
+ * bypass every observer of the live timeline (code-page tracking, overlay
+ * watch, write traces, card/parity hooks) and go straight to memory. MMIO is
+ * an allow-list: the GPU (GP0, and GP1 DMA-direction / info queries), the GPU
+ * and OTC DMA channels with DPCR/DICR, and I_STAT/I_MASK -- all of which the
+ * pass restores. Everything else (SPU key-ons, CD, timers, SIO, MDEC, memory
+ * control, other DMA channels) would escape the restore, so it is dropped and
+ * counted instead. */
+uint64_t g_render_pass_dropped_writes[RENDER_PASS_DROP_CLASSES];
+
+static void render_pass_mmio_write(uint32_t phys, uint32_t val,
+ uint32_t width) {
+ if (width == 4) mmio_write32(phys, val);
+ else if (width == 2) mmio_write16(phys, (uint16_t)val);
+ else mmio_write8(phys, (uint8_t)val);
+}
+
+/* The policy is render_pass_store_to (render_pass_plan.c, unit-tested by
+ * render_pass_sandbox_test); this only wires it to memory.c's arrays. */
+static void render_pass_store(uint32_t addr, uint32_t val, uint32_t width) {
+ RenderPassStoreTarget t;
+ int cls;
+ t.ram = ram;
+ t.ram_size = psx_ram_live_bytes(); /* live geometry, as psx_ram_map_write */
+ t.scratchpad = scratchpad;
+ t.scratchpad_size = SCRATCHPAD_SIZE;
+ t.isolate_cache = (sr_ptr && (*sr_ptr & 0x10000u)) ? 1 : 0;
+ t.mmio_write = render_pass_mmio_write;
+ cls = render_pass_store_to(&t, addr, val, width);
+ if (cls >= 0) g_render_pass_dropped_writes[cls]++;
+}
+
static void psx_write_word_raw(uint32_t addr, uint32_t val);
void psx_write_word(uint32_t addr, uint32_t val) {
extern void (*g_overlay_flush_pending_cycles)(void);
if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles();
+ if (g_psx_render_pass_active) { render_pass_store(addr, val, 4); return; }
if (g_ls_mode == 2) { ls_write_hook(addr, 4, val); return; }
if (g_ds_recording) {
if (g_dma_exec_depth > 0) ds_note_dma_write();
@@ -1909,6 +1946,7 @@ static void psx_write_half_raw(uint32_t addr, uint16_t val);
void psx_write_half(uint32_t addr, uint16_t val) {
extern void (*g_overlay_flush_pending_cycles)(void);
if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles();
+ if (g_psx_render_pass_active) { render_pass_store(addr, val, 2); return; }
if (g_ls_mode == 2) { ls_write_hook(addr, 2, val); return; }
if (g_ds_recording) {
if (g_dma_exec_depth > 0) ds_note_dma_write();
@@ -2252,6 +2290,7 @@ static void psx_write_byte_raw(uint32_t addr, uint8_t val);
void psx_write_byte(uint32_t addr, uint8_t val) {
extern void (*g_overlay_flush_pending_cycles)(void);
if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles();
+ if (g_psx_render_pass_active) { render_pass_store(addr, val, 1); return; }
if (g_ls_mode == 2) { ls_write_hook(addr, 1, val); return; }
if (g_ls_mode != 1 || s_ls_op_active || g_ls_suppress_record || g_dma_exec_depth > 0) { psx_write_byte_raw(addr, val); return; }
if (!psx_get_in_exception()) ls_write_hook(addr, 1, val);
diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c
index a38f5ae8..16580c67 100644
--- a/runtime/src/overlay_loader.c
+++ b/runtime/src/overlay_loader.c
@@ -2234,6 +2234,21 @@ void overlay_loader_get_ci_skip_diag(uint64_t *unit, uint64_t *supp,
}
int overlay_loader_call_unit_depth(void) { return g_call_unit_depth; }
+/* Native-shard nesting for a landing that longjmps out of a shard and then
+ * resumes the interrupted code (the render-pass watchdog abort, render_pass.c):
+ * the active-candidate stack depth and the in-progress entry are put back by
+ * the dispatch frames the longjmp skips. */
+void overlay_loader_native_nesting(int *active_depth, uint32_t *inprogress) {
+ if (active_depth) *active_depth = s_active_depth;
+ if (inprogress) *inprogress = s_native_inprogress;
+}
+void overlay_loader_set_native_nesting(int active_depth, uint32_t inprogress) {
+ if (active_depth >= 0 &&
+ active_depth <= (int)(sizeof(s_active_stack) / sizeof(s_active_stack[0])))
+ s_active_depth = active_depth;
+ s_native_inprogress = inprogress;
+}
+
static int overlay_irq_suppressed_now(void) {
/* Differential replay (and its authoritative interpreter pass) is atomic.
* Never let a previously armed rate-limit punch a real IRQ into a shadow. */
diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c
index a8832a3c..4bfe7460 100644
--- a/runtime/src/psx_cycles.c
+++ b/runtime/src/psx_cycles.c
@@ -1,6 +1,7 @@
/* psx_cycles.c — PSX guest CPU cycle clock. */
#include "psx_cycles.h"
+#include "psx_cycle_freeze.h"
#include "cpu_state.h"
#include
#include
@@ -28,6 +29,26 @@ uint32_t *g_psx_cyc_local_acc = NULL;
static int s_cycle_replay_active = 0;
static uint64_t s_cycle_replay_live = 0;
+/* ---- render-pass freeze (see psx_cycle_freeze.h) ---- */
+int g_psx_render_pass_active = 0;
+static uint64_t s_freeze_start = 0;
+static uint64_t s_freeze_limit = 0;
+static void (*s_freeze_overrun)(void) = 0;
+
+static void psx_cycle_freeze_tick(void) {
+ extern uint64_t g_psx_cycle_fast_limit;
+ /* Come back at most every 16K cycles for the watchdog; never service. */
+ psx_next_service_cycle = psx_cycle_count + 16384u;
+ g_psx_cycle_fast_limit = 0;
+ if (s_freeze_limit &&
+ psx_cycle_count - s_freeze_start > s_freeze_limit &&
+ s_freeze_overrun) {
+ void (*overrun)(void) = s_freeze_overrun;
+ s_freeze_overrun = 0;
+ overrun();
+ }
+}
+
int psx_cycle_replay_begin(uint64_t start_cycle) {
extern int g_ls_replay_active;
if (!g_ls_replay_active || s_cycle_replay_active) return 0;
@@ -181,6 +202,7 @@ static void psx_devices_recompute_deadline(void) {
}
void psx_devices_service_to_now(void) {
+ if (g_psx_render_pass_active) { psx_cycle_freeze_tick(); return; }
if (s_in_device_service) return; /* device code charged cycles: absorb */
if (g_plp_cycle_diag) g_plp_svc_calls++;
g_psx_cycle_fast_limit = 0;
@@ -243,6 +265,7 @@ void psx_devices_service_to_now(void) {
* that on every GPU/CD/MDEC MMIO touch. Recompute here instead. */
void psx_devices_mmio_sync(void) {
psx_cyc_batch_flush();
+ if (g_psx_render_pass_active) { psx_cycle_freeze_tick(); return; }
if (s_devices_synced_cycle != psx_cycle_count) {
psx_devices_service_to_now();
} else {
@@ -304,6 +327,11 @@ void psx_advance_cycles_slow(uint32_t cycles) {
}
}
if (cycles == 0) return;
+ if (g_psx_render_pass_active) {
+ psx_cycle_count += (uint64_t)cycles;
+ if (psx_cycle_count >= psx_next_service_cycle) psx_cycle_freeze_tick();
+ return;
+ }
#ifdef PSX_COSIM
psx_advance_cycles_exact(cycles);
#else
@@ -792,3 +820,47 @@ void psx_gte_stall(CPUState* cpu) {
psx_advance_cycles((uint32_t)(cpu->gte_ts_done - psx_cycle_count));
}
}
+
+int psx_cycle_freeze_begin(PsxCycleFreeze *save, uint64_t watchdog_cycles,
+ void (*overrun)(void)) {
+ extern uint64_t g_psx_cycle_fast_limit;
+ if (!save || g_psx_render_pass_active) return 0;
+ save->cycle_count = psx_cycle_count;
+ save->next_service = psx_next_service_cycle;
+ save->fast_limit = g_psx_cycle_fast_limit;
+ save->batch = g_psx_cyc_batch;
+ save->batch_limit = g_psx_cyc_batch_limit;
+ save->local_acc = g_psx_cyc_local_acc;
+ save->local_acc_value = g_psx_cyc_local_acc ? *g_psx_cyc_local_acc : 0u;
+ save->in_device_service = psx_in_device_service;
+ save->bb_defer = g_psx_cyc_bb_defer;
+ /* Pending deferred charges stay pending: they belong to the interrupted
+ * guest code and are republished, unchanged, after the pass. */
+ g_psx_cyc_batch = 0;
+ g_psx_cyc_batch_limit = 0;
+ g_psx_cyc_local_acc = NULL;
+ psx_in_device_service = 0;
+ s_freeze_start = psx_cycle_count;
+ s_freeze_limit = watchdog_cycles;
+ s_freeze_overrun = overrun;
+ g_psx_render_pass_active = 1;
+ psx_cycle_freeze_tick();
+ return 1;
+}
+
+void psx_cycle_freeze_end(const PsxCycleFreeze *save) {
+ extern uint64_t g_psx_cycle_fast_limit;
+ g_psx_render_pass_active = 0;
+ s_freeze_overrun = 0;
+ s_freeze_limit = 0;
+ if (!save) return;
+ psx_cycle_count = save->cycle_count;
+ psx_next_service_cycle = save->next_service;
+ g_psx_cycle_fast_limit = save->fast_limit;
+ g_psx_cyc_batch = save->batch;
+ g_psx_cyc_batch_limit = save->batch_limit;
+ g_psx_cyc_local_acc = save->local_acc;
+ if (save->local_acc) *save->local_acc = save->local_acc_value;
+ psx_in_device_service = save->in_device_service;
+ g_psx_cyc_bb_defer = save->bb_defer;
+}
diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c
new file mode 100644
index 00000000..891f0d2c
--- /dev/null
+++ b/runtime/src/render_pass.c
@@ -0,0 +1,602 @@
+/* render_pass.c - host-timed render passes (psx_mod_render_pass_plan /
+ * psx_mod_render_pass, mod_plugins.h). See docs/RENDER_PASSES.md.
+ *
+ * A pass is a sandboxed nested guest call. The guest-visible machine is
+ * checkpointed, time is frozen (psx_cycle_freeze.h), the plugin draws an image of
+ * the scene through the game's own code, the OpenGL backend captures the
+ * declared display rect, and then everything is put back: CPU (with the
+ * GTE), RAM, scratchpad, I-cache tags, I_STAT/I_MASK, timers, DMA and GPU
+ * registers, and the VRAM rect. Stores that could escape that restore (SPU,
+ * CD, timers, other DMA channels, ...) never reach their device in the first
+ * place (memory.c render_pass_store).
+ *
+ * Nothing here runs unless a trusted plugin calls the API, and the plan
+ * refuses in netplay, rollback, rewind, fast-forward, self-check
+ * resimulation, or without the OpenGL presenter's flip-aware interpolation
+ * (psx_mod_render_pass_status says which). */
+
+#include "render_pass.h"
+
+#include
+#include
+#include
+#include
+
+#include "cpu_state.h"
+#include "dirty_ram_interp.h"
+#include "dma.h"
+#include "gpu.h"
+#include "gpu_gl_renderer.h"
+#include "mod_plugins.h"
+#include "overlay_loader.h"
+#include "psx_icache.h"
+#include "timers.h"
+
+extern uint8_t *memory_get_ram_ptr(void);
+extern uint8_t *memory_get_scratchpad_ptr(void);
+/* Live main-RAM geometry (psx_ram_geometry.c): 2 MiB retail, or 8 MiB with
+ * the opt-in 8 MB RAM mod. Latched by memory_init() once per boot, so it
+ * cannot change inside a pass, but it can between sessions. */
+extern uint32_t memory_get_ram_bytes(void);
+extern uint32_t i_stat;
+extern uint32_t i_mask;
+extern uint32_t dma_snapshot_bytes(void);
+extern void dma_snapshot_write(uint8_t *p);
+extern int dma_snapshot_read(const uint8_t *p, uint32_t len);
+extern uint32_t interrupts_get_cycles_since_vblank(void);
+extern void interrupts_set_cycles_since_vblank(uint32_t v);
+extern void psx_irq_refresh_cause_ip2(void);
+extern int psx_get_in_exception(void);
+extern int psx_netplay_active(void);
+extern int psx_netplay_is_resimulating(void);
+extern int psx_selfcheck_resim_active(void);
+extern int psx_rewind_is_open(void);
+extern int psx_presentation_fast_forward(void);
+extern uint64_t g_guest_store_count;
+extern uint64_t g_mmio_access_count;
+extern uint32_t g_debug_last_store_pc;
+extern uint32_t g_debug_current_func_addr;
+extern int g_psx_dispatch_depth;
+extern int g_psx_call_bail;
+extern int g_ls_mode;
+extern int g_ls_replay_active;
+extern void (*g_overlay_flush_pending_cycles)(void);
+/* Host nesting that runtime frames set on entry and put back on exit. */
+extern int g_call_unit_depth; /* overlay_loader_call_native */
+extern int g_dma_exec_depth; /* dma.c kick / async writes */
+extern int g_dma_cur_ch;
+extern uint32_t g_dma_cur_madr;
+extern uint32_t g_dma_cur_bcr;
+extern uint32_t g_dma_initiator_pc;
+extern int g_dirty_interp_active; /* dirty-RAM interpreter */
+extern int g_exec_phase;
+extern int g_precise_mode;
+extern uint32_t g_dirty_safe_resume_pc;
+extern uint32_t cdrom_snapshot_bytes(void);
+extern void cdrom_snapshot_write(uint8_t *p);
+extern uint32_t spu_snapshot_bytes(void);
+extern void spu_snapshot_write(uint8_t *p);
+extern uint32_t sio_snapshot_bytes(void);
+extern void sio_snapshot_write(uint8_t *p);
+extern uint32_t mdec_snapshot_bytes(void);
+extern void mdec_snapshot_write(uint8_t *p);
+
+#define RP_SPAD_SIZE 1024u
+/* ~8 M guest cycles is about four PS1 frames of CPU time: far beyond any
+ * frame's draw code, short enough that a pass stuck on a wait loop costs the
+ * host well under a frame before it is rolled back. */
+#define RP_WATCHDOG_CYCLES 8000000u
+/* Faults (watchdog, VRAM leaks) before passes stay off for the session. */
+#define RP_FAULT_LIMIT 8u
+
+/* Host-side nesting state. Generated functions, native overlay calls, DMA
+ * kicks, the dirty-RAM interpreter and GP0 processing each set some of this
+ * on entry and put it back on exit. The watchdog abort longjmps past those
+ * exits, so the landing puts the interrupted code's values back from the
+ * checkpoint, as the scheduler and exception landings reset theirs
+ * (traps.c psx_scheduler_run, interrupts.c psx_check_interrupts). The cycle
+ * deferral depth (g_psx_cyc_bb_defer) is part of PsxCycleFreeze. After a
+ * pass that returns normally all of it is already balanced. */
+typedef struct RenderPassNesting {
+ int call_unit_depth;
+ int dma_exec_depth, dma_cur_ch;
+ uint32_t dma_cur_madr, dma_cur_bcr, dma_initiator_pc;
+ int dirty_interp_active, exec_phase, precise_mode;
+ uint32_t dirty_safe_resume_pc;
+ DirtyRamLoadDelay ld_delay;
+ int ov_active_depth;
+ uint32_t ov_inprogress;
+ void (*ov_flush)(void);
+} RenderPassNesting;
+
+static void nesting_save(RenderPassNesting *n) {
+ n->call_unit_depth = g_call_unit_depth;
+ n->dma_exec_depth = g_dma_exec_depth;
+ n->dma_cur_ch = g_dma_cur_ch;
+ n->dma_cur_madr = g_dma_cur_madr;
+ n->dma_cur_bcr = g_dma_cur_bcr;
+ n->dma_initiator_pc = g_dma_initiator_pc;
+ n->dirty_interp_active = g_dirty_interp_active;
+ n->exec_phase = g_exec_phase;
+ n->precise_mode = g_precise_mode;
+ n->dirty_safe_resume_pc = g_dirty_safe_resume_pc;
+ dirty_ram_ld_delay_save(&n->ld_delay);
+ overlay_loader_native_nesting(&n->ov_active_depth, &n->ov_inprogress);
+ n->ov_flush = g_overlay_flush_pending_cycles;
+}
+
+static void nesting_restore(const RenderPassNesting *n) {
+ g_call_unit_depth = n->call_unit_depth;
+ g_dma_exec_depth = n->dma_exec_depth;
+ g_dma_cur_ch = n->dma_cur_ch;
+ g_dma_cur_madr = n->dma_cur_madr;
+ g_dma_cur_bcr = n->dma_cur_bcr;
+ g_dma_initiator_pc = n->dma_initiator_pc;
+ g_dirty_interp_active = n->dirty_interp_active;
+ g_exec_phase = n->exec_phase;
+ g_precise_mode = n->precise_mode;
+ g_dirty_safe_resume_pc = n->dirty_safe_resume_pc;
+ dirty_ram_ld_delay_restore(&n->ld_delay);
+ overlay_loader_set_native_nesting(n->ov_active_depth, n->ov_inprogress);
+ g_overlay_flush_pending_cycles = n->ov_flush;
+}
+
+/* PSX_RENDER_PASS_VERIFY: a pass that returned normally must leave the
+ * nesting balanced; the restore would otherwise hide the imbalance. (The
+ * deferred load is not nesting: the interpreter retires it on exit.) */
+static int nesting_balanced(const RenderPassNesting *n) {
+ RenderPassNesting now;
+ memset(&now, 0, sizeof now);
+ nesting_save(&now);
+ return now.call_unit_depth == n->call_unit_depth &&
+ now.dma_exec_depth == n->dma_exec_depth &&
+ now.dirty_interp_active == n->dirty_interp_active &&
+ now.exec_phase == n->exec_phase &&
+ now.precise_mode == n->precise_mode &&
+ now.ov_active_depth == n->ov_active_depth &&
+ now.ov_flush == n->ov_flush;
+}
+
+/* After a watchdog abort, before the restore: which exits the longjmp
+ * skipped (for the fault log). Returns how many values it found changed. */
+static int nesting_describe(const RenderPassNesting *n, int bb_defer_ck,
+ char *out, size_t cap) {
+ RenderPassNesting now;
+ int count = 0;
+ size_t len = 0;
+ memset(&now, 0, sizeof now);
+ nesting_save(&now);
+ out[0] = '\0';
+#define RP_NOTE(cond, ...) do { if (cond) { \
+ int w_ = snprintf(out + len, cap - len, "%s", count ? ", " : ""); \
+ if (w_ > 0 && (size_t)w_ < cap - len) len += (size_t)w_; \
+ w_ = snprintf(out + len, cap - len, __VA_ARGS__); \
+ if (w_ > 0 && (size_t)w_ < cap - len) len += (size_t)w_; \
+ count++; } } while (0)
+ RP_NOTE(g_psx_cyc_bb_defer != bb_defer_ck, "cycle deferral %+d",
+ g_psx_cyc_bb_defer - bb_defer_ck);
+ RP_NOTE(now.call_unit_depth != n->call_unit_depth, "call unit %+d",
+ now.call_unit_depth - n->call_unit_depth);
+ RP_NOTE(now.ov_active_depth != n->ov_active_depth, "shard stack %+d",
+ now.ov_active_depth - n->ov_active_depth);
+ RP_NOTE(now.ov_flush != n->ov_flush, "shard cycle hook");
+ RP_NOTE(now.dma_exec_depth != n->dma_exec_depth, "DMA depth %+d",
+ now.dma_exec_depth - n->dma_exec_depth);
+ RP_NOTE(now.dirty_interp_active != n->dirty_interp_active, "interpreter flag");
+ RP_NOTE(now.precise_mode != n->precise_mode, "precise flag");
+ RP_NOTE(now.exec_phase != n->exec_phase, "exec phase %d->%d",
+ now.exec_phase, n->exec_phase);
+ RP_NOTE(now.ld_delay.armed != n->ld_delay.armed, "pending load");
+#undef RP_NOTE
+ return count;
+}
+
+typedef struct RenderPassCheckpoint {
+ CPUState cpu;
+ uint8_t spad[RP_SPAD_SIZE];
+ uint32_t icache[1024];
+ uint32_t i_stat, i_mask, csv;
+ uint16_t t_counter[3], t_target[3];
+ uint32_t t_mode[3], t_frac[3];
+ int32_t t_irq[3];
+ uint64_t store_count, mmio_count;
+ uint32_t last_store_pc, current_func;
+ int dispatch_depth, call_bail;
+ uint32_t dma_len;
+ uint32_t ram_bytes; /* live RAM geometry at the checkpoint */
+ RenderPassNesting nest;
+} RenderPassCheckpoint;
+
+static uint8_t *s_ram_copy; /* s_ram_copy_cap bytes */
+static uint32_t s_ram_copy_cap;
+static uint8_t *s_dma_copy;
+static RenderPassCheckpoint s_ck;
+static PsxCycleFreeze s_freeze;
+static jmp_buf s_abort_jmp;
+static volatile int s_abort_armed;
+static int s_nesting;
+
+static RenderPassStats s_stats;
+static int s_verify = -1;
+static int s_open_generation; /* next pass captures frame N's image */
+static uint32_t s_plan_period = 2;
+/* The last pass's restore left the machine as it found it; while no guest
+ * code runs (clock and store count unchanged) and the plan is the same, the
+ * next pass may reuse that pass's VRAM backup. */
+static uint64_t s_plan_serial, s_restored_plan;
+static uint64_t s_restored_cycle, s_restored_stores;
+static int s_restored_valid;
+
+/* PSX_RENDER_PASS_WATCHDOG= lowers the cut-off (debug), so a
+ * title's rollback path can be exercised on real passes. */
+static uint64_t watchdog_cycles(void) {
+ static uint64_t v;
+ if (!v) {
+ const char *e = getenv("PSX_RENDER_PASS_WATCHDOG");
+ long long n = e ? atoll(e) : 0;
+ v = (n >= 1000 && n < (long long)RP_WATCHDOG_CYCLES)
+ ? (uint64_t)n : (uint64_t)RP_WATCHDOG_CYCLES;
+ if (v != RP_WATCHDOG_CYCLES)
+ fprintf(stderr, "psxrecomp: render pass watchdog lowered to %llu "
+ "guest cycles (PSX_RENDER_PASS_WATCHDOG)\n",
+ (unsigned long long)v);
+ }
+ return v;
+}
+
+static int verify_on(void) {
+ if (s_verify < 0) {
+ const char *e = getenv("PSX_RENDER_PASS_VERIFY");
+ s_verify = (e && e[0] && e[0] != '0') ? 1 : 0;
+ }
+ return s_verify;
+}
+
+void render_pass_get_stats(RenderPassStats *out) {
+ if (!out) return;
+ *out = s_stats;
+ for (int i = 0; i < RENDER_PASS_DROP_CLASSES; i++)
+ out->dropped[i] = g_render_pass_dropped_writes[i];
+}
+
+void render_pass_reset_session(void) {
+ memset(&s_stats, 0, sizeof s_stats);
+ memset(g_render_pass_dropped_writes, 0, sizeof g_render_pass_dropped_writes);
+ s_open_generation = 0;
+ s_restored_valid = 0;
+}
+
+/* Everything that must hold before guest code may run frozen, as a
+ * PSX_MOD_RENDER_PASS_* reason (READY = all of it holds). */
+static uint32_t pass_status(void) {
+ uint32_t gl;
+ if (s_stats.disabled) return PSX_MOD_RENDER_PASS_DISABLED;
+ if (psx_netplay_active() || psx_netplay_is_resimulating() ||
+ psx_selfcheck_resim_active() || psx_rewind_is_open() ||
+ g_ls_mode || g_ls_replay_active)
+ return PSX_MOD_RENDER_PASS_SESSION;
+ if (psx_presentation_fast_forward()) return PSX_MOD_RENDER_PASS_FAST_FORWARD;
+ gl = gl_renderer_pass_unavailable();
+ if (gl != PSX_MOD_RENDER_PASS_READY) return gl;
+ if (s_nesting || g_psx_render_pass_active || psx_get_in_exception() ||
+ dma_gpu_linked_list_active())
+ return PSX_MOD_RENDER_PASS_BUSY;
+ return PSX_MOD_RENDER_PASS_READY;
+}
+
+static int passes_allowed(void) {
+ return pass_status() == PSX_MOD_RENDER_PASS_READY;
+}
+
+uint32_t psx_mod_render_pass_status(void) {
+ return pass_status();
+}
+
+uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks,
+ uint32_t shown_after_vblanks,
+ uint32_t *alpha_q16, uint32_t max) {
+ uint32_t wanted = 0, n;
+ s_open_generation = 0;
+ if (!alpha_q16 || max == 0 || period_vblanks == 0 || period_vblanks > 8 ||
+ shown_after_vblanks > 8)
+ return 0;
+ if (!passes_allowed()) return 0;
+ n = gl_renderer_pass_plan(period_vblanks, shown_after_vblanks,
+ alpha_q16, max, &wanted);
+ s_stats.wanted += wanted;
+ if (!n) {
+ if (wanted) s_stats.refused++;
+ return 0;
+ }
+ s_stats.plans++;
+ s_stats.planned += n;
+ s_plan_serial++;
+ s_open_generation = 1;
+ s_plan_period = period_vblanks;
+ return n;
+}
+
+/* FNV-1a step over 64-bit words, then the tail bytes. Each step is a
+ * bijection of h, so a change confined to one word always changes the hash.
+ * Word steps take an eighth of the time of byte steps, which keeps
+ * PSX_RENDER_PASS_VERIFY affordable with 8 MiB of RAM live. */
+static uint64_t fnv(uint64_t h, const void *p, size_t n) {
+ const uint8_t *b = (const uint8_t *)p;
+ size_t i = 0;
+ for (; i + 8u <= n; i += 8u) {
+ uint64_t w;
+ memcpy(&w, b + i, sizeof w);
+ h = (h ^ w) * 1099511628211ULL;
+ }
+ for (; i < n; i++) h = (h ^ b[i]) * 1099511628211ULL;
+ return h;
+}
+
+/* Guest-visible state hash for PSX_RENDER_PASS_VERIFY. */
+static uint64_t state_hash(const CPUState *cpu) {
+ uint64_t h = 1469598103934665603ULL;
+ uint16_t tc[3], tt[3];
+ uint32_t tm[3], tf[3];
+ int32_t ti[3];
+ uint32_t csv = interrupts_get_cycles_since_vblank();
+ uint64_t cyc = psx_cycle_count;
+ h = fnv(h, cpu->gpr, sizeof cpu->gpr);
+ h = fnv(h, &cpu->hi, sizeof cpu->hi);
+ h = fnv(h, &cpu->lo, sizeof cpu->lo);
+ h = fnv(h, cpu->cop0, sizeof cpu->cop0);
+ h = fnv(h, cpu->gte_data, sizeof cpu->gte_data);
+ h = fnv(h, cpu->gte_ctrl, sizeof cpu->gte_ctrl);
+ h = fnv(h, memory_get_ram_ptr(), memory_get_ram_bytes());
+ h = fnv(h, memory_get_scratchpad_ptr(), RP_SPAD_SIZE);
+ h = fnv(h, g_psx_icache_tv, sizeof g_psx_icache_tv);
+ h = fnv(h, &i_stat, sizeof i_stat);
+ h = fnv(h, &i_mask, sizeof i_mask);
+ h = fnv(h, &csv, sizeof csv);
+ h = fnv(h, &cyc, sizeof cyc);
+ timers_get_snapshot(tc, tm, tt, ti, tf);
+ h = fnv(h, tc, sizeof tc); h = fnv(h, tm, sizeof tm);
+ h = fnv(h, tt, sizeof tt); h = fnv(h, ti, sizeof ti); h = fnv(h, tf, sizeof tf);
+ if (s_dma_copy) {
+ uint32_t n = dma_snapshot_bytes();
+ uint8_t *tmp = (uint8_t *)malloc(n);
+ if (tmp) { dma_snapshot_write(tmp); h = fnv(h, tmp, n); free(tmp); }
+ }
+ {
+ uint64_t g = gpu_pass_state_hash();
+ h = fnv(h, &g, sizeof g);
+ }
+ /* Devices a pass must never touch (their stores are dropped, their
+ * clocks frozen): prove it. */
+ {
+ uint32_t (*bytes[4])(void) = { cdrom_snapshot_bytes, spu_snapshot_bytes,
+ sio_snapshot_bytes, mdec_snapshot_bytes };
+ void (*write[4])(uint8_t *) = { cdrom_snapshot_write, spu_snapshot_write,
+ sio_snapshot_write, mdec_snapshot_write };
+ for (int i = 0; i < 4; i++) {
+ uint32_t n = bytes[i]();
+ uint8_t *tmp = n ? (uint8_t *)malloc(n) : NULL;
+ if (!tmp) continue;
+ write[i](tmp);
+ h = fnv(h, tmp, n);
+ free(tmp);
+ }
+ }
+ return h;
+}
+
+static int checkpoint_save(const CPUState *cpu) {
+ uint32_t dma_len = dma_snapshot_bytes();
+ /* The whole live RAM: a pass's stores fold through the same geometry
+ * (render_pass_store_to), so every byte one can reach is in here. */
+ uint32_t ram_bytes = memory_get_ram_bytes();
+ if (s_ram_copy_cap < ram_bytes) {
+ uint8_t *p = (uint8_t *)realloc(s_ram_copy, ram_bytes);
+ if (!p) return 0;
+ s_ram_copy = p;
+ s_ram_copy_cap = ram_bytes;
+ }
+ if (!s_dma_copy) {
+ s_dma_copy = (uint8_t *)malloc(dma_len);
+ if (!s_dma_copy) return 0;
+ }
+ if (!gpu_pass_checkpoint_save()) return 0;
+ s_ck.cpu = *cpu;
+ s_ck.ram_bytes = ram_bytes;
+ memcpy(s_ram_copy, memory_get_ram_ptr(), ram_bytes);
+ memcpy(s_ck.spad, memory_get_scratchpad_ptr(), RP_SPAD_SIZE);
+ memcpy(s_ck.icache, g_psx_icache_tv, sizeof s_ck.icache);
+ s_ck.i_stat = i_stat;
+ s_ck.i_mask = i_mask;
+ s_ck.csv = interrupts_get_cycles_since_vblank();
+ timers_get_snapshot(s_ck.t_counter, s_ck.t_mode, s_ck.t_target,
+ s_ck.t_irq, s_ck.t_frac);
+ s_ck.dma_len = dma_len;
+ dma_snapshot_write(s_dma_copy);
+ s_ck.store_count = g_guest_store_count;
+ s_ck.mmio_count = g_mmio_access_count;
+ s_ck.last_store_pc = g_debug_last_store_pc;
+ s_ck.current_func = g_debug_current_func_addr;
+ s_ck.dispatch_depth = g_psx_dispatch_depth;
+ s_ck.call_bail = g_psx_call_bail;
+ memset(&s_ck.nest, 0, sizeof s_ck.nest);
+ nesting_save(&s_ck.nest);
+ return 1;
+}
+
+static void checkpoint_restore(CPUState *cpu) {
+ gpu_pass_checkpoint_restore();
+ (void)dma_snapshot_read(s_dma_copy, s_ck.dma_len);
+ timers_set_snapshot(s_ck.t_counter, s_ck.t_mode, s_ck.t_target,
+ s_ck.t_irq, s_ck.t_frac);
+ interrupts_set_cycles_since_vblank(s_ck.csv);
+ i_stat = s_ck.i_stat;
+ i_mask = s_ck.i_mask;
+ memcpy(memory_get_ram_ptr(), s_ram_copy, s_ck.ram_bytes);
+ memcpy(memory_get_scratchpad_ptr(), s_ck.spad, RP_SPAD_SIZE);
+ memcpy(g_psx_icache_tv, s_ck.icache, sizeof s_ck.icache);
+ *cpu = s_ck.cpu;
+ psx_irq_refresh_cause_ip2();
+ g_guest_store_count = s_ck.store_count;
+ g_mmio_access_count = s_ck.mmio_count;
+ g_debug_last_store_pc = s_ck.last_store_pc;
+ g_debug_current_func_addr = s_ck.current_func;
+ g_psx_dispatch_depth = s_ck.dispatch_depth;
+ g_psx_call_bail = s_ck.call_bail;
+ nesting_restore(&s_ck.nest);
+}
+
+static double s_ms_per_tick = 0.0;
+static double ema_ms(double cur, uint64_t ticks) {
+ double ms = (double)ticks * s_ms_per_tick;
+ return cur > 0.0 ? cur * 0.9 + ms * 0.1 : ms;
+}
+
+static void watchdog_overrun(void) {
+ s_stats.watchdog++;
+ s_stats.watchdog_flag = 1;
+ if (s_abort_armed) {
+ s_abort_armed = 0;
+ longjmp(s_abort_jmp, 1);
+ }
+}
+
+static char s_abort_detail[192];
+
+static void note_fault(const char *what) {
+ s_stats.aborted++;
+ if (s_stats.aborted <= 4)
+ fprintf(stderr, "psxrecomp: render pass rolled back (%s%s%s)\n", what,
+ s_abort_detail[0] ? "; restored skipped exits: " : "",
+ s_abort_detail);
+ s_abort_detail[0] = '\0';
+ if (s_stats.watchdog + s_stats.vram_leaks >= RP_FAULT_LIMIT &&
+ !s_stats.disabled) {
+ s_stats.disabled = 1;
+ fprintf(stderr, "psxrecomp: render passes disabled for this session "
+ "after %u faults\n", (unsigned)RP_FAULT_LIMIT);
+ }
+}
+
+int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass,
+ PSXModRenderPassFn fn, void *user) {
+ uint64_t t0, t1, tb, tg, te, tr, hash_before = 0, hash_after = 0;
+ uint64_t cycles_before;
+ uint32_t leaks;
+ int ok = 0, open, reuse;
+ static uint32_t s_leaks_before;
+
+ if (!cpu || !pass || !fn || pass->struct_size < sizeof *pass ||
+ pass->w == 0 || pass->h == 0 || pass->alpha_q16 == 0 ||
+ pass->alpha_q16 >= 65536u)
+ return 0;
+ if (!passes_allowed()) return 0;
+
+ /* An overlay DLL may still hold cycles it has not published. They belong
+ * to the live timeline: publish them now, or the pass's first store would
+ * publish them into the frozen clock and the restore would drop them. */
+ if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles();
+
+ t0 = gl_renderer_perf_ticks();
+ open = s_open_generation;
+ {
+ static double inv = 0.0;
+ if (inv == 0.0) inv = 1000.0 / (double)gl_renderer_perf_frequency();
+ s_ms_per_tick = inv;
+ }
+ reuse = !open && s_restored_valid && s_restored_plan == s_plan_serial &&
+ s_restored_cycle == psx_cycle_count &&
+ s_restored_stores == g_guest_store_count;
+ /* Frame N's own image is captured by the first pass after a plan. */
+ if (!gl_renderer_pass_begin(pass->x, pass->y, pass->w, pass->h, open,
+ s_plan_period, reuse))
+ return 0;
+ s_open_generation = 0;
+ s_leaks_before = gl_renderer_pass_leaks();
+
+ if (!checkpoint_save(cpu)) {
+ gl_renderer_pass_end(0, 0);
+ return 0;
+ }
+ if (verify_on()) hash_before = state_hash(cpu);
+
+ tb = gl_renderer_perf_ticks();
+ s_nesting = 1;
+ cycles_before = psx_cycle_count;
+ (void)psx_cycle_freeze_begin(&s_freeze, watchdog_cycles(),
+ watchdog_overrun);
+ if (setjmp(s_abort_jmp) == 0) {
+ s_abort_armed = 1;
+ ok = fn(cpu, user, pass->alpha_q16) ? 1 : 0;
+ s_abort_armed = 0;
+ if (!ok) s_stats.discarded++;
+ if (verify_on() && !nesting_balanced(&s_ck.nest)) {
+ s_stats.verify_mismatch++;
+ fprintf(stderr, "psxrecomp: RENDER PASS VERIFY mismatch "
+ "(host nesting not balanced after the pass)\n");
+ }
+ } else {
+ /* Watchdog abort from inside guest code. An overlay shard may still
+ * hold cycles it has not published: publish them now, into the
+ * frozen clock the restore discards, never into the live one. The
+ * nesting the longjmp skipped is put back by checkpoint_restore and
+ * psx_cycle_freeze_end below. */
+ if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles();
+ if (nesting_describe(&s_ck.nest, s_freeze.bb_defer, s_abort_detail,
+ sizeof s_abort_detail))
+ s_stats.nesting_repairs++;
+ ok = 0;
+ }
+ s_stats.guest_cycles_last = psx_cycle_count - cycles_before;
+ tg = gl_renderer_perf_ticks();
+
+ leaks = gl_renderer_pass_leaks() - s_leaks_before;
+ if (leaks) {
+ s_stats.vram_leaks += leaks;
+ ok = 0;
+ }
+ /* Capture (when kept), then roll the VRAM rect and renderer back. */
+ gl_renderer_pass_end(ok ? pass->alpha_q16 : 0, ok);
+ te = gl_renderer_perf_ticks();
+ checkpoint_restore(cpu);
+ psx_cycle_freeze_end(&s_freeze);
+ s_nesting = 0;
+ s_restored_valid = 1;
+ s_restored_plan = s_plan_serial;
+ s_restored_cycle = psx_cycle_count;
+ s_restored_stores = g_guest_store_count;
+ tr = gl_renderer_perf_ticks();
+ s_stats.avg_begin_ms = ema_ms(s_stats.avg_begin_ms, tb - t0);
+ s_stats.avg_guest_ms = ema_ms(s_stats.avg_guest_ms, tg - tb);
+ s_stats.avg_end_ms = ema_ms(s_stats.avg_end_ms, te - tg);
+ s_stats.avg_restore_ms = ema_ms(s_stats.avg_restore_ms, tr - te);
+
+ if (verify_on()) {
+ hash_after = state_hash(cpu);
+ s_stats.verify_checks++;
+ if (hash_after != hash_before || !gl_renderer_pass_verify_vram()) {
+ s_stats.verify_mismatch++;
+ if (s_stats.verify_mismatch <= 8)
+ fprintf(stderr, "psxrecomp: RENDER PASS VERIFY mismatch "
+ "(state %016llx -> %016llx)\n",
+ (unsigned long long)hash_before,
+ (unsigned long long)hash_after);
+ }
+ }
+
+ t1 = gl_renderer_perf_ticks();
+ {
+ double ms = (double)(t1 - t0) * 1000.0 /
+ (double)gl_renderer_perf_frequency();
+ s_stats.last_pass_ms = ms;
+ s_stats.avg_pass_ms = s_stats.avg_pass_ms > 0.0
+ ? s_stats.avg_pass_ms * 0.9 + ms * 0.1 : ms;
+ }
+ gl_renderer_pass_note_cost(t1 - t0);
+ if (ok) s_stats.passes++;
+ else if (leaks) note_fault("VRAM write outside the pass rect");
+ else if (s_stats.watchdog_flag) note_fault("guest-cycle watchdog");
+ s_stats.watchdog_flag = 0;
+ /* Present anything that fell due while the pass ran. */
+ gl_renderer_pass_service_presents();
+ return ok;
+}
diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c
new file mode 100644
index 00000000..b04ef525
--- /dev/null
+++ b/runtime/src/render_pass_plan.c
@@ -0,0 +1,306 @@
+#include "render_pass_plan.h"
+#include "render_pass.h"
+
+#include
+#include
+#include
+
+#define Q16 65536.0
+
+static uint32_t to_q16(double p) {
+ double q = floor(p * Q16 + 0.5);
+ if (q < 1.0) q = 1.0;
+ if (q > Q16 - 1.0) q = Q16 - 1.0;
+ return (uint32_t)q;
+}
+
+uint32_t render_pass_plan_phases(const RenderPassPlanInput *in,
+ uint32_t *alpha_q16, uint32_t *wanted) {
+ double phases[RENDER_PASS_MAX_PHASES * 4u];
+ uint32_t count = 0, cap, n;
+
+ if (wanted) *wanted = 0;
+ if (!in || !alpha_q16 || in->max == 0 || !(in->frame_length > 0.0) ||
+ !isfinite(in->frame_length))
+ return 0;
+ cap = sizeof phases / sizeof phases[0];
+
+ /* No live output schedule: nothing would show a pass. */
+ if (!(in->target_period > 0.0) || !isfinite(in->target_period) ||
+ !isfinite(in->next_deadline) || !isfinite(in->frame_start))
+ return 0;
+ {
+ /* Walk the presenter's free-running output grid through the frame. */
+ double end = in->frame_start + in->frame_length;
+ double d = in->next_deadline;
+ if (d <= in->frame_start) {
+ double steps = floor((in->frame_start - d) / in->target_period) + 1.0;
+ d += steps * in->target_period;
+ }
+ for (; d < end && count < cap; d += in->target_period) {
+ double p = (d - in->frame_start) / in->frame_length;
+ if (p < 1.0 / 64.0) continue;
+ if (p >= 1.0) break;
+ phases[count++] = p;
+ }
+ }
+ if (count == 0) return 0;
+ if (wanted) *wanted = count;
+
+ n = count;
+ if (n > in->max) n = in->max;
+ if (n > RENDER_PASS_MAX_PHASES) n = RENDER_PASS_MAX_PHASES;
+ if (in->budget >= 0.0) {
+ /* Cost unknown (no pass measured at this image size yet): one pass
+ * measures it without stalling the guest for a whole plan. */
+ double fit = in->pass_cost > 0.0 ? floor(in->budget / in->pass_cost)
+ : (in->budget > 0.0 ? 1.0 : 0.0);
+ if (fit < 0.0) fit = 0.0;
+ if (fit < (double)n) n = (uint32_t)fit;
+ }
+ if (n == 0) return 0;
+ if (n == count) {
+ for (uint32_t i = 0; i < n; i++) alpha_q16[i] = to_q16(phases[i]);
+ } else {
+ /* Evenly spread subset; the presenter blends across the gaps. */
+ uint32_t last = UINT32_MAX, out = 0;
+ for (uint32_t i = 0; i < n; i++) {
+ uint32_t idx = (uint32_t)floor(((double)i + 0.5) *
+ (double)count / (double)n);
+ if (idx >= count) idx = count - 1;
+ if (idx == last) continue;
+ alpha_q16[out++] = to_q16(phases[idx]);
+ last = idx;
+ }
+ n = out;
+ }
+ return n;
+}
+
+int render_pass_select(const uint32_t *phases, uint32_t n, double p,
+ uint32_t *lo, uint32_t *hi, float *t) {
+ uint32_t i;
+ double q, a, b, w;
+ if (!phases || n == 0) return 0;
+ q = p * Q16;
+ if (!(q > (double)phases[0])) {
+ *lo = *hi = 0;
+ *t = 0.0f;
+ return 1;
+ }
+ for (i = 0; i + 1 < n && (double)phases[i + 1] <= q; i++) { }
+ if (i + 1 >= n) {
+ *lo = *hi = n - 1;
+ *t = 0.0f;
+ return 1;
+ }
+ a = (double)phases[i];
+ b = (double)phases[i + 1];
+ w = b > a ? (q - a) / (b - a) : 0.0;
+ if (w <= 1.0 / 256.0) {
+ *lo = *hi = i;
+ *t = 0.0f;
+ } else if (w >= 1.0 - 1.0 / 256.0) {
+ *lo = *hi = i + 1;
+ *t = 0.0f;
+ } else {
+ *lo = i;
+ *hi = i + 1;
+ *t = (float)w;
+ }
+ return 1;
+}
+
+int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p,
+ uint32_t *lo, uint32_t *hi, float *t) {
+ if (!(p <= RENDER_PASS_GEN_HOLD_MAX)) return 0;
+ return render_pass_select(phases, n, p, lo, hi, t);
+}
+
+double render_pass_ema(double current, double sample) {
+ if (!(sample >= 0.0) || !isfinite(sample)) return current;
+ if (!(current > 0.0)) return sample;
+ return current * 0.75 + sample * 0.25;
+}
+
+void render_pass_cost_add(RenderPassCost *cost, double sample, int allocated) {
+ if (!cost || !(sample >= 0.0) || !isfinite(sample)) return;
+ if (allocated && cost->skips < RENDER_PASS_ALLOC_SKIPS) {
+ cost->skips++;
+ return;
+ }
+ cost->skips = 0;
+ cost->unsampled = 0;
+ if (cost->kept < RENDER_PASS_COST_WARMUP) {
+ cost->warm[cost->kept++] = sample;
+ if (cost->kept == RENDER_PASS_COST_WARMUP) {
+ /* Median of the warm-up samples (insertion sort of three). */
+ double w[RENDER_PASS_COST_WARMUP];
+ memcpy(w, cost->warm, sizeof w);
+ for (unsigned i = 1; i < RENDER_PASS_COST_WARMUP; i++)
+ for (unsigned j = i; j > 0 && w[j - 1] > w[j]; j--) {
+ double t = w[j]; w[j] = w[j - 1]; w[j - 1] = t;
+ }
+ cost->ema = w[RENDER_PASS_COST_WARMUP / 2u];
+ if (cost->rewarm_from > 0.0) {
+ /* A re-measure: wait longer before the next one unless it
+ * found the old estimate stale. */
+ unsigned limit = cost->rewarm_after ? cost->rewarm_after
+ : RENDER_PASS_REWARM_MIN;
+ if (cost->ema <= cost->rewarm_from * RENDER_PASS_REWARM_STALE)
+ cost->rewarm_after = 0;
+ else
+ cost->rewarm_after = limit >= RENDER_PASS_REWARM_MAX / 2u
+ ? RENDER_PASS_REWARM_MAX : limit * 2u;
+ cost->rewarm_from = 0.0;
+ }
+ }
+ return;
+ }
+ cost->ema = render_pass_ema(cost->ema, sample);
+}
+
+double render_pass_cost_estimate(const RenderPassCost *cost) {
+ if (!cost || cost->kept < RENDER_PASS_COST_WARMUP) return 0.0;
+ return cost->ema;
+}
+
+int render_pass_cost_note_plan(RenderPassCost *cost) {
+ unsigned limit;
+ /* Warming up: its one-pass plans are what measures the cost. */
+ if (!cost || cost->kept < RENDER_PASS_COST_WARMUP) return 0;
+ limit = cost->rewarm_after ? cost->rewarm_after : RENDER_PASS_REWARM_MIN;
+ if (++cost->unsampled < limit) return 0;
+ /* No pass has been measured against the estimate for `limit` plans:
+ * measure again (render_pass_cost_add sets the next wait). */
+ cost->rewarm_from = cost->ema;
+ cost->kept = 0;
+ cost->skips = 0;
+ cost->unsampled = 0;
+ return 1;
+}
+
+double render_pass_budget(double idle_ticks, double pass_ticks,
+ double frame_length, double share) {
+ double b;
+ if (!(frame_length > 0.0)) return 0.0;
+ if (!(share > 0.0)) return 0.0;
+ if (share > 1.0) share = 1.0;
+ if (!(idle_ticks > 0.0) && !(pass_ticks > 0.0))
+ return frame_length * share;
+ b = ((idle_ticks > 0.0 ? idle_ticks : 0.0) +
+ (pass_ticks > 0.0 ? pass_ticks : 0.0)) * share;
+ if (b > frame_length) b = frame_length;
+ return b;
+}
+
+int render_pass_mmio_class(uint32_t phys, uint32_t val, uint32_t width) {
+ if (phys == 0x1F801810u) return width == 4 ? -1 : RENDER_PASS_DROP_GPU;
+ if (phys == 0x1F801814u) {
+ uint32_t cmd = val >> 24;
+ return (width == 4 && (cmd == 0x04u || cmd == 0x10u))
+ ? -1 : RENDER_PASS_DROP_GPU;
+ }
+ if (phys >= 0x1F801070u && phys <= 0x1F801077u) return -1;
+ if ((phys >= 0x1F8010A0u && phys <= 0x1F8010AFu) || /* ch2 GPU */
+ (phys >= 0x1F8010E0u && phys <= 0x1F8010EFu) || /* ch6 OTC */
+ (phys >= 0x1F8010F0u && phys <= 0x1F8010F7u)) /* DPCR/DICR */
+ return -1;
+ if (phys >= 0x1F801080u && phys <= 0x1F8010FFu) return RENDER_PASS_DROP_DMA;
+ if (phys >= 0x1F801C00u && phys <= 0x1F801FFFu) return RENDER_PASS_DROP_SPU;
+ if (phys >= 0x1F801800u && phys <= 0x1F801803u) return RENDER_PASS_DROP_CD;
+ if (phys >= 0x1F801100u && phys <= 0x1F80112Fu) return RENDER_PASS_DROP_TIMER;
+ return RENDER_PASS_DROP_OTHER;
+}
+
+int render_pass_store_to(const RenderPassStoreTarget *t, uint32_t addr,
+ uint32_t val, uint32_t width) {
+ uint32_t phys;
+ if (addr >= 0xC0000000u) return RENDER_PASS_DROP_OTHER; /* KSEG2 */
+ if (t->isolate_cache) return -1; /* cache-only store */
+ phys = addr & 0x1FFFFFFFu;
+ /* The 8 MiB DRAM decode window folds through the live geometry, exactly
+ * as psx_ram_map_write: retail mirrors 2 MiB four times, the 8 MB RAM
+ * map is unique. */
+ if (phys < 0x00800000u) phys &= t->ram_size - 1u;
+ if (phys < t->ram_size) {
+ for (uint32_t i = 0; i < width; i++)
+ t->ram[phys + i] = (uint8_t)(val >> (8u * i));
+ return -1;
+ }
+ if (phys >= 0x1F800000u && phys < 0x1F800000u + t->scratchpad_size) {
+ uint32_t off = phys - 0x1F800000u;
+ for (uint32_t i = 0; i < width && off + i < t->scratchpad_size; i++)
+ t->scratchpad[off + i] = (uint8_t)(val >> (8u * i));
+ return -1;
+ }
+ if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) {
+ int cls = render_pass_mmio_class(phys, val, width);
+ if (cls >= 0) return cls;
+ t->mmio_write(phys, val, width);
+ return -1;
+ }
+ /* Mod memory, expansion, ROM, unmapped: never written by a pass (mod
+ * arenas are not part of the pass restore). */
+ return RENDER_PASS_DROP_OTHER;
+}
+
+int render_pass_vram_policy(const RenderPassJournal *j, int px, int py,
+ int pw, int ph, int vram_w, int vram_h,
+ int can_journal, int *x, int *y, int *w, int *h) {
+ if (*w <= 0 || *h <= 0) return RENDER_PASS_VRAM_ALLOW;
+ if (*x < 0) { *w += *x; *x = 0; }
+ if (*y < 0) { *h += *y; *y = 0; }
+ if (*x + *w > vram_w) *w = vram_w - *x;
+ if (*y + *h > vram_h) *h = vram_h - *y;
+ if (*w <= 0 || *h <= 0) return RENDER_PASS_VRAM_ALLOW;
+ if (*x >= px && *y >= py && *x + *w <= px + pw && *y + *h <= py + ph)
+ return RENDER_PASS_VRAM_ALLOW;
+ if (!can_journal) return RENDER_PASS_VRAM_REFUSE;
+ for (int i = 0; i < j->n; i++)
+ if (*x >= j->x[i] && *y >= j->y[i] && *x + *w <= j->x[i] + j->w[i] &&
+ *y + *h <= j->y[i] + j->h[i])
+ return RENDER_PASS_VRAM_ALLOW; /* already covered */
+ return j->n < RENDER_PASS_JOURNAL_MAX ? RENDER_PASS_VRAM_JOURNAL
+ : RENDER_PASS_VRAM_REFUSE;
+}
+
+int render_pass_journal_add(RenderPassJournal *j, const uint16_t *vram,
+ int vram_w, int x, int y, int w, int h) {
+ int i = j->n;
+ size_t need = (size_t)w * (size_t)h;
+ if (i >= RENDER_PASS_JOURNAL_MAX || w <= 0 || h <= 0) return -1;
+ if (j->cap[i] < need) {
+ uint16_t *p = (uint16_t *)realloc(j->rows[i], need * sizeof(uint16_t));
+ if (!p) return -1;
+ j->rows[i] = p;
+ j->cap[i] = need;
+ }
+ for (int row = 0; row < h; row++)
+ memcpy(j->rows[i] + (size_t)row * (size_t)w,
+ vram + (size_t)(y + row) * (size_t)vram_w + (size_t)x,
+ (size_t)w * sizeof(uint16_t));
+ j->x[i] = x; j->y[i] = y; j->w[i] = w; j->h[i] = h;
+ j->n = i + 1;
+ return i;
+}
+
+void render_pass_journal_rollback(RenderPassJournal *j, uint16_t *vram,
+ int vram_w) {
+ for (int i = j->n - 1; i >= 0; i--)
+ for (int row = 0; row < j->h[i]; row++)
+ memcpy(vram + (size_t)(j->y[i] + row) * (size_t)vram_w +
+ (size_t)j->x[i],
+ j->rows[i] + (size_t)row * (size_t)j->w[i],
+ (size_t)j->w[i] * sizeof(uint16_t));
+ j->n = 0;
+}
+
+void render_pass_journal_free(RenderPassJournal *j) {
+ for (int i = 0; i < RENDER_PASS_JOURNAL_MAX; i++) {
+ free(j->rows[i]);
+ j->rows[i] = NULL;
+ j->cap[i] = 0;
+ }
+ j->n = 0;
+}
diff --git a/runtime/tests/psx_ram_runtime_map_stubs.c b/runtime/tests/psx_ram_runtime_map_stubs.c
index 0c09a4de..a3f8bd41 100644
--- a/runtime/tests/psx_ram_runtime_map_stubs.c
+++ b/runtime/tests/psx_ram_runtime_map_stubs.c
@@ -96,6 +96,34 @@ void gpu_ws_end_linked_list(void) {}
void gpu_ws_prepass_linked_list(uint32_t a) { (void)a; }
void gpu_ws_restore_linked_list_rank(uint32_t r) { (void)r; }
+/* ---- render passes (render_pass.c; never active in this test) ------------- */
+/* memory.c routes stores through the pass store policy and dma.c walks a GPU
+ * linked list synchronously while a pass runs; with the flag at 0 neither
+ * path is taken, so the doubles trap if they ever are. */
+int g_psx_render_pass_active;
+int render_pass_store_to(const void *t, uint32_t a, uint32_t v, uint32_t w) {
+ (void)t; (void)a; (void)v; (void)w; __builtin_trap();
+}
+void dma_gpu_ll_advance(void *s, uint32_t c, const void *ops, void *o) {
+ (void)s; (void)c; (void)ops; (void)o; __builtin_trap();
+}
+void gpu_set_gp0_linked_list_node(uint32_t a, uint32_t n) { (void)a; (void)n; }
+void gpu_ws_validate_linked_list_header(uint32_t a, uint32_t h) { (void)a; (void)h; }
+void gpu_ws_validate_linked_list_node(uint32_t a, uint32_t n) { (void)a; (void)n; }
+/* MMIO readers: the render-pass store path in memory.c keeps them in this
+ * link (mingw-w64 GCC 15 + LTO fails without them). No test access reaches
+ * MMIO, so these trap too. */
+void debug_server_trace_mmio_read(uint32_t a, uint32_t v, uint8_t w) {
+ (void)a; (void)v; (void)w; __builtin_trap();
+}
+uint32_t cdrom_read(uint32_t a) { (void)a; __builtin_trap(); }
+uint32_t mdec_read(uint32_t a) { (void)a; __builtin_trap(); }
+uint32_t gpu_read_gpustat(void) { __builtin_trap(); }
+uint32_t spu_read(uint32_t a) { (void)a; __builtin_trap(); }
+uint32_t timers_read(uint32_t a) { (void)a; __builtin_trap(); }
+uint32_t sio_read(uint32_t a) { (void)a; __builtin_trap(); }
+void sio_tick(int c) { (void)c; __builtin_trap(); }
+
/* ---- device snapshot sections (fixed-size doubles; readers count) --------- */
#define STUB_SECTION(name, size) \
uint32_t name##_snapshot_bytes(void) { return (size); } \
diff --git a/runtime/tests/run_gl_readback_region.py b/runtime/tests/run_gl_readback_region.py
index 5f4e2a4b..028389be 100644
--- a/runtime/tests/run_gl_readback_region.py
+++ b/runtime/tests/run_gl_readback_region.py
@@ -66,7 +66,11 @@ def main():
return result
fixture = args.fixture or framework / "runtime/tests/test_gl_readback_region.c"
- for name, source in [("probe", fixture), ("sw", framework / "runtime/src/gpu_sw_renderer.c")]:
+ # The renderer's render-pass paths call the journal and VRAM policy in
+ # render_pass_plan.c (self-contained: no other runtime symbols).
+ sources = [("probe", fixture), ("sw", framework / "runtime/src/gpu_sw_renderer.c"),
+ ("rp", framework / "runtime/src/render_pass_plan.c")]
+ for name, source in sources:
if run([compiler / "gcc.exe", "-std=c11", "-O2", "-flto", "-DPSX_SDL3=1",
"-DPSX_NO_DEBUG_TOOLS=1", *includes, "-c", source,
"-o", dest / (name + ".o")]).returncode:
@@ -74,7 +78,7 @@ def main():
libraries = ["m", "kernel32", "user32", "gdi32", "winmm", "imm32", "ole32",
"oleaut32", "version", "uuid", "advapi32", "setupapi", "shell32",
"dinput8", "opengl32"]
- if run([compiler / "g++.exe", "-O2", "-flto", dest / "probe.o", dest / "sw.o",
+ if run([compiler / "g++.exe", "-O2", "-flto", *[dest / (name + ".o") for name, _ in sources],
sdl_library, *["-l" + name for name in libraries],
"-o", dest / "probe.exe"]).returncode:
return 2
diff --git a/runtime/tests/test_mod_owned_display_controls.py b/runtime/tests/test_mod_owned_display_controls.py
index 336c739e..a4a02191 100644
--- a/runtime/tests/test_mod_owned_display_controls.py
+++ b/runtime/tests/test_mod_owned_display_controls.py
@@ -127,6 +127,7 @@ for reset in (
"g_bezel_path.clear();",
"g_frame_interpolation_blend = g_frame_interpolation_blend_default;",
"g_frame_interpolation_source = PSX_MOD_FRAME_SOURCE_VBLANK;",
+ "render_pass_reset_session();",
):
assert reset in after, f"mod-owned session reset is missing: {reset}"
literal = re.fullmatch(r"(g_\w+) = (false|true|\d+);", reset)
diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c
new file mode 100644
index 00000000..f9c57435
--- /dev/null
+++ b/runtime/tests/test_render_pass_abort.c
@@ -0,0 +1,383 @@
+/* Render-pass watchdog abort (render_pass.c psx_mod_render_pass).
+ *
+ * A pass that runs away is cut off by a longjmp from the frozen clock's
+ * watchdog, deep inside nested guest frames. That longjmp skips every exit
+ * those frames would have run: generated functions' bb_defer cleanup,
+ * overlay_loader_call_native's unit depth, the DMA kick's exec depth, the
+ * interpreter's active/phase/precise flags and pending load, the native-shard
+ * stack and its cycle flush hook. The live game must resume exactly as if
+ * the pass never happened, so this test aborts a pass from such frames and
+ * checks each of those values, the clock and RAM afterwards. The GPU, VRAM,
+ * device snapshots and presenter are mocks; render_pass.c and psx_cycles.c
+ * are the real ones. Build/run: ctest -R render_pass_abort_test */
+
+#include "render_pass.h"
+
+#include
+#include
+#include
+#include
+
+#include "cpu_state.h"
+#include "dirty_ram_interp.h"
+#include "mod_plugins.h"
+#include "psx_cyc.h"
+
+static int failures;
+#define CHECK(c, m) do { if (!(c)) { fprintf(stderr, "FAIL: %s\n", m); failures++; } } while (0)
+
+/* ---- runtime globals the two units share with the rest of the runtime -- */
+int g_ls_replay_active = 0;
+int g_ls_mode = 0;
+int g_precise_mode = 0;
+int g_psx_call_bail = 0;
+int g_psx_dispatch_depth = 0;
+uint32_t i_stat = 0, i_mask = 0;
+uint64_t g_guest_store_count = 0;
+uint64_t g_mmio_access_count = 0;
+uint32_t g_debug_last_store_pc = 0;
+uint32_t g_debug_current_func_addr = 0;
+uint32_t g_psx_icache_tv[1024];
+uint64_t g_render_pass_dropped_writes[RENDER_PASS_DROP_CLASSES];
+void (*g_overlay_flush_pending_cycles)(void) = NULL;
+int g_call_unit_depth = 0;
+int g_dma_exec_depth = 0;
+int g_dma_cur_ch = -1;
+uint32_t g_dma_cur_madr = 0, g_dma_cur_bcr = 0, g_dma_initiator_pc = 0;
+int g_dirty_interp_active = 0;
+int g_exec_phase = 0;
+uint32_t g_dirty_safe_resume_pc = 0;
+
+/* ---- mock devices and memory ------------------------------------------ */
+static uint8_t s_ram[2u * 1024u * 1024u];
+static uint8_t s_spad[1024];
+static uint8_t s_dma_regs[64];
+static uint32_t s_csv = 1234;
+static uint16_t s_tc[3] = {1, 2, 3}, s_tt[3] = {4, 5, 6};
+static uint32_t s_tm[3] = {7, 8, 9}, s_tf[3];
+static int32_t s_ti[3];
+
+uint8_t *memory_get_ram_ptr(void) { return s_ram; }
+uint32_t memory_get_ram_bytes(void) { return (uint32_t)sizeof s_ram; }
+uint8_t *memory_get_scratchpad_ptr(void) { return s_spad; }
+uint32_t dma_snapshot_bytes(void) { return sizeof s_dma_regs; }
+void dma_snapshot_write(uint8_t *p) { memcpy(p, s_dma_regs, sizeof s_dma_regs); }
+int dma_snapshot_read(const uint8_t *p, uint32_t len) {
+ if (len != sizeof s_dma_regs) return 0;
+ memcpy(s_dma_regs, p, len);
+ return 1;
+}
+int dma_gpu_linked_list_active(void) { return 0; }
+uint32_t interrupts_get_cycles_since_vblank(void) { return s_csv; }
+void interrupts_set_cycles_since_vblank(uint32_t v) { s_csv = v; }
+void psx_irq_refresh_cause_ip2(void) {}
+void timers_get_snapshot(uint16_t c[3], uint32_t m[3], uint16_t t[3],
+ int32_t irq[3], uint32_t f[3]) {
+ memcpy(c, s_tc, sizeof s_tc); memcpy(m, s_tm, sizeof s_tm);
+ memcpy(t, s_tt, sizeof s_tt); memcpy(irq, s_ti, sizeof s_ti);
+ memcpy(f, s_tf, sizeof s_tf);
+}
+void timers_set_snapshot(const uint16_t c[3], const uint32_t m[3],
+ const uint16_t t[3], const int32_t irq[3],
+ const uint32_t f[3]) {
+ memcpy(s_tc, c, sizeof s_tc); memcpy(s_tm, m, sizeof s_tm);
+ memcpy(s_tt, t, sizeof s_tt); memcpy(s_ti, irq, sizeof s_ti);
+ memcpy(s_tf, f, sizeof s_tf);
+}
+uint32_t cdrom_snapshot_bytes(void) { return 0; }
+void cdrom_snapshot_write(uint8_t *p) { (void)p; }
+uint32_t spu_snapshot_bytes(void) { return 0; }
+void spu_snapshot_write(uint8_t *p) { (void)p; }
+uint32_t sio_snapshot_bytes(void) { return 0; }
+void sio_snapshot_write(uint8_t *p) { (void)p; }
+uint32_t mdec_snapshot_bytes(void) { return 0; }
+void mdec_snapshot_write(uint8_t *p) { (void)p; }
+
+int psx_get_in_exception(void) { return 0; }
+int psx_netplay_active(void) { return 0; }
+int psx_netplay_is_resimulating(void) { return 0; }
+int psx_selfcheck_resim_active(void) { return 0; }
+int psx_selfcheck_enabled(void) { return 0; }
+int psx_rewind_is_open(void) { return 0; }
+
+/* psx_cycles.c device side: nothing may advance inside a pass. */
+static uint64_t s_dev_cycles;
+void sio_advance(uint32_t c) { s_dev_cycles += c; }
+void cdrom_advance(uint32_t c) { s_dev_cycles += c; }
+void dma_advance(uint32_t c) { s_dev_cycles += c; }
+void timers_advance(uint32_t c) { s_dev_cycles += c; }
+void interrupts_advance_cycles(uint32_t c) { s_dev_cycles += c; }
+void interrupts_service_scheduled_events(void) {}
+uint32_t interrupts_cycles_to_vblank(void) { return 564480u; }
+uint32_t timers_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; }
+uint32_t cdrom_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; }
+uint32_t dma_cycles_to_internal_event(void) { return UINT32_MAX; }
+uint32_t dma_cycles_to_deliverable_irq(uint32_t m) { (void)m; return UINT32_MAX; }
+uint32_t sio_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; }
+uint32_t psx_spu_sample_event_cycles_to_next(void) { return UINT32_MAX; }
+void psx_spu_sample_event_service(void) {}
+void starvation_watchdog_check(void) {}
+void starvation_ring_pc_sample(void) {}
+void dirty_ram_irq_ambient_resync_after_restore(void) {}
+
+/* The interpreter's deferred load and the native-shard stack live in those
+ * modules' statics; model them here the same way. */
+static DirtyRamLoadDelay s_ld;
+void dirty_ram_ld_delay_discard(void) { memset(&s_ld, 0, sizeof s_ld); }
+void dirty_ram_ld_delay_save(DirtyRamLoadDelay *o) { *o = s_ld; }
+void dirty_ram_ld_delay_restore(const DirtyRamLoadDelay *i) { s_ld = *i; }
+static int s_ov_active_depth;
+static uint32_t s_ov_inprogress;
+void overlay_loader_native_nesting(int *d, uint32_t *ip) {
+ *d = s_ov_active_depth; *ip = s_ov_inprogress;
+}
+void overlay_loader_set_native_nesting(int d, uint32_t ip) {
+ s_ov_active_depth = d; s_ov_inprogress = ip;
+}
+
+/* GPU and presenter. */
+uint64_t gpu_pass_state_hash(void) { return 42; }
+int gpu_pass_checkpoint_save(void) { return 1; }
+void gpu_pass_checkpoint_restore(void) {}
+static uint64_t s_ticks;
+uint64_t gl_renderer_perf_ticks(void) { return s_ticks += 1000; }
+uint64_t gl_renderer_perf_frequency(void) { return 1000000000u; }
+uint32_t gl_renderer_pass_unavailable(void) { return 0; }
+int psx_presentation_fast_forward(void) { return 0; }
+uint32_t gl_renderer_pass_plan(uint32_t p, uint32_t s, uint32_t *a,
+ uint32_t max, uint32_t *wanted) {
+ (void)p; (void)s; (void)a; (void)max;
+ if (wanted) *wanted = 0;
+ return 0;
+}
+static int s_open_passes, s_kept;
+int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen,
+ uint32_t period, int reuse_backup) {
+ (void)x; (void)y; (void)w; (void)h; (void)open_gen; (void)period;
+ (void)reuse_backup;
+ s_open_passes++;
+ return 1;
+}
+void gl_renderer_pass_end(uint32_t alpha_q16, int keep) {
+ (void)alpha_q16;
+ s_open_passes--;
+ if (keep) s_kept++;
+}
+uint32_t gl_renderer_pass_leaks(void) { return 0; }
+int gl_renderer_pass_verify_vram(void) { return 1; }
+void gl_renderer_pass_note_cost(uint64_t t) { (void)t; }
+void gl_renderer_pass_service_presents(void) {}
+
+/* ---- an overlay shard's cycle shim (overlay_dispatch_preamble.c.inc) --- */
+static uint32_t s_shard_pending;
+static void shard_flush(void) {
+ uint32_t c = s_shard_pending;
+ s_shard_pending = 0;
+ if (c) psx_advance_cycles(c);
+}
+static void live_flush(void) {}
+
+/* ---- guest frames ------------------------------------------------------ */
+typedef struct Frames {
+ int depth; /* nested frames to open */
+ int run_away; /* innermost frame spins until the watchdog fires */
+} Frames;
+
+/* One generated function that calls through a native overlay unit, a DMA
+ * kick and the interpreter, each with the runtime's own entry/exit protocol,
+ * then recurses. */
+static void guest_frame(CPUState *cpu, const Frames *f, int level) {
+ __attribute__((cleanup(psx_cyc_bb_defer_cleanup))) int guard = 1;
+ int prev_unit = g_call_unit_depth, prev_active = g_dirty_interp_active;
+ int prev_phase = g_exec_phase, prev_precise = g_precise_mode;
+ int prev_ov_depth = s_ov_active_depth;
+ uint32_t prev_ov_ip = s_ov_inprogress;
+ void (*prev_flush)(void) = g_overlay_flush_pending_cycles;
+ (void)guard;
+ psx_cyc_bb_defer_begin();
+ g_call_unit_depth = prev_unit + 1; /* overlay_loader_call_native */
+ s_ov_active_depth = prev_ov_depth + 1; /* overlay_loader_dispatch */
+ s_ov_inprogress = 0x80100000u + (uint32_t)level;
+ g_overlay_flush_pending_cycles = shard_flush;
+ g_exec_phase = 2;
+ g_dma_exec_depth++; /* dma.c kick */
+ g_dma_cur_ch = 2;
+ g_dirty_interp_active = 1; /* dirty_ram_dispatch */
+ g_precise_mode = 1; /* psx_run_precise */
+ s_ld.armed = 1; s_ld.rt = 8; s_ld.val = 0xDEAD0000u + (uint32_t)level;
+ s_shard_pending = 777; /* shard-side charges */
+ s_ram[0x1000 + level] = 0xAA; /* the pass draws */
+ cpu->gpr[8] = 0x1234u + (uint32_t)level;
+
+ if (level + 1 < f->depth) {
+ guest_frame(cpu, f, level + 1);
+ } else if (f->run_away) {
+ /* A wait loop that never ends in frozen time: charges per
+ * instruction, publishes at every branch edge. */
+ for (uint32_t i = 0; i < 100000000u; i++) {
+ psx_cyc_charge(4);
+ psx_cyc_bb_defer_flush();
+ }
+ }
+
+ s_ld.armed = 0; /* retired on interpreter exit */
+ g_precise_mode = prev_precise;
+ g_dirty_interp_active = prev_active;
+ g_dma_exec_depth--;
+ g_dma_cur_ch = -1;
+ g_exec_phase = prev_phase;
+ shard_flush();
+ g_overlay_flush_pending_cycles = prev_flush;
+ s_ov_inprogress = prev_ov_ip;
+ s_ov_active_depth = prev_ov_depth;
+ g_call_unit_depth = prev_unit;
+}
+
+static int pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) {
+ (void)alpha_q16;
+ guest_frame(cpu, (const Frames *)user, 0);
+ return 1;
+}
+
+static int leaky_pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) {
+ (void)cpu; (void)user; (void)alpha_q16;
+ g_call_unit_depth++; /* forgets its exit */
+ return 1;
+}
+
+typedef struct Live {
+ int bb_defer, unit, dma, dma_ch, active, phase, precise, dispatch;
+ int ov_depth;
+ uint32_t ov_ip, batch, resume;
+ void (*flush)(void);
+ DirtyRamLoadDelay ld;
+ uint64_t cycles, next_service, dev_cycles;
+} Live;
+
+static void snap(Live *l) {
+ memset(l, 0, sizeof *l);
+ l->bb_defer = g_psx_cyc_bb_defer;
+ l->unit = g_call_unit_depth;
+ l->dma = g_dma_exec_depth;
+ l->dma_ch = g_dma_cur_ch;
+ l->active = g_dirty_interp_active;
+ l->phase = g_exec_phase;
+ l->precise = g_precise_mode;
+ l->dispatch = g_psx_dispatch_depth;
+ l->ov_depth = s_ov_active_depth;
+ l->ov_ip = s_ov_inprogress;
+ l->batch = g_psx_cyc_batch;
+ l->resume = g_dirty_safe_resume_pc;
+ l->flush = g_overlay_flush_pending_cycles;
+ l->ld = s_ld;
+ l->cycles = psx_cycle_count;
+ l->next_service = psx_next_service_cycle;
+ l->dev_cycles = s_dev_cycles;
+}
+
+static void check_live(const Live *a, const char *when) {
+ Live b;
+ char m[160];
+ snap(&b);
+#define SAME(field, what) do { \
+ snprintf(m, sizeof m, "%s: %s restored", when, what); \
+ CHECK(a->field == b.field, m); } while (0)
+ SAME(bb_defer, "g_psx_cyc_bb_defer");
+ SAME(unit, "g_call_unit_depth");
+ SAME(dma, "g_dma_exec_depth");
+ SAME(dma_ch, "g_dma_cur_ch");
+ SAME(active, "g_dirty_interp_active");
+ SAME(phase, "g_exec_phase");
+ SAME(precise, "g_precise_mode");
+ SAME(dispatch, "g_psx_dispatch_depth");
+ SAME(ov_depth, "native-shard stack depth");
+ SAME(ov_ip, "native in-progress entry");
+ SAME(batch, "pending cycle batch");
+ SAME(resume, "interpreter resume latch");
+ SAME(flush, "shard cycle-flush hook");
+ SAME(ld.armed, "pending load (armed)");
+ SAME(ld.val, "pending load (value)");
+ SAME(cycles, "guest clock");
+ SAME(next_service, "service deadline");
+ SAME(dev_cycles, "device time (nothing advanced)");
+#undef SAME
+}
+
+int main(void) {
+ CPUState cpu;
+ PSXModRenderPass pass;
+ RenderPassStats st;
+ Frames runaway = {5, 1}, clean = {5, 0};
+ Live live;
+ uint8_t ram_before[64];
+
+#ifdef _WIN32
+ _putenv_s("PSX_RENDER_PASS_VERIFY", "1");
+#else
+ setenv("PSX_RENDER_PASS_VERIFY", "1", 1);
+#endif
+ memset(&cpu, 0, sizeof cpu);
+ cpu.gpr[8] = 0x55u;
+ psx_cycles_resync_after_restore(NULL);
+ psx_advance_cycles(50000);
+
+ /* The live context the pass interrupts: inside two generated functions
+ * (the game's VSync entry hook), a native shard's flush hook installed,
+ * the interpreter's resume latch and a pending load of its own. */
+ g_psx_cyc_bb_defer = 2;
+ g_psx_cyc_batch = 9;
+ g_psx_dispatch_depth = 3;
+ g_exec_phase = 3;
+ g_dirty_safe_resume_pc = 0x80012344u;
+ g_overlay_flush_pending_cycles = live_flush;
+ s_ld.armed = 1; s_ld.rt = 3; s_ld.val = 0x11112222u; s_ld.age = 1;
+ s_ov_active_depth = 1;
+ s_ov_inprogress = 0x80110000u;
+ memcpy(ram_before, s_ram + 0x1000, sizeof ram_before);
+
+ memset(&pass, 0, sizeof pass);
+ pass.struct_size = sizeof pass;
+ pass.w = 320; pass.h = 240;
+ pass.alpha_q16 = 32768u;
+
+ /* 1. A pass that runs away inside five nested frames. */
+ snap(&live);
+ CHECK(psx_mod_render_pass(&cpu, &pass, pass_fn, &runaway) == 0,
+ "a runaway pass is rolled back, not presented");
+ render_pass_get_stats(&st);
+ CHECK(st.watchdog == 1 && st.aborted == 1 && st.passes == 0,
+ "the watchdog cut it off once");
+ CHECK(st.nesting_repairs == 1, "the landing found and undid skipped exits");
+ CHECK(!st.disabled, "one fault does not disable passes");
+ CHECK(s_open_passes == 0 && s_kept == 0, "the presenter closed the pass, no image");
+ CHECK(!g_psx_render_pass_active, "time is live again");
+ check_live(&live, "after a watchdog abort");
+ CHECK(s_shard_pending == 0,
+ "the shard's unpublished pass cycles went into the frozen clock");
+ CHECK(memcmp(ram_before, s_ram + 0x1000, sizeof ram_before) == 0,
+ "RAM the pass wrote is restored");
+ CHECK(cpu.gpr[8] == 0x55u, "CPU registers are restored");
+ CHECK(st.verify_mismatch == 0,
+ "PSX_RENDER_PASS_VERIFY: machine state identical after the abort");
+
+ /* 2. The same frames, returning normally, present an image. */
+ snap(&live);
+ CHECK(psx_mod_render_pass(&cpu, &pass, pass_fn, &clean) == 1,
+ "a pass that completes is presented");
+ render_pass_get_stats(&st);
+ CHECK(st.passes == 1 && s_kept == 1, "one image kept");
+ CHECK(st.nesting_repairs == 1, "a normal return needs no repair");
+ check_live(&live, "after a normal pass");
+ CHECK(st.verify_mismatch == 0, "a balanced pass verifies clean");
+
+ /* 3. Verify mode flags a pass that leaves the nesting unbalanced (the
+ * restore would otherwise hide it), and still restores it. */
+ snap(&live);
+ (void)psx_mod_render_pass(&cpu, &pass, leaky_pass_fn, NULL);
+ render_pass_get_stats(&st);
+ CHECK(st.verify_mismatch == 1, "an unbalanced pass is reported in verify mode");
+ check_live(&live, "after an unbalanced pass");
+
+ printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures);
+ return failures ? 1 : 0;
+}
diff --git a/runtime/tests/test_render_pass_freeze.c b/runtime/tests/test_render_pass_freeze.c
new file mode 100644
index 00000000..061d8a0e
--- /dev/null
+++ b/runtime/tests/test_render_pass_freeze.c
@@ -0,0 +1,135 @@
+/* Render-pass time freeze (psx_cycle_freeze.h psx_cycle_freeze_begin/end).
+ * A pass counts guest cycles but never services a device, and afterwards
+ * every clock value is exactly as before. Build/run:
+ * ctest -R render_pass_freeze_test */
+
+#include "psx_cycles.h"
+#include "psx_cycle_freeze.h"
+
+#include
+#include
+#include
+
+int g_ls_replay_active = 0;
+int g_ls_mode = 0;
+int g_precise_mode = 0;
+int g_psx_call_bail = 0;
+uint32_t i_mask = 0;
+uint64_t g_guest_store_count = 0;
+uint64_t g_mmio_access_count = 0;
+
+/* Mock devices: count every advance and event. The "CD" raises an event
+ * every 1000 cycles; the VBlank every 564480. */
+static uint64_t s_dev_cycles = 0;
+static uint64_t s_dev_calls = 0;
+static uint64_t s_cd_events = 0;
+static uint32_t s_cd_phase = 0;
+static uint64_t s_vblank_phase = 0;
+
+void sio_advance(uint32_t cycles) { (void)cycles; }
+void cdrom_advance(uint32_t cycles) {
+ s_dev_calls++;
+ s_dev_cycles += cycles;
+ s_cd_phase += cycles;
+ while (s_cd_phase >= 1000u) { s_cd_phase -= 1000u; s_cd_events++; }
+}
+void dma_advance(uint32_t cycles) { (void)cycles; }
+void timers_advance(uint32_t cycles) { (void)cycles; }
+void interrupts_advance_cycles(uint32_t cycles) { s_vblank_phase += cycles; }
+void interrupts_service_scheduled_events(void) {}
+
+uint32_t interrupts_cycles_to_vblank(void) {
+ return (uint32_t)(564480u - (s_vblank_phase % 564480u));
+}
+uint32_t timers_cycles_to_irq(uint32_t mask) { (void)mask; return UINT32_MAX; }
+uint32_t cdrom_cycles_to_irq(uint32_t mask) {
+ (void)mask;
+ return 1000u - s_cd_phase;
+}
+uint32_t dma_cycles_to_internal_event(void) { return UINT32_MAX; }
+uint32_t dma_cycles_to_deliverable_irq(uint32_t mask) { (void)mask; return UINT32_MAX; }
+uint32_t sio_cycles_to_irq(uint32_t mask) { (void)mask; return UINT32_MAX; }
+uint32_t psx_spu_sample_event_cycles_to_next(void) { return UINT32_MAX; }
+void psx_spu_sample_event_service(void) {}
+int psx_get_in_exception(void) { return 0; }
+void starvation_watchdog_check(void) {}
+void starvation_ring_pc_sample(void) {}
+int psx_netplay_active(void) { return 0; }
+int psx_selfcheck_enabled(void) { return 0; }
+void dirty_ram_ld_delay_discard(void) {}
+void dirty_ram_irq_ambient_resync_after_restore(void) {}
+
+static int failures;
+#define CHECK(c, m) do { if (!(c)) { fprintf(stderr, "FAIL: %s\n", m); failures++; } } while (0)
+
+static jmp_buf s_jmp;
+static int s_overruns;
+static void overrun(void) { s_overruns++; longjmp(s_jmp, 1); }
+
+int main(void) {
+ PsxCycleFreeze save;
+ uint64_t cyc0, next0, dev_calls0, dev_cycles0, cd0;
+ uint32_t local = 7;
+
+ psx_cycles_resync_after_restore(NULL);
+ psx_advance_cycles(12345); /* some live history */
+ g_psx_cyc_batch = 40; /* deferred charges of the guest code */
+ g_psx_cyc_local_acc = &local;
+ cyc0 = psx_cycle_count;
+ next0 = psx_next_service_cycle;
+ dev_calls0 = s_dev_calls;
+ dev_cycles0 = s_dev_cycles;
+ cd0 = s_cd_events;
+
+ CHECK(psx_cycle_freeze_begin(&save, 0, NULL), "freeze begins");
+ CHECK(g_psx_render_pass_active == 1, "pass flag set");
+ CHECK(!psx_cycle_freeze_begin(&save, 0, NULL), "freezes do not nest");
+ CHECK(g_psx_cyc_batch == 0 && g_psx_cyc_local_acc == NULL,
+ "the guest's pending charges are parked, not published");
+ for (int i = 0; i < 1000; i++) psx_advance_cycles(1000); /* 10^6 cycles */
+ psx_advance_cycles_slow(5000);
+ psx_devices_service_to_now();
+ psx_devices_mmio_sync();
+ CHECK(psx_cycle_count - cyc0 == 1005000u,
+ "cycles are still counted inside the pass (GTE/muldiv deadlines)");
+ CHECK(s_dev_calls == dev_calls0 && s_dev_cycles == dev_cycles0 &&
+ s_cd_events == cd0,
+ "no device advanced and no device event fired in a pass");
+ psx_cycle_freeze_end(&save);
+ CHECK(g_psx_render_pass_active == 0, "pass flag cleared");
+ CHECK(psx_cycle_count == cyc0, "cycle count restored exactly");
+ CHECK(psx_next_service_cycle == next0, "service deadline restored exactly");
+ CHECK(g_psx_cyc_batch == 40 && g_psx_cyc_local_acc == &local && local == 7,
+ "the guest's pending charges come back untouched");
+
+ /* The live timeline continues exactly as if the pass never happened. */
+ g_psx_cyc_batch = 0;
+ g_psx_cyc_local_acc = NULL;
+ psx_advance_cycles(3000);
+ CHECK(s_cd_events == cd0 + (uint64_t)((12345u % 1000u + 3000u) / 1000u) ||
+ s_cd_events >= cd0 + 3u,
+ "devices resume on the live clock after the pass");
+
+ /* Watchdog: a pass that runs away is cut off. The longjmp skips the
+ * bb_defer exits of the generated frames it leaves (their cleanup
+ * handlers never run); end() puts the interrupted depth back. */
+ s_overruns = 0;
+ cyc0 = psx_cycle_count;
+ g_psx_cyc_bb_defer = 2;
+ if (setjmp(s_jmp) == 0) {
+ CHECK(psx_cycle_freeze_begin(&save, 200000u, overrun), "freeze with watchdog");
+ g_psx_cyc_bb_defer += 3; /* three nested generated frames */
+ for (int i = 0; i < 1000; i++) psx_advance_cycles(1000);
+ CHECK(0, "watchdog should have fired");
+ }
+ psx_cycle_freeze_end(&save);
+ CHECK(s_overruns == 1, "watchdog fired once");
+ CHECK(psx_cycle_count == cyc0 && !g_psx_render_pass_active,
+ "state restored after a watchdog abort");
+ CHECK(g_psx_cyc_bb_defer == 2,
+ "cycle deferral depth restored after a watchdog abort");
+ g_psx_cyc_bb_defer = 0;
+
+ printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures);
+ return failures ? 1 : 0;
+}
diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py
new file mode 100644
index 00000000..22a66c51
--- /dev/null
+++ b/runtime/tests/test_render_pass_guards.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+"""Pin the render-pass sandbox (docs/RENDER_PASSES.md) at its choke points.
+
+A render pass runs guest draw code in frozen time and restores the machine
+afterwards. That only holds if every path that could let time pass, deliver an
+interrupt, touch a device the restore does not cover, or record the pass into
+the live timeline checks g_psx_render_pass_active first. These are the choke
+points; each guard below names the hole it closes.
+"""
+
+from pathlib import Path
+import re
+
+ROOT = Path(__file__).resolve().parents[2]
+SRC = ROOT / "runtime" / "src"
+
+
+def body(text, signature):
+ """Return the first ~40 lines of the function whose definition starts with
+ `signature` (enough to see its early-return gates)."""
+ i = text.index(signature)
+ return "\n".join(text[i:].splitlines()[:40])
+
+
+cycles = (SRC / "psx_cycles.c").read_text(encoding="utf-8")
+b = body(cycles, "void psx_devices_service_to_now(void) {")
+assert re.search(r"if \(g_psx_render_pass_active\)[^\n]*return;", b), (
+ "device servicing must stop during a pass (time would advance)")
+b = body(cycles, "void psx_devices_mmio_sync(void) {")
+assert "g_psx_render_pass_active" in b, (
+ "MMIO sync must not catch devices up during a pass")
+b = body(cycles, "void psx_advance_cycles_slow(uint32_t cycles) {")
+assert "g_psx_render_pass_active" in b, (
+ "the slow/conservative cycle path must not advance devices during a pass")
+
+irq = (SRC / "interrupts.c").read_text(encoding="utf-8")
+for sig in ("int psx_interrupt_delivery_needed(const CPUState* cpu) {",
+ "void psx_check_interrupts(CPUState* cpu) {"):
+ lines = body(irq, sig).splitlines()[:6]
+ assert any("g_psx_render_pass_active" in l and "return" in l for l in lines), (
+ "no interrupt may be delivered inside a pass: " + sig)
+
+dma = (SRC / "dma.c").read_text(encoding="utf-8")
+assert "while (g_psx_render_pass_active && gpu_linked_list.active)" in dma, (
+ "GPU linked-list DMA must complete synchronously in a pass")
+b = body(dma, "static void schedule_delayed_complete(int ch, uint32_t total_words,")
+assert "g_psx_render_pass_active" in b, (
+ "delayed DMA completion would never arrive in frozen time")
+
+mem = (SRC / "memory.c").read_text(encoding="utf-8")
+for fn in ("void psx_write_word(uint32_t addr, uint32_t val) {",
+ "void psx_write_half(uint32_t addr, uint16_t val) {",
+ "void psx_write_byte(uint32_t addr, uint8_t val) {"):
+ lines = body(mem, fn).splitlines()[:6]
+ assert any("render_pass_store" in l for l in lines), (
+ "pass stores must bypass live-timeline observers: " + fn)
+b = body(mem, "static void render_pass_store(")
+assert "render_pass_store_to(&t, addr, val, width)" in b and \
+ "g_render_pass_dropped_writes[cls]++" in b, (
+ "pass stores must go through the tested store policy "
+ "(render_pass_store_to, render_pass_sandbox_test) and count drops")
+assert "t.ram_size = psx_ram_live_bytes();" in b, (
+ "pass stores must fold RAM through the LIVE 2/8 MiB geometry, as "
+ "psx_ram_map_write does (not the 8 MiB backing size)")
+rp = (SRC / "render_pass.c").read_text(encoding="utf-8")
+assert "uint32_t ram_bytes = memory_get_ram_bytes();" in body(
+ rp, "static int checkpoint_save(") and \
+ "memcpy(memory_get_ram_ptr(), s_ram_copy, s_ck.ram_bytes);" in body(
+ rp, "static void checkpoint_restore(") and \
+ "memory_get_ram_bytes()" in body(rp, "static uint64_t state_hash("), (
+ "the pass checkpoint, restore and verify hash must cover the live RAM "
+ "size (8 MiB with the 8 MB RAM mod)")
+plan = (SRC / "render_pass_plan.c").read_text(encoding="utf-8")
+assert "render_pass_mmio_class(phys, val, width)" in body(
+ plan, "int render_pass_store_to("), (
+ "pass MMIO stores must go through the MMIO allow-list")
+gl = (SRC / "gpu_gl_renderer.c").read_text(encoding="utf-8")
+assert "render_pass_vram_policy(&s_pj_cpu" in body(
+ gl, "static int pass_refuse_write("), (
+ "out-of-rect VRAM writes must go through the tested journal policy")
+assert "render_pass_journal_rollback(&s_pj_cpu" in body(
+ gl, "static void pass_journal_rollback("), (
+ "the journal rollback must restore the CPU VRAM rows")
+# The journal backs up the hr surface, the raw mirror and the CPU rows. That
+# is complete in native-wide too only while the out-of-rect write paths never
+# touch a wide surface; R4 copies 2x1 pixels outside the display every frame,
+# so refusing them under native-wide rolled back every widescreen pass.
+def definition(text, name):
+ """The whole body of function `name` (its definition, not a prototype)."""
+ m = re.search(r"^static [\w ]+\b" + name + r"\([^;{]*\)\s*\{", text, re.M)
+ assert m, "no definition of " + name
+ depth, i = 0, m.end() - 1
+ while True:
+ depth += {"{": 1, "}": -1}.get(text[i], 0)
+ if depth == 0:
+ return text[m.start():i + 1]
+ i += 1
+
+
+assert "g_wide" not in definition(gl, "pass_journal_protect"), (
+ "out-of-rect writes are journaled in native-wide as well")
+for name in ("gpu_fill", "gpu_copy_rect", "flush_cpu_upload",
+ "glb_vram_transfer_in", "glb_vram_write"):
+ assert "wide" not in definition(gl, name), (
+ "journaled writes must not reach a native-wide surface: " + name)
+# One-time pass allocations are not cost samples (render_pass_plan_test):
+# every path that makes a pass texture or framebuffer must count it, and the
+# cost average must go through the tested sampling rule.
+for name in ("pass_make_color_fbo", "pass_gen_reserve"):
+ assert "s_pass_allocs++" in definition(gl, name), (
+ "pass allocations must be counted: " + name)
+assert "render_pass_cost_add(" in body(
+ gl, "void gl_renderer_pass_note_cost(uint64_t ticks) {"), (
+ "the pass-cost average must leave allocating passes out")
+assert "s_pass_allocs_begin = s_pass_allocs;" in body(
+ gl, "int gl_renderer_pass_begin("), (
+ "each pass must mark where its allocations start")
+# The average belongs to one presented image size. A plan at another size
+# passes an unknown cost (0), for which render_pass_plan_phases plans a single
+# measuring pass (render_pass_plan_test): passes run on the emulation thread.
+nc = body(gl, "void gl_renderer_pass_note_cost(uint64_t ticks) {")
+assert "s_pass_cost_w != s_interp_w || s_pass_cost_h != s_interp_h" in nc and \
+ "memset(&s_pass_cost, 0, sizeof s_pass_cost);" in nc, (
+ "a new image size must start the pass-cost average over")
+assert re.search(r"in\.pass_cost = \(s_pass_cost_w == s_interp_w && "
+ r"s_pass_cost_h == s_interp_h\)\s*"
+ r"\? render_pass_cost_estimate\(&s_pass_cost\) : 0\.0;",
+ gl[gl.index("uint32_t gl_renderer_pass_plan("):][:4000]), (
+ "a plan must not use a cost measured at another image size")
+# Only passes that run are measured: an estimate that prices every plan out
+# (measured in a transient) must be measured again, or passes stay shed and a
+# plugin's crossfade fallback never ends (render_pass_plan_test).
+gp = gl[gl.index("uint32_t gl_renderer_pass_plan("):]
+gp = gp[:gp.index("\n}\n")]
+assert re.search(r"if \(want && s_pass_cost_w == s_interp_w && "
+ r"s_pass_cost_h == s_interp_h &&\s*"
+ r"render_pass_cost_note_plan\(&s_pass_cost\)\) \{", gp) and \
+ re.search(r"in\.pass_cost = 0\.0;\s*n = render_pass_plan_phases\(&in, alpha_q16, NULL\);",
+ gp), (
+ "every plan that wants passes must let a stale estimate be re-measured, "
+ "and the re-measuring plan must ask for one pass")
+# A frame on screen longer than planned (a lagging tick) must hold its newest
+# pass image, never fall back to the older capture (render_pass_plan_test).
+pgp = definition(gl, "pass_gen_present")
+assert "render_pass_gen_select(g->phase, g->n, p, &lo, &hi, &t)" in pgp, (
+ "pass images must be selected through the tested late-flip rule")
+assert "render_pass_select(" not in pgp and "0.5 / (double)g->period" not in pgp, (
+ "no early expiry of a late frame's images")
+cls = body(plan, "int render_pass_mmio_class(")
+for dev in ("RENDER_PASS_DROP_SPU", "RENDER_PASS_DROP_CD",
+ "RENDER_PASS_DROP_TIMER"):
+ assert dev in cls, "a pass must never reach " + dev
+
+dbg = (SRC / "debug_server.c").read_text(encoding="utf-8")
+for fn in ("void debug_server_trace_write_check(",
+ "void debug_server_trace_mmio_write("):
+ assert "g_psx_render_pass_active" in body(dbg, fn), (
+ "rolled-back pass writes must stay out of the frame fingerprints: " + fn)
+
+rp = (SRC / "render_pass.c").read_text(encoding="utf-8")
+for gate in ("psx_netplay_active()", "psx_rewind_is_open()",
+ "psx_selfcheck_resim_active()", "psx_get_in_exception()",
+ "dma_gpu_linked_list_active()",
+ "psx_presentation_fast_forward()"):
+ assert gate in rp, "passes must refuse to run when " + gate
+main = (SRC / "main.cpp").read_text(encoding="utf-8")
+ff = main[main.index("const int present_every ="):]
+ff = ff[:ff.index("turbo_skip = (turbo_skip + 1) % present_every;")]
+assert "s_presentation_fast_forward = 1;" in ff, (
+ "manual fast-forward must refuse render passes (it presents every 2nd "
+ "VBlank, so the plan would otherwise still see presents)")
+
+b = body(rp, "static void checkpoint_restore(CPUState *cpu) {")
+assert "nesting_restore(&s_ck.nest);" in b, (
+ "a watchdog abort longjmps past the exits of the frames it leaves: the "
+ "restore must put the host nesting back (render_pass_abort_test)")
+
+gl = (SRC / "gpu_gl_renderer.c").read_text(encoding="utf-8")
+b = body(gl, "static int pass_gen_reserve(int gi, uint32_t need, int w, int h) {")
+assert "need > pass_slot_cap(w, h)" in b, (
+ "pass image textures must stay inside the slot cap's memory budget")
+assert "pass_gen_reserve(gi, g->n + 1u, g->tex_w, g->tex_h)" in gl, (
+ "pass image textures are made as slots fill, not all PASS_SLOTS up front")
+assert "for (uint32_t i = 0; i < PASS_SLOTS; i++) {\n if (!s_pgen_tex" not in gl, (
+ "no eager allocation of every slot texture")
+assert "pass_resources_release();" in body(gl, "void gl_renderer_shutdown(void) {"), (
+ "pass textures die with the context: forget them at shutdown")
+
+# Default off means no cost for other titles: the headers folded into the
+# overlay codegen hash (runtime/codegen_hash_sources.cmake) must not carry the
+# render-pass API, or every title's overlay cache and savestates would be
+# invalidated by a feature they never use. The freeze lives in the
+# runtime-only psx_cycle_freeze.h.
+INC = ROOT / "runtime" / "include"
+hash_list = (ROOT / "runtime" / "codegen_hash_sources.cmake").read_text(
+ encoding="utf-8")
+for header in re.findall(r"runtime/include/([\w.]+)", hash_list):
+ text = (INC / header).read_text(encoding="utf-8")
+ for name in ("g_psx_render_pass_active", "PsxCycleFreeze",
+ "psx_cycle_freeze", "render_pass"):
+ assert name not in text, (
+ f"{header} is in the codegen hash and must not mention {name}")
+assert "psx_cycle_freeze.h" not in hash_list, (
+ "psx_cycle_freeze.h is runtime-only; keep it out of the codegen hash")
+
+print("render-pass sandbox guards passed")
diff --git a/runtime/tests/test_render_pass_plan.c b/runtime/tests/test_render_pass_plan.c
new file mode 100644
index 00000000..e514618f
--- /dev/null
+++ b/runtime/tests/test_render_pass_plan.c
@@ -0,0 +1,307 @@
+/* Render-pass planning and presentation-selection math.
+ * Build/run: ctest -R render_pass_plan_test */
+#include
+#include
+#include
+#include
+
+#include "render_pass.h"
+#include "render_pass_plan.h"
+
+static int failures;
+#define CHECK(condition, message) do { \
+ if (!(condition)) { fprintf(stderr, "FAIL: %s\n", message); failures++; } \
+} while (0)
+
+/* One 30 Hz game frame (2 VBlanks at 59.94 Hz) in nanosecond ticks. */
+static const double kFrame = 2.0 * 1e9 / 59.94;
+
+static void test_counts_per_rate(void) {
+ static const struct { double hz; uint32_t lo, hi; } rates[] = {
+ {60.0, 1, 2}, {100.0, 3, 4}, {120.0, 3, 4}, {200.0, 6, 7},
+ {240.0, 7, 8}, {300.0, 9, 10},
+ };
+ for (size_t r = 0; r < sizeof rates / sizeof rates[0]; r++) {
+ double period = 1e9 / rates[r].hz;
+ /* Slide the output grid across one output period. */
+ for (int k = 0; k < 16; k++) {
+ RenderPassPlanInput in = {0};
+ uint32_t a[RENDER_PASS_MAX_PHASES], wanted = 0, n;
+ char msg[160];
+ in.frame_start = 1e12;
+ in.frame_length = kFrame;
+ in.target_period = period;
+ in.next_deadline = in.frame_start - 3.0 * period +
+ period * (double)k / 16.0;
+ in.budget = -1.0;
+ in.max = RENDER_PASS_MAX_PHASES;
+ n = render_pass_plan_phases(&in, a, &wanted);
+ snprintf(msg, sizeof msg, "%.0f Hz offset %d/16: %u passes",
+ rates[r].hz, k, (unsigned)n);
+ CHECK(n >= rates[r].lo && n <= rates[r].hi, msg);
+ CHECK(wanted == n, "unlimited budget sheds nothing");
+ for (uint32_t i = 0; i < n; i++) {
+ CHECK(a[i] > 0 && a[i] < 65536u, "phase inside (0, 1)");
+ if (i) CHECK(a[i] > a[i - 1], "phases ascend");
+ /* Each phase is an actual output deadline. */
+ double d = in.frame_start + (double)a[i] / 65536.0 * kFrame;
+ double m = fmod(d - in.next_deadline, period);
+ if (m > period / 2) m -= period;
+ snprintf(msg, sizeof msg,
+ "%.0f Hz phase %u lands on a deadline (off %.0f ns)",
+ rates[r].hz, (unsigned)i, m);
+ CHECK(fabs(m) < kFrame / 65536.0 + 1.0, msg);
+ }
+ }
+ }
+}
+
+static void test_shedding(void) {
+ RenderPassPlanInput in = {0};
+ uint32_t a[RENDER_PASS_MAX_PHASES], wanted = 0, n;
+ in.frame_start = 1e12;
+ in.frame_length = kFrame;
+ in.target_period = 1e9 / 300.0;
+ in.next_deadline = in.frame_start + 1e9 / 600.0; /* half-period offset */
+ in.max = RENDER_PASS_MAX_PHASES;
+ in.pass_cost = 2e6; /* 2 ms per pass */
+ in.budget = 5e6; /* 5 ms */
+ n = render_pass_plan_phases(&in, a, &wanted);
+ CHECK(wanted == 10, "300 Hz half-offset wants ten phases");
+ CHECK(n == 2, "a 5 ms budget at 2 ms per pass affords two");
+ CHECK(n == 2 && a[0] < 32768u && a[1] > 32768u,
+ "a shed subset spreads across the frame");
+
+ in.budget = 1e6;
+ CHECK(render_pass_plan_phases(&in, a, &wanted) == 0 && wanted == 10,
+ "less than one pass of budget renders none");
+
+ /* No pass measured yet (first plans, or a new image size): passes run on
+ * the emulation thread, so one measures the cost; a whole plan of
+ * unknown cost could stall the guest for frames (46.9 ms each at 4K). */
+ in.pass_cost = 0.0;
+ in.budget = 26e6;
+ n = render_pass_plan_phases(&in, a, &wanted);
+ CHECK(n == 1 && wanted == 10, "unknown cost plans one pass");
+ CHECK(n == 1 && a[0] > 16384u && a[0] < 49152u,
+ "the one pass sits mid-frame, as a shed subset of one does");
+ in.budget = 0.0;
+ CHECK(render_pass_plan_phases(&in, a, NULL) == 0,
+ "no budget: not even the measuring pass");
+ in.pass_cost = 2e6;
+
+ in.budget = -1.0;
+ in.max = 4;
+ CHECK(render_pass_plan_phases(&in, a, NULL) == 4,
+ "capacity caps the plan");
+
+ in.max = RENDER_PASS_MAX_PHASES;
+ in.target_period = 0.0;
+ CHECK(render_pass_plan_phases(&in, a, &wanted) == 0 && wanted == 0,
+ "no live output schedule plans nothing");
+}
+
+static void test_select(void) {
+ const uint32_t ph[] = {0u, 16384u, 32768u, 49152u};
+ uint32_t lo = 99, hi = 99;
+ float t = -1.0f;
+ CHECK(render_pass_select(ph, 4, 0.0, &lo, &hi, &t) && lo == 0 && hi == 0,
+ "phase 0 shows the game's image");
+ CHECK(render_pass_select(ph, 4, 0.25, &lo, &hi, &t) && lo == 1 && hi == 1,
+ "an exact pass phase shows that pass alone");
+ CHECK(render_pass_select(ph, 4, 0.375, &lo, &hi, &t) && lo == 1 && hi == 2 &&
+ fabsf(t - 0.5f) < 1e-4f,
+ "between passes the neighbours blend");
+ CHECK(render_pass_select(ph, 4, 0.9, &lo, &hi, &t) && lo == 3 && hi == 3,
+ "past the last pass it holds (the next frame is not known yet)");
+ CHECK(render_pass_select(ph, 1, 0.6, &lo, &hi, &t) && lo == 0 && hi == 0,
+ "with only the game image it holds");
+ CHECK(!render_pass_select(ph, 0, 0.5, &lo, &hi, &t), "no items, no pick");
+ CHECK(render_pass_select(ph, 4, -0.1, &lo, &hi, &t) && lo == 0 && hi == 0,
+ "before the frame starts show its first image");
+}
+
+/* A frame on screen longer than planned (a 30 Hz tick that takes three
+ * VBlanks) keeps its newest image until the next flip; it must never go back
+ * to the game's own image (phase 0), which is older. */
+static void test_gen_select_late(void) {
+ const uint32_t ph[] = {0u, 16384u, 32768u, 49152u};
+ const double late[] = {1.0, 1.25, 1.2501, 1.3, 1.5, 2.0, 3.99,
+ RENDER_PASS_GEN_HOLD_MAX};
+ uint32_t lo = 99, hi = 99;
+ float t = -1.0f;
+ CHECK(render_pass_gen_select(ph, 4, 0.375, &lo, &hi, &t) && lo == 1 &&
+ hi == 2 && fabsf(t - 0.5f) < 1e-4f,
+ "inside the frame it selects as render_pass_select");
+ for (unsigned i = 0; i < sizeof late / sizeof late[0]; i++) {
+ char msg[96];
+ lo = hi = 99;
+ snprintf(msg, sizeof msg, "late flip (p = %.4f) holds the newest image",
+ late[i]);
+ CHECK(render_pass_gen_select(ph, 4, late[i], &lo, &hi, &t) &&
+ lo == 3 && hi == 3 && t == 0.0f, msg);
+ }
+ CHECK(!render_pass_gen_select(ph, 4, RENDER_PASS_GEN_HOLD_MAX + 0.01,
+ &lo, &hi, &t),
+ "a game that stops flipping expires the frame after the hold");
+ CHECK(!render_pass_gen_select(ph, 4, NAN, &lo, &hi, &t),
+ "a phase that is not a number expires it");
+ CHECK(!render_pass_gen_select(ph, 0, 0.5, &lo, &hi, &t), "no items, no pick");
+}
+
+static void test_budget_and_ema(void) {
+ CHECK(fabs(render_pass_budget(0, 0, 100.0, 0.5) - 50.0) < 1e-9,
+ "no history spends the share of the frame");
+ CHECK(fabs(render_pass_budget(20.0, 10.0, 100.0, 0.8) - 24.0) < 1e-9,
+ "idle plus pass time, scaled");
+ CHECK(render_pass_budget(500.0, 0.0, 100.0, 0.8) == 100.0,
+ "never more than a frame");
+ CHECK(render_pass_budget(10.0, 10.0, 0.0, 0.8) == 0.0, "no frame, no budget");
+ CHECK(render_pass_ema(0.0, 4.0) == 4.0, "first sample seeds the average");
+ CHECK(fabs(render_pass_ema(4.0, 8.0) - 5.0) < 1e-9, "quarter-weight update");
+ CHECK(render_pass_ema(4.0, -1.0) == 4.0, "bad samples are ignored");
+ {
+ /* First-use allocations (70 ms at 4K) must not set the average a
+ * shed-for-time plan then never revisits; steady passes (10 ms) do. */
+ RenderPassCost c;
+ memset(&c, 0, sizeof c);
+ render_pass_cost_add(&c, 70.0, 1);
+ CHECK(render_pass_cost_estimate(&c) == 0.0 && c.skips == 1 && c.kept == 0,
+ "an allocating pass is not a sample");
+ render_pass_cost_add(&c, 10.0, 0);
+ render_pass_cost_add(&c, 10.0, 0);
+ CHECK(render_pass_cost_estimate(&c) == 0.0 && c.skips == 0,
+ "two samples: still warming up (unknown: one pass per plan)");
+ render_pass_cost_add(&c, 10.0, 0);
+ CHECK(render_pass_cost_estimate(&c) == 10.0, "three samples seed the average");
+ render_pass_cost_add(&c, 70.0, 1);
+ CHECK(render_pass_cost_estimate(&c) == 10.0,
+ "a later allocating pass leaves it alone");
+ for (unsigned i = 1; i < RENDER_PASS_ALLOC_SKIPS; i++)
+ render_pass_cost_add(&c, 70.0, 1);
+ CHECK(render_pass_cost_estimate(&c) == 10.0 &&
+ c.skips == RENDER_PASS_ALLOC_SKIPS,
+ "up to RENDER_PASS_ALLOC_SKIPS in a row");
+ render_pass_cost_add(&c, 70.0, 1);
+ CHECK(fabs(render_pass_cost_estimate(&c) - 25.0) < 1e-9 && c.skips == 0,
+ "then an allocating pass counts, so the average cannot freeze");
+ render_pass_cost_add(&c, -1.0, 0);
+ render_pass_cost_add(&c, NAN, 0);
+ CHECK(fabs(render_pass_cost_estimate(&c) - 25.0) < 1e-9,
+ "bad samples are ignored");
+ }
+ {
+ /* One slow first pass (a busy host: 24 ms against a steady 3 ms)
+ * must not price passes out: the median of the warm-up wins. */
+ RenderPassCost c;
+ memset(&c, 0, sizeof c);
+ render_pass_cost_add(&c, 24.0, 0);
+ render_pass_cost_add(&c, 3.0, 0);
+ render_pass_cost_add(&c, 3.5, 0);
+ CHECK(render_pass_cost_estimate(&c) == 3.5,
+ "warm-up median ignores one outlier");
+ memset(&c, 0, sizeof c);
+ render_pass_cost_add(&c, 47.0, 0);
+ render_pass_cost_add(&c, 46.0, 0);
+ render_pass_cost_add(&c, 48.0, 0);
+ CHECK(render_pass_cost_estimate(&c) == 47.0,
+ "a truly expensive size (4K) still prices itself out");
+ }
+ {
+ /* The first passes of a race can all run in a transient (14.8 ms
+ * against a steady 6.9 ms in a verify run). Priced out, no pass runs
+ * to correct it: an estimate no pass was measured against for
+ * RENDER_PASS_REWARM_MIN plans is measured again. */
+ RenderPassCost c;
+ unsigned i, rewarms = 0;
+ memset(&c, 0, sizeof c);
+ CHECK(!render_pass_cost_note_plan(&c), "unknown cost: nothing to re-measure");
+ for (i = 0; i < RENDER_PASS_COST_WARMUP; i++) render_pass_cost_add(&c, 14.8, 0);
+ CHECK(render_pass_cost_estimate(&c) == 14.8, "the transient sets the estimate");
+ for (i = 1; i < RENDER_PASS_REWARM_MIN; i++)
+ rewarms += (unsigned)render_pass_cost_note_plan(&c);
+ CHECK(rewarms == 0 && render_pass_cost_estimate(&c) == 14.8,
+ "29 plans without a pass: still trusted");
+ CHECK(render_pass_cost_note_plan(&c) && render_pass_cost_estimate(&c) == 0.0,
+ "the 30th: unknown again, so plans ask for one pass");
+ CHECK(!render_pass_cost_note_plan(&c), "no second restart while warming up");
+ for (i = 0; i < RENDER_PASS_COST_WARMUP; i++) render_pass_cost_add(&c, 6.9, 0);
+ CHECK(render_pass_cost_estimate(&c) == 6.9, "the steady cost replaces it");
+ CHECK(c.rewarm_after == 0, "a stale estimate found: the wait stays at the minimum");
+ /* Passes run on it: each measured pass restarts the count. */
+ for (i = 0; i < 10u * RENDER_PASS_REWARM_MIN; i++) {
+ rewarms += (unsigned)render_pass_cost_note_plan(&c);
+ render_pass_cost_add(&c, 6.9, 0);
+ }
+ CHECK(rewarms == 0, "an estimate passes run against is never restarted");
+ /* Priced out again (a busier host): re-measured after the minimum. */
+ for (i = 0; i < RENDER_PASS_REWARM_MIN; i++)
+ rewarms += (unsigned)render_pass_cost_note_plan(&c);
+ CHECK(rewarms == 1, "priced out again: re-measured after the minimum wait");
+ }
+ {
+ /* A size that is truly too expensive (47 ms passes), or a machine at
+ * its limit (10 ms re-measured as 9): each re-measure confirms the
+ * estimate, so the waits double to RENDER_PASS_REWARM_MAX and the
+ * one-pass warm-ups become rare. */
+ static const double cases[][2] = {{47.0, 47.0}, {10.0, 9.0}};
+ for (unsigned k = 0; k < 2; k++) {
+ RenderPassCost c;
+ unsigned i, plans = 0, waits[8] = {0}, w = 0, first = 1;
+ memset(&c, 0, sizeof c);
+ while (w < 8u && plans < 4000u) {
+ if (render_pass_cost_estimate(&c) == 0.0) {
+ /* the warm-up's passes */
+ render_pass_cost_add(&c, first ? cases[k][0] : cases[k][1], 0);
+ } else {
+ first = 0;
+ plans++;
+ if (render_pass_cost_note_plan(&c)) {
+ waits[w++] = plans;
+ plans = 0;
+ }
+ }
+ }
+ CHECK(w == 8u && waits[0] == RENDER_PASS_REWARM_MIN &&
+ waits[1] == 2u * RENDER_PASS_REWARM_MIN &&
+ waits[2] == 4u * RENDER_PASS_REWARM_MIN,
+ "a confirmed estimate: the waits double");
+ for (i = 5; i < 8; i++)
+ CHECK(waits[i] == RENDER_PASS_REWARM_MAX, "and stop at the maximum");
+ }
+ }
+}
+
+static void test_store_policy(void) {
+ CHECK(render_pass_mmio_class(0x1F801810u, 0x28000000u, 4) == -1, "GP0 reaches the GPU");
+ CHECK(render_pass_mmio_class(0x1F801814u, 0x04000002u, 4) == -1, "GP1 DMA mode allowed");
+ CHECK(render_pass_mmio_class(0x1F801814u, 0x10000007u, 4) == -1, "GP1 info query allowed");
+ CHECK(render_pass_mmio_class(0x1F801814u, 0x05000000u, 4) == RENDER_PASS_DROP_GPU,
+ "GP1 display start (a flip) is dropped");
+ CHECK(render_pass_mmio_class(0x1F801814u, 0x00000000u, 4) == RENDER_PASS_DROP_GPU,
+ "GP1 reset is dropped");
+ CHECK(render_pass_mmio_class(0x1F8010A8u, 0x01000401u, 4) == -1, "GPU DMA CHCR allowed");
+ CHECK(render_pass_mmio_class(0x1F8010E8u, 0x11000002u, 4) == -1, "OTC DMA allowed");
+ CHECK(render_pass_mmio_class(0x1F8010F4u, 0, 4) == -1, "DICR allowed (restored)");
+ CHECK(render_pass_mmio_class(0x1F8010C8u, 0x01000201u, 4) == RENDER_PASS_DROP_DMA,
+ "SPU DMA is dropped");
+ CHECK(render_pass_mmio_class(0x1F801074u, 0, 4) == -1, "I_MASK allowed (restored)");
+ CHECK(render_pass_mmio_class(0x1F801D88u, 0x00FFu, 2) == RENDER_PASS_DROP_SPU,
+ "SPU key-on is dropped (cannot be undone)");
+ CHECK(render_pass_mmio_class(0x1F801C00u, 0, 2) == RENDER_PASS_DROP_SPU, "SPU voice regs dropped");
+ CHECK(render_pass_mmio_class(0x1F801801u, 0x1Bu, 1) == RENDER_PASS_DROP_CD, "CD command dropped");
+ CHECK(render_pass_mmio_class(0x1F801104u, 0, 4) == RENDER_PASS_DROP_TIMER, "timer mode dropped");
+ CHECK(render_pass_mmio_class(0x1F801040u, 0, 1) == RENDER_PASS_DROP_OTHER, "SIO dropped");
+ CHECK(render_pass_mmio_class(0x1F801820u, 0, 4) == RENDER_PASS_DROP_OTHER, "MDEC dropped");
+}
+
+int main(void) {
+ test_store_policy();
+ test_counts_per_rate();
+ test_shedding();
+ test_select();
+ test_gen_select_late();
+ test_budget_and_ema();
+ printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures);
+ return failures ? 1 : 0;
+}
diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c
new file mode 100644
index 00000000..16a3a057
--- /dev/null
+++ b/runtime/tests/test_render_pass_sandbox.c
@@ -0,0 +1,567 @@
+/* Render-pass sandbox: what a pass may write and what it gets back.
+ *
+ * 1. Store policy (render_pass_plan.c render_pass_store_to, the function
+ * memory.c routes every store made inside a pass through): RAM and
+ * scratchpad are written directly; SPU, CD, timer, other-DMA, GP1 and
+ * everything else outside the allow-list is dropped under its class and
+ * never reaches the device; GP0, GP1 DMA mode, I_STAT/I_MASK, GPU/OTC DMA
+ * and DPCR/DICR reach it.
+ * 2. A real pass (render_pass.c, psx_cycles.c and the real timers.c) that
+ * runs 10^6 guest cycles, makes those stores, acknowledges and masks
+ * interrupts: the drops are counted per class, no device advances, no
+ * timer IRQ is raised, and RAM, scratchpad, I_STAT/I_MASK, timers and the
+ * clock are exactly as before. Status gates refuse the pass without
+ * running it. The same with 8 MiB RAM live (the opt-in 8 MB RAM map):
+ * stores reach unique RAM up to 0x7FFFFF and all 8 MiB are restored; a
+ * retail pass after it folds mirrors again.
+ * 3. VRAM journal (render_pass_vram_policy / _journal_add / _rollback, the
+ * CPU side of gpu_gl_renderer.c's out-of-rect journal): writes inside the
+ * pass rect are allowed, writes outside are backed up first and rolled
+ * back exactly, covered writes are not journaled twice, a full journal or
+ * a renderer mode that cannot journal refuses.
+ *
+ * The GPU, presenter and remaining devices are mocks. Build/run:
+ * ctest -R render_pass_sandbox_test */
+
+#include "render_pass.h"
+#include "render_pass_plan.h"
+
+#include
+#include
+#include
+#include
+
+#include "cpu_state.h"
+#include "dirty_ram_interp.h"
+#include "mod_plugins.h"
+#include "psx_cycles.h"
+#include "timers.h"
+
+static int failures;
+#define CHECK(c, m) do { if (!(c)) { fprintf(stderr, "FAIL: %s\n", m); failures++; } } while (0)
+
+/* ---- runtime globals the units share with the rest of the runtime ----- */
+int g_ls_replay_active = 0;
+int g_ls_mode = 0;
+int g_precise_mode = 0;
+int g_psx_call_bail = 0;
+int g_psx_dispatch_depth = 0;
+uint32_t i_stat = 0, i_mask = 0;
+uint64_t g_guest_store_count = 0;
+uint64_t g_mmio_access_count = 0;
+uint32_t g_debug_last_store_pc = 0;
+uint32_t g_debug_current_func_addr = 0;
+uint32_t g_psx_icache_tv[1024];
+uint64_t g_render_pass_dropped_writes[RENDER_PASS_DROP_CLASSES];
+void (*g_overlay_flush_pending_cycles)(void) = NULL;
+int g_call_unit_depth = 0;
+int g_dma_exec_depth = 0;
+int g_dma_cur_ch = -1;
+uint32_t g_dma_cur_madr = 0, g_dma_cur_bcr = 0, g_dma_initiator_pc = 0;
+int g_dirty_interp_active = 0;
+int g_exec_phase = 0;
+uint32_t g_dirty_safe_resume_pc = 0;
+
+/* ---- memory and the MMIO devices a pass may reach ----------------------- */
+/* Backing store sized for the largest geometry, as memory.c's; the live size
+ * (what memory_get_ram_bytes() answers and memory.c passes the store policy)
+ * is 2 MiB retail or 8 MiB with the 8 MB RAM mod. */
+#define RAM_2MB (2u * 1024u * 1024u)
+#define RAM_8MB (8u * 1024u * 1024u)
+static uint8_t s_ram[RAM_8MB];
+static uint32_t s_ram_live = RAM_2MB;
+static uint8_t s_spad[1024];
+static uint8_t s_dma_regs[64];
+static uint32_t s_csv = 1234;
+static int s_mmio_calls, s_gp0_words, s_gp1_calls, s_irq_refresh;
+static uint32_t s_last_mmio;
+
+static void mock_mmio_write(uint32_t phys, uint32_t val, uint32_t width) {
+ (void)width;
+ s_mmio_calls++;
+ s_last_mmio = phys;
+ if (phys == 0x1F801070u) i_stat &= val; /* acknowledge */
+ else if (phys == 0x1F801074u) i_mask = val;
+ else if (phys == 0x1F801810u) s_gp0_words++;
+ else if (phys == 0x1F801814u) s_gp1_calls++;
+ else if (phys >= 0x1F801100u && phys <= 0x1F80112Fu)
+ timers_write(phys, val); /* would move a timer */
+}
+
+static RenderPassStoreTarget target(void) {
+ RenderPassStoreTarget t;
+ memset(&t, 0, sizeof t);
+ t.ram = s_ram;
+ t.ram_size = s_ram_live;
+ t.scratchpad = s_spad;
+ t.scratchpad_size = sizeof s_spad;
+ t.mmio_write = mock_mmio_write;
+ return t;
+}
+
+/* What memory.c psx_write_word/half/byte do while a pass is active. */
+static void guest_store(uint32_t addr, uint32_t val, uint32_t width) {
+ RenderPassStoreTarget t = target();
+ int cls;
+ if (!g_psx_render_pass_active) {
+ fprintf(stderr, "FAIL: guest_store outside a pass\n");
+ failures++;
+ return;
+ }
+ cls = render_pass_store_to(&t, addr, val, width);
+ if (cls >= 0) g_render_pass_dropped_writes[cls]++;
+}
+
+uint8_t *memory_get_ram_ptr(void) { return s_ram; }
+uint32_t memory_get_ram_bytes(void) { return s_ram_live; }
+uint8_t *memory_get_scratchpad_ptr(void) { return s_spad; }
+uint32_t dma_snapshot_bytes(void) { return sizeof s_dma_regs; }
+void dma_snapshot_write(uint8_t *p) { memcpy(p, s_dma_regs, sizeof s_dma_regs); }
+int dma_snapshot_read(const uint8_t *p, uint32_t len) {
+ if (len != sizeof s_dma_regs) return 0;
+ memcpy(s_dma_regs, p, len);
+ return 1;
+}
+int dma_gpu_linked_list_active(void) { return 0; }
+uint32_t interrupts_get_cycles_since_vblank(void) { return s_csv; }
+void interrupts_set_cycles_since_vblank(uint32_t v) { s_csv = v; }
+void psx_irq_refresh_cause_ip2(void) { s_irq_refresh++; }
+static int s_irqs_raised;
+void psx_irq_raise(uint32_t bit, uint32_t detail) {
+ (void)detail;
+ s_irqs_raised++;
+ i_stat |= 1u << bit;
+}
+void event_ring_record_aux(int ev, uint8_t src, uint32_t aux) {
+ (void)ev; (void)src; (void)aux;
+}
+uint32_t cdrom_snapshot_bytes(void) { return 0; }
+void cdrom_snapshot_write(uint8_t *p) { (void)p; }
+uint32_t spu_snapshot_bytes(void) { return 0; }
+void spu_snapshot_write(uint8_t *p) { (void)p; }
+uint32_t sio_snapshot_bytes(void) { return 0; }
+void sio_snapshot_write(uint8_t *p) { (void)p; }
+uint32_t mdec_snapshot_bytes(void) { return 0; }
+void mdec_snapshot_write(uint8_t *p) { (void)p; }
+
+int psx_get_in_exception(void) { return 0; }
+int psx_netplay_active(void) { return 0; }
+int psx_netplay_is_resimulating(void) { return 0; }
+int psx_selfcheck_resim_active(void) { return 0; }
+int psx_selfcheck_enabled(void) { return 0; }
+int psx_rewind_is_open(void) { return 0; }
+static int s_fast_forward;
+int psx_presentation_fast_forward(void) { return s_fast_forward; }
+
+/* psx_cycles.c device side (timers are the real ones). */
+static uint64_t s_dev_cycles;
+void sio_advance(uint32_t c) { s_dev_cycles += c; }
+void cdrom_advance(uint32_t c) { s_dev_cycles += c; }
+void dma_advance(uint32_t c) { s_dev_cycles += c; }
+void interrupts_advance_cycles(uint32_t c) { s_dev_cycles += c; }
+void interrupts_service_scheduled_events(void) {}
+uint32_t interrupts_cycles_to_vblank(void) { return 564480u; }
+uint32_t cdrom_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; }
+uint32_t dma_cycles_to_internal_event(void) { return UINT32_MAX; }
+uint32_t dma_cycles_to_deliverable_irq(uint32_t m) { (void)m; return UINT32_MAX; }
+uint32_t sio_cycles_to_irq(uint32_t m) { (void)m; return UINT32_MAX; }
+uint32_t psx_spu_sample_event_cycles_to_next(void) { return UINT32_MAX; }
+void psx_spu_sample_event_service(void) {}
+void starvation_watchdog_check(void) {}
+void starvation_ring_pc_sample(void) {}
+void dirty_ram_irq_ambient_resync_after_restore(void) {}
+
+static DirtyRamLoadDelay s_ld;
+void dirty_ram_ld_delay_discard(void) { memset(&s_ld, 0, sizeof s_ld); }
+void dirty_ram_ld_delay_save(DirtyRamLoadDelay *o) { *o = s_ld; }
+void dirty_ram_ld_delay_restore(const DirtyRamLoadDelay *i) { s_ld = *i; }
+void overlay_loader_native_nesting(int *d, uint32_t *ip) { *d = 0; *ip = 0; }
+void overlay_loader_set_native_nesting(int d, uint32_t ip) { (void)d; (void)ip; }
+
+/* GPU and presenter. */
+uint64_t gpu_pass_state_hash(void) { return 42; }
+int gpu_pass_checkpoint_save(void) { return 1; }
+void gpu_pass_checkpoint_restore(void) {}
+static uint64_t s_ticks;
+uint64_t gl_renderer_perf_ticks(void) { return s_ticks += 1000; }
+uint64_t gl_renderer_perf_frequency(void) { return 1000000000u; }
+static uint32_t s_gl_status = PSX_MOD_RENDER_PASS_READY;
+uint32_t gl_renderer_pass_unavailable(void) { return s_gl_status; }
+uint32_t gl_renderer_pass_plan(uint32_t p, uint32_t s, uint32_t *a,
+ uint32_t max, uint32_t *wanted) {
+ (void)p; (void)s; (void)a; (void)max;
+ if (wanted) *wanted = 0;
+ return 0;
+}
+static int s_open_passes, s_kept;
+int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen,
+ uint32_t period, int reuse_backup) {
+ (void)x; (void)y; (void)w; (void)h; (void)open_gen; (void)period;
+ (void)reuse_backup;
+ s_open_passes++;
+ return 1;
+}
+void gl_renderer_pass_end(uint32_t alpha_q16, int keep) {
+ (void)alpha_q16;
+ s_open_passes--;
+ if (keep) s_kept++;
+}
+uint32_t gl_renderer_pass_leaks(void) { return 0; }
+int gl_renderer_pass_verify_vram(void) { return 1; }
+void gl_renderer_pass_note_cost(uint64_t t) { (void)t; }
+void gl_renderer_pass_service_presents(void) {}
+
+/* ---- 1. store policy --------------------------------------------------- */
+static void test_store_policy(void) {
+ RenderPassStoreTarget t = target();
+ int calls;
+ memset(s_ram, 0, sizeof s_ram);
+ memset(s_spad, 0, sizeof s_spad);
+ s_mmio_calls = 0;
+
+ CHECK(render_pass_store_to(&t, 0x80001000u, 0x11223344u, 4) == -1 &&
+ s_ram[0x1000] == 0x44 && s_ram[0x1003] == 0x11, "KSEG0 RAM word written");
+ CHECK(render_pass_store_to(&t, 0xA0001004u, 0xBEEFu, 2) == -1 &&
+ s_ram[0x1004] == 0xEF && s_ram[0x1005] == 0xBE, "KSEG1 RAM half written");
+ CHECK(render_pass_store_to(&t, 0x00201008u, 0x5Au, 1) == -1 &&
+ s_ram[0x1008] == 0x5A, "RAM mirror folds onto main RAM");
+ CHECK(render_pass_store_to(&t, 0x1F800010u, 0xCAFEF00Du, 4) == -1 &&
+ s_spad[0x10] == 0x0D && s_spad[0x13] == 0xCA, "scratchpad written");
+ CHECK(s_mmio_calls == 0, "memory stores never touch MMIO");
+
+ /* Dropped, per class, and never delivered. */
+ CHECK(render_pass_store_to(&t, 0x1F801D88u, 0xFFFFu, 2) == RENDER_PASS_DROP_SPU,
+ "SPU key-on dropped (SPU)");
+ CHECK(render_pass_store_to(&t, 0x1F801C00u, 0x3FFFu, 2) == RENDER_PASS_DROP_SPU,
+ "SPU voice volume dropped (SPU)");
+ CHECK(render_pass_store_to(&t, 0x1F801801u, 0x1Bu, 1) == RENDER_PASS_DROP_CD,
+ "CD command dropped (CD)");
+ CHECK(render_pass_store_to(&t, 0x1F801104u, 0x58u, 4) == RENDER_PASS_DROP_TIMER,
+ "timer mode dropped (TIMER)");
+ CHECK(render_pass_store_to(&t, 0x1F801128u, 100u, 2) == RENDER_PASS_DROP_TIMER,
+ "timer target dropped (TIMER)");
+ CHECK(render_pass_store_to(&t, 0x1F8010C0u, 0x80100000u, 4) == RENDER_PASS_DROP_DMA,
+ "SPU DMA MADR dropped (DMA)");
+ CHECK(render_pass_store_to(&t, 0x1F8010B8u, 0x11000000u, 4) == RENDER_PASS_DROP_DMA,
+ "CD DMA CHCR dropped (DMA)");
+ CHECK(render_pass_store_to(&t, 0x1F801814u, 0x00000000u, 4) == RENDER_PASS_DROP_GPU,
+ "GP1 reset dropped (GPU)");
+ CHECK(render_pass_store_to(&t, 0x1F801810u, 0xE1000000u, 2) == RENDER_PASS_DROP_GPU,
+ "a half-word GP0 store is dropped (GPU)");
+ CHECK(render_pass_store_to(&t, 0x1F801040u, 0x11u, 1) == RENDER_PASS_DROP_OTHER,
+ "SIO dropped (OTHER)");
+ CHECK(render_pass_store_to(&t, 0x1F801000u, 0x1F000000u, 4) == RENDER_PASS_DROP_OTHER,
+ "memory control dropped (OTHER)");
+ CHECK(render_pass_store_to(&t, 0x1F801820u, 0u, 4) == RENDER_PASS_DROP_OTHER,
+ "MDEC dropped (OTHER)");
+ CHECK(render_pass_store_to(&t, 0x1F000000u, 1u, 1) == RENDER_PASS_DROP_OTHER,
+ "expansion 1 dropped (OTHER)");
+ CHECK(render_pass_store_to(&t, 0xFFFE0130u, 0x804u, 4) == RENDER_PASS_DROP_OTHER,
+ "KSEG2 cache control dropped (OTHER)");
+ CHECK(s_mmio_calls == 0, "no dropped store reached a device");
+
+ /* Allowed: all restored after the pass. */
+ calls = s_mmio_calls;
+ CHECK(render_pass_store_to(&t, 0x1F801810u, 0x28FFFFFFu, 4) == -1, "GP0 word allowed");
+ CHECK(render_pass_store_to(&t, 0x1F801814u, 0x04000002u, 4) == -1, "GP1 DMA mode allowed");
+ CHECK(render_pass_store_to(&t, 0x1F801814u, 0x10000007u, 4) == -1, "GP1 info allowed");
+ CHECK(render_pass_store_to(&t, 0x1F801070u, 0xFFFFFFFEu, 4) == -1, "I_STAT ack allowed");
+ CHECK(render_pass_store_to(&t, 0x1F801074u, 0x0000000Du, 4) == -1, "I_MASK allowed");
+ CHECK(render_pass_store_to(&t, 0x1F8010A8u, 0x01000401u, 4) == -1, "GPU DMA allowed");
+ CHECK(render_pass_store_to(&t, 0x1F8010E8u, 0x11000002u, 4) == -1, "OTC DMA allowed");
+ CHECK(render_pass_store_to(&t, 0x1F8010F0u, 0x0F6F4321u, 4) == -1, "DPCR allowed");
+ CHECK(s_mmio_calls == calls + 8, "allowed stores reached their device");
+
+ /* Isolated cache: nothing but the I-cache would change. */
+ t.isolate_cache = 1;
+ s_ram[0x2000] = 0x77;
+ CHECK(render_pass_store_to(&t, 0x80002000u, 0u, 4) == -1 && s_ram[0x2000] == 0x77,
+ "IsC store absorbed, RAM untouched");
+}
+
+/* ---- 2. a real pass ------------------------------------------------------ */
+static int pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) {
+ (void)user; (void)alpha_q16;
+ cpu->gpr[8] = 0xDEADu;
+ guest_store(0x80010000u, 0x12345678u, 4); /* RAM */
+ guest_store(0x1F800020u, 0x99u, 1); /* scratchpad */
+ guest_store(0x1F801D88u, 0x00FFu, 2); /* SPU key-on */
+ guest_store(0x1F801DAAu, 0xC000u, 2); /* SPU control */
+ guest_store(0x1F801801u, 0x0Au, 1); /* CD: Init */
+ guest_store(0x1F801124u, 0x0000u, 4); /* timer 2 mode */
+ guest_store(0x1F8010C8u, 0x01000201u, 4); /* SPU DMA CHCR */
+ guest_store(0x1F801814u, 0x03000001u, 4); /* GP1 display off */
+ guest_store(0x1F801044u, 0x0001u, 2); /* SIO */
+ guest_store(0x1F801810u, 0x02000000u, 4); /* GP0 fill: allowed */
+ guest_store(0x1F801070u, 0u, 4); /* ack every IRQ */
+ guest_store(0x1F801074u, 0u, 4); /* mask every IRQ */
+ for (int i = 0; i < 1000; i++) psx_advance_cycles(1000u); /* 10^6 */
+ /* A device-side change the store policy cannot see (as if some runtime
+ * path wrote a timer directly): the checkpoint must still undo it. */
+ timers_write(0x1F801100u, 0x1234u);
+ return 1;
+}
+
+static int never_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) {
+ (void)cpu; (void)user; (void)alpha_q16;
+ failures++;
+ fprintf(stderr, "FAIL: a refused pass ran its callback\n");
+ return 1;
+}
+
+static void test_pass(void) {
+ CPUState cpu;
+ PSXModRenderPass pass;
+ RenderPassStats st;
+ uint16_t tc0[3], tt0[3], tc1[3], tt1[3];
+ uint32_t tm0[3], tf0[3], tm1[3], tf1[3];
+ int32_t ti0[3], ti1[3];
+ uint8_t ram0[64], spad0[64];
+ uint64_t cyc0, dev0, next0;
+ uint32_t stat0, mask0;
+ int irqs0;
+
+ memset(&cpu, 0, sizeof cpu);
+ cpu.gpr[8] = 0x55u;
+ memset(s_ram, 0xA5, sizeof s_ram);
+ memset(s_spad, 0x3C, sizeof s_spad);
+ psx_cycles_resync_after_restore(NULL);
+
+ /* Live machine: timer 2 counts the system clock and interrupts on its
+ * target every 1000 cycles; timer 0 runs free. */
+ timers_init();
+ timers_write(0x1F801128u, 1000u);
+ timers_write(0x1F801124u, 0x0058u);
+ i_mask = 0x0040u; /* timer 2 unmasked */
+ i_stat = 0x0001u; /* a pending VBlank */
+ psx_advance_cycles(2500u);
+ CHECK(s_irqs_raised > 0, "sanity: timer 2 interrupts while time is live");
+
+ memset(&pass, 0, sizeof pass);
+ pass.struct_size = sizeof pass;
+ pass.w = 320; pass.h = 240;
+ pass.alpha_q16 = 32768u;
+
+ timers_get_snapshot(tc0, tm0, tt0, ti0, tf0);
+ memcpy(ram0, s_ram + 0x10000, sizeof ram0);
+ memcpy(spad0, s_spad, sizeof spad0);
+ cyc0 = psx_cycle_count;
+ next0 = psx_next_service_cycle;
+ dev0 = s_dev_cycles;
+ stat0 = i_stat;
+ mask0 = i_mask;
+ irqs0 = s_irqs_raised;
+ s_mmio_calls = 0;
+
+ CHECK(psx_mod_render_pass(&cpu, &pass, pass_fn, NULL) == 1, "the pass ran and was kept");
+ render_pass_get_stats(&st);
+ CHECK(st.passes == 1 && s_kept == 1 && s_open_passes == 0, "one image, pass closed");
+ CHECK(st.dropped[RENDER_PASS_DROP_SPU] == 2, "2 SPU stores dropped and counted");
+ CHECK(st.dropped[RENDER_PASS_DROP_CD] == 1, "1 CD store dropped and counted");
+ CHECK(st.dropped[RENDER_PASS_DROP_TIMER] == 1, "1 timer store dropped and counted");
+ CHECK(st.dropped[RENDER_PASS_DROP_DMA] == 1, "1 other-DMA store dropped and counted");
+ CHECK(st.dropped[RENDER_PASS_DROP_GPU] == 1, "1 GP1 store dropped and counted");
+ CHECK(st.dropped[RENDER_PASS_DROP_OTHER] == 1, "1 SIO store dropped and counted");
+ CHECK(s_mmio_calls == 3, "only GP0, I_STAT and I_MASK reached a device");
+ CHECK(s_irqs_raised == irqs0, "no interrupt raised in 10^6 frozen cycles");
+ CHECK(s_dev_cycles == dev0, "no device advanced");
+ CHECK(psx_cycle_count == cyc0 && psx_next_service_cycle == next0,
+ "guest clock and service deadline restored");
+ CHECK(i_stat == stat0 && i_mask == mask0, "I_STAT / I_MASK restored");
+ CHECK(s_irq_refresh > 0, "CAUSE IP2 recomputed after the restore");
+ timers_get_snapshot(tc1, tm1, tt1, ti1, tf1);
+ CHECK(memcmp(tc0, tc1, sizeof tc0) == 0 && memcmp(tm0, tm1, sizeof tm0) == 0 &&
+ memcmp(tt0, tt1, sizeof tt0) == 0 && memcmp(ti0, ti1, sizeof ti0) == 0 &&
+ memcmp(tf0, tf1, sizeof tf0) == 0, "timers exactly as before");
+ CHECK(memcmp(ram0, s_ram + 0x10000, sizeof ram0) == 0, "RAM restored");
+ CHECK(memcmp(spad0, s_spad, sizeof spad0) == 0, "scratchpad restored");
+ CHECK(cpu.gpr[8] == 0x55u, "CPU registers restored");
+ CHECK(!g_psx_render_pass_active, "time is live again");
+
+ /* Time is live again: the same cycles now move the timer and interrupt. */
+ psx_advance_cycles(2500u);
+ CHECK(s_irqs_raised > irqs0, "timer 2 interrupts again after the pass");
+
+ /* Status gates refuse without running the callback. */
+ s_fast_forward = 1;
+ CHECK(psx_mod_render_pass_status() == PSX_MOD_RENDER_PASS_FAST_FORWARD,
+ "fast-forward status");
+ CHECK(psx_mod_render_pass(&cpu, &pass, never_fn, NULL) == 0,
+ "no pass during fast-forward");
+ s_fast_forward = 0;
+ s_gl_status = PSX_MOD_RENDER_PASS_BACKEND;
+ CHECK(psx_mod_render_pass_status() == PSX_MOD_RENDER_PASS_BACKEND,
+ "backend status");
+ CHECK(psx_mod_render_pass(&cpu, &pass, never_fn, NULL) == 0,
+ "no pass while the backend declines");
+ s_gl_status = PSX_MOD_RENDER_PASS_READY;
+ CHECK(psx_mod_render_pass_status() == PSX_MOD_RENDER_PASS_READY, "ready again");
+}
+
+/* ---- 2b. 8 MiB main RAM live (psx.enhancement.8mb-ram) ------------------ */
+static int s_retail_fold_seen;
+static int pass8_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) {
+ (void)user; (void)alpha_q16;
+ cpu->gpr[9] = 0xBEEFu;
+ guest_store(0x80600000u, 0xCAFEBABEu, 4); /* unique above 2 MiB */
+ guest_store(0xA07FFFFCu, 0x01020304u, 4); /* last word of the map */
+ guest_store(0x80200010u, 0x55u, 1); /* no retail fold */
+ guest_store(0x80000100u, 0x66u, 1); /* low RAM as well */
+ return 1;
+}
+
+static int pass_retail_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) {
+ const uint8_t *before = (const uint8_t *)user;
+ (void)cpu; (void)alpha_q16;
+ guest_store(0x80600010u, 0x77u, 1); /* retail: folds onto 0x10 */
+ s_retail_fold_seen = s_ram[0x10] == 0x77 && s_ram[0x600010] == before[0x600010];
+ return 1;
+}
+
+static void test_ram_8mb(void) {
+ RenderPassStoreTarget t;
+ CPUState cpu;
+ PSXModRenderPass pass;
+ RenderPassStats st0, st1;
+ uint8_t *ram0 = (uint8_t *)malloc(RAM_8MB);
+ if (!ram0) { CHECK(0, "8 MiB test buffer"); return; }
+
+ /* Store policy with the expanded map: the window no longer mirrors. */
+ s_ram_live = RAM_8MB;
+ t = target();
+ memset(s_ram, 0, sizeof s_ram);
+ CHECK(render_pass_store_to(&t, 0x80600000u, 0x11223344u, 4) == -1 &&
+ s_ram[0x600000] == 0x44 && s_ram[0x600003] == 0x11 && s_ram[0] == 0,
+ "8 MiB: 0x80600000 is its own RAM, not a mirror of 0");
+ CHECK(render_pass_store_to(&t, 0x00201008u, 0x5Au, 1) == -1 &&
+ s_ram[0x201008] == 0x5A && s_ram[0x1008] == 0,
+ "8 MiB: 0x00201008 is not folded onto 0x1008");
+ CHECK(render_pass_store_to(&t, 0xA07FFFFEu, 0xBEEFu, 2) == -1 &&
+ s_ram[0x7FFFFE] == 0xEF && s_ram[0x7FFFFF] == 0xBE,
+ "8 MiB: the top of the window is RAM");
+ CHECK(render_pass_store_to(&t, 0x80800000u, 1u, 1) == RENDER_PASS_DROP_OTHER,
+ "8 MiB: past the decode window is not RAM");
+
+ /* A real pass: all 8 MiB come back, the upper 6 MiB included. */
+ for (uint32_t i = 0; i < RAM_8MB; i++)
+ s_ram[i] = (uint8_t)((i * 2654435761u) >> 24);
+ memcpy(ram0, s_ram, RAM_8MB);
+ memset(&cpu, 0, sizeof cpu);
+ memset(&pass, 0, sizeof pass);
+ pass.struct_size = sizeof pass;
+ pass.w = 320; pass.h = 240;
+ pass.alpha_q16 = 16384u;
+ render_pass_get_stats(&st0);
+ CHECK(psx_mod_render_pass(&cpu, &pass, pass8_fn, NULL) == 1,
+ "8 MiB: the pass ran and was kept");
+ render_pass_get_stats(&st1);
+ CHECK(st1.passes == st0.passes + 1, "8 MiB: one more pass");
+ CHECK(memcmp(ram0, s_ram, RAM_8MB) == 0,
+ "8 MiB: all of RAM restored, above 2 MiB too");
+ CHECK(cpu.gpr[9] == 0u, "8 MiB: CPU registers restored");
+
+ /* The next session without the mod (retail again): stores fold into the
+ * low 2 MiB and the restore covers them; the backing bytes above the
+ * live size are neither written nor needed. */
+ s_ram_live = RAM_2MB;
+ s_retail_fold_seen = 0;
+ CHECK(psx_mod_render_pass(&cpu, &pass, pass_retail_fn, ram0) == 1,
+ "retail after 8 MiB: the pass ran");
+ CHECK(s_retail_fold_seen, "retail after 8 MiB: 0x80600010 folded onto 0x10");
+ CHECK(memcmp(ram0, s_ram, RAM_8MB) == 0,
+ "retail after 8 MiB: RAM restored");
+ free(ram0);
+}
+
+/* ---- 3. VRAM journal ----------------------------------------------------- */
+#define VW 1024
+#define VH 512
+static uint16_t s_vram[VW * VH], s_vram0[VW * VH];
+
+static void paint(int x, int y, int w, int h, uint16_t v) {
+ for (int r = y; r < y + h; r++)
+ for (int c = x; c < x + w; c++) s_vram[r * VW + c] = v;
+}
+
+static void test_journal(void) {
+ RenderPassJournal j;
+ int x, y, w, h, n;
+ memset(&j, 0, sizeof j);
+ for (int i = 0; i < VW * VH; i++) s_vram[i] = (uint16_t)(i * 2654435761u >> 16);
+ memcpy(s_vram0, s_vram, sizeof s_vram);
+
+ /* The pass draws buffer 1 at (0, 240) 320x240. */
+#define POLICY(X, Y, W, H, CAN) \
+ (x = (X), y = (Y), w = (W), h = (H), \
+ render_pass_vram_policy(&j, 0, 240, 320, 240, VW, VH, (CAN), &x, &y, &w, &h))
+ CHECK(POLICY(10, 250, 100, 100, 1) == RENDER_PASS_VRAM_ALLOW, "inside the rect: allowed");
+ paint(10, 250, 100, 100, 0x1111);
+ CHECK(POLICY(0, 0, 0, 5, 1) == RENDER_PASS_VRAM_ALLOW, "empty write: allowed");
+
+ CHECK(POLICY(640, 0, 64, 32, 1) == RENDER_PASS_VRAM_JOURNAL, "outside: journal");
+ CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 0, "journaled as entry 0");
+ paint(640, 0, 64, 32, 0x2222);
+ CHECK(POLICY(650, 4, 16, 16, 1) == RENDER_PASS_VRAM_ALLOW, "covered: not journaled twice");
+ paint(650, 4, 16, 16, 0x3333);
+
+ CHECK(POLICY(300, 200, 40, 60, 1) == RENDER_PASS_VRAM_JOURNAL, "straddling the rect: journal");
+ CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 1, "entry 1");
+ paint(300, 200, 40, 60, 0x4444);
+
+ /* Overlapping entry 0 partly: rolled back newest first. */
+ CHECK(POLICY(600, 16, 64, 32, 1) == RENDER_PASS_VRAM_JOURNAL, "overlapping: journal");
+ CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 2, "entry 2");
+ paint(600, 16, 64, 32, 0x5555);
+
+ /* Clipped to VRAM. */
+ CHECK(POLICY(-8, -8, 24, 24, 1) == RENDER_PASS_VRAM_JOURNAL && x == 0 && y == 0 &&
+ w == 16 && h == 16, "negative origin clipped");
+ CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 3, "entry 3");
+ paint(x, y, w, h, 0x6666);
+ CHECK(POLICY(1016, 508, 32, 32, 1) == RENDER_PASS_VRAM_JOURNAL && w == 8 && h == 4,
+ "far edge clipped");
+ CHECK(render_pass_journal_add(&j, s_vram, VW, x, y, w, h) == 4, "entry 4");
+ paint(x, y, w, h, 0x7777);
+
+ CHECK(POLICY(800, 300, 8, 8, 0) == RENDER_PASS_VRAM_REFUSE,
+ "a mode that cannot journal refuses");
+
+ n = j.n;
+ while (j.n < RENDER_PASS_JOURNAL_MAX) {
+ int k = j.n;
+ CHECK(POLICY(400 + 8 * k, 100, 4, 4, 1) == RENDER_PASS_VRAM_JOURNAL, "fill journal");
+ render_pass_journal_add(&j, s_vram, VW, x, y, w, h);
+ paint(x, y, w, h, (uint16_t)(0x8000 + k));
+ }
+ CHECK(j.n == RENDER_PASS_JOURNAL_MAX && n == 5, "journal full");
+ CHECK(POLICY(900, 400, 4, 4, 1) == RENDER_PASS_VRAM_REFUSE, "full journal refuses");
+ CHECK(render_pass_journal_add(&j, s_vram, VW, 900, 400, 4, 4) == -1,
+ "add refuses past the limit");
+
+ /* End of pass: roll back, then the pass rect itself is restored by the
+ * renderer's own backup; outside it VRAM must equal the original. */
+ render_pass_journal_rollback(&j, s_vram, VW);
+ CHECK(j.n == 0, "journal emptied");
+ paint(0, 240, 320, 240, 0);
+ {
+ int bad = 0;
+ uint16_t *a = s_vram, *b = s_vram0;
+ for (int r = 0; r < VH; r++)
+ for (int c = 0; c < VW; c++) {
+ if (r >= 240 && r < 480 && c < 320) continue;
+ if (a[r * VW + c] != b[r * VW + c]) bad++;
+ }
+ CHECK(bad == 0, "every out-of-rect write rolled back exactly");
+ }
+ render_pass_journal_free(&j);
+#undef POLICY
+}
+
+int main(void) {
+ test_store_policy();
+ test_pass();
+ test_ram_8mb();
+ test_journal();
+ printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures);
+ return failures ? 1 : 0;
+}