Sega Mega Drive · Game development

Mega Drive Mastery

From the toolchain to a cartridge you sell — with a running emulator beside the text. Every chapter loads the ROM that belongs to it.

Part 2 — Mastering the VDP

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 budgetOver budget, with a cap
smallcap

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:

Targetdisplay on, H40blanking interval or display off, H40
VRAM18 bytes per scanline205 bytes per scanline
CRAM/VSRAM9 words102 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

Modeblanking linesbytes per frameequivalent to
NTSC, 224 lines387790243 tiles
PAL, 224 lines8918,245570 tiles
PAL, 240 lines7314,965467 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

TransferBytes per frame
Sprite table (SPR_update, 80 sprites)640
One line-scroll table (224 lines)448
One complete palette (16 colours)32
One single tile32
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:

MethodWhenNote
CPUsmall amounts of data, any timeslow, but without side effects
DMAimmediately, outside the picturehalts the 68000 until done
DMA_QUEUEthe normal case in a running gamethe buffer has to stay valid until VBlank
DMA_QUEUE_COPYwhen the buffer is released right awaycopies 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

  1. Never write to VRAM outside the VBlank — except deliberately, in an H-interrupt (chapter 11).
  2. Register everything, transfer nothing immediately, once the game is running.
  3. Fix a budget per subsystem and show it in the debug HUD. What you can see, you do not overrun.
  4. Spread the loading out: one tile column per frame instead of twenty every twenty frames. Even load always beats peaks.
  5. 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_QUEUE with a buffer that is released afterwards. See above; DMA_QUEUE_COPY is 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

  1. Set the tile count in phase 3 of m07 so that the display reports exactly 7790 bytes. How many tiles is that, given the scroll tables already take 896 bytes?
  2. 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.
  3. 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 07-dma · 4 Dateien · 183 Zeilen
makefile1 Zeilen
include $(GDK)/makefile.gen
res/resources.h11 Zeilen
#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.c144 Zeilen
/* 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.c27 Zeilen
/* 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 */
};