mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
6e6a0b3ed8
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>
186 lines
7.8 KiB
C
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;
|
|
}
|