Files
psxrecomp/runtime/tests/test_cause_ip2_combinational.c
Matthew Stanley 6e6a0b3ed8 spu: implement reverb, noise, sweeps and SPU IRQ; make CAUSE.IP2 combinational
Closes the four gaps named in issue #103 and lands the separable, verified
parts of PR #102. See docs/internal/SPU_FIDELITY_103.md for the per-piece
disposition and the oracle-verification queue.

SPU DSP (issue #103), clean-room from hardware documentation:

- Reverb: the documented 22050 Hz engine — vLIN/vRIN input, same-side and
  crossed different-side IIR reflections, 4-tap comb early echo, both
  all-pass filters, vLOUT/vROUT output, and the work-area address advance.
  Gated on SPUCNT bit 7 for WRITES only; reads, IRQ checks, address advance
  and output always run, as on hardware. Addressing is confined to
  [mBASE, 0x80000). Per-voice sends from EON, CD send from SPUCNT bits 0+2.
- Noise: 16-bit LFSR with the documented parity feedback, clocked at the
  SPUCNT bits 8-13 rate; NON voices substitute it for their ADPCM sample.
- Volume sweeps: all 24 voice L/R plus main L/R. Bit 15 selects sweep vs
  direct; sweep is a live envelope on the same rate machinery as ADSR, and a
  guest read of a sweeping register returns the live level.
- Capture buffers: CD L/R and voice 1/3 output written to SPU RAM, so a game
  that parks the IRQ address there still gets its interrupt.
- SPU IRQ (I_STAT bit 9): checked at every SPU RAM access class — FIFO, DMA
  both directions, ADPCM block fetch, capture writes, reverb work-area
  accesses — gated on SPUCNT bit 6, mirrored in SPUSTAT bit 6, acked by
  clearing SPUCNT bit 6.

The CD-only fast path in spu_render was removed rather than extended: the
SPU now has per-frame work that must run in every path, and an FMV (XA audio
with zero active voices) is exactly the CD-with-reverb case.

This is a clean-room implementation from the psx-spx/nocash register map and
documented algorithm. Beetle's source was deliberately not consulted: it is
GPL-2.0-or-later and this project is PolyForm Noncommercial. PR #16 was
parked for exactly that reason and PR #13's reverb was separately wrong (it
gated on bit 15 of dAPF1, an address offset, instead of SPUCNT bit 7). The
one place this knowingly deviates from hardware is the 22.05 -> 44.1 kHz
reconstruction filter, which the documentation does not specify; it is
isolated in a single function and flagged for oracle comparison.

CAUSE.IP2 (from PR #102):

IP2 is combinational on R3000A — it mirrors the interrupt line and falls the
instant the guest acks I_STAT or masks the source. It was being OR'd in at
delivery and never cleared, in TWO places (the compiled path and
psx_interpreter.c, the latter missed by PR #102), leaving a phantom pending
interrupt that can spin a kernel dispatcher looping on CAUSE.IP & SR.IM.
psx_irq_refresh_cause_ip2() is now the sole writer of bit 10, refreshed at
raise, I_STAT ack, I_MASK write, HLE context restore and power-on. Verified
against the Beetle oracle's irq.cpp/cpu.cpp rather than asserted, and
covered by test_cause_ip2_combinational.

Mid-dispatch audio pump (from PR #102), with a fix: routed through a gate
that mirrors sdl_audio_update's mute/sink decision. Pumping unconditionally
from the VBlank edge, as submitted, would have pushed real audio during every
turbo-load hard mute and defeated the freeze-in-place mute model.

SPU RAM DMA readback (from PR #102): DMA4 in the SPU->RAM direction wrote
literal zeros; SPU RAM is readable memory and titles carry state through it
across an Exec boundary.

Also fixes an unrelated build race found on the way: tools/embed_spirv.py
wrote its intermediate SPIR-V to <source>.spv, so the two runtime targets
that embed the same shaders raced on one path and one deleted the file the
other was reading.

Co-Authored-By: Alexandros Mandravillis <Alexbeav@live.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:16:16 -07:00

186 lines
7.8 KiB
C

/*
* Validate that COP0.CAUSE bit 10 (IP2) behaves as a COMBINATIONAL mirror of
* the interrupt-controller line, not as a latch.
*
* On R3000A the Cause.IP field is not storage — it reflects the current state
* of the interrupt input pins. On the PSX only IP2 is wired, and it carries the
* INTC output, i.e. (I_STAT & I_MASK) != 0. So IP2 must RISE when a device
* raises and FALL the instant the guest acks I_STAT or masks the source, with
* no CPU involvement in either direction.
*
* This was previously wrong in two independent places: both the compiled
* delivery path (interrupts.c) and the standalone interpreter delivery path
* (psx_interpreter.c) only ever OR'd bit 10 in at delivery and never cleared
* it. A kernel exception dispatcher that loops on CAUSE.IP & SR.IM to decide
* whether to service again then sees a pending interrupt that no longer exists.
*
* Cross-checked against the independent Beetle oracle rather than asserted:
* beetle-psx/mednafen/psx/irq.cpp recomputes the line as
* AssertIRQ(0, (Status & Mask)) at power-on, at every assert, and at BOTH the
* status-ack and mask-write halves of a register write; cpu.cpp's AssertIRQ
* clears bit (10+n) unconditionally before re-setting it from the level.
*
* The mirror under test is deliberately tiny and depends only on i_stat/i_mask
* plus a caller-supplied CAUSE pointer, so it is exercised directly here rather
* than by linking the whole interrupt subsystem.
*
* Build:
* cc -std=c99 -Wall -Wextra -Werror \
* -o test_cause_ip2_combinational test_cause_ip2_combinational.c
*/
#include <stdint.h>
#include <stdio.h>
/* ---- The unit under test -------------------------------------------------
* Mirrors runtime/src/interrupts.c. Kept in lockstep with it; if the two ever
* disagree this test is the thing that should be updated last, not first. */
uint32_t i_stat;
uint32_t i_mask;
static uint32_t *s_cause_ptr;
static 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);
}
static void psx_irq_set_cause_ptr(uint32_t *p)
{
s_cause_ptr = p;
psx_irq_refresh_cause_ip2();
}
static void psx_irq_raise(uint32_t bit)
{
i_stat |= (1u << bit);
psx_irq_refresh_cause_ip2();
}
/* Mirrors memory.c's interrupt_write_stat_masked: writing I_STAT ACKS bits —
* a zero bit in the written value clears the corresponding pending bit. */
static void write_i_stat(uint32_t val, uint32_t mask)
{
uint32_t ack_mask = mask & 0x7FFu;
i_stat = (i_stat & ~ack_mask) | (i_stat & val & ack_mask);
psx_irq_refresh_cause_ip2();
}
static void write_i_mask(uint32_t val, uint32_t mask)
{
i_mask = ((i_mask & ~mask) | (val & mask)) & 0x7FFu;
psx_irq_refresh_cause_ip2();
}
/* ---- Harness ------------------------------------------------------------ */
static int failures;
#define IP2(cause) (((cause) >> 10) & 1u)
#define CHECK(cond, label) do { \
if (!(cond)) { \
fprintf(stderr, "FAIL: %s\n", (label)); \
failures++; \
} \
} while (0)
#define IRQ_VBLANK 0
#define IRQ_SPU 9
int main(void)
{
uint32_t cause;
/* --- power-on recompute (Beetle's IRQ_Power -> Recalc) --------------- */
i_stat = 0; i_mask = 0;
cause = 0xFFFFFFFFu; /* worst case: bit 10 already set from junk state */
psx_irq_set_cause_ptr(&cause);
CHECK(IP2(cause) == 0, "wiring the mirror recomputes IP2 at power-on");
/* --- a raise on a MASKED source must not assert the line ------------- */
i_stat = 0; i_mask = 0; cause = 0;
psx_irq_set_cause_ptr(&cause);
psx_irq_raise(IRQ_VBLANK);
CHECK(i_stat == 1u, "raise sets the I_STAT bit regardless of mask");
CHECK(IP2(cause) == 0, "raise with the source masked leaves IP2 clear");
/* --- unmasking an already-pending source asserts the line ----------- */
write_i_mask(0x0001u, 0xFFFFu);
CHECK(IP2(cause) == 1, "unmasking a pending source raises IP2");
/* --- re-masking drops it again, with I_STAT untouched ---------------- */
write_i_mask(0x0000u, 0xFFFFu);
CHECK(i_stat == 1u, "masking does not clear the pending I_STAT bit");
CHECK(IP2(cause) == 0, "masking a pending source drops IP2");
/* --- raise on an UNMASKED source asserts immediately ----------------- */
i_stat = 0; i_mask = 0x0001u; cause = 0;
psx_irq_set_cause_ptr(&cause);
CHECK(IP2(cause) == 0, "no pending source means IP2 clear");
psx_irq_raise(IRQ_VBLANK);
CHECK(IP2(cause) == 1, "raise on an unmasked source raises IP2");
/* --- THE REGRESSION: acking I_STAT must drop IP2 --------------------- */
/* Writing 0 to a pending bit acknowledges it. Before the fix, bit 10 was
* latched at delivery and survived this, so a dispatcher looping on
* CAUSE.IP & SR.IM re-serviced an interrupt that no longer existed. */
write_i_stat(0x0000u, 0xFFFFu);
CHECK(i_stat == 0u, "writing zero acks the pending bit");
CHECK(IP2(cause) == 0, "acking the last pending source drops IP2");
/* --- a partial ack leaves the line up while anything remains --------- */
i_stat = 0; i_mask = (1u << IRQ_VBLANK) | (1u << IRQ_SPU); cause = 0;
psx_irq_set_cause_ptr(&cause);
psx_irq_raise(IRQ_VBLANK);
psx_irq_raise(IRQ_SPU);
CHECK(IP2(cause) == 1, "two pending sources raise IP2");
write_i_stat(~(uint32_t)(1u << IRQ_VBLANK), 0xFFFFu); /* ack VBlank only */
CHECK(i_stat == (1u << IRQ_SPU), "partial ack clears only the acked bit");
CHECK(IP2(cause) == 1, "IP2 stays asserted while another source is pending");
write_i_stat(0x0000u, 0xFFFFu);
CHECK(IP2(cause) == 0, "IP2 drops once the last source is acked");
/* --- the mirror must not disturb other CAUSE bits -------------------- */
/* ExcCode, BD and the software-interrupt bits IP0/IP1 are all real state
* the guest or the exception path owns; only bit 10 belongs to the line. */
i_stat = 0; i_mask = 0x0001u;
cause = 0x8000000Cu | (1u << 8) | (1u << 9); /* BD | ExcCode=3 | IP0 | IP1 */
psx_irq_set_cause_ptr(&cause);
uint32_t preserved = cause & ~(1u << 10);
psx_irq_raise(IRQ_VBLANK);
CHECK(IP2(cause) == 1, "raise asserts IP2 alongside existing CAUSE bits");
CHECK((cause & ~(1u << 10)) == preserved,
"asserting IP2 preserves ExcCode / BD / software-interrupt bits");
write_i_stat(0x0000u, 0xFFFFu);
CHECK((cause & ~(1u << 10)) == preserved,
"clearing IP2 preserves ExcCode / BD / software-interrupt bits");
/* --- byte-width register writes must still recompute ----------------- */
/* I_STAT/I_MASK are byte- and halfword-writable; the mask argument is how
* partial writes are expressed, and every one of them can move the line. */
i_stat = 0; i_mask = 0; cause = 0;
psx_irq_set_cause_ptr(&cause);
psx_irq_raise(IRQ_SPU); /* bit 9 — in the high byte */
CHECK(IP2(cause) == 0, "masked SPU raise leaves IP2 clear");
write_i_mask(0x0200u, 0xFF00u); /* halfword-high write unmasks it */
CHECK(IP2(cause) == 1, "a partial-width mask write still recomputes IP2");
/* --- bits above the 11-bit INTC range never drive the line ---------- */
i_stat = 0; i_mask = 0; cause = 0;
psx_irq_set_cause_ptr(&cause);
i_stat = 0xF800u; /* outside the 0x7FF INTC range */
i_mask = 0xF800u;
psx_irq_refresh_cause_ip2();
CHECK(IP2(cause) == 0, "out-of-range I_STAT/I_MASK bits do not assert IP2");
if (failures) {
fprintf(stderr, "FAILED (%d)\n", failures);
return 1;
}
printf("test_cause_ip2_combinational: all checks passed\n");
return 0;
}