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