OpenSNES
Modern Open-Source SNES Development SDK
Loading...
Searching...
No Matches
HDMA Tutorial
hdma example

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.

What HDMA actually is

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:

  1. Timing — the PPU's HBlank window between scanlines, every scanline, for as long as the channel stays enabled. Not on demand; it just keeps running once configured.
  2. Cadence — you supply a table in memory that says: write this value(s) for N scanlines, then write that value for M scanlines, then stop. The 5A22's DMA controller walks the table as the frame is drawn, pausing the CPU for each per-scanline transfer.
  3. Cost — HDMA reads steal a few machine cycles from each scanline. The 65816 still runs your code, just slightly slower during active display. You're paying for the per-scanline write.

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 four channel registers

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:

  • hdmaSetup(channel, mode, destReg, table) — for any table, RAM or const, in any bank: the bank is read from the table pointer.
  • hdmaSetupBank, the explicit-bank form, was removed on 2026-10-05 — it predated far pointers.

…and the enable/disable calls:

  • hdmaEnable(channel) / hdmaDisable(channel) — one channel by number, 0-7, like every other call of the header. A value above 7 is refused: it is a 0.x bit mask left behind (until 0.48 these two names took the mask).
  • hdmaEnableMask(channelMask) / hdmaDisableMask(channelMask) — several channels at once: set or clear their bits in $420C.

Transfer modes

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?".

Table format

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:

  • Bit 7 = 0 — non-repeat mode. The entry carries one scanline worth of data. It is written once, then the destination register holds the value for N scanlines (every PPU register latches what you write). Right whenever the value is constant across the band.
  • Bit 7 = 1 — repeat mode. The count says how many scanlines worth of data follow — one fresh group written per line. Needed only when the value genuinely changes every line (scroll wave, smooth gradient, moving window edge).

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:

const u8 gradient_table[] = {
32, 0x00, /* 32 scanlines: COLDATA = 0x00 */
32, 0x08, /* 32 scanlines: COLDATA = 0x08 */
32, 0x10, /* 32 scanlines: COLDATA = 0x10 */
32, 0x18, /* 32 scanlines: COLDATA = 0x18 */
32, 0x20, /* 32 scanlines: COLDATA = 0x20 */
32, 0x28, /* 32 scanlines: COLDATA = 0x28 */
32, 0x30, /* 32 scanlines: COLDATA = 0x30 */
0, /* End */
};
unsigned char u8
8-bit unsigned integer (0 to 255)
Definition types.h:47

Repeat mode, by contrast, carries one data group per scanline — a 32-line repeat entry is followed by 32 groups, not one:

const u8 scroll_wave_table[] = {
0xA0, /* $80 | 32 → 32 scanlines of per-line data */
0x10, 0x00, /* line 0: BGnHOFS = 0x0010 */
0x11, 0x00, /* line 1: BGnHOFS = 0x0011 */
/* ... 30 more { lo, hi } pairs, one per scanline ... */
0, /* End */
};

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.

Setup pattern

The full sequence to put HDMA on screen:

#include <snes.h>
#include <snes/hdma.h>
const u8 my_table[] = { /* … as above … */ };
int main(void) {
/* … load tiles, palettes, tilemap … */
/* Configure HDMA channel 6 to write to COLDATA ($2132) */
/* Enable channel 6 — HDMA starts firing on the next active display */
while (1) {
}
}
int main(void)
Definition main.c:66
void consoleInit(void)
Initialize SNES hardware.
void WaitForVBlank(void)
Wait for next VBlank period.
void setScreenOn(void)
Enable screen display.
#define BG_MODE1
Definition video.h:35
SNES HDMA (Horizontal-blanking DMA).
void hdmaEnableMask(u8 channelMask)
Enable HDMA channel(s), given as a bit mask.
#define HDMA_MODE_1REG
HDMA mode: 1 register, 1 byte.
Definition hdma.h:150
void hdmaSetup(u8 channel, u8 mode, u8 destReg, const void *table)
Set up an HDMA channel.
#define HDMA_CHANNEL_6
HDMA channel 6 (recommended for HDMA — never used by the runtime).
Definition hdma.h:135
OpenSNES common-case master header.
void setMode(u8 mode, u8 flags)
Set background mode.

Two things often forgotten:

  1. The destination register argument is the low byte of the PPU register address, not the full 16-bit address. 0x32 for $2132 (COLDATA), 0x0D for $210D (BG1HOFS), 0x21 for $2121 (CGADD), etc. See lib/include/snes/hdma.h for the named constants (HDMA_DEST_COLDATA, HDMA_DEST_BGnHOFS, etc.).
  2. Enable comes last. Configure first, then flip $420C. Enabling before the source/mode/destination are settled gives the PPU a frame of garbage HDMA reads.

Worked patterns (the shipped examples)

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.

Per-scanline wave: by hand, then by the module — examples/hdma/hdma_wave

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.

Parallax scrolling — examples/scrolling/parallax_scroll

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).

Static colour gradient — examples/hdma/gradient_colors

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.

Helpers showcase — examples/hdma/hdma_helpers

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.

Window animation — examples/windows/transparent_window

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.

Stationary window — examples/windows/window

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.

Indirect HDMA — examples/hdma/hdma_indirect_gradient

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)).

Full Mode 7 matrix per line — examples/mode7/perspective_rotate

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".

Two channels, one visual — the "9-bit" backdrop

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 module's ready-made effects

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.

/* Sky: palette colour 0 from deep blue at the top to orange at the bottom */
hdmaColorGradient(HDMA_CHANNEL_6, 0, RGB(4, 8, 28), RGB(28, 16, 4));
/* ... */
void hdmaColorGradient(u8 channel, u8 colorIndex, u16 topColor, u16 bottomColor)
Create a per-scanline CGRAM color gradient.
void hdmaColorGradientStop(u8 channel)
Stop color gradient effect.
#define RGB(r, g, b)
Create RGB color value.
Definition video.h:88

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.

/* Iris: only a circle of BG1 stays visible */
u8 r;
for (r = 0; r < 128; r += 2) {
}
hdmaIrisWipeStop(HDMA_CHANNEL_6); /* also clears the window registers */
#define TM_BG1
Definition registers.h:443
void hdmaIrisWipeStop(u8 channel)
Stop iris wipe effect and restore window registers.
void hdmaIrisWipe(u8 channel, u8 layers, u8 centerX, u8 centerY, u8 radius)
Create a circular window mask (iris/spotlight effect).

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.

/* Underwater: BG1 ripples, more at the bottom of the screen */
hdmaWaterRipple(HDMA_CHANNEL_6, 0, 8, 2); /* bg, max amplitude, speed */
while (underwater) {
}
hdmaWaveStop(); /* stops it and resets the BG's scroll */
void hdmaWaveStop(void)
Stop wave effect and disable HDMA channel.
void hdmaWaveInit(void)
Initialize HDMA wave effect system.
void hdmaWaveUpdate(void)
Update wave animation.
void hdmaWaterRipple(u8 channel, u8 bg, u8 amplitude, u8 speed)
Create a water ripple distortion effect.

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.

When HDMA isn't enough: H-timer IRQ streaming

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.

Gotchas

🔴 Channel 7 is reserved for OAM DMA

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.

🟢 Bank byte trap on hdmaSetup (resolved)

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.

🟡 Repeat-mode discipline matters

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.

🟡 HDMA on BG1 scroll can corrupt Mode 7 writes (shared latch)

$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.

🟡 HDMA fires before the user callback in the NMI handler

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().

🟡 Don't enable channels you haven't configured

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:

hdmaEnableMask(1 << HDMA_CHANNEL_6); /* good */
hdmaEnableMask(0xFF); /* bad — channels 0-5, 7 are random */

Cycle cost

HDMA reads steal cycles from the active-display window every scanline. Approximate budget (FastROM off):

  • Per-scanline overhead per active HDMA channel: ~8 cycles for setup, plus ~8 cycles per byte transferred.
  • A 1-byte non-repeat table running on one channel: ~16 cycles per scanline, ~3.5 K cycles per frame.
  • A 4-byte repeat table on one channel: ~40 cycles per scanline, ~9 K cycles per frame.

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.

See also