OpenSNES
Modern Open-Source SNES Development SDK
Loading...
Searching...
No Matches
SA-1 Enhancement Chip Tutorial

This tutorial covers the SA-1 coprocessor — a second 65816 CPU clocked at 10.74 MHz inside the cartridge, running in parallel with the main CPU.

What Is SA-1?

The SA-1 is a 65c816 clone clocked at 10.74 MHz (vs 3.58 MHz for the main CPU). It shares the same instruction set, so you already know how to program it — in assembly: the SDK does not run C on it, because the C runtime lives in work RAM, which the SA-1 cannot see (table below).

How fast it really runs depends on memory, not on the clock. The SA-1 Speed Test of the higan test ROMs was photographed running on a real console (one console, one board: 1L8B-10). Read off those photographs:

Main CPU runs from SA-1 runs from SA-1 effective speed
WRAM I-RAM 10.74 MHz
I-RAM I-RAM 3.72 MHz
WRAM ROM 10.07 MHz (7.67 across a JMP)
ROM ROM 5.04 MHz — the two CPUs share the ROM bus
BW-RAM BW-RAM 2.69 MHz

The summary table in that test's README gives about 5.4 MHz for ROM / ROM and is the figure most often repeated; the photographs of the same run say 5.04, and the measurement is what this page follows. No reference manual states these speeds: they are measurements, on one machine.

So "3× the main CPU" holds only for SA-1 code that keeps off the ROM while the main CPU is on it. The table gives the rate while both CPUs are in a case; a real program moves between cases, so its effective speed is a mix, and a property of the scene rather than of the cartridge. Measure it. examples/chips/sa1_starfield builds both ways: by default its SA-1 copies its loop and sine table into I-RAM at boot and runs there; built with make clean && make SA1_CODE_IN=ROM they stay in ROM.

luna profile examples/chips/sa1_starfield/sa1_starfield.sfc \
--from-frame 60 --until-frame 180 --top 0
# I-RAM build (default):
# sa1: 5454120 instr, 21441240 clocks (0.0% idle in WAI), 0.4% of busy clocks
# lost to bus conflicts (rom 0, bwram 0, iram 78240) — ~10.70 MHz while running
# sa1: accesses 0.0% rom, 100.0% iram, 0.0% bwram, 0.0% other
# ROM build (SA1_CODE_IN=ROM):
# sa1: 3951600 instr, 21441240 clocks (0.0% idle in WAI), 20.8% of busy clocks
# lost to bus conflicts (rom 4463010, bwram 0, iram 3300) — ~8.50 MHz while running
# sa1: accesses 86.0% rom, 14.0% iram, 0.0% bwram, 0.0% other

From ROM, 86 % of the SA-1's accesses are ROM, but the penalty only applies when the main CPU is on ROM at the same moment, so it loses a fifth of its clocks, not half: about 8.5 MHz, 2.4× the main CPU. Code in ROM also pays one more cycle on every jump, call and return into ROM, so a loop that jumps often runs slower than its conflict share alone says (luna v1.30.0, from ares, checked against a console on the SNES-SA1 Speed Test). From I-RAM it keeps almost all of them: 10.7 MHz, 3×. The remaining 0.4 % is the main CPU touching I-RAM too (it reads the positions there and polls the sync flag). Both builds draw the same frames; the I-RAM one simply finishes each frame's work sooner (luna v1.30.0, whose SA-1 timing matches a console on the SNES-SA1 Speed Test; the instruction count includes the SA-1's wait for the next frame, so it grows with the spare time).

The I-RAM version costs 289 bytes of code and 256 of table out of the 2 KB, and a loop written to run anywhere: brl instead of jmp, and the table read at its I-RAM address (sa1_boot.asm). Games like Kirby Super Star, Super Mario RPG, and Kirby's Dream Land 3 used it to handle AI, physics, and decompression that the main CPU couldn't keep up with.

Feature Main CPU SA-1
Clock 3.58 MHz 10.74 MHz (effective speed: see above)
Instruction set 65c816 65c816 (identical)
WRAM access Yes (128 KB) No
PPU access Yes No
APU access Yes No
I-RAM access Yes ($3000-$37FF) Yes ($0000-$07FF, mirrors $3000)
ROM access Yes Yes (separate bus, no conflicts)
BW-RAM access Yes (via $6000 window) Yes (256 KB max)

The key constraint: SA-1 cannot touch WRAM, PPU, or APU. It can only read ROM and read/write I-RAM and BW-RAM. All communication with the main CPU happens through the 2 KB shared I-RAM.

Memory Architecture

┌─────────────────────────────────────────────────────┐
│ ROM (up to 8 MB) │
│ Both CPUs read simultaneously │
└──────────────────────┬──────────────────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌────┴─────┐
│ Main CPU │ │ I-RAM │ │ SA-1 │
│ 3.58 MHz │ │ 2 KB │ │ 10.74MHz │
│ │ │ shared │ │ │
│ WRAM 128K │ │ $3000- │ │ BW-RAM │
│ PPU / APU │ │ $37FF │ │ 256 KB │
└───────────┘ └───────────┘ └──────────┘

I-RAM — The Mailbox

I-RAM is 2 KB of fast SRAM at $3000-$37FF (both CPUs see this address). The SA-1 also sees it at $0000-$07FF (its direct page region).

This is how the two CPUs communicate: the main CPU writes data to I-RAM, the SA-1 reads it, computes results, writes them back, and signals "done."

Write protection (resolved polarity): Both CPUs have independent write-protection registers (SIWP at $2229, CIWP at $222A). Each bit is a write-enable flag for one 256-byte I-RAM page — bit=1 enables writing, $FF = fully writable. This is what fullsnes, the official Nintendo development manual (§4.1.25) and nocash's hardware debugging all state, and how Mesen2, snes9x and luna behave. The Super Famicom Dev Wiki page documents the opposite polarity for $2229/$222A — that page is wrong (and inconsistent with its own $2226/$2227 entries). OpenSNES writes $FF; the crt0 I-RAM self-test catches any regression at runtime (sa1IsReady() returns false). Details in KNOWN_LIMITATIONS.md.

Getting Started

1. Enable SA-1 in Your Makefile

OPENSNES := $(shell cd ../../.. && pwd)
TARGET := mygame.sfc
ROM_NAME := MY SA1 GAME
CSRC := main.c
USE_LIB := 1
USE_SA1 := 1
LIB_MODULES := console sprite dma background input sa1
include $(OPENSNES)/make/common.mk

USE_SA1 := 1 does three things:

  • Selects the SA-1 ROM header (cartridge type $34, SA-1 + RAM; $35 with a battery when USE_SRAM := 1; BW-RAM size from SA1_BWRAM_SIZE, 32 KB by default)
  • Links the SA-1 library variant
  • Includes your SA-1 boot stub in the ROM

2. Write Your SA-1 Boot Code

Create sa1_boot.asm in your example directory (not in templates/). This code runs on the SA-1:

.ifdef SA1
.SECTION ".sa1_boot" SUPERFREE
.ACCU 16
.INDEX 16
SA1Start:
; Standard 65816 init
sei
clc
xce
rep #$30
.ACCU 16
.INDEX 16
lda #$37FF
tcs ; Stack at top of I-RAM
lda #$3000
tcd ; Direct page in I-RAM
sep #$20
.ACCU 8
; Enable SA-1 I-RAM writes (CRITICAL!). $FF = writable: fullsnes, the
; Nintendo manual §4.1.25 and nocash agree (resolved 2026-09-02; the
; sfc-dev-wiki states the inverse — see the box above).
lda #$FF
sta.l $00222A ; CIWP = $FF (all I-RAM writable)
; Signal ready to main CPU
lda #$A5
sta.l $3000 ; Magic byte
; === Your SA-1 code here ===
; Example: idle loop (replace with your compute loop)
- wai
bra -
.ENDS
.endif

Why sta.l everywhere? The SA-1's data bank register (DB) is undefined at boot. Using sta.l (long absolute, opcode $8F) bypasses DB entirely. This is the safest pattern for SA-1 code.

3. Initialize from C

#include <snes.h>
#include <snes/sa1.h>
int main(void) {
if (sa1IsReady()) {
// SA-1 is running!
} else {
// SA-1 failed to boot (wrong emulator or cartridge)
}
while (1) { WaitForVBlank(); }
return 0;
}
int main(void)
Definition main.c:66
void consoleInit(void)
Initialize SNES hardware.
void WaitForVBlank(void)
Wait for next VBlank period.
void setScreenOn(void)
Enable screen display.
#define BG_MODE1
Definition video.h:35
SA-1 Enhancement Chip Interface.
u8 sa1IsReady(void)
Did the SA-1 boot? (crt0 starts it; this only reads the outcome).
OpenSNES common-case master header.
void setMode(u8 mode, u8 flags)
Set background mode.

sa1IsReady() returns 1 if the SA-1 wrote the $A5 magic byte to I-RAM $3000 within the timeout. The boot sequence (in crt0.asm) handles all the register setup: reset vector, I-RAM write protection, and SA-1 release.

Communication Patterns

Pattern 1: Flag Sync (Producer/Consumer)

The SA-1 computes data, sets a flag. The main CPU reads the data, clears the flag. Used by the sa1_starfield example.

┌──────────┐ I-RAM ┌──────────┐
│ SA-1 │ ──────────▸ │ Main CPU │
│ │ $3001 = 1 │ │
│ Compute │ (data ready) │ Read buf │
│ Write buf│ │ Clear $3001│
│ Set flag │ ◂────────── │ │
│ Wait... │ $3001 = 0 │ Display │
└──────────┘ (consumed) └──────────┘

SA-1 side (assembly):

_main:
; Wait for main CPU to clear sync flag
- lda.l $3001
bne -
; Compute and write results to I-RAM buffer
; ... your compute code ...
; Signal ready
lda #$01
sta.l $3001
jmp _main

Main CPU side (C):

#define SA1_SYNC (*(volatile u8*)0x3001)
#define SA1_BUF ((volatile u8*)0x3010)
while (1) {
while (!SA1_SYNC) { } /* Wait for SA-1 */
/* Read results from I-RAM */
for (i = 0; i < count; i++) {
result[i] = SA1_BUF[i];
}
SA1_SYNC = 0; /* Tell SA-1 we consumed */
}
#define SA1_SYNC
Definition main.c:27
static u8 i
Definition main.c:155

Pattern 2: Free-Running Counter

The SA-1 increments a value continuously. The main CPU reads it whenever needed. No synchronization required — just atomic reads. See sa1_hello for register readback examples.

; SA-1 side: increment 32-bit counter forever
_sa1_count_loop:
lda.l $003002 ; Load low word
inc a
sta.l $003002 ; Store low word
bne _sa1_count_loop
lda.l $003004 ; Carry: increment high word
inc a
sta.l $003004
jmp _sa1_count_loop
/* Main CPU side: just read the counter */
u16 lo = *(volatile u16*)0x3002;
u16 hi = *(volatile u16*)0x3004;
unsigned short u16
16-bit unsigned integer (0 to 65535)
Definition types.h:53

I-RAM Layout Convention

We recommend this layout for SA-1 projects:

Address Size Purpose
$3000 1 Magic/status byte ($A5 = ready)
$3001 1 Sync flag (0 = idle, 1 = data ready)
$3002-$300F 14 Control variables (counters, params)
$3010-$37FF 2032 Data buffer (up to 508 entries × 4 bytes)

Both CPUs must agree on this layout. Define constants in both your C code and assembly:

#define SA1_SYNC (*(volatile u8*)0x3001)
#define SA1_BUFFER ((volatile u8*)0x3010)

SA-1 Assembly Tips

Use sta.l / lda.l for Everything

The SA-1's DB register is undefined at boot. Don't use sta addr (absolute) — use sta.l addr (long absolute) to specify the full 24-bit address.

; WRONG — depends on DB register:
sta $3008
; CORRECT — full 24-bit address, no DB dependency:
sta.l $3008

ROM Table Lookups with lda.l table,x

To read data tables in ROM from the SA-1, use lda.l table,x (opcode $BF). This is a long indexed read — no DB dependency:

; Sine table in the same SUPERFREE section
lda.l $3006 ; index
rep #$20
.ACCU 16
and #$00FF ; zero-extend for 16-bit X
tax
sep #$20
.ACCU 8
lda.l sine_table,x ; opcode $BF — full 24-bit + X

No stx.l — Save X Through A

The 65816 has no stx.l instruction. To save/restore X via I-RAM:

txa ; X → A
sta.l $3008 ; save to I-RAM
; ... later ...
lda.l $3008 ; reload
tax ; A → X

Explicit .ACCU After Every rep/sep

WLA-DX loses accumulator width tracking after branch merges. Always add explicit .ACCU 8/.ACCU 16 directives:

rep #$20
.ACCU 16 ; ALWAYS add this!
; ... 16-bit code ...
sep #$20
.ACCU 8 ; ALWAYS add this!
; ... 8-bit code ...

Debugging

luna, the SDK's emulator, runs the SA-1 natively:

  1. luna state rom.sfc --until-frame 60 --out - — the sa1 block gives the SA-1's pc, pb, p and whether it is running
  2. --peek 00:3000:4 — read I-RAM (shared by both CPUs) to check the $A5 handshake byte after boot
  3. luna mcp — step, breakpoints and memory reads over MCP (docs/tutorials/debugging.md)

A GUI emulator with an SA-1 debugger (Mesen2, bsnes-plus) remains useful for stepping the SA-1 side by hand.

Common issues:

  • SA-1 PC stuck at $0000: reset vector ($2203/$2204) wrong, or I-RAM write-protection (CIWP $222A) left in the protected state so the SA-1 can't write its handshake — set it the same way the crt0 does ($FF, write-enabled; see the polarity note above)
  • I-RAM reads return $FF: SIWP/CIWP only gate writes, never reads — so this is not a protection problem; the SA-1 simply hasn't written I-RAM yet (not released from reset, or wrong reset vector)
  • Counter not incrementing: Check that $2200 was written correctly ($00 = release)

Example ROMs

Example What it demonstrates
sa1_hello Boot diagnostic — verifies SA-1 initialization
sa1_starfield 128 sprites with sine-wave Lissajous patterns

What SA-1 Can't Do

  • No PPU access — SA-1 can compute sprite positions but can't write OAM/VRAM
  • No APU access — can't trigger sound effects
  • No WRAM access — can't read/write the main CPU's 128 KB RAM
  • No joypad reading — input comes through main CPU → I-RAM

The SA-1 is a compute accelerator. The main CPU remains the "director" — it reads input, talks to the PPU/APU, and delegates heavy math to the SA-1.

Further Reading