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