Chapter 10
DMA and the VBlank budget
What this chapter gives you: You know how many bytes you get into the VDP per frame, what every transfer costs, and how to stop your game breaking on exactly that. Milestone: milestones/07-dma. This is the chapter that decides whether your game holds 60 frames per second.
| Within the budget | Over budget, with a cap |
|---|---|
1. Why there is a budget at all
During display the VDP reads from VRAM continuously: tiles, tilemaps, sprite table. The 68000 may only write in the gaps between. How big those gaps are depends on whether a picture is being output at that moment:
| Target | display on, H40 | blanking interval or display off, H40 |
|---|---|---|
| VRAM | 18 bytes per scanline | 205 bytes per scanline |
| CRAM/VSRAM | 9 words | 102 words |
The factor of eleven between those two columns is the reason for practically every architectural decision in a Mega Drive game.
The budget in numbers
| Mode | blanking lines | bytes per frame | equivalent to |
|---|---|---|---|
| NTSC, 224 lines | 38 | 7790 | 243 tiles |
| PAL, 224 lines | 89 | 18,245 | 570 tiles |
| PAL, 240 lines | 73 | 14,965 | 467 tiles |
SGDK's own documentation is more cautious and says 7.2 KB (NTSC) and 15 KB (PAL) — the difference is the overhead of setting up each individual transfer. Reckon with 7500 bytes and you will be right.
PAL has more than twice as much. Develop only on a PAL machine and you build a game that collapses in NTSC. Test in both worlds — md-shot.sh rom 3 shots ntsc is one keystroke away (chapter 05).
2. What things cost
| Transfer | Bytes per frame |
|---|---|
Sprite table (SPR_update, 80 sprites) | 640 |
| One line-scroll table (224 lines) | 448 |
| One complete palette (16 colours) | 32 |
| One single tile | 32 |
| One tile column for level streaming (28 tiles) | 896 |
| One 32×32 sprite animation frame (16 tiles) | 512 |
A realistic frame in an action platformer:
sprite table 640
line scroll A + B 896
streamed level column 896
animation phases (4x) 2048
palette effect 32
-------------------------------
total 4512 of 7790 = 58 %The rest is headroom for explosions, bosses and the moment when everything happens at once. Plan above 70 % and you are planning frame drops.
The milestone shows this calculation live: in phase 1 the display reports 1920 bytes — 1024 for the tiles plus exactly 896 for the two line-scroll tables from chapter 07. The number from the previous chapter confirms itself, without your having to take it on faith.
3. The DMA queue
You do not transfer immediately, you register. SGDK collects the jobs and works through them during the blanking interval:
VDP_setHorizontalScrollLine(BG_B, 0, offsetB, 224, DMA_QUEUE);
DMA_queueDma(DMA_VRAM, source, dest, length_in_words, step);
u16 queued = DMA_getQueueTransferSize(); /* bytes pending */The four transfer methods and when to use them:
| Method | When | Note |
|---|---|---|
CPU | small amounts of data, any time | slow, but without side effects |
DMA | immediately, outside the picture | halts the 68000 until done |
DMA_QUEUE | the normal case in a running game | the buffer has to stay valid until VBlank |
DMA_QUEUE_COPY | when the buffer is released right away | copies the data, costs RAM |
The difference between the last two is a real source of bugs — SGDK warns about it in its own source:
// be careful, we are releasing buffer here so DMA_QUEUE transfer isn't safe here,
// use DMA_QUEUE_COPY instead for safe operation
MEM_free(t);Register a buffer and then release it and you transfer garbage at the next VBlank — and the fault appears sporadically, because it depends on how memory happens to be allocated.
4. What happens when you exceed it
If the blanking interval is not enough, the transfer runs on into the display. There the rate drops from 205 to 18 bytes per line — eleven times the time for the same data. The consequences on real hardware:
- graphics garbage in the topmost lines, because the VDP is still transferring there instead of drawing,
- a halved frame rate, because the next vertical interrupt arrives too late,
- with sound: dropouts, because the Z80 is halted during a DMA from ROM.
An honest note: in phase 4 of the milestone a measured 13,696 bytes were registered — 176 % of the budget. In MAME 0.288 this produced neither picture faults nor a measurable drop in frame rate: the phase changes still came exactly every four seconds. So the emulator does not model the access slots during display strictly.
That is not an all-clear, it is the best conceivable argument for chapter 31: faults like that are only visible on the real console. Check your DMA budget only in the emulator and you have not checked it.
5. The cap
So you build the protection yourself instead of hoping for the best:
DMA_setMaxTransferSize(7790); /* never more than an NTSC VBlank allows */
DMA_setIgnoreOverCapacity(TRUE); /* the surplus is dropped */With the cap set, SGDK works through the queue only up to the limit and leaves the rest. In the debug build a warning appears in the emulator log:
DMA_flushQueue(..) warning: frame #1234 - transfer size is above 7790 bytes (13696),
some transfers are ignored !A dropped tile is always better than a torn picture. At worst you see it one frame late — a frame drop, on the other hand, everybody notices.
The second limit is the length of the queue itself:
DMA_setBufferSize(200); /* number of jobs, not bytes */The default is 80 jobs. If you register many small transfers — when streaming column by column, say — you hit this limit before the byte budget is exhausted.
6. The rules that follow
- Never write to VRAM outside the VBlank — except deliberately, in an H-interrupt (chapter 11).
- Register everything, transfer nothing immediately, once the game is running.
- Fix a budget per subsystem and show it in the debug HUD. What you can see, you do not overrun.
- Spread the loading out: one tile column per frame instead of twenty every twenty frames. Even load always beats peaks.
- Develop in NTSC, cross-check in PAL — not the other way round.
Tips and tricks
- Show
DMA_getQueueTransferSize()permanently in the debug HUD. One single number that tells you how close to the wall you are. The milestone does exactly that. - Line scrolling only for the lines that need it. 448 bytes for 224 lines — for a water effect across 60 lines it is 120.
- Only transfer the sprite table when something has changed. In menus and while paused that saves 640 bytes per frame.
- Keep tiles compressed in ROM and transfer them unpacked during VBlank — the 68000 has plenty of time to unpack during display, while the VBlank has none to spare for transferring (chapter 12).
- Large transfers at a screen change belong between two scenes, with the display switched off (
VDP_setEnable(FALSE)): the 205 bytes then apply to every scanline, for the whole frame.
Pitfalls
SYS_getCPULoad()does not account for DMA time. In this milestone's measurements the display sat at just under 60 % almost unchanged at 1920 bytes and at 13,696. Processor load says nothing about the transfer budget — those are two separate budgets and you have to watch both.DMA_QUEUEwith a buffer that is released afterwards. See above;DMA_QUEUE_COPYis the safe route.- DMA from ROM halts the Z80. On long transfers you hear it as a click. If you are hunting sound dropouts, start here.
- A DMA source crossing a 128 KB boundary. The DMA unit's address counter wraps within a 128 KB block; SGDK handles that, your own assembly code has to do it itself (chapter 29).
- Forgetting the queue length. 200 small jobs do not fit into 80 slots — raise
DMA_setBufferSize()or combine jobs. - Testing only in PAL. Your budget is more than twice as large there. The NTSC picture breaks later, at the buyer's.
Exercise
- Set the tile count in phase 3 of
m07so that the display reports exactly 7790 bytes. How many tiles is that, given the scroll tables already take 896 bytes? - Switch the cap off in phase 4 and run the ROM on a real console (chapter 31). Describe what you see — and compare it with the emulator.
- Extend the debug HUD with a warning colour: HUD background yellow from 70 %, red from 90 % of the budget. From now on you see every overrun immediately.
Next: Chapter 11 — Raster effects
Quelltext dieses Kapitels
makefile
include $(GDK)/makefile.gen
res/resources.h
#include <genesis.h>
#ifndef _RES_RESOURCES_H_
#define _RES_RESOURCES_H_
extern const Image img_bgb;
extern const Image img_bga;
extern const SpriteDefinition spr_player;
extern const SpriteDefinition spr_drone;
#endif // _RES_RESOURCES_H_
src/main.c
/* Milestone m07 — the VBlank budget, made tangible.
*
* Four phases with rising transfer volume per frame. The readout names bytes
* and percent of the NTSC VBlank budget, the colour bar at the border shows
* the computing time used, and from phase 3 on you see what happens when the
* transfer no longer fits into the blanking interval.
*
* Budget: 38 blanking lines x 205 bytes = 7790 bytes (NTSC, H40, 224 lines).
* SGDK's own documentation says 7.2 KB — the difference is the setup cost of
* the transfer itself. */
#include <genesis.h>
#include "resources.h"
/* The planes are 256 px tall, the screen shows 224. Aligned to the bottom,
* the topmost 32 image rows stay invisible. */
/* Width of a VDP plane at 64 tiles. Every scroll value is clamped to it
* BEFORE it goes into the table.
*
* The VDP's H-scroll value is 10 bits wide and only wraps at 1024 — the plane
* already wraps at 512. Left alone, the value eventually jumps from -512 to
* +511, shifting the picture by 511 px. Because every layer has its own speed
* that happens at DIFFERENT moments: the picture tears apart. Masked, the
* offset wraps where the image joins itself seamlessly anyway, and nobody
* sees a thing. */
#define PLANE_WIDTH 512
#define WRAP(v) ((s16)((v) & (PLANE_WIDTH - 1)))
#define V_SCROLL 32
#define ROWS 224
#define PHASE_LEN 240
#define BUDGET_NTSC 7790
/* Target of the transfer: a VRAM area that is not displayed. What matters
* here is the COST of the transfer, not its content. */
#define TARGET_TILE 1000
static const u16 TILES_PER_PHASE[4] = { 32, 128, 240, 400 };
static const char *TITEL[4] = {
" 1 LITTLE - 1024 BYTES 13 PERCENT ",
" 2 A LOT - 4096 BYTES 53 PERCENT ",
" 3 LIMIT - 7680 BYTES 99 PERCENT ",
" 4 TOO MUCH - CAPPED AT 7790 ",
};
/* Layer boundaries in IMAGE rows (bgb.png is 256 px tall), not in raster
* rows: the vertical scroll of V_SCROLL shifts the two against each other.
* Entering values read off the image directly as raster rows was the bug —
* the distant peaks ended up in the sky layer and stood still. */
typedef struct { u16 from_image_row; u16 speed; } Layer;
static const Layer LAYERS[] = { {0,0}, {82,32}, {124,64}, {162,112} };
#define NUM_LAYERS 4
static s16 offsetA[ROWS], offsetB[ROWS];
static void buildOffsets(s16 camera)
{
u16 row = 0;
for (u16 s = 0; s < NUM_LAYERS; s++)
{
u16 bis = ROWS;
if (s + 1 < NUM_LAYERS)
{
/* Convert image row to raster row — without subtracting V_SCROLL
* every layer sits 32 rows too low. */
u16 n = LAYERS[s + 1].from_image_row;
bis = (n > V_SCROLL) ? (n - V_SCROLL) : 0;
if (bis > ROWS) bis = ROWS;
}
s16 v = WRAP(-((camera * LAYERS[s].speed) >> 8));
while (row < bis) offsetB[row++] = v;
}
for (u16 y = 0; y < ROWS; y++) offsetA[y] = WRAP(-camera);
}
int main(bool hardReset)
{
if (!hardReset) SYS_hardReset();
VDP_setScrollingMode(HSCROLL_LINE, VSCROLL_PLANE);
u16 idx = TILE_USER_INDEX;
VDP_drawImageEx(BG_B, &img_bgb, TILE_ATTR_FULL(PAL0,FALSE,FALSE,FALSE,idx), 0,0, FALSE, DMA);
idx += img_bgb.tileset->numTile;
VDP_drawImageEx(BG_A, &img_bga, TILE_ATTR_FULL(PAL1,FALSE,FALSE,FALSE,idx), 0,0, FALSE, DMA);
PAL_setPalette(PAL0, img_bgb.palette->data, CPU);
PAL_setPalette(PAL1, img_bga.palette->data, CPU);
VDP_setVerticalScroll(BG_A, V_SCROLL);
VDP_setVerticalScroll(BG_B, V_SCROLL);
VDP_setWindowVPos(FALSE, 3);
VDP_setTextPalette(PAL1);
/* The queue has to hold the largest phase. */
DMA_setBufferSize(200);
s16 camera = 0;
u16 tick = 0, phase = 0;
char row[40];
while (TRUE)
{
camera++;
if (++tick >= PHASE_LEN) { tick = 0; phase = (phase + 1) & 3; }
/* --- Section 1: game logic (blue) ------------------------------- */
PAL_setColor(0, RGB24_TO_VDPCOLOR(0x000044));
buildOffsets(camera);
/* --- Section 2: queue transfers (red) ---------------------------- */
PAL_setColor(0, RGB24_TO_VDPCOLOR(0x440000));
VDP_setHorizontalScrollLine(BG_B, 0, offsetB, ROWS, DMA_QUEUE);
VDP_setHorizontalScrollLine(BG_A, 0, offsetA, ROWS, DMA_QUEUE);
/* Phase 4 switches on SGDK's protection: anything beyond the budget is not
* transferred at all, the surplus jobs are dropped. Better a tile late
* than a torn picture. */
if (phase == 3)
{
DMA_setMaxTransferSize(BUDGET_NTSC);
DMA_setIgnoreOverCapacity(TRUE);
}
else
DMA_setMaxTransferSize(0); /* 0 = no cap */
u16 tiles = TILES_PER_PHASE[phase];
/* Queue it in 8 chunks — the way a real game streams. */
for (u16 i = 0; i < 8; i++)
DMA_queueDma(DMA_VRAM, (void *)img_bgb.tileset->tiles,
(TARGET_TILE + i * 50) * 32, (tiles / 8) * 16, 2);
u16 queued = DMA_getQueueTransferSize();
sprintf(row, "%s", TITEL[phase]); VDP_drawTextBG(WINDOW, row, 0, 0);
sprintf(row, " QUEUED %5u BYTES OF %4u ",
queued, BUDGET_NTSC); VDP_drawTextBG(WINDOW, row, 0, 1);
sprintf(row, " CPU %3u%% CAP %5u BYTES ",
SYS_getCPULoad(), DMA_getMaxTransferSize()); VDP_drawTextBG(WINDOW, row, 0, 2);
/* --- Section 3: remainder = headroom (black) --------------------- */
PAL_setColor(0, RGB24_TO_VDPCOLOR(0x000000));
SYS_doVBlankProcess();
}
return 0;
}
src/rom_header.c
/* ROM header — the first thing console, flash cart and emulator read.
* Fixed at $000100 in the ROM. SGDK ships a default header; for a product you
* write your own. Every field has a fixed length — strings that fall short
* have to be padded with spaces. */
#include <genesis.h>
__attribute__((externally_visible))
const ROMHeader rom_header = {
"SEGA MEGA DRIVE ", /* required marker, exactly so */
"(C)RB 2026.AUG", /* maker + date */
"MEGA DRIVE MASTERY ", /* title, domestic (48) */
"MEGA DRIVE MASTERY ", /* title, overseas (48) */
"GM MDM00001-00", /* serial number */
0x0000, /* checksum: set by sizebnd.py */
"J ", /* E/A: J = 3-Tasten-Pad */
0x00000000, /* ROM start */
0x000FFFFF, /* end of ROM (1 MB here) */
0xE0FF0000, /* RAM start */
0xE0FFFFFF, /* RAM end */
" ", /* "RA" only with SRAM */
0x0000, /* SRAM-Typ (Kapitel 32) */
0x00000000,
0x00000000,
" ", /* Modem */
"MEGA DRIVE MASTERY - CHAPTER 01 ", /* Notiz (40) */
"JUE " /* Region: Japan/USA/Europa */
};