This tutorial covers SNES HDMA (Horizontal-blanking DMA): what it is, when to reach for it, the four registers per channel, the eight transfer modes, and the per-scanline patterns the six shipped examples exercise. It assumes you have already worked through the Graphics and Scrolling tutorials.
A regular DMA (often written "MDMA" — general-purpose DMA — when you want to disambiguate from HDMA) moves a block of bytes between WRAM/ROM and a PPU register, synchronously, at the CPU's request. It happens once when you trigger it.
HDMA is different in three ways:
The classic uses are effects whose look changes vertically across the frame: a colour gradient (top different from bottom), a wave that bends the picture, a parallax background where each layer scrolls at its own rate, a window mask that follows the screen down, a brightness fade. Anything that needs "the value of register X is different on scanline 100 than it was on scanline 50" is reaching for HDMA.
If your effect is the same across the whole frame, set the register once at VBlank and stop. HDMA pays its scanline cost regardless of whether the value actually changes per line.
The SNES has eight DMA channels (0–7). Each channel has the same set of registers; HDMA configures them slightly differently from MDMA.
For HDMA channel n (where n is 0–7), the addresses are $43n0, $43n1, $43n2, $43n3, $43n4. The lib defines them as HDMA_CHANNEL_n.
| Register | Name | Purpose |
|---|---|---|
| $43n0 | DMAPn | Transfer mode (3 bits) + reverse / direct addressing flags |
| $43n1 | BBADn | The PPU register the writes target — the low byte of $21nn |
| $43n2–$43n4 | A1Tn / A1Bn | 24-bit source address of the table (A1Tn low+high, A1Bn bank) |
| $420C | HDMAEN | Bitmask: which channels run HDMA this frame |
You set those, set the corresponding bit of $420C, and let the PPU drive the rest.
The lib hides those four register writes behind two helpers:
…and a separate enable/disable pair:
The "what does each table entry write" axis. Eight modes, set via the low 3 bits of DMAPn:
| Mode | Bytes per write | Goes to | Typical use |
|---|---|---|---|
| 0 | 1 | reg | INIDISP brightness, MOSAIC, single-byte registers |
| 1 | 2 | reg, reg+1 | BG scroll (BGnHOFS writes $210D low then high), CGADD/CGDATA pairs |
| 2 | 2 | reg, reg | Two-byte writes to the same register (e.g. CGADD-then-CGADD) |
| 3 | 4 | reg, reg, reg+1, reg+1 | Mode 7 matrix elements |
| 4 | 4 | reg, reg+1, reg+2, reg+3 | Window position pair (WH0L, WH0H, WH1L, WH1H) |
| 5 | 4 | reg, reg+1, reg, reg+1 | Pair-of-2-byte registers |
| 6 | 2 | reg, reg | Same as mode 2, alternate latching |
| 7 | 4 | reg, reg+1, reg+2, reg+3 | Same as mode 4 |
The lib defines named constants — HDMA_MODE_1REG, HDMA_MODE_1REG_2X, HDMA_MODE_2REG, etc. Picking the right mode is mostly a question of "what register am I writing, and how many bytes does it want?".
Every HDMA table is a sequence of groups. Each group starts with a line-count byte, followed by N × byteCount data bytes (where byteCount is the bytes-per-write of the transfer mode and N depends on whether the high bit of the line count is set).
The line-count byte's high bit is the repeat flag:
A table ends with a line-count byte of 0.
Worked example, non-repeat — a gradient that writes one byte per group to set background colour intensity per stripe:
Same effect with repeat mode would write every scanline:
The "wrong mode" failure is silent and visible: scroll registers in non-repeat mode write only the first scanline of each group, then the PPU's latched scroll value drifts for the rest. You see the effect "glitching" instead of holding.
The full sequence to put HDMA on screen:
Two things often forgotten:
Every shipped example exercises a distinct HDMA case. Read the @par What to Observe block at the top of each main.c for the interactive demo; the patterns themselves are reusable building blocks.
Pre-computes seven sine tables (224 + 111 wrap entries each) at amplitudes 0–24 pixels, then runs HDMA channel 6 in HDMA_MODE_1REG_2X to write both bytes of BG1HOFS ($210D) every scanline. Animation advances the table pointer by 3 bytes per frame to scroll the wave continuously. The alternating solid/empty tile pattern in the background makes the distortion clearly visible.
A static BG3 with a moving BG1 on top, where BG1 scrolls at one rate at the top of the screen and a different rate at the bottom — classic two-speed parallax. The HDMA table is regenerated each frame in RAM (non-const) to update the scroll offsets, written to BG1HOFS. This example is also a real-world demonstration of why HDMA tables that change every frame want to be in RAM rather than in const ROM (see "bank byte trap" below).
A simpler form of the brightness gradient: a fixed table in ROM written to CGADD/CGDATA changes the BG palette colour on different rows, giving a banded sky. Useful pattern when the gradient never animates and the table can live in const ROM.
Walks through the lib's HDMA helpers (hdmaSetup, hdmaSetupBank, hdmaEnable, hdmaDisable, the brightness-gradient builder) on a single screen, with on-screen text labelling each. The example to skim when you want to remember the API surface.
Combines HDMA with the window/colour-math pipeline. HDMA writes to the window position registers (WH0L, WH0H, WH1L, WH1H) per scanline to animate a moving window, while colour math blends the BG1 layer through the window onto the BG2 layer beneath. Mode 4 transfer (4 bytes, 4 sequential registers) does the four window writes in one HDMA group.
The non-animated cousin: HDMA in repeat mode writes to the window registers once (and then "every scanline" since window registers are write-only and forget on the next scanline). The window position holds across the visible region; the effect is a static masked area rather than animation.
hdmaSetupIndirect() (DMAP bit 6): table entries hold POINTERS to the payload instead of the payload itself, letting many scanline bands share data blocks. The example reproduces krom's RedSpace gradient pixel-exactly with 32 shared 4-byte CGRAM blocks. The data bank for the pointed-to blocks goes in $43x7 — pass it with the bank-extraction idiom ((u8)((u32)(void *)table >> 16)).
krom's WaveHDMA idiom: 896 pre-built [1][offset16] entries, and the per-frame "animation" is just hdmaSetup(..., table + phase * 3) — a zero-copy pointer bump. Cheaper than regenerating tables and immune to the VBlank budget.
Four channels, one per matrix register (M7A/B/C/D ← HDMA_DEST_M7A..D), each in HDMA_MODE_1REG_2X. See the Mode 7 tutorial for the technique; the HDMA lesson here is arming: hdmaSetup() configures but does NOT enable — without hdmaEnable(0x0F) you get a static 1:1 view that can look convincingly like a broken perspective. Check dma.hdmaen in luna's typed state when an HDMA effect "does nothing".
Channel 0 rewrites the backdrop colour per line (HDMA_MODE_2REG_2X into CGADD: [addr16][data16]), channel 1 rewrites INIDISP brightness per line. Colour x brightness plus per-line jitter dithers the gradient into more perceptual steps than the PPU's 5 bits — and INIDISP is owned by the stream: the demo never calls setScreenOn().
HDMA's widest mode moves 4 bytes per scanline. Some techniques need more — HiColor reloads 16 bytes (8 colours) of CGRAM every line. The tool for that is the H-timer IRQ: irqSet() a raw ASM handler, irqSetHTimer(190) so it fires near the end of the visible line, irqEnable(IRQ_HTIMER) — the handler fires a general DMA whose source auto-advances across transfers. See examples/color/hicolor_1792 (1792 colours from a 4bpp BG) and the loud contract in <snes/interrupt.h>: handlers are ASM-only (a C callback cannot afford per-scanline prologue latency), must ack $4211, and must save what they touch. Plain C *///% in NMI callbacks is safe (the runtime switches to software paths there), but fixMul()/fixLerp() are not — see KNOWN_LIMITATIONS.
The runtime's NMI handler uses DMA channel 7 to transfer the OAM shadow buffer to the PPU each frame. Do not configure HDMA on channel 7 — the OAM DMA will overwrite your HDMA register setup, the HDMA will read garbage, and you will spend an hour wondering why your gradient disappears every few frames. Use channels 1–6 for HDMA. Channel 0 is also taken by dmaCopyVram, so if you have a hot-loop that calls it, prefer channels 1–6 for HDMA.
This is enforced by convention, not by code. The lib's documentation (lib/include/snes/hdma.h) names the trap explicitly.
hdmaSetup hardcodes bank $00 for ROM source addresses (≥ $8000). If your table is a static const u8 mytable[] = … and bank $00's 32 KB filled up, the linker spills the table into bank $01+ — but hdmaSetup still tells the PPU to read from bank $00, address X. The PPU happily reads garbage from wherever bank $00 address X lands.
Two fixes:
The bank-overflow check (make/common.mk → symmap.py) flags when bank $00 is filling up; pair that with an explicit choice of hdmaSetupBank when const tables get big.
Three classes of registers, three rules:
| Register class | Examples | Mode |
|---|---|---|
| Self-latching (PPU holds value) | INIDISP, COLDATA, CGADD, MOSAIC | non-repeat OK |
| Per-scanline-consumed | BG scroll, Mode 7 matrix | repeat required |
| Write-only / forgets | WH0L/H, WH1L/H | repeat required |
Wrong mode for the second/third class shows as "the effect works for the first scanline of each group, then the latched value drifts". Verify in Mesen2's tile/window viewer if you suspect this.
templates/crt0.asm's NMI handler runs the VRAM-critical work (OAM DMA, tilemap DMA, scroll sync) first, then calls your nmiSet() callback, then reads input. HDMA configuration done in the user callback takes effect next frame, not the current one. If you need to reconfigure HDMA each frame (e.g. to swap tables), do it in main-thread code before WaitForVBlank().
hdmaEnable(0xFF) enables every HDMA channel, including channels that hold leftover register values from a previous configuration (or boot defaults). Always enable only the channels you've configured this frame:
HDMA reads steal cycles from the active-display window every scanline. Approximate budget (FastROM off):
The 65816 has roughly 1369 cycles per scanline at 3.58 MHz, so a single HDMA channel costs 0.6 % – 3 % of CPU time. Two or three active channels are the practical limit before the cost shows up as missed timing in inner game-logic loops.