From d22ebac9a4698752874c75ae626fdc2dba178a58 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:36:19 -0700 Subject: [PATCH 01/22] Host-timed render passes for true in-between frames psx_mod_render_pass_plan() / psx_mod_render_pass() let a trusted plugin redraw its game between two logic ticks with the game's own draw code. A pass is a sandboxed nested guest call: - time is frozen: cycles are counted (GTE and mult/div deadlines keep working) but no device advances, no VBlank or device event fires and no interrupt is delivered; GPU linked-list DMA and delayed completions finish synchronously (psx_cycles freeze, interrupts, dma); - stores go straight to RAM/scratchpad, bypassing code-page tracking, overlay watch and write traces; MMIO is an allow-list (GP0, GP1 DMA mode / info, GPU and OTC DMA, I_STAT/I_MASK) and SPU, CD, timer and other device stores are dropped and counted (memory.c); - afterwards CPU (with the GTE), RAM, scratchpad, I-cache tags, interrupt, timer, DMA and GPU registers are restored exactly (render_pass.c, a GPU register checkpoint without savestate side effects), with a guest-cycle watchdog that rolls a runaway pass back; - on OpenGL the declared display rect (hr colour + mask stencil, raw mirror, native-wide band, CPU VRAM rows) is backed up, drawn, captured and rolled back; writes outside it are journaled and restored, or refused. The presenter keeps two generations of images (the game's own frame plus one image per pass phase), promotes one when the flip-aware source sees the display flip to its rect, and shows the newest image at each output deadline's phase, crossfading across gaps when passes were shed. Presents that fall due while passes run are made between them. The plan predicts the output deadlines of the coming frame and sheds passes to fit a host-time budget learnt from the presenter's idle time (PSX_RENDER_PASS_BUDGET). PSX_MOD_FRAME_INTERPOLATION_HOLD repeats the newest frame where no pass image applies, so a pass-driven title never shows anything later than stock. Default off: nothing runs unless a plugin calls the API, and the plan refuses in netplay, rollback, rewind, turbo, self-check resimulation, while suspended (FMV) or without GL flip-aware interpolation. Pass writes are kept out of frame_fingerprint. Debug: render_pass_stats, render_pass_dump, PSX_RENDER_PASS_VERIFY=1 (state + VRAM hashes around every pass). The freeze API lives in the runtime-only psx_cycle_freeze.h. psx_cycles.h is part of the overlay codegen hash and stays byte-identical, so titles that never use passes keep their overlay caches and savestates. Tests: render_pass_plan_test (deadline-exact phases per rate, shedding, selection), render_pass_freeze_test (10^6 frozen cycles, exact restore, watchdog), render_pass_guards (sandbox choke points; the codegen-hashed headers stay free of the pass API). Co-Authored-By: Claude Opus 5.5 --- recompiler/CMakeLists.txt | 1 + runtime/CMakeLists.txt | 16 + runtime/include/dma.h | 1 + runtime/include/frame_interpolation.h | 10 + runtime/include/gpu.h | 4 + runtime/include/gpu_gl_renderer.h | 29 + runtime/include/mod_plugins.h | 56 +- runtime/include/psx_cycle_freeze.h | 45 ++ runtime/include/render_pass.h | 60 ++ runtime/include/render_pass_plan.h | 63 ++ runtime/runtime.cmake | 2 + runtime/src/debug_server.c | 65 ++ runtime/src/dma.c | 14 +- runtime/src/frame_interpolation.c | 22 + runtime/src/gpu.c | 61 ++ runtime/src/gpu_gl_renderer.c | 722 +++++++++++++++++- runtime/src/interrupts.c | 6 + runtime/src/main.cpp | 12 +- runtime/src/memory.c | 66 ++ runtime/src/psx_cycles.c | 70 ++ runtime/src/render_pass.c | 364 +++++++++ runtime/src/render_pass_plan.c | 127 +++ .../tests/test_mod_owned_display_controls.py | 1 + runtime/tests/test_render_pass_freeze.c | 128 ++++ runtime/tests/test_render_pass_guards.py | 91 +++ runtime/tests/test_render_pass_plan.c | 128 ++++ 26 files changed, 2146 insertions(+), 18 deletions(-) create mode 100644 runtime/include/psx_cycle_freeze.h create mode 100644 runtime/include/render_pass.h create mode 100644 runtime/include/render_pass_plan.h create mode 100644 runtime/src/render_pass.c create mode 100644 runtime/src/render_pass_plan.c create mode 100644 runtime/tests/test_render_pass_freeze.c create mode 100644 runtime/tests/test_render_pass_guards.py create mode 100644 runtime/tests/test_render_pass_plan.c diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index ca6c14d5..50138eb4 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -831,6 +831,7 @@ if(BUILD_TESTING) overlay_decodable_fallback overlay_init_guard 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 4264deaa..8d9476bb 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -605,6 +605,22 @@ 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) + 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/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 550f2e9d..8baa1baf 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..5b6f39ef 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -39,6 +39,35 @@ 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); +/* 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. 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); +/* 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. */ +void gl_renderer_pass_diag(uint64_t out[8]); +/* Out-of-rect VRAM writes journaled and rolled back (lifetime count). */ +uint64_t gl_renderer_pass_journaled(void); +/* 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..5f2530f5 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -236,7 +236,12 @@ 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 }; int psx_mod_set_frame_interpolation_blend(uint32_t blend_mode); /* @@ -254,6 +259,55 @@ 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, rewind, turbo, rollback or while the + * presenter is suspended (FMV); psx_mod_render_pass_plan() returns 0 then. + * + * 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); 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/psx_cycle_freeze.h b/runtime/include/psx_cycle_freeze.h new file mode 100644 index 00000000..45f42381 --- /dev/null +++ b/runtime/include/psx_cycle_freeze.h @@ -0,0 +1,45 @@ +#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; +} 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..aa50337e --- /dev/null +++ b/runtime/include/render_pass.h @@ -0,0 +1,60 @@ +#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 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..b91413ac --- /dev/null +++ b/runtime/include/render_pass_plan.h @@ -0,0 +1,63 @@ +#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 + +#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 */ + 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; *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); + +/* Exponential moving average used for the per-pass host cost. */ +double render_pass_ema(double current, double sample); + +/* 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); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 1dda84e7..b4c8e350 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 37eecef1..0b5bf3e7 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -47,6 +47,7 @@ #include "card_data_writes.h" #include "crash_trace.h" #include "gpu_gl_renderer.h" +#include "render_pass.h" #include "lockstep.h" #include "guest_tty.h" @@ -7951,6 +7952,64 @@ 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[8]; + render_pass_get_stats(&st); + gl_renderer_pass_diag(gd); + 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," + "\"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," + "\"unmatched_flips\":%llu,\"early_presents\":%llu," + "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%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.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[4], (unsigned long long)gd[5], + (unsigned long long)gd[6], (unsigned long long)gd[7], + (unsigned long long)gl_renderer_pass_journaled()); +} + +/* 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 @@ -9631,6 +9690,9 @@ void debug_server_trace_write_check(uint32_t phys, uint32_t old_val, return; #endif if (s_fmv_quiet) return; + /* 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 (is_card_critical_addr(phys)) card_trace_record(phys, old_val, new_val, width); fp_record_write(phys, new_val, g_debug_last_store_pc); { @@ -9655,6 +9717,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; @@ -13863,6 +13926,8 @@ 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 }, { "gl_wide_fast", handle_gl_wide_fast }, { "synth_recurse", handle_synth_recurse }, { "gl_fbo_peek", handle_gl_fbo_peek }, 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 7194b278..9b83a038 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -6245,5 +6245,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 d8ac66f1..4a06db20 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -69,6 +69,7 @@ #include "gpu_gl_renderer.h" #include "mod_texture_banks.h" #include "frame_interpolation.h" +#include "render_pass_plan.h" #include "host_osd.h" #include "psx_savestate_menu.h" #include "host_time.h" @@ -364,6 +365,48 @@ 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; +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; +static int pass_rect_contains(int x0, int y0, int x1, int y1) { + return x0 >= s_pass_x && y0 >= s_pass_y && + x1 < s_pass_x + s_pass_w && y1 < s_pass_y + s_pass_h; +} +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 or native-wide is active, refused and counted (the pass is then rolled + * back without an image). */ +static int pass_refuse_write(const char *what, int x, int y, int w, int h) { + if (!s_pass_active) return 0; + if (w <= 0 || h <= 0) return 0; + 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 0; + if (pass_rect_contains(x, y, x + w - 1, y + h - 1)) return 0; + if (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); @@ -1348,9 +1391,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 +1702,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; @@ -2414,7 +2469,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); } @@ -2441,12 +2497,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; @@ -2628,6 +2686,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. */ @@ -2635,6 +2694,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)) { @@ -3978,6 +4038,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) { @@ -4000,12 +4061,15 @@ 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); @@ -4075,6 +4139,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; @@ -4113,23 +4178,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 @@ -4142,7 +4207,37 @@ static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { p_glActiveTexture(PSXGL_TEXTURE0); } +static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { + interp_draw_textures(s_interp_tex[s_interp_prev], s_interp_tex[s_interp_cur], + alpha, s_interp_blend_mode, lx, ly, lw, lh); +} + +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; + int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); + int lx, ly, lw, lh; + if (s_interp_force_4_3) + letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); + else + letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); + glDisable(GL_SCISSOR_TEST); + glViewport(0, 0, ww, wh); + if (lx != 0 || ly != 0 || lw != ww || lh != wh) { + glClearColor(0.f, 0.f, 0.f, 1.f); + glClear(GL_COLOR_BUFFER_BIT); + } + present_bezel(ww, wh, 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++; + return 1; +} + static int interp_present(float alpha) { + if (s_interp_hold) alpha = 1.0f; if (!s_ctx || !s_interp_enabled || s_interp_suspended || s_interp_valid < 1) return 0; int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); @@ -4167,11 +4262,16 @@ static int interp_present(float alpha) { } 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); @@ -4190,13 +4290,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), @@ -4221,6 +4323,598 @@ 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]; +static GLuint s_pgen_tex[2][PASS_SLOTS]; +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 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; +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; + +static double s_pass_cost_ema = 0.0; /* host ticks per pass */ +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 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; +} + +int gl_renderer_pass_ready(void) { + return s_ctx && s_raster_ok && !s_cpu_auth_dual && s_interp_enabled && + !s_interp_suspended && s_interp_source == 1 && s_interp_valid > 0 && + s_interp_source_hz > 0.0; +} + +/* 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_idle_ticks_accum = s_pass_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_ema; + in.budget = render_pass_budget((double)s_idle_ticks_last, + (double)s_pass_ticks_last, in.frame_length, + (double)s_pass_budget_pct / 100.0); + in.max = max < cap - 1u ? max : cap - 1u; + return render_pass_plan_phases(&in, alpha_q16, wanted); +} + +void gl_renderer_pass_note_cost(uint64_t ticks) { + s_pass_cost_ema = render_pass_ema(s_pass_cost_ema, (double)ticks); + 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; + *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 int pass_gen_textures(int gi, int w, int h) { + if (s_pgen_alloc_w[gi] == w && s_pgen_alloc_h[gi] == h && s_pgen_tex[gi][0]) + return 1; + for (uint32_t i = 0; i < PASS_SLOTS; i++) { + if (!s_pgen_tex[gi][i]) glGenTextures(1, &s_pgen_tex[gi][i]); + glBindTexture(GL_TEXTURE_2D, s_pgen_tex[gi][i]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, + GL_UNSIGNED_BYTE, NULL); + } + s_pgen_alloc_w[gi] = w; + s_pgen_alloc_h[gi] = h; + 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 16 +typedef struct PassJournal { + int x, y, w, h; + GLuint hr_tex, hr_rb, hr_fbo, raw_tex, raw_fbo; + int hr_w, hr_h, raw_w, raw_h; + uint16_t *cpu; + size_t cpu_cap; +} PassJournal; +static PassJournal s_pj[PASS_JOURNAL_MAX]; +static int s_pj_n = 0; +static uint64_t s_pj_total = 0; + +static int pass_journal_protect(int x, int y, int w, int h) { + int S = s_scale; + PassJournal *j; + if (g_wide_w > 0) return 0; /* wide margins are not journaled */ + for (int i = 0; i < s_pj_n; i++) { + PassJournal *e = &s_pj[i]; + if (x >= e->x && y >= e->y && x + w <= e->x + e->w && y + h <= e->y + e->h) + return 1; /* already covered */ + } + if (s_pj_n >= PASS_JOURNAL_MAX) return 0; + j = &s_pj[s_pj_n]; + 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; + if (j->cpu_cap < (size_t)w * (size_t)h) { + free(j->cpu); + j->cpu = (uint16_t *)malloc((size_t)w * (size_t)h * sizeof(uint16_t)); + j->cpu_cap = j->cpu ? (size_t)w * (size_t)h : 0; + if (!j->cpu) return 0; + } + flush_flat_batch(); + flush_tex_batch(); + flush_cpu_upload(); + j->x = x; j->y = y; j->w = w; j->h = h; + 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); + for (int row = 0; row < h; row++) + memcpy(j->cpu + (size_t)row * w, s_vram + (size_t)(y + row) * VRAM_W + x, + (size_t)w * sizeof(uint16_t)); + s_pj_n++; + s_pj_total++; + return 1; +} + +static void pass_journal_rollback(void) { + int S = s_scale; + for (int i = s_pj_n - 1; i >= 0; i--) { + PassJournal *j = &s_pj[i]; + pass_blit(j->hr_fbo, s_hr_fbo, 0, 0, j->x * S, j->y * S, j->w * S, + j->h * S, GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + pass_blit(j->raw_fbo, s_raw_fbo, 0, 0, j->x, j->y, j->w, j->h, + GL_COLOR_BUFFER_BIT); + for (int row = 0; row < j->h; row++) + memcpy(s_vram + (size_t)(j->y + row) * VRAM_W + j->x, + j->cpu + (size_t)row * j->w, (size_t)j->w * sizeof(uint16_t)); + } + s_pj_n = 0; +} + +int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, + uint32_t period_vblanks) { + 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; + 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_textures(gi, 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; + } + + /* 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_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_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)) { + /* 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; + + 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; + if (p > 1.0 + 0.5 / (double)g->period) { + /* The next flip is late: stop showing this frame's passes. */ + g->valid = 0; + s_pgen_expired++; + return 0; + } + if (!render_pass_select(g->phase, g->n, p, &lo, &hi, &t)) 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++; + 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[8]) { + 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)(s_pass_cost_ema * 1e6 / + (double)SDL_GetPerformanceFrequency()); /* us */ + out[7] = (uint64_t)s_pgen[s_pgen_cur].n; +} + +uint64_t gl_renderer_pass_journaled(void) { return s_pj_total; } + +static uint64_t s_idle_ticks_accum_fwd(uint64_t add) { + s_idle_ticks_accum += add; + return s_idle_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 29134adf..15ca6267 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" @@ -1472,6 +1473,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(); } extern "C" int psx_mod_set_native_vblank_rate( @@ -1545,7 +1549,8 @@ 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); @@ -1554,7 +1559,10 @@ extern "C" int psx_mod_set_frame_interpolation_blend( g_frame_interpolation_blend = (int)blend_mode; 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; } diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 3e2bf7ea..4f80be84 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -26,6 +26,7 @@ #include "guest_tty.h" #include "psx_cycles.h" #include "psx_memory.h" +#include "render_pass.h" #include "starvation_ring.h" #include #include @@ -1703,10 +1704,73 @@ 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 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; +} + +static void render_pass_store(uint32_t addr, uint32_t val, uint32_t width) { + uint32_t phys; + if (addr >= 0xC0000000u) { /* cache control, KSEG2 */ + g_render_pass_dropped_writes[RENDER_PASS_DROP_OTHER]++; + return; + } + if (sr_ptr && (*sr_ptr & 0x10000u)) return; /* IsC: cache-only store */ + phys = psx_phys_addr(addr); + if (phys < RAM_SIZE) { + for (uint32_t i = 0; i < width; i++) ram[phys + i] = (uint8_t)(val >> (8u * i)); + return; + } + if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { + uint32_t off = phys - 0x1F800000u; + for (uint32_t i = 0; i < width && off + i < SCRATCHPAD_SIZE; i++) + scratchpad[off + i] = (uint8_t)(val >> (8u * i)); + return; + } + if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { + int cls = render_pass_mmio_class(phys, val, width); + if (cls >= 0) { g_render_pass_dropped_writes[cls]++; return; } + if (width == 4) mmio_write32(phys, val); + else if (width == 2) mmio_write16(phys, (uint16_t)val); + else mmio_write8(phys, (uint8_t)val); + return; + } + /* Mod memory, expansion, ROM, unmapped: never written by a pass (mod + * arenas are not part of the pass restore). */ + g_render_pass_dropped_writes[RENDER_PASS_DROP_OTHER]++; +} + 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 +1973,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 +2317,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/psx_cycles.c b/runtime/src/psx_cycles.c index 6859c461..b2743360 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 @@ -784,3 +812,45 @@ 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; + /* 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; +} diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c new file mode 100644 index 00000000..3e963d11 --- /dev/null +++ b/runtime/src/render_pass.c @@ -0,0 +1,364 @@ +/* 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, turbo, self-check resimulation, or + * without the OpenGL presenter's flip-aware interpolation. */ + +#include "render_pass.h" + +#include +#include +#include +#include + +#include "cpu_state.h" +#include "dma.h" +#include "gpu.h" +#include "gpu_gl_renderer.h" +#include "mod_plugins.h" +#include "psx_icache.h" +#include "timers.h" + +extern uint8_t *memory_get_ram_ptr(void); +extern uint8_t *memory_get_scratchpad_ptr(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 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; + +#define RP_RAM_SIZE (2u * 1024u * 1024u) +#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 + +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; +} RenderPassCheckpoint; + +static uint8_t *s_ram_copy; /* RP_RAM_SIZE */ +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; + +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; +} + +/* Everything that must hold before guest code may run frozen. */ +static int passes_allowed(void) { + if (s_stats.disabled || s_nesting || g_psx_render_pass_active) return 0; + if (psx_netplay_active() || psx_netplay_is_resimulating()) return 0; + if (psx_selfcheck_resim_active() || psx_rewind_is_open()) return 0; + if (g_ls_mode || g_ls_replay_active) return 0; + if (psx_get_in_exception()) return 0; + if (dma_gpu_linked_list_active()) return 0; + return 1; +} + +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_open_generation = 1; + s_plan_period = period_vblanks; + return n; +} + +static uint64_t fnv(uint64_t h, const void *p, size_t n) { + const uint8_t *b = (const uint8_t *)p; + for (size_t i = 0; 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(), RP_RAM_SIZE); + 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); + } + return h; +} + +static int checkpoint_save(const CPUState *cpu) { + uint32_t dma_len = dma_snapshot_bytes(); + if (!s_ram_copy) { + s_ram_copy = (uint8_t *)malloc(RP_RAM_SIZE); + if (!s_ram_copy) return 0; + } + 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; + memcpy(s_ram_copy, memory_get_ram_ptr(), RP_RAM_SIZE); + 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; + 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, RP_RAM_SIZE); + 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; +} + +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 void note_fault(const char *what) { + s_stats.aborted++; + if (s_stats.aborted <= 4) + fprintf(stderr, "psxrecomp: render pass rolled back (%s)\n", what); + 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; + 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() || !gl_renderer_pass_ready()) return 0; + + 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; + } + /* 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)) + 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, RP_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++; + } else { + 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; + 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..eefbb202 --- /dev/null +++ b/runtime/src/render_pass_plan.c @@ -0,0 +1,127 @@ +#include "render_pass_plan.h" + +#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) { + double fit = in->pass_cost > 0.0 ? floor(in->budget / in->pass_cost) + : (double)n; + 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; +} + +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; +} + +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; +} diff --git a/runtime/tests/test_mod_owned_display_controls.py b/runtime/tests/test_mod_owned_display_controls.py index 0ff12595..8c6c2b6d 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_freeze.c b/runtime/tests/test_render_pass_freeze.c new file mode 100644 index 00000000..a721052d --- /dev/null +++ b/runtime/tests/test_render_pass_freeze.c @@ -0,0 +1,128 @@ +/* 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. */ + s_overruns = 0; + cyc0 = psx_cycle_count; + if (setjmp(s_jmp) == 0) { + CHECK(psx_cycle_freeze_begin(&save, 200000u, overrun), "freeze with watchdog"); + 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"); + + 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..ebe2ae06 --- /dev/null +++ b/runtime/tests/test_render_pass_guards.py @@ -0,0 +1,91 @@ +#!/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) +cls = body(mem, "static 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()"): + assert gate in rp, "passes must refuse to run when " + gate + +# 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..6fa41d74 --- /dev/null +++ b/runtime/tests/test_render_pass_plan.c @@ -0,0 +1,128 @@ +/* Render-pass planning and presentation-selection math. + * Build/run: ctest -R render_pass_plan_test */ +#include +#include +#include + +#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"); + + 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"); +} + +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"); +} + +int main(void) { + test_counts_per_rate(); + test_shedding(); + test_select(); + test_budget_and_ema(); + printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); + return failures ? 1 : 0; +} From 1a45230e77fa0315b03a6d24717f9542564acaf0 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:04:12 -0700 Subject: [PATCH 02/22] Render passes: trade surplus presents for passes in the budget At high output rates presenting took most of the host time the budget was learnt from, so 200-240 FPS rendered almost no passes and looked like 30 Hz. A shed present is only coalesced while a shed pass is a missing motion sample, so the budget now counts present time beyond two presents per game frame as available. interp_present goes through the same accounted pair presenter (identical GL calls). Measured on an M4 under load (R4, 1x): 240 FPS went from ~20 to ~74 passes/s at 153 presents/s, still 59.9 guest VBlanks/s. Co-Authored-By: Claude Opus 5.5 --- runtime/src/gpu_gl_renderer.c | 57 ++++++++++++++++------------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 4a06db20..32f741d2 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -410,7 +410,6 @@ static int pass_refuse_write(const char *what, int x, int y, int w, int h) { 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 */ @@ -4207,14 +4206,11 @@ static void interp_draw_textures(GLuint prev_tex, GLuint curr_tex, float alpha, p_glActiveTexture(PSXGL_TEXTURE0); } -static void interp_draw_quad(float alpha, int lx, int ly, int lw, int lh) { - interp_draw_textures(s_interp_tex[s_interp_prev], s_interp_tex[s_interp_cur], - alpha, s_interp_blend_mode, lx, ly, lw, lh); -} - +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) @@ -4233,32 +4229,15 @@ static int interp_present_pair(GLuint a, GLuint b, float t, int blend_mode) { 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; - if (!s_ctx || !s_interp_enabled || s_interp_suspended || s_interp_valid < 1) - return 0; - int ww = 0, wh = 0; SDL_GL_GetDrawableSize(s_win, &ww, &wh); - int lx, ly, lw, lh; - if (s_interp_force_4_3) - letterbox_rect_aspect(ww, wh, 4, 3, &lx, &ly, &lw, &lh); - else - letterbox_rect(ww, wh, &lx, &ly, &lw, &lh); - glDisable(GL_SCISSOR_TEST); - glViewport(0, 0, ww, wh); - if (lx != 0 || ly != 0 || lw != ww || lh != wh) { - glClearColor(0.f, 0.f, 0.f, 1.f); - glClear(GL_COLOR_BUFFER_BIT); - } - present_bezel(ww, wh, lx, ly, lw, lh); - interp_draw_quad(alpha, 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++; - return 1; + 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) { @@ -4381,6 +4360,8 @@ static int s_pb_last_dx = 0, s_pb_last_dy = 0, s_pb_last_dw = 0, s_pb_last_ static double s_pass_cost_ema = 0.0; /* host ticks per pass */ 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; @@ -4426,7 +4407,8 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, /* 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_idle_ticks_accum = s_pass_ticks_accum = 0; + 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; @@ -4449,9 +4431,16 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, (double)shown_after_vblanks * sp; in.frame_length = (double)period_vblanks * sp; in.pass_cost = s_pass_cost_ema; - in.budget = render_pass_budget((double)s_idle_ticks_last, - (double)s_pass_ticks_last, in.frame_length, - (double)s_pass_budget_pct / 100.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; return render_pass_plan_phases(&in, alpha_q16, wanted); } @@ -4847,6 +4836,12 @@ static uint64_t s_idle_ticks_accum_fwd(uint64_t 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, From 82a835b20b4e2254ea52109bf6b50795ca2dfc87 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:04:12 -0700 Subject: [PATCH 03/22] frame_fingerprint reset_on_load for A/B runs from one savestate frame_fingerprint reset_on_load=1 restarts 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 the same guest point. Debug tooling only. Co-Authored-By: Claude Opus 5.5 --- runtime/include/debug_server.h | 3 +++ runtime/src/debug_server.c | 21 +++++++++++++++++++++ runtime/src/main.cpp | 3 +++ 3 files changed, 27 insertions(+) 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/src/debug_server.c b/runtime/src/debug_server.c index 0b5bf3e7..b3454379 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5028,8 +5028,29 @@ static void handle_frame(int id, const char *json) * of two runs (native vs interp/oracle): the first frame whose wr or pc differs * is the first-divergence frame. Small integer fields only — no large/ragged * payload, so it never trips the trace-dump JSON/eviction problems. */ +/* 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; + g_fp_wr_hash = g_fp_pc_hash = 1469598103934665603ULL; + g_fp_mmio_hash = g_fp_sp_hash = 1469598103934665603ULL; + g_fp_write_count = g_fp_mmio_count = g_fp_sp_count = 0; + 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; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 15ca6267..00efb41c 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1018,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(); From 1989056c4555f66cf74ef3db435de9d344fda1ef Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:05:16 -0700 Subject: [PATCH 04/22] docs: render passes (API, sandbox, presenter, budget, verification) docs/RENDER_PASSES.md; FRAME_RATE.md gains HOLD and a link; TCP_COMMANDS.md describes gl_interp, render_pass_stats and render_pass_dump and regenerates the command index (which also picks up three commands master had not indexed yet). Co-Authored-By: Claude Opus 5.5 --- docs/FRAME_RATE.md | 4 +- docs/RENDER_PASSES.md | 108 ++++++++++++++++++++++++++++++++++++++++++ docs/TCP_COMMANDS.md | 11 +++-- 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 docs/RENDER_PASSES.md diff --git a/docs/FRAME_RATE.md b/docs/FRAME_RATE.md index e951c0d0..cc9d9482 100644 --- a/docs/FRAME_RATE.md +++ b/docs/FRAME_RATE.md @@ -29,9 +29,11 @@ netplay session or a later offline one never inherits them. |---|---| | `LINEAR` (default) | crossfade `mix(prev, cur, a)` | | `MOTION_ADAPTIVE` | crossfade small changes, switch large changes at a = 0.5 | +| `HOLD` | no crossfade: repeat the newest frame (for titles whose plugin supplies in-between images with render passes) | Blending never invents motion: moving objects ghost, and the image is one -source frame behind the newest one. +source frame behind the newest one. True in-between frames need the game's +own draw code: see [RENDER_PASSES.md](RENDER_PASSES.md). ## Blend source: every VBlank or real flips diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md new file mode 100644 index 00000000..516202b2 --- /dev/null +++ b/docs/RENDER_PASSES.md @@ -0,0 +1,108 @@ +# Render passes: true in-between frames + +Frame blending ([FRAME_RATE.md](FRAME_RATE.md)) crossfades finished frames; it +cannot show objects in positions the game never drew. A **render pass** lets a +trusted game plugin redraw its scene between two logic ticks with the game's +own draw code, with objects and camera placed part of the way from one tick +to the next. The pass never changes the game: guest time is frozen while it +runs and the machine is restored bit-for-bit afterwards. Its only product is +an image the presenter shows between the game's own frames. + +Default off. Nothing happens unless a plugin calls the API below. + +## API (mod_plugins.h) + +```c +uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, + uint32_t shown_after_vblanks, + uint32_t *alpha_q16, uint32_t max); +int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, + PSXModRenderPassFn fn, void *user); +``` + +Call both from an emulation-thread function-entry hook at the point where +the game has finished the logic of tick n+1 but the display still has to +flip to frame n. For a PsyQ double-buffered loop that is the entry of the +`VSync(0)` that precedes `PutDispEnv`. + +1. `psx_mod_render_pass_plan(P, D, a, max)` returns the phases (Q16, in + (0, 1)) at which the presenter will show frame n: its output deadlines + during the P VBlanks the frame stays on screen, starting after D more + VBlank presents. When the host cannot afford them all, an evenly spread + subset is returned. 0 means "no passes this frame" (see Gates). +2. For each phase, `psx_mod_render_pass()` with the display rect the next + flip shows (the DISPENV rect). `fn(cpu, user, alpha)` may write guest RAM + and call guest functions with `psx_dispatch_call()`; it returns nonzero to + keep the image. The first pass after a plan also captures frame n's own + image (phase 0). + +The presenter uses `PSX_MOD_FRAME_SOURCE_FLIP` to see the +flip, and a plugin that supplies passes normally selects +`PSX_MOD_FRAME_INTERPOLATION_HOLD`, so wherever no pass image applies the +newest game frame is repeated rather than crossfaded one frame late. + +## What a pass may and may not do + +While `fn` runs (`g_psx_render_pass_active`): + +| Area | Behaviour | Where | +|---|---|---| +| Guest clock | cycles are counted (GTE and mult/div deadlines work) but no device is serviced, no VBlank or device event fires | `psx_cycles.c` freeze | +| Interrupts | never delivered | `interrupts.c` | +| GPU DMA | linked lists and delayed completions finish synchronously | `dma.c` | +| RAM / scratchpad stores | written directly, bypassing code-page tracking, overlay watch, write traces and fingerprints | `memory.c` `render_pass_store` | +| MMIO stores | allowed: GP0, GP1 DMA mode / info, GPU and OTC DMA channels, DPCR/DICR, I_STAT/I_MASK. Dropped and counted: SPU (key-ons), CD, timers, SIO, MDEC, other DMA channels, memory control | `memory.c` | +| VRAM | only the declared rect; writes that bypass the scissor elsewhere are journaled and rolled back (refused under native-wide) | `gpu_gl_renderer.c` | +| Runaway code | an 8 M guest-cycle watchdog rolls the pass back | `render_pass.c` | + +After `fn` (success or not) everything is restored: CPU state with the GTE, +2 MiB RAM, scratchpad, I-cache tags, I_STAT/I_MASK, timers, DMA and GPU +registers (without the widescreen side effects of a savestate load), the +VRAM rect (hr colour, mask stencil, raw 16-bit mirror, native-wide band, CPU +VRAM rows), the renderer's coherency bookkeeping, and every clock value. +After 8 faults (watchdog or refused writes) passes stay off for the session. + +## Gates + +The plan returns 0 in netplay, rollback resimulation, self-check, rewind, +lockstep, an exception, while a GPU DMA list is in flight, on anything but +the OpenGL renderer with FLIP-source interpolation, while the presenter is +suspended (FMV), and when no VBlank was presented since the last plan (debug +turbo, headless). + +## Presentation and budget + +The presenter keeps two generations of images: the frame on screen and the +one being built for the next flip. A generation becomes current when the +FLIP source sees the display flip to its rect; from that interval's start +its phases map onto host time. At each output deadline the newest image at +or before the deadline's phase is shown, crossfaded into the next one when +passes were shed. Deadlines that fall due while passes run are presented +between passes, so the frame on screen keeps moving. + +Passes cost host time inside the game's frame. The budget per frame is +`PSX_RENDER_PASS_BUDGET` percent (default 80) of the presenter's idle time +plus the presents beyond two per frame, learnt from the previous frame, over +a smoothed per-pass cost. Nothing ever slows the guest down: when the budget +runs out, fewer passes are rendered. + +Pass images are kept at internal resolution (the size the presenter +captures); two generations fit a 256 MiB budget, which limits passes per +frame at very high internal resolutions. + +## Verifying a title + +- `render_pass_stats` (TCP): passes, shedding, faults, dropped device + stores, timing split (backup / guest code / capture / restore), presents + made from pass images. +- `render_pass_dump path= count=`: PNGs of the next n frames' images + (the game's own frame, then each pass in phase order). +- `PSX_RENDER_PASS_VERIFY=1`: hash CPU, RAM, scratchpad, I-cache, interrupt, + timer, DMA and GPU state and read back the VRAM rect before and after every + pass; `verify_mismatch` must stay 0. +- `frame_fingerprint reset_on_load=1` then a savestate load: the per-frame + write/MMIO/cycle fingerprints of a run with passes must equal a run + without them. + +Tests: `render_pass_plan_test` and `render_pass_freeze_test` (runtime ctest), +`render_pass_guards` (source guard, recompiler ctest). diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 01eb5d27..558a67ce 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -53,6 +53,9 @@ Columns: **N** = native, **D** = DuckStation oracle. | `screenshot_hires` | ✓ | ✓ | `path` | PNG of the **supersampled** surface (the present path the window uses), at `display × gr_scale()`. ⚠ `screenshot`/`screenshot_file` capture native 15-bit VRAM and are **blind to anything that only exists in the hi-res mirror** — geometry correction, SSAA edges, perspective UVs — so they show a clean frame while the player sees a broken one. Use this one to verify those. Falls back to the native resolve (and reports `scale: 1`) when no hi-res surface exists | | `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) | | `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | +| `gl_interp` | ✓ | | — | OpenGL frame-rate presenter ([FRAME_RATE.md](FRAME_RATE.md)): enabled/suspended, host and target Hz, swaps, `source` (`vblank`/`flip`), `flip_period`, `captures` (new source frames) and `duplicates` (VBlanks that re-presented the same frame) | +| `render_pass_stats` | ✓ | | — | Render passes ([RENDER_PASSES.md](RENDER_PASSES.md)): plans, phases wanted/planned (shedding), passes, rollbacks, dropped device stores by class, `verify_mismatch` under `PSX_RENDER_PASS_VERIFY=1`, host-time split per pass, presents made from pass images | +| `render_pass_dump` | ✓ | | `path`, `count` | Write the images (the game's own frame, then each pass in phase order) of the next `count` frames that get passes as `/g__a.png` | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | | `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history | | `irq_state` | ✓ | ✓ | — | `I_STAT`, `I_MASK` (both), plus chain state on native | @@ -336,9 +339,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**316 commands registered** — 303 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**318 commands registered** — 305 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -54 of 316 have prose above; **262 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +57 of 318 have prose above; **261 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -464,7 +467,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `get_snapshots` | ✓ | ✓ | ✓ | | `gl_coh_ring` | ✓ | | | | `gl_fbo_peek` | ✓ | | | -| `gl_interp` | ✓ | | | +| `gl_interp` | ✓ | | ✓ | | `gl_present_ring` | ✓ | | | | `gl_vram_diff` | ✓ | | | | `gl_wide_fast` | ✓ | | | @@ -553,6 +556,8 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `record_frame` | ✓ | | | | `record_frame_dump` | ✓ | | | | `record_reads_dump` | ✓ | | | +| `render_pass_dump` | ✓ | | ✓ | +| `render_pass_stats` | ✓ | | ✓ | | `restore_trace` | ✓ | | | | `restore_trace_clear` | ✓ | | | | `restore_trace_window` | ✓ | | | From eb6b8e49b9c98149194b91a5debc6c1d2c83c359 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:08:53 -0700 Subject: [PATCH 05/22] Render passes: publish overlay cycles first; test the store policy; verify devices - Publish an overlay DLL's pending cycles before a pass freezes the clock; otherwise the pass's first store would publish them into the frozen clock and the restore would drop them from the live timeline. - The pass store policy moves into render_pass_plan.c as render_pass_mmio_class() and gets a table test (SPU key-on, CD, timers, SIO, MDEC and other DMA channels dropped; GP0, GP1 DMA mode/info, GPU and OTC DMA, DICR/DPCR, I_STAT/I_MASK allowed). - PSX_RENDER_PASS_VERIFY also hashes CD, SPU, SIO and MDEC state. Co-Authored-By: Claude Opus 5.5 --- runtime/include/render_pass_plan.h | 6 +++++ runtime/src/memory.c | 22 ++--------------- runtime/src/render_pass.c | 30 ++++++++++++++++++++++++ runtime/src/render_pass_plan.c | 20 ++++++++++++++++ runtime/tests/test_render_pass_guards.py | 6 ++++- runtime/tests/test_render_pass_plan.c | 25 ++++++++++++++++++++ 6 files changed, 88 insertions(+), 21 deletions(-) diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index b91413ac..fc0f1b4a 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -56,6 +56,12 @@ double render_pass_ema(double current, double sample); 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); + #ifdef __cplusplus } #endif diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 4f80be84..8ae6a586 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -27,6 +27,7 @@ #include "psx_cycles.h" #include "psx_memory.h" #include "render_pass.h" +#include "render_pass_plan.h" #include "starvation_ring.h" #include #include @@ -1716,25 +1717,6 @@ static inline void d44_note(uint32_t phys, uint32_t old, uint32_t val) { * counted instead. */ uint64_t g_render_pass_dropped_writes[RENDER_PASS_DROP_CLASSES]; -static 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; -} - static void render_pass_store(uint32_t addr, uint32_t val, uint32_t width) { uint32_t phys; if (addr >= 0xC0000000u) { /* cache control, KSEG2 */ @@ -1754,7 +1736,7 @@ static void render_pass_store(uint32_t addr, uint32_t val, uint32_t width) { return; } if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - int cls = render_pass_mmio_class(phys, val, width); + int cls = render_pass_mmio_class(phys, val, width); /* render_pass_plan.c */ if (cls >= 0) { g_render_pass_dropped_writes[cls]++; return; } if (width == 4) mmio_write32(phys, val); else if (width == 2) mmio_write16(phys, (uint16_t)val); diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 3e963d11..f04127f1 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -52,6 +52,15 @@ 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); +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_RAM_SIZE (2u * 1024u * 1024u) #define RP_SPAD_SIZE 1024u @@ -183,6 +192,22 @@ static uint64_t state_hash(const CPUState *cpu) { 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; } @@ -279,6 +304,11 @@ int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, return 0; if (!passes_allowed() || !gl_renderer_pass_ready()) 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; { diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index eefbb202..ae48aa43 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -1,4 +1,5 @@ #include "render_pass_plan.h" +#include "render_pass.h" #include @@ -125,3 +126,22 @@ double render_pass_budget(double idle_ticks, double pass_ticks, 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; +} diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index ebe2ae06..6420489c 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -54,7 +54,11 @@ for fn in ("void psx_write_word(uint32_t addr, uint32_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) -cls = body(mem, "static int render_pass_mmio_class(") +assert "render_pass_mmio_class(phys, val, width)" in body( + mem, "static void render_pass_store("), ( + "pass MMIO stores must go through the tested store policy") +plan = (SRC / "render_pass_plan.c").read_text(encoding="utf-8") +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 diff --git a/runtime/tests/test_render_pass_plan.c b/runtime/tests/test_render_pass_plan.c index 6fa41d74..f960c01e 100644 --- a/runtime/tests/test_render_pass_plan.c +++ b/runtime/tests/test_render_pass_plan.c @@ -4,6 +4,7 @@ #include #include +#include "render_pass.h" #include "render_pass_plan.h" static int failures; @@ -118,7 +119,31 @@ static void test_budget_and_ema(void) { CHECK(render_pass_ema(4.0, -1.0) == 4.0, "bad samples are ignored"); } +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(); From 1a8f3820850ccf2706a3a369223b57931bf533e2 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:57:26 -0700 Subject: [PATCH 06/22] Render passes: put host nesting back after a watchdog abort The watchdog leaves a runaway pass by longjmp from inside guest code, which skips the exits of every frame it leaves: generated functions' bb_defer cleanup, overlay_loader_call_native's unit depth, the native-shard stack and cycle-flush hook, the DMA kick's exec depth, and the dirty-RAM interpreter's active/phase/precise flags, resume latch and pending load. The checkpoint now saves all of it and the restore puts it back (bb_defer with the cycle freeze), as the scheduler and exception landings do for theirs. An overlay shard's unpublished pass cycles are flushed into the frozen clock at the landing so they never reach the live one. PSX_RENDER_PASS_VERIFY also checks that a pass which returned normally left the nesting balanced, since the restore would hide that. render_pass_stats gains nesting_repairs and the fault log names what the landing undid. render_pass_abort_test aborts a pass from five nested frames (real render_pass.c and psx_cycles.c, mock devices and presenter) and checks every value, the clock and RAM; it fails without this change. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 13 +- docs/TCP_COMMANDS.md | 2 +- runtime/CMakeLists.txt | 13 + runtime/include/dirty_ram_interp.h | 12 + runtime/include/overlay_loader.h | 5 + runtime/include/psx_cycle_freeze.h | 4 + runtime/include/render_pass.h | 1 + runtime/src/debug_server.c | 2 + runtime/src/dirty_ram_interp.c | 16 + runtime/src/overlay_loader.c | 15 + runtime/src/psx_cycles.c | 2 + runtime/src/render_pass.c | 140 ++++++++- runtime/tests/test_render_pass_abort.c | 376 +++++++++++++++++++++++ runtime/tests/test_render_pass_freeze.c | 9 +- runtime/tests/test_render_pass_guards.py | 5 + 15 files changed, 610 insertions(+), 5 deletions(-) create mode 100644 runtime/tests/test_render_pass_abort.c diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 516202b2..4d88ec1c 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -60,6 +60,13 @@ After `fn` (success or not) everything is restored: CPU state with the GTE, 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 @@ -99,10 +106,12 @@ frame at very high internal resolutions. (the game's own frame, then each pass in phase order). - `PSX_RENDER_PASS_VERIFY=1`: hash CPU, RAM, scratchpad, I-cache, interrupt, timer, DMA and GPU state and read back the VRAM rect before and after every - pass; `verify_mismatch` must stay 0. + pass, and check that a pass that returned normally left the host nesting + balanced; `verify_mismatch` must stay 0. - `frame_fingerprint reset_on_load=1` then a savestate load: the per-frame write/MMIO/cycle fingerprints of a run with passes must equal a run without them. -Tests: `render_pass_plan_test` and `render_pass_freeze_test` (runtime ctest), +Tests: `render_pass_plan_test`, `render_pass_freeze_test` and +`render_pass_abort_test` (runtime ctest), `render_pass_guards` (source guard, recompiler ctest). diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 558a67ce..7506c8cc 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -54,7 +54,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `present_shot` | ✓ | | `path` | PNG of the **composed present surface** — the frame after the backend fits the display buffer to the window, so it carries the presented aspect. ⚠ every other capture resolves the display buffer *before* that fit: on a 508×256 display in a 4:3 window they answer 508×256 while the player sees 640×480. Use this one for anything aspect-shaped (widescreen, letterbox), where a pre-fit buffer would hide the very stage the change touches. Staged and fulfilled on the next present, so the ack means *queued* — poll `present_shot_seq`. Unavailable headless and on the Vulkan backend (its swapchain has no readback hook) | | `present_shot_seq` | ✓ | | — | Completion counter for `present_shot`, plus `wrote` (1 = that completion produced a PNG). Sample before staging, poll until `seq` moves. Advances on success *and* failure, so the poll always terminates | | `gl_interp` | ✓ | | — | OpenGL frame-rate presenter ([FRAME_RATE.md](FRAME_RATE.md)): enabled/suspended, host and target Hz, swaps, `source` (`vblank`/`flip`), `flip_period`, `captures` (new source frames) and `duplicates` (VBlanks that re-presented the same frame) | -| `render_pass_stats` | ✓ | | — | Render passes ([RENDER_PASSES.md](RENDER_PASSES.md)): plans, phases wanted/planned (shedding), passes, rollbacks, dropped device stores by class, `verify_mismatch` under `PSX_RENDER_PASS_VERIFY=1`, host-time split per pass, presents made from pass images | +| `render_pass_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, presents made from pass images | | `render_pass_dump` | ✓ | | `path`, `count` | Write the images (the game's own frame, then each pass in phase order) of the next `count` frames that get passes as `/g__a.png` | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | | `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history | diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 8d9476bb..1d97dd22 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -621,6 +621,19 @@ if(BUILD_TESTING) 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() + 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/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/overlay_loader.h b/runtime/include/overlay_loader.h index 5cd8c46e..1eb0e03b 100644 --- a/runtime/include/overlay_loader.h +++ b/runtime/include/overlay_loader.h @@ -145,6 +145,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 index 45f42381..ba300bb0 100644 --- a/runtime/include/psx_cycle_freeze.h +++ b/runtime/include/psx_cycle_freeze.h @@ -33,6 +33,10 @@ typedef struct PsxCycleFreeze { 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)); diff --git a/runtime/include/render_pass.h b/runtime/include/render_pass.h index aa50337e..d1b9a84c 100644 --- a/runtime/include/render_pass.h +++ b/runtime/include/render_pass.h @@ -35,6 +35,7 @@ typedef struct RenderPassStats { 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]; diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index b3454379..4d21f020 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7986,6 +7986,7 @@ static void handle_render_pass_stats(int id, const char *json) 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}," @@ -8001,6 +8002,7 @@ static void handle_render_pass_stats(int id, const char *json) (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], diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 79aaa721..72419d7c 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/overlay_loader.c b/runtime/src/overlay_loader.c index cac04bb4..c75abf86 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -2233,6 +2233,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 b2743360..cc5c07b2 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -825,6 +825,7 @@ int psx_cycle_freeze_begin(PsxCycleFreeze *save, uint64_t watchdog_cycles, 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; @@ -853,4 +854,5 @@ void psx_cycle_freeze_end(const PsxCycleFreeze *save) { 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 index f04127f1..75bd6ea9 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -22,10 +22,12 @@ #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" @@ -53,6 +55,17 @@ 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); @@ -71,6 +84,108 @@ extern void mdec_snapshot_write(uint8_t *p); /* 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]; @@ -83,6 +198,7 @@ typedef struct RenderPassCheckpoint { uint32_t last_store_pc, current_func; int dispatch_depth, call_bail; uint32_t dma_len; + RenderPassNesting nest; } RenderPassCheckpoint; static uint8_t *s_ram_copy; /* RP_RAM_SIZE */ @@ -239,6 +355,8 @@ static int checkpoint_save(const CPUState *cpu) { 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; } @@ -261,6 +379,7 @@ static void checkpoint_restore(CPUState *cpu) { 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; @@ -278,10 +397,15 @@ static void watchdog_overrun(void) { } } +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)\n", what); + 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; @@ -339,7 +463,21 @@ int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, 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; diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c new file mode 100644 index 00000000..6333968c --- /dev/null +++ b/runtime/tests/test_render_pass_abort.c @@ -0,0 +1,376 @@ +/* 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; } +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; } +int gl_renderer_pass_ready(void) { return 1; } +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) { + (void)x; (void)y; (void)w; (void)h; (void)open_gen; (void)period; + 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]; + + setenv("PSX_RENDER_PASS_VERIFY", "1", 1); + 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 index a721052d..061d8a0e 100644 --- a/runtime/tests/test_render_pass_freeze.c +++ b/runtime/tests/test_render_pass_freeze.c @@ -110,11 +110,15 @@ int main(void) { s_cd_events >= cd0 + 3u, "devices resume on the live clock after the pass"); - /* Watchdog: a pass that runs away is cut off. */ + /* 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"); } @@ -122,6 +126,9 @@ int main(void) { 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 index 6420489c..d2cd3309 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -75,6 +75,11 @@ for gate in ("psx_netplay_active()", "psx_rewind_is_open()", "dma_gpu_linked_list_active()"): assert gate in rp, "passes must refuse to run when " + gate +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)") + # 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 From ea1e67a4236ca926992a851f1f0a7a383be79d02 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:57:32 -0700 Subject: [PATCH 07/22] Render passes: PSX_RENDER_PASS_WATCHDOG to exercise the rollback path A debug override that lowers the guest-cycle watchdog (1000 up to the 8 M default), so a title's real passes can be driven through the abort path and checked with fingerprints against a run without passes. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 4 ++++ runtime/src/render_pass.c | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 4d88ec1c..c65e9c4c 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -108,6 +108,10 @@ frame at very high internal resolutions. 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. diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 75bd6ea9..52ac987d 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -214,6 +214,23 @@ static int s_verify = -1; static int s_open_generation; /* next pass captures frame N's image */ static uint32_t s_plan_period = 2; +/* 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"); @@ -456,7 +473,7 @@ int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, tb = gl_renderer_perf_ticks(); s_nesting = 1; cycles_before = psx_cycle_count; - (void)psx_cycle_freeze_begin(&s_freeze, RP_WATCHDOG_CYCLES, + (void)psx_cycle_freeze_begin(&s_freeze, watchdog_cycles(), watchdog_overrun); if (setjmp(s_abort_jmp) == 0) { s_abort_armed = 1; From 856cbabffebac0964c5edb6dad2e7bb7ede6c119 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:57:39 -0700 Subject: [PATCH 08/22] Render passes: make pass image textures as slots fill pass_slot_cap() limits a generation to what two generations fit in 256 MiB, but pass_gen_textures() allocated all 17 slots of each generation at internal-resolution size as soon as passes ran, whatever the cap: 34 textures, about 167 MB at 4x and 1.5 GB at 12x for at most two used slots. Slot textures are now made on first use, never past the cap, so the budget holds at any internal scale; a size change frees the old set, and context teardown frees every pass texture, backup and journal FBO and forgets their names (a new context no longer reuses dead ones). render_pass_stats reports image_textures and image_bytes. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 5 +- docs/TCP_COMMANDS.md | 2 +- runtime/include/gpu_gl_renderer.h | 2 + runtime/src/debug_server.c | 10 ++- runtime/src/gpu_gl_renderer.c | 86 +++++++++++++++++++++--- runtime/tests/test_render_pass_guards.py | 11 +++ 6 files changed, 100 insertions(+), 16 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index c65e9c4c..989de283 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -94,8 +94,9 @@ a smoothed per-pass cost. Nothing ever slows the guest down: when the budget runs out, fewer passes are rendered. Pass images are kept at internal resolution (the size the presenter -captures); two generations fit a 256 MiB budget, which limits passes per -frame at very high internal resolutions. +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 diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 7506c8cc..792f2945 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -54,7 +54,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `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, presents made from pass images | +| `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, presents made from pass images, pass image textures allocated (`image_textures`, `image_bytes`) | | `render_pass_dump` | ✓ | | `path`, `count` | Write the images (the game's own frame, then each pass in phase order) of the next `count` frames that get passes as `/g__a.png` | | `geom_correction` | | ✓ | — | `[video] geometry_correction` / `perspective_texturing` engagement: enable flag plus free-running `geometry_vertex_hits` and `perspective_triangles` totals. Both enhancements silently fall back to the faithful path on anything they cannot prove is projected geometry, so a zero counter with the flag on means the title never qualifies — sample twice and diff for a rate | | `sio_state` | ✓ | ✓ | — | SIO registers + (native only) pad/memcard protocol + TX/RX history | diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 5b6f39ef..eb787047 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -64,6 +64,8 @@ void gl_renderer_pass_service_presents(void); void gl_renderer_pass_diag(uint64_t out[8]); /* Out-of-rect VRAM writes journaled and rolled back (lifetime count). */ uint64_t gl_renderer_pass_journaled(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); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 4d21f020..2aeb9337 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7980,9 +7980,11 @@ static void handle_render_pass_stats(int id, const char *json) { (void)json; RenderPassStats st; - uint64_t gd[8]; + uint64_t gd[8], 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," @@ -7997,7 +7999,8 @@ static void handle_render_pass_stats(int id, const char *json) "\"promotions\":%llu,\"pass_presents\":%llu," "\"blended_presents\":%llu,\"expired\":%llu," "\"unmatched_flips\":%llu,\"early_presents\":%llu," - "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%llu}", + "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%llu," + "\"image_textures\":%u,\"image_bytes\":%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, @@ -8018,7 +8021,8 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)gd[2], (unsigned long long)gd[3], (unsigned long long)gd[4], (unsigned long long)gd[5], (unsigned long long)gd[6], (unsigned long long)gd[7], - (unsigned long long)gl_renderer_pass_journaled()); + (unsigned long long)gl_renderer_pass_journaled(), + (unsigned)image_textures, (unsigned long long)image_bytes); } /* render_pass_dump path= count=: write the images (the game's own diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 32f741d2..d9320b49 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -3212,7 +3212,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]); @@ -4335,7 +4338,11 @@ typedef struct PassGen { 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; @@ -4484,17 +4491,32 @@ static int pass_make_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo, return 1; } -static int pass_gen_textures(int gi, int w, int h) { - if (s_pgen_alloc_w[gi] == w && s_pgen_alloc_h[gi] == h && s_pgen_tex[gi][0]) - return 1; - for (uint32_t i = 0; i < PASS_SLOTS; i++) { - if (!s_pgen_tex[gi][i]) glGenTextures(1, &s_pgen_tex[gi][i]); - glBindTexture(GL_TEXTURE_2D, s_pgen_tex[gi][i]); +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_pgen_alloc_w[gi] = w; - s_pgen_alloc_h[gi] = h; return 1; } @@ -4570,6 +4592,38 @@ static PassJournal s_pj[PASS_JOURNAL_MAX]; static int s_pj_n = 0; 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); + } + s_pj_n = 0; +} + static int pass_journal_protect(int x, int y, int w, int h) { int S = s_scale; PassJournal *j; @@ -4643,7 +4697,7 @@ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, 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_textures(gi, tw, th)) return 0; + 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; @@ -4718,7 +4772,8 @@ void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { 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)) { + 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; @@ -4831,6 +4886,17 @@ void gl_renderer_pass_diag(uint64_t out[8]) { uint64_t gl_renderer_pass_journaled(void) { return s_pj_total; } +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; diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index d2cd3309..9a26f9ac 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -80,6 +80,17 @@ 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 From 69e6b6b5521a73fa774bc93da05a7a6d0c343777 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:21:40 -0700 Subject: [PATCH 09/22] Render passes: refuse during fast-forward; say why passes are unavailable Manual fast-forward presents every second VBlank, so the plan still saw presents and kept rendering passes, spending host time the fast-forward wants, although the API said turbo refuses them. main.cpp now records when presentation is sped up (manual fast-forward, turbo-through-loads, FMV auto-skip, TCP turbo) and the plan and passes refuse meanwhile. psx_mod_render_pass_status() tells a plugin why passes cannot run, the host-time budget aside: NO_PRESENTER, BACKEND (the renderer declines passes in its current mode), DISABLED (after repeated faults), SESSION, FAST_FORWARD or BUSY. The GL backend reports its part through gl_renderer_pass_unavailable(), where a renderer mode that cannot host passes reports BACKEND. psx_mod_set_frame_interpolation_blend() now also takes effect when called from a hook after activation, so a plugin can show a crossfade while passes are unavailable and return to HOLD afterwards. Debug: PSX_RENDER_PASS_REFUSE=1 or TCP render_pass_refuse on=1 makes the backend decline passes, to test a plugin's fallback; render_pass_stats reports the status. Default off: nothing reads the new flag or status unless a plugin uses render passes. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 40 ++++++++++++++++++++---- docs/TCP_COMMANDS.md | 8 +++-- runtime/include/gpu_gl_renderer.h | 9 ++++++ runtime/include/mod_plugins.h | 35 +++++++++++++++++++-- runtime/src/debug_server.c | 18 +++++++++-- runtime/src/gpu_gl_renderer.c | 37 ++++++++++++++++++++-- runtime/src/main.cpp | 15 +++++++++ runtime/src/render_pass.c | 39 ++++++++++++++++------- runtime/tests/test_render_pass_abort.c | 3 +- runtime/tests/test_render_pass_guards.py | 9 +++++- 10 files changed, 184 insertions(+), 29 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 989de283..9f45a212 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -18,6 +18,7 @@ uint32_t psx_mod_render_pass_plan(uint32_t period_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 @@ -41,13 +42,36 @@ 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 | +| 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 | `memory.c` `render_pass_store` | @@ -72,10 +96,12 @@ After 8 faults (watchdog or refused writes) passes stay off for the session. ## Gates The plan returns 0 in netplay, rollback resimulation, self-check, rewind, -lockstep, an exception, while a GPU DMA list is in flight, on anything but -the OpenGL renderer with FLIP-source interpolation, while the presenter is -suspended (FMV), and when no VBlank was presented since the last plan (debug -turbo, headless). +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 @@ -102,7 +128,9 @@ internal resolutions; a size change frees the old set. - `render_pass_stats` (TCP): passes, shedding, faults, dropped device stores, timing split (backup / guest code / capture / restore), presents - made from pass images. + 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, diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 792f2945..1b4b4a5c 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -54,8 +54,9 @@ Columns: **N** = native, **D** = DuckStation oracle. | `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, presents made from pass images, pass image textures allocated (`image_textures`, `image_bytes`) | +| `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, presents made from pass images, 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) | | `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 | @@ -339,9 +340,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**318 commands registered** — 305 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`). -57 of 318 have prose above; **261 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +58 of 319 have prose above; **261 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -557,6 +558,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `record_frame_dump` | ✓ | | | | `record_reads_dump` | ✓ | | | | `render_pass_dump` | ✓ | | ✓ | +| `render_pass_refuse` | ✓ | | ✓ | | `render_pass_stats` | ✓ | | ✓ | | `restore_trace` | ✓ | | | | `restore_trace_clear` | ✓ | | | diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index eb787047..cd2c3d4f 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -33,6 +33,9 @@ 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); @@ -43,6 +46,12 @@ void gl_renderer_interpolation_source_diag(int *source, uint32_t *flip_period, /* 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, diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 5f2530f5..471041bd 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -243,6 +243,10 @@ enum { * 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 @@ -264,8 +268,10 @@ 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, rewind, turbo, rollback or while the - * presenter is suspended (FMV); psx_mod_render_pass_plan() returns 0 then. + * 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 @@ -308,6 +314,31 @@ uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, * 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/src/debug_server.c b/runtime/src/debug_server.c index 2aeb9337..2c6aa9a1 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -48,6 +48,7 @@ #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" @@ -8000,7 +8001,7 @@ static void handle_render_pass_stats(int id, const char *json) "\"blended_presents\":%llu,\"expired\":%llu," "\"unmatched_flips\":%llu,\"early_presents\":%llu," "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%llu," - "\"image_textures\":%u,\"image_bytes\":%llu}", + "\"image_textures\":%u,\"image_bytes\":%llu,\"status\":%u}", 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, @@ -8022,7 +8023,19 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)gd[4], (unsigned long long)gd[5], (unsigned long long)gd[6], (unsigned long long)gd[7], (unsigned long long)gl_renderer_pass_journaled(), - (unsigned)image_textures, (unsigned long long)image_bytes); + (unsigned)image_textures, (unsigned long long)image_bytes, + (unsigned)psx_mod_render_pass_status()); +} + +/* 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 @@ -13955,6 +13968,7 @@ static const CmdEntry s_commands[] = { { "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/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index d9320b49..5929c7b9 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -70,6 +70,7 @@ #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" @@ -4077,6 +4078,11 @@ void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz "(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(); @@ -4386,10 +4392,35 @@ static void pass_gens_invalidate(void) { 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 s_ctx && s_raster_ok && !s_cpu_auth_dual && s_interp_enabled && - !s_interp_suspended && s_interp_source == 1 && s_interp_valid > 0 && - s_interp_source_hz > 0.0; + return gl_renderer_pass_unavailable() == PSX_MOD_RENDER_PASS_READY; } /* Slots per generation that fit a 256 MiB budget for both generations. */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 00efb41c..0a613d40 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1272,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; @@ -1560,6 +1566,9 @@ extern "C" int psx_mod_set_frame_interpolation_blend( 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" @@ -7084,6 +7093,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; @@ -7096,6 +7109,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; } @@ -7124,6 +7138,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/render_pass.c b/runtime/src/render_pass.c index 52ac987d..8867f9ef 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -11,8 +11,9 @@ * place (memory.c render_pass_store). * * Nothing here runs unless a trusted plugin calls the API, and the plan - * refuses in netplay, rollback, rewind, turbo, self-check resimulation, or - * without the OpenGL presenter's flip-aware interpolation. */ + * 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" @@ -46,6 +47,7 @@ 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; @@ -252,15 +254,30 @@ void render_pass_reset_session(void) { s_open_generation = 0; } -/* Everything that must hold before guest code may run frozen. */ +/* 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) { - if (s_stats.disabled || s_nesting || g_psx_render_pass_active) return 0; - if (psx_netplay_active() || psx_netplay_is_resimulating()) return 0; - if (psx_selfcheck_resim_active() || psx_rewind_is_open()) return 0; - if (g_ls_mode || g_ls_replay_active) return 0; - if (psx_get_in_exception()) return 0; - if (dma_gpu_linked_list_active()) return 0; - return 1; + 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, @@ -443,7 +460,7 @@ int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, pass->w == 0 || pass->h == 0 || pass->alpha_q16 == 0 || pass->alpha_q16 >= 65536u) return 0; - if (!passes_allowed() || !gl_renderer_pass_ready()) 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 diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 6333968c..5a2cd16d 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -141,7 +141,8 @@ 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; } -int gl_renderer_pass_ready(void) { return 1; } +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; diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index 9a26f9ac..7d280c51 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -72,8 +72,15 @@ for fn in ("void debug_server_trace_write_check(", 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()"): + "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, ( From 673e75ec86201b68543b404411debd62d938a7bf Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:47:23 -0700 Subject: [PATCH 10/22] Render passes: reuse the VRAM backup for later passes of a frame Every pass backed up the display rect (hr colour + stencil and raw mirror blits, CPU rows) although the previous pass of the same frame had just restored exactly that backup. On Apple's OpenGL each blit ends the render pass and submits a command buffer, which was about half of a pass's begin time. A pass now reuses the previous pass's backup when render_pass.c vouches that no guest code ran since its restore (same plan, guest clock and store count unchanged) and the rect, scale and native-wide source match; the first pass of every frame still takes a fresh backup. R4 Grand Prix race, 240 Hz interpolated: average pass begin 0.28 -> 0.16 ms. render_pass_stats reports backups_reused. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 5 +++++ docs/TCP_COMMANDS.md | 2 +- runtime/include/gpu_gl_renderer.h | 9 +++++++-- runtime/src/debug_server.c | 6 ++++-- runtime/src/gpu_gl_renderer.c | 21 ++++++++++++++++++++- runtime/src/render_pass.c | 19 +++++++++++++++++-- runtime/tests/test_render_pass_abort.c | 3 ++- 7 files changed, 56 insertions(+), 9 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 9f45a212..a979d201 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -113,6 +113,11 @@ 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. +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. 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 diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 1b4b4a5c..cf466527 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -54,7 +54,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `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, presents made from pass images, 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) | +| `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, presents made from pass images, 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 | diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index cd2c3d4f..1628635e 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -58,9 +58,12 @@ uint32_t gl_renderer_pass_plan(uint32_t period_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. Returns 0 when refused (nothing changed). */ + * 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); + 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); @@ -73,6 +76,8 @@ void gl_renderer_pass_service_presents(void); void gl_renderer_pass_diag(uint64_t out[8]); /* 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. */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 2c6aa9a1..008e1e23 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8001,7 +8001,8 @@ static void handle_render_pass_stats(int id, const char *json) "\"blended_presents\":%llu,\"expired\":%llu," "\"unmatched_flips\":%llu,\"early_presents\":%llu," "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%llu," - "\"image_textures\":%u,\"image_bytes\":%llu,\"status\":%u}", + "\"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, @@ -8024,7 +8025,8 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)gd[6], (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)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 diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 5929c7b9..45a27a6c 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -4362,6 +4362,11 @@ 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]; @@ -4653,6 +4658,7 @@ static void pass_resources_release(void) { pass_free_color_fbo(&e->raw_tex, NULL, &e->raw_fbo, &e->raw_w, &e->raw_h); } s_pj_n = 0; + s_pb_valid = 0; } static int pass_journal_protect(int x, int y, int w, int h) { @@ -4710,7 +4716,7 @@ static void pass_journal_rollback(void) { } int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, - uint32_t period_vblanks) { + 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; @@ -4743,6 +4749,15 @@ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, 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, @@ -4783,7 +4798,9 @@ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, 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"); @@ -4846,6 +4863,7 @@ void gl_renderer_pass_end(uint32_t alpha_q16, int keep) { 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; @@ -4916,6 +4934,7 @@ void gl_renderer_pass_diag(uint64_t out[8]) { } 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]; diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 8867f9ef..4e59d782 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -215,6 +215,12 @@ 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. */ @@ -252,6 +258,7 @@ 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 @@ -298,6 +305,7 @@ uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, } s_stats.plans++; s_stats.planned += n; + s_plan_serial++; s_open_generation = 1; s_plan_period = period_vblanks; return n; @@ -453,7 +461,7 @@ int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, 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; + int ok = 0, open, reuse; static uint32_t s_leaks_before; if (!cpu || !pass || !fn || pass->struct_size < sizeof *pass || @@ -474,9 +482,12 @@ int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, 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)) + s_plan_period, reuse)) return 0; s_open_generation = 0; s_leaks_before = gl_renderer_pass_leaks(); @@ -528,6 +539,10 @@ int psx_mod_render_pass(struct CPUState *cpu, const PSXModRenderPass *pass, 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); diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 5a2cd16d..9286dbfe 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -151,8 +151,9 @@ uint32_t gl_renderer_pass_plan(uint32_t p, uint32_t s, uint32_t *a, } 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) { + 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; } From 3e262c128da3d9f6a8169e4e81096425fb74803e Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:52:39 -0700 Subject: [PATCH 11/22] Render passes: unit-test the sandbox (store drops, frozen devices, journal) The store policy and the CPU side of the out-of-rect VRAM journal move into render_pass_plan.c as pure functions (render_pass_store_to, render_pass_vram_policy, render_pass_journal_add/_rollback); memory.c and gpu_gl_renderer.c only wire them to their arrays and GL copies. Behaviour is unchanged. render_pass_sandbox_test covers what the plan's U5a list asked for: - every device class a pass may not reach (SPU key-on and control, CD, timer, other DMA channels, GP1, SIO, memory control, MDEC, expansion, KSEG2) is dropped under its class and never delivered, while GP0, GP1 DMA mode/info, I_STAT/I_MASK, GPU/OTC DMA and DPCR reach the device; - a real pass (render_pass.c, psx_cycles.c, timers.c) that makes those stores, acknowledges and masks every interrupt, runs 10^6 cycles while timer 2 is armed to interrupt every 1000, and changes a timer behind the store policy's back: drops counted per class, no device advanced, no interrupt raised, RAM, scratchpad, I_STAT/I_MASK, timers and clock restored, and the timer interrupts again once time is live; - fast-forward and backend status refuse the pass without running it; - VRAM writes inside the rect are allowed, outside ones journaled once (covered writes not twice, clipped to VRAM) and rolled back exactly, newest first; a full journal or a mode that cannot journal refuses. Mutations it catches: SPU or CD mis-classed, device service not frozen, rollback order or entry skipped, I_STAT, timer, RAM or scratchpad restore removed. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 24 +- runtime/CMakeLists.txt | 16 + runtime/include/render_pass_plan.h | 51 +++ runtime/src/gpu_gl_renderer.c | 72 ++-- runtime/src/memory.c | 47 +-- runtime/src/render_pass_plan.c | 91 +++++ runtime/tests/test_render_pass_guards.py | 18 +- runtime/tests/test_render_pass_sandbox.c | 483 +++++++++++++++++++++++ 8 files changed, 721 insertions(+), 81 deletions(-) create mode 100644 runtime/tests/test_render_pass_sandbox.c diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index a979d201..428d5d46 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -150,6 +150,24 @@ internal resolutions; a size change frees the old set. write/MMIO/cycle fingerprints of a run with passes must equal a run without them. -Tests: `render_pass_plan_test`, `render_pass_freeze_test` and -`render_pass_abort_test` (runtime ctest), -`render_pass_guards` (source guard, recompiler ctest). +Tests (runtime ctest unless noted): + +- `render_pass_plan_test`: phase planning per rate, shedding, selection, 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 + 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/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 1d97dd22..fdb198bf 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -634,6 +634,22 @@ if(BUILD_TESTING) 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/render_pass_plan.h b/runtime/include/render_pass_plan.h index fc0f1b4a..a9eb33ee 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -12,6 +12,7 @@ * 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 @@ -62,6 +63,56 @@ double render_pass_budget(double idle_ticks, double pass_ticks, * 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, ram_size bytes (mirrored) */ + uint32_t ram_size; + 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 (e.g. native-wide margins are not journaled). */ +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 diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 45a27a6c..387582bf 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -381,10 +381,9 @@ static uint64_t s_idle_ticks_accum_fwd(uint64_t add); static int s_pass_active = 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; -static int pass_rect_contains(int x0, int y0, int x1, int y1) { - return x0 >= s_pass_x && y0 >= s_pass_y && - x1 < s_pass_x + s_pass_w && y1 < s_pass_y + s_pass_h; -} +/* 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 @@ -392,15 +391,14 @@ static int pass_journal_protect(int x, int y, int w, int h); * full or native-wide is active, refused and counted (the pass is then rolled * back without an image). */ static int pass_refuse_write(const char *what, int x, int y, int w, int h) { + int policy; if (!s_pass_active) return 0; - if (w <= 0 || h <= 0) return 0; - 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 0; - if (pass_rect_contains(x, y, x + w - 1, y + h - 1)) return 0; - if (pass_journal_protect(x, y, w, h)) 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, @@ -4616,16 +4614,12 @@ static void pass_verify_read(uint8_t **hr, size_t *hr_cap, uint8_t **raw, } /* VRAM journal for out-of-rect writes during a pass (see pass_refuse_write). */ -#define PASS_JOURNAL_MAX 16 +#define PASS_JOURNAL_MAX RENDER_PASS_JOURNAL_MAX typedef struct PassJournal { - int x, y, w, h; GLuint hr_tex, hr_rb, hr_fbo, raw_tex, raw_fbo; int hr_w, hr_h, raw_w, raw_h; - uint16_t *cpu; - size_t cpu_cap; } PassJournal; static PassJournal s_pj[PASS_JOURNAL_MAX]; -static int s_pj_n = 0; static uint64_t s_pj_total = 0; static void pass_free_color_fbo(GLuint *tex, GLuint *rb, GLuint *fbo, @@ -4657,21 +4651,16 @@ static void pass_resources_release(void) { 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); } - s_pj_n = 0; + 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; + int S = s_scale, i = s_pj_cpu.n; PassJournal *j; if (g_wide_w > 0) return 0; /* wide margins are not journaled */ - for (int i = 0; i < s_pj_n; i++) { - PassJournal *e = &s_pj[i]; - if (x >= e->x && y >= e->y && x + w <= e->x + e->w && y + h <= e->y + e->h) - return 1; /* already covered */ - } - if (s_pj_n >= PASS_JOURNAL_MAX) return 0; - j = &s_pj[s_pj_n]; + 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) || @@ -4679,40 +4668,29 @@ static int pass_journal_protect(int x, int y, int w, int h) { &j->raw_h, w, h, PSXGL_R16UI, PSXGL_RED_INTEGER, GL_UNSIGNED_SHORT)) return 0; - if (j->cpu_cap < (size_t)w * (size_t)h) { - free(j->cpu); - j->cpu = (uint16_t *)malloc((size_t)w * (size_t)h * sizeof(uint16_t)); - j->cpu_cap = j->cpu ? (size_t)w * (size_t)h : 0; - if (!j->cpu) return 0; - } flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - j->x = x; j->y = y; j->w = w; j->h = h; + 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); - for (int row = 0; row < h; row++) - memcpy(j->cpu + (size_t)row * w, s_vram + (size_t)(y + row) * VRAM_W + x, - (size_t)w * sizeof(uint16_t)); - s_pj_n++; s_pj_total++; return 1; } static void pass_journal_rollback(void) { int S = s_scale; - for (int i = s_pj_n - 1; i >= 0; i--) { + for (int i = s_pj_cpu.n - 1; i >= 0; i--) { PassJournal *j = &s_pj[i]; - pass_blit(j->hr_fbo, s_hr_fbo, 0, 0, j->x * S, j->y * S, j->w * S, - j->h * S, GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); - pass_blit(j->raw_fbo, s_raw_fbo, 0, 0, j->x, j->y, j->w, j->h, - GL_COLOR_BUFFER_BIT); - for (int row = 0; row < j->h; row++) - memcpy(s_vram + (size_t)(j->y + row) * VRAM_W + j->x, - j->cpu + (size_t)row * j->w, (size_t)j->w * sizeof(uint16_t)); + 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); } - s_pj_n = 0; + 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, @@ -4807,7 +4785,7 @@ backed_up: 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_n = 0; + s_pj_cpu.n = 0; s_pass_active = 1; return 1; } diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 8ae6a586..1330c348 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1717,35 +1717,26 @@ static inline void d44_note(uint32_t phys, uint32_t old, uint32_t val) { * 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) { - uint32_t phys; - if (addr >= 0xC0000000u) { /* cache control, KSEG2 */ - g_render_pass_dropped_writes[RENDER_PASS_DROP_OTHER]++; - return; - } - if (sr_ptr && (*sr_ptr & 0x10000u)) return; /* IsC: cache-only store */ - phys = psx_phys_addr(addr); - if (phys < RAM_SIZE) { - for (uint32_t i = 0; i < width; i++) ram[phys + i] = (uint8_t)(val >> (8u * i)); - return; - } - if (phys >= 0x1F800000u && phys <= 0x1F8003FFu) { - uint32_t off = phys - 0x1F800000u; - for (uint32_t i = 0; i < width && off + i < SCRATCHPAD_SIZE; i++) - scratchpad[off + i] = (uint8_t)(val >> (8u * i)); - return; - } - if (phys >= 0x1F801000u && phys <= 0x1F803FFFu) { - int cls = render_pass_mmio_class(phys, val, width); /* render_pass_plan.c */ - if (cls >= 0) { g_render_pass_dropped_writes[cls]++; return; } - if (width == 4) mmio_write32(phys, val); - else if (width == 2) mmio_write16(phys, (uint16_t)val); - else mmio_write8(phys, (uint8_t)val); - return; - } - /* Mod memory, expansion, ROM, unmapped: never written by a pass (mod - * arenas are not part of the pass restore). */ - g_render_pass_dropped_writes[RENDER_PASS_DROP_OTHER]++; + RenderPassStoreTarget t; + int cls; + t.ram = ram; + t.ram_size = RAM_SIZE; + 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); diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index ae48aa43..df51a3e1 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -2,6 +2,8 @@ #include "render_pass.h" #include +#include +#include #define Q16 65536.0 @@ -145,3 +147,92 @@ int render_pass_mmio_class(uint32_t phys, uint32_t val, uint32_t width) { 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; + if (phys < 0x00800000u) phys &= t->ram_size - 1u; /* RAM mirrors */ + 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/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index 7d280c51..33332429 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -54,10 +54,22 @@ for fn in ("void psx_write_word(uint32_t addr, uint32_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) -assert "render_pass_mmio_class(phys, val, width)" in body( - mem, "static void render_pass_store("), ( - "pass MMIO stores must go through the tested store policy") +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") 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") cls = body(plan, "int render_pass_mmio_class(") for dev in ("RENDER_PASS_DROP_SPU", "RENDER_PASS_DROP_CD", "RENDER_PASS_DROP_TIMER"): diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c new file mode 100644 index 00000000..0e111061 --- /dev/null +++ b/runtime/tests/test_render_pass_sandbox.c @@ -0,0 +1,483 @@ +/* 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. + * 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 ----------------------- */ +#define RAM_SIZE (2u * 1024u * 1024u) +static uint8_t s_ram[RAM_SIZE]; +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 = RAM_SIZE; + 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; } +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"); +} + +/* ---- 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_journal(); + printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); + return failures ? 1 : 0; +} From ab2ddbc351be9b4c6ef074848202b0df1f0de301 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:48:30 -0700 Subject: [PATCH 12/22] Render passes: journal out-of-rect writes under native-wide too Found in the R4 integration build (widescreen + Interpolated): R4 copies 2x1 VRAM pixels at 0,0, outside the pass rect, every frame. The journal backs such writes up and restores them, but pass_journal_protect() refused whenever a native-wide surface existed, so with the widescreen view on every pass was rolled back ("render pass refused copy 0,0 2x1 outside 0,240 320x240", "rolled back (VRAM write outside the pass rect)") and no in-between frame was ever shown. The refusal was not needed: fills (gpu_fill), copies (gpu_copy_rect), uploads (glb_vram_transfer_in / flush_cpu_upload) and pokes (glb_vram_write) write the hr surface, the raw mirror and the CPU VRAM rows only -- exactly what the journal backs up -- never a wide surface. The wide margins come from mirrored draws, which the pass scissor already keeps inside the pass band. test_render_pass_guards pins both halves: pass_journal_protect() must not test g_wide, and none of those five write paths may reference a wide surface. With the old refusal put back the guard fails. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 2 +- runtime/include/render_pass_plan.h | 2 +- runtime/src/gpu_gl_renderer.c | 8 +++++--- runtime/tests/test_render_pass_guards.py | 22 ++++++++++++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 428d5d46..ff172ac3 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -76,7 +76,7 @@ While `fn` runs (`g_psx_render_pass_active`): | GPU DMA | linked lists and delayed completions finish synchronously | `dma.c` | | RAM / scratchpad stores | written directly, bypassing code-page tracking, overlay watch, write traces and fingerprints | `memory.c` `render_pass_store` | | MMIO stores | allowed: GP0, GP1 DMA mode / info, GPU and OTC DMA channels, DPCR/DICR, I_STAT/I_MASK. Dropped and counted: SPU (key-ons), CD, timers, SIO, MDEC, other DMA channels, memory control | `memory.c` | -| VRAM | only the declared rect; writes that bypass the scissor elsewhere are journaled and rolled back (refused under native-wide) | `gpu_gl_renderer.c` | +| 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, diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index a9eb33ee..44176a36 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -99,7 +99,7 @@ typedef struct RenderPassJournal { } 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 (e.g. native-wide margins are not journaled). */ + * 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); diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 387582bf..b5e404c5 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -388,8 +388,11 @@ 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 or native-wide is active, refused and counted (the pass is then rolled - * back without an image). */ + * 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; @@ -4658,7 +4661,6 @@ static void pass_resources_release(void) { 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 (g_wide_w > 0) return 0; /* wide margins are not journaled */ 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, diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index 33332429..9ddeba5f 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -70,6 +70,28 @@ assert "render_pass_vram_policy(&s_pj_cpu" in body( 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) cls = body(plan, "int render_pass_mmio_class(") for dev in ("RENDER_PASS_DROP_SPU", "RENDER_PASS_DROP_CD", "RENDER_PASS_DROP_TIMER"): From b3cbe617c82d2b7678f0034f8e88d506b50c236b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:01:06 -0700 Subject: [PATCH 13/22] Render passes: leave first-use allocations out of the pass-cost average Found in the R4 integration build at 4K (internal resolution 9x): the first passes of a session create their image slots and backup framebuffers at 2880x2160 and cost ~70 ms; the steady passes after them cost ~10 ms. The plan's cost average (seeded by the first sample) then sat near 20 ms, above the ~14 ms the frame had to spare, so every later plan was shed for time -- and since a shed plan runs no pass, the average was never measured again: Interpolated drew no in-between frame for the rest of the session. A pass that made a pass texture or framebuffer (pass_make_color_fbo, a new pass image slot) is no longer a cost sample: render_pass_cost_sample() leaves it out, at most RENDER_PASS_ALLOC_SKIPS (8) in a row so the average can never freeze on a path that allocates every pass. The time still counts towards the frame's pass-time budget as before. render_pass_plan_test covers the rule (allocating pass skipped, next steady pass seeds the average, the skip cap, no counter = every pass counts); test_render_pass_guards pins that both allocation sites count, that each pass marks its start, and that the average goes through the rule. Co-Authored-By: Claude Opus 5.5 --- runtime/include/render_pass_plan.h | 11 +++++++++++ runtime/src/gpu_gl_renderer.c | 14 +++++++++++++- runtime/src/render_pass_plan.c | 10 ++++++++++ runtime/tests/test_render_pass_guards.py | 12 ++++++++++++ runtime/tests/test_render_pass_plan.c | 21 +++++++++++++++++++++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index 44176a36..3681bb52 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -51,6 +51,17 @@ int render_pass_select(const uint32_t *phases, uint32_t n, double p, /* Exponential moving average used for the per-pass host cost. */ double render_pass_ema(double current, double sample); +/* The pass-cost average after one pass that took `sample` host ticks. + * `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 + * (*skips counts them; any kept sample resets it). */ +#define RENDER_PASS_ALLOC_SKIPS 8u +double render_pass_cost_sample(double current, double sample, int allocated, + unsigned *skips); + /* 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. */ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index b5e404c5..43b5205d 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -379,6 +379,10 @@ static uint64_t s_idle_ticks_accum_fwd(uint64_t add); * 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_sample). */ +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, @@ -4377,6 +4381,7 @@ 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; static double s_pass_cost_ema = 0.0; /* host ticks per pass */ +static unsigned s_pass_cost_skips = 0; 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; @@ -4490,7 +4495,11 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, } void gl_renderer_pass_note_cost(uint64_t ticks) { - s_pass_cost_ema = render_pass_ema(s_pass_cost_ema, (double)ticks); + /* A pass that made pass textures or framebuffers (first use, a size + * change) is not a cost sample: see render_pass_cost_sample(). */ + s_pass_cost_ema = render_pass_cost_sample( + s_pass_cost_ema, (double)ticks, s_pass_allocs != s_pass_allocs_begin, + &s_pass_cost_skips); s_pass_ticks_accum += ticks; } @@ -4513,6 +4522,7 @@ static int pass_make_color_fbo(GLuint *tex, GLuint *rb, GLuint *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); @@ -4553,6 +4563,7 @@ static int pass_gen_reserve(int gi, uint32_t need, int w, int h) { 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; } @@ -4702,6 +4713,7 @@ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, 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(); diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index df51a3e1..2f0f88cc 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -115,6 +115,16 @@ double render_pass_ema(double current, double sample) { return current * 0.75 + sample * 0.25; } +double render_pass_cost_sample(double current, double sample, int allocated, + unsigned *skips) { + if (allocated && skips && *skips < RENDER_PASS_ALLOC_SKIPS) { + (*skips)++; + return current; + } + if (skips) *skips = 0; + return render_pass_ema(current, sample); +} + double render_pass_budget(double idle_ticks, double pass_ticks, double frame_length, double share) { double b; diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index 9ddeba5f..29abf16d 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -92,6 +92,18 @@ 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_sample(" 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") cls = body(plan, "int render_pass_mmio_class(") for dev in ("RENDER_PASS_DROP_SPU", "RENDER_PASS_DROP_CD", "RENDER_PASS_DROP_TIMER"): diff --git a/runtime/tests/test_render_pass_plan.c b/runtime/tests/test_render_pass_plan.c index f960c01e..a2055e7d 100644 --- a/runtime/tests/test_render_pass_plan.c +++ b/runtime/tests/test_render_pass_plan.c @@ -117,6 +117,27 @@ static void test_budget_and_ema(void) { 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. */ + unsigned skips = 0; + double e = 0.0; + e = render_pass_cost_sample(e, 70.0, 1, &skips); + CHECK(e == 0.0 && skips == 1, "an allocating pass is not a sample"); + e = render_pass_cost_sample(e, 10.0, 0, &skips); + CHECK(e == 10.0 && skips == 0, "the next steady pass seeds the average"); + e = render_pass_cost_sample(e, 70.0, 1, &skips); + CHECK(e == 10.0, "a later allocating pass leaves it alone"); + for (unsigned i = 1; i < RENDER_PASS_ALLOC_SKIPS; i++) + e = render_pass_cost_sample(e, 70.0, 1, &skips); + CHECK(e == 10.0 && skips == RENDER_PASS_ALLOC_SKIPS, + "up to RENDER_PASS_ALLOC_SKIPS in a row"); + e = render_pass_cost_sample(e, 70.0, 1, &skips); + CHECK(fabs(e - 25.0) < 1e-9 && skips == 0, + "then an allocating pass counts, so the average cannot freeze"); + e = render_pass_cost_sample(e, 25.0, 0, NULL); + CHECK(fabs(e - 25.0) < 1e-9, "no skip counter: every pass counts"); + } } static void test_store_policy(void) { From d300319b7e1f6521c9bd4d43317e2c998d8afa4b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:56:07 -0700 Subject: [PATCH 14/22] Render passes: follow the live 2/8 MiB RAM geometry Upstream now has one live main-RAM geometry: retail 2 MiB mirrored four times across the 8 MiB decode window, or 8 MiB unique with the opt-in 8 MB RAM mod (psx_memory.h). memory.c's RAM array is sized for the largest geometry, so the pass store policy was handed 8 MiB even on a retail boot, and a store to a mirror (0x80200000..) would have landed in the unused upper backing bytes instead of folding onto RAM. With the 8 MB map live, the checkpoint copied and restored only the low 2 MiB. - memory.c passes the store policy psx_ram_live_bytes(), so pass stores fold exactly as psx_ram_map_write does outside a pass. - render_pass.c checkpoints, restores and (PSX_RENDER_PASS_VERIFY) hashes memory_get_ram_bytes() bytes; the copy grows if a later session boots with the larger map. - render_pass_sandbox_test: policy and a real pass with 8 MiB live (unique addresses up to 0x7FFFFF, all 8 MiB restored), then a retail pass folding mirrors again. Restoring a fixed 2 MiB fails it twice; folding with the backing size fails it twice. render_pass_guards pins the live size in memory.c and in the checkpoint, restore and hash. - psx_ram_runtime_map_test (upstream's 2/8 MiB test over the real memory.c and dma.c) links those files with section GC. The pass paths make six more symbols reachable there (the pass flag and store policy, the synchronous GPU linked-list walk); its stubs double them, trapping if a pass path ever runs. With the macOS-only link gaps stubbed as well, the test passes on this branch as on master. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 12 ++- runtime/include/render_pass_plan.h | 6 +- runtime/src/memory.c | 2 +- runtime/src/render_pass.c | 27 +++++-- runtime/src/render_pass_plan.c | 5 +- runtime/tests/psx_ram_runtime_map_stubs.c | 15 ++++ runtime/tests/test_render_pass_abort.c | 1 + runtime/tests/test_render_pass_guards.py | 11 +++ runtime/tests/test_render_pass_sandbox.c | 92 ++++++++++++++++++++++- 9 files changed, 151 insertions(+), 20 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index ff172ac3..105a209c 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -74,14 +74,15 @@ While `fn` runs (`g_psx_render_pass_active`): | 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 | `memory.c` `render_pass_store` | +| 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, -2 MiB RAM, scratchpad, I-cache tags, I_STAT/I_MASK, timers, DMA and GPU -registers (without the widescreen side effects of a savestate load), the +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 @@ -163,7 +164,10 @@ Tests (runtime ctest unless noted): 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 - out-of-rect VRAM journal policy and its exact rollback of the CPU VRAM rows. + 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. diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index 3681bb52..b69a456c 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -82,8 +82,10 @@ int render_pass_mmio_class(uint32_t phys, uint32_t val, uint32_t width); * pass restores), else the RENDER_PASS_DROP_* class it was dropped under, * which the caller counts. */ typedef struct RenderPassStoreTarget { - uint8_t *ram; /* main RAM, ram_size bytes (mirrored) */ - uint32_t ram_size; + 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 */ diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 1330c348..6f63240e 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1730,7 +1730,7 @@ static void render_pass_store(uint32_t addr, uint32_t val, uint32_t width) { RenderPassStoreTarget t; int cls; t.ram = ram; - t.ram_size = RAM_SIZE; + 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; diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 4e59d782..077cd349 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -34,6 +34,10 @@ 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); @@ -77,7 +81,6 @@ 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_RAM_SIZE (2u * 1024u * 1024u) #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 @@ -200,10 +203,12 @@ typedef struct RenderPassCheckpoint { 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; /* RP_RAM_SIZE */ +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; @@ -331,7 +336,7 @@ static uint64_t state_hash(const CPUState *cpu) { 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(), RP_RAM_SIZE); + 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); @@ -371,9 +376,14 @@ static uint64_t state_hash(const CPUState *cpu) { static int checkpoint_save(const CPUState *cpu) { uint32_t dma_len = dma_snapshot_bytes(); - if (!s_ram_copy) { - s_ram_copy = (uint8_t *)malloc(RP_RAM_SIZE); - if (!s_ram_copy) return 0; + /* 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); @@ -381,7 +391,8 @@ static int checkpoint_save(const CPUState *cpu) { } if (!gpu_pass_checkpoint_save()) return 0; s_ck.cpu = *cpu; - memcpy(s_ram_copy, memory_get_ram_ptr(), RP_RAM_SIZE); + 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; @@ -410,7 +421,7 @@ static void checkpoint_restore(CPUState *cpu) { 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, RP_RAM_SIZE); + 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; diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index 2f0f88cc..aa7cc914 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -164,7 +164,10 @@ int render_pass_store_to(const RenderPassStoreTarget *t, uint32_t addr, if (addr >= 0xC0000000u) return RENDER_PASS_DROP_OTHER; /* KSEG2 */ if (t->isolate_cache) return -1; /* cache-only store */ phys = addr & 0x1FFFFFFFu; - if (phys < 0x00800000u) phys &= t->ram_size - 1u; /* RAM mirrors */ + /* 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)); diff --git a/runtime/tests/psx_ram_runtime_map_stubs.c b/runtime/tests/psx_ram_runtime_map_stubs.c index 0c09a4de..6bd2acce 100644 --- a/runtime/tests/psx_ram_runtime_map_stubs.c +++ b/runtime/tests/psx_ram_runtime_map_stubs.c @@ -96,6 +96,21 @@ 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; } + /* ---- 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/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 9286dbfe..3dbc2083 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -58,6 +58,7 @@ 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); } diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index 29abf16d..2bc6e346 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -59,6 +59,17 @@ 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("), ( diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index 0e111061..16a3a057 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -11,7 +11,9 @@ * 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. + * 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 @@ -61,8 +63,13 @@ int g_exec_phase = 0; uint32_t g_dirty_safe_resume_pc = 0; /* ---- memory and the MMIO devices a pass may reach ----------------------- */ -#define RAM_SIZE (2u * 1024u * 1024u) -static uint8_t s_ram[RAM_SIZE]; +/* 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; @@ -85,7 +92,7 @@ static RenderPassStoreTarget target(void) { RenderPassStoreTarget t; memset(&t, 0, sizeof t); t.ram = s_ram; - t.ram_size = RAM_SIZE; + t.ram_size = s_ram_live; t.scratchpad = s_spad; t.scratchpad_size = sizeof s_spad; t.mmio_write = mock_mmio_write; @@ -106,6 +113,7 @@ static void guest_store(uint32_t addr, uint32_t val, uint32_t width) { } 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); } @@ -390,6 +398,81 @@ static void test_pass(void) { 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 @@ -477,6 +560,7 @@ static void test_journal(void) { 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; From cbaf7a3a6c0c05b0e1e9926623bd2c7ed1199bf4 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:35:03 -0700 Subject: [PATCH 15/22] docs: render passes: register the pass hook under the plugin id The pass point is a function-entry hook. Say how to register it with psx_mod_register_function_entry_plugin() under the manifest [[plugin]] id (it then runs only while the mod plan activates the plugin), to register once because a repeated id and address returns 0, and that entry hooks also fire for guest functions a pass calls. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 105a209c..58aad90f 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -26,6 +26,14 @@ 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 From 4961e810e0ae5f24b59f1f0e8b5810bb7e14d4a9 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:51:47 -0700 Subject: [PATCH 16/22] Render passes: hash RAM by words in verify mode PSX_RENDER_PASS_VERIFY hashes the guest-visible state before and after every pass, RAM included. Byte-wise FNV made that the bulk of a verify pass's begin cost in R4: 4.0 ms with 2 MiB (Grand Prix) and 10.6 ms with the 8 MB RAM map (attract demo), so with 8 MiB live the budget shed nearly every plan and only 10 passes were checked in 30 s. Hash 64-bit words instead; each step is still one bijective FNV step, so a change in one word always changes the hash. Begin cost is now 0.8 ms (2 MiB) and 2.5 ms (8 MiB); the same 8 MiB run checked 755 passes, 0 mismatches. Verify mode only: the hash is compared within one pass and never stored. Co-Authored-By: Claude Opus 5.5 --- runtime/src/render_pass.c | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 077cd349..891f0d2c 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -316,9 +316,19 @@ uint32_t psx_mod_render_pass_plan(uint32_t 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; - for (size_t i = 0; i < n; i++) h = (h ^ b[i]) * 1099511628211ULL; + 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; } From 1decf2574d80e6ebfa122603b4d3335877abc6fb Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:31:52 -0700 Subject: [PATCH 17/22] render_pass_abort_test: set its environment with _putenv_s on Windows setenv() is POSIX-only; mingw-w64 declares none, and GCC 14+ reject the implicit declaration, so a MinGW configure with BUILD_TESTING failed to build the target. Use _putenv_s under _WIN32, as test_hd_texture_pack and test_text_xlate_fix_only do. zig cc -target x86_64-windows-gnu now builds all four render-pass tests and frame_interpolation_schedule_test. Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_render_pass_abort.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 3dbc2083..f9c57435 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -311,7 +311,11 @@ int main(void) { 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); From 0c0d375673eb9125bbb8b08a4ccd1f62069363dd Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:32:19 -0700 Subject: [PATCH 18/22] GL readback runner: link render_pass_plan.c into the fixture The MinGW fixture compiles gpu_gl_renderer.c (through test_gl_readback_region.c) with only gpu_sw_renderer.c beside it. gl_renderer_shutdown() now releases the render-pass resources, including the VRAM journal's CPU rows, so the PE link left render_pass_journal_free unresolved and the runner returned 2. render_pass_plan.c is self-contained (libc only), so link it rather than stub each journal and policy entry point. Checked with a macOS clang stand-in for gcc.exe/g++.exe (-flto, dead_strip): the branch before this left render_pass_journal_free unresolved; now it links, and the probe then behaves exactly as on master (macOS gives the fixture a GL 2.1 context, so both stop at shader compile). Co-Authored-By: Claude Opus 5.5 --- runtime/tests/run_gl_readback_region.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 From 3d705f73c3f93f7bf164066131fd642f584040bb Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:35:48 -0700 Subject: [PATCH 19/22] Render passes: hold a late frame's newest image until the flip A generation's timeline is fixed at plan time (t_len = plan period x VBlank). When the game frame stayed up longer (a 30 Hz tick that lags to three VBlanks, or host-interval drift), pass_gen_present expired the generation past phase 1 + 0.5/period and the presenter fell back to its own capture, which in FLIP mode is the frame's phase-0 image: the output ran 0 -> .25 -> .5 -> .75, stepped back to 0, then jumped to the next frame. render_pass_gen_select() (render_pass_plan.c) now keeps render_pass_select's hold of the newest image past phase 1 and only expires a generation after RENDER_PASS_GEN_HOLD_MAX (4) frame lengths without a flip, i.e. a game that stopped flipping. render_pass_stats reports late_presents (presents more than 1/64 of a frame past its planned end). The plan keeps the plugin's period: its phases were placed on the output deadlines of a frame of that length, and the flip tracker's period is the previous frame's, which cannot predict a sporadic lag. render_pass_plan_test covers phases 1 to 4 (hold) and past 4 / NaN (expire); render_pass_guards pins pass_gen_present to the rule. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 12 +++++++--- docs/TCP_COMMANDS.md | 2 +- runtime/include/gpu_gl_renderer.h | 6 +++-- runtime/include/render_pass_plan.h | 14 ++++++++++++ runtime/src/debug_server.c | 5 ++-- runtime/src/gpu_gl_renderer.c | 15 ++++++++---- runtime/src/render_pass_plan.c | 6 +++++ runtime/tests/test_render_pass_guards.py | 7 ++++++ runtime/tests/test_render_pass_plan.c | 29 ++++++++++++++++++++++++ 9 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 58aad90f..89f11d1a 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -120,7 +120,13 @@ 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. +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 @@ -161,8 +167,8 @@ internal resolutions; a size change frees the old set. Tests (runtime ctest unless noted): -- `render_pass_plan_test`: phase planning per rate, shedding, selection, the - MMIO allow-list. +- `render_pass_plan_test`: phase planning per rate, shedding, 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 diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index cf466527..41fabf23 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -54,7 +54,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `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, presents made from pass images, 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_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, 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 | diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 1628635e..8f5eb89c 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -72,8 +72,10 @@ 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. */ -void gl_renderer_pass_diag(uint64_t out[8]); + * 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). */ +void gl_renderer_pass_diag(uint64_t out[9]); /* 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). */ diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index b69a456c..71e2cdd9 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -48,6 +48,20 @@ uint32_t render_pass_plan_phases(const RenderPassPlanInput *in, 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); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 008e1e23..29bf0410 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7981,7 +7981,7 @@ static void handle_render_pass_stats(int id, const char *json) { (void)json; RenderPassStats st; - uint64_t gd[8], image_bytes = 0; + uint64_t gd[9], image_bytes = 0; uint32_t image_textures; render_pass_get_stats(&st); gl_renderer_pass_diag(gd); @@ -7998,7 +7998,7 @@ static void handle_render_pass_stats(int id, const char *json) "\"avg_end_ms\":%.3f,\"avg_restore_ms\":%.3f," "\"guest_cycles_last\":%llu,\"disabled\":%d," "\"promotions\":%llu,\"pass_presents\":%llu," - "\"blended_presents\":%llu,\"expired\":%llu," + "\"blended_presents\":%llu,\"expired\":%llu,\"late_presents\":%llu," "\"unmatched_flips\":%llu,\"early_presents\":%llu," "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%llu," "\"image_textures\":%u,\"image_bytes\":%llu,\"status\":%u," @@ -8021,6 +8021,7 @@ static void handle_render_pass_stats(int id, const char *json) (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[7], (unsigned long long)gl_renderer_pass_journaled(), diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 43b5205d..67a80fd7 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -4359,6 +4359,7 @@ 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; @@ -4882,18 +4883,23 @@ static int pass_gen_present(uint64_t deadline) { g->tex_h != s_interp_h) return 0; p = ((double)deadline - g->t_start) / g->t_len; - if (p > 1.0 + 0.5 / (double)g->period) { - /* The next flip is late: stop showing this frame's passes. */ + /* 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 (!render_pass_select(g->phase, g->n, p, &lo, &hi, &t)) 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; } @@ -4913,7 +4919,7 @@ void gl_renderer_pass_service_presents(void) { } } -void gl_renderer_pass_diag(uint64_t out[8]) { +void gl_renderer_pass_diag(uint64_t out[9]) { out[0] = s_pgen_promotions; out[1] = s_pgen_presents; out[2] = s_pgen_blends; @@ -4923,6 +4929,7 @@ void gl_renderer_pass_diag(uint64_t out[8]) { out[6] = (uint64_t)(s_pass_cost_ema * 1e6 / (double)SDL_GetPerformanceFrequency()); /* us */ out[7] = (uint64_t)s_pgen[s_pgen_cur].n; + out[8] = s_pgen_late; } uint64_t gl_renderer_pass_journaled(void) { return s_pj_total; } diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index aa7cc914..d08dc7c7 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -109,6 +109,12 @@ int render_pass_select(const uint32_t *phases, uint32_t n, double p, 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; diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index 2bc6e346..36cc5091 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -115,6 +115,13 @@ assert "render_pass_cost_sample(" in body( assert "s_pass_allocs_begin = s_pass_allocs;" in body( gl, "int gl_renderer_pass_begin("), ( "each pass must mark where its allocations start") +# 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"): diff --git a/runtime/tests/test_render_pass_plan.c b/runtime/tests/test_render_pass_plan.c index a2055e7d..1a02bc2e 100644 --- a/runtime/tests/test_render_pass_plan.c +++ b/runtime/tests/test_render_pass_plan.c @@ -106,6 +106,34 @@ static void test_select(void) { "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"); @@ -168,6 +196,7 @@ int main(void) { 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; From 29a49eb4d5a27f610bd3b07651669c31a1edda30 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:36:01 -0700 Subject: [PATCH 20/22] Render passes: one pass per plan until the cost is measured at this size render_pass_plan_phases() treated an unknown pass cost (0) as free, so the first plans of a process scheduled every phase up to the slot cap. The passes that allocate textures are left out of the average (up to 8 in a row), so the first two frames ran at full count, and after an aspect or resolution change the stale cost of the old size stayed in force. Passes run on the emulation thread: at 4K (46.9 ms a pass) the first race frames stalled the guest by several passes each. - An unknown cost with a budget now plans one pass: it measures the cost and costs the guest at most one pass per frame while it is learnt. - The cost is a RenderPassCost (render_pass_plan.c), replacing render_pass_cost_sample: the same allocation rule, then a warm-up of three kept samples whose median seeds the average. Until then the estimate is unknown. With one-pass plans a single slow first pass (a busy host) would otherwise set the average alone, and a plan that sheds every pass never measures again. - The renderer keys the cost to the presented image size: a plan at another size passes an unknown cost, and the first pass there starts the cost over. - RENDER_PASSES.md no longer claims passes never slow the guest, and says a cost above every frame's budget keeps passes shed until the size changes. render_pass_plan_test covers the one-pass plan (none without budget), the warm-up median (one outlier ignored; a truly expensive size still prices itself out) and the allocation rule; render_pass_guards pins the size key. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 24 ++++++--- runtime/include/render_pass_plan.h | 43 ++++++++++----- runtime/src/gpu_gl_renderer.c | 27 ++++++---- runtime/src/render_pass_plan.c | 37 ++++++++++--- runtime/tests/test_render_pass_guards.py | 14 ++++- runtime/tests/test_render_pass_plan.c | 69 +++++++++++++++++++----- 6 files changed, 164 insertions(+), 50 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 89f11d1a..a60dc575 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -133,11 +133,22 @@ 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. The budget per frame is -`PSX_RENDER_PASS_BUDGET` percent (default 80) of the presenter's idle time -plus the presents beyond two per frame, learnt from the previous frame, over -a smoothed per-pass cost. Nothing ever slows the guest down: when the budget -runs out, fewer passes are rendered. +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, so while the measured cost stays above every frame's budget, +passes stay shed until the image size changes (a plugin can fall back to a +crossfade, as described under "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 @@ -167,7 +178,8 @@ internal resolutions; a size change frees the old set. Tests (runtime ctest unless noted): -- `render_pass_plan_test`: phase planning per rate, shedding, selection, +- `render_pass_plan_test`: phase planning per rate, shedding (one pass while + the cost is unknown), the cost warm-up and allocation rule, 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. diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index 71e2cdd9..daccd3b5 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -26,7 +26,9 @@ typedef struct RenderPassPlanInput { 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 */ + 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; @@ -35,8 +37,9 @@ typedef struct RenderPassPlanInput { * 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; *wanted (optional) receives the unshed count. Returns the count - * (0 when nothing is wanted or affordable). */ + * 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); @@ -65,16 +68,32 @@ int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p, /* Exponential moving average used for the per-pass host cost. */ double render_pass_ema(double current, double sample); -/* The pass-cost average after one pass that took `sample` host ticks. - * `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 - * (*skips counts them; any kept sample resets it). */ +/* 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; a plan then sheds everything and never + * measures again. 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). */ #define RENDER_PASS_ALLOC_SKIPS 8u -double render_pass_cost_sample(double current, double sample, int allocated, - unsigned *skips); +#define RENDER_PASS_COST_WARMUP 3u +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 */ +} 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); /* 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 diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 67a80fd7..645a126f 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -381,7 +381,7 @@ static uint64_t s_idle_ticks_accum_fwd(uint64_t add); 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_sample). */ + * 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; @@ -4381,8 +4381,12 @@ 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; -static double s_pass_cost_ema = 0.0; /* host ticks per pass */ -static unsigned s_pass_cost_skips = 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_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; @@ -4480,7 +4484,8 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, 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_ema; + 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. */ @@ -4496,11 +4501,15 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, } 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_sample(). */ - s_pass_cost_ema = render_pass_cost_sample( - s_pass_cost_ema, (double)ticks, s_pass_allocs != s_pass_allocs_begin, - &s_pass_cost_skips); + * 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; } @@ -4926,7 +4935,7 @@ void gl_renderer_pass_diag(uint64_t out[9]) { out[3] = s_pgen_expired; out[4] = s_pgen_unmatched; out[5] = s_pgen_early; - out[6] = (uint64_t)(s_pass_cost_ema * 1e6 / + 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; diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index d08dc7c7..8da1bb34 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -51,8 +51,10 @@ uint32_t render_pass_plan_phases(const RenderPassPlanInput *in, 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) - : (double)n; + : (in->budget > 0.0 ? 1.0 : 0.0); if (fit < 0.0) fit = 0.0; if (fit < (double)n) n = (uint32_t)fit; } @@ -121,14 +123,33 @@ double render_pass_ema(double current, double sample) { return current * 0.75 + sample * 0.25; } -double render_pass_cost_sample(double current, double sample, int allocated, - unsigned *skips) { - if (allocated && skips && *skips < RENDER_PASS_ALLOC_SKIPS) { - (*skips)++; - return current; +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; } - if (skips) *skips = 0; - return render_pass_ema(current, sample); + cost->skips = 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]; + } + 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; } double render_pass_budget(double idle_ticks, double pass_ticks, diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index 36cc5091..6e9e5c04 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -109,12 +109,24 @@ for name in ("gpu_fill", "gpu_copy_rect", "flush_cpu_upload", 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_sample(" in body( +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") # 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") diff --git a/runtime/tests/test_render_pass_plan.c b/runtime/tests/test_render_pass_plan.c index 1a02bc2e..6f8fd222 100644 --- a/runtime/tests/test_render_pass_plan.c +++ b/runtime/tests/test_render_pass_plan.c @@ -3,6 +3,7 @@ #include #include #include +#include #include "render_pass.h" #include "render_pass_plan.h" @@ -75,6 +76,20 @@ static void test_shedding(void) { 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, @@ -148,23 +163,49 @@ static void test_budget_and_ema(void) { { /* 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. */ - unsigned skips = 0; - double e = 0.0; - e = render_pass_cost_sample(e, 70.0, 1, &skips); - CHECK(e == 0.0 && skips == 1, "an allocating pass is not a sample"); - e = render_pass_cost_sample(e, 10.0, 0, &skips); - CHECK(e == 10.0 && skips == 0, "the next steady pass seeds the average"); - e = render_pass_cost_sample(e, 70.0, 1, &skips); - CHECK(e == 10.0, "a later allocating pass leaves it alone"); + 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++) - e = render_pass_cost_sample(e, 70.0, 1, &skips); - CHECK(e == 10.0 && skips == RENDER_PASS_ALLOC_SKIPS, + 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"); - e = render_pass_cost_sample(e, 70.0, 1, &skips); - CHECK(fabs(e - 25.0) < 1e-9 && skips == 0, + 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"); - e = render_pass_cost_sample(e, 25.0, 0, NULL); - CHECK(fabs(e - 25.0) < 1e-9, "no skip counter: every pass counts"); + 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"); } } From 4bb58c2a8119350e86d53e30f889c4f99df15714 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:10:20 -0700 Subject: [PATCH 21/22] Render passes: measure a stale pass-cost estimate again Only passes that run are measured. The first passes of a race can all run in a transient (a busy host, code run for the first time): in earlier Grand Prix runs the three warm-up passes measured 13-15 ms against a steady 3-7 ms at the same setting. An estimate that high prices most plans out, no pass runs to correct it, and a plugin that crossfades while passes are shed (R4 Frame Rate) stayed on the crossfade for most or all of the run: 4, 9 and 29 passes in 22 s at 300 Hz, 7 in a verify run at 120 Hz, against about 1400 when the first passes were fast. render_pass_cost_note_plan() (render_pass_plan.c) counts the plans that wanted passes since the last measured pass. After 30 (about a second of a 30 Hz game) the warm-up restarts, so plans ask for one pass until three have been measured again. When the new median is more than 3/4 of 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 estimate stale keeps the wait at 30. render_pass_stats reports cost_rewarms. render_pass_plan_test covers the restart after 30 plans, an estimate that passes keep measuring never restarting, the minimum wait after a stale estimate, and the doubling waits when re-measures confirm the estimate (47 ms at 4K, 10 ms re-measured as 9); render_pass_guards pins the planner's call and its one-pass re-plan. Co-Authored-By: Claude Opus 5.5 --- docs/RENDER_PASSES.md | 18 +++++-- docs/TCP_COMMANDS.md | 2 +- runtime/include/gpu_gl_renderer.h | 4 +- runtime/include/render_pass_plan.h | 27 ++++++++-- runtime/src/debug_server.c | 8 +-- runtime/src/gpu_gl_renderer.c | 18 ++++++- runtime/src/render_pass_plan.c | 28 +++++++++++ runtime/tests/test_render_pass_guards.py | 12 +++++ runtime/tests/test_render_pass_plan.c | 63 ++++++++++++++++++++++++ 9 files changed, 164 insertions(+), 16 deletions(-) diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index a60dc575..6275f333 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -146,9 +146,18 @@ 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, so while the measured cost stays above every frame's budget, -passes stay shed until the image size changes (a plugin can fall back to a -crossfade, as described under "When passes are unavailable"). +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 @@ -179,7 +188,8 @@ internal resolutions; a size change frees the old set. 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, selection, + 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. diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 41fabf23..94196f4e 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -54,7 +54,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `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, 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_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 | diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 8f5eb89c..62c37868 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -74,8 +74,8 @@ 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). */ -void gl_renderer_pass_diag(uint64_t out[9]); + * 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). */ diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index daccd3b5..294efee6 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -79,21 +79,40 @@ double render_pass_ema(double current, double sample); * 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; a plan then sheds everything and never - * measures again. 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). */ + * 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 diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 29bf0410..e0074c41 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7981,7 +7981,7 @@ static void handle_render_pass_stats(int id, const char *json) { (void)json; RenderPassStats st; - uint64_t gd[9], image_bytes = 0; + uint64_t gd[10], image_bytes = 0; uint32_t image_textures; render_pass_get_stats(&st); gl_renderer_pass_diag(gd); @@ -8000,7 +8000,8 @@ static void handle_render_pass_stats(int id, const char *json) "\"promotions\":%llu,\"pass_presents\":%llu," "\"blended_presents\":%llu,\"expired\":%llu,\"late_presents\":%llu," "\"unmatched_flips\":%llu,\"early_presents\":%llu," - "\"cost_us\":%llu,\"frame_images\":%llu,\"journaled\":%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, @@ -8023,7 +8024,8 @@ static void handle_render_pass_stats(int id, const char *json) (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[7], + (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(), diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 645a126f..3ec96b61 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -4387,6 +4387,7 @@ static int s_pb_last_dx = 0, s_pb_last_dy = 0, s_pb_last_dw = 0, s_pb_last_ * 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; @@ -4497,7 +4498,19 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, (double)s_pass_budget_pct / 100.0); } in.max = max < cap - 1u ? max : cap - 1u; - return render_pass_plan_phases(&in, alpha_q16, wanted); + { + 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) { @@ -4928,7 +4941,7 @@ void gl_renderer_pass_service_presents(void) { } } -void gl_renderer_pass_diag(uint64_t out[9]) { +void gl_renderer_pass_diag(uint64_t out[10]) { out[0] = s_pgen_promotions; out[1] = s_pgen_presents; out[2] = s_pgen_blends; @@ -4939,6 +4952,7 @@ void gl_renderer_pass_diag(uint64_t out[9]) { (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; } diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index 8da1bb34..b04ef525 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -130,6 +130,7 @@ void render_pass_cost_add(RenderPassCost *cost, double sample, int allocated) { 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) { @@ -141,6 +142,18 @@ void render_pass_cost_add(RenderPassCost *cost, double sample, int allocated) { 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; } @@ -152,6 +165,21 @@ double render_pass_cost_estimate(const RenderPassCost *cost) { 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; diff --git a/runtime/tests/test_render_pass_guards.py b/runtime/tests/test_render_pass_guards.py index 6e9e5c04..22a66c51 100644 --- a/runtime/tests/test_render_pass_guards.py +++ b/runtime/tests/test_render_pass_guards.py @@ -127,6 +127,18 @@ assert re.search(r"in\.pass_cost = \(s_pass_cost_w == s_interp_w && " 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") diff --git a/runtime/tests/test_render_pass_plan.c b/runtime/tests/test_render_pass_plan.c index 6f8fd222..e514618f 100644 --- a/runtime/tests/test_render_pass_plan.c +++ b/runtime/tests/test_render_pass_plan.c @@ -207,6 +207,69 @@ static void test_budget_and_ema(void) { 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) { From 16cec81f1eaf917bb73705cea355617be355c038 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 29 Sep 2026 11:15:54 -0700 Subject: [PATCH 22/22] psx_ram_runtime_map_test: link doubles for the MMIO readers memory.c now keeps Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/tests/psx_ram_runtime_map_stubs.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/runtime/tests/psx_ram_runtime_map_stubs.c b/runtime/tests/psx_ram_runtime_map_stubs.c index 6bd2acce..a3f8bd41 100644 --- a/runtime/tests/psx_ram_runtime_map_stubs.c +++ b/runtime/tests/psx_ram_runtime_map_stubs.c @@ -110,6 +110,19 @@ void dma_gpu_ll_advance(void *s, uint32_t c, const void *ops, void *o) { 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) \