Skip to content

spu: reverb, noise, sweeps and SPU IRQ (closes #103); CAUSE.IP2 combinational (supersedes #102) - #104

Merged
mstan merged 4 commits into
masterfrom
feat/spu-fidelity-103
Aug 6, 2026
Merged

spu: reverb, noise, sweeps and SPU IRQ (closes #103); CAUSE.IP2 combinational (supersedes #102)#104
mstan merged 4 commits into
masterfrom
feat/spu-fidelity-103

Conversation

@mstan

@mstan mstan commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Closes #103. Absorbs and supersedes #102.

Implements the four SPU gaps Issue #103 names — reverb, noise generator, volume
sweeps and SPU IRQ — and lands the separable, independently verified parts of
PR #102. Full per-piece disposition, including everything I could not settle,
is in docs/internal/SPU_FIDELITY_103.md.

Why this supersedes #102 rather than merging alongside it

#102 covers 1 of #103's 4 items. I verified that by grepping its complete
diff for reverb|noise|sweep|LFSR|vLOUT|vROUT|dAPF — zero matches. It
implements SPU IRQ plus three unrelated fixes. Its SPU IRQ and this branch's
would collide textually in spu.c, so there is one implementation, here.

Everything of Alexbeav's that stands is carried, and he is credited as
Co-Authored-By on the implementation commit:

  • CAUSE.IP2 combinational — his central claim, and it is correct.
    Checked against the in-tree Beetle oracle rather than accepted on reasoning:
    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, and cpu.cpp's
    AssertIRQ clears CAUSE bit 10 unconditionally before re-setting it from
    the level. That is a combinational mirror, and it matches R3000A, where the
    Cause.IP field is not storage.
  • SPU RAM DMA readback — DMA4 in the SPU→RAM direction wrote literal zeros.
    SPU RAM is readable memory; that was not a transfer at all.
  • Mid-dispatch audio pump — the SPU is autonomous on hardware, so a guest
    busy-wait that never presents a frame must not freeze SPU time.

Two defects in #102 are fixed here:

  1. A second latching IP2 writer it missed. runtime/src/psx_interpreter.c
    had its own cop0[COP0_CAUSE] |= (1u << 10) at delivery — the identical
    defect in the interpreter path. An audit of every cop0[13] writer confirms
    no other site touches bit 10, and psx_irq_refresh_cause_ip2() is now the
    sole owner.
  2. The audio pump would have broken every turbo load. spu: implement SPU IRQ + CAUSE.IP2 combinational + mid-dispatch audio pump #102 wires the hook
    to sdl_audio_pump() unconditionally, but sdl_audio_update() is the sole
    authority on whether to emit, discard, or not pump at all — during a
    turbo-load hard mute it deliberately does not pump, so the queue drains and
    voice positions freeze in place and music resumes where it left off.
    Pumping unconditionally from the VBlank edge bypasses that and would replay
    time-compressed audio on every load. The hook now goes through a wrapper
    that mirrors the gate state.

#102's own validation (Medal of Honor Underground, Gran Turismo) could not
be reproduced — neither disc is in our suite — so those claims are not repeated
here as if verified. What is verified is the hardware behaviour behind each
change, plus non-regression on our own titles.

Clean-room, and why that mattered

psxrecomp ships under PolyForm Noncommercial 1.0.0; Beetle PSX/mednafen is
GPL-2.0-or-later. Incompatible. Beetle's tree contains a complete, correct
reverb, so the obvious route is closed, and two contributors walked into it:
PR #16 was parked on license grounds after an audit found it tracked
beetle-psx/mednafen/psx/spu.cpp lines 590–730, and PR #13's reverb was
separately wrong — it gated on bit 15 of dAPF1, an address offset whose top
bit is set for any offset ≥ 0x8000, so reverb switched on and off according to
an address value. The real gate is SPUCNT bit 7.

This is written from the psx-spx / nocash register map and documented
algorithm. Beetle's source was not consulted for the implementation; it is used
only as a runtime output oracle, which is not a derivative work. That is the
path docs/internal/upstream/martin-pr16-spu-reverb.md prescribes.

What landed

  • Reverb — 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, work-area advance. SPUCNT bit 7 gates
    writes only; reads, IRQ checks, address advance and output always run, as
    on hardware. Addressing is provably 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, as live envelopes on the
    same rate machinery as ADSR; a guest read of a sweeping register returns the
    live level.
  • SPU IRQ — 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 bit 6.
  • Capture buffers — CD L/R and voice 1/3 written to SPU RAM, so a game that
    parks the IRQ address there still gets its interrupt.

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, zero
active voices) is exactly the CD-with-reverb case.

Verification

  • test_spu_fidelity137 checks, 0 failures. New, from scratch. Covers
    IRQ on each access class plus ack/re-arm, reverb write-gating and work-area
    wrap confinement, the CD-only path, noise non-repetition, sweep rise/fall and
    live readback, direct-volume decode, and savestate round-trip.
  • test_cause_ip2_combinational — rise, fall on ack, fall on mask, partial ack
    with another source pending, partial-width writes, preservation of
    ExcCode/BD/IP0/IP1. Both registered in CTest.
  • BIOS LLE boot proves the reverb engine runs. The BIOS itself sets SPUCNT
    bit 7 and allocates a 62 KB work area at mBASE = 0x70940. Through the boot
    chime the work area holds 256/256 non-zero reflection halfwords peaking at
    24681, reverb_cur laps correctly inside the work area, and after the dry
    signal goes silent the work area keeps ringing down (628 → 18 → 2) for ~3 s —
    a decaying tail outliving its input. Noise LFSR observed shifting with correct
    parity feedback.
  • Tomba 2 intro FMV, A/B against the exact parent commit with a fully warm
    shard cache and the pre-SPU exe as control: interp_share 0.7813 vs 0.7775,
    exc_share 0.2386 vs 0.2381, 39.1 vs 38.0–38.4 fps. Identical within noise —
    the added per-frame DSP work costs nothing measurable.
  • MMX6, Rainy Turtloid stage, ~248 s at 59.9 fps with 36 host underruns
    total.
    Note honestly that this stage streams its audio as CD-XA and drives
    no SPU voices (active_mask = 0, eon = 0, non = 0, SPUCNT bit 7
    clear), so it validates no regression on the CD-only path — which is exactly
    where the fast path was removed, the CD bus made to drain continuously, and
    capture writes added — and not the reverb.

Known deviation, stated plainly

The reverb engine runs at 22050 Hz, but the hardware's 22.05 → 44.1 kHz
reconstruction filter is not specified in the documentation available under
the licensing constraint, and Beetle's filter was deliberately not copied. This
branch uses linear reconstruction, isolated in a single swappable function and
marked in the source. All ten places the documentation ran out are enumerated
in §6 of the design doc as an oracle-comparison work list. An
output-vs-Beetle audio comparison at a fixed scene is the natural follow-up and
has not been done.

Merge prerequisites

  • recomp-ui must be bumped in lockstep, unrelated to this change but
    blocking: framework master's feat(mods): expose linked package attribution
    needs recomp-ui 99558ee, which adds author_links / author_link_count /
    source_name / source_url to RecompLauncherCModPackage. A title pinned to
    854ae10 fails to compile with 'RecompLauncherCModPackage' has no member named 'author_link_count'.
  • Every title needs regen + revalidation, because CAUSE.IP2 changes core IRQ
    delivery for all of them. This is the highest-risk item in the branch. It is
    the faithful behaviour, so per Rule -1 it lands and titles are revalidated
    rather than the fix being narrowed.

Also recommend closing

feat/pr13-salvage-remainder retains nothing but the rejected reverb commit
5fc8e15d. Its other three commits are already on master — verified four ways
(git cherry -v marks them -; cherry-picking all three onto master produces
empty commits; and execute_ch5_pio, the CFC0 + Cause write-protection, and
the autocompile.c change are all present). So PR #13 can be closed and that
branch abandoned; origin/park/pr13-spu-reverb-rejected still retains the
commit.

mstan and others added 4 commits August 5, 2026 18:16
…inational

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>
Measured, not asserted:
- BIOS LLE boot proves the reverb engine runs and rings down after the dry
  signal stops; noise LFSR observed shifting with correct parity feedback.
- Tomba 2 intro FMV A/B against the exact parent commit shows the added
  per-frame DSP work is performance-neutral (min p50 40.0 vs 40.3 fps) and
  does not starve the CD bus or the host sink. The FMV's 40 fps and its
  ~19/s host underruns are PRE-EXISTING on master.
- Records all ten places the documentation ran out as an oracle-comparison
  work list, and the two merge prerequisites (rebase onto moved master; bump
  recomp-ui to 99558ee or no title compiles).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bound)

The first FMV A/B was run on a fresh worktree whose overlay shard cache was
cold AND whose autocompile could never work: game.toml points at
psxrecomp-v4/recompiler/build-t2/psxrecomp-game.exe while the documented build
recipe builds the recompiler at build-recompiler/ in the worktree root, so it
failed 8/8 with WinError 2 and the runtime silently ran interpreted
(dispatch_native = 0). The '40 fps / ~19 underruns per second' attributed to
master in the previous commit was that broken setup, not master.

Re-measured against the fully warm cache with the pre-SPU exe as a control in
the same worktree: interp_share 0.7813 vs 0.7775, exc_share 0.2386 vs 0.2381,
39.1 vs 38.0-38.4 fps. Identical within noise, so the DSP work is free.

Separately characterises the real pre-existing defect: the FMV is 96% native by
dispatch count but 78% interpreted by wall clock with 24% in exception
handling, even warm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rp trap

Master fixed the embed_spirv.py concurrency race independently and better
(tempfile.TemporaryDirectory cleans up on early-return error paths too), so the
rebase conflict was resolved in master's favour and our mkdtemp version
removed. This branch stays SPU-only.

Records the trap that invalidated the first Tomba 2 measurement: game.toml's
overlay_autocompile_cmd names a recompiler path, and when it is absent
autocompile fails every attempt, no shard is ever built, and the runtime
silently interprets everything. Tomba 2 expects a worktree-relative path the
documented build recipe does not produce; MMX6 hardcodes an absolute path into
the main repo that works only by luck of local layout. Check autocompile_status
before trusting any perf number from a fresh worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SPU: reverb, noise, sweep volumes and IRQ timing are not modeled

1 participant