From 5297bd7a002712f2d2c719fe744e5331522edc5f Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 08:56:48 -0700 Subject: [PATCH 1/6] Render passes: replay a span of a compiled guest function (psx_mod_run_guest_span) Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/dirty_ram_interp.h | 18 +++++++ runtime/include/mod_plugins.h | 17 ++++++ runtime/include/render_pass.h | 2 + runtime/src/debug_server.c | 5 +- runtime/src/dirty_ram_interp.c | 69 ++++++++++++++++++++---- runtime/src/render_pass.c | 17 +++++- runtime/tests/test_render_pass_abort.c | 53 +++++++++++++++++- runtime/tests/test_render_pass_sandbox.c | 7 +++ 8 files changed, 173 insertions(+), 15 deletions(-) diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h index 98ad41dc..18cc4ee8 100644 --- a/runtime/include/dirty_ram_interp.h +++ b/runtime/include/dirty_ram_interp.h @@ -42,6 +42,24 @@ extern "C" { * corruption, 2026-06-10). */ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr); +/* Replay [start_pc, stop_pc) of already-loaded guest code from the CPU state + * given: every PC in the range is interpreted from its live RAM bytes, even + * where a compiled body or a compiled resume point (the return address of a + * call) begins, so a span can start in the middle of a compiled function. + * Calls the span makes run on their normal backend and return into it. + * Returns 1 when control reaches stop_pc, 0 when it leaves the range any other + * way (a return, a jump out, a nested span) or runs past max_insns + * interpreted instructions. The range is half-open, CODE identity, one span + * at a time. A span is a replay, not a function entry: it fires no + * function-entry hooks at its own PCs and stays out of capture, alias seeding + * and per-PC entry statistics. Instruction hooks fire as in compiled code. + * Mods reach it through psx_mod_run_guest_span (render passes only). */ +int dirty_ram_run_span(CPUState* cpu, uint32_t start_pc, uint32_t stop_pc, + uint64_t max_insns); +/* The open span (0,0 = none), for landings that longjmp past its exit. */ +void dirty_ram_span_get(uint32_t *lo, uint32_t *hi); +void dirty_ram_span_set(uint32_t lo, uint32_t hi); + /* Retire a deferred R3000A load-delay writeback (see dirty_ram_interp.c). The * interpreter defers a load's destination-register write past the delay-slot * instruction, as hardware does; call this anywhere control leaves the diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 2bb49d20..737260c5 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -348,6 +348,23 @@ enum { PSX_MOD_RENDER_PASS_BUSY = 6 }; uint32_t psx_mod_render_pass_status(void); + +/* + * Replay part of an already-loaded guest function inside a render pass: + * run from start_pc with the CPU state given until control reaches stop_pc. + * Every PC in [start_pc, stop_pc) is interpreted from its RAM bytes, so the + * span may start anywhere in a compiled function, including just after one + * of its calls; calls the span makes run normally and return into it. + * A plugin uses this to redraw with the game's own frame code, branches and + * all, from registers it captured at start_pc during the real frame (an + * instruction hook), instead of re-implementing that code's call sequence. + * Returns 1 when stop_pc is reached; 0 when control leaves the range another + * way (a return or jump out, a nested span), after 1M interpreted + * instructions, or outside a pass. The pass restores the machine either way. + * Both PCs are 4-aligned, in one segment, start_pc < stop_pc. + */ +int psx_mod_run_guest_span(struct CPUState* cpu, uint32_t start_pc, + uint32_t stop_pc); 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/render_pass.h b/runtime/include/render_pass.h index d1b9a84c..87736a2c 100644 --- a/runtime/include/render_pass.h +++ b/runtime/include/render_pass.h @@ -38,6 +38,8 @@ typedef struct RenderPassStats { uint64_t nesting_repairs; /* aborts whose skipped exits the restore undid */ uint64_t verify_checks; /* PSX_RENDER_PASS_VERIFY comparisons */ uint64_t verify_mismatch; /* ... that found a difference */ + uint64_t spans; /* psx_mod_run_guest_span calls that reached stop */ + uint64_t span_failures; /* ... that left the span or overran */ uint64_t dropped[RENDER_PASS_DROP_CLASSES]; double last_pass_ms; /* host time of the last pass (sandbox + draw) */ double avg_pass_ms; /* smoothed */ diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 9b236be9..95e8db9a 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7977,7 +7977,7 @@ static void handle_render_pass_stats(int id, const char *json) "\"cost_us\":%llu,\"cost_rewarms\":%llu,\"frame_images\":%llu," "\"journaled\":%llu," "\"image_textures\":%u,\"image_bytes\":%llu,\"status\":%u," - "\"backups_reused\":%llu}", + "\"backups_reused\":%llu,\"spans\":%llu,\"span_failures\":%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, @@ -8003,7 +8003,8 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned long long)gl_renderer_pass_journaled(), (unsigned)image_textures, (unsigned long long)image_bytes, (unsigned)psx_mod_render_pass_status(), - (unsigned long long)gl_renderer_pass_backups_reused()); + (unsigned long long)gl_renderer_pass_backups_reused(), + (unsigned long long)st.spans, (unsigned long long)st.span_failures); } /* render_pass_refuse on=<0|1>: make the OpenGL backend decline render passes diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index b6edd193..01bab932 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -2414,6 +2414,17 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr); +/* Guest span replay (dirty_ram_run_span, psx_mod_run_guest_span): while a span + * is open, every PC in [s_span_lo, s_span_hi) (CODE identity) is interpreted + * from the live RAM bytes, even where a compiled body or one of its resume + * points starts there. Calls the span makes leave the range and run on their + * normal backend. A span is a replay, not an entry: it stays out of capture, + * seeding and entry statistics. */ +static uint32_t s_span_lo, s_span_hi; +static inline int span_forced(uint32_t phys) { + return phys >= s_span_lo && phys < s_span_hi; +} + /* Public entry point. Caller (psx_dispatch) has translated `addr` to a * KSEG-stripped form already in some cases, so accept any address and * mask. Returns 1 if interpretation handled the basic block; 0 if the @@ -2553,6 +2564,40 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { return r; } +void dirty_ram_span_get(uint32_t *lo, uint32_t *hi) { + if (lo) *lo = s_span_lo; + if (hi) *hi = s_span_hi; +} + +void dirty_ram_span_set(uint32_t lo, uint32_t hi) { + s_span_lo = lo; + s_span_hi = hi; +} + +int dirty_ram_run_span(CPUState* cpu, uint32_t start_pc, uint32_t stop_pc, + uint64_t max_insns) { + const uint32_t lo = start_pc & 0x1FFFFFFFu, hi = stop_pc & 0x1FFFFFFFu; + if (!cpu || s_span_hi || ((start_pc | stop_pc) & 3u) || lo >= hi || + (start_pc ^ stop_pc) & 0xE0000000u) + return 0; + const uint64_t first = g_dirty_ram_insns_run; + int reached = 0; + s_span_lo = lo; + s_span_hi = hi; + cpu->pc = start_pc; + for (;;) { + const uint32_t pc = cpu->pc; + if (pc == stop_pc) { reached = 1; break; } + if (!pc || !span_forced(pc & 0x1FFFFFFFu) || g_psx_call_bail || + g_dirty_ram_insns_run - first > max_insns) + break; + if (!dirty_ram_dispatch(cpu, pc, stop_pc)) break; + } + s_span_lo = s_span_hi = 0; + dirty_ram_ld_delay_flush(cpu); + return reached; +} + /* ===== Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md) ===== */ /* Is a hardware interrupt deliverable to the guest at this exact instruction @@ -2820,7 +2865,8 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * They differ only for a PC in a retail RAM mirror. */ uint32_t phys = addr & 0x1FFFFFFFu; const uint32_t ram_phys = psx_ram_map_read(phys); - int clean_game_text_miss = 0; + const int forced = span_forced(phys); + int clean_game_text_miss = forced; if (addr == 0x80000048u) { g_sentinel_reach_dirty++; @@ -2848,7 +2894,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ /* Run the statically-compiled game function only while the target is still * native-safe. Dirty overlay pages and pages whose text bytes diverged from * the original EXE image fall through to interpret the live RAM bytes. */ - if (psx_game_text_native_ok(addr)) { + if (!forced && psx_game_text_native_ok(addr)) { g_mixed_depth++; { ls_func_enter(addr, cpu); @@ -2861,7 +2907,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ if (_gc) return 1; } clean_game_text_miss = psx_game_address_in_text(addr) ? 1 : 0; - } else if (psx_game_address_in_text(addr)) { + } else if (!forced && psx_game_address_in_text(addr)) { /* RAM at a game-text address diverged from the static EXE image * (runtime-relocated / overlaid / self-modified code the compiled * static function no longer reflects). The live RAM is the truth: @@ -2879,7 +2925,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * another segment misses above and is interpreted here like any clean * text miss, never run through the other segment's body. Record it with * the full PC; the fix is a segment-qualified seed and regeneration. */ - if (clean_game_text_miss) + if (clean_game_text_miss && !forced) (void)psx_segment_miss_note(addr, psx_game_is_function_entry, cpu->gpr[31], cpu->gpr[29], (uint32_t)s_frame_count); @@ -2892,7 +2938,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ extern int psx_overlay_dispatch(CPUState *cpu, uint32_t addr); int previous_phase = g_exec_phase; g_exec_phase = 3; - int handled = psx_overlay_dispatch(cpu, addr); + int handled = !forced && psx_overlay_dispatch(cpu, addr); g_exec_phase = previous_phase; if (handled) return 1; } @@ -2909,7 +2955,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ extern void overlay_regs_snap(uint32_t out[34], const CPUState *cpu); extern void overlay_fp_log(uint32_t addr, const uint32_t *in_regs, const CPUState *cpu, int native); - int _ovfp = overlay_fp_enabled() && + int _ovfp = !forced && overlay_fp_enabled() && overlay_cache_window_contains(phys) && overlay_loader_is_candidate(phys); uint32_t _in_regs[34]; @@ -2924,7 +2970,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ { extern int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); - if (overlay_loader_dispatch(cpu, addr)) { + if (!forced && overlay_loader_dispatch(cpu, addr)) { if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 1); return 1; } @@ -2959,7 +3005,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * counts dispatches the interpreter actually handles inside a capture * window. The autocapture tick reads-and-resets this to decide whether * an unseen region variant is being interp-executed right now. */ - if (overlay_cache_window_contains(phys)) g_dirty_window_dispatches++; + if (!forced && overlay_cache_window_contains(phys)) g_dirty_window_dispatches++; /* Reset soft-fail state at block entry. */ g_unsupported_seen = 0; @@ -2977,13 +3023,14 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ { extern uint32_t g_psx_mod_function_entry_hooks; extern int psx_mod_function_entry(CPUState *cpu, uint32_t address); - if (g_psx_mod_function_entry_hooks && psx_mod_function_entry(cpu, addr)) return 1; + if (!forced && g_psx_mod_function_entry_hooks && psx_mod_function_entry(cpu, addr)) + return 1; } /* Per-PC entry counter (visible via dirty_ram_stats). */ - DirtyRamPcEntry *pc_entry = pc_table_get_or_insert(phys); + DirtyRamPcEntry *pc_entry = forced ? NULL : pc_table_get_or_insert(phys); if (pc_entry) pc_entry->hits++; - { + if (!forced) { uint32_t word = phys >> 2; g_dirty_ram_dispatch_pc_bitmap[word >> 5] |= 1u << (word & 31u); /* ...and the segment it entered through, from the full PC diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 6f849051..44165ce9 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -108,6 +108,7 @@ typedef struct RenderPassNesting { int ov_active_depth; uint32_t ov_inprogress; void (*ov_flush)(void); + uint32_t span_lo, span_hi; /* open guest span (dirty_ram_run_span) */ } RenderPassNesting; static void nesting_save(RenderPassNesting *n) { @@ -125,6 +126,7 @@ static void nesting_save(RenderPassNesting *n) { dirty_ram_ld_delay_save(&n->ld_delay); overlay_loader_native_nesting(&n->ov_active_depth, &n->ov_inprogress); n->ov_flush = g_overlay_flush_pending_cycles; + dirty_ram_span_get(&n->span_lo, &n->span_hi); } static void nesting_restore(const RenderPassNesting *n) { @@ -142,6 +144,7 @@ static void nesting_restore(const RenderPassNesting *n) { dirty_ram_ld_delay_restore(&n->ld_delay); overlay_loader_set_native_nesting(n->ov_active_depth, n->ov_inprogress); g_overlay_flush_pending_cycles = n->ov_flush; + dirty_ram_span_set(n->span_lo, n->span_hi); } /* PSX_RENDER_PASS_VERIFY: a pass that returned normally must leave the @@ -158,7 +161,8 @@ static int nesting_balanced(const RenderPassNesting *n) { now.exec_phase == n->exec_phase && now.precise_mode == n->precise_mode && now.ov_active_depth == n->ov_active_depth && - now.ov_flush == n->ov_flush; + now.ov_flush == n->ov_flush && + now.span_lo == n->span_lo && now.span_hi == n->span_hi; } /* After a watchdog abort, before the restore: which exits the longjmp @@ -193,6 +197,7 @@ static int nesting_describe(const RenderPassNesting *n, int bb_defer_ck, RP_NOTE(now.exec_phase != n->exec_phase, "exec phase %d->%d", now.exec_phase, n->exec_phase); RP_NOTE(now.ld_delay.armed != n->ld_delay.armed, "pending load"); + RP_NOTE(now.span_hi != n->span_hi, "guest span"); #undef RP_NOTE return count; } @@ -298,6 +303,16 @@ uint32_t psx_mod_render_pass_status(void) { return pass_status(); } +int psx_mod_run_guest_span(CPUState *cpu, uint32_t start_pc, uint32_t stop_pc) { + if (!g_psx_render_pass_active || !cpu) return 0; + if (dirty_ram_run_span(cpu, start_pc, stop_pc, 1000000u)) { + s_stats.spans++; + return 1; + } + s_stats.span_failures++; + return 0; +} + uint32_t psx_mod_render_pass_plan(uint32_t period_vblanks, uint32_t shown_after_vblanks, uint32_t *alpha_q16, uint32_t max) { diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 55668e07..25638b2c 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -236,6 +236,29 @@ static void guest_frame(CPUState *cpu, const Frames *f, int level) { g_call_unit_depth = prev_unit; } +/* The interpreter's guest-span state (dirty_ram_run_span): a span replays + * guest frames like any other code, so a runaway inside one leaves it open + * when the watchdog longjmps out. */ +static uint32_t s_span_lo, s_span_hi; +static const Frames *s_span_frames; +void dirty_ram_span_get(uint32_t *lo, uint32_t *hi) { *lo = s_span_lo; *hi = s_span_hi; } +void dirty_ram_span_set(uint32_t lo, uint32_t hi) { s_span_lo = lo; s_span_hi = hi; } +int dirty_ram_run_span(CPUState *cpu, uint32_t start, uint32_t stop, uint64_t max) { + (void)max; + if (s_span_hi) return 0; + s_span_lo = start & 0x1FFFFFFFu; + s_span_hi = stop & 0x1FFFFFFFu; + guest_frame(cpu, s_span_frames, 0); + s_span_lo = s_span_hi = 0; + return 1; +} + +static int span_pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) { + (void)alpha_q16; + s_span_frames = (const Frames *)user; + return psx_mod_run_guest_span(cpu, 0x800143FCu, 0x8001473Cu); +} + static int pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) { (void)alpha_q16; guest_frame(cpu, (const Frames *)user, 0); @@ -251,7 +274,7 @@ static int leaky_pass_fn(struct CPUState *cpu, void *user, uint32_t alpha_q16) { typedef struct Live { int bb_defer, unit, dma, host, dma_ch, active, phase, precise, dispatch; int ov_depth; - uint32_t ov_ip, batch, resume; + uint32_t ov_ip, batch, resume, span_lo, span_hi; void (*flush)(void); DirtyRamLoadDelay ld; uint64_t cycles, next_service, dev_cycles; @@ -273,6 +296,8 @@ static void snap(Live *l) { l->batch = g_psx_cyc_batch; l->resume = g_dirty_safe_resume_pc; l->flush = g_overlay_flush_pending_cycles; + l->span_lo = s_span_lo; + l->span_hi = s_span_hi; l->ld = s_ld; l->cycles = psx_cycle_count; l->next_service = psx_next_service_cycle; @@ -300,6 +325,8 @@ static void check_live(const Live *a, const char *when) { SAME(batch, "pending cycle batch"); SAME(resume, "interpreter resume latch"); SAME(flush, "shard cycle-flush hook"); + SAME(span_lo, "guest span start"); + SAME(span_hi, "guest span end"); SAME(ld.armed, "pending load (armed)"); SAME(ld.val, "pending load (value)"); SAME(cycles, "guest clock"); @@ -383,6 +410,30 @@ int main(void) { CHECK(st.verify_mismatch == 1, "an unbalanced pass is reported in verify mode"); check_live(&live, "after an unbalanced pass"); + /* 4. A guest span runs only inside a pass. */ + CHECK(psx_mod_run_guest_span(&cpu, 0x800143FCu, 0x8001473Cu) == 0, + "a guest span outside a pass is refused"); + render_pass_get_stats(&st); + CHECK(st.spans == 0 && st.span_failures == 0, "a refused span counts nothing"); + + /* 5. A span that completes inside a pass is counted and closed. */ + snap(&live); + CHECK(psx_mod_render_pass(&cpu, &pass, span_pass_fn, &clean) == 1, + "a pass that replays a span is presented"); + render_pass_get_stats(&st); + CHECK(st.spans == 1, "the span reached its stop"); + check_live(&live, "after a pass with a span"); + + /* 6. A span that runs away: the watchdog's landing closes it, so later + * dispatches of those PCs take their compiled bodies again. */ + snap(&live); + CHECK(psx_mod_render_pass(&cpu, &pass, span_pass_fn, &runaway) == 0, + "a runaway span is rolled back"); + render_pass_get_stats(&st); + CHECK(st.watchdog == 2, "the watchdog cut the span off"); + CHECK(s_span_hi == 0 && s_span_lo == 0, "the abort closed the open span"); + check_live(&live, "after a span watchdog abort"); + printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); return failures ? 1 : 0; } diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index e5f80bba..26908c2a 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -176,6 +176,13 @@ static DirtyRamLoadDelay s_ld; void dirty_ram_ld_delay_discard(void) { memset(&s_ld, 0, sizeof s_ld); } void dirty_ram_ld_delay_save(DirtyRamLoadDelay *o) { *o = s_ld; } void dirty_ram_ld_delay_restore(const DirtyRamLoadDelay *i) { s_ld = *i; } +static uint32_t s_span_lo, s_span_hi; +void dirty_ram_span_get(uint32_t *lo, uint32_t *hi) { *lo = s_span_lo; *hi = s_span_hi; } +void dirty_ram_span_set(uint32_t lo, uint32_t hi) { s_span_lo = lo; s_span_hi = hi; } +int dirty_ram_run_span(CPUState *cpu, uint32_t start, uint32_t stop, uint64_t max) { + (void)cpu; (void)start; (void)stop; (void)max; + return 0; +} void overlay_loader_native_nesting(int *d, uint32_t *ip) { *d = 0; *ip = 0; } void overlay_loader_set_native_nesting(int d, uint32_t ip) { (void)d; (void)ip; } From 1e6ef854c51e60be1dbc647853dfd7b1db04121c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 09:00:38 -0700 Subject: [PATCH 2/6] Frontend: --hidden-window runs the full presenter in a never-shown window --- runtime/src/main.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4078fdcd..f174e1b6 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1303,6 +1303,11 @@ static int g_hotkey_pad_fast_forward_toggle = 0; /* unbound: latch fas static uint32_t g_savestate_input_guard_min_until = 0; static uint32_t g_savestate_input_guard_max_until = 0; static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ +/* Test frontend: the full windowed runtime (presenter, frame interpolation, + * render passes) in a window that is never shown. For automated checks of + * presentation features without putting a window on the desktop; drive it + * over TCP like --headless. */ +static int g_hidden_window = 0; /* FMV instant-skip via the game's OWN end-of-movie path. Tomba's MDEC player * (FUN_8001efe8) tears a movie down when the streamed frame number reaches that @@ -13297,6 +13302,8 @@ int main(int argc, char** argv) { * --launcher force the GUI launcher (overrides skip_launcher) * --no-launcher skip the GUI launcher (boot straight in) * --headless skip SDL window/audio; use TCP screenshots/state + * --hidden-window full presenter in a never-shown window (TCP-driven + * tests of interpolation / render passes) * --netplay enable delay-sync LAN (also PSX_NETPLAY=1) * --net-slot N local player slot (0|1) * --net-input-player N host device to sample (0=P1, 1=P2; default auto) @@ -13338,6 +13345,9 @@ int main(int argc, char** argv) { } else if (std::strcmp(argv[i], "--headless") == 0) { g_headless = 1; force_no_launcher = true; + } else if (std::strcmp(argv[i], "--hidden-window") == 0) { + g_hidden_window = 1; + force_no_launcher = true; } else if (std::strcmp(argv[i], "--netplay") == 0) { net_cfg.enabled = 1; } else if (std::strcmp(argv[i], "--net-slot") == 0 && i + 1 < argc) { @@ -16183,6 +16193,11 @@ session_reboot: win_flags |= PSX_SDL_WINDOW_HIGH_DENSITY; } if (g_video_renderer == 2) win_flags |= SDL_WINDOW_VULKAN; + if (g_hidden_window) { + win_flags &= ~(Uint32)SDL_WINDOW_SHOWN; + win_flags |= SDL_WINDOW_HIDDEN; + g_fullscreen = 0; + } /* Fullscreen on launch (launcher's tri-state Fullscreen control): 1 = * borderless desktop fullscreen (keeps the desktop resolution, letterboxes * the image), 2 = exclusive fullscreen (real display-mode change), 0 = From b27be21d1e84cc09bce8166c35f2a02dd6e77f23 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 09:03:05 -0700 Subject: [PATCH 3/6] Frontend: --hidden-window never blocks on a modal or file picker --- runtime/src/main.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index f174e1b6..7980ad24 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2472,18 +2472,21 @@ static void write_cached_path(const char* argv0, const char* filename, if (f.is_open()) f << path.string() << "\n"; } +/* Nobody is at the screen: never block on a modal dialog or a file picker. */ +static bool frontend_unattended() { return g_headless || g_hidden_window; } + static void launcher_warning(const char* title, const std::string& msg) { std::fprintf(stderr, "%s: %s\n", title, msg.c_str()); - // Headless (--headless / PSX_HEADLESS): NEVER pop a blocking modal — it would + // Unattended (--headless / PSX_HEADLESS / --hidden-window): NEVER pop a blocking modal — it would // hang an unattended/CI/scripted run forever waiting for a click. // SDL_ShowSimpleMessageBox is cross-platform; the Win32-only MessageBoxA left // macOS and Linux users with a silent exit (v0.4.0 macOS report, 2026-09-15). - if (!g_headless) SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_WARNING, title, msg.c_str(), NULL); + if (!frontend_unattended()) SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_WARNING, title, msg.c_str(), NULL); } static void launcher_info(const char* title, const std::string& msg) { std::fprintf(stderr, "%s: %s\n", title, msg.c_str()); - if (!g_headless) SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_INFORMATION, title, msg.c_str(), NULL); + if (!frontend_unattended()) SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_INFORMATION, title, msg.c_str(), NULL); } /* Game display name for picker dialogs ("Tomba!"); set after the game @@ -2494,7 +2497,8 @@ static bool pick_runtime_file(const char* title, const char* filter, std::filesystem::path& out, const char* cli_flag) { // Headless: never open an interactive file dialog (it blocks). Fail the // resolve so boot aborts cleanly with the stderr message the caller printed. - if (g_headless) { + // Also when unattended in a hidden window (--hidden-window). + if (frontend_unattended()) { std::fprintf(stderr, "psxrecomp: headless — cannot prompt for '%s'.\n" " Supply it on the command line: %s (or set it in game.toml).\n", From 61bc68e53391f2c29160af959af72205a52fb6da Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 09:11:43 -0700 Subject: [PATCH 4/6] Render passes: FLIP_SHOWN mode for games that flip each frame as soon as it is drawn --- runtime/include/gpu_gl_renderer.h | 3 +++ runtime/include/mod_plugins.h | 18 ++++++++++++++++++ runtime/include/render_pass_plan.h | 12 ++++++++++++ runtime/src/gpu_gl_renderer.c | 14 ++++++++++---- runtime/src/render_pass.c | 9 +++++++++ runtime/src/render_pass_plan.c | 10 ++++++++++ runtime/tests/test_render_pass_abort.c | 1 + runtime/tests/test_render_pass_plan.c | 21 +++++++++++++++++++++ runtime/tests/test_render_pass_sandbox.c | 1 + 9 files changed, 85 insertions(+), 4 deletions(-) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index ba2f4a7f..9e3bcfe0 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -66,6 +66,9 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks, * rect at the same scale. Returns 0 when refused (nothing changed). */ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, uint32_t period_vblanks, int reuse_backup); +/* Generations opened from now on are for a frame already on screen (1) or + * for the next flip (0, default); render_pass_gen_flip_matches. */ +void gl_renderer_pass_set_flip_shown(int shown); /* 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); diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 737260c5..7c5a614d 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -349,6 +349,24 @@ enum { }; uint32_t psx_mod_render_pass_status(void); +/* + * When the game flips relative to the pass point. PENDING (default, reset at + * every session start): the pass rect is the one the next flip will show -- + * the PsyQ VSync(0)-then-PutDispEnv loop, whose frame N is drawn and waits. + * SHOWN: the game flips each frame as soon as it is drawn (for example + * PutDispEnv in a VBlank callback armed by the DrawSync callback), so by the + * time frame N+1's logic is done frame N is already on screen. The pass rect + * is then that on-screen rect, and frame N's own image and its in-between + * images are shown from the game's next flip on: one game frame later than + * the game shows them, as a host interpolator with one frame of history + * would. Set it from activation, before the first plan. + */ +enum { + PSX_MOD_RENDER_PASS_FLIP_PENDING = 0, + PSX_MOD_RENDER_PASS_FLIP_SHOWN = 1 +}; +int psx_mod_set_render_pass_flip(uint32_t mode); + /* * Replay part of an already-loaded guest function inside a render pass: * run from start_pc with the CPU state given until control reaches stop_pc. diff --git a/runtime/include/render_pass_plan.h b/runtime/include/render_pass_plan.h index 294efee6..e4e519f2 100644 --- a/runtime/include/render_pass_plan.h +++ b/runtime/include/render_pass_plan.h @@ -65,6 +65,18 @@ int render_pass_select(const uint32_t *phases, uint32_t n, double p, int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p, uint32_t *lo, uint32_t *hi, float *t); +/* Is the flip the FLIP source just saw the one a pending generation waits + * for? A generation built for the next flip (`shown` 0, the canonical PsyQ + * VSync-then-PutDispEnv point) waits for the display to flip to its own rect. + * One built for a frame already on screen (`shown` 1: the game flips each + * frame as soon as it is drawn) waits for the next flip, to any other rect; + * its images then show one game frame late. Either way the presented + * geometry must still be the one the generation was captured at. */ +int render_pass_gen_flip_matches(int shown, int gen_x, int gen_y, + int gen_source, int gen_w, int gen_h, + int flip_x, int flip_y, int flip_source, + int flip_w, int flip_h); + /* Exponential moving average used for the per-pass host cost. */ double render_pass_ema(double current, double sample); diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 52ff57e5..218092c9 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -5782,8 +5782,10 @@ typedef struct PassGen { 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 */ + int shown; /* built for a frame already on screen */ double t_start, t_len; /* host ticks, set on promotion */ } PassGen; +static int s_pass_flip_shown = 0; static PassGen s_pgen[2]; /* Slot textures are made as slots fill: [0, s_pgen_alloc_n) exist, all at * s_pgen_alloc_w x h. A generation never fills past pass_slot_cap, so two @@ -6196,6 +6198,7 @@ int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, 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; + g->shown = s_pass_flip_shown; pass_capture_into(s_pgen_tex[gi][0], g); /* the game's own image */ g->phase[0] = 0; g->n = 1; @@ -6421,14 +6424,17 @@ static uint64_t s_present_ticks_accum_fwd(uint64_t add) { return s_present_ticks_accum; } +void gl_renderer_pass_set_flip_shown(int shown) { s_pass_flip_shown = shown ? 1 : 0; } + /* 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. */ + * for (render_pass_gen_flip_matches) 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) { + if (pend->valid && !pend->promoted && + render_pass_gen_flip_matches(pend->shown, pend->x, pend->y, + pend->source_path, pend->tex_w, pend->tex_h, + origin_x, origin_y, source_path, pw, ph)) { s_pgen_promote = 1; } else { s_pgen_promote = 0; diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index 44165ce9..c287993b 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -275,6 +275,15 @@ void render_pass_reset_session(void) { memset(g_render_pass_dropped_writes, 0, sizeof g_render_pass_dropped_writes); s_open_generation = 0; s_restored_valid = 0; + gl_renderer_pass_set_flip_shown(0); +} + +int psx_mod_set_render_pass_flip(uint32_t mode) { + if (mode != PSX_MOD_RENDER_PASS_FLIP_PENDING && + mode != PSX_MOD_RENDER_PASS_FLIP_SHOWN) + return 0; + gl_renderer_pass_set_flip_shown(mode == PSX_MOD_RENDER_PASS_FLIP_SHOWN); + return 1; } /* Everything that must hold before guest code may run frozen, as a diff --git a/runtime/src/render_pass_plan.c b/runtime/src/render_pass_plan.c index b04ef525..21e17fd5 100644 --- a/runtime/src/render_pass_plan.c +++ b/runtime/src/render_pass_plan.c @@ -111,6 +111,16 @@ int render_pass_select(const uint32_t *phases, uint32_t n, double p, return 1; } +int render_pass_gen_flip_matches(int shown, int gen_x, int gen_y, + int gen_source, int gen_w, int gen_h, + int flip_x, int flip_y, int flip_source, + int flip_w, int flip_h) { + const int same_rect = gen_x == flip_x && gen_y == flip_y; + if (gen_source != flip_source || gen_w != flip_w || gen_h != flip_h) + return 0; + return shown ? !same_rect : same_rect; +} + int render_pass_gen_select(const uint32_t *phases, uint32_t n, double p, uint32_t *lo, uint32_t *hi, float *t) { if (!(p <= RENDER_PASS_GEN_HOLD_MAX)) return 0; diff --git a/runtime/tests/test_render_pass_abort.c b/runtime/tests/test_render_pass_abort.c index 25638b2c..36673d48 100644 --- a/runtime/tests/test_render_pass_abort.c +++ b/runtime/tests/test_render_pass_abort.c @@ -152,6 +152,7 @@ uint32_t gl_renderer_pass_plan(uint32_t p, uint32_t s, uint32_t *a, return 0; } static int s_open_passes, s_kept; +void gl_renderer_pass_set_flip_shown(int shown) { (void)shown; } int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, uint32_t period, int reuse_backup) { (void)x; (void)y; (void)w; (void)h; (void)open_gen; (void)period; diff --git a/runtime/tests/test_render_pass_plan.c b/runtime/tests/test_render_pass_plan.c index e514618f..65a12fcd 100644 --- a/runtime/tests/test_render_pass_plan.c +++ b/runtime/tests/test_render_pass_plan.c @@ -149,6 +149,26 @@ static void test_gen_select_late(void) { CHECK(!render_pass_gen_select(ph, 0, 0.5, &lo, &hi, &t), "no items, no pick"); } +/* Promotion: a generation for the next flip waits for its own rect; one for + * a frame already on screen waits for the flip after it, to another rect. */ +static void test_gen_flip_matches(void) { + /* PsyQ VSync-then-PutDispEnv: built for the rect the flip shows. */ + CHECK(render_pass_gen_flip_matches(0, 0, 240, 1, 320, 240, 0, 240, 1, 320, 240), + "pending: the flip to the generation's rect promotes it"); + CHECK(!render_pass_gen_flip_matches(0, 0, 240, 1, 320, 240, 0, 0, 1, 320, 240), + "pending: a flip to the other buffer does not"); + /* Flip-when-drawn (V8:2): built for the rect already on screen. */ + CHECK(render_pass_gen_flip_matches(1, 0, 240, 1, 320, 240, 0, 0, 1, 320, 240), + "shown: the next flip, to the other buffer, promotes it"); + CHECK(!render_pass_gen_flip_matches(1, 0, 240, 1, 320, 240, 0, 240, 1, 320, 240), + "shown: a redraw of the rect on screen is not the next flip"); + /* Geometry the images were captured at must still be presented. */ + CHECK(!render_pass_gen_flip_matches(1, 0, 240, 1, 320, 240, 0, 0, 0, 320, 240), + "a different presented source never promotes"); + CHECK(!render_pass_gen_flip_matches(0, 0, 0, 1, 320, 240, 0, 0, 1, 640, 480), + "a different presented size never promotes"); +} + 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"); @@ -301,6 +321,7 @@ int main(void) { test_shedding(); test_select(); test_gen_select_late(); + test_gen_flip_matches(); test_budget_and_ema(); printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); return failures ? 1 : 0; diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index 26908c2a..ceb99fca 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -202,6 +202,7 @@ uint32_t gl_renderer_pass_plan(uint32_t p, uint32_t s, uint32_t *a, return 0; } static int s_open_passes, s_kept; +void gl_renderer_pass_set_flip_shown(int shown) { (void)shown; } int gl_renderer_pass_begin(int x, int y, int w, int h, int open_gen, uint32_t period, int reuse_backup) { (void)x; (void)y; (void)w; (void)h; (void)open_gen; (void)period; From 3935ae8cab4336ed14d3a01237b066079ac2961b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 09:31:14 -0700 Subject: [PATCH 5/6] Render passes: spans run their calls with the full trampoline; phase 0 is the flipped image Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/include/dirty_ram_interp.h | 10 ++++ runtime/src/debug_server.c | 12 ++++- runtime/src/dirty_ram_interp.c | 79 ++++++++++++++++++++++-------- runtime/src/gpu_gl_renderer.c | 13 +++++ 4 files changed, 92 insertions(+), 22 deletions(-) diff --git a/runtime/include/dirty_ram_interp.h b/runtime/include/dirty_ram_interp.h index 18cc4ee8..e9a9620f 100644 --- a/runtime/include/dirty_ram_interp.h +++ b/runtime/include/dirty_ram_interp.h @@ -56,6 +56,16 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr); * Mods reach it through psx_mod_run_guest_span (render passes only). */ int dirty_ram_run_span(CPUState* cpu, uint32_t start_pc, uint32_t stop_pc, uint64_t max_insns); +/* Why the last span that failed stopped: 1 returned (pc 0), 2 left the range, + * 3 call-bail unwind, 4 instruction budget, 5 the interpreter declined the PC, + * 6 bad arguments or a span already open, 7 a call did not return to the + * span (`after`). `pc` is where control was. */ +typedef struct DirtyRamSpanFailure { + uint32_t reason, pc, start_pc, stop_pc; + uint32_t ra, after; /* $ra at the exit; pc after a call that did not return */ + uint64_t insns; +} DirtyRamSpanFailure; +void dirty_ram_span_last_failure(DirtyRamSpanFailure *out); /* The open span (0,0 = none), for landings that longjmp past its exit. */ void dirty_ram_span_get(uint32_t *lo, uint32_t *hi); void dirty_ram_span_set(uint32_t lo, uint32_t hi); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 95e8db9a..6c6c4744 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7957,7 +7957,9 @@ static void handle_render_pass_stats(int id, const char *json) RenderPassStats st; uint64_t gd[10], image_bytes = 0; uint32_t image_textures; + DirtyRamSpanFailure sf; render_pass_get_stats(&st); + dirty_ram_span_last_failure(&sf); gl_renderer_pass_diag(gd); image_textures = gl_renderer_pass_image_textures(&image_bytes); send_fmt("{\"id\":%d,\"ok\":true,\"plans\":%llu,\"planned\":%llu," @@ -7977,7 +7979,10 @@ static void handle_render_pass_stats(int id, const char *json) "\"cost_us\":%llu,\"cost_rewarms\":%llu,\"frame_images\":%llu," "\"journaled\":%llu," "\"image_textures\":%u,\"image_bytes\":%llu,\"status\":%u," - "\"backups_reused\":%llu,\"spans\":%llu,\"span_failures\":%llu}", + "\"backups_reused\":%llu,\"spans\":%llu,\"span_failures\":%llu," + "\"span_fail\":{\"reason\":%u,\"pc\":\"0x%08X\",\"start\":\"0x%08X\"," + "\"stop\":\"0x%08X\",\"ra\":\"0x%08X\",\"after\":\"0x%08X\"," + "\"insns\":%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, @@ -8004,7 +8009,10 @@ static void handle_render_pass_stats(int id, const char *json) (unsigned)image_textures, (unsigned long long)image_bytes, (unsigned)psx_mod_render_pass_status(), (unsigned long long)gl_renderer_pass_backups_reused(), - (unsigned long long)st.spans, (unsigned long long)st.span_failures); + (unsigned long long)st.spans, (unsigned long long)st.span_failures, + (unsigned)sf.reason, (unsigned)sf.pc, (unsigned)sf.start_pc, + (unsigned)sf.stop_pc, (unsigned)sf.ra, (unsigned)sf.after, + (unsigned long long)sf.insns); } /* render_pass_refuse on=<0|1>: make the OpenGL backend decline render passes diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 01bab932..41335c96 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -512,6 +512,17 @@ extern int psx_game_text_native_ok_full(uint32_t addr); #endif extern void psx_dispatch_call(CPUState* cpu, uint32_t addr, uint32_t return_addr); +/* Guest span replay (dirty_ram_run_span, psx_mod_run_guest_span): while a span + * is open, every PC in [s_span_lo, s_span_hi) (CODE identity) is interpreted + * from the live RAM bytes, even where a compiled body or one of its resume + * points starts there. Calls the span makes leave the range and run on their + * normal backend. A span is a replay, not an entry: it stays out of capture, + * seeding and entry statistics. */ +static uint32_t s_span_lo, s_span_hi; +static inline int span_forced(uint32_t phys) { + return phys >= s_span_lo && phys < s_span_hi; +} + /* Forward decls from memory.c — used to read instruction bytes. */ extern uint8_t *memory_get_ram_ptr(void); extern void dirty_ram_mark_executable_range(uint32_t phys, uint32_t len); @@ -1710,7 +1721,9 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, #endif uint32_t _cr = callret_begin(cpu, pc, target); /* call-resolution ring */ #define CRET(code, rv) do { callret_end(_cr, cpu, (code)); return (rv); } while (0) - if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; CRET(CRES_PLAIN, 1); } /* slice / lockstep-replay: plain transfer, never execute the callee */ + /* slice / lockstep-replay / guest span: plain transfer, never execute the + * callee (a span's runner calls it with the full trampoline). */ + if (g_precise_mode || g_ls_replay_active || span_forced(pc & 0x1FFFFFFFu)) { cpu->pc = target; CRET(CRES_PLAIN, 1); } if (rd != 31) { /* No architectural $ra contract: preserve the transfer as a * pc-chain and let the callee's eventual JR choose the real @@ -1928,7 +1941,9 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, #else #define XRES(code) do { (void)(code); } while (0) #endif - if (g_precise_mode || g_ls_replay_active) { cpu->pc = target; return 1; } /* slice / lockstep-replay: plain transfer, never execute the callee */ + /* slice / lockstep-replay / guest span: plain transfer, never execute the + * callee (a span's runner calls it with the full trampoline). */ + if (g_precise_mode || g_ls_replay_active || span_forced(pc & 0x1FFFFFFFu)) { cpu->pc = target; return 1; } #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (interp_enter_compiled(cpu, target)) { @@ -2414,16 +2429,6 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_addr); -/* Guest span replay (dirty_ram_run_span, psx_mod_run_guest_span): while a span - * is open, every PC in [s_span_lo, s_span_hi) (CODE identity) is interpreted - * from the live RAM bytes, even where a compiled body or one of its resume - * points starts there. Calls the span makes leave the range and run on their - * normal backend. A span is a replay, not an entry: it stays out of capture, - * seeding and entry statistics. */ -static uint32_t s_span_lo, s_span_hi; -static inline int span_forced(uint32_t phys) { - return phys >= s_span_lo && phys < s_span_hi; -} /* Public entry point. Caller (psx_dispatch) has translated `addr` to a * KSEG-stripped form already in some cases, so accept any address and @@ -2574,28 +2579,62 @@ void dirty_ram_span_set(uint32_t lo, uint32_t hi) { s_span_hi = hi; } +static DirtyRamSpanFailure s_span_failure; + +void dirty_ram_span_last_failure(DirtyRamSpanFailure *out) { + if (out) *out = s_span_failure; +} + int dirty_ram_run_span(CPUState* cpu, uint32_t start_pc, uint32_t stop_pc, uint64_t max_insns) { const uint32_t lo = start_pc & 0x1FFFFFFFu, hi = stop_pc & 0x1FFFFFFFu; + DirtyRamSpanFailure f = {0u, start_pc, start_pc, stop_pc, 0u, 0u, 0u}; if (!cpu || s_span_hi || ((start_pc | stop_pc) & 3u) || lo >= hi || - (start_pc ^ stop_pc) & 0xE0000000u) + (start_pc ^ stop_pc) & 0xE0000000u) { + f.reason = 6u; + s_span_failure = f; return 0; + } const uint64_t first = g_dirty_ram_insns_run; - int reached = 0; s_span_lo = lo; s_span_hi = hi; cpu->pc = start_pc; for (;;) { const uint32_t pc = cpu->pc; - if (pc == stop_pc) { reached = 1; break; } - if (!pc || !span_forced(pc & 0x1FFFFFFFu) || g_psx_call_bail || - g_dirty_ram_insns_run - first > max_insns) - break; - if (!dirty_ram_dispatch(cpu, pc, stop_pc)) break; + f.pc = pc; + f.ra = cpu->gpr[31]; + if (pc == stop_pc) break; + if (!pc) { f.reason = 1u; break; } + if (g_psx_call_bail) { f.reason = 3u; break; } + if (!span_forced(pc & 0x1FFFFFFFu)) { + /* The interpreter hands a call out of clean code back as a tail + * transfer (pc = callee, $ra = the return address). A call made + * by the span returns into it: run it as the caller would. A + * transfer out that is not a call ends the span. */ + const uint32_t ra = cpu->gpr[31]; + const uint32_t site = ra - 8u; + /* The span's last instruction may be a call: it returns to stop. */ + const uint32_t w = span_forced(site & 0x1FFFFFFFu) && + (span_forced(ra & 0x1FFFFFFFu) || ra == stop_pc) + ? fetch_word(site & 0x1FFFFFFFu) : 0u; + const int is_call = (w >> 26) == 0x03u || /* jal */ + ((w >> 26) == 0u && (w & 0x3Fu) == 0x09u); /* jalr */ + if (!is_call) { f.reason = 2u; break; } + cpu->pc = 0; + psx_dispatch_call(cpu, pc, ra); + if (g_psx_call_bail) { f.reason = 3u; break; } + if (cpu->pc != 0u && cpu->pc != ra) { f.after = cpu->pc; f.reason = 7u; break; } + cpu->pc = ra; + continue; + } + if (g_dirty_ram_insns_run - first > max_insns) { f.reason = 4u; break; } + if (!dirty_ram_dispatch(cpu, pc, stop_pc)) { f.reason = 5u; break; } } s_span_lo = s_span_hi = 0; dirty_ram_ld_delay_flush(cpu); - return reached; + f.insns = g_dirty_ram_insns_run - first; + if (f.reason) s_span_failure = f; + return f.reason == 0u; } /* ===== Cycle-budgeted precise event slicing (PRECISE_IRQ_SLICE.md) ===== */ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 218092c9..ef6b8f3b 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -6490,6 +6490,19 @@ static void pass_apply_promotion(void) { g->promoted = 1; g->t_start = s_interp_schedule.frame_start; g->t_len = (double)g->period * sp; + /* Phase 0 is the image the game actually flipped to. The capture at + * the pass point is the same for a game that draws nothing more into + * the rect before its flip; a game that finishes the frame later (V8:2 + * draws its HUD at the next submit, onto the rect about to be shown) + * would otherwise show its own frame without that last layer. The + * flip was matched to this generation's rect and presented geometry, + * and no guest code ran since it, so the rect holds that image. A + * FLIP_SHOWN generation's rect is not the one flipped to: it keeps + * the image taken when it opened. */ + if (!g->shown) { + pass_capture_into(s_pgen_tex[s_pgen_cur][0], g); + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, 0); + } s_pgen_promotions++; if (s_pdump_left > 0) pass_dump_generation(s_pgen_cur); } From 642cd48f7da4f3e34fe624aaf8fee3d765428d24 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 2 Oct 2026 10:16:55 -0700 Subject: [PATCH 6/6] Render passes: motion kit and per-frame driver for frame-rate plugins; porting guide Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/RENDER_PASSES.md | 119 +++++++ runtime/CMakeLists.txt | 12 + runtime/include/mod_plugins.h | 26 ++ runtime/include/render_pass_motion.h | 119 +++++++ runtime/runtime.cmake | 2 + runtime/src/render_pass_frame.c | 58 +++ runtime/src/render_pass_motion.c | 447 ++++++++++++++++++++++++ runtime/tests/test_render_pass_motion.c | 215 ++++++++++++ 8 files changed, 998 insertions(+) create mode 100644 runtime/include/render_pass_motion.h create mode 100644 runtime/src/render_pass_frame.c create mode 100644 runtime/src/render_pass_motion.c create mode 100644 runtime/tests/test_render_pass_motion.c diff --git a/docs/RENDER_PASSES.md b/docs/RENDER_PASSES.md index 6275f333..3090994e 100644 --- a/docs/RENDER_PASSES.md +++ b/docs/RENDER_PASSES.md @@ -73,6 +73,125 @@ back to `HOLD` when the status is `READY` again. A backend mode that cannot host passes (for example a renderer path whose VRAM is not one texture) should report `BACKEND` from `gl_renderer_pass_unavailable()`. +## Flip modes + +`psx_mod_set_render_pass_flip()` says how the game flips relative to the pass +point (default `PENDING`, reset at every session start): + +- `PENDING`: frame N is drawn and waits; the next flip shows the pass rect. + This is the PsyQ `VSync(0)`-then-`PutDispEnv` loop, and also any game that + flips only after the next frame is submitted (Vigilante 8: 2nd Offense + flips from a VBlank callback armed by the next submit). Phase 0 is + re-captured when the flip is seen, so a game that adds a last layer to the + shown buffer after the pass point (V8:2 draws its HUD at the next submit) + shows its own final image at phase 0. +- `SHOWN`: the game flips each frame as soon as it is drawn, so at the pass + point frame N is already on screen. The rect is that on-screen rect; frame + N and its in-betweens are shown from the next flip, one game frame later + than stock (the latency of any interpolator with one frame of history). + +Decide by decoding the game's DISPENV / DRAWENV tables and where it calls +`PutDispEnv` and `DrawOTag`, not by assumption: in V8:2 `DISPENV[i]` shows +the buffer `DRAWENV[1-i]` draws. + +## Replaying a span of frame code + +Re-implementing a game's per-frame draw call sequence in a plugin copies one +path through it (HUD, messages, pause menu, replay mode each branch +differently) and drifts from it. Instead, a plugin records the registers at +the start of the game's draw section during the real frame (an instruction +hook, `mod_instruction_sites`) and, inside a pass, replays that section: + +```c +int psx_mod_run_guest_span(struct CPUState *cpu, uint32_t start_pc, uint32_t stop_pc); +``` + +Every PC in `[start_pc, stop_pc)` is interpreted from its RAM bytes, even +where a compiled body or a compiled resume point (the return address of a +call) begins, so the span may start in the middle of a compiled function. +Calls the span makes leave the range as plain transfers and run with the +full dispatch trampoline back to their return address (compiled code is CPS: +a callee's own calls and returns go through the trampoline). A call may be +the span's last instruction. Anything else that leaves the range ends the +span with failure. Only inside a pass: the sandbox rolls a failed or runaway +span back, and the watchdog landing closes an open span. The span is a +replay of the game's own instructions, never a substitute for code the +recompiler could not translate; it stays out of capture, alias seeding and +per-PC entry statistics, and fires instruction hooks as compiled code does. +`render_pass_stats` counts `spans` / `span_failures` and keeps the last +failure (`span_fail`: reason, exit PC, `$ra`, post-call PC). + +## Blending game state (render_pass_motion.h) + +What makes a pass an in-between image is the state the draw code reads, +placed part of the way between two logic ticks. `render_pass_motion.h` holds +that for any title: + +```c +psx_motion_begin(set, tick); /* each frame, capture point */ +psx_motion_track(set, PSX_MOTION_MATRIX, obj + 0x20, psx_motion_identity(type, fn, mesh)); +psx_motion_prepare(set, &limits, &stats); /* pair with the previous frame */ +/* in each pass */ +psx_motion_apply(set, alpha); /* write blends to guest RAM */ +psx_motion_blend_at(set, kind, addr, alpha, &out); /* a value a hook hands on */ +``` + +Kinds: PsyQ `MATRIX` (slerped rotation, linear translation), its translation +only (billboards), `VECTOR`, `SVECTOR`, 12-bit `ANGLES` (shorter arc) and a +`SCALAR`. Values pair by address and identity, so a heap block reused by +another object never blends between the two. `limits.move_per_tick` (game +units) and `limits.turn_radians` separate motion from placement: a value +past them was respawned, teleported or cut to and keeps its current value. +Measure the game's real motion first; V8:2's world spans about 2^27 units +per axis and moves up to 2^16 per frame. + +## Per-frame driver + +```c +int psx_mod_activate_render_pass_rate(const char *pkg, const char *feature, + const char *option, uint32_t flip_mode); +uint32_t psx_mod_render_pass_frame(struct CPUState *cpu, + const PSXModRenderPassFrame *frame, + PSXModRenderPassFn fn, void *user); +``` + +The first reads a choice option (`display` or a rate) and selects the FLIP +source, HOLD blend, the flip mode and the rate. The second plans one game +frame, runs `fn` at each phase into the frame's rect, and switches the +presenter to a crossfade while passes are unavailable for a lasting reason +(back to HOLD when they return). + +## Porting a title + +1. Find the main loop's draw section: the range from camera setup through + OT build and HUD to the submit call, and every call in it. +2. Decode DISPENV/DRAWENV and the submit/flip path: which buffer the next + flip shows at the pass point, and in what order the game draws into it + (immediate `DrawOTag`, VBlank callback). Pick the flip mode. +3. Find the pass point (a function entry after the section's OT build, before + submit) and the logic tick counter. +4. Find what the draw code reads that moves: the camera input (often the + argument of a set-camera function: track it with `blend_at` from an entry + hook), object transforms and their identity, particles. +5. Measure motion per frame with histograms (mod counters) before setting + limits. +6. Plugin: instruction hook at the section start (registers), capture at the + pass point, `psx_mod_render_pass_frame` with a pass function that applies + the blend, replays the span and submits as the screen gets it. +7. Verify in `--hidden-window` (below): `render_pass_stats` passes with no + span failures, `render_pass_dump` images between their neighbours, + `PSX_RENDER_PASS_VERIFY=1` with `verify_mismatch` 0. + +`Vigilante82PSXRecomp/src/mods/v82_frame_rate_plugin.cpp` is the worked +example. + +## Testing without a window + +`--hidden-window` runs the full windowed runtime (presenter, interpolation, +render passes) in a window that is never shown and never opens a modal or a +file picker. `--headless` skips the presenter, so plans there are always +empty. Drive it over TCP; set `SDL_AUDIODRIVER=dummy` to keep it silent. + ## What a pass may and may not do While `fn` runs (`g_psx_render_pass_active`): diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 56a208d9..c2226f66 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -795,6 +795,18 @@ if(BUILD_TESTING) endif() add_test(NAME render_pass_plan_test COMMAND render_pass_plan_test) + # Motion kit and per-frame driver for render-pass plugins: blending, + # identity matching, limits and the crossfade fallback, on mock RAM. + add_executable(render_pass_motion_test + tests/test_render_pass_motion.c + src/render_pass_motion.c + src/render_pass_frame.c) + target_include_directories(render_pass_motion_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(render_pass_motion_test PRIVATE m) + endif() + add_test(NAME render_pass_motion_test COMMAND render_pass_motion_test) + # Render-pass time freeze against mock devices (psx_cycles.c only). add_executable(render_pass_freeze_test tests/test_render_pass_freeze.c diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 7c5a614d..151e6f81 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -367,6 +367,32 @@ enum { }; int psx_mod_set_render_pass_flip(uint32_t mode); +/* + * The per-frame plumbing every frame-rate plugin repeats (render_pass_frame.c). + * Blending the game state itself: render_pass_motion.h. + * + * Activation: read a choice option holding "display" (follow the measured + * display refresh) or a frame rate, and select the FLIP source, HOLD blend, + * `flip_mode` and that rate. Returns 0 if any setting was refused. + */ +int psx_mod_activate_render_pass_rate(const char* package, const char* feature, + const char* option, uint32_t flip_mode); +typedef struct PSXModRenderPassFrame { + uint32_t struct_size; /* sizeof(PSXModRenderPassFrame) */ + uint32_t period_vblanks; /* VBlanks this game frame stays on screen */ + uint32_t shown_after_vblanks; /* VBlank presents before it is shown */ + uint16_t x, y, w, h; /* VRAM rect the passes draw */ +} PSXModRenderPassFrame; +/* + * Plan this game frame's passes and run `fn` at each phase. While passes are + * unavailable for a lasting reason (BACKEND, DISABLED) the presenter is + * switched to a motion-adaptive crossfade, and back to HOLD once they return. + * Returns how many passes produced an image. + */ +uint32_t psx_mod_render_pass_frame(struct CPUState* cpu, + const PSXModRenderPassFrame* frame, + PSXModRenderPassFn fn, void* user); + /* * Replay part of an already-loaded guest function inside a render pass: * run from start_pc with the CPU state given until control reaches stop_pc. diff --git a/runtime/include/render_pass_motion.h b/runtime/include/render_pass_motion.h new file mode 100644 index 00000000..9bb1ea7b --- /dev/null +++ b/runtime/include/render_pass_motion.h @@ -0,0 +1,119 @@ +#ifndef PSX_RENDER_PASS_MOTION_H +#define PSX_RENDER_PASS_MOTION_H +/* + * Motion kit for render-pass plugins (docs/RENDER_PASSES.md, "Blending game + * state"). A pass redraws the scene with the game's own code between two + * logic ticks; what makes the image an in-between one is the game state the + * draw code reads, placed part of the way from the previous tick to the + * current one. This kit owns that part for any title: + * + * per game frame, at the plugin's capture point + * psx_motion_begin(set, tick) the game's logic tick counter + * psx_motion_track(set, kind, addr, identity) for each value the draw + * code reads (object matrices, + * positions, angles, the camera) + * psx_motion_prepare(set, &limits, &stats) pair with the previous frame + * in each pass + * psx_motion_apply(set, alpha) write the blends into guest RAM + * psx_motion_blend_at(set, addr, ...) or blend one value a hook hands on + * + * Values are matched by address AND identity: game objects are heap blocks, + * and a freed address reused by another object must never blend between the + * two. A value that moved further than the limits allow in the ticks between + * the frames was placed (respawn, teleport, camera cut), not moved; it keeps + * its current value, as do values without history. Nothing is written + * outside psx_motion_apply, which a pass's sandbox rolls back. + * + * Guest RAM is read and written through psx_mod_read_* / psx_mod_write_*. + */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* What lives at a tracked address (guest RAM layout). */ +enum { + /* PsyQ MATRIX: 3x3 s16 rotation in 4.12 at +0x00..+0x11, s32 t[3] at + * +0x14. Rotation slerps when both rows are orthonormal, otherwise it + * blends elementwise; translation blends linearly. */ + PSX_MOTION_MATRIX = 1, + /* Only the translation of a MATRIX (+0x14..+0x1F): for matrices whose + * rotation the draw code rebuilds itself (billboards). */ + PSX_MOTION_MATRIX_TRANSLATION = 2, + PSX_MOTION_VECTOR = 3, /* s32 x, y, z */ + PSX_MOTION_SVECTOR = 4, /* s16 x, y, z */ + /* s16 x, y, z angles, 4096 per turn: each blends along the shorter arc. */ + PSX_MOTION_ANGLES = 5, + PSX_MOTION_SCALAR = 6 /* s32 */ +}; + +typedef struct PSXMotionLimits { + /* Distance one value may move per logic tick and still blend + * (translation, VECTOR, SVECTOR, SCALAR, in the game's units). */ + double move_per_tick; + /* Rotation between the two frames beyond which a value snaps + * (MATRIX rotation, ANGLES). 0 means pi (any turn up to a half turn). */ + double turn_radians; +} PSXMotionLimits; + +typedef struct PSXMotionStats { + uint32_t tracked; /* values captured this frame */ + uint32_t blended; /* ... that will blend */ + uint32_t placed; /* ... that moved or turned past the limits */ + uint32_t unmatched; /* ... new, or a different identity at the address */ + uint32_t ticks; /* logic ticks between the two frames (0 = no history) */ +} PSXMotionStats; + +typedef struct PSXMotionMatrix { + int16_t r[9]; + int32_t t[3]; +} PSXMotionMatrix; + +typedef struct PSXMotionSet PSXMotionSet; + +PSXMotionSet* psx_motion_set_create(uint32_t capacity); +void psx_motion_set_destroy(PSXMotionSet* set); + +/* Start this frame's capture. `tick` is the game's logic tick counter at + * this frame; the previous capture becomes the history. */ +void psx_motion_begin(PSXMotionSet* set, uint32_t tick); +/* Record the value of `kind` at `addr` as the draw code will read it. + * `identity` names what lives there (psx_motion_identity of its type, + * handler, model...). One value per (addr, kind) per frame; a repeat + * replaces the earlier one. Returns 0 when the set is full. */ +int psx_motion_track(PSXMotionSet* set, uint32_t kind, uint32_t addr, + uint32_t identity); +/* Pair this capture with the previous one. Returns how many values will + * blend. History counts only when it is the capture just before this one + * with 1..8 ticks between them; otherwise nothing blends. */ +uint32_t psx_motion_prepare(PSXMotionSet* set, const PSXMotionLimits* limits, + PSXMotionStats* stats); +/* Write every prepared value at t (0 = previous frame, 1 = this frame). */ +void psx_motion_apply(const PSXMotionSet* set, double t); +/* Blend one prepared value at t into `out` without writing guest RAM: + * a PSXMotionMatrix for the MATRIX kinds, int32_t[3] for VECTOR / SVECTOR / + * ANGLES, int32_t for SCALAR. Returns 0 when that value does not blend this + * frame (out then holds its current value) or was not tracked (out + * untouched, returns -1). */ +int psx_motion_blend_at(const PSXMotionSet* set, uint32_t kind, uint32_t addr, + double t, void* out); +/* Forget all history (scene change, load, a frame the plugin skipped). */ +void psx_motion_invalidate(PSXMotionSet* set); + +/* Mix up to three words into an identity tag. */ +uint32_t psx_motion_identity(uint32_t a, uint32_t b, uint32_t c); + +/* The math, for plugins that blend values the set does not hold. Returns 0 + * when the rotation turned past turn_radians (out = b). */ +int psx_motion_blend_matrix(const PSXMotionMatrix* a, const PSXMotionMatrix* b, + double t, double turn_radians, + PSXMotionMatrix* out); +void psx_motion_read_matrix(uint32_t addr, PSXMotionMatrix* out); +void psx_motion_write_matrix(uint32_t addr, const PSXMotionMatrix* m, + int rotation); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 5463e147..047ab000 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -353,6 +353,8 @@ set(PSXRECOMP_RUNTIME_SOURCES ${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/render_pass_motion.c + ${PSXRECOMP_ROOT}/runtime/src/render_pass_frame.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/render_pass_frame.c b/runtime/src/render_pass_frame.c new file mode 100644 index 00000000..9a4d8156 --- /dev/null +++ b/runtime/src/render_pass_frame.c @@ -0,0 +1,58 @@ +/* Per-frame driver and activation for render-pass frame-rate plugins: the + * parts every title repeats around psx_mod_render_pass_plan / _pass. */ +#include "mod_plugins.h" + +#include +#include + +#define FRAME_MAX_PHASES 16u + +static int s_crossfade; /* passes lastingly unavailable: presenter crossfades */ + +int psx_mod_activate_render_pass_rate(const char* package, const char* feature, + const char* option, uint32_t flip_mode) { + char value[16]; + unsigned long fps = 0; /* 0 follows the measured display refresh */ + if (package && feature && option && + psx_mod_option_value(package, feature, option, value, sizeof value) && + strcmp(value, "display") != 0) { + char* end = value; + const unsigned long parsed = strtoul(value, &end, 10); + if (end != value && *end == '\0') fps = parsed; + } + s_crossfade = 0; + if (!psx_mod_set_frame_interpolation_source(PSX_MOD_FRAME_SOURCE_FLIP) || + !psx_mod_set_render_pass_flip(flip_mode) || + !psx_mod_set_frame_interpolation_blend(PSX_MOD_FRAME_INTERPOLATION_HOLD)) + return 0; + return psx_mod_set_frame_interpolation((uint32_t)fps); +} + +uint32_t psx_mod_render_pass_frame(struct CPUState* cpu, + const PSXModRenderPassFrame* frame, + PSXModRenderPassFn fn, void* user) { + uint32_t phases[FRAME_MAX_PHASES], n, kept = 0; + const uint32_t status = psx_mod_render_pass_status(); + const int lasting = status == PSX_MOD_RENDER_PASS_BACKEND || + status == PSX_MOD_RENDER_PASS_DISABLED; + PSXModRenderPass pass; + if (lasting != s_crossfade) { + s_crossfade = lasting; + psx_mod_set_frame_interpolation_blend( + lasting ? PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE + : PSX_MOD_FRAME_INTERPOLATION_HOLD); + } + if (!cpu || !frame || !fn || frame->struct_size < sizeof *frame || + frame->period_vblanks == 0 || frame->w == 0 || frame->h == 0) + return 0; + n = psx_mod_render_pass_plan(frame->period_vblanks, frame->shown_after_vblanks, + phases, FRAME_MAX_PHASES); + memset(&pass, 0, sizeof pass); + pass.struct_size = sizeof pass; + pass.x = frame->x; pass.y = frame->y; pass.w = frame->w; pass.h = frame->h; + for (uint32_t i = 0; i < n; i++) { + pass.alpha_q16 = phases[i]; + if (psx_mod_render_pass(cpu, &pass, fn, user)) kept++; + } + return kept; +} diff --git a/runtime/src/render_pass_motion.c b/runtime/src/render_pass_motion.c new file mode 100644 index 00000000..89d9eeb1 --- /dev/null +++ b/runtime/src/render_pass_motion.c @@ -0,0 +1,447 @@ +/* Motion kit for render-pass plugins: capture, pair and blend the game state + * a pass redraws from. See include/render_pass_motion.h. */ +#include "render_pass_motion.h" +#include "mod_plugins.h" + +#include +#include +#include + +#define MOTION_MAX_TICKS 8u +#define MOTION_PI 3.14159265358979323846 + +typedef struct MotionEntry { + uint32_t addr, identity, seq; + uint8_t kind; + int32_t v[12]; /* MATRIX: r[0..8], t at [9..11]; others from v[0] */ +} MotionEntry; + +typedef struct MotionPair { uint32_t prev, cur; } MotionPair; + +struct PSXMotionSet { + uint32_t cap; + MotionEntry* cap_buf[2]; + uint32_t n[2]; + uint32_t tick[2]; + int began[2]; /* a capture was made into this buffer */ + int sorted[2]; + int cur; /* buffer the current capture writes */ + int history; /* the other buffer holds the capture just before */ + MotionPair* pairs; + uint32_t npairs; + uint32_t ticks; +}; + +uint32_t psx_motion_identity(uint32_t a, uint32_t b, uint32_t c) { + uint32_t h = 2166136261u; + const uint32_t w[3] = {a, b, c}; + for (int i = 0; i < 3; i++) + for (int k = 0; k < 4; k++) h = (h ^ ((w[i] >> (8 * k)) & 0xFFu)) * 16777619u; + return h; +} + +PSXMotionSet* psx_motion_set_create(uint32_t capacity) { + PSXMotionSet* s; + if (capacity == 0) return NULL; + s = (PSXMotionSet*)calloc(1, sizeof *s); + if (!s) return NULL; + s->cap = capacity; + s->cap_buf[0] = (MotionEntry*)calloc(capacity, sizeof(MotionEntry)); + s->cap_buf[1] = (MotionEntry*)calloc(capacity, sizeof(MotionEntry)); + s->pairs = (MotionPair*)calloc(capacity, sizeof(MotionPair)); + if (!s->cap_buf[0] || !s->cap_buf[1] || !s->pairs) { + psx_motion_set_destroy(s); + return NULL; + } + return s; +} + +void psx_motion_set_destroy(PSXMotionSet* s) { + if (!s) return; + free(s->cap_buf[0]); + free(s->cap_buf[1]); + free(s->pairs); + free(s); +} + +void psx_motion_invalidate(PSXMotionSet* s) { + if (!s) return; + s->history = 0; + s->began[0] = s->began[1] = 0; + s->n[0] = s->n[1] = 0; + s->npairs = 0; + s->ticks = 0; +} + +void psx_motion_begin(PSXMotionSet* s, uint32_t tick) { + if (!s) return; + s->history = s->began[s->cur]; + s->cur ^= 1; + s->n[s->cur] = 0; + s->tick[s->cur] = tick; + s->began[s->cur] = 1; + s->sorted[s->cur] = 0; + s->npairs = 0; + s->ticks = 0; +} + +/* ---- guest RAM --------------------------------------------------------- */ + +void psx_motion_read_matrix(uint32_t addr, PSXMotionMatrix* m) { + for (int i = 0; i < 9; i++) m->r[i] = (int16_t)psx_mod_read_half(addr + 2u * (uint32_t)i); + for (int i = 0; i < 3; i++) m->t[i] = (int32_t)psx_mod_read_word(addr + 0x14u + 4u * (uint32_t)i); +} + +void psx_motion_write_matrix(uint32_t addr, const PSXMotionMatrix* m, int rotation) { + if (rotation) + for (int i = 0; i < 9; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)m->r[i]); + for (int i = 0; i < 3; i++) psx_mod_write_word(addr + 0x14u + 4u * (uint32_t)i, (uint32_t)m->t[i]); +} + +static void read_entry(MotionEntry* e) { + switch (e->kind) { + case PSX_MOTION_MATRIX: + case PSX_MOTION_MATRIX_TRANSLATION: { + PSXMotionMatrix m; + psx_motion_read_matrix(e->addr, &m); + for (int i = 0; i < 9; i++) e->v[i] = m.r[i]; + for (int i = 0; i < 3; i++) e->v[9 + i] = m.t[i]; + break; + } + case PSX_MOTION_VECTOR: + for (int i = 0; i < 3; i++) e->v[i] = (int32_t)psx_mod_read_word(e->addr + 4u * (uint32_t)i); + break; + case PSX_MOTION_SVECTOR: + case PSX_MOTION_ANGLES: + for (int i = 0; i < 3; i++) e->v[i] = (int16_t)psx_mod_read_half(e->addr + 2u * (uint32_t)i); + break; + case PSX_MOTION_SCALAR: + e->v[0] = (int32_t)psx_mod_read_word(e->addr); + break; + } +} + +int psx_motion_track(PSXMotionSet* s, uint32_t kind, uint32_t addr, uint32_t identity) { + MotionEntry* e; + if (!s || kind < PSX_MOTION_MATRIX || kind > PSX_MOTION_SCALAR) return 0; + if (!s->began[s->cur] || s->n[s->cur] >= s->cap) return 0; + e = &s->cap_buf[s->cur][s->n[s->cur]]; + memset(e, 0, sizeof *e); + e->addr = addr; + e->kind = (uint8_t)kind; + e->identity = identity; + e->seq = s->n[s->cur]++; + s->sorted[s->cur] = 0; + read_entry(e); + return 1; +} + +/* ---- math ---------------------------------------------------------------- */ + +static int orthonormal(const int16_t* r) { + for (int i = 0; i < 3; i++) { + double len = 0; + for (int j = 0; j < 3; j++) len += (double)r[3 * i + j] * r[3 * i + j]; + if (fabs(sqrt(len) - 4096.0) > 4096.0 * 0.04) return 0; + for (int k = i + 1; k < 3; k++) { + double dot = 0; + for (int j = 0; j < 3; j++) dot += (double)r[3 * i + j] * r[3 * k + j]; + if (fabs(dot) > 4096.0 * 4096.0 * 0.04) return 0; + } + } + return 1; +} + +static void to_quat(const int16_t* r, double q[4]) { + double a[9], n; + for (int i = 0; i < 9; i++) a[i] = r[i] / 4096.0; + const double tr = a[0] + a[4] + a[8]; + if (tr > 0) { + double s = sqrt(tr + 1.0) * 2; + q[0] = 0.25 * s; q[1] = (a[7] - a[5]) / s; q[2] = (a[2] - a[6]) / s; q[3] = (a[3] - a[1]) / s; + } else if (a[0] > a[4] && a[0] > a[8]) { + double s = sqrt(1.0 + a[0] - a[4] - a[8]) * 2; + q[0] = (a[7] - a[5]) / s; q[1] = 0.25 * s; q[2] = (a[1] + a[3]) / s; q[3] = (a[2] + a[6]) / s; + } else if (a[4] > a[8]) { + double s = sqrt(1.0 + a[4] - a[0] - a[8]) * 2; + q[0] = (a[2] - a[6]) / s; q[1] = (a[1] + a[3]) / s; q[2] = 0.25 * s; q[3] = (a[5] + a[7]) / s; + } else { + double s = sqrt(1.0 + a[8] - a[0] - a[4]) * 2; + q[0] = (a[3] - a[1]) / s; q[1] = (a[2] + a[6]) / s; q[2] = (a[5] + a[7]) / s; q[3] = 0.25 * s; + } + n = sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]); + for (int i = 0; i < 4; i++) q[i] /= n; +} + +static int16_t fixed12(double v) { + double x = v * 4096.0; + if (x > 32767.0) x = 32767.0; + if (x < -32768.0) x = -32768.0; + return (int16_t)lround(x); +} + +static double turn_limit(double turn) { return turn > 0 ? turn : MOTION_PI; } + +/* Angle between two orientations, or -1 when either is not a rotation. */ +static double rotation_angle(const int16_t* a, const int16_t* b) { + double p[4], q[4], d; + if (!orthonormal(a) || !orthonormal(b)) return -1; + to_quat(a, p); + to_quat(b, q); + d = fabs(p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]); + if (d > 1.0) d = 1.0; + return 2.0 * acos(d); +} + +static int blend_rotation(const int16_t* a, const int16_t* b, double t, double turn, int16_t* out) { + if (orthonormal(a) && orthonormal(b)) { + double p[4], q[4], d, angle, wa, wb, r[4]; + to_quat(a, p); + to_quat(b, q); + d = p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]; + if (d < 0) { for (int i = 0; i < 4; i++) q[i] = -q[i]; d = -d; } + if (d > 1.0) d = 1.0; + angle = acos(d); + if (2.0 * angle > turn_limit(turn)) { memcpy(out, b, 9 * sizeof *out); return 0; } + wa = 1 - t; wb = t; + if (angle > 1e-6) { wa = sin((1 - t) * angle) / sin(angle); wb = sin(t * angle) / sin(angle); } + for (int i = 0; i < 4; i++) r[i] = wa * p[i] + wb * q[i]; + { + const double w = r[0], x = r[1], y = r[2], z = r[3]; + const double m[9] = {1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), + 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), + 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)}; + for (int i = 0; i < 9; i++) out[i] = fixed12(m[i]); + } + return 1; + } + /* Scaled or sheared: elementwise, refusing jumps no motion produces. */ + for (int i = 0; i < 9; i++) + if (abs((int)a[i] - (int)b[i]) > 4096) { memcpy(out, b, 9 * sizeof *out); return 0; } + for (int i = 0; i < 9; i++) out[i] = (int16_t)lround(a[i] + (b[i] - a[i]) * t); + return 1; +} + +int psx_motion_blend_matrix(const PSXMotionMatrix* a, const PSXMotionMatrix* b, + double t, double turn, PSXMotionMatrix* out) { + const int ok = blend_rotation(a->r, b->r, t, turn, out->r); + for (int i = 0; i < 3; i++) + out->t[i] = ok ? (int32_t)llround(a->t[i] + ((double)b->t[i] - a->t[i]) * t) : b->t[i]; + return ok; +} + +static double dist3(const int32_t* a, const int32_t* b) { + double s = 0; + for (int i = 0; i < 3; i++) { const double d = (double)b[i] - a[i]; s += d * d; } + return sqrt(s); +} + +static int32_t arc12(int32_t from, int32_t to) { /* shortest signed delta */ + int32_t d = (to - from) & 4095; + return d > 2048 ? d - 4096 : d; /* an exact half turn goes forward */ +} + +/* ---- pairing ------------------------------------------------------------- */ + +static int entry_cmp(const void* x, const void* y) { + const MotionEntry* a = (const MotionEntry*)x; + const MotionEntry* b = (const MotionEntry*)y; + if (a->addr != b->addr) return a->addr < b->addr ? -1 : 1; + if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1; + return a->seq < b->seq ? -1 : a->seq > b->seq; +} + +/* Sort by (addr, kind); of repeats the last tracked wins. */ +static void sort_capture(PSXMotionSet* s, int b) { + MotionEntry* e = s->cap_buf[b]; + uint32_t n = s->n[b], w = 0; + if (s->sorted[b]) return; + qsort(e, n, sizeof *e, entry_cmp); + for (uint32_t i = 0; i < n; i++) { + if (w && e[w - 1].addr == e[i].addr && e[w - 1].kind == e[i].kind) e[w - 1] = e[i]; + else e[w++] = e[i]; + } + s->n[b] = w; + s->sorted[b] = 1; +} + +/* 1 when the pair moved within the limits over `ticks`. */ +static int within_limits(const MotionEntry* a, const MotionEntry* b, + const PSXMotionLimits* lim, uint32_t ticks) { + const double move = lim->move_per_tick > 0 ? lim->move_per_tick * ticks : HUGE_VAL; + switch (a->kind) { + case PSX_MOTION_MATRIX: { + double ang; + int16_t ra[9], rb[9]; + if (dist3(&a->v[9], &b->v[9]) > move) return 0; + for (int i = 0; i < 9; i++) { ra[i] = (int16_t)a->v[i]; rb[i] = (int16_t)b->v[i]; } + ang = rotation_angle(ra, rb); + if (ang >= 0) return ang <= turn_limit(lim->turn_radians); + for (int i = 0; i < 9; i++) if (abs(a->v[i] - b->v[i]) > 4096) return 0; + return 1; + } + case PSX_MOTION_MATRIX_TRANSLATION: + return dist3(&a->v[9], &b->v[9]) <= move; + case PSX_MOTION_VECTOR: + case PSX_MOTION_SVECTOR: + return dist3(a->v, b->v) <= move; + case PSX_MOTION_ANGLES: { + const double lim12 = turn_limit(lim->turn_radians) * 4096.0 / (2.0 * MOTION_PI); + for (int i = 0; i < 3; i++) if (fabs((double)arc12(a->v[i], b->v[i])) > lim12) return 0; + return 1; + } + case PSX_MOTION_SCALAR: + return fabs((double)b->v[0] - a->v[0]) <= move; + } + return 0; +} + +uint32_t psx_motion_prepare(PSXMotionSet* s, const PSXMotionLimits* lim, + PSXMotionStats* stats) { + PSXMotionStats st; + const PSXMotionLimits none = {0, 0}; + const int c = s ? s->cur : 0, h = c ^ 1; + memset(&st, 0, sizeof st); + if (!lim) lim = &none; + if (!s || !s->began[c]) { if (stats) *stats = st; return 0; } + sort_capture(s, c); + st.tracked = s->n[c]; + s->npairs = 0; + s->ticks = 0; + if (s->history && s->began[h]) { + const uint32_t ticks = s->tick[c] - s->tick[h]; + if (ticks >= 1 && ticks <= MOTION_MAX_TICKS) s->ticks = ticks; + } + if (!s->ticks) { + st.unmatched = st.tracked; + } else { + const MotionEntry* pe = s->cap_buf[h]; + const MotionEntry* ce = s->cap_buf[c]; + uint32_t i = 0; + sort_capture(s, h); + for (uint32_t j = 0; j < s->n[c]; j++) { + while (i < s->n[h] && (pe[i].addr < ce[j].addr || + (pe[i].addr == ce[j].addr && pe[i].kind < ce[j].kind))) + i++; + if (i >= s->n[h] || pe[i].addr != ce[j].addr || pe[i].kind != ce[j].kind || + pe[i].identity != ce[j].identity) { + st.unmatched++; + } else if (!within_limits(&pe[i], &ce[j], lim, s->ticks)) { + st.placed++; + } else { + s->pairs[s->npairs].prev = i; + s->pairs[s->npairs].cur = j; + s->npairs++; + } + } + } + st.blended = s->npairs; + st.ticks = s->ticks; + if (stats) *stats = st; + return s->npairs; +} + +/* Blend one pair into `v` (entry layout). */ +static void blend_entry(const MotionEntry* a, const MotionEntry* b, double t, int32_t* v) { + switch (a->kind) { + case PSX_MOTION_MATRIX: { + int16_t ra[9], rb[9], ro[9]; + for (int i = 0; i < 9; i++) { ra[i] = (int16_t)a->v[i]; rb[i] = (int16_t)b->v[i]; } + blend_rotation(ra, rb, t, MOTION_PI * 2, ro); /* limits were applied at prepare */ + for (int i = 0; i < 9; i++) v[i] = ro[i]; + for (int i = 9; i < 12; i++) v[i] = (int32_t)llround(a->v[i] + ((double)b->v[i] - a->v[i]) * t); + break; + } + case PSX_MOTION_MATRIX_TRANSLATION: + for (int i = 0; i < 9; i++) v[i] = b->v[i]; + for (int i = 9; i < 12; i++) v[i] = (int32_t)llround(a->v[i] + ((double)b->v[i] - a->v[i]) * t); + break; + case PSX_MOTION_ANGLES: + for (int i = 0; i < 3; i++) + v[i] = (int32_t)lround(a->v[i] + arc12(a->v[i], b->v[i]) * t); + break; + default: + for (int i = 0; i < 3; i++) v[i] = (int32_t)llround(a->v[i] + ((double)b->v[i] - a->v[i]) * t); + break; + } +} + +static void write_entry(uint8_t kind, uint32_t addr, const int32_t* v) { + switch (kind) { + case PSX_MOTION_MATRIX: + case PSX_MOTION_MATRIX_TRANSLATION: { + PSXMotionMatrix m; + for (int i = 0; i < 9; i++) m.r[i] = (int16_t)v[i]; + for (int i = 0; i < 3; i++) m.t[i] = v[9 + i]; + psx_motion_write_matrix(addr, &m, kind == PSX_MOTION_MATRIX); + break; + } + case PSX_MOTION_VECTOR: + for (int i = 0; i < 3; i++) psx_mod_write_word(addr + 4u * (uint32_t)i, (uint32_t)v[i]); + break; + case PSX_MOTION_SVECTOR: + for (int i = 0; i < 3; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)v[i]); + break; + case PSX_MOTION_ANGLES: + for (int i = 0; i < 3; i++) psx_mod_write_half(addr + 2u * (uint32_t)i, (uint16_t)(v[i] & 4095)); + break; + case PSX_MOTION_SCALAR: + psx_mod_write_word(addr, (uint32_t)v[0]); + break; + } +} + +void psx_motion_apply(const PSXMotionSet* s, double t) { + if (!s || !s->npairs) return; + const MotionEntry* pe = s->cap_buf[s->cur ^ 1]; + const MotionEntry* ce = s->cap_buf[s->cur]; + for (uint32_t k = 0; k < s->npairs; k++) { + int32_t v[12]; + const MotionEntry* a = &pe[s->pairs[k].prev]; + const MotionEntry* b = &ce[s->pairs[k].cur]; + blend_entry(a, b, t, v); + write_entry(b->kind, b->addr, v); + } +} + +static void export_value(uint8_t kind, const int32_t* v, void* out) { + if (kind == PSX_MOTION_MATRIX || kind == PSX_MOTION_MATRIX_TRANSLATION) { + PSXMotionMatrix* m = (PSXMotionMatrix*)out; + for (int i = 0; i < 9; i++) m->r[i] = (int16_t)v[i]; + for (int i = 0; i < 3; i++) m->t[i] = v[9 + i]; + } else if (kind == PSX_MOTION_SCALAR) { + *(int32_t*)out = v[0]; + } else { + memcpy(out, v, 3 * sizeof(int32_t)); + } +} + +int psx_motion_blend_at(const PSXMotionSet* s, uint32_t kind, uint32_t addr, + double t, void* out) { + const MotionEntry* ce; + uint32_t lo = 0, hi; + if (!s || !out || !s->began[s->cur]) return -1; + ce = s->cap_buf[s->cur]; + hi = s->n[s->cur]; + if (!s->sorted[s->cur]) { + for (uint32_t j = hi; j-- > 0;) /* unprepared: newest wins */ + if (ce[j].addr == addr && ce[j].kind == kind) { export_value(ce[j].kind, ce[j].v, out); return 0; } + return -1; + } + while (lo < hi) { + const uint32_t mid = (lo + hi) / 2; + if (ce[mid].addr < addr || (ce[mid].addr == addr && ce[mid].kind < kind)) lo = mid + 1; + else hi = mid; + } + if (lo >= s->n[s->cur] || ce[lo].addr != addr || ce[lo].kind != kind) return -1; + for (uint32_t k = 0; k < s->npairs; k++) { + if (s->pairs[k].cur == lo) { + int32_t v[12]; + blend_entry(&s->cap_buf[s->cur ^ 1][s->pairs[k].prev], &ce[lo], t, v); + export_value(ce[lo].kind, v, out); + return 1; + } + } + export_value(ce[lo].kind, ce[lo].v, out); + return 0; +} diff --git a/runtime/tests/test_render_pass_motion.c b/runtime/tests/test_render_pass_motion.c new file mode 100644 index 00000000..6098ec78 --- /dev/null +++ b/runtime/tests/test_render_pass_motion.c @@ -0,0 +1,215 @@ +/* render_pass_motion.c and render_pass_frame.c against a mock guest RAM and + * mock render-pass / mod APIs. */ +#include "mod_plugins.h" +#include "render_pass_motion.h" + +#include +#include +#include + +static int failures; +#define CHECK(cond, msg) do { if (!(cond)) { failures++; printf("FAIL: %s\n", msg); } } while (0) + +/* ---- mock guest RAM ------------------------------------------------------ */ +static uint8_t s_ram[0x10000]; +static uint32_t off(uint32_t a) { return a & 0xFFFFu; } +uint8_t psx_mod_read_byte(uint32_t a) { return s_ram[off(a)]; } +uint16_t psx_mod_read_half(uint32_t a) { uint16_t v; memcpy(&v, s_ram + off(a), 2); return v; } +uint32_t psx_mod_read_word(uint32_t a) { uint32_t v; memcpy(&v, s_ram + off(a), 4); return v; } +void psx_mod_write_byte(uint32_t a, uint8_t v) { s_ram[off(a)] = v; } +void psx_mod_write_half(uint32_t a, uint16_t v) { memcpy(s_ram + off(a), &v, 2); } +void psx_mod_write_word(uint32_t a, uint32_t v) { memcpy(s_ram + off(a), &v, 4); } + +/* ---- mock mod / pass APIs (render_pass_frame.c) --------------------------- */ +static char s_option[16] = "display"; +static uint32_t s_fps = 99, s_source = 99, s_blend = 99, s_flip = 99, s_blend_calls; +static uint32_t s_status, s_phases, s_period, s_shown, s_passes_run; +int psx_mod_option_value(const char* p, const char* f, const char* o, char* out, uint32_t n) { + (void)p; (void)f; (void)o; + snprintf(out, n, "%s", s_option); + return 1; +} +int psx_mod_set_frame_interpolation(uint32_t fps) { s_fps = fps; return 1; } +int psx_mod_set_frame_interpolation_source(uint32_t s) { s_source = s; return 1; } +int psx_mod_set_frame_interpolation_blend(uint32_t b) { s_blend = b; s_blend_calls++; return 1; } +int psx_mod_set_render_pass_flip(uint32_t m) { s_flip = m; return 1; } +uint32_t psx_mod_render_pass_status(void) { return s_status; } +uint32_t psx_mod_render_pass_plan(uint32_t period, uint32_t shown, uint32_t* a, uint32_t max) { + s_period = period; s_shown = shown; + if (s_status != PSX_MOD_RENDER_PASS_READY) return 0; + for (uint32_t i = 0; i < s_phases && i < max; i++) a[i] = (i + 1) * 65536u / (s_phases + 1); + return s_phases < max ? s_phases : max; +} +int psx_mod_render_pass(struct CPUState* cpu, const PSXModRenderPass* p, + PSXModRenderPassFn fn, void* user) { + s_passes_run++; + return fn(cpu, user, p->alpha_q16); +} + +/* ---- helpers --------------------------------------------------------------- */ +static void put_matrix(uint32_t a, double yaw, int32_t x, int32_t y, int32_t z) { + PSXMotionMatrix m; + const double c = cos(yaw), s = sin(yaw); + const double r[9] = {c, 0, s, 0, 1, 0, -s, 0, c}; + for (int i = 0; i < 9; i++) m.r[i] = (int16_t)lround(r[i] * 4096.0); + m.t[0] = x; m.t[1] = y; m.t[2] = z; + psx_motion_write_matrix(a, &m, 1); +} +static double yaw_of(uint32_t a) { + PSXMotionMatrix m; + psx_motion_read_matrix(a, &m); + return atan2(m.r[2] / 4096.0, m.r[0] / 4096.0); +} + +static void test_matrix_slerp(void) { + PSXMotionSet* s = psx_motion_set_create(16); + PSXMotionLimits lim = {1000.0, 0}; + PSXMotionStats st; + PSXMotionMatrix m; + put_matrix(0x100, 0.0, 0, 0, 0); + psx_motion_begin(s, 10); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x100, 7); + CHECK(psx_motion_prepare(s, &lim, &st) == 0 && st.ticks == 0, "first frame has no history"); + put_matrix(0x100, 1.2, 800, 0, -400); + psx_motion_begin(s, 12); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x100, 7); + CHECK(psx_motion_prepare(s, &lim, &st) == 1 && st.ticks == 2 && st.blended == 1, + "the same object two ticks later blends"); + psx_motion_apply(s, 0.5); + psx_motion_read_matrix(0x100, &m); + CHECK(fabs(yaw_of(0x100) - 0.6) < 0.002, "rotation slerps to the half-way angle"); + CHECK(m.t[0] == 400 && m.t[2] == -200, "translation blends linearly"); + { + double len = 0; + for (int j = 0; j < 3; j++) len += (double)m.r[j] * m.r[j]; + CHECK(fabs(sqrt(len) - 4096.0) < 3.0, "the blended rotation stays a rotation"); + } + CHECK(psx_motion_blend_at(s, PSX_MOTION_MATRIX, 0x100, 1.0, &m) == 1 && + m.t[0] == 800, "blend_at at t=1 is the current value"); + psx_motion_set_destroy(s); +} + +static void test_identity_and_limits(void) { + PSXMotionSet* s = psx_motion_set_create(16); + PSXMotionLimits lim = {1000.0, 1.0}; + PSXMotionStats st; + int32_t v[3]; + put_matrix(0x200, 0.0, 0, 0, 0); /* moves within limits */ + put_matrix(0x300, 0.0, 0, 0, 0); /* reused by another object */ + put_matrix(0x400, 0.0, 0, 0, 0); /* teleports */ + put_matrix(0x500, 0.0, 0, 0, 0); /* spins past the turn limit */ + psx_motion_begin(s, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x200, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x300, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x400, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x500, 1); + psx_motion_prepare(s, &lim, &st); + put_matrix(0x200, 0.0, 1500, 0, 0); + put_matrix(0x300, 0.0, 10, 0, 0); + put_matrix(0x400, 0.0, 5000, 0, 0); + put_matrix(0x500, 2.0, 0, 0, 0); + psx_motion_begin(s, 3); /* two ticks: 2000 units allowed */ + psx_motion_track(s, PSX_MOTION_MATRIX, 0x200, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x300, 2); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x400, 1); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x500, 1); + psx_motion_track(s, PSX_MOTION_VECTOR, 0x600, 1); /* new this frame */ + CHECK(psx_motion_prepare(s, &lim, &st) == 1, "only the moving object blends"); + CHECK(st.placed == 2 && st.unmatched == 2 && st.tracked == 5, + "teleport and spin are placed; reuse and new are unmatched"); + psx_motion_apply(s, 0.5); + CHECK((int32_t)psx_mod_read_word(0x300 + 0x14) == 10, "a reused address keeps its new object"); + CHECK((int32_t)psx_mod_read_word(0x400 + 0x14) == 5000, "a teleport is drawn where it landed"); + CHECK((int32_t)psx_mod_read_word(0x200 + 0x14) == 750, "the mover is half-way"); + CHECK(psx_motion_blend_at(s, PSX_MOTION_VECTOR, 0x600, 0.5, v) == 0, "no history: current value"); + CHECK(psx_motion_blend_at(s, PSX_MOTION_VECTOR, 0x700, 0.5, v) == -1, "untracked: refused"); + + /* A gap (a frame not captured) or too many ticks: nothing blends. */ + psx_motion_begin(s, 20); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x200, 1); + CHECK(psx_motion_prepare(s, &lim, &st) == 0 && st.ticks == 0, "17 ticks apart is not one motion"); + psx_motion_invalidate(s); + psx_motion_begin(s, 21); + psx_motion_track(s, PSX_MOTION_MATRIX, 0x200, 1); + CHECK(psx_motion_prepare(s, &lim, &st) == 0, "after invalidate there is no history"); + psx_motion_set_destroy(s); +} + +static void test_angles_vectors_scalars(void) { + PSXMotionSet* s = psx_motion_set_create(16); + PSXMotionLimits lim = {100.0, 0}; + PSXMotionStats st; + psx_mod_write_half(0x800, 4000); psx_mod_write_half(0x802, 100); psx_mod_write_half(0x804, 0); + psx_mod_write_half(0x810, (uint16_t)-50); psx_mod_write_half(0x812, 0); psx_mod_write_half(0x814, 0); + psx_mod_write_word(0x820, 1000); + psx_motion_begin(s, 5); + psx_motion_track(s, PSX_MOTION_ANGLES, 0x800, 1); + psx_motion_track(s, PSX_MOTION_SVECTOR, 0x810, 1); + psx_motion_track(s, PSX_MOTION_SCALAR, 0x820, 1); + psx_motion_prepare(s, &lim, &st); + psx_mod_write_half(0x800, 96); psx_mod_write_half(0x802, 300); psx_mod_write_half(0x804, 2048); + psx_mod_write_half(0x810, 50); + psx_mod_write_word(0x820, 1100); + psx_motion_begin(s, 6); + psx_motion_track(s, PSX_MOTION_ANGLES, 0x800, 1); + psx_motion_track(s, PSX_MOTION_SVECTOR, 0x810, 1); + psx_motion_track(s, PSX_MOTION_SCALAR, 0x820, 1); + CHECK(psx_motion_prepare(s, &lim, &st) == 3, "angles, svector and scalar blend"); + psx_motion_apply(s, 0.5); + CHECK(psx_mod_read_half(0x800) == 0, "4000 -> 96 crosses 4096 the short way (0)"); + CHECK(psx_mod_read_half(0x802) == 200, "100 -> 300 is 200"); + CHECK(psx_mod_read_half(0x804) == 1024, "0 -> 2048 half turn blends to 1024"); + CHECK((int16_t)psx_mod_read_half(0x810) == 0, "signed svector -50 -> 50 is 0"); + CHECK(psx_mod_read_word(0x820) == 1050, "scalar blends"); + psx_motion_set_destroy(s); +} + +static int s_fn_calls; +static double s_fn_last; +static int frame_fn(struct CPUState* cpu, void* user, uint32_t alpha) { + (void)cpu; (void)user; + s_fn_calls++; + s_fn_last = alpha / 65536.0; + return 1; +} + +static void test_frame_driver(void) { + PSXModRenderPassFrame f; + char cpu_storage[4096]; + struct CPUState* cpu = (struct CPUState*)cpu_storage; + strcpy(s_option, "165"); + CHECK(psx_mod_activate_render_pass_rate("p", "f", "rate", PSX_MOD_RENDER_PASS_FLIP_PENDING) && + s_fps == 165 && s_source == PSX_MOD_FRAME_SOURCE_FLIP && + s_blend == PSX_MOD_FRAME_INTERPOLATION_HOLD && + s_flip == PSX_MOD_RENDER_PASS_FLIP_PENDING, "activation selects rate, FLIP, HOLD, flip mode"); + strcpy(s_option, "display"); + psx_mod_activate_render_pass_rate("p", "f", "rate", PSX_MOD_RENDER_PASS_FLIP_SHOWN); + CHECK(s_fps == 0 && s_flip == PSX_MOD_RENDER_PASS_FLIP_SHOWN, "display refresh is rate 0"); + + memset(&f, 0, sizeof f); + f.struct_size = sizeof f; + f.period_vblanks = 2; f.w = 320; f.h = 240; + s_status = PSX_MOD_RENDER_PASS_READY; + s_phases = 3; + s_blend_calls = 0; + CHECK(psx_mod_render_pass_frame(cpu, &f, frame_fn, NULL) == 3 && s_fn_calls == 3 && + s_period == 2 && s_fn_last > 0.7, "every planned phase runs"); + CHECK(s_blend_calls == 0, "READY keeps HOLD without re-setting it"); + s_status = PSX_MOD_RENDER_PASS_BACKEND; + CHECK(psx_mod_render_pass_frame(cpu, &f, frame_fn, NULL) == 0 && + s_blend == PSX_MOD_FRAME_INTERPOLATION_MOTION_ADAPTIVE, "lasting refusal crossfades"); + s_status = PSX_MOD_RENDER_PASS_SESSION; + psx_mod_render_pass_frame(cpu, &f, frame_fn, NULL); + CHECK(s_blend == PSX_MOD_FRAME_INTERPOLATION_HOLD, "a transient reason returns to HOLD"); + f.struct_size = 4; + CHECK(psx_mod_render_pass_frame(cpu, &f, frame_fn, NULL) == 0, "a short struct is refused"); +} + +int main(void) { + test_matrix_slerp(); + test_identity_and_limits(); + test_angles_vectors_scalars(); + test_frame_driver(); + printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); + return failures ? 1 : 0; +}