Skip to content
RetrogamingDeep Dive Published Updated 8 min readViews unavailable

SNES SPC700 IPL Upload Protocol: Boot Handshake and ARAM Transfer

Load an SPC700 program through the SNES APU ports with the IPL handshake, byte acknowledgements, safe address bounds, timeout handling, and emulator tests.

The Super Nintendo’s audio processor is a second computer, not a collection of sound registers on the main CPU bus. The 65C816 cannot directly read or write the SPC700’s audio RAM (ARAM); the two processors exchange bytes through four one-way port latches. After reset, a small SPC700 program called the IPL ROM waits for the main CPU to upload data, copies it into ARAM, and can then jump to an entry point. Understanding this handshake is useful for homebrew loaders, emulator developers, ROM diagnostics, and anyone debugging why the first music command never reaches the sound driver.

This is the boot-time IPL transfer protocol, not a general-purpose audio-driver command format. Once a game’s SPC700 driver is running, the game and driver define their own protocol over the same ports. Do not assume that a port value used as an IPL byte index has the same meaning after the driver takes control.

Four ports, eight directional latches

On the SNES main CPU side, the ports are APUIO0 through APUIO3 at $2140-$2143. On the SPC700 side, the same connections appear as $F4-$F7 in its address space. These are not four shared bidirectional memory bytes. Each physical port has a main-CPU-to-APU value and a separate APU-to-main-CPU value. A write updates the value the other processor reads; reading on the writing side returns the other direction’s most recent value.

That distinction is crucial to acknowledgement logic. The main CPU writes the transfer index to $2140; the SPC700 echoes the index back through its output latch; the main CPU then reads that response from $2140. A read made on the same cycle that the other side writes can observe an invalid combination on some hardware. Poll for a stable expected value and follow the documented handshake rather than treating the ports as ordinary shared RAM.

The SNES development references also caution that these ports should be written in 8-bit mode. A 16-bit write spanning $2140 and $2141 can, under reported hardware conditions, disturb the $2143 port. Even if a wide write works on one emulator or cartridge, use byte stores for protocol writes and avoid relying on adjacent-register side effects.

Wait for IPL initialization

After reset, the IPL code initializes a small part of SPC700 state and signals readiness by writing $AA to its port $F4 and $BB to $F5. The main CPU sees those bytes at APUIO0 and APUIO1. Before starting a transfer, wait until both values are present. The signal is a handshake state, not a reason to assume the IPL completed a fixed number of main-CPU frames.

repeat until read8(APUIO0) == 0xAA
        and read8(APUIO1) == 0xBB:
    poll_with_timeout()

The timeout is an application safety mechanism, not part of the IPL wire protocol. If readiness never arrives, report a failed or reset audio subsystem instead of spinning forever. In an emulator, this check is a useful regression assertion after SNES reset: the APU should execute its initialization path and expose the expected ready signature through the main CPU’s read latches.

Send one block into ARAM

For a data block, place the destination address in APUIO2 and APUIO3, low byte first. Write a nonzero value to APUIO1 to select data-transfer behavior, then write a kick token to APUIO0. The first kick after reset is $CC; later blocks use a new nonzero token that is distinguishable from the previous byte-index exchange. The SPC700 acknowledges the kick by echoing it through its side of port $F4. Wait for that response before sending the first byte.

Each byte transfer uses two writes from the main CPU: put the data byte in APUIO1, then write the low eight bits of the byte index to APUIO0. The SPC700 notices that the index changed, reads the data from its $F5 port, stores it to the current ARAM destination, increments the destination address, and echoes the index through its $F4 output. The main CPU must wait for the matching echo before advancing to the next byte. The index is a change marker and acknowledgement token; it is not the full ARAM address.

function upload_block(destination, bytes, kick_token):
    write8(APUIO2, low(destination))
    write8(APUIO3, high(destination))
    write8(APUIO1, 1)             # any nonzero transfer command
    write8(APUIO0, kick_token)    # first block uses 0xCC
    wait_until(read8(APUIO0) == kick_token)

    for index, value in enumerate(bytes):
        write8(APUIO1, value)
        token = index & 0xFF
        write8(APUIO0, token)
        wait_until(read8(APUIO0) == token)

This pseudocode shows one block and omits how the caller selects subsequent kick tokens. The initial token is $CC; later block tokens must be nonzero and advance beyond the final byte-index token of the previous block. Reference upload implementations derive that next kick from the index after the block and its exact wrap behavior, so keep the counter arithmetic aligned with the reference routine rather than hard-coding a constant for every block. A production 65C816 routine also needs to preserve processor mode assumptions, use byte-width port accesses, and ensure no interrupt handler delays the final acknowledgement past its short observation window. Reference upload implementations warn that the last byte’s echo may be brief; an interrupt or NMI at the wrong point can make software miss it. During IPL upload, either prevent such interference in a controlled critical section or use a timing strategy demonstrated on the target hardware.

The IPL increments the 16-bit ARAM destination after each byte. A block can therefore cross a low-byte boundary, and the sender’s one-byte acknowledgement token wraps modulo 256. Keep the payload length and transfer counter in wider software variables even though the acknowledgement is only eight bits. Validate that the final address stays in valid ARAM and does not unintentionally collide with the stack, driver workspace, sample data, or hardware-mapped area.

Finish by transferring control

Once all program and data blocks have been copied, the main CPU sends the SPC700 entry-point address in APUIO2/APUIO3, writes zero to APUIO1 to select execution rather than another data block, and writes a new nonzero kick token to APUIO0. The token must advance beyond the last byte-index handshake so the IPL can distinguish the new command from stale state. The SPC700 acknowledges the kick and jumps to the requested address.

write8(APUIO2, low(entry_point))
write8(APUIO3, high(entry_point))
write8(APUIO1, 0)                 # execution command
write8(APUIO0, next_kick_token)   # new command, not the previous byte token
wait_until(read8(APUIO0) == next_kick_token)

The entry point must refer to the uploaded program, not the overlaid IPL ROM at the top of the SPC700 address space. The boot ROM occupies $FFC0-$FFFF while mapped. Keep transfer tables, program code, and sample data within the available ARAM layout selected for the driver, and confirm the exact overlap constraints in the loader you use. A successful final acknowledgement proves the boot protocol accepted the jump command; it does not prove the new driver initialized its own ports, DSP registers, sample directory, or echo buffer correctly.

Emulator behavior and failure diagnosis

Model the APU ports as paired directional latches and schedule SPC700 execution independently from the 65C816. An instantaneous “copy bytes to audio RAM when the main CPU writes $CC” shortcut can pass a simple boot test but loses observable acknowledgements, timing windows, and the race behavior that software may depend on. The boot ROM’s behavior should emerge from SPC700 execution, or from a clearly scoped equivalent state machine that reproduces its port reads, stores, index increments, and responses.

Useful tests include:

  • Reset the console and confirm $AA/$BB readiness appears only after the emulated IPL initialization runs.
  • Upload a short block across an address low-byte boundary and compare all resulting ARAM bytes with the source buffer.
  • Upload more than 256 bytes and verify the eight-bit acknowledgement token wraps while the 16-bit destination continues to advance.
  • Delay the SPC700 between byte polling and acknowledgement; verify the main CPU waits rather than skipping data or accepting stale output-latch state.
  • Inject an NMI or interrupt around the final byte response and compare behavior with the documented timing caveat.
  • Send a new command with a stale token and ensure it is not accidentally treated as a fresh kick.
  • Confirm the final entry point executes only after its kick acknowledgement, then verify driver-owned initialization separately.

When a real game has silence but video and controls work, collect a port trace before changing sound emulation. Record both sides’ reads and writes, timestamps, destination address, byte index, and the value observed at each acknowledgement. This separates several distinct defects: the APU never reached ready, the kick was not issued, the byte token did not change, the response was sampled too early, transfer data was corrupted, or the uploaded driver started but failed later during DSP initialization.

The main-CPU/APU boundary is a small asynchronous mailbox with a specific boot protocol. Correct models preserve the two directions of each port, wait for observable acknowledgements, respect the byte-write constraint, and keep IPL upload separate from the driver’s runtime command language. Those rules make audio boot predictable without pretending that every post-boot sound-driver protocol is the same.

Related:

Sources:

Comments