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
369 changes: 369 additions & 0 deletions docs/internal/SPU_FIDELITY_103.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions runtime/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ if(BUILD_TESTING)
add_test(NAME launcher_device_roundtrip_test
COMMAND launcher_device_roundtrip_test)

# COP0.CAUSE IP2 must mirror the INTC line combinationally, never latch.
add_executable(cause_ip2_combinational_test
tests/test_cause_ip2_combinational.c)
add_test(NAME cause_ip2_combinational_test
COMMAND cause_ip2_combinational_test)

add_executable(psx_cyc_batch_test tests/test_psx_cyc_batch.c)
target_include_directories(psx_cyc_batch_test PRIVATE include)
target_compile_definitions(psx_cyc_batch_test PRIVATE
Expand Down Expand Up @@ -82,6 +88,15 @@ if(BUILD_TESTING)
add_test(NAME mod_gpu_dma_aperture_test
COMMAND mod_gpu_dma_aperture_test)

# Clean-room SPU DSP fidelity (issue #103): SPU IRQ on every RAM access
# class, reverb (write gating + work-area wrap + CD-only path), noise
# generator, volume sweeps / direct decode, capture buffers, and
# savestate coverage of all the new state. Includes src/spu.c directly
# (white-box) and stubs its externs.
add_executable(spu_fidelity_test tests/test_spu_fidelity.c)
target_include_directories(spu_fidelity_test PRIVATE include)
add_test(NAME spu_fidelity_test COMMAND spu_fidelity_test)

add_executable(overlay_capture_retry_test
tests/test_overlay_capture_retry.cpp
src/overlay_capture.c)
Expand Down
1 change: 1 addition & 0 deletions runtime/include/audio_trace.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ enum {
stage 0=post-ADPCM-decode (native rate),
stage 1=post-resample+volume (44100). */
AUDIO_EV_SINK_DROP = 10, /* turbo host sink; a=guest SPU frames discarded */
AUDIO_EV_DMA_READ = 11, /* SPU RAM -> CPU DMA; a=words, b=dest RAM addr */
};

typedef struct {
Expand Down
29 changes: 25 additions & 4 deletions runtime/include/spu.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ typedef struct SpuVoiceState {
uint16_t phase; /* sub-sample phase counter (0..0x1000) */
uint16_t env_level; /* live ADSR envelope level (0..0x7FFF) */
uint8_t adsr_phase; /* 0=attack 1=decay 2=sustain 3=release */
int16_t vol_cur_l; /* live effective L volume (sweep-aware, full int16) */
int16_t vol_cur_r; /* live effective R volume (sweep-aware, full int16) */
} SpuVoiceState;

typedef struct SpuGlobalState {
Expand All @@ -62,6 +64,19 @@ typedef struct SpuGlobalState {
uint32_t eon; /* reverb mode (0x1F801D98/9A) */
uint32_t endx; /* end-block reached latch (0x1F801D9C/9E) */
uint32_t active_mask; /* recomp-local "still voicing" mask */

/* ---- SPU DSP fidelity state (issue #103: IRQ/reverb/noise/sweeps) ---- */
uint16_t irq_flag; /* SPU IRQ latch (mirrors SPUSTAT bit 6) */
uint16_t reverb_on; /* SPUCNT bit 7 (reverb work-area write enable) */
uint32_t irq_addr; /* IRQ address as a BYTE address (reg 0x1F801DA4 << 3) */
uint32_t reverb_mbase; /* reverb work area start, byte address (mBASE << 3) */
uint32_t reverb_cur; /* current reverb buffer address (byte, advances @22050Hz) */
uint32_t capture_pos; /* capture-buffer write offset (0..0x3FE, wraps at 0x400) */
uint16_t noise_lfsr; /* live noise shift register value */
uint16_t noise_pad; /* reserved / alignment */
uint32_t sweep_l_mask; /* voices whose LEFT volume register is in sweep mode */
uint32_t sweep_r_mask; /* voices whose RIGHT volume register is in sweep mode */
uint32_t sweep_main; /* bit0 = main L in sweep mode, bit1 = main R */
} SpuGlobalState;

void spu_get_voice_state(int voice, SpuVoiceState* out);
Expand All @@ -76,7 +91,10 @@ typedef enum {
SPU_EV_KEYON = 1,
SPU_EV_KEYOFF = 2,
SPU_EV_END_STOP = 3, /* loop_end without repeat → voice silenced */
SPU_EV_END_LOOP = 4 /* loop_end with repeat → cur_addr=repeat_addr */
SPU_EV_END_LOOP = 4, /* loop_end with repeat → cur_addr=repeat_addr */
SPU_EV_IRQ = 5 /* SPU RAM IRQ-address hit → I_STAT bit 9 raised;
voice=0xFF (not voice-attributable), addr=byte
address that matched */
} SpuEventKind;

typedef struct SpuEvent {
Expand All @@ -100,8 +118,11 @@ void spu_event_reset(void);
uint32_t spu_read(uint32_t addr);
void spu_write(uint32_t addr, uint32_t value);

/* DMA channel 4 interface */
/* DMA channel 4 interface. spu_dma_read reads one 32-bit word from SPU RAM
* at the current transfer address, advances the address by 4, and runs the
* SPU IRQ-address check (SPU RAM -> CPU direction, DICR direction bit 0). */
void spu_dma_write(uint32_t word);
uint32_t spu_dma_read(void);
int spu_dma_ready(void);

/* CD-ROM XA/CDDA input path. Samples are stereo 44.1 kHz PCM entering the
Expand All @@ -126,8 +147,8 @@ typedef struct {
int16_t s[4]; /* decoded samples sample_idx-1 .. +2 (block-edge clamped) */
float frac; /* fractional sub-sample phase in [0,1) */
uint16_t env; /* env_level (0..0x7FFF) */
int16_t vol_l; /* per-voice L volume (direct_volume-decoded) */
int16_t vol_r; /* per-voice R volume */
int16_t vol_l; /* per-voice L volume, 1.14 scale (effective int16 >> 1) */
int16_t vol_r; /* per-voice R volume, same scale */
uint8_t active;
} SpuShadowVoiceTapPub;

Expand Down
58 changes: 50 additions & 8 deletions runtime/src/debug_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -6047,6 +6047,13 @@ static void handle_spu_status(int id, const char *json)
(void)json;
SpuDebugInfo info;
spu_debug_info(&info);
/* The DSP-fidelity state (issue #103: SPU IRQ, reverb, noise, sweeps) lives
* in SpuGlobalState. Surfaced here so the whole SPU can be judged from one
* always-on query — without it there is no way to tell whether the reverb
* engine is actually stepping, whether the IRQ is armed, or which volume
* registers are sweeping. */
SpuGlobalState g;
spu_get_global_state(&g);
send_fmt("{\"id\":%d,\"ok\":true,"
"\"ctrl\":\"0x%04X\",\"active_mask\":\"0x%06X\","
"\"main_l\":%d,\"main_r\":%d,"
Expand All @@ -6055,7 +6062,15 @@ static void handle_spu_status(int id, const char *json)
"\"render_frames\":%llu,\"nonzero_frames\":%llu,"
"\"last_peak\":%d,\"peak\":%d,"
"\"cd_frames\":%u,\"cd_push_frames\":%llu,"
"\"cd_overflow_frames\":%llu,\"cd_underflow_frames\":%llu}",
"\"cd_overflow_frames\":%llu,\"cd_underflow_frames\":%llu,"
"\"pmon\":\"0x%06X\",\"non\":\"0x%06X\",\"eon\":\"0x%06X\","
"\"endx\":\"0x%06X\","
"\"irq_flag\":%u,\"irq_addr\":\"0x%05X\","
"\"reverb_on\":%u,\"reverb_mbase\":\"0x%05X\","
"\"reverb_cur\":\"0x%05X\",\"capture_pos\":\"0x%03X\","
"\"noise_lfsr\":\"0x%04X\","
"\"sweep_l_mask\":\"0x%06X\",\"sweep_r_mask\":\"0x%06X\","
"\"sweep_main\":%u}",
id,
info.ctrl & 0xFFFFu,
info.active_mask & 0xFFFFFFu,
Expand All @@ -6071,7 +6086,21 @@ static void handle_spu_status(int id, const char *json)
info.cd_frames,
(unsigned long long)info.cd_push_frames,
(unsigned long long)info.cd_overflow_frames,
(unsigned long long)info.cd_underflow_frames);
(unsigned long long)info.cd_underflow_frames,
g.pmon & 0xFFFFFFu,
g.non & 0xFFFFFFu,
g.eon & 0xFFFFFFu,
g.endx & 0xFFFFFFu,
(unsigned)g.irq_flag,
g.irq_addr & 0xFFFFFu,
(unsigned)g.reverb_on,
g.reverb_mbase & 0xFFFFFu,
g.reverb_cur & 0xFFFFFu,
g.capture_pos & 0xFFFu,
(unsigned)g.noise_lfsr,
g.sweep_l_mask & 0xFFFFFFu,
g.sweep_r_mask & 0xFFFFFFu,
(unsigned)g.sweep_main);
}

/* ---- Per-voice SPU snapshot. Mirrors fields the Beetle oracle exposes
Expand All @@ -6087,7 +6116,9 @@ static void handle_spu_voices(int id, const char *json)
SpuGlobalState g;
spu_get_global_state(&g);

size_t cap = 8192;
/* 24 voices x ~280 chars + header. Headroom matters: snprintf would silently
* truncate mid-object and hand the caller unparseable JSON. */
size_t cap = 16384;
char *out = (char *)malloc(cap);
if (!out) { send_fmt("{\"id\":%d,\"ok\":false,\"err\":\"alloc\"}", id); return; }
size_t off = 0;
Expand Down Expand Up @@ -6116,7 +6147,11 @@ static void handle_spu_voices(int id, const char *json)
"\"adsr_lo\":\"0x%04X\",\"adsr_hi\":\"0x%04X\","
"\"cur_addr\":\"0x%05X\",\"repeat_addr\":\"0x%05X\","
"\"flags\":\"0x%02X\",\"sample_idx\":%d,\"phase\":\"0x%04X\","
"\"env\":\"0x%04X\",\"env_phase\":%d}",
"\"env\":\"0x%04X\",\"env_phase\":%d,"
/* Live effective volumes. For a sweeping register (bit 15 set) the
* vol_l/vol_r control words above say nothing about the current
* level, so these are the only way to see a sweep actually glide. */
"\"vol_cur_l\":%d,\"vol_cur_r\":%d}",
v == 0 ? "" : ",",
v, s.active,
s.vol_ctrl_l, s.vol_ctrl_r,
Expand All @@ -6126,7 +6161,8 @@ static void handle_spu_voices(int id, const char *json)
s.adsr_lo, s.adsr_hi,
s.cur_addr, s.repeat_addr,
s.last_flags, s.sample_idx, s.phase,
s.env_level, s.adsr_phase);
s.env_level, s.adsr_phase,
s.vol_cur_l, s.vol_cur_r);
if (n > 0) off += (size_t)n;
}
n = snprintf(out + off, cap - off, "]}");
Expand Down Expand Up @@ -6155,7 +6191,7 @@ static void handle_spu_ram(int id, const char *json)
}

/* ---- SPU event ring dump. Returns the most recent N events
* (KEYON / KEYOFF / END_STOP / END_LOOP) with frame timestamps. */
* (KEYON / KEYOFF / END_STOP / END_LOOP / IRQ) with frame timestamps. */
static void handle_spu_events(int id, const char *json)
{
int count = json_get_int(json, "count", 256);
Expand All @@ -6165,7 +6201,12 @@ static void handle_spu_events(int id, const char *json)
if (!evs) { send_fmt("{\"id\":%d,\"ok\":false,\"err\":\"alloc\"}", id); return; }
uint32_t got = spu_event_get(evs, (uint32_t)count);
uint64_t total = spu_event_total();
static const char *kind_names[5] = { "?", "KEYON", "KEYOFF", "END_STOP", "END_LOOP" };
/* Index by SpuEventKind (spu.h). IRQ (=5) is not voice-attributable; the
* ring stores voice=0xFF for it and `addr` is the byte address that matched
* the programmed IRQ address. Keep this table in step with SpuEventKind or
* a new kind renders as "?". */
static const char *kind_names[6] = { "?", "KEYON", "KEYOFF", "END_STOP",
"END_LOOP", "IRQ" };

/* Worst case ~200 chars per event; 64 KB is plenty for 4096 events. */
size_t cap = 256u + (size_t)got * 256u;
Expand All @@ -6178,7 +6219,8 @@ static void handle_spu_events(int id, const char *json)
if (n > 0) off += (size_t)n;
for (uint32_t i = 0; i < got; i++) {
const SpuEvent *e = &evs[i];
const char *kn = (e->kind <= 4) ? kind_names[e->kind] : "?";
const char *kn = (e->kind < sizeof(kind_names) / sizeof(kind_names[0]))
? kind_names[e->kind] : "?";
n = snprintf(out + off, cap - off,
"%s{\"seq\":%llu,\"frame\":%u,\"kind\":\"%s\",\"v\":%d,"
"\"pitch\":\"0x%04X\",\"addr\":\"0x%05X\","
Expand Down
11 changes: 10 additions & 1 deletion runtime/src/dma.c
Original file line number Diff line number Diff line change
Expand Up @@ -798,10 +798,19 @@ static uint32_t execute_ch4_spu(void) {
audio_trace_event(AUDIO_EV_DMA_WRITE, total_words,
channels[4].madr & 0x1FFFFCu);
} else {
/* SPU RAM -> CPU RAM. This direction previously zero-filled the
* destination, which is not a transfer at all: SPU RAM is readable
* memory and games do read it back. Titles that carry state through
* SPU RAM across an Exec boundary (a checksummed block surviving an
* EXE swap, since SPU RAM is one of the few regions main RAM's reload
* does not touch) got zeros, failed their own integrity check, and
* fell back to a cold-boot path. */
for (uint32_t i = 0; i < total_words; i++) {
psx_write_word(addr, 0);
psx_write_word(addr, spu_dma_read());
addr = (addr + addr_step) & 0x1FFFFCu;
}
audio_trace_event(AUDIO_EV_DMA_READ, total_words,
channels[4].madr & 0x1FFFFCu);
}

channels[4].madr = addr;
Expand Down
101 changes: 92 additions & 9 deletions runtime/src/interrupts.c
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,61 @@ extern uint32_t i_mask;
* recorded here with its guest cycle. */
#include "device_trace.h"

/* ---- CAUSE.IP2 is COMBINATIONAL, not latched ---------------------------
*
* On R3000A the Cause.IP field is not storage: it reflects the current state
* of the interrupt input pins. On the PSX only IP2 (bit 10) is wired, and it
* carries the interrupt controller's output line, i.e. (I_STAT & I_MASK) != 0.
* It therefore RISES when a device raises and FALLS the instant the guest acks
* I_STAT or masks the source — with no CPU involvement either way.
*
* This runtime previously only ever OR'd bit 10 in at delivery and never
* cleared it, leaving a phantom IP2 in COP0.CAUSE. A kernel exception
* dispatcher that loops on CAUSE.IP & SR.IM to decide whether to service
* again sees a pending interrupt that no longer exists and can spin in its
* event scan forever.
*
* Verified against the independent Beetle oracle rather than asserted:
* beetle-psx/mednafen/psx/irq.cpp defines
* #define Recalc() PSX_CPU->AssertIRQ(0, (bool)(Status & Mask))
* and calls it from IRQ_Assert (raise), from IRQ_Write for BOTH the Status ack
* and the Mask write, and at power-on; cpu.cpp's AssertIRQ clears bit (10+n)
* unconditionally and re-sets it only when the level is asserted. So the line
* is recomputed at every point (I_STAT & I_MASK) can change, which is exactly
* the set of call sites below.
*
* Ownership: this function is the ONLY writer of CAUSE bit 10. The delivery
* path no longer ORs it in separately — one owner, no divergence.
*
* Derived from PR #102 by Alexandros Mandravillis; the mirror call sites and
* the single-owner refactor are ours. */
static uint32_t *s_cause_ptr;

void psx_irq_refresh_cause_ip2(void)
{
if (!s_cause_ptr) return;
if ((i_stat & i_mask & 0x7FFu) != 0u)
*s_cause_ptr |= (1u << 10);
else
*s_cause_ptr &= ~(1u << 10);
}

void psx_irq_set_cause_ptr(uint32_t *p)
{
s_cause_ptr = p;
/* Power-on recompute, mirroring Beetle's IRQ_Power() -> Recalc(). Without
* this the first mirror only happens at the first raise/ack, so a CAUSE
* read before any interrupt activity would show a stale bit. */
psx_irq_refresh_cause_ip2();
}

/* Central IRQ-raise choke point. All device sources call this to set their
* I_STAT bit so the device-event ring sees every raise from one place with the
* exact guest cycle. Pure addition over `i_stat |= (1<<bit)` — identical effect
* on i_stat, plus the trace note (no-op unless the ring is armed). */
* exact guest cycle. */
void psx_irq_raise(uint32_t bit, uint32_t detail)
{
i_stat |= (1u << bit);
psx_irq_refresh_cause_ip2();
device_trace_note(bit, detail);
}

Expand Down Expand Up @@ -309,6 +357,33 @@ static int should_defer_vblank_for_sio(void) {
return since_progress < VBLANK_DEFER_STALE_CYCLES;
}

/* ---- Mid-dispatch audio pump -------------------------------------------
*
* The SPU is autonomous on real hardware: it keeps consuming samples and
* advancing voice positions while the CPU busy-waits. Our audio pump is driven
* from the main loop between presented frames, so a guest busy-wait that never
* completes a frame starves it and freezes SPU time. That is self-deadlocking
* for any game that waits on an SPU-generated condition — the SPU IRQ it waits
* for needs SPU time to advance, and SPU time only advances when the wait ends.
*
* The VBlank edge is the right place to also pump because it is derived from
* the guest cycle counter, not from host presentation, so it keeps firing
* through such a wait. The pump itself is guest-cycle-budgeted (it renders
* elapsed_cycles/768 frames and carries the remainder), so pumping from both
* here and the main loop produces the same total sample count — the second
* caller simply finds little or no debt outstanding. Both callers are the main
* loop thread, so this is not concurrent with the SDL audio callback, which
* only drains an already-filled ring.
*
* Called at the END of the edge so the VBlank's own IRQ raise and ring records
* complete first: the pump can itself raise an SPU IRQ, and that should land
* after the VBlank edge it followed rather than interleaved into it.
*
* From PR #102 by Alexandros Mandravillis. */
static void (*s_midframe_audio_pump)(void);

void psx_set_midframe_audio_pump(void (*fn)(void)) { s_midframe_audio_pump = fn; }

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 All @@ -328,6 +403,8 @@ static void fire_vblank_edge(void) {
timers_tick(33868); /* ~1 NTSC frame worth of cycles */
cdrom_tick(); /* Process pending CDROM responses */
#endif
/* Keep SPU time flowing across guest busy-waits (see comment above). */
if (s_midframe_audio_pump) s_midframe_audio_pump();
}

void interrupts_service_scheduled_events(void) {
Expand Down Expand Up @@ -1085,14 +1162,20 @@ void psx_check_interrupts(CPUState* cpu) {
exception_entries_total++;
uint32_t pre_handler_istat = i_stat; /* snapshot for cooldown decision */

/* Set COP0 Cause: ExcCode=0 (interrupt). IP2 reflects the INTC line, so
* set it only when the hardware source is what's being delivered; a pure
* software interrupt must present the guest-written IP0/IP1 bits
* unmodified (the guest's dispatcher discriminates stages by exactly
* these bits — see sw_pending rationale at the top of this function). */
/* Set COP0 Cause: ExcCode=0 (interrupt). The ~0x7C mask deliberately
* preserves the whole IP field, because a pure software interrupt must
* present the guest-written IP0/IP1 bits unmodified (the guest's dispatcher
* discriminates stages by exactly those bits — see the sw_pending rationale
* at the top of this function).
*
* IP2 specifically is NOT set here. It is combinational and has a single
* owner, psx_irq_refresh_cause_ip2(), which already tracks the INTC line at
* every point that line can move. Refreshing rather than OR-ing means a
* delivery that races an ack cannot leave a stale bit behind, and a
* software-interrupt delivery gets IP2 reflecting the true line state
* instead of whatever bit 10 happened to be left as. */
cpu->cop0[COP0_CAUSE] = (cpu->cop0[COP0_CAUSE] & ~0x7C) | (0 << 2);
if (hw_deliverable)
cpu->cop0[COP0_CAUSE] |= (1 << 10);
psx_irq_refresh_cause_ip2();

/* Push SR exception stack: shift bits [5:0] left by 2. */
cpu->cop0[COP0_SR] = (sr & ~0x3F) | ((sr & 0x0F) << 2);
Expand Down
Loading