examples/hdma/gradient_colors: a COLDATA gradient written by HDMA, one colour step per scanline band.
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 the enable/disable calls:
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:
Repeat mode, by contrast, carries one data group per scanline — a 32-line repeat entry is followed by 32 groups, not one:
The classic silent failure is a layout mismatch, not a register property: per-line data placed under a non-repeat count makes the hardware write the first group, hold it for the whole count, then misparse the remaining data bytes as line counts — the effect appears to work for the first line of each group and then falls apart.
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.
At boot, krom's WaveHDMA idiom: 896 pre-built [1][offset16] entries on channel 2 (krom used 0, which dmaCopyVram owns here) in HDMA_MODE_1REG_2X (both bytes of BG1HOFS, $210D, every line), and the per-frame "animation" is just hdmaSetup(..., table + phase * 3) — a zero-copy pointer bump, cheaper than regenerating the table and immune to the VBlank budget. Press A and hdmaWaveH() / hdmaWaveUpdate() produce nearly the same ripple on channel 6 from a table the module computes in RAM, with the amplitude as a parameter.
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, hdmaEnableMask, hdmaDisableMask, 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: a static window band needs only non-repeat entries — the window registers latch, so one write per band holds (end the table with an empty-window entry, left > right, if the shape must stop before the bottom of the frame). The shipped example keeps the repeat-mode table because it shares its build path with the animated variant; both produce the same static masked area.
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)).
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 hdmaEnableMask(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".
krom's RedSpace9BitHDMA trick, described in examples/hdma/gradient_colors's README: one channel rewrites the backdrop colour per line (HDMA_MODE_2REG_2X into CGADD: [addr16][data16]), another 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().
The hdma module builds the table, arms the channel and keeps the state for a few classic effects. Each takes the channel to use (6 or lower — see the channel 7 gotcha below) and has a matching stop call.
hdmaColorGradientStop() stops the channel but cannot restore the colour: CGRAM keeps the last value the gradient wrote, so reload your palette entry if the scene needs it back. hdmaBrightnessGradientStop(channel) ends an hdmaBrightnessGradient() and sets the screen back to full brightness.
hdmaIrisWipe() sets up the window registers for you, and it waits for a VBlank inside, before switching the window mask on: each call costs one frame, which is what makes the loop above an animation.
hdmaWaveInit() switches every HDMA channel off, yours included: call it before setting up your own channels, not after. The ripple and hdmaWaveH() share the same buffers, so only one of them runs at a time; hdmaWaveStop() ends either.
hdmaWindowShape(channel, table) points a channel at your own table of window edges (a line count, then left and right, written to WH0/WH1). It only configures the channel: enable it with hdmaEnableMask() and choose which layers the window masks with the window module, as examples/windows/transparent_window does.
Two calls read or reset the enable state the module keeps: hdmaGetEnabled() returns the bitmask of channels the module has enabled, and hdmaDisableAll() switches them all off.
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 used to hardcode bank $00 for its table, so a static const table that the linker placed elsewhere was read from the wrong bank, and hdmaSetupBank existed to pass the right one by hand (removed 2026-10-05). Since pointers became far pointers (A6) hdmaSetup reads the bank byte of the table pointer itself: a const table works from any bank — which is where const data lives by default since v0.41.0 — and so does a RAM table, on LoROM and on HiROM. hdmaSetupBank is removed (2026-10-05, deprecated since 2026-09-20): it carries two banks and the explicit one wins. Use hdmaSetup.
Every PPU register latches what you write — there is no register class that forgets its value between scanlines. The mode choice follows the data, not the register:
| Data shape | Examples | Mode |
|---|---|---|
| Constant value per band | colour stripes, letterbox window, scroll split | non-repeat |
| Fresh value every line | scroll wave, smooth gradient, moving window edge | repeat |
The two layouts differ (non-repeat = one data group held for the count; repeat = one data group per line of the count), so mixing them misparses the table — the effect works for the first line of each group, then falls apart. If you suspect this, dump the table bytes and walk them against the format above, or inspect the frame in luna.
$210D/$210E and the Mode 7 registers $211B-$2120 share a single write-twice latch. An HDMA channel (or IRQ) that writes a BG1 scroll register between the two writes of a Mode 7 register silently corrupts the value — and the Mode 7 multiplier result in $2134-$2136. See the matching gotcha in the Mode 7 tutorial.
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().
hdmaEnableMask(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):
A scanline is 1364 master cycles (about 1324 usable after the DRAM refresh pause — roughly 227 CPU cycles at 3.58 MHz FastROM), so a single HDMA channel costs about 1–3 % of the bus time. Two or three active channels are the practical limit before the cost shows up as missed timing in inner game-logic loops.