This tutorial covers SNES DMA — the foundational mechanism for moving bytes between WRAM/ROM and the PPU. It's the most-used facility in the SDK (every example with graphics goes through dmaCopyVram), and the gotchas it carries (VBlank discipline, channel reservations, bank-byte trap, the 4 KB-per-frame budget) come up over and over.
If you've already read the HDMA tutorial, the channel / register architecture will look familiar — the DMA registers are the same physical channel registers, used in a different mode. Read DMA first, then HDMA: HDMA is a specialisation that streams data per scanline, while DMA is the bulk-transfer baseline.
A DMA transfer moves a contiguous block of bytes between WRAM/ROM and a PPU register, off-CPU. The CPU stalls for the duration of the transfer (it does not keep running), but the per-byte cost is far below what a hand-rolled lda/sta loop would manage:
The faster per-byte rate plus the lack of loop overhead means DMA is strictly better for any transfer over a handful of bytes. The lib uses DMA for every VRAM, CGRAM, and OAM update.
The same physical channels and registers serve both modes. The difference is when the transfer fires:
| DMA (sometimes "MDMA" — general-purpose DMA) | HDMA | |
|---|---|---|
| Trigger | Synchronous, on $420B write (you say "go") | Per-scanline, automatic, gated by $420C enable bits |
| Use | Bulk transfers (load tile data, palette, full OAM) | Per-scanline effects (gradients, parallax, perspective) |
| Cost | One CPU stall per transfer | Continuous overhead while enabled |
| Window | Force blank (any time) or VBlank (small budget) | Active display only |
A typical project uses DMA for setup (load assets at boot, refresh OAM each frame) and HDMA for effects during display.
The SNES has eight DMA channels, numbered 0–7. They share a single register set per channel ($43n0–$43nA) but are otherwise independent — each can run its own transfer. The lib reserves two of them:
| Channel | Reserved for | Notes |
|---|---|---|
| 0 | Main-thread DMA helpers (dmaCopyVram, dmaCopyCGram, dmaFillVRAM, etc.) | Used every time you call those functions. Re-using channel 0 for HDMA collides with these calls. |
| 1–6 | Free for user use | This is where HDMA goes. |
| 7 | NMI handler's OAM DMA | Pre-armed at boot to OAM-DMA params; the NMI handler triggers it conditionally on oam_update_flag. Do not configure HDMA on channel 7 — see KNOWN_LIMITATIONS.md for why. |
If you use only the lib's helpers, you never touch the channel registers directly. If you write a custom DMA path (Mode 7 interleaved load, multi-channel HDMA, etc.), pick channels 1–6.
The same modes as HDMA — the DMAPn register's low 3 bits encode them. For DMA, the mode determines how the source is written to a PPU register sequentially (vs HDMA's per-scanline cadence):
| Mode | Bytes per "unit" | Pattern |
|---|---|---|
| 0 | 1 | reg |
| 1 | 2 | reg, reg+1 (the common case for VRAM word writes via $2118/$2119) |
| 2 | 2 | reg, reg (same register twice — for CGRAM via $2122) |
| 3 | 4 | reg, reg, reg+1, reg+1 |
| 4 | 4 | reg, reg+1, reg+2, reg+3 |
| 5 | 4 | reg, reg+1, reg, reg+1 |
| 6 | 2 | reg, reg |
| 7 | 4 | reg, reg+1, reg+2, reg+3 |
The lib's dmaCopyVram uses mode 1 (16-bit VRAM word writes via $2118/$2119); dmaCopyCGram uses mode 0 with destination $2122 (CGDATA). You rarely need to think about modes unless you're writing a custom DMA helper.
Two more bits of the DMAPn register matter:
The lib's helpers set these correctly for their use case. Custom paths need to set them in $43n0.
This is the most important rule in the entire SNES API surface, and the one that catches the most newcomers:
The PPU only accepts writes to VRAM, CGRAM, and OAM during VBlank or while the screen is in force blank (INIDISP bit 7 set). Outside those windows, writes are silently dropped.
Two windows, two patterns:
A dmaCopyVram() call outside both windows produces a build that compiles, links, and runs with no error, then displays garbage tiles or nothing at all. The catch is silent. KNOWN_LIMITATIONS.md flags this as the canonical 🔴 silent-corruption mode.
Bulk asset load at boot, the most common DMA use:
The init order — consoleInit → setMode → DMAs → bgSet*Ptr → setMainScreen → setScreenOn — is the canonical pattern. See the new_example.md rule for the full checklist; getting it wrong produces a black screen or garbage on the first frame.
For game state that mutates during play (tilemap streaming as the camera scrolls, palette cycling, dynamic sprite tile uploads), the DMA happens every frame inside VBlank:
Two non-obvious points:
If a per-frame transfer exceeds ~4 KB, split it across multiple VBlanks (the "1-page-per-VBlank" pattern documented in KNOWN_LIMITATIONS.md). For very large updates that can tolerate a visible flash, drop into force blank:
| Function | What it does |
|---|---|
| dmaCopyVram(src, vramAddr, size) | Copy bytes from WRAM/ROM to VRAM. The bank is taken from src's own bank byte, so the source may live in any bank. Mode 1 (write $2118/$2119). The workhorse. |
| dmaCopyVramBank(src, bank, vramAddr, size) | Same, for when the address and bank are held separately (16-bit offset + explicit bank byte). |
| dmaFillVRAM(value, dest, size) | Fill a VRAM region with a fixed 16-bit value (fixed-source mode). Used to clear tilemaps. |
| dmaClearVRAM(void) | Zero all 64 KB of VRAM. Boot-time use only (force blank required). |
| dmaCopyCGram(src, startColor, size) | Copy palette data to CGRAM. Mode 0 (write $2122). |
| dmaCopyCGramBank(src, bank, startColor, size) | Same, with explicit source bank. |
| dmaCopyOam(src, size) | One-shot OAM transfer, write $2104. Mostly used at init — the NMI handler does the per-frame OAM DMA automatically. |
| dmaCopyVramMode7(tilemap, mapSize, tiles, tilesSize) | Two-pass interleaved DMA for Mode 7's split low-byte/high-byte VRAM layout. See the Mode 7 tutorial. |
| dmaTransfer(channel, mode, srcBank, srcAddr, destReg, size) | Generic DMA — pick your own channel, mode, destination register. Use when the named helpers don't fit (e.g., transfers to $2180 WRAM data port, or experimental modes). |
The first thing to internalise. The PPU returns silently from a write to $2118, $2119, $2122, $2104, etc. when active display is on. There is no error, no flag, no diagnostic. The next time you turn the screen on, the writes you "did" weren't.
Mitigation: every DMA-touching function in the lib calls into a DMA register sequence that assumes VBlank or force blank. The discipline is yours: wrap heavy bulk transfers in setScreenOff()/setScreenOn(), and put per-frame transfers after WaitForVBlank().
NMI runs for ~35,000 master cycles after VBlank starts (the lib's NMI handler reserves some of that for OAM DMA, scroll sync, joypad read, user callback, etc.). DMA costs ~8 cycles per byte. Net: about 4 KB of user DMA before the PPU starts sampling registers for active display while you're still writing.
Exceed it and the PPU starts reading mid-DMA — you get garbage tiles on the top scanlines for that frame. The corruption is visible, not silent — but it's also intermittent and hard to debug because it depends on game state.
Mitigation: count your bytes. If a per-frame update needs > 4 KB, either drop into force blank (visible flash, OK for boss intro / scene transition) or split across multiple VBlanks ("1-page-per-VBlank" pattern — KNOWN_LIMITATIONS.md documents the canonical form).
dmaCopyVram(src, vramAddr, size) takes the source bank from src's own bank byte, so an asset the linker placed in bank $01 or higher (because bank $00's 32 KB ROM filled up — see .claude/rules/bank0_budget.md) transfers correctly with no extra work. dmaCopyVramBank() remains for the rarer case where the address and bank are held separately.
dmaCopyVram and friends use channel 0. If you also configure HDMA on channel 0 (which the lib documents as discouraged but doesn't actively prevent), the next dmaCopyVram call clobbers the HDMA setup. Same class of bug as the OAM-DMA-on-channel-7 trap — different channel.
Mitigation: HDMA on channels 1–6.
The lib's NMI handler triggers an OAM DMA every frame when oam_update_flag is set (since the perf fix in commit 6d438e6). You don't need to call dmaCopyOam() per frame — the runtime does it for you. Use dmaCopyOam() only at init (before the first WaitForVBlank), or for one-off forced refreshes.
If you write directly to oamMemory[], set oam_update_flag = 1 yourself. The lib's oamSet, oamSetXY, oamClear, etc. and the oamSetFast / oamSetXYFast macros do this for you. See KNOWN_LIMITATIONS.md (Performance traps) for the canonical pattern.
Mode 7 stores tilemap and tile data interleaved in the low and high bytes of each VRAM word. A single dmaCopyVram writes the same source byte to both halves — wrong. Use dmaCopyVramMode7(tilemap, tilemapSize, tiles, tilesSize) which performs two passes with the right VMAIN/VMDATAL/VMDATAH configuration, or write the equivalent assembly helper. See the Mode 7 tutorial.
Main-thread code sometimes writes multi-byte sequences via $2180 after setting an address with $2181-$2183. If NMI fires mid-sequence and any code in the NMI path touches those ports, the address pointer corrupts silently and the main thread resumes writing garbage to a wrong location.
The lib's NMI handler in templates/crt0.asm never touches $2180-$2183. If you write a custom nmiSet() callback, do not use any function that goes through these ports. This is documented in KNOWN_LIMITATIONS.md and called out inline in templates/crt0.asm:798-802.
DMA is the fastest bulk transfer the SNES offers. Approximate cost per transfer:
A 1 KB DMA: 12 + 1024 × 8 + 8 ≈ 8,212 cycles, ~0.6 % of a 60 Hz frame.
A 4 KB DMA: 12 + 4096 × 8 + 8 ≈ 32,800 cycles, ~2.4 % of a 60 Hz frame (and right at the VBlank budget — only works during force blank or with careful timing).
The lib's NMI handler reports its DMA cost as part of its overall budget; the perf fix in commit 6d438e6 removed an unnecessary 544-byte OAM DMA per frame for sprite-idle ROMs (~4,300 cycles regained, ~12 % of VBlank budget).