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 (same bytes-to-registers result as mode 1) |
| 6 | 2 | reg, reg (same as mode 2) |
| 7 | 4 | reg, reg, reg+1, reg+1 (same as mode 3 — not four consecutive registers; that is mode 4) |
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:
isInVBlank() returns 1 while the PPU is in VBlank (it reads the VBlank flag of HVBJOY, $4212 bit 7 — anomie's timing doc, S-CPU registers): a cheap assertion for code that must only run there. Force blank is a separate question, which isInVBlank() does not answer.
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:
A game that streams graphics while it plays — a sprite's new frame, the row or column of map a scroll uncovered — makes several small transfers in the same VBlank. Each dmaCopyVram() call spends more on its arguments and its setup than on a 128-byte transfer itself, and all of it in the VBlank, the one stretch of the frame where time is short.
<snes/vramqueue.h> (module vramqueue) splits the work in two. During the frame the game notes each transfer; in VBlank one assembly routine sends them all:
VRAM_QUEUE_COLUMN steps 32 words after each word, for a column of a 32-wide tilemap.
A streamed sprite frame is several strips — one per row of tiles, 512 bytes apart in the sheet opensnes-sprite sheet writes and 256 words apart in VRAM. vramQueuePushSprite(src, addr, size_px) notes them all in one call (two strips for 16x16, four for 32x32), or none if they do not all fit.
Be clear about what it buys. Six 128-byte transfers, measured (devtools/libbench):
| Whole cost | Of which in VBlank | |
|---|---|---|
| six dmaCopyVram() calls | 16,600 | 16,600 |
| six vramQueuePush() and one vramQueueFlush() | 21,700 | 10,700 |
The queue costs more in total — noting an entry is a call too — and a third less in VBlank. Use it when the VBlank is what you are short of; for one or two uploads, or in force blank, call dmaCopyVram().
The budget stays yours: what does not fit in the VBlank lands in active display and is ignored by the PPU, with no error. A real project (issue #165) found six 512-byte sprite frames to be the most that fit after the NMI handler, and queues five.
| 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 | Removed (2026-10-05; deprecated since 2026-09-20). It predated far pointers: dmaCopyVram reads the bank from src. |
| 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 | Removed (2026-10-05). Use dmaCopyCGram. |
| 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, src, destReg, size) | Generic DMA — pick your own channel, mode, destination register; the bank comes from src. Use when the named helpers don't fit (e.g., transfers to $2180 WRAM data port, or experimental modes). Until 0.48 it took the bank and the address as two arguments. |
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.
Two sharpening details from the hardware references: writes are also ignored during HBlank (only VBlank and force blank are safe), and the VRAM address in VMADD still auto-increments even when the write is ignored — which is why a transfer that straddles the end of VBlank resumes at the wrong VRAM address and shifts everything written after it.
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().
VBlank lasts 37 scanlines on NTSC with the standard 224-line display — about 50,500 master cycles (22 scanlines, ~30,000 cycles, with overscan). The lib's NMI handler reserves part of that for OAM DMA, scroll sync, joypad read and the user callback; DMA moves 1 byte per 8 master cycles, so the raw ceiling is ~6 KB and the practical user budget is about 4 KB 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. The explicit-bank copy of 0.x, dmaCopyVramBank, was removed on 2026-10-05: it predated far pointers and ignored the bank of src.
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).