docs: correct the Tomba 2 perf measurement (first attempt was interp-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>
This commit is contained in:
Matthew Stanley
2026-08-05 17:06:20 -07:00
parent fe518e3031
commit 7e1421be7c
+351 -332
View File
@@ -1,332 +1,351 @@
# SPU fidelity burndown — Issue #103 and PR #102
Branch: `feat/spu-fidelity-103` · Worktree: `_wt-spu-103` · Base: `origin/master` `7f7fbc8e`
Closes the four gaps named in Issue #103 (reverb, noise, volume sweeps, SPU IRQ)
and lands the separable, verified parts of PR #102. Written so the disposition of
each piece is auditable rather than asserted.
---
## 1. Scope overlap between #102 and #103
They intersect on exactly one item.
| Item | Issue #103 | PR #102 | Disposition |
| --- | --- | --- | --- |
| Reverb | ✔ asked | — | Implemented here, clean-room (§3) |
| Noise LFSR | ✔ asked | — | Implemented here, clean-room (§3) |
| Volume sweeps | ✔ asked | — | Implemented here, clean-room (§3) |
| SPU IRQ | ✔ asked | ✔ offered | **One implementation**, ours, in `spu.c` |
| CAUSE.IP2 combinational | — | ✔ offered | Taken, verified, extended (§2) |
| Mid-dispatch audio pump | — | ✔ offered | Taken, with a regression fixed (§2) |
| SPU RAM DMA readback | — | ✔ offered | Taken as-is in substance (§2) |
So this is one coherent change, not two competing ones. PR #102 covers 1 of
#103's 4 items; the other three were never in it.
## 2. PR #102 — per-piece verdict
PR #102's four changes were reviewed as four separable items, because they carry
very different risk. Attribution to Alexandros Mandravillis is recorded in the
commit trailer and in the source comments; the code here is a reimplementation
on the same findings, because two of the PR's three commits touch `spu.c` and
would have collided with the clean-room work.
### 2a. CAUSE.IP2 combinational — CONFIRMED, taken, and extended
The PR's claim is that `CAUSE.IP2` is a live mirror of the interrupt line rather
than a latched bit. **The claim is correct**, and it was checked against the
in-tree Beetle oracle rather than accepted on the PR's reasoning:
- `beetle-psx/mednafen/psx/irq.cpp` defines
`#define Recalc() PSX_CPU->AssertIRQ(0, (bool)(Status & Mask))`
and calls it from `IRQ_Assert` (raise), from **both** halves of `IRQ_Write`
(the Status ack and the Mask write), and from `IRQ_Power`.
- `beetle-psx/mednafen/psx/cpu.cpp:289 AssertIRQ()` clears `CAUSE` bit `10+n`
unconditionally, then re-sets it only if the level is asserted.
That is the definition of a combinational mirror, and it matches R3000A: the
`Cause.IP` field is not storage, it reflects the interrupt pins. Our runtime
previously only ever OR'd bit 10 in at delivery and never cleared it.
Extensions beyond the PR:
1. **A second latching writer the PR missed.** `runtime/src/psx_interpreter.c`
had its own `cpu->cop0[COP0_CAUSE] |= (1u << 10);` at interrupt delivery —
the identical defect in the standalone interpreter path. Fixed too. An audit
of every writer of `cop0[13]` confirms no other site touches bit 10: the
interpreter's `MTC0` paths correctly mask guest writes to `0x0300`
(software-interrupt bits only), and the exception paths mask `~0x7C` /
`~0x8000007C`, preserving the IP field.
2. **Single ownership.** The compiled delivery path no longer sets bit 10
itself; `psx_irq_refresh_cause_ip2()` is the only writer. Two writers of one
combinational bit is how the bug survived in the first place.
3. **Power-on recompute**, mirroring Beetle's `IRQ_Power() -> Recalc()`.
4. **A regression test**, `runtime/tests/test_cause_ip2_combinational.c`,
covering rise, fall on ack, fall on mask, partial ack with another source
still pending, partial-width register writes, preservation of ExcCode/BD/IP0/
IP1, and out-of-range I_STAT bits. Registered in CTest.
Risk note: this is a real behavioural change to core IRQ delivery for every
title, and it is the highest-risk item in this branch. It is nonetheless the
faithful behaviour, so per Rule -1 it lands and titles are revalidated rather
than the fix being narrowed.
### 2b. Mid-dispatch audio pump — taken, with a regression the PR would have shipped
The reasoning is sound: the SPU is autonomous on hardware, our pump is driven
from the main loop between presented frames, so a guest busy-wait that never
presents a frame freezes SPU time — self-deadlocking for any game waiting on an
SPU-generated condition. The VBlank edge is derived from the guest cycle
counter, so it keeps firing through such a wait and is a defensible place to
pump. Confirmed single-threaded: `spu_render` runs on the main loop thread
(`sdl_audio_pump` is the producer; the SDL callback only drains an SPSC ring),
so this introduces no data race on `i_stat`/`cop0`.
**Defect found and fixed.** PR #102 wires the hook to `sdl_audio_pump()`
unconditionally. But `sdl_audio_update()` is the sole authority on whether a
pump should emit, discard, or not run:
- hard mute (turbo loads) → *does not pump at all*; the queue drains and voice
positions freeze in place, so music resumes where it left off instead of
replaying time-compressed garble. This is user-validated behaviour.
- turbo sink → pumps with `discard_output = true`.
Pumping unconditionally from the VBlank edge bypasses both, which would push
real audio during every turbo load. The hook now goes through
`sdl_audio_pump_midframe()`, which mirrors the gate state
(`AUDIO_GATE_NORMAL` / `MUTED` / `SINK`) that `sdl_audio_update()` last set.
Known tension, recorded rather than guessed at: in the `MUTED` state SPU time
still freezes, so a game that busy-waits on an SPU condition *during* a turbo
load would stall. No title in our suite is known to do this. Advancing SPU time
under mute would change validated mute audio, so it is not being done
speculatively.
Placement: the pump call sits at the **end** of `fire_vblank_edge()` so the
VBlank's own raise and ring records complete first — the pump can itself raise
an SPU IRQ, which should land after the edge rather than interleaved into it.
### 2c. SPU RAM DMA readback — taken
DMA4 in the SPU→RAM direction previously wrote literal zeros. SPU RAM is
readable memory; this was not a transfer at all. Titles that carry a checksummed
block through SPU RAM across an `Exec` boundary (SPU RAM being one of the few
regions a main-RAM reload does not touch) read zeros, failed their own integrity
check, and fell back to a cold-boot path. Lowest-risk item in the PR:
previously-dead path, no existing behaviour depends on the zeros.
### 2d. Validation claims — NOT reproduced, and cannot be
PR #102 validates against Medal of Honor Underground (SLUS-01270) and Gran
Turismo (SCUS-94194). Neither disc is in our suite. Those specific claims are
**unverified by us and should not be repeated as if they were**. What we
verified independently is the hardware behaviour behind each change (§2a–2c) and
non-regression across our own titles.
## 3. Issue #103 — why this had to be clean-room
⚠ **This is the load-bearing constraint on the whole item, and it is why #103
sat open.**
`psxrecomp` is distributed under **PolyForm Noncommercial 1.0.0**. Beetle PSX /
mednafen is **GPL-2.0-or-later**. They are incompatible: GPL source cannot be
folded into a PolyForm distribution. Beetle's tree contains a complete, correct,
well-tested reverb — so the obvious implementation route is closed, and two
contributors independently walked into it:
- **PR #16** (Martin Penkava) implemented all of this by porting Beetle. Parked
on license grounds after a line-by-line audit found it tracked
`beetle-psx/mednafen/psx/spu.cpp` lines 590–730 in register layout, resampling
coefficients, buffer layout, arithmetic and processing order. See
`docs/internal/upstream/martin-pr16-spu-reverb.md` on branch
`audit/pr16-spu-reverb-gpl-lineage-mpenkava`.
- **PR #13** (parked at `5fc8e15d`) shipped a reverb too, rejected for being
*wrong* rather than for licensing: it gated reverb on bit 15 of `0x1F801DC0`,
which is `dAPF1` — an all-pass *offset* register 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 (`0x1F801DAA`) bit 7. It also read
`dAPF2` / `vIIR` / `vCOMB1` as "feedback" / "wet level" / "early-reflection
level"; they are none of those.
The PR #16 audit names the only acceptable path, and this branch takes it: **a
clean-room implementation from hardware documentation, with independently
derived tests.** The psx-spx / nocash register map and documented algorithm were
the source. Beetle's source was deliberately not consulted for the
implementation.
Beetle remains fully usable as a **runtime oracle** — running the binary and
comparing its audio output is not a derivative work, and that comparison is how
the remaining uncertainties in §5 get settled. `audio_wav` and `audio_stats`
exist on both `psx-runtime` and `psx-beetle`, so the comparison is a symmetric
always-on ring query, not an arm-then-capture.
### Pre-existing lineage exposure — reported, not changed
Separate from this work, and worth a decision:
- `runtime/src/spu.c` `calc_vc_delta()` is commented *"Ported verbatim from
Beetle's CalcVCDelta"*. This is the ADSR rate decoder, and it is in every
released binary. Same category as PR #16, already shipped.
- `runtime/include/spu_gauss.h` cites *"No$PSX docs / DuckStation
core/spu.cpp"*.
Deliberately left alone here: rewriting the ADSR rate decoder clean-room changes
every voice envelope in every title and needs its own revalidation campaign, not
a rider on this branch.
## 4. `feat/pr13-salvage-remainder` is fully superseded — no surgery needed
Prior notes flagged this branch as "ready to land" while Issue #103 says its
reverb must stay parked, and treated reconciling that as a blocker. **It is not
a blocker: the branch retains nothing but the rejected reverb commit.**
`git cherry -v origin/master feat/pr13-salvage-remainder`:
```
- e9a8a24e interp: deliver alignment exceptions, protect Cause read-only bits, add CFC0
- 3ce30155 dma: implement channel 5 (PIO / expansion port) transfers
+ 5fc8e15d spu: reverb, noise LFSR, sweep volumes, pitch modulation, SPU IRQ
- 9f3cd0a3 autocompile: name the resolved interpreter at configure, warn on Cygwin builds
```
Three of four are already upstream (`-`). Verified four independent ways rather
than trusting patch-id alone:
- cherry-picking all three onto current `origin/master` produced **empty**
commits;
- `execute_ch5_pio()` is present in `runtime/src/dma.c` on master;
- `CFC0` handling and the `& ~0x0300u | (val & 0x0300u)` Cause write-protection
are present in `runtime/src/dirty_ram_interp.c` on master;
- `git diff origin/master 9f3cd0a3 -- runtime/src/autocompile.c` is **empty**.
Consequences: there is no salvage left to preserve, no git surgery to perform,
and the "two competing SPU IRQ implementations" question resolves by default —
`5fc8e15d` is both superseded and Beetle-derived. The branch should be
abandoned and PR #13 closed. `origin/park/pr13-spu-reverb-rejected` still
retains the commit, so nothing is lost.
Incidentally, `e9a8a24e`'s Cause write-protection (guest `MTC0` may only write
bits 8–9) is exactly what makes §2a safe: the guest cannot forge IP2.
## 5. Known deviations and the oracle-verification queue
Recorded explicitly rather than presented as exact (Rule 14).
- **22.05 → 44.1 kHz reverb reconstruction filter.** The reverb engine runs at
22050 Hz, but the hardware's reconstruction filter is not specified in the
documentation used. Beetle's 39-tap FIR was deliberately not copied. The
implementation uses a clearly-marked, single-function reconstruction so the
filter can be swapped once measured against the oracle. **This is the one
place the implementation is known to differ from hardware.**
- Further per-feature judgement calls are listed in §6 as reported by the
implementation pass; each is a candidate for oracle comparison.
## 6. Implementation judgement calls
Every point where the documentation ran out. Each is an oracle-comparison
candidate, NOT a settled fact. Listed so a later measurement pass has a work
list instead of having to rediscover them.
1. **Reverb 22.05 ↔ 44.1 kHz boundary** (the flagged known gap): input is the
box average of the frame pair, output is linear interpolation between
successive engine results. Isolated in `rev_reconstruct()` plus one call
site. **The one deliberate deviation from hardware.**
2. **Noise divider wiring**: down-counter decremented by step `4 + SPUCNT[9:8]`
per 44.1 kHz cycle, reloaded with `0x20000 >> shift`. The least-documented
part of the noise generator.
3. **Register-write IRQ compare sites**: the transfer-address write, the
IRQ-address write and an SPUCNT-enable re-check all compare against the
*resting* transfer address immediately. Consequence: enabling the IRQ while
both registers are 0 fires immediately (unit 0 == unit 0). Real drivers set
the address first.
4. **Suppressed reverb writes** (SPUCNT bit 7 clear) do not run the IRQ compare,
since no access occurs. Reads always do.
5. **Sweep negative-phase semantics**: the envelope works on a 0..0x7FFF value
in the phase's domain; wrong-sign levels clamp to 0 first. A sweep-mode
register read returns the live level as signed 16-bit.
6. **Reverb send taps**: post-envelope and post-voice-volume for EON voices;
post-CD-volume for the CD send.
7. **Capture values**: CD captured pre-CD-volume (raw input bus), voice 1/3
post-envelope pre-voice-volume. The CD bus now **pops continuously while the
SPU is enabled** (SPUCNT bit 0 gates only mix/reverb), needed for capture
correctness. This is a behaviour change — measured as *not* causing
starvation, see §7.
8. **Mix saturation order**: the summed bus (dry + CD + wet) saturates to 16-bit
*before* main volume, per the documented mixer order. Replaces previous
unclamped, int32-overflow-prone math. Audible only in loud scenes.
9. **All-disabled gating**: capture / noise / reverb / sweeps run only while
SPUCNT bit 15 is set; whether they free-run with the SPU disabled is
undocumented. Noise clocks after the voice walk within a frame.
10. **Shadow tap and `SpuDebugInfo` volume fields** keep the historical 1.14
scale (`level >> 1`) so `spu_shadow.c` and old captures stay byte-compatible
for direct-mode volumes.
## 7. Validation status
- [x] `test_cause_ip2_combinational` passes (`-Wall -Wextra -Werror`).
- [x] `test_spu_fidelity` passes — 137 checks, 0 failures. Independently re-run,
not taken from the implementation report.
- [x] All changed translation units compile clean; `psx-runtime` and
`Tomba2Recomp` both link.
- [x] **BIOS LLE boot proves the reverb engine runs.** The BIOS itself sets
SPUCNT bit 7 (`ctrl = 0xC085`) and allocates a 62 KB work area at
`mBASE = 0x70940`. Sampled through the boot chime: the work area holds
256/256 non-zero reflection halfwords peaking at 24681, `reverb_cur`
advances and laps correctly inside `[mBASE, 0x80000)`, and after the dry
signal goes silent (output peak → 0 at t≈6.8 s) the work area keeps
ringing down (628 → 18 → 2) for ~3 s. A decaying tail that outlives its
input is the reverb working. Noise LFSR observed shifting with correct
parity feedback (`000F → 001F → … → FFA3`).
- [x] **Tomba 2 intro FMV: A/B against the exact parent commit.** Same worktree,
framework at `c88c3ca9^` vs `c88c3ca9`, identical generated code
(SPU work is runtime-only).
| Metric | Baseline | SPU branch |
| --- | --- | --- |
| FPS drop | 59.9 → 40.7 at t=6.2 s | 59.9 → 40.4 at t=6.2 s |
| min p50 fps | 40.3 | 40.0 |
| `cd_underflow_frames` | 441, flat | 441, flat |
| host underruns @32 s | 594 | 582 |
| audio peak | 23066 | 26876 |
| `reverb_cur`/`noise_lfsr` | absent (control) | live |
Conclusions: performance is a wash (the FMV's 40 fps and its ~19/s host
underruns are **pre-existing on master** — worth their own issue, not
caused here); the continuous CD-bus drain does **not** starve the bus; and
the peak rising 23066 → 26876 is wet signal adding on top of dry, still
well under 32767, so not clipping. FMV video confirmed correct by
screenshot through the debug server (320x224, intro scene).
- [ ] Oracle audio comparison against `psx-beetle` at a fixed scene — settles
the §6 items, especially (1). `audio_wav`/`audio_stats` exist on both
ports, so this is a symmetric always-on ring query. **Not yet done.**
- [ ] User ear-validation (final gate).
## 9. Merge prerequisites
- **Rebase required.** This branch is based on `7f7fbc8e`; master moved during
the session (observed `9217ae2`, alpha-258). Re-fetch and rebase; do not
merge stale.
- **`recomp-ui` must be bumped in lockstep.** Framework master's
`feat(mods): expose linked package attribution` needs recomp-ui `99558ee`
(`feat(mods): link package authors and sources`), which adds `author_links` /
`author_link_count` / `source_name` / `source_url` to
`RecompLauncherCModPackage`. Tomba 2's pin at `854ae10` is one commit short
and **fails to compile** with `'RecompLauncherCModPackage' has no member named
'author_link_count'`. This is unrelated to the SPU work but blocks any title
build against current master.
- Every title needs regen + revalidation, because CAUSE.IP2 touches core IRQ
delivery for all of them.
## 8. Incidental fix — `tools/embed_spirv.py` build race
Not SPU-related, found while building. Two runtime targets (`psx-runtime`,
`psx-oracle`) embed the same shader set, so ninja runs `embed_spirv.py`
concurrently. It wrote its intermediate SPIR-V to `<source>.spv` **in the source
tree**, so the two invocations raced on one path and whichever finished first
`os.remove()`d the file the other was about to read — a hard build failure
(`FileNotFoundError: .../blit.frag.spv`), and source-tree pollution besides.
Now compiled to a private `mkdtemp` directory, with the output header written
via atomic replace and a loud error if `glslc` reports success but produces no
file. Fixed per Rule 15 rather than worked around.
# SPU fidelity burndown — Issue #103 and PR #102
Branch: `feat/spu-fidelity-103` · Worktree: `_wt-spu-103` · Base: `origin/master` `7f7fbc8e`
Closes the four gaps named in Issue #103 (reverb, noise, volume sweeps, SPU IRQ)
and lands the separable, verified parts of PR #102. Written so the disposition of
each piece is auditable rather than asserted.
---
## 1. Scope overlap between #102 and #103
They intersect on exactly one item.
| Item | Issue #103 | PR #102 | Disposition |
| --- | --- | --- | --- |
| Reverb | ✔ asked | — | Implemented here, clean-room (§3) |
| Noise LFSR | ✔ asked | — | Implemented here, clean-room (§3) |
| Volume sweeps | ✔ asked | — | Implemented here, clean-room (§3) |
| SPU IRQ | ✔ asked | ✔ offered | **One implementation**, ours, in `spu.c` |
| CAUSE.IP2 combinational | — | ✔ offered | Taken, verified, extended (§2) |
| Mid-dispatch audio pump | — | ✔ offered | Taken, with a regression fixed (§2) |
| SPU RAM DMA readback | — | ✔ offered | Taken as-is in substance (§2) |
So this is one coherent change, not two competing ones. PR #102 covers 1 of
#103's 4 items; the other three were never in it.
## 2. PR #102 — per-piece verdict
PR #102's four changes were reviewed as four separable items, because they carry
very different risk. Attribution to Alexandros Mandravillis is recorded in the
commit trailer and in the source comments; the code here is a reimplementation
on the same findings, because two of the PR's three commits touch `spu.c` and
would have collided with the clean-room work.
### 2a. CAUSE.IP2 combinational — CONFIRMED, taken, and extended
The PR's claim is that `CAUSE.IP2` is a live mirror of the interrupt line rather
than a latched bit. **The claim is correct**, and it was checked against the
in-tree Beetle oracle rather than accepted on the PR's reasoning:
- `beetle-psx/mednafen/psx/irq.cpp` defines
`#define Recalc() PSX_CPU->AssertIRQ(0, (bool)(Status & Mask))`
and calls it from `IRQ_Assert` (raise), from **both** halves of `IRQ_Write`
(the Status ack and the Mask write), and from `IRQ_Power`.
- `beetle-psx/mednafen/psx/cpu.cpp:289 AssertIRQ()` clears `CAUSE` bit `10+n`
unconditionally, then re-sets it only if the level is asserted.
That is the definition of a combinational mirror, and it matches R3000A: the
`Cause.IP` field is not storage, it reflects the interrupt pins. Our runtime
previously only ever OR'd bit 10 in at delivery and never cleared it.
Extensions beyond the PR:
1. **A second latching writer the PR missed.** `runtime/src/psx_interpreter.c`
had its own `cpu->cop0[COP0_CAUSE] |= (1u << 10);` at interrupt delivery —
the identical defect in the standalone interpreter path. Fixed too. An audit
of every writer of `cop0[13]` confirms no other site touches bit 10: the
interpreter's `MTC0` paths correctly mask guest writes to `0x0300`
(software-interrupt bits only), and the exception paths mask `~0x7C` /
`~0x8000007C`, preserving the IP field.
2. **Single ownership.** The compiled delivery path no longer sets bit 10
itself; `psx_irq_refresh_cause_ip2()` is the only writer. Two writers of one
combinational bit is how the bug survived in the first place.
3. **Power-on recompute**, mirroring Beetle's `IRQ_Power() -> Recalc()`.
4. **A regression test**, `runtime/tests/test_cause_ip2_combinational.c`,
covering rise, fall on ack, fall on mask, partial ack with another source
still pending, partial-width register writes, preservation of ExcCode/BD/IP0/
IP1, and out-of-range I_STAT bits. Registered in CTest.
Risk note: this is a real behavioural change to core IRQ delivery for every
title, and it is the highest-risk item in this branch. It is nonetheless the
faithful behaviour, so per Rule -1 it lands and titles are revalidated rather
than the fix being narrowed.
### 2b. Mid-dispatch audio pump — taken, with a regression the PR would have shipped
The reasoning is sound: the SPU is autonomous on hardware, our pump is driven
from the main loop between presented frames, so a guest busy-wait that never
presents a frame freezes SPU time — self-deadlocking for any game waiting on an
SPU-generated condition. The VBlank edge is derived from the guest cycle
counter, so it keeps firing through such a wait and is a defensible place to
pump. Confirmed single-threaded: `spu_render` runs on the main loop thread
(`sdl_audio_pump` is the producer; the SDL callback only drains an SPSC ring),
so this introduces no data race on `i_stat`/`cop0`.
**Defect found and fixed.** PR #102 wires the hook to `sdl_audio_pump()`
unconditionally. But `sdl_audio_update()` is the sole authority on whether a
pump should emit, discard, or not run:
- hard mute (turbo loads) → *does not pump at all*; the queue drains and voice
positions freeze in place, so music resumes where it left off instead of
replaying time-compressed garble. This is user-validated behaviour.
- turbo sink → pumps with `discard_output = true`.
Pumping unconditionally from the VBlank edge bypasses both, which would push
real audio during every turbo load. The hook now goes through
`sdl_audio_pump_midframe()`, which mirrors the gate state
(`AUDIO_GATE_NORMAL` / `MUTED` / `SINK`) that `sdl_audio_update()` last set.
Known tension, recorded rather than guessed at: in the `MUTED` state SPU time
still freezes, so a game that busy-waits on an SPU condition *during* a turbo
load would stall. No title in our suite is known to do this. Advancing SPU time
under mute would change validated mute audio, so it is not being done
speculatively.
Placement: the pump call sits at the **end** of `fire_vblank_edge()` so the
VBlank's own raise and ring records complete first — the pump can itself raise
an SPU IRQ, which should land after the edge rather than interleaved into it.
### 2c. SPU RAM DMA readback — taken
DMA4 in the SPU→RAM direction previously wrote literal zeros. SPU RAM is
readable memory; this was not a transfer at all. Titles that carry a checksummed
block through SPU RAM across an `Exec` boundary (SPU RAM being one of the few
regions a main-RAM reload does not touch) read zeros, failed their own integrity
check, and fell back to a cold-boot path. Lowest-risk item in the PR:
previously-dead path, no existing behaviour depends on the zeros.
### 2d. Validation claims — NOT reproduced, and cannot be
PR #102 validates against Medal of Honor Underground (SLUS-01270) and Gran
Turismo (SCUS-94194). Neither disc is in our suite. Those specific claims are
**unverified by us and should not be repeated as if they were**. What we
verified independently is the hardware behaviour behind each change (§2a–2c) and
non-regression across our own titles.
## 3. Issue #103 — why this had to be clean-room
⚠ **This is the load-bearing constraint on the whole item, and it is why #103
sat open.**
`psxrecomp` is distributed under **PolyForm Noncommercial 1.0.0**. Beetle PSX /
mednafen is **GPL-2.0-or-later**. They are incompatible: GPL source cannot be
folded into a PolyForm distribution. Beetle's tree contains a complete, correct,
well-tested reverb — so the obvious implementation route is closed, and two
contributors independently walked into it:
- **PR #16** (Martin Penkava) implemented all of this by porting Beetle. Parked
on license grounds after a line-by-line audit found it tracked
`beetle-psx/mednafen/psx/spu.cpp` lines 590–730 in register layout, resampling
coefficients, buffer layout, arithmetic and processing order. See
`docs/internal/upstream/martin-pr16-spu-reverb.md` on branch
`audit/pr16-spu-reverb-gpl-lineage-mpenkava`.
- **PR #13** (parked at `5fc8e15d`) shipped a reverb too, rejected for being
*wrong* rather than for licensing: it gated reverb on bit 15 of `0x1F801DC0`,
which is `dAPF1` — an all-pass *offset* register 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 (`0x1F801DAA`) bit 7. It also read
`dAPF2` / `vIIR` / `vCOMB1` as "feedback" / "wet level" / "early-reflection
level"; they are none of those.
The PR #16 audit names the only acceptable path, and this branch takes it: **a
clean-room implementation from hardware documentation, with independently
derived tests.** The psx-spx / nocash register map and documented algorithm were
the source. Beetle's source was deliberately not consulted for the
implementation.
Beetle remains fully usable as a **runtime oracle** — running the binary and
comparing its audio output is not a derivative work, and that comparison is how
the remaining uncertainties in §5 get settled. `audio_wav` and `audio_stats`
exist on both `psx-runtime` and `psx-beetle`, so the comparison is a symmetric
always-on ring query, not an arm-then-capture.
### Pre-existing lineage exposure — reported, not changed
Separate from this work, and worth a decision:
- `runtime/src/spu.c` `calc_vc_delta()` is commented *"Ported verbatim from
Beetle's CalcVCDelta"*. This is the ADSR rate decoder, and it is in every
released binary. Same category as PR #16, already shipped.
- `runtime/include/spu_gauss.h` cites *"No$PSX docs / DuckStation
core/spu.cpp"*.
Deliberately left alone here: rewriting the ADSR rate decoder clean-room changes
every voice envelope in every title and needs its own revalidation campaign, not
a rider on this branch.
## 4. `feat/pr13-salvage-remainder` is fully superseded — no surgery needed
Prior notes flagged this branch as "ready to land" while Issue #103 says its
reverb must stay parked, and treated reconciling that as a blocker. **It is not
a blocker: the branch retains nothing but the rejected reverb commit.**
`git cherry -v origin/master feat/pr13-salvage-remainder`:
```
- e9a8a24e interp: deliver alignment exceptions, protect Cause read-only bits, add CFC0
- 3ce30155 dma: implement channel 5 (PIO / expansion port) transfers
+ 5fc8e15d spu: reverb, noise LFSR, sweep volumes, pitch modulation, SPU IRQ
- 9f3cd0a3 autocompile: name the resolved interpreter at configure, warn on Cygwin builds
```
Three of four are already upstream (`-`). Verified four independent ways rather
than trusting patch-id alone:
- cherry-picking all three onto current `origin/master` produced **empty**
commits;
- `execute_ch5_pio()` is present in `runtime/src/dma.c` on master;
- `CFC0` handling and the `& ~0x0300u | (val & 0x0300u)` Cause write-protection
are present in `runtime/src/dirty_ram_interp.c` on master;
- `git diff origin/master 9f3cd0a3 -- runtime/src/autocompile.c` is **empty**.
Consequences: there is no salvage left to preserve, no git surgery to perform,
and the "two competing SPU IRQ implementations" question resolves by default —
`5fc8e15d` is both superseded and Beetle-derived. The branch should be
abandoned and PR #13 closed. `origin/park/pr13-spu-reverb-rejected` still
retains the commit, so nothing is lost.
Incidentally, `e9a8a24e`'s Cause write-protection (guest `MTC0` may only write
bits 8–9) is exactly what makes §2a safe: the guest cannot forge IP2.
## 5. Known deviations and the oracle-verification queue
Recorded explicitly rather than presented as exact (Rule 14).
- **22.05 → 44.1 kHz reverb reconstruction filter.** The reverb engine runs at
22050 Hz, but the hardware's reconstruction filter is not specified in the
documentation used. Beetle's 39-tap FIR was deliberately not copied. The
implementation uses a clearly-marked, single-function reconstruction so the
filter can be swapped once measured against the oracle. **This is the one
place the implementation is known to differ from hardware.**
- Further per-feature judgement calls are listed in §6 as reported by the
implementation pass; each is a candidate for oracle comparison.
## 6. Implementation judgement calls
Every point where the documentation ran out. Each is an oracle-comparison
candidate, NOT a settled fact. Listed so a later measurement pass has a work
list instead of having to rediscover them.
1. **Reverb 22.05 ↔ 44.1 kHz boundary** (the flagged known gap): input is the
box average of the frame pair, output is linear interpolation between
successive engine results. Isolated in `rev_reconstruct()` plus one call
site. **The one deliberate deviation from hardware.**
2. **Noise divider wiring**: down-counter decremented by step `4 + SPUCNT[9:8]`
per 44.1 kHz cycle, reloaded with `0x20000 >> shift`. The least-documented
part of the noise generator.
3. **Register-write IRQ compare sites**: the transfer-address write, the
IRQ-address write and an SPUCNT-enable re-check all compare against the
*resting* transfer address immediately. Consequence: enabling the IRQ while
both registers are 0 fires immediately (unit 0 == unit 0). Real drivers set
the address first.
4. **Suppressed reverb writes** (SPUCNT bit 7 clear) do not run the IRQ compare,
since no access occurs. Reads always do.
5. **Sweep negative-phase semantics**: the envelope works on a 0..0x7FFF value
in the phase's domain; wrong-sign levels clamp to 0 first. A sweep-mode
register read returns the live level as signed 16-bit.
6. **Reverb send taps**: post-envelope and post-voice-volume for EON voices;
post-CD-volume for the CD send.
7. **Capture values**: CD captured pre-CD-volume (raw input bus), voice 1/3
post-envelope pre-voice-volume. The CD bus now **pops continuously while the
SPU is enabled** (SPUCNT bit 0 gates only mix/reverb), needed for capture
correctness. This is a behaviour change — measured as *not* causing
starvation, see §7.
8. **Mix saturation order**: the summed bus (dry + CD + wet) saturates to 16-bit
*before* main volume, per the documented mixer order. Replaces previous
unclamped, int32-overflow-prone math. Audible only in loud scenes.
9. **All-disabled gating**: capture / noise / reverb / sweeps run only while
SPUCNT bit 15 is set; whether they free-run with the SPU disabled is
undocumented. Noise clocks after the voice walk within a frame.
10. **Shadow tap and `SpuDebugInfo` volume fields** keep the historical 1.14
scale (`level >> 1`) so `spu_shadow.c` and old captures stay byte-compatible
for direct-mode volumes.
## 7. Validation status
- [x] `test_cause_ip2_combinational` passes (`-Wall -Wextra -Werror`).
- [x] `test_spu_fidelity` passes — 137 checks, 0 failures. Independently re-run,
not taken from the implementation report.
- [x] All changed translation units compile clean; `psx-runtime` and
`Tomba2Recomp` both link.
- [x] **BIOS LLE boot proves the reverb engine runs.** The BIOS itself sets
SPUCNT bit 7 (`ctrl = 0xC085`) and allocates a 62 KB work area at
`mBASE = 0x70940`. Sampled through the boot chime: the work area holds
256/256 non-zero reflection halfwords peaking at 24681, `reverb_cur`
advances and laps correctly inside `[mBASE, 0x80000)`, and after the dry
signal goes silent (output peak → 0 at t≈6.8 s) the work area keeps
ringing down (628 → 18 → 2) for ~3 s. A decaying tail that outlives its
input is the reverb working. Noise LFSR observed shifting with correct
parity feedback (`000F → 001F → … → FFA3`).
- [x] **Tomba 2 intro FMV — measured twice, because the first attempt was on a
broken setup. Recording both so the mistake is not repeated.**
*Attempt 1 (INVALID as an absolute measurement).* A fresh worktree has a
cold overlay shard cache, and `autocompile` failed 8/8 with `WinError 2`:
`game.toml` points at `psxrecomp-v4/recompiler/build-t2/psxrecomp-game.exe`
while the documented build recipe puts the recompiler at
`build-recompiler/` in the worktree root. No shards were ever built and the
runtime silently fell back to the interpreter (`dispatch_native = 0`,
`native_share = 0.0`). The resulting "40 fps, ~19 underruns/s" was my setup,
NOT a property of master. Fixed by building the recompiler at the path
`game.toml` expects; autocompile then reports `fails=0`, `shard_ok` rising,
and the cache grows.
*Attempt 2 (valid).* Ran the SPU build against the fully warm cache in
`_wt-t2-measure`, then the original pre-SPU exe in the same worktree with
the same cache as a control:
| Metric | Control (no SPU work) | SPU branch |
| --- | --- | --- |
| `native_share` (by time) | 0.1558 | 0.1704 |
| `interp_share` | 0.7813 | 0.7775 |
| `exc_share` | 0.2386 | 0.2381 |
| p50 fps | 39.1 | 38.0-38.4 |
| native/interp dispatch | 96.6% native | 95.9% native |
Identical within noise, so the added per-frame DSP work costs nothing
measurable. Reverb/noise state observed live; peak rose 23066 -> 26876
(wet adding to dry, still under 32767 so not clipping); `cd_underflow`
flat at 441 on both, so the continuous CD-bus drain does not starve it.
FMV video confirmed correct by debug-server screenshot (320x224).
**Pre-existing defect characterised, not caused here:** Tomba 2's intro FMV
is 96% native by DISPATCH COUNT but 78% interpreted by WALL CLOCK, with 24%
of time in exception handling, giving ~39 fps and continuous host-sink
underruns even with a fully warm cache. A few interpreted blocks consume
most of the frame. Worth its own issue.
- [ ] Oracle audio comparison against `psx-beetle` at a fixed scene — settles
the §6 items, especially (1). `audio_wav`/`audio_stats` exist on both
ports, so this is a symmetric always-on ring query. **Not yet done.**
- [ ] User ear-validation (final gate).
## 9. Merge prerequisites
- **Rebase required.** This branch is based on `7f7fbc8e`; master moved during
the session (observed `9217ae2`, alpha-258). Re-fetch and rebase; do not
merge stale.
- **`recomp-ui` must be bumped in lockstep.** Framework master's
`feat(mods): expose linked package attribution` needs recomp-ui `99558ee`
(`feat(mods): link package authors and sources`), which adds `author_links` /
`author_link_count` / `source_name` / `source_url` to
`RecompLauncherCModPackage`. Tomba 2's pin at `854ae10` is one commit short
and **fails to compile** with `'RecompLauncherCModPackage' has no member named
'author_link_count'`. This is unrelated to the SPU work but blocks any title
build against current master.
- Every title needs regen + revalidation, because CAUSE.IP2 touches core IRQ
delivery for all of them.
## 8. Incidental fix — `tools/embed_spirv.py` build race
Not SPU-related, found while building. Two runtime targets (`psx-runtime`,
`psx-oracle`) embed the same shader set, so ninja runs `embed_spirv.py`
concurrently. It wrote its intermediate SPIR-V to `<source>.spv` **in the source
tree**, so the two invocations raced on one path and whichever finished first
`os.remove()`d the file the other was about to read — a hard build failure
(`FileNotFoundError: .../blit.frag.spv`), and source-tree pollution besides.
Now compiled to a private `mkdtemp` directory, with the output header written
via atomic replace and a loud error if `glslc` reports success but produces no
file. Fixed per Rule 15 rather than worked around.