From a74fd9f7f88b5b854d56dbcc9a68cd2dd7a8292b Mon Sep 17 00:00:00 2001 From: Pegasus Date: Sat, 22 Aug 2026 16:31:54 +0300 Subject: [PATCH 1/4] runtime: schedule SPU IRQ samples by guest clock (cherry picked from commit 1a973806ca0af75355a62bbc59165ceff8ce294a) --- recompiler/CMakeLists.txt | 1 + runtime/include/interrupts.h | 6 ++ runtime/src/interrupts.c | 81 +++++++++++++++++++ runtime/src/psx_cycles.c | 2 + .../test_spu_sample_scheduler_default.py | 26 ++++++ 5 files changed, 116 insertions(+) create mode 100644 runtime/tests/test_spu_sample_scheduler_default.py diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 1796ea88..8527f226 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -571,6 +571,7 @@ if(BUILD_TESTING) overlay_decodable_fallback overlay_init_guard sdl3_main_single_include + spu_sample_scheduler_default vk_build_default vk_color_self_barrier vk_command_buffer_batching diff --git a/runtime/include/interrupts.h b/runtime/include/interrupts.h index d74e2cc5..5be1f54e 100644 --- a/runtime/include/interrupts.h +++ b/runtime/include/interrupts.h @@ -80,6 +80,12 @@ void psx_check_interrupts_dispatch_entry(struct CPUState* cpu, uint32_t resume_p void interrupts_advance_cycles(uint32_t cycles); void interrupts_service_scheduled_events(void); uint32_t interrupts_cycles_to_vblank(void); +/* While IRQ9 is enabled, expose the next 44.1-kHz sample as a first-class + * device deadline so the CPU can observe and acknowledge an IRQ before the + * following sample. UINT32_MAX means inactive. PSX_SPU_SAMPLE_EVENTS=0 is a + * diagnostic opt-out. */ +uint32_t psx_spu_sample_event_cycles_to_next(void); +void psx_spu_sample_event_service(void); /* VBlank phase within the current frame (0 .. VBLANK_CYCLES-1). Persisted in * BS_SEC_IRQ (and selfcheck's out-of-band latch) so resim keeps the snap's * phase. Legacy 8-byte IRQ sections still rebase to 0 on load. */ diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index d84a781a..7279c045 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -37,6 +37,7 @@ #include "lockstep.h" #include "psx_cycles.h" #include "psx_scheduler.h" +#include "spu.h" #include #include #include @@ -384,8 +385,88 @@ static int should_defer_vblank_for_sio(void) { * From PR #102 by Alexandros Mandravillis. */ static void (*s_midframe_audio_pump)(void); +/* First-divergence telemetry for the per-sample SPU scheduler. */ +uint64_t g_spu_sample_deadline_queries; +uint64_t g_spu_sample_service_checks; +uint64_t g_spu_sample_service_pumps; +uint64_t g_spu_sample_raw_boundaries; +uint64_t g_spu_sample_deferred_mismatches; +uint64_t g_spu_sample_enabled_queries; +uint64_t g_spu_sample_enabled_services; +uint32_t g_spu_sample_last_query_phase; +uint32_t g_spu_sample_last_query_delta; +uint32_t g_spu_sample_last_service_phase; +uint64_t g_spu_sample_mode_rejects; +uint64_t g_spu_sample_pump_null_rejects; +uint64_t g_spu_sample_ctrl_rejects; + void psx_set_midframe_audio_pump(void (*fn)(void)) { s_midframe_audio_pump = fn; } +/* While SPU IRQ9 is enabled, expose each 44.1-kHz sample as a device deadline + * so guest code can acknowledge and re-arm an IRQ-address hit before the next + * sample. Rendering a whole VBlank's accumulated samples as one chunk collapses + * multiple hardware IRQ edges into one latch, slowing IRQ-driven audio engines + * and blocking cutscene synchronization. Keep an explicit opt-out for bisecting + * old captures; faithful per-sample scheduling is the production default. */ +static int spu_sample_event_mode(void) { + static int enabled = -1; + if (enabled < 0) { + const char *e = getenv("PSX_SPU_SAMPLE_EVENTS"); + enabled = (!e || !*e || strcmp(e, "0") != 0) ? 1 : 0; + } + return enabled; +} + +uint32_t psx_spu_sample_event_cycles_to_next(void) { + SpuGlobalState state; + g_spu_sample_deadline_queries++; + if (!spu_sample_event_mode()) { + g_spu_sample_mode_rejects++; + return UINT32_MAX; + } + if (!s_midframe_audio_pump) { + g_spu_sample_pump_null_rejects++; + return UINT32_MAX; + } + spu_get_global_state(&state); + if ((state.ctrl & 0x0040u) == 0) { + g_spu_sample_ctrl_rejects++; + return UINT32_MAX; + } + g_spu_sample_enabled_queries++; + + /* The PS1 CPU/SPU ratio is exactly 768 CPU cycles per 44.1-kHz sample. + * Cycle zero is the common hardware epoch; a value exactly on a sample + * boundary names the following event, not an already-serviced event. */ + const uint32_t phase = (uint32_t)(psx_get_cycle_count() % 768u); + const uint32_t delta = phase ? (768u - phase) : 768u; + g_spu_sample_last_query_phase = phase; + g_spu_sample_last_query_delta = delta; + return delta; +} + +void psx_spu_sample_event_service(void) { + g_spu_sample_service_checks++; + if (!spu_sample_event_mode() || !s_midframe_audio_pump) + return; + SpuGlobalState state; + spu_get_global_state(&state); + if ((state.ctrl & 0x0040u) != 0) { + g_spu_sample_enabled_services++; + g_spu_sample_last_service_phase = (uint32_t)(psx_cycle_count % 768u); + } + if ((psx_cycle_count % 768u) == 0) { + g_spu_sample_raw_boundaries++; + if ((psx_get_cycle_count() % 768u) != 0) + g_spu_sample_deferred_mismatches++; + } + if ((state.ctrl & 0x0040u) != 0 && + (psx_get_cycle_count() % 768u) == 0) { + g_spu_sample_service_pumps++; + s_midframe_audio_pump(); + } +} + static void fire_vblank_edge(void) { /* Subtract one VBlank period rather than reset to 0 so cycle overshoot * carries forward. Prevents long-running blocks from rounding multiple diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index a00e6ef3..c806eec7 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -80,6 +80,7 @@ static void advance_devices(uint32_t c) { dma_advance(c); timers_advance(c); interrupts_advance_cycles(c); + psx_spu_sample_event_service(); } /* ===== Event-deadline device servicing (production fast path) ================= @@ -139,6 +140,7 @@ static uint32_t devices_cycles_to_next_internal_event(void) { uint32_t c = cdrom_cycles_to_irq(0xFFFFFFFFu); if (c < best) best = c; uint32_t d = dma_cycles_to_internal_event(); if (d < best) best = d; uint32_t s = sio_cycles_to_irq(0xFFFFFFFFu); if (s < best) best = s; + uint32_t a = psx_spu_sample_event_cycles_to_next(); if (a < best) best = a; if (best == 0) best = 1; /* due/overdue: process within one cycle */ return best; } diff --git a/runtime/tests/test_spu_sample_scheduler_default.py b/runtime/tests/test_spu_sample_scheduler_default.py new file mode 100644 index 00000000..18928316 --- /dev/null +++ b/runtime/tests/test_spu_sample_scheduler_default.py @@ -0,0 +1,26 @@ +"""Guard the title-neutral SPU IRQ scheduling correction. + +SPU RAM IRQ-address hits can occur more than once per video frame. The runtime +must let guest code acknowledge and re-arm IRQ9 between 44.1-kHz samples rather +than rendering a VBlank-sized chunk and collapsing the hits into one latch. +""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +INTERRUPTS = (ROOT / "runtime" / "src" / "interrupts.c").read_text(encoding="utf-8") +CYCLES = (ROOT / "runtime" / "src" / "psx_cycles.c").read_text(encoding="utf-8") + +# Production defaults to faithful per-sample scheduling. Keep only an explicit +# zero-valued diagnostic opt-out for comparing old captures. +assert 'getenv("PSX_SPU_SAMPLE_EVENTS")' in INTERRUPTS +assert 'enabled = (!e || !*e || strcmp(e, "0") != 0) ? 1 : 0;' in INTERRUPTS + +# The sample boundary must participate in the common device-deadline slicer and +# be serviced after each charged slice, otherwise the default above is inert. +assert "psx_spu_sample_event_cycles_to_next()" in CYCLES +assert "psx_spu_sample_event_service();" in CYCLES + +# One hardware SPU output sample is exactly 768 CPU cycles. +assert "psx_get_cycle_count() % 768u" in INTERRUPTS From 6262888ec65f9dcecb62116ab704eb5b48f766d8 Mon Sep 17 00:00:00 2001 From: Pegasus Date: Sat, 22 Aug 2026 19:02:37 +0300 Subject: [PATCH 2/4] audio: prime SPU scheduler before first sample deadline (cherry picked from commit 54c26abcd4c97b726ceb9adcfa5608bc4080746a) --- runtime/src/main.cpp | 17 +++++++++++++++++ .../tests/test_spu_sample_scheduler_default.py | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 9406c8e7..4ac3da69 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -12824,6 +12824,19 @@ int main(int argc, char** argv) { if (game_entry_pc != 0) fntrace_set_game_range(game_entry_pc, 0); + /* Headless still runs the SPU as a guest-cycle device. Register and prime + * the pump before the frontend split so deterministic gates exercise the + * same sample-deadline path as an operator run. Without the cycle-zero + * prime, the first deadline service establishes a shifted epoch and the + * headless smoke cannot detect the resulting audio/cutscene regression. */ +#ifndef PSX_SDL_NO_AUDIO + if (g_headless) { + audio_trace_init(); + psx_set_midframe_audio_pump(sdl_audio_pump_midframe); + sdl_audio_pump_midframe(); + } +#endif + if (g_headless) { std::fprintf(stdout, "psxrecomp: headless frontend enabled\n"); } else { @@ -12919,6 +12932,10 @@ int main(int argc, char** argv) { * depend on a successful host open (Win↔Linux aux/spu fork). Routed * through the gated wrapper so turbo mute/sink still apply. */ psx_set_midframe_audio_pump(sdl_audio_pump_midframe); + /* Establish the cycle-zero audio epoch before the first device deadline. + * Otherwise the first D-1 quiet-prefix service becomes the epoch and shifts + * every nominal 768-cycle sample boundary. */ + sdl_audio_pump_midframe(); #endif Uint32 win_flags = SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE; diff --git a/runtime/tests/test_spu_sample_scheduler_default.py b/runtime/tests/test_spu_sample_scheduler_default.py index 18928316..90bcd6ff 100644 --- a/runtime/tests/test_spu_sample_scheduler_default.py +++ b/runtime/tests/test_spu_sample_scheduler_default.py @@ -6,11 +6,13 @@ """ from pathlib import Path +import re ROOT = Path(__file__).resolve().parents[2] INTERRUPTS = (ROOT / "runtime" / "src" / "interrupts.c").read_text(encoding="utf-8") CYCLES = (ROOT / "runtime" / "src" / "psx_cycles.c").read_text(encoding="utf-8") +MAIN = (ROOT / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8") # Production defaults to faithful per-sample scheduling. Keep only an explicit # zero-valued diagnostic opt-out for comparing old captures. @@ -22,5 +24,18 @@ assert "psx_spu_sample_event_cycles_to_next()" in CYCLES assert "psx_spu_sample_event_service();" in CYCLES +# Register and prime the SPU pump in both the visible and headless frontends. +# A frame-count-only headless smoke must exercise the same sample epoch as the +# operator build, and the first service must not establish a shifted epoch. +prime = re.compile( + r"psx_set_midframe_audio_pump\(sdl_audio_pump_midframe\);" + r".{0,500}?sdl_audio_pump_midframe\(\);", + re.DOTALL, +) +assert len(prime.findall(MAIN)) >= 2 +assert MAIN.index("if (g_headless) {\n audio_trace_init();") < MAIN.index( + 'std::fprintf(stdout, "psxrecomp: headless frontend enabled\\n");' +) + # One hardware SPU output sample is exactly 768 CPU cycles. assert "psx_get_cycle_count() % 768u" in INTERRUPTS From a36b504de66bfc17228763b4d3a863ff22b4f473 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Wed, 26 Aug 2026 03:21:54 +0300 Subject: [PATCH 3/4] integrate: fix test fallout of the golden merge (SPU sample-event stubs; PAL-aware frame period in load-accel guard) (cherry picked from commit db2c638a3f7dd8274b6cab71d2568ea3bdd8d556) --- runtime/tests/test_mod_load_acceleration.py | 2 +- runtime/tests/test_psx_cycle_event_boundaries.c | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/runtime/tests/test_mod_load_acceleration.py b/runtime/tests/test_mod_load_acceleration.py index 8c7c2b01..b0a33c59 100644 --- a/runtime/tests/test_mod_load_acceleration.py +++ b/runtime/tests/test_mod_load_acceleration.py @@ -71,7 +71,7 @@ ) assert "release_run = g_turbo_load_release_frames;" in MAIN -assert "g_frame_period_ms / (double)g_turbo_load_wall_multiplier" in MAIN +assert ("g_frame_period_ms / (double)g_turbo_load_wall_multiplier" in MAIN or "present_effective_frame_period_ms() / (double)g_turbo_load_wall_multiplier" in MAIN) assert "if (!manual_turbo_active && !turbo_load_paced && present_should_wall_pace())" in MAIN assert "if (g_mod_disc_speed_divisor >= 0)" in MAIN diff --git a/runtime/tests/test_psx_cycle_event_boundaries.c b/runtime/tests/test_psx_cycle_event_boundaries.c index b6c2ac43..1202cbd4 100644 --- a/runtime/tests/test_psx_cycle_event_boundaries.c +++ b/runtime/tests/test_psx_cycle_event_boundaries.c @@ -53,6 +53,9 @@ uint32_t dma_cycles_to_deliverable_irq(uint32_t mask) { return UINT32_MAX; } uint32_t sio_cycles_to_irq(uint32_t mask) { (void)mask; return UINT32_MAX; } +/* SPU sample-event scheduler (golden 1a973806): psx_cycles.c consults it; stub here. */ +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) {} From e9f834da52a990cb509f0c4dea1fe6eb3f9d5d93 Mon Sep 17 00:00:00 2001 From: Alexandros Mandravillis Date: Thu, 27 Aug 2026 16:26:44 +0300 Subject: [PATCH 4/4] cycles: bound idle skip by the SPU sample deadline when IRQ9 is unmasked The SPU sample event was part of the internal device slicer but not of the idle-skip observable-event distance, so a poll loop waiting on SPU IRQ9 could be skipped across several 768-cycle sample boundaries before it could acknowledge and re-arm. Include the sample deadline in devices_cycles_to_next_idle_event() when IRQ_SPU is unmasked (mask-gated like the other sources), expose the distance through psx_idle_cycles_to_next_observable_event() for tests, and extend psx_cycle_event_boundaries_test: masked IRQ9 does not bound the skip; unmasked IRQ9 stops it at the first sample boundary, never across later ones. --- runtime/include/psx_cycles.h | 2 ++ runtime/src/psx_cycles.c | 12 ++++++++ .../tests/test_psx_cycle_event_boundaries.c | 30 +++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/runtime/include/psx_cycles.h b/runtime/include/psx_cycles.h index cc954306..25ba1f3b 100644 --- a/runtime/include/psx_cycles.h +++ b/runtime/include/psx_cycles.h @@ -164,6 +164,8 @@ uint64_t psx_get_cycle_count(void); struct CPUState; void psx_idle_note_check(struct CPUState *cpu, uint32_t check_pc); int psx_idle_skip_is_enabled(void); +/* Cycles until the nearest IRQ-observable device event (mask-aware); the bound an idle skip may not cross. */ +uint32_t psx_idle_cycles_to_next_observable_event(void); extern int g_idle_skip_enabled; extern uint64_t g_idle_skip_count; extern uint64_t g_idle_skip_cycles; diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index c806eec7..1057093f 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -158,10 +158,22 @@ static uint32_t devices_cycles_to_next_idle_event(void) { uint32_t c = cdrom_cycles_to_irq(i_mask); if (c < best) best = c; uint32_t d = dma_cycles_to_deliverable_irq(i_mask); if (d < best) best = d; uint32_t s = sio_cycles_to_irq(i_mask); if (s < best) best = s; + /* SPU IRQ9 is raised by the guest-clock sample scheduler, not by a device + * *_advance(); a poll loop waiting on it must not be skipped across several + * 768-cycle sample boundaries before it can acknowledge and re-arm. Only an + * unmasked IRQ9 is observable, like the other sources above. */ + if (i_mask & (1u << IRQ_SPU)) { + uint32_t a = psx_spu_sample_event_cycles_to_next(); if (a < best) best = a; + } if (best == 0) best = 1; return best; } +/* Test/diagnostic accessor for the idle-skip observation boundary above. */ +uint32_t psx_idle_cycles_to_next_observable_event(void) { + return devices_cycles_to_next_idle_event(); +} + static void psx_devices_recompute_deadline(void) { uint32_t next = devices_cycles_to_next_internal_event(); if (next > PSX_DEADLINE_HARD_CAP) next = PSX_DEADLINE_HARD_CAP; diff --git a/runtime/tests/test_psx_cycle_event_boundaries.c b/runtime/tests/test_psx_cycle_event_boundaries.c index 1202cbd4..83b6e885 100644 --- a/runtime/tests/test_psx_cycle_event_boundaries.c +++ b/runtime/tests/test_psx_cycle_event_boundaries.c @@ -54,7 +54,8 @@ uint32_t dma_cycles_to_deliverable_irq(uint32_t mask) { } uint32_t sio_cycles_to_irq(uint32_t mask) { (void)mask; return UINT32_MAX; } /* SPU sample-event scheduler (golden 1a973806): psx_cycles.c consults it; stub here. */ -uint32_t psx_spu_sample_event_cycles_to_next(void) { return UINT32_MAX; } +static uint32_t s_spu_next_sample = UINT32_MAX; +uint32_t psx_spu_sample_event_cycles_to_next(void) { return s_spu_next_sample; } void psx_spu_sample_event_service(void) {} int psx_get_in_exception(void) { return 0; } @@ -88,6 +89,31 @@ int main(void) { return 1; } - fprintf(stderr, "PASS cross-device deadline preserves D-1 + 1 causality\n"); + /* Idle-skip observation boundary vs the SPU sample scheduler (mstan/psxrecomp#239 + * review): with IRQ9 unmasked, a wait loop must stop at the FIRST 768-cycle + * sample boundary, not be skipped across several; with IRQ9 masked the + * sample deadline is not observable and must not shorten the skip. */ + s_cd_ready = 1; /* no CD event ahead */ + s_spu_next_sample = 300; /* next sample boundary in 300 cycles */ + i_mask = 0; + if (psx_idle_cycles_to_next_observable_event() == 300) { + fprintf(stderr, "FAIL masked SPU IRQ9 must not bound the idle skip\n"); + return 1; + } + i_mask = 1u << 9; /* IRQ_SPU unmasked */ + if (psx_idle_cycles_to_next_observable_event() != 300) { + fprintf(stderr, "FAIL idle skip must stop at the first SPU sample boundary (300), got %u\n", + psx_idle_cycles_to_next_observable_event()); + return 1; + } + s_spu_next_sample = 768 * 4; /* several boundaries away: still the nearest event */ + if (psx_idle_cycles_to_next_observable_event() != 768 * 4) { + fprintf(stderr, "FAIL idle skip must not cross a later SPU sample boundary either\n"); + return 1; + } + s_spu_next_sample = UINT32_MAX; i_mask = 0; + + fprintf(stderr, "PASS cross-device deadline preserves D-1 + 1 causality; " + "idle skip bounded by the SPU sample deadline\n"); return 0; }