This tutorial covers SRAM — the battery-backed RAM region on a SNES cartridge that survives power-off, used for save games on every cart that has one (Zelda, Final Fantasy VI, Super Mario World, …). The lib exposes a small set of byte-array helpers; the discipline lives in treating SRAM as untrusted data that needs validation on every load.
It assumes you have read the Graphics tutorial. The DMA tutorial is useful background but not required — SRAM access goes through plain CPU loads/stores, not DMA.
A SNES cartridge contains:
Sizes range from 2 KB (early carts, Final Fantasy II) up to 32 KB (RPGs, Final Fantasy VI). The lib supports the standard sizes via SRAM_SIZE_2KB, SRAM_SIZE_4KB, SRAM_SIZE_8KB (the common case), SRAM_SIZE_16KB, SRAM_SIZE_32KB.
On real hardware, two failure modes:
Both are silent failures. Your code must validate SRAM on every load — magic number, checksum, version field. A blind sramLoad() into a struct gives you whatever was in that battery-backed memory, correct or not.
The SNES MMU maps SRAM into bank space differently for LoROM vs HiROM:
| Layout | SRAM banks | SRAM addresses | Notes |
|---|---|---|---|
| LoROM | $70–$7D | $70:0000–$77:7FFF | 32 KB max (eight 4 KB regions). Most common. |
| HiROM | $30–$3F (mirror to $B0–$BF) | $30:6000–$3F:7FFF | 8 KB per bank in the lower half, 32 KB max. |
| SA-1 | $40–$4F (BW-RAM) | $40:0000–$40:7FFF | The cart's battery RAM; the SDK declares 32 KB. The SNES CPU may write it only once SBWE ($2226) is set, which crt0 does. |
The lib's sramSave/sramLoad hide the layout: you pass a pointer and a byte count, and the helper uses $70:0000 on a LoROM build, $30:6000 on a HiROM one and $40:0000 (BW-RAM) on an SA-1 one. Two limits to know:
This paragraph claimed the helpers "hide these details" long before they did — until 2026-09-20 they used the LoROM address on every build. The HiROM mapping is pinned by testing/fixtures/libtests_hirom, which reads the bytes back from $30:6000.
Two changes to enable SRAM in a project:
The SRAM_SIZE numeric value matches the ROM header's SRAMSIZE byte. Map:
| SRAM_SIZE | Cart SRAM | Header byte |
|---|---|---|
| 1 | 2 KB | $01 |
| 2 | 4 KB | $02 |
| 3 | 8 KB | $03 |
| 4 | 16 KB | $04 |
| 5 | 32 KB | $05 |
Most homebrew picks 8 KB unless you genuinely need more. Larger SRAM declarations affect emulator save-state allocation and on real flash carts the actual reserved region.
The build system fills the SNES ROM header automatically based on USE_SRAM and SRAM_SIZE:
Emulators read these bytes at ROM load to know how big to allocate the SRAM buffer and where to persist the .srm file. Mismatched declarations (Makefile says 8 KB, header says 2 KB) produce silent "my saves disappear after 2 KB worth of data" bugs.
| Function | Purpose |
|---|---|
| sramSave(data, size) | Copy size bytes from WRAM data to SRAM offset 0. |
| sramLoad(data, size) | Copy size bytes from SRAM offset 0 to WRAM data. |
| sramSaveOffset(data, size, offset) | Save to SRAM at byte offset — used for multi-slot saves. |
| sramLoadOffset(data, size, offset) | Load from SRAM at byte offset. |
| sramClear(size) | Zero size bytes of SRAM starting at offset 0. Used for "delete save". |
| sramChecksum(data, size) | Compute an 8-bit XOR checksum of data. Use this for save-integrity validation. |
The five copying functions return SRAM_OK (0), SRAM_ERR_RANGE or SRAM_ERR_NO_SRAM; a refused call copies nothing (see the gotcha below).
The byte arrays are flat — there's no filesystem on SRAM, just a linear address space starting at offset 0. You pick the layout.
Single save slot with magic-number validation:
The two non-obvious bits:
For "Slot 1, Slot 2, Slot 3" RPG-style saves, use offset addressing:
The SDK's examples/memory/save_game is exactly this pattern: two slots, distinct offsets, full struct round-trip with on-screen verification of the loaded values.
Zero the relevant region:
The next load attempt fails magic-number validation and falls back to defaults. (Don't try "delete by setting magic to invalid" — a direct write is simpler and survives partial-write failures.)
examples/memory/save_game — two save slots, each holding a SaveState { posX, posY, camX, camY }. Press A to save test data to slot 1, B to load it back; X/**Y** for slot 2. Loaded values are displayed as hex on screen so you can verify round-trip integrity. The example exercises:
It does not implement magic-number / checksum validation — that's left as the production-readiness step the tutorial above documents. For real games, always add it.
Real hardware: whatever was in the cells at cart assembly. Emulators: typically zero-filled, but flash carts and some emulator configurations preserve previous content across cart swaps. Your code must validate before trusting:
This is not optional. A sramLoad into a struct without validation gives you garbage and your game silently boots into corrupted state. The save_game example (which doesn't validate) is fine for a tech demo; production code is not.
A 10–20-year-old cartridge's coin-cell battery can fail. When it does, SRAM contents drift toward "all $00" or "all $FF" or random patterns. Your validation must catch both:
Every function of the family except sramChecksum returns a code: SRAM_OK, SRAM_ERR_RANGE (offset + size exceeds the declared SRAM) or SRAM_ERR_NO_SRAM (the header declares none — USE_SRAM := 1 is missing). A refused call copies nothing. The capacity is read from your ROM's own header, so it always matches the SRAM_SIZE you built with (capped at what the module addresses in one bank: 32 KB on LoROM, 8 KB on HiROM).
Before that the family returned void and copied whatever it was asked, past the declared size. The classic bug — SRAM_SIZE := 1 (2 KB) in the Makefile, then SLOT_SIZE = 1024 × 4 slots — now fails loudly on slot 2 if you look at the return value:
Unlike VRAM/CGRAM/OAM (which need VBlank or force blank), SRAM is plain RAM mapped into the CPU's address space. You can read or write it at any time during active display. The lib's helpers do not call WaitForVBlank() and don't need to.
This is good news ergonomically — sramSave mid-frame is fine — but newcomers from a "VBlank everything" mental model sometimes add unnecessary WaitForVBlank() calls around SRAM operations.
A pointer in WRAM points to bank $00 (or $7E) at some specific address; that address is meaningful only while the cartridge runs this exact ROM. If you save a pointer and reload it on a future boot, the pointer might be valid (same ROM) or not (different game or emulator's memory layout). Always serialise structs as plain-old-data: counts, flags, indices, NEVER pointers.
The SNES is little-endian (low byte first). sramSave/sramLoad copies raw bytes; struct fields land in the order the C compiler laid them out. Same compiler reading and writing → bit-perfect round-trip. If you ever migrate save data between PVSnesLib and OpenSNES (or any two compilers with different alignment rules), check struct layout first — padding bytes can shift between compilers.
If you write a tutorial / demo that "saves on cycle X and loads on boot", expect different behaviour across platforms. The save_game example uses two slots specifically so you can verify round-trip in a single boot cycle without depending on persistence.
sramClear(size) writes zeros to SRAM. It does not disable SRAM, prevent future writes, or "format" the chip. Re-saving after clear works as normal. The function is for "wipe save data" UI flows, not for "secure erase".
SRAM saves go through the 65816's MVN block-move (lib/source/sram.asm), which costs 7 CPU cycles per byte moved. A 256-byte save:
A full 8 KB save: ~57 K CPU cycles ≈ 430 K master cycles — slightly more than one full frame (a frame is ~357 K master cycles). SRAM access needs no VBlank window, so nothing corrupts, but the frame that runs the save arrives late. For multi-KB auto-saves during gameplay, split the save across frames (say 2 KB per frame) or accept the one-off hitch.
There is no DMA path for SRAM — general DMA cannot copy between two A-bus addresses (WRAM and cartridge SRAM both live on the A bus), so MVN is the fastest option available.