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; +}