Skip to content

APU SPC700 SDSP

DoubleGate edited this page Jul 8, 2026 · 1 revision

APU — SPC700 (S-SMP) + S-DSP + ARAM

References: docs/apu.md, docs/scheduler.md §async-resync, docs/adr/0001, docs/adr/0004. Sources cited inline in the underlying spec: SNESdev S-SMP, undisbeliever S-SMP clock measurements, byuu.net/bsnes.org scheduler articles.

Purpose

The SNES audio subsystem is a genuinely separate computer on the same board: a Sony SPC700 (S-SMP) 8-bit CPU, an S-DSP wavetable synthesizer, and 64 KiB of dedicated ARAM, all clocked from their own 24.576 MHz ceramic resonator — asynchronous to the main 21.477 MHz master clock the CPU and PPU share. Keeping this second timeline coherent with the main CPU across the four communication ports it uses to talk to the rest of the machine is one of the SNES's genuinely hard emulation problems. crates/rustysnes-apu owns the whole subsystem; the S-DSP only sees a narrow bus/ARAM trait.

Hardware facts

  • SPC700 clock: 1.024 MHz, independent of the rest of the console, derived from a 24.576 MHz resonator (which also clocks the S-DSP internally at 3.072 MHz).
  • Resonator tolerance is ±0.5%. Measured real-hardware S-DSP sample rates span roughly 32036–32152 Hz around a nominal 32000 Hz. This drift, plus a variable S-CPU↔S-SMP handshake delay, is a documented cause of TAS desyncs on real hardware — and the reason resonator drift is deliberately excluded from RustySNES's deterministic core.
  • S-DSP: 8 voices, BRR-compressed samples decoded with 4-point Gaussian interpolation, producing one 32 kHz 16-bit stereo sample every 768 resonator cycles.
  • 64 KiB ARAM, time-shared between the SMP and the DSP (one SMP access for every second DSP access).
  • Three hardware timers: two running at 8 kHz, one at 64 kHz.
  • Four communication ports at $F4$F7 on the SMP side and $2140$2143 on the CPU side. Each port is two independent one-way latches — a read on either side returns what the other side last wrote, never an echo of your own write.
  • IPL boot ROM: the SMP starts execution in a 64-byte internal boot ROM that handshakes with the main CPU over the ports to receive the actual audio driver program the game uploads at boot — a timing-sensitive sequence the emulator has to reproduce exactly for any game to make sound at all.

The async-resync model

Because the SPC700/S-DSP run on their own crystal, RustySNES runs them as a genuinely separate timeline and only forces the two into lockstep at the moments they actually communicate — a read or write of one of the four ports. The mechanism is a single signed integer relative-time accumulator tracking how far ahead the CPU's timeline is versus the SMP's: each CPU step of N master clocks subtracts N × 24,576,000 from the accumulator, and each SMP step of N clocks adds N × 21,477,272 (the two chips' own source frequencies used as integer scaling factors — no floating point anywhere in this calculation). The bus resyncs the SMP up to "now" on every port access; between accesses, the SMP is free to run arbitrarily far ahead of the CPU's position.

This is the same cooperative-threaded technique higan/bsnes use, implemented single-threaded instead — real coroutines would complicate bit-deterministic save-states and netplay, so the integer accumulator gets equivalent accuracy without them.

Determinism caveat

Resonator drift (±0.5%) is deliberately not modeled in the deterministic core — the SPC domain always runs at its fixed nominal 1.024 MHz. A separate, explicitly non-deterministic "hardware-accurate audio" toggle exists in the frontend for drift simulation, but it never touches the bit-identical save-state/replay path. On-cart RTC chips (S-RTC, RTC-4513) are handled the same way: seeded and frozen rather than free-running in real time.

The S-DSP voice pipeline

Each of the 8 voices carries a BRR sample-source pointer, an ADSR/GAIN envelope, a pitch value (interpolated through the 4-tap Gaussian table), and independent left/right volume; all 8 are mixed into the 32 kHz stereo output. BRR sample blocks are 9 bytes (1 header + 8 data bytes decoding to 16 samples), each block carrying its own per-block shift and filter selection. The echo path (an 8-tap FIR filter) and pitch modulation both read back from ARAM. Because the DSP is a streaming, stateful device rather than an instruction-discrete processor, there is no per-instruction JSON oracle for it the way there is for the SPC700 CPU.

Cycle-accurate DSP: the 32-tick micro-sequence

The S-DSP is modeled as a hard pipeline rather than a per-voice-at-once mixer. Every 32 kHz output sample is produced across 32 micro-ticks, with the nine per-voice steps, the echo path steps, and housekeeping latches interleaved across the 8 voices on a fixed 32-entry phase table, reproduced from the ares reference core. This interleaving is what gives sub-sample timing precision to register writes like OUTX/ENVX/ENDX — the exact resolution blargg's DSP and memory-access timing test suites probe. The integration drives the DSP one tick per 2 SMP base clocks, so an SMP instruction reading a DSP register mid-execution observes the DSP advanced to exactly that point and no further — the cycle-correct value, not an end-of-instruction approximation.

Interfaces (sketch)

// rustysnes-apu
pub struct Smp { /* SPC700 registers + 64 KiB ARAM + 3 timers + IPL ROM */ }
pub struct Dsp { /* 8 voices, echo, master volume; reads ARAM */ }

pub trait ApuPorts {            // the $2140-$2143 / $F4-$F7 latches
    fn cpu_read_port(&mut self, n: u8) -> u8;   // resyncs the SMP first
    fn cpu_write_port(&mut self, n: u8, v: u8); // resyncs the SMP first
}

Edge cases the core models

  • The IPL boot handshake is the first thing that has to work correctly — it exercises the resync mechanism immediately at power-on.
  • Port latch direction: each of the four ports is two independent one-way latches, not a shared register.
  • Forced per-scanline sync: without it, audio latency between the two timelines could grow unbounded even when no port access happens.
  • Timer dividers tick on the SMP's own timebase, never the master clock.

Validation

The primary SPC700 CPU oracle is SingleStepTests/spc700 (per-instruction JSON plus bus activity, MIT-licensed). Per docs/apu.md's current implementation status, the committed sample set (256 opcode files, 12,800 tests) and the full external set (256,000 tests) both reach 100.00% on state, cycle count, and the combined full-pass metric — every one of the 256 opcodes, including MUL/DIV, the word operations, DAA/DAS, the bit-manipulation family, and STP/SLEEP.

The harder oracle is blargg's spc_* cycle-accuracy suite (spc_smp, spc_timer, spc_mem_access_times, spc_dsp6) — historically difficult enough that Mesen-S is cited as the only emulator to have passed all of them. docs/apu.md records that all four now reach blargg's literal PASSED TESTS verdict (decoded from the ROM's own result-grid tilemap output, not a proxy metric), following two specific fixes documented there in detail:

  • A timer-phase fix, reordering the integrated system's recording bus to clock the timers before a register write's side effect lands (matching ares and Mesen2), rather than after.
  • A DSP GAIN mode-7 threshold fix, correcting a signed-vs-unsigned comparison bug in the bent/two-slope envelope-increase mode that could under-trigger the reduced slope when a prior GAIN decrease left the internal envelope latch negative.

The S-DSP itself has no per-instruction oracle (it's a streaming device), so it is validated behaviorally: in-module unit tests derived from the ares reference core (Gaussian interpolation table entries, the noise LFSR sequence, BRR filter decode, envelope attack/release timing, echo silence/mute behavior) plus .spc-to-.wav output comparison against reference implementations.

See Testing-Strategy for how this fits the project's overall accuracy battery, and Lockstep-Scheduler for how the asynchronous APU timeline integrates with the CPU/PPU on the shared master clock.

Clone this wiki locally