Merge pull request #104 from mstan/feat/spu-fidelity-103

spu: reverb, noise, sweeps and SPU IRQ (closes #103); CAUSE.IP2 combinational (supersedes #102)
This commit is contained in:
Matt Stanley
2026-08-05 18:34:11 -07:00
committed by GitHub
14 changed files with 2180 additions and 140 deletions
+369
View File
@@ -0,0 +1,369 @@
# 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. Two build traps hit on the way (neither SPU-related)
**`tools/embed_spirv.py` race — fixed upstream, our fix dropped.** Two runtime
targets embed the same shader set, so ninja runs the script concurrently; it
wrote its intermediate SPIR-V to `<source>.spv` in the source tree, so the two
invocations raced and one `os.remove()`d the file the other was reading
(`FileNotFoundError: .../blit.frag.spv`). We fixed it locally with `mkdtemp`,
but master fixed the same bug independently using
`tempfile.TemporaryDirectory` — which is better, since it also cleans up when
an error path returns early. **The rebase conflict was resolved in master's
favour and our redundant fix removed**, keeping this branch to SPU work.
**Autocompile silently falling back to the interpreter.** A fresh title
worktree can run 100% interpreted without saying so. `game.toml`'s
`overlay_autocompile_cmd` names a specific recompiler path, and if it is not
there, autocompile fails every attempt and no shard is ever built — the runtime
just interprets. It cost a whole invalid performance measurement here (§7).
- Tomba 2 expects `psxrecomp-v4/recompiler/build-t2/psxrecomp-game.exe`, while
the documented recipe builds the recompiler at `build-recompiler/` in the
worktree root. Result: `runs=8 fails=8 shard_ok=0`, `dispatch_native = 0`.
- MMX6 instead hardcodes an **absolute** path into the main repo
(`F:/Projects/psxrecomp/psxrecomp/recompiler/build/psxrecomp-game.exe`),
which happens to exist with a matching codegen hash — so it works by luck of
local layout and would fail on any other machine.
Check `autocompile_status` (`fails`, `shard_ok`) before trusting ANY performance
number from a fresh worktree. Master commit `42a69624` moves in this direction
for `regen_bios`; the same diagnosis is wanted for the autocompile path.
+15
View File
@@ -24,6 +24,12 @@ if(BUILD_TESTING)
add_test(NAME launcher_device_roundtrip_test
COMMAND launcher_device_roundtrip_test)
# COP0.CAUSE IP2 must mirror the INTC line combinationally, never latch.
add_executable(cause_ip2_combinational_test
tests/test_cause_ip2_combinational.c)
add_test(NAME cause_ip2_combinational_test
COMMAND cause_ip2_combinational_test)
add_executable(psx_cyc_batch_test tests/test_psx_cyc_batch.c)
target_include_directories(psx_cyc_batch_test PRIVATE include)
target_compile_definitions(psx_cyc_batch_test PRIVATE
@@ -82,6 +88,15 @@ if(BUILD_TESTING)
add_test(NAME mod_gpu_dma_aperture_test
COMMAND mod_gpu_dma_aperture_test)
# Clean-room SPU DSP fidelity (issue #103): SPU IRQ on every RAM access
# class, reverb (write gating + work-area wrap + CD-only path), noise
# generator, volume sweeps / direct decode, capture buffers, and
# savestate coverage of all the new state. Includes src/spu.c directly
# (white-box) and stubs its externs.
add_executable(spu_fidelity_test tests/test_spu_fidelity.c)
target_include_directories(spu_fidelity_test PRIVATE include)
add_test(NAME spu_fidelity_test COMMAND spu_fidelity_test)
add_executable(overlay_capture_retry_test
tests/test_overlay_capture_retry.cpp
src/overlay_capture.c)
+1
View File
@@ -60,6 +60,7 @@ enum {
stage 0=post-ADPCM-decode (native rate),
stage 1=post-resample+volume (44100). */
AUDIO_EV_SINK_DROP = 10, /* turbo host sink; a=guest SPU frames discarded */
AUDIO_EV_DMA_READ = 11, /* SPU RAM -> CPU DMA; a=words, b=dest RAM addr */
};
typedef struct {
+25 -4
View File
@@ -49,6 +49,8 @@ typedef struct SpuVoiceState {
uint16_t phase; /* sub-sample phase counter (0..0x1000) */
uint16_t env_level; /* live ADSR envelope level (0..0x7FFF) */
uint8_t adsr_phase; /* 0=attack 1=decay 2=sustain 3=release */
int16_t vol_cur_l; /* live effective L volume (sweep-aware, full int16) */
int16_t vol_cur_r; /* live effective R volume (sweep-aware, full int16) */
} SpuVoiceState;
typedef struct SpuGlobalState {
@@ -62,6 +64,19 @@ typedef struct SpuGlobalState {
uint32_t eon; /* reverb mode (0x1F801D98/9A) */
uint32_t endx; /* end-block reached latch (0x1F801D9C/9E) */
uint32_t active_mask; /* recomp-local "still voicing" mask */
/* ---- SPU DSP fidelity state (issue #103: IRQ/reverb/noise/sweeps) ---- */
uint16_t irq_flag; /* SPU IRQ latch (mirrors SPUSTAT bit 6) */
uint16_t reverb_on; /* SPUCNT bit 7 (reverb work-area write enable) */
uint32_t irq_addr; /* IRQ address as a BYTE address (reg 0x1F801DA4 << 3) */
uint32_t reverb_mbase; /* reverb work area start, byte address (mBASE << 3) */
uint32_t reverb_cur; /* current reverb buffer address (byte, advances @22050Hz) */
uint32_t capture_pos; /* capture-buffer write offset (0..0x3FE, wraps at 0x400) */
uint16_t noise_lfsr; /* live noise shift register value */
uint16_t noise_pad; /* reserved / alignment */
uint32_t sweep_l_mask; /* voices whose LEFT volume register is in sweep mode */
uint32_t sweep_r_mask; /* voices whose RIGHT volume register is in sweep mode */
uint32_t sweep_main; /* bit0 = main L in sweep mode, bit1 = main R */
} SpuGlobalState;
void spu_get_voice_state(int voice, SpuVoiceState* out);
@@ -76,7 +91,10 @@ typedef enum {
SPU_EV_KEYON = 1,
SPU_EV_KEYOFF = 2,
SPU_EV_END_STOP = 3, /* loop_end without repeat → voice silenced */
SPU_EV_END_LOOP = 4 /* loop_end with repeat → cur_addr=repeat_addr */
SPU_EV_END_LOOP = 4, /* loop_end with repeat → cur_addr=repeat_addr */
SPU_EV_IRQ = 5 /* SPU RAM IRQ-address hit → I_STAT bit 9 raised;
voice=0xFF (not voice-attributable), addr=byte
address that matched */
} SpuEventKind;
typedef struct SpuEvent {
@@ -100,8 +118,11 @@ void spu_event_reset(void);
uint32_t spu_read(uint32_t addr);
void spu_write(uint32_t addr, uint32_t value);
/* DMA channel 4 interface */
/* DMA channel 4 interface. spu_dma_read reads one 32-bit word from SPU RAM
* at the current transfer address, advances the address by 4, and runs the
* SPU IRQ-address check (SPU RAM -> CPU direction, DICR direction bit 0). */
void spu_dma_write(uint32_t word);
uint32_t spu_dma_read(void);
int spu_dma_ready(void);
/* CD-ROM XA/CDDA input path. Samples are stereo 44.1 kHz PCM entering the
@@ -126,8 +147,8 @@ typedef struct {
int16_t s[4]; /* decoded samples sample_idx-1 .. +2 (block-edge clamped) */
float frac; /* fractional sub-sample phase in [0,1) */
uint16_t env; /* env_level (0..0x7FFF) */
int16_t vol_l; /* per-voice L volume (direct_volume-decoded) */
int16_t vol_r; /* per-voice R volume */
int16_t vol_l; /* per-voice L volume, 1.14 scale (effective int16 >> 1) */
int16_t vol_r; /* per-voice R volume, same scale */
uint8_t active;
} SpuShadowVoiceTapPub;
+50 -8
View File
@@ -6047,6 +6047,13 @@ static void handle_spu_status(int id, const char *json)
(void)json;
SpuDebugInfo info;
spu_debug_info(&info);
/* The DSP-fidelity state (issue #103: SPU IRQ, reverb, noise, sweeps) lives
* in SpuGlobalState. Surfaced here so the whole SPU can be judged from one
* always-on query — without it there is no way to tell whether the reverb
* engine is actually stepping, whether the IRQ is armed, or which volume
* registers are sweeping. */
SpuGlobalState g;
spu_get_global_state(&g);
send_fmt("{\"id\":%d,\"ok\":true,"
"\"ctrl\":\"0x%04X\",\"active_mask\":\"0x%06X\","
"\"main_l\":%d,\"main_r\":%d,"
@@ -6055,7 +6062,15 @@ static void handle_spu_status(int id, const char *json)
"\"render_frames\":%llu,\"nonzero_frames\":%llu,"
"\"last_peak\":%d,\"peak\":%d,"
"\"cd_frames\":%u,\"cd_push_frames\":%llu,"
"\"cd_overflow_frames\":%llu,\"cd_underflow_frames\":%llu}",
"\"cd_overflow_frames\":%llu,\"cd_underflow_frames\":%llu,"
"\"pmon\":\"0x%06X\",\"non\":\"0x%06X\",\"eon\":\"0x%06X\","
"\"endx\":\"0x%06X\","
"\"irq_flag\":%u,\"irq_addr\":\"0x%05X\","
"\"reverb_on\":%u,\"reverb_mbase\":\"0x%05X\","
"\"reverb_cur\":\"0x%05X\",\"capture_pos\":\"0x%03X\","
"\"noise_lfsr\":\"0x%04X\","
"\"sweep_l_mask\":\"0x%06X\",\"sweep_r_mask\":\"0x%06X\","
"\"sweep_main\":%u}",
id,
info.ctrl & 0xFFFFu,
info.active_mask & 0xFFFFFFu,
@@ -6071,7 +6086,21 @@ static void handle_spu_status(int id, const char *json)
info.cd_frames,
(unsigned long long)info.cd_push_frames,
(unsigned long long)info.cd_overflow_frames,
(unsigned long long)info.cd_underflow_frames);
(unsigned long long)info.cd_underflow_frames,
g.pmon & 0xFFFFFFu,
g.non & 0xFFFFFFu,
g.eon & 0xFFFFFFu,
g.endx & 0xFFFFFFu,
(unsigned)g.irq_flag,
g.irq_addr & 0xFFFFFu,
(unsigned)g.reverb_on,
g.reverb_mbase & 0xFFFFFu,
g.reverb_cur & 0xFFFFFu,
g.capture_pos & 0xFFFu,
(unsigned)g.noise_lfsr,
g.sweep_l_mask & 0xFFFFFFu,
g.sweep_r_mask & 0xFFFFFFu,
(unsigned)g.sweep_main);
}
/* ---- Per-voice SPU snapshot. Mirrors fields the Beetle oracle exposes
@@ -6087,7 +6116,9 @@ static void handle_spu_voices(int id, const char *json)
SpuGlobalState g;
spu_get_global_state(&g);
size_t cap = 8192;
/* 24 voices x ~280 chars + header. Headroom matters: snprintf would silently
* truncate mid-object and hand the caller unparseable JSON. */
size_t cap = 16384;
char *out = (char *)malloc(cap);
if (!out) { send_fmt("{\"id\":%d,\"ok\":false,\"err\":\"alloc\"}", id); return; }
size_t off = 0;
@@ -6116,7 +6147,11 @@ static void handle_spu_voices(int id, const char *json)
"\"adsr_lo\":\"0x%04X\",\"adsr_hi\":\"0x%04X\","
"\"cur_addr\":\"0x%05X\",\"repeat_addr\":\"0x%05X\","
"\"flags\":\"0x%02X\",\"sample_idx\":%d,\"phase\":\"0x%04X\","
"\"env\":\"0x%04X\",\"env_phase\":%d}",
"\"env\":\"0x%04X\",\"env_phase\":%d,"
/* Live effective volumes. For a sweeping register (bit 15 set) the
* vol_l/vol_r control words above say nothing about the current
* level, so these are the only way to see a sweep actually glide. */
"\"vol_cur_l\":%d,\"vol_cur_r\":%d}",
v == 0 ? "" : ",",
v, s.active,
s.vol_ctrl_l, s.vol_ctrl_r,
@@ -6126,7 +6161,8 @@ static void handle_spu_voices(int id, const char *json)
s.adsr_lo, s.adsr_hi,
s.cur_addr, s.repeat_addr,
s.last_flags, s.sample_idx, s.phase,
s.env_level, s.adsr_phase);
s.env_level, s.adsr_phase,
s.vol_cur_l, s.vol_cur_r);
if (n > 0) off += (size_t)n;
}
n = snprintf(out + off, cap - off, "]}");
@@ -6155,7 +6191,7 @@ static void handle_spu_ram(int id, const char *json)
}
/* ---- SPU event ring dump. Returns the most recent N events
* (KEYON / KEYOFF / END_STOP / END_LOOP) with frame timestamps. */
* (KEYON / KEYOFF / END_STOP / END_LOOP / IRQ) with frame timestamps. */
static void handle_spu_events(int id, const char *json)
{
int count = json_get_int(json, "count", 256);
@@ -6165,7 +6201,12 @@ static void handle_spu_events(int id, const char *json)
if (!evs) { send_fmt("{\"id\":%d,\"ok\":false,\"err\":\"alloc\"}", id); return; }
uint32_t got = spu_event_get(evs, (uint32_t)count);
uint64_t total = spu_event_total();
static const char *kind_names[5] = { "?", "KEYON", "KEYOFF", "END_STOP", "END_LOOP" };
/* Index by SpuEventKind (spu.h). IRQ (=5) is not voice-attributable; the
* ring stores voice=0xFF for it and `addr` is the byte address that matched
* the programmed IRQ address. Keep this table in step with SpuEventKind or
* a new kind renders as "?". */
static const char *kind_names[6] = { "?", "KEYON", "KEYOFF", "END_STOP",
"END_LOOP", "IRQ" };
/* Worst case ~200 chars per event; 64 KB is plenty for 4096 events. */
size_t cap = 256u + (size_t)got * 256u;
@@ -6178,7 +6219,8 @@ static void handle_spu_events(int id, const char *json)
if (n > 0) off += (size_t)n;
for (uint32_t i = 0; i < got; i++) {
const SpuEvent *e = &evs[i];
const char *kn = (e->kind <= 4) ? kind_names[e->kind] : "?";
const char *kn = (e->kind < sizeof(kind_names) / sizeof(kind_names[0]))
? kind_names[e->kind] : "?";
n = snprintf(out + off, cap - off,
"%s{\"seq\":%llu,\"frame\":%u,\"kind\":\"%s\",\"v\":%d,"
"\"pitch\":\"0x%04X\",\"addr\":\"0x%05X\","
+10 -1
View File
@@ -798,10 +798,19 @@ static uint32_t execute_ch4_spu(void) {
audio_trace_event(AUDIO_EV_DMA_WRITE, total_words,
channels[4].madr & 0x1FFFFCu);
} else {
/* SPU RAM -> CPU RAM. This direction previously zero-filled the
* destination, which is not a transfer at all: SPU RAM is readable
* memory and games do read it back. Titles that carry state through
* SPU RAM across an Exec boundary (a checksummed block surviving an
* EXE swap, since SPU RAM is one of the few regions main RAM's reload
* does not touch) got zeros, failed their own integrity check, and
* fell back to a cold-boot path. */
for (uint32_t i = 0; i < total_words; i++) {
psx_write_word(addr, 0);
psx_write_word(addr, spu_dma_read());
addr = (addr + addr_step) & 0x1FFFFCu;
}
audio_trace_event(AUDIO_EV_DMA_READ, total_words,
channels[4].madr & 0x1FFFFCu);
}
channels[4].madr = addr;
+92 -9
View File
@@ -115,13 +115,61 @@ extern uint32_t i_mask;
* recorded here with its guest cycle. */
#include "device_trace.h"
/* ---- CAUSE.IP2 is COMBINATIONAL, not latched ---------------------------
*
* On R3000A the Cause.IP field is not storage: it reflects the current state
* of the interrupt input pins. On the PSX only IP2 (bit 10) is wired, and it
* carries the interrupt controller's output line, i.e. (I_STAT & I_MASK) != 0.
* It therefore RISES when a device raises and FALLS the instant the guest acks
* I_STAT or masks the source — with no CPU involvement either way.
*
* This runtime previously only ever OR'd bit 10 in at delivery and never
* cleared it, leaving a phantom IP2 in COP0.CAUSE. A kernel exception
* dispatcher that loops on CAUSE.IP & SR.IM to decide whether to service
* again sees a pending interrupt that no longer exists and can spin in its
* event scan forever.
*
* Verified against the independent Beetle oracle rather than asserted:
* beetle-psx/mednafen/psx/irq.cpp defines
* #define Recalc() PSX_CPU->AssertIRQ(0, (bool)(Status & Mask))
* and calls it from IRQ_Assert (raise), from IRQ_Write for BOTH the Status ack
* and the Mask write, and at power-on; cpu.cpp's AssertIRQ clears bit (10+n)
* unconditionally and re-sets it only when the level is asserted. So the line
* is recomputed at every point (I_STAT & I_MASK) can change, which is exactly
* the set of call sites below.
*
* Ownership: this function is the ONLY writer of CAUSE bit 10. The delivery
* path no longer ORs it in separately — one owner, no divergence.
*
* Derived from PR #102 by Alexandros Mandravillis; the mirror call sites and
* the single-owner refactor are ours. */
static uint32_t *s_cause_ptr;
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);
}
void psx_irq_set_cause_ptr(uint32_t *p)
{
s_cause_ptr = p;
/* Power-on recompute, mirroring Beetle's IRQ_Power() -> Recalc(). Without
* this the first mirror only happens at the first raise/ack, so a CAUSE
* read before any interrupt activity would show a stale bit. */
psx_irq_refresh_cause_ip2();
}
/* Central IRQ-raise choke point. All device sources call this to set their
* I_STAT bit so the device-event ring sees every raise from one place with the
* exact guest cycle. Pure addition over `i_stat |= (1<<bit)` — identical effect
* on i_stat, plus the trace note (no-op unless the ring is armed). */
* exact guest cycle. */
void psx_irq_raise(uint32_t bit, uint32_t detail)
{
i_stat |= (1u << bit);
psx_irq_refresh_cause_ip2();
device_trace_note(bit, detail);
}
@@ -309,6 +357,33 @@ static int should_defer_vblank_for_sio(void) {
return since_progress < VBLANK_DEFER_STALE_CYCLES;
}
/* ---- Mid-dispatch audio pump -------------------------------------------
*
* The SPU is autonomous on real hardware: it keeps consuming samples and
* advancing voice positions while the CPU busy-waits. Our audio pump is driven
* from the main loop between presented frames, so a guest busy-wait that never
* completes a frame starves it and freezes SPU time. That is self-deadlocking
* for any game that waits on an SPU-generated condition — the SPU IRQ it waits
* for needs SPU time to advance, and SPU time only advances when the wait ends.
*
* The VBlank edge is the right place to also pump because it is derived from
* the guest cycle counter, not from host presentation, so it keeps firing
* through such a wait. The pump itself is guest-cycle-budgeted (it renders
* elapsed_cycles/768 frames and carries the remainder), so pumping from both
* here and the main loop produces the same total sample count — the second
* caller simply finds little or no debt outstanding. Both callers are the main
* loop thread, so this is not concurrent with the SDL audio callback, which
* only drains an already-filled ring.
*
* Called at the END of the edge so the VBlank's own IRQ raise and ring records
* complete first: the pump can itself raise an SPU IRQ, and that should land
* after the VBlank edge it followed rather than interleaved into it.
*
* From PR #102 by Alexandros Mandravillis. */
static void (*s_midframe_audio_pump)(void);
void psx_set_midframe_audio_pump(void (*fn)(void)) { s_midframe_audio_pump = fn; }
static void fire_vblank_edge(void) {
/* Subtract one VBlank period rather than reset to 0 so cycle overshoot
* carries forward. Prevents long-running blocks from rounding multiple
@@ -328,6 +403,8 @@ static void fire_vblank_edge(void) {
timers_tick(33868); /* ~1 NTSC frame worth of cycles */
cdrom_tick(); /* Process pending CDROM responses */
#endif
/* Keep SPU time flowing across guest busy-waits (see comment above). */
if (s_midframe_audio_pump) s_midframe_audio_pump();
}
void interrupts_service_scheduled_events(void) {
@@ -1085,14 +1162,20 @@ irq_deliver_eval:
exception_entries_total++;
uint32_t pre_handler_istat = i_stat; /* snapshot for cooldown decision */
/* Set COP0 Cause: ExcCode=0 (interrupt). IP2 reflects the INTC line, so
* set it only when the hardware source is what's being delivered; a pure
* software interrupt must present the guest-written IP0/IP1 bits
* unmodified (the guest's dispatcher discriminates stages by exactly
* these bits — see sw_pending rationale at the top of this function). */
/* Set COP0 Cause: ExcCode=0 (interrupt). The ~0x7C mask deliberately
* preserves the whole IP field, because a pure software interrupt must
* present the guest-written IP0/IP1 bits unmodified (the guest's dispatcher
* discriminates stages by exactly those bits — see the sw_pending rationale
* at the top of this function).
*
* IP2 specifically is NOT set here. It is combinational and has a single
* owner, psx_irq_refresh_cause_ip2(), which already tracks the INTC line at
* every point that line can move. Refreshing rather than OR-ing means a
* delivery that races an ack cannot leave a stale bit behind, and a
* software-interrupt delivery gets IP2 reflecting the true line state
* instead of whatever bit 10 happened to be left as. */
cpu->cop0[COP0_CAUSE] = (cpu->cop0[COP0_CAUSE] & ~0x7C) | (0 << 2);
if (hw_deliverable)
cpu->cop0[COP0_CAUSE] |= (1 << 10);
psx_irq_refresh_cause_ip2();
/* Push SR exception stack: shift bits [5:0] left by 2. */
cpu->cop0[COP0_SR] = (sr & ~0x3F) | ((sr & 0x0F) << 2);
+57
View File
@@ -153,6 +153,9 @@ extern "C" {
/* memory.c */
extern "C" void memory_init(const char* bios_path);
extern "C" void memory_set_sr_ptr(const uint32_t *p);
/* interrupts.c */
extern "C" void psx_irq_set_cause_ptr(uint32_t *p);
extern "C" void psx_set_midframe_audio_pump(void (*fn)(void));
extern "C" uint32_t memory_get_bios_checksum(void);
extern "C" void dirty_ram_register_text_image(uint32_t phys_lo,
const uint8_t *bytes,
@@ -2136,6 +2139,43 @@ static void runtime_perf_diag_tick() {
for (int i = 0; i < 6; i++) last_up[i] = up[i];
}
/* Audio gate state, shared with the mid-frame (VBlank-edge) pump.
*
* sdl_audio_update() below is the sole authority on whether a pump should emit
* audio, discard it, or not run at all. The mid-frame pump exists to keep SPU
* time flowing across guest busy-waits that never present a frame, but it must
* NOT bypass that authority: pumping unconditionally would push real audio
* during a turbo-load hard mute, defeating the mute model (the queue is
* supposed to drain and voice positions freeze in place, so music resumes where
* it left off rather than replaying time-compressed garble), and would emit to
* the device during the discard-only turbo sink.
*
* So the mid-frame pump mirrors whatever the last frame decided. */
enum AudioGate { AUDIO_GATE_NORMAL = 0, AUDIO_GATE_MUTED = 1, AUDIO_GATE_SINK = 2 };
static AudioGate s_audio_gate = AUDIO_GATE_NORMAL;
/* Invoked from the guest-derived VBlank edge (interrupts.c). */
static void sdl_audio_pump_midframe(void) {
if (!sdl_audio_device) return;
switch (s_audio_gate) {
case AUDIO_GATE_MUTED:
/* Deliberately nothing. This preserves the existing, user-validated
* freeze-in-place mute semantics exactly. NOTE a real tension here: the
* SPU is autonomous on hardware and never freezes, so a game that
* busy-waits on an SPU-generated condition *during* a turbo load would
* still stall. No title in our suite is known to do that; recording it
* rather than guessing a fix that would change validated mute audio. */
return;
case AUDIO_GATE_SINK:
sdl_audio_pump(true); /* advance SPU time, discard output */
return;
case AUDIO_GATE_NORMAL:
default:
sdl_audio_pump(false);
return;
}
}
static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) {
if (!sdl_audio_device) return;
{ /* Tag audio events with the vblank frame counter. */
@@ -2171,10 +2211,13 @@ static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) {
}
muted = 1;
}
s_audio_gate = AUDIO_GATE_MUTED;
hangover = HANGOVER_FRAMES;
return;
}
if (muted) {
/* Still inside the post-mute hangover: the gate stays MUTED so the
* mid-frame pump does not sneak audio out ahead of the unmute ramp. */
if (hangover > 0) { hangover--; return; }
muted = 0;
sdl_audio_fadein_left = sdl_audio_fade_samples;
@@ -2188,6 +2231,7 @@ static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) {
audio_trace_event(AUDIO_EV_MUTE, 0, 2); /* b=2: discard-only sink */
}
g_turbo_audio_sink_active = 1;
s_audio_gate = AUDIO_GATE_SINK;
sdl_audio_pump(true);
return;
}
@@ -2199,6 +2243,7 @@ static void sdl_audio_update(int hard_mute_active, int turbo_sink_active) {
(uint32_t)sdl_audio_fadein_left, 2);
g_audio_unmute_resync = 1;
}
s_audio_gate = AUDIO_GATE_NORMAL;
sdl_audio_pump(false);
}
@@ -6812,6 +6857,12 @@ session_reboot:
g_audio_host_rate = have.freq;
audio_trace_set_tap_rate(AUDIO_TAP_HOST, (uint32_t)have.freq);
(void)psx_sdl_audio_resume(sdl_audio_device);
/* Also pump from the guest-derived VBlank edge so SPU time keeps
* advancing across guest busy-waits that never present a frame
* (guest-cycle-budgeted; same thread — see interrupts.c). Routed
* through the gated wrapper so it honours the turbo mute/sink
* state rather than bypassing it. */
psx_set_midframe_audio_pump(sdl_audio_pump_midframe);
}
}
#endif
@@ -7148,6 +7199,12 @@ session_reboot:
/* Let memory subsystem see SR for cache-isolation checks. */
memory_set_sr_ptr(&cpu.cop0[12]);
/* Wire the CAUSE.IP2 mirror. IP2 is combinational on real hardware and has
* to track (I_STAT & I_MASK) through raises, acks and mask writes; this
* hands interrupts.c the one location it is allowed to maintain. Also
* performs the power-on recompute. */
psx_irq_set_cause_ptr(&cpu.cop0[13]);
/* Wire debug server to CPU state for register queries. */
debug_server_set_cpu(&cpu);
+7
View File
@@ -809,17 +809,24 @@ static void imask_trace_record(uint32_t old_val, uint32_t new_val, uint8_t width
* the freeze heartbeat against g_vblank_raise/deliver counts. */
uint64_t g_vblank_ack_count = 0;
/* interrupts.c — CAUSE.IP2 mirrors the INTC line and must be recomputed at
* every point (I_STAT & I_MASK) can change. Both writers below are such a
* point: an ack can drop the line, a mask write can drop or raise it. */
extern void psx_irq_refresh_cause_ip2(void);
static void interrupt_write_stat_masked(uint32_t val, uint32_t mask) {
uint32_t ack_mask = mask & 0x7FFu;
uint32_t before = i_stat;
i_stat = (i_stat & ~ack_mask) | (i_stat & val & ack_mask);
if ((before & 1u) && !(i_stat & 1u)) g_vblank_ack_count++; /* VBLANK bit 1->0 */
psx_irq_refresh_cause_ip2();
}
static void interrupt_write_mask_masked(uint32_t val, uint32_t mask, uint8_t width) {
uint32_t old = i_mask;
i_mask = ((i_mask & ~mask) | (val & mask)) & 0x7FFu;
imask_trace_record(old, i_mask, width);
psx_irq_refresh_cause_ip2();
}
/* Getters for debug server */
+8 -1
View File
@@ -25,6 +25,7 @@
extern uint32_t i_stat;
/* Central IRQ-raise choke point (interrupts.c) — also records the device ring. */
extern void psx_irq_raise(uint32_t bit, uint32_t detail);
extern void psx_irq_refresh_cause_ip2(void); /* interrupts.c — CAUSE.IP2 mirror */
extern uint32_t i_mask;
#define COP0_SR 12
@@ -540,7 +541,13 @@ static void interp_check_interrupts(CPUState* cpu) {
/* Fire interrupt exception.
* Push SR stack, set Cause, set EPC, jump to vector. */
cpu->cop0[COP0_CAUSE] = (cpu->cop0[COP0_CAUSE] & ~0x7Cu) | (0u << 2); /* ExcCode=0 (interrupt) */
cpu->cop0[COP0_CAUSE] |= (1u << 10); /* IP2 */
/* IP2 is combinational — it mirrors the INTC line and is owned solely by
* psx_irq_refresh_cause_ip2() (interrupts.c). Latching it here with |= was
* the same defect as the compiled delivery path had: the bit was set at
* delivery and never cleared, so it survived the guest's I_STAT ack as a
* phantom pending interrupt. We reach this point only when
* (i_stat & i_mask) != 0, so the mirror necessarily asserts it. */
psx_irq_refresh_cause_ip2();
cpu->cop0[COP0_SR] = (sr & ~0x3Fu) | ((sr & 0x0Fu) << 2);
cpu->cop0[COP0_EPC] = cpu->pc;
+769 -117
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -24,6 +24,7 @@ static uint32_t traps_parity_rw(void* ctx, uint32_t addr) {
/* Forward declarations from interrupts.c */
int psx_get_in_exception(void);
void psx_exception_longjmp(void);
void psx_irq_refresh_cause_ip2(void);
/* ── Deterministic TCB scheduler — SCAFFOLDING (plan steps 1-2, INERT) ───────
* These definitions back psx_scheduler.h. They are NOT yet wired into the live
@@ -340,7 +341,14 @@ static uint32_t psx_restore_context_from_tcb(CPUState* cpu, uint32_t tcb)
uint32_t saved_sr = cpu->read_word(save + 140u);
cpu->cop0[12] = (saved_sr & 0xFFFFFFC0u) | ((saved_sr >> 2) & 0x0Fu);
}
/* Restore the saved CAUSE, then re-derive IP2. On hardware the IP field is
* live interrupt-line state and is never storage, so it cannot meaningfully
* be saved and restored: a context captured while an interrupt was pending
* and restored after the ack would re-latch a phantom IP2 out of guest
* memory. Reload the architecturally-saved bits, then let the single owner
* recompute bit 10 from the actual line. */
cpu->cop0[13] = cpu->read_word(save + 144u);
psx_irq_refresh_cause_ip2();
cpu->gpr[26] = cpu->read_word(save + 128u);
psx_assert_no_sentinel_pc("restore_context_from_tcb", tcb, cpu->gpr[26]);
thread_ctx_ring_log(cpu, tcb, cpu->gpr[26], 1);
@@ -0,0 +1,185 @@
/*
* 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;
}
+584
View File
@@ -0,0 +1,584 @@
/*
* test_spu_fidelity.c — clean-room unit tests for the SPU DSP fidelity
* features (GitHub issue #103): SPU IRQ (I_STAT bit 9), reverb, the noise
* generator, volume sweeps, the capture buffers, and savestate coverage of
* all the new state.
*
* Harness pattern: includes spu.c directly (white-box — the tests assert on
* internal engine state like rev_cur and the sweep envelopes as well as on
* guest-visible register reads and rendered PCM) and stubs the few externs
* spu.c pulls in. Written from scratch against the psx-spx hardware
* documentation; no emulator source consulted.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdbool.h>
#include "../src/spu.c"
/* ---- stubs for spu.c externs -------------------------------------------- */
uint64_t s_frame_count = 0;
static uint32_t g_irq_raises = 0;
static uint32_t g_irq_last_bit = 0;
static uint32_t g_irq_last_detail = 0;
void psx_irq_raise(uint32_t bit, uint32_t detail) {
g_irq_raises++;
g_irq_last_bit = bit;
g_irq_last_detail = detail;
}
uint64_t psx_get_cycle_count(void) { return 0; }
void audio_trace_pcm(int tap, const int16_t *stereo, int frames) {
(void)tap; (void)stereo; (void)frames;
}
void audio_trace_event(uint16_t kind, uint32_t a, uint32_t b) {
(void)kind; (void)a; (void)b;
}
bool spu_shadow_enabled(void) { return false; }
void spu_shadow_reset(void) {}
void spu_shadow_process(int16_t *canon, int frames) { (void)canon; (void)frames; }
/* ---- tiny check harness -------------------------------------------------- */
static int g_checks = 0;
static int g_fails = 0;
#define CHECK(cond) do { \
g_checks++; \
if (!(cond)) { \
g_fails++; \
printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
} \
} while (0)
/* ---- helpers -------------------------------------------------------------- */
#define R_SPUCNT 0x1F801DAAu
#define R_SPUSTAT 0x1F801DAEu
#define R_IRQADDR 0x1F801DA4u
#define R_XFERADDR 0x1F801DA6u
#define R_FIFO 0x1F801DA8u
#define R_KON_LO 0x1F801D88u
#define R_NON_LO 0x1F801D94u
#define R_EON_LO 0x1F801D98u
#define R_MAINVOLL 0x1F801D80u
#define R_MAINVOLR 0x1F801D82u
#define R_CDVOLL 0x1F801DB0u
#define R_CDVOLR 0x1F801DB2u
static void wr(uint32_t addr, uint16_t v) { spu_write(addr, v); }
static uint16_t rd(uint32_t addr) { return (uint16_t)spu_read(addr); }
static int16_t g_render_buf[4096 * 2];
static void render_n(int frames) {
while (frames > 0) {
int n = frames > 4096 ? 4096 : frames;
spu_render(g_render_buf, n);
frames -= n;
}
}
static void push_cd_const(int frames, int16_t l, int16_t r) {
static int16_t buf[1024 * 2];
while (frames > 0) {
int n = frames > 1024 ? 1024 : frames;
for (int i = 0; i < n; i++) { buf[i * 2] = l; buf[i * 2 + 1] = r; }
spu_cd_audio_push(buf, n);
frames -= n;
}
}
/* voice register byte addresses: base + 2*reg (reg: 0=VOLL 1=VOLR 2=PITCH
* 3=START 4=ADSRLO 5=ADSRHI 6=CURVOL 7=LOOP) */
static uint32_t vreg(int v, int reg) {
return 0x1F801C00u + (uint32_t)(v * 8 + reg) * 2u;
}
static void setup_voice(int v, uint16_t start_unit, uint16_t voll, uint16_t volr) {
wr(vreg(v, 0), voll);
wr(vreg(v, 1), volr);
wr(vreg(v, 2), 0x1000); /* pitch 1.0 */
wr(vreg(v, 3), start_unit);
wr(vreg(v, 4), 0x0000); /* Ar=0 (fastest attack) */
wr(vreg(v, 5), 0x0000);
}
static int ram_region_zero(uint32_t start, uint32_t end) {
for (uint32_t a = start; a < end; a++)
if (spu_ram[a]) return 0;
return 1;
}
static int buf_has_nonzero(const int16_t *buf, int samples) {
for (int i = 0; i < samples; i++)
if (buf[i]) return 1;
return 0;
}
/* ==== 1. SPU IRQ ============================================================ */
/* FIFO, DMA-write, DMA-read, transfer-address-write and SPUCNT-write access
* sites; latch/ack/re-arm behaviour; SPUSTAT bit 6 mirror. */
static void test_irq_transfer_paths(void) {
spu_init();
g_irq_raises = 0;
/* Program the IRQ address and transfer address BEFORE enabling the IRQ:
* enabling with both still at their reset value of 0 is itself a
* (correct) unit-0 match under the enable-time re-check. */
wr(R_SPUCNT, 0x8000); /* SPU enable, IRQ still off */
wr(R_IRQADDR, 0x0600); /* IRQ at byte 0x3000 */
wr(R_XFERADDR, 0x05FF); /* transfer addr byte 0x2FF8: no match */
wr(R_SPUCNT, 0x8040); /* IRQ enable; resting unit 0x5FF: no fire */
CHECK(g_irq_raises == 0);
/* DMA writes advance 0x2FF8 -> 0x3000; the third word touches 0x3000. */
spu_dma_write(0xAABBCCDDu);
spu_dma_write(0x11223344u);
CHECK(g_irq_raises == 0);
spu_dma_write(0x55667788u);
CHECK(g_irq_raises == 1);
CHECK(g_irq_last_bit == 9);
CHECK(g_irq_last_detail == 0x3000u);
CHECK((rd(R_SPUSTAT) & 0x40u) != 0);
/* Latched: another hit does not re-raise until acknowledged. */
spu_dma_write(0x99999999u); /* 0x3004..0x3007, still unit 0x600 */
CHECK(g_irq_raises == 1);
/* Acknowledge: SPUCNT write with bit 6 clear drops SPUSTAT bit 6. */
wr(R_SPUCNT, 0x8000);
CHECK((rd(R_SPUSTAT) & 0x40u) == 0);
/* Re-arm with transfer address resting at a NON-matching unit. */
wr(R_SPUCNT, 0x8040); /* transfer now 0x3008 (unit 0x601): no fire */
CHECK(g_irq_raises == 1);
/* Transfer-address write pointing AT the IRQ address is a compare site. */
wr(R_XFERADDR, 0x0600);
CHECK(g_irq_raises == 2);
CHECK((rd(R_SPUSTAT) & 0x40u) != 0);
/* SPUCNT write with bit 6 set is a compare site too (transfer address
* still resting on the IRQ unit). */
wr(R_SPUCNT, 0x8000); /* ack */
wr(R_SPUCNT, 0x8040); /* re-enable -> immediate re-hit */
CHECK(g_irq_raises == 3);
/* Manual FIFO writes: park below the IRQ unit while IRQ is acked, walk
* up to it, and the store that touches byte 0x3000 fires. */
wr(R_SPUCNT, 0x8000); /* ack + disable */
wr(R_XFERADDR, 0x05FF); /* byte 0x2FF8 */
wr(R_SPUCNT, 0x8040); /* unit 0x5FF: no immediate fire */
CHECK(g_irq_raises == 3);
wr(R_FIFO, 0x1111); /* 0x2FF8 */
wr(R_FIFO, 0x2222); /* 0x2FFA */
wr(R_FIFO, 0x3333); /* 0x2FFC */
wr(R_FIFO, 0x4444); /* 0x2FFE */
CHECK(g_irq_raises == 3);
wr(R_FIFO, 0x5555); /* 0x3000 -> fire */
CHECK(g_irq_raises == 4);
/* DMA read direction: data comes back LE and the access fires the IRQ. */
wr(R_SPUCNT, 0x8000); /* ack + IRQ off (no checks while moving) */
wr(R_XFERADDR, 0x05FF); /* byte 0x2FF8 */
CHECK(spu_dma_read() == 0x22221111u); /* FIFO halfwords 1111,2222 */
CHECK(spu_dma_read() == 0x44443333u); /* FIFO halfwords 3333,4444 */
wr(R_IRQADDR, 0x0601); /* byte 0x3008 */
wr(R_SPUCNT, 0x8040); /* transfer at 0x3000 (unit 0x600): no fire */
CHECK(g_irq_raises == 4);
/* 0x3000: FIFO halfword 5555 over the DMA word 0x55667788's top half. */
CHECK(spu_dma_read() == 0x55665555u);
CHECK(g_irq_raises == 4);
(void)spu_dma_read(); /* 0x3004 */
CHECK(g_irq_raises == 4);
(void)spu_dma_read(); /* 0x3008 -> fire */
CHECK(g_irq_raises == 5);
CHECK(g_irq_last_detail == 0x3008u);
}
/* SPUCNT bit 6 clear => no IRQ, ever, from any access. */
static void test_irq_disabled_never_fires(void) {
spu_init();
g_irq_raises = 0;
wr(R_SPUCNT, 0x8000); /* SPU enable, IRQ DISABLED */
wr(R_IRQADDR, 0x0600);
wr(R_XFERADDR, 0x0600);
spu_dma_write(0x12345678u);
wr(R_XFERADDR, 0x0600);
wr(R_FIFO, 0xBEEF);
render_n(16); /* capture + reverb accesses also silent */
CHECK(g_irq_raises == 0);
CHECK((rd(R_SPUSTAT) & 0x40u) == 0);
}
/* Voice ADPCM block fetch is a compare site. */
static void test_irq_on_block_fetch(void) {
spu_init();
g_irq_raises = 0;
wr(R_SPUCNT, 0x8000);
wr(R_IRQADDR, 0x0600); /* byte 0x3000 */
wr(R_SPUCNT, 0x8040); /* enable IRQ after aiming it */
CHECK(g_irq_raises == 0);
setup_voice(0, 0x0600, 0x3FFF, 0x3FFF);
wr(R_KON_LO, 0x0001);
render_n(2); /* first sample fetches the block at 0x3000 */
CHECK(g_irq_raises == 1);
CHECK(g_irq_last_detail == 0x3000u);
}
/* Capture-buffer writes are compare sites (IRQ parked in the CD-R ring). */
static void test_irq_on_capture_write(void) {
spu_init();
g_irq_raises = 0;
wr(R_SPUCNT, 0x8000);
wr(R_IRQADDR, 0x0080); /* byte 0x400: CD-R capture ring, offset 0 */
wr(R_SPUCNT, 0x8040);
CHECK(g_irq_raises == 0);
render_n(1);
CHECK(g_irq_raises == 1);
CHECK(g_irq_last_detail == 0x400u);
}
/* Reverb work-area accesses are compare sites (reads happen even with the
* reverb master write-enable OFF). */
static void test_irq_on_reverb_access(void) {
spu_init();
g_irq_raises = 0;
wr(R_SPUCNT, 0x8000); /* enable; SPUCNT.7 (reverb) stays OFF */
wr(R_IRQADDR, 0xFFF0); /* byte 0x7FF80 */
wr(0x1F801DA2u, 0xFFF0); /* mBASE -> work area [0x7FF80, 0x80000) */
wr(R_SPUCNT, 0x8040); /* IRQ on; resting transfer unit 0: no fire */
CHECK(g_irq_raises == 0);
render_n(2); /* one 22050 Hz step -> reads at the base */
CHECK(g_irq_raises == 1);
}
/* ==== 2. Reverb ============================================================= */
static void setup_basic_reverb(void) {
wr(0x1F801DA2u, 0x2000); /* mBASE: byte 0x10000 */
wr(SPU_R_VLIN, 0x7FFF);
wr(SPU_R_VRIN, 0x7FFF);
wr(SPU_R_VIIR, 0x4000);
wr(SPU_R_VWALL, 0x4000);
wr(SPU_R_MLSAME, 0x0004); /* write tap: +32 bytes */
wr(SPU_R_MRSAME, 0x0104);
wr(SPU_R_DLSAME, 0x0006);
wr(SPU_R_DRSAME, 0x0106);
wr(SPU_R_MLCOMB1, 0x0002); /* read tap: +16 bytes (8 steps behind) */
wr(SPU_R_MRCOMB1, 0x0102);
wr(SPU_R_VCOMB1, 0x4000);
wr(SPU_R_VLOUT, 0x7FFF);
wr(SPU_R_VROUT, 0x7FFF);
wr(R_CDVOLL, 0x7FFF);
wr(R_CDVOLR, 0x7FFF);
wr(R_MAINVOLL, 0x3FFF);
wr(R_MAINVOLR, 0x3FFF);
}
/* Work-area WRITES are suppressed while SPUCNT.7 is clear and happen when it
* is set — driven from the CD-audio-only path (zero active voices), which is
* exactly the FMV case the old fast path silently skipped. */
static void test_reverb_write_gating_and_cd_path(void) {
spu_init();
g_irq_raises = 0;
/* SPU enable + CD enable + CD reverb send; reverb write-enable OFF. */
wr(R_SPUCNT, 0x8005);
setup_basic_reverb();
for (int v = 0; v < 24; v++) CHECK(!voices[v].active);
push_cd_const(64, 0x2000, 0x2000);
render_n(64);
/* Reads happened, but nothing may have been written to the work area. */
CHECK(ram_region_zero(0x10000u, 0x10800u));
/* Now enable reverb writes (SPUCNT bit 7). */
wr(R_SPUCNT, 0x8085);
push_cd_const(64, 0x2000, 0x2000);
render_n(64);
CHECK(!ram_region_zero(0x10000u, 0x10800u));
/* Reverb tail: CD input stops; the wet path must keep the output alive
* (this is the reverb-on-CD-only / FMV behaviour in one assert). */
memset(g_render_buf, 0, sizeof(g_render_buf));
spu_render(g_render_buf, 64);
CHECK(buf_has_nonzero(g_render_buf, 64 * 2));
}
/* Reverb addressing wraps inside [mBASE, 0x80000) and never escapes it,
* whatever the offset registers hold. */
static void test_reverb_addressing_wrap(void) {
spu_init();
wr(R_SPUCNT, 0x8085); /* enable + CD + reverb writes (no IRQ) */
wr(0x1F801DA2u, 0xFFF0); /* mBASE byte 0x7FF80: 128-byte area */
wr(SPU_R_VLIN, 0x7FFF);
wr(SPU_R_VRIN, 0x7FFF);
wr(SPU_R_VIIR, 0x4000);
wr(SPU_R_VWALL, 0x4000);
wr(SPU_R_MLSAME, 0x0020); /* +0x100 bytes: exceeds the area, must wrap */
wr(SPU_R_MRSAME, 0x0030);
wr(SPU_R_DLSAME, 0x0040);
wr(SPU_R_DRSAME, 0x0050);
wr(SPU_R_MLAPF1, 0x1234); /* arbitrary large offsets */
wr(SPU_R_MRAPF1, 0x4321);
wr(SPU_R_MLAPF2, 0x7FFF);
wr(SPU_R_MRAPF2, 0x0FFF);
wr(SPU_R_DAPF1, 0x3000);
wr(SPU_R_DAPF2, 0x0800);
wr(SPU_R_VAPF1, 0x3000);
wr(SPU_R_VAPF2, 0x3000);
wr(SPU_R_VLOUT, 0x7FFF);
wr(SPU_R_VROUT, 0x7FFF);
wr(R_CDVOLL, 0x7FFF);
wr(R_CDVOLR, 0x7FFF);
/* Every effective address the engine can form stays inside the area. */
static const uint32_t addr_regs[] = {
SPU_R_MLSAME, SPU_R_MRSAME, SPU_R_DLSAME, SPU_R_DRSAME,
SPU_R_MLAPF1, SPU_R_MRAPF1, SPU_R_MLAPF2, SPU_R_MRAPF2,
};
for (unsigned i = 0; i < sizeof(addr_regs) / sizeof(addr_regs[0]); i++) {
uint32_t ea0 = rev_ea(addr_regs[i], 0);
uint32_t eam = rev_ea(addr_regs[i], -2);
uint32_t eap = rev_ea(addr_regs[i], -((int32_t)RREG(SPU_R_DAPF1) << 3));
CHECK(ea0 >= 0x7FF80u && ea0 < 0x80000u);
CHECK(eam >= 0x7FF80u && eam < 0x80000u);
CHECK(eap >= 0x7FF80u && eap < 0x80000u);
}
/* Run long enough to lap the 128-byte area several times. */
push_cd_const(256, 0x3000, -0x3000);
render_n(256);
CHECK(rev_cur >= 0x7FF80u && rev_cur < 0x80000u);
/* Nothing below the work area was touched (capture rings stay <0x1000). */
CHECK(ram_region_zero(0x7F000u, 0x7FF80u));
/* The area itself was written. */
CHECK(!ram_region_zero(0x7FF80u, 0x80000u));
}
/* ==== 3. Noise generator ==================================================== */
static void test_noise_generator(void) {
/* Control: same voice in ADPCM mode over all-zero SPU RAM is silent. */
spu_init();
wr(R_SPUCNT, 0xB400); /* enable; noise shift=13, step=4 */
wr(R_MAINVOLL, 0x3FFF);
wr(R_MAINVOLR, 0x3FFF);
setup_voice(0, 0x0600, 0x3FFF, 0x3FFF);
wr(R_KON_LO, 0x0001);
memset(g_render_buf, 0, sizeof(g_render_buf));
spu_render(g_render_buf, 512);
CHECK(!buf_has_nonzero(g_render_buf, 512 * 2));
/* Noise mode: same setup + NON bit -> non-silent, non-constant output. */
spu_init();
wr(R_SPUCNT, 0xB400);
wr(R_MAINVOLL, 0x3FFF);
wr(R_MAINVOLR, 0x3FFF);
setup_voice(0, 0x0600, 0x3FFF, 0x3FFF);
wr(R_NON_LO, 0x0001);
wr(R_KON_LO, 0x0001);
memset(g_render_buf, 0, sizeof(g_render_buf));
spu_render(g_render_buf, 512);
CHECK(buf_has_nonzero(g_render_buf, 512 * 2));
/* Count distinct left-channel values: the LFSR must keep moving. */
int distinct = 0;
for (int i = 0; i < 512; i++) {
int seen = 0;
for (int j = 0; j < i; j++)
if (g_render_buf[j * 2] == g_render_buf[i * 2]) { seen = 1; break; }
if (!seen) distinct++;
}
CHECK(distinct >= 8);
CHECK(noise_lfsr != 0); /* self-seeded from 0 via the xor-1 term */
}
/* ==== 4. Volume sweeps + direct decode ===================================== */
static void test_direct_volume_decode(void) {
spu_init();
/* Documented decode: effective volume = signed bits14-0 << 1. */
wr(vreg(0, 0), 0x2000);
CHECK(sweep_voice_env[0][0].level == 0x4000);
wr(vreg(0, 0), 0x3FFF);
CHECK(sweep_voice_env[0][0].level == 0x7FFE);
wr(vreg(0, 0), 0x4001); /* negative: (int16)(0x4001<<1) = -32766 */
CHECK(sweep_voice_env[0][0].level == -32766);
wr(vreg(0, 0), 0x0000);
CHECK(sweep_voice_env[0][0].level == 0);
/* End-to-end gain pipeline: CD sample 0x1000 x CD vol 0x7FFF x main
* 0x3FFF(direct => 0x7FFE) = 4094. Locks the >>15 application shape. */
spu_init();
wr(R_SPUCNT, 0x8001); /* enable + CD audio */
wr(R_CDVOLL, 0x7FFF);
wr(R_CDVOLR, 0x7FFF);
wr(R_MAINVOLL, 0x3FFF);
wr(R_MAINVOLR, 0x3FFF);
push_cd_const(1, 0x1000, 0x1000);
spu_render(g_render_buf, 1);
CHECK(g_render_buf[0] == 4094);
CHECK(g_render_buf[1] == 4094);
}
static void test_sweep_rise_and_fall(void) {
spu_init();
wr(R_SPUCNT, 0x8000);
/* Linear increase sweep, rate 0x20, from level 0. */
wr(vreg(0, 0), 0x8000 | 0x0020);
render_n(64);
int16_t mid = sweep_voice_env[0][0].level;
CHECK(mid > 0);
CHECK((int16_t)rd(vreg(0, 0)) == mid); /* guest read sees the live level */
render_n(64);
int16_t late = sweep_voice_env[0][0].level;
CHECK(late > mid);
/* Decrease sweep starts from the current (direct-set) level and falls. */
wr(vreg(0, 1), 0x3FFF); /* direct: level 0x7FFE */
CHECK(sweep_voice_env[0][1].level == 0x7FFE);
wr(vreg(0, 1), 0x8000 | 0x2000 | 0x0020); /* sweep, decrease, rate 0x20 */
render_n(64);
int16_t fall_mid = sweep_voice_env[0][1].level;
CHECK(fall_mid < 0x7FFE);
CHECK(fall_mid >= 0);
render_n(64);
int16_t fall_late = sweep_voice_env[0][1].level;
CHECK(fall_late < fall_mid);
CHECK(fall_late >= 0);
/* Main-volume sweeps use the same machinery. */
wr(R_MAINVOLL, 0x8000 | 0x0020);
render_n(64);
CHECK(sweep_main_env[0].level > 0);
/* Current-main-volume mirror register reports the live level. */
CHECK((int16_t)rd(0x1F801DB8u) == sweep_main_env[0].level);
}
/* Capture rings advance one halfword per output sample, wrap at 0x400, and
* record the raw CD input bus. */
static void test_capture_buffer_contents(void) {
spu_init();
wr(R_SPUCNT, 0x8000); /* SPU enable; CD MIX disabled — the
capture rings record the bus anyway */
push_cd_const(4, 0x1234, 0x2345);
render_n(4);
for (int i = 0; i < 4; i++) {
uint16_t l = (uint16_t)(spu_ram[i * 2] | (spu_ram[i * 2 + 1] << 8));
uint16_t r = (uint16_t)(spu_ram[0x400 + i * 2] | (spu_ram[0x400 + i * 2 + 1] << 8));
CHECK(l == 0x1234);
CHECK(r == 0x2345);
}
CHECK(capture_pos == 8);
render_n(508); /* 512 total -> exactly one wrap */
CHECK(capture_pos == 0);
CHECK((rd(R_SPUSTAT) & 0x800u) == 0); /* first half again */
render_n(256);
CHECK(capture_pos == 0x200);
CHECK((rd(R_SPUSTAT) & 0x800u) != 0); /* second half */
}
/* ==== 5. Savestate round-trip =============================================== */
static void test_savestate_roundtrip(void) {
spu_init();
g_irq_raises = 0;
/* Busy state: reverb ringing, noise voice keyed, sweeps mid-glide,
* capture advanced, IRQ latched. */
wr(R_SPUCNT, 0xB485); /* enable + noise(13,4) + reverb + CD + CD-rev */
wr(R_IRQADDR, 0x0080); /* park the IRQ in the CD-R capture ring */
wr(R_SPUCNT, 0xB4C5); /* + IRQ enable -> first render latches it */
setup_basic_reverb();
setup_voice(5, 0x0600, 0x3FFF, 0x3FFF);
wr(R_NON_LO, 0x0020); /* voice 5 is a noise voice */
wr(R_EON_LO, 0x0020); /* voice 5 also feeds reverb */
wr(vreg(6, 0), 0x8000 | 0x0020); /* an idle voice's sweep still glides */
wr(R_KON_LO, 0x0020);
push_cd_const(128, 0x1800, -0x1800);
render_n(128); /* ring drained exactly; state is rich */
CHECK(irq_flag == 1);
uint32_t n = spu_snapshot_bytes();
uint8_t *snap = (uint8_t *)malloc(n);
CHECK(snap != NULL);
spu_snapshot_write(snap);
/* The runtime savestate persists the 512KB SPU RAM separately (via
* spu_get_ram_ptr); mirror that here — the reverb work area and capture
* rings live in RAM and timeline B must replay over identical bytes. */
uint8_t *ram_snap = (uint8_t *)malloc(SPU_RAM_SIZE);
CHECK(ram_snap != NULL);
memcpy(ram_snap, spu_ram, SPU_RAM_SIZE);
uint32_t rev_cur_0 = rev_cur;
uint8_t rev_phase_0 = rev_phase;
uint16_t noise_lfsr_0 = noise_lfsr;
int32_t noise_timer_0 = noise_timer;
uint32_t capture_pos_0 = capture_pos;
uint8_t irq_flag_0 = irq_flag;
int16_t sweep_lvl_0 = sweep_voice_env[6][0].level;
uint32_t sweep_div_0 = sweep_voice_env[6][0].divider;
int32_t rev_out_l_0 = rev_out_l;
/* Timeline A. */
static int16_t out_a[256 * 2], out_b[256 * 2];
spu_render(out_a, 256);
CHECK(buf_has_nonzero(out_a, 256 * 2)); /* noise + reverb tail audible */
/* State moved on... */
CHECK(rev_cur != rev_cur_0 || noise_lfsr != noise_lfsr_0
|| capture_pos != capture_pos_0);
/* ...restore and replay: timeline B must be byte-identical. */
CHECK(spu_snapshot_read(snap, n) == 1);
memcpy(spu_ram, ram_snap, SPU_RAM_SIZE);
CHECK(rev_cur == rev_cur_0);
CHECK(rev_phase == rev_phase_0);
CHECK(noise_lfsr == noise_lfsr_0);
CHECK(noise_timer == noise_timer_0);
CHECK(capture_pos == capture_pos_0);
CHECK(irq_flag == irq_flag_0);
CHECK(sweep_voice_env[6][0].level == sweep_lvl_0);
CHECK(sweep_voice_env[6][0].divider == sweep_div_0);
CHECK(rev_out_l == rev_out_l_0);
spu_render(out_b, 256);
CHECK(memcmp(out_a, out_b, sizeof(out_a)) == 0);
/* A truncated blob is rejected. */
CHECK(spu_snapshot_read(snap, n - 1) == 0);
free(ram_snap);
free(snap);
}
/* ==== main ================================================================== */
int main(void) {
test_irq_transfer_paths();
test_irq_disabled_never_fires();
test_irq_on_block_fetch();
test_irq_on_capture_write();
test_irq_on_reverb_access();
test_reverb_write_gating_and_cd_path();
test_reverb_addressing_wrap();
test_noise_generator();
test_direct_volume_decode();
test_sweep_rise_and_fall();
test_capture_buffer_contents();
test_savestate_roundtrip();
printf("test_spu_fidelity: %d checks, %d failures\n", g_checks, g_fails);
return g_fails ? 1 : 0;
}