The DSP-1 (a NEC µPD77C25 running Sony's fixed firmware) is the SNES's math coprocessor — the chip behind Pilotwings' and Super Mario Kart's pseudo-3D. Unlike the SA-1 and Super FX you do not program it: you invoke its built-in commands (matrix, vector, projection, trig) over a two-register port, and OpenSNES wraps that port as ordinary C calls. This tutorial covers the mental model, the shipped API, and the projection pipeline the dsp1_cube example uses.
A fixed-function DSP on the cartridge, mapped into the address space — not a second CPU. On the LoROM board this SDK targets, its two byte-wide registers live at (fullsnes, DSP mapping):
| Register | Address | Role |
|---|---|---|
| DR (data) | $30:8000 | commands in, parameters in, results out |
| SR (status) | $30:C000 | bit 7 = RQM, the only handshake flag |
Enable it with one Makefile line — USE_DSP1 := 1 — which sets the ROM-header cartridge type to $03 (ROM + DSP). The dsp1 lib module is linked automatically.
Every transfer is: poll RQM until set, then move one byte. Commands are a single byte; parameters and results are 16-bit words sent as two bytes, LSB first. That is the whole protocol — the lib's driver (lib/source/dsp1.asm) does it for you, so from C a command is just a function call that returns when the transaction is complete. Commands have fixed in/out word counts; the driver knows them, which is also why you should never poke $30:8000 yourself mid-frame: a half-finished transaction desynchronises the chip (recover with dsp1Init(), which hammers the $80 Sync/Reset byte).
The DSP-1 types its operands per parameter slot:
| Type | Meaning | Format |
|---|---|---|
| I | integer | signed 16-bit (coordinates, distances) |
| T | fraction | signed 1.15 — 0x7FFF ≈ +1.0, 0x4000 = 0.5 |
| A | angle | signed 16-bit, full turn = 2^16 — 0x4000 = 90° |
These are not the SDK's own formats (fixed is 8.8, fixSin angles are 8-bit). snes/dsp1.h ships bridge macros: DSP1_T_FROM_FIX(f), DSP1_FIX_FROM_T(t), DSP1_A_FROM_FIX8(a). Mind the T range: it is −1..+1, so clamp before converting anything that can exceed ±1.0.
| Call | DSP command | In → Out | Use for |
|---|---|---|---|
| dsp1Init() | $80 ×128 | — | resync at boot / after a desync |
| dsp1IsPresent() | $00 KAT | — → 1/0 | probe the chip (bounded, never hangs) |
| dsp1Multiply(a,b) | $00 | 2 → 1 | 1.15 product |
| dsp1Triangle(a,r) | $04 | 2 → 2 | r·sin, r·cos |
| dsp1Rotate(a,x,y) | $0C | 3 → 2 | 2D rotate |
| dsp1Attitude(s,az,ay,ax) | $01 | 4 → 0 | build the rotation matrix |
| dsp1Objective(x,y,z) | $0D | 3 → 3 | transform a point by the matrix |
| dsp1SetCamera(&cam) | $02 | 7 → 4 | set up the projection plane (the seven inputs are the fields of Dsp1Camera) |
| dsp1Project(x,y,z) | $06 | 3 → 3 | world point → screen H, V + scale M |
| dsp1Target(h,v) | $0E | 2 → 2 | screen point → ground plane (pick / aim) |
| dsp1Raster(ab,cd,vs,n) | $0A | 1 → 4·n (stream) | per-scanline Mode 7 matrices into HDMA payloads |
| dsp1Distance(x,y,z) | $28 | 3 → 1 | 3D length (hardware sqrt; reads one low on exact lengths) |
| dsp1Range(x,y,z,r) | $18 | 4 → 1 | sphere test: (d² − r²) >> 15, negative = inside |
Multi-word results land in the globals dsp1_o0/dsp1_o1/dsp1_o2 (dsp1_o3 for Parameter); single-word commands return their value.
Conventions in that snippet were characterised empirically on luna (DSP-1B firmware, 2026-09) — the references do not document them, so treat them as measured behaviour, not gospel:
Copy those two lens values (lfe, les) as your starting point and tune from there. Its four output words are Vof, Vva, Cx, Cy (official manual §5.4.1, verified on luna): the raster of the "imaginary centre", the horizon raster relative to it, and the ground point under that centre. They are what the ground pipeline below runs on.
The reason the chip was on the Super Mario Kart cartridge: the Raster command. Give it a camera and it streams, per scanline, the 2×2 Mode 7 matrix that projects the ground plane — perspective and rotation, A, B, C and D — as an open-ended stream the CPU stops with a sentinel. The lib wraps the whole transaction:
tab_ab/tab_cd are HDMA_MODE_2REG_2X repeat blocks (M7A/M7B on one channel, M7C/M7D on the other) whose payload the chip fills directly; HDR is the header the caller lays out. Three things to know:
dsp1Target(h, v) is the inverse for the same plane: the ground point under a screen pixel, in raster coordinates (Target(0, 0) = Cx/Cy).
You do not need 3D graphics to profit from the chip:
Two properties measured on luna's DSP-1B firmware and pinned by the library fixture (testing/fixtures/libtests_dsp1), neither stated by any hardware reference we could find. dsp1Distance reads one low on exact lengths — (3, 4, 12) gives 12 — so compare with >= / <, never ==. dsp1Range returns the squared difference shifted right by 15, not the raw difference: a point less than 32768 squared-units outside the surface reads 0, the same as one exactly on it. Choose world units so that margin is small next to the radius.
Both are single calls where the 65816 would need three multiplies, two adds and (for Distance) a software square root.
luna emulates the DSP-1 at low level and needs Sony's dsp1b.rom (copyrighted, not shipped). Install it once — cp dsp1b.rom ~/.config/luna/firmware/ or luna state --dsp1-rom <path> <rom> — and it persists. Without it the ROM boots but the chip stays inert; that is why dsp1IsPresent() exists and why the DSP-1 tests are firmware-gated (they SKIP, not fail, in CI). Target revision is DSP-1B, the bug-fixed one and the emulator default.
A command is a multi-byte DR transaction. If an nmiSet() callback issues a DSP-1 call while the main loop is mid-transaction, both are corrupted — same class as the fixMul() restriction in KNOWN_LIMITATIONS.md. Keep all DSP-1 work in the main loop.
dsp1Init()'s $80 Sync recovers an interrupted fixed-length command, not an open stream: inside a Raster stream every byte pair is swallowed as a 16-bit word, so a wedged stream never returns to command mode until $8000 lands on a D slot. dsp1Raster() always closes its stream; do not hand-roll $0A (and never $1A, which the DSP-1B firmware never returns from — measured on luna, see the reference note).
Every word costs two RQM-gated byte moves. The cube's 8 vertices × (Objective + Project) ≈ 100 word transfers per frame — fine at 60 fps, but budget before scaling up: reuse the Attitude matrix, only re-run Parameter when the camera moves, and batch what you can.