mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 10:19:47 -04:00
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:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user