Replace absolute F:/C: paths with repo-relative (../TombaRecomp, bios/, beetle-psx/) or placeholders (<desktop>/<psxref>/<nesrecomp>/<psxrecomp-v4>) across accuracy docs, WIDESCREEN/SHADOW/RECURSION_BUG/config_schema/ISSUES/README, and tools/*.py|.ps1. Removed a personal C:\Users\Matthew\Desktop path (ISSUES.md). Left CLAUDE.md/PLAN.md sibling-project + version-history paths intact (intentional layout documentation).
17 KiB
Axis 5 — SPU (Audio) Accuracy Findings
Cross-reference of our SPU implementation against the in-tree Beetle/Mednafen oracle and nocash psx-spx.
- Our impl:
runtime/src/spu.c(799 LOC) +runtime/src/spu_shadow.c(float resample shadow, opt-in, verify-only — irrelevant to hardware accuracy). - Oracle:
beetle-psx/mednafen/psx/spu.cpp(1664 LOC,PS_SPU). - Spec: nocash psx-spx "Sound Processing Unit (SPU)".
The file's own header comment is candid (spu.c:1-8): "Reverb, noise, sweep
volumes, and IRQ timing are not modeled yet." That summary is accurate and is
the headline of this axis. We are a compact register + direct-ADPCM voice
mixer, not a hardware-faithful SPU.
1. What our implementation does
Implemented (and mostly faithful)
- 24 voices —
SPU_VOICE_COUNT 24, mixed inspu_render(spu.c:521-599). - ADPCM block decode —
decode_block(spu.c:298-344): 28-sample blocks, shift/filter header, 4-tap → 2-tap Pochobit filter coefficientsf0/f1 = {0,60,115,98,122}/{0,0,-52,-55,-60}. Matches Beetle'sWeights[]exactly (spu.cpp:297-305). Loop-start flag (0x04) latchesrepeat_addr; loop-end flag (0x01) handled invoice_next_sample. - ADSR envelope —
calc_vc_delta(spu.c:110-142) is a verbatim port of Beetle'sCalcVCDelta(spu.cpp:187-225);adsr_run(spu.c:146-212) mirrorsPS_SPU::RunEnvelope(spu.cpp:478-537) including the four phases, the exp/linearlog_mode/dec_modeselectors per phase, theuoflow_resetclamp, and the decay→sustain transition atsustain_lvl = (Sl+1)<<11. Rate decoding (Ar 0x7F, Dr<<2, Rr<<2, Sr) matchesCacheEnvelope(spu.cpp:436-458). This is the strongest part of our SPU. - Key-on / key-off —
key_onresets envelope to Attack/level-0 (spu.c:464-482, matchesResetEnvelopespu.cpp:460-467);key_off→ Release preserving level (spu.c:488-497, matchesReleaseEnvelopespu.cpp:469-475). KEYON clears ENDX bit (spu.c:479), correct per psx-spx. - ENDX latch —
endx_latchset on loop-end block, readable at 0x1F801D9C/9E (spu.c:635-640), cleared by KEYON. Functionally correct for poll-driven music engines. - CURVOL read-back — voice reg 6 returns live
env_level(spu.c:646-651), matching Beetle'scase 0x0C: return ADSR.EnvLevel(spu.cpp:1299-1300). Good; this is what lets BIOS pick free voices. - CD audio input — separate 44.1 kHz stereo ring (
spu_cd_audio_push,spu.c:262-287); mixed when SPUCNT bit0 set, scaled by CD vol regs (spu.c:561-569). - DMA / transfer —
spu_dma_write(word) + manual 0x1F801DA8 fifo write + 0x1F801DA6 transfer-addr latch (spu.c:682-706). - Pitch — per-voice pitch register applied as a 12-bit-fractional phase
accumulator (
spu.c:453-460).
Stubbed / missing / wrong (detailed in §2)
- Reverb engine — entirely absent. No all-pass/comb, no
RunReverb, no reverb register set (dAPF/IIR/ACC/FB/MIX coefficients), no reverb work area. - Noise generator — entirely absent. No LFSR, no
RunNoise, NON register read but never used in the mix. - Pitch modulation (FM) — absent. PMON register stored but never applied.
- Volume sweeps — absent.
direct_volume(spu.c:232-244) explicitly drops sweep mode ("Sweep mode is not modeled; use the magnitude as a direct volume"). NoSPU_Sweepclass equivalent. Main volume also usesdirect_volume. - SPU IRQ (address match) — absent. No
IRQAddrcompare, noIRQ_Assert. - Capture buffers — absent. No CD-left/CD-right/voice1/voice3 writeback to SPU RAM at 0x000/0x200/0x400/0x600.
- SPUSTAT — hardcoded
0x0400(spu.c:627-628), ignores SPUCNT mirror, IRQ flag, DMA/transfer state. - Timing model — host-audio-driven, not guest-clock-driven (see §2.1).
2. Specific discrepancies vs Beetle spu.cpp + psx-spx
2.1 Timing model: host-pull vs guest 768-cycle clock (ARCHITECTURAL)
- Beetle:
UpdateFromCDC(clocks)(spu.cpp:761-1072) advances aclock_dividerseeded at 768 (spu.cpp:105); one stereo output sample is produced every 768 SPU clocks, lock-stepped to the guest CPU/CDC timeline. Voice decode, envelope, sweep, noise, reverb, and capture all clock once per guest sample. - Ours:
spu_render(out, frames)is called from the SDL host audio pump (main.cpp:630), driven by how many frames the host audio queue needs — not by guest cycles. Envelope/pitch/decode are stepped once per output frame insidespu_render(spu.c:542-589), decoupled from guest time. - Consequence: envelope/release rates drift relative to the game's sense of time (a release programmed in "guest samples" elapses in "host frames"), voices advance whenever the host asks for audio rather than when the guest clocks the SPU, and there is no deterministic, oracle-comparable per-sample timeline. This is the root reason a sample-stream diff vs Beetle (§4) will not line up phase-accurately even where the per-voice math is correct. This is the single largest structural divergence.
2.2 Reverb — completely missing (player-audible)
- Beetle: full reverb —
RunReverb(spu.cpp:652-730) with FIR up/down resamplers (Reverb4422/Reverb2244,ResampTable[20]), the IIR/comb/all-pass network (IIR_INPUT_A/B,IIR_A/B,ACC,FB_A/B,MDA/MDB/IVB), reverb work-area wrap (Get_Reverb_Offsetspu.cpp:577-585), per-voice reverb-send viaReverb_Mode(EON,spu.cpp:884-888), and the full reverb register set written throughReverbRegs[](dAPF1/2, IIR_ALPHA/COEF, ACC_COEF_A..D, FB_SRC/ALPHA/X, IIR_SRC/DEST, ACC_SRC, MIX_DEST, IN_COEF). - Ours: none of this exists. EON is read into
SpuGlobalState.eon(spu.c:748-749) for debug only; reverb registers (0x1F801DC0–0x1F801DFF) are stored inspu_regs[]but never read.ReverbVol/ReverbWAignored. - Impact: every game that uses SPU reverb (most — ambience, halls, caves, menu chimes) loses its reverb tail. Audible "dry" sound.
2.3 Noise generator — missing
- Beetle:
RunNoise(spu.cpp:733-759) clocks the 15-bit LFSR per the noise-frequency field of SPUCNT ((SPUControl>>8)&0x3F); voices withNoise_Modebit set output(int16)LFSRinstead of ADPCM (spu.cpp:854-855). - Ours: NON is stored to
SpuGlobalState.non(spu.c:746-747) but never consulted. A noise-mode voice in our mixer still plays whatever ADPCM its address points at — wrong waveform entirely. - Impact: drums/cymbals/explosions/wind that use the hardware noise channel play as garbage ADPCM or silence. Game-dependent but common.
2.4 Pitch modulation (FM / PMON) — missing
- Beetle:
PhaseModCache = FM_Mode & ~1(spu.cpp:796); a PMON voice'sphase_inc = Pitch + ((int16)Pitch * (prev voice PreLRSample))>>15(spu.cpp:907-914) — the previous voice's output modulates this voice's pitch. - Ours: PMON stored to
SpuGlobalState.pmon(spu.c:744-745) but pitch is always the raw register (spu.c:453-455). NoPreLRSamplechain. - Impact: FM-synth instruments (some music engines, sound effects) play at a fixed pitch instead of the intended sweep/vibrato.
2.5 Volume sweeps — missing (player-audible)
- Beetle:
SPU_Sweep(spu.cpp:228-288) clocks an exponential/linear sweep per voice L/R and for main volume when bit15 set, else uses(Control&0x7FFF)<<1as the static volume (spu.cpp:891-897, 1059-1066). - Ours:
direct_volume(spu.c:232-244) takes the magnitude of sweep-mode values as a fixed level and otherwise treats the field as signed direct volume. No clocking, no ramp. Note also the scale mismatch: Beetle's static path is(Control&0x7FFF)<<1(15-bit volume → 16-bit) and the voice mix is>>15(spu.cpp:878-879); ours clamps to ±0x3FFF and mixes>>14(spu.c:558-559, 241-242). Net per-voice gain differs by roughly a factor of 2 and ignores the sign of negative volumes in sweep mode. - Impact: any fade-in/fade-out done via volume sweep (extremely common for music note envelopes layered on top of ADSR, and for whole-mix fades) is replaced by a static level — notes pop in/out instead of swelling, and overall balance is off due to the gain-scale mismatch.
2.6 SPU IRQ (address match) — missing
- Beetle:
CheckIRQAddr/RunDecoderassertIRQ_SPUwhen the decode/read/ write address equalsIRQAddrand SPUCNT bit6 is set (spu.cpp:309-317, 366-374, 539-549); cleared when bit6 cleared (spu.cpp:1242-1246). - Ours:
IRQAddrregister is stored inspu_regs[](0x1F801DA4) but never compared; no interrupt is ever raised; SPUSTAT bit6 (IRQ flag) is hardcoded 0. - Impact: games/music drivers that use SPU IRQ for streaming/loop synchronization (sample-accurate retriggering, some XA/stream engines) will never get their interrupt and can stall or mistime audio.
2.7 Capture buffers — missing
- Beetle: writes CD-DA L/R to SPU RAM 0x000/0x200 and voice1/voice3 output to
0x400/0x600 each sample, advancing
CWA(spu.cpp:870-874, 1012-1013, 1031). - Ours: no capture writeback. SPU RAM only ever receives DMA/fifo writes.
- Impact: games that read the capture region (some reverb/echo tricks, oscilloscope visualizers, a few music engines that re-read voice output) get stale/zero data.
2.8 SPUSTAT (0x1F801DAE) — hardcoded
- Beetle:
SPUStatus = SPUControl & 0x3F, plus IRQ bit (0x40), plus the capture-half bit and DMA/transfer-mode bits (spu.cpp:823-827). - Ours: always returns
0x0400(spu.c:627-628). The low-6-bit SPUCNT mirror, IRQ flag, and transfer-busy/half bits are absent. - Impact: BIOS/driver code that polls SPUSTAT for transfer-ready or IRQ
status reads a constant; mostly tolerated because
spu_dma_readyalways returns 1, but it is not faithful and can mis-sequence transfer-mode changes.
2.9 ADPCM shift==invalid handling differs (minor)
- Beetle:
shift > 12→shift = 8; CV &= 0x8888(spu.cpp:406-410, preserves only the sign nibble of each sample). - Ours:
if (shift > 12) shift = 12(spu.c:310) and decodes normally. - Impact: rare (valid streams never set shift>12), but a malformed/edge block decodes to different samples than hardware.
2.10 "End without repeat" garbage-decode model differs (minor)
- Beetle: on loop-end without loop bit, sets
CurAddr = LoopAddr, forces envelope to Release and EnvLevel=0 immediately (spu.cpp:341-353). - Ours: sets Release but keeps the current level decaying, and keeps decoding
forward through whatever follows in RAM (
spu.c:398-413, documented as "garbage masked by dying envelope"). Audibly close but not bit-identical, and the forward-decode (vs Beetle's jump to LoopAddr) reads different SPU RAM.
2.11 IgnoreSampLA / manual LoopAddr write — not modeled (minor)
- Beetle: a manual write to voice LoopAddr (reg 0x0E) sets
IgnoreSampLA, preventing the loop-start flag from overwriting the program-set loop address (spu.cpp:1149-1152, 383-387). - Ours:
repeat_addris always overwritten by the 0x04 flag (spu.c:330) and initialized from reg 7 at key-on (spu.c:471); a mid-sample manual loop override is lost. - Impact: music engines that set a custom loop point after key-on loop to the wrong place. Game-dependent.
3. Prioritized fix list (player-audible impact first)
- P0 — Implement reverb (§2.2). Largest universally-audible gap. Port
Beetle's
RunReverb+SPU_Sweep-independent reverb register decode +Get_Reverb_Offset/work-area + per-voice EON send +ReverbVolmix. Wholesale port fromspu.cpp:564-730is the faithful path. - P0 — Implement volume sweeps (§2.5) and fix the gain scale. Port
SPU_Sweep(spu.cpp:228-288), drive per-voice L/R and main/global volume through it, and reconcile the>>14vs>>15/<<1scaling so absolute levels match hardware. Affects every note envelope and every fade. - P1 — Implement the noise generator (§2.3). Port
RunNoise(spu.cpp:733-759) + LFSR, and select LFSR output forNoise_Modevoices in the mix. Needed for correct percussion/SFX. - P1 — Move to the guest-clock timing model (§2.1). Drive SPU sample
generation from a 768-cycle divider off the global guest-cycle counter
(mirror
UpdateFromCDC), buffering output for the host audio pump, instead of pulling per host-callback. Prerequisite for deterministic oracle diffs and for correct envelope/reverb/noise rates. Aligns with FAITHFUL_TIMING_PLAN.md (devices on the global guest-cycle counter). - P1 — Implement SPU IRQ address match (§2.6). Compare decode/transfer
addresses against
IRQAddrunder SPUCNT bit6 and raise IRQ_SPU; reflect in SPUSTAT bit6 and clear on bit6 clear. Needed for stream/loop-sync engines. - P2 — Pitch modulation / PMON (§2.4). Add the
PreLRSamplechain and PMON-gatedphase_inc(spu.cpp:907-914). - P2 — Capture buffers (§2.7). Write CD-DA + voice1/voice3 to
0x000/0x200/0x400/0x600 with
CWAadvance. - P2 — Real SPUSTAT (§2.8). Compose from SPUCNT mirror + IRQ + transfer state instead of the constant.
- P3 — Edge cases: shift>12 handling (§2.9), end-without-repeat LoopAddr jump + immediate EnvLevel=0 (§2.10), IgnoreSampLA manual-loop override (§2.11).
The whole-file faithful path (per Rule -1 / RULE -1: no surgical per-game
patches) is to port Beetle's PS_SPU core wholesale — decode, envelope,
sweep, noise, reverb, capture, IRQ, SPUSTAT — driven by the guest clock, and keep
only our integration shims (event ring, CD ring, shadow tap, debug snapshots).
The current file already shares the exact decode filter and ADSR math with
Beetle, so a wholesale port is low-risk and removes the entire "what's faithful"
guessing surface in one pass rather than re-deriving each subsystem.
4. Validation method
Use the two-process harness (CLAUDE.md §16): psx-runtime on port 4500
(this is the Tomba2 build per MEMORY/build-t2; generic BIOS build is 4370) and
psx-beetle on port 4382/4380. Ring-buffer-first per global rules — never
arm-then-capture.
-
SPU output sample-stream diff (the primary audible test).
- Add an always-on SPU output ring on both sides capturing the final
stereo sample stream (post-mix, pre-host-resample). Beetle already has
IntermediateBuffer[4096][2](spu.cpp:76-77, 1048-1055) — expose it as a ring over the debug protocol. Our side: tapspu_render'sout_stereo(or better, the proposed guest-clock sample producer) into a matching ring. - Drive both to the same scene (same disc, same input script), then query both rings for the same guest-frame window and diff sample-by-sample. Expect: today, large divergence once reverb/noise/sweep-driven content plays; success = bounded error within ADPCM/round-off tolerance after the P0–P1 fixes. Note the §2.1 timing model means phase alignment requires the guest-clock fix (P1) before a tight diff is meaningful — until then, compare per-voice envelope/pitch (below) and spectral/energy envelopes rather than raw sample phase.
- Add an always-on SPU output ring on both sides capturing the final
stereo sample stream (post-mix, pre-host-resample). Beetle already has
-
Per-voice envelope + register state diff (deterministic, available now).
- Both sides already emit a frame-stamped SPU event ring
(
psxrecomp_beetle_spu_eventKEYON/KEYOFF/END_STOP/END_LOOP —spu.c:87-102andspu.cpp:337-359, 931-958). Diff KEYON/KEYOFF voice+addr+env timelines between ports: this validates voice allocation, loop/end detection, and envelope level at transitions without needing sample-phase alignment. - Compare
spu_get_voice_state/spu_get_global_state(spu.c:716-755) against Beetle'sGetRegister(spu.cpp:1507-1580, GSREG_* incl. ADSR_LEVEL, reverb regs, FM/NOISE/REVERB_ON, sweep volumes) per voice, per frame, for: EnvLevel, Phase, CurAddr, Pitch, and (once implemented) sweep Current, LFSR, ReverbCur. ENDX/BlockEnd compare directly.
- Both sides already emit a frame-stamped SPU event ring
(
-
Subsystem-specific oracles after each fix.
- Reverb: with a reverb-heavy scene, compare the reverb work-area region of
SPU RAM (read via debug RAM read on both sides) and the
ReverbCurpointer; then the reverb-send mix energy. - Noise: pin
LFSRandNoiseDivider/NoiseCounterstep-for-step againstRunNoiseafter seeding identical SPUCNT noise-freq. - IRQ: set
IRQAddrin a known scene and confirm both raise IRQ_SPU at the same guest frame (interrupt ring). - Capture: read SPU RAM 0x000/0x200/0x400/0x600 on both sides and diff.
- Reverb: with a reverb-heavy scene, compare the reverb work-area region of
SPU RAM (read via debug RAM read on both sides) and the
-
Regression guard. Keep the ADSR
calc_vc_deltabyte-identical to Beetle'sCalcVCDelta(already true) and add a unit test that drives both through the full rate range 0..127 in each phase, asserting identical (increment, divinco) pairs — this is the one part proven faithful and must not regress during the wholesale port.