Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions recompiler/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions runtime/include/interrupts.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 2 additions & 0 deletions runtime/include/psx_cycles.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
81 changes: 81 additions & 0 deletions runtime/src/interrupts.c
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
#include "lockstep.h"
#include "psx_cycles.h"
#include "psx_scheduler.h"
#include "spu.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions runtime/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions runtime/src/psx_cycles.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) =================
Expand Down Expand Up @@ -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;
}
Expand All @@ -156,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;
Expand Down
2 changes: 1 addition & 1 deletion runtime/tests/test_mod_load_acceleration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 30 additions & 1 deletion runtime/tests/test_psx_cycle_event_boundaries.c
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ 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. */
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; }

void starvation_watchdog_check(void) {}
Expand Down Expand Up @@ -85,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;
}
41 changes: 41 additions & 0 deletions runtime/tests/test_spu_sample_scheduler_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""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
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.
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

# 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