devlog.

TerminalGB · Emulator accuracy

The double-speed bug that cost 2,304 pixels

A register-commit trick worth exactly one dot of time was calibrated against single-speed test ROMs. In double speed there was no longer a spare dot to hold it, so it silently disappeared and a Color game drew a wrong stripe across the screen.

2,304
Wrong pixels, before
0
Wrong pixels, after · PASS
23,040
Pixels in a GB frame
1 → 0
Dots of commit stagger

The symptom

A Game Boy Color game that flips LCDC bits partway through a scanline — the register that controls which tile sources the picture processor is reading from — came out with a visibly wrong stripe pattern, but only when the console was running in double-speed mode. The same game in normal speed was pixel-exact.

That "only in double speed" is the whole tell. When one variant of a scene is perfect and its faster-clocked twin is wrong, the bug is not in the drawing logic — the drawing logic is provably correct at single speed. The bug is in how time is being counted.

Two clocks, one of which doubles

The Game Boy Color can switch its CPU into a double-speed mode. The important, easily-forgotten detail is that this does not speed up the screen. The picture processor's dot clock is fixed; only the CPU's own clock doubles. So the unit that a lot of internal timing is measured in — the CPU machine cycle, or "M-cycle" — changes width depending on the mode:

SINGLE SPEED — 4 DOTS PER M-CYCLE dot 1 dot 2 dot 3 dot 4 stagger value applied ✓ (3 dots spare) DOUBLE SPEED — 2 DOTS PER M-CYCLE dot 1 dot 2 stagger consumes the entire M-cycle ✗ value never applied
A one-dot register-commit stagger fits comfortably inside a four-dot M-cycle. Halve the M-cycle and the same one dot consumes the whole thing — leaving no room for the deferred value to land.

The one-dot stagger

Real Color hardware doesn't make an LCDC write visible at the exact instant the CPU performs it — a couple of the bits become visible to the picture processor one dot later than the rest. TerminalGB modelled that faithfully with a one-dot "early" stagger on the memory unit's LCDC write-commit: hold the change back by a dot, then apply it.

The trouble is where that one dot came from. The stagger was calibrated exclusively against a suite of single-speed reference ROMs, where an M-cycle is four dots wide. A one-dot delay inside a four-dot budget is comfortable. But in double speed the M-cycle is only two dots wide, and the deferred value's slot fell off the end of the cycle. The commit was silently never applied — no error, no assertion, just a value that quietly evaporated and a stripe drawn from the wrong tile source for one dot too long.

The general trap

A timing offset measured in absolute dots is only correct for the clock it was measured on. The moment the surrounding cycle changes width, an offset that "fit comfortably" can consume the entire window — and because nothing overflows, nothing complains.

Finding it

It surfaced as a hard reference failure, not a bug report. The accuracy harness runs the AGE test-ROM m3-bg-lcdc-ds@cgbBCE — a Color, double-speed ROM built specifically to stress mid-scanline LCDC writes — and compares the rendered frame against a hardware-captured reference image. It read 2,304 of 23,040 pixels wrong. Its single-speed sibling, m3-bg-lcdc@cgbBCE, was already pixel-exact. One passing, one failing, identical but for the speed switch: that pair is what pointed straight at the clock, not the renderer.

The fix

The commit routine now knows what speed it is running at. Where it used to apply a fixed one-dot stagger, it takes the running GbSpeed and drops the stagger to zero when there is no room for it:

// ppu_write_commit — CGB LCDC arm
let early = match speed {
    GbSpeed::Single => 1,   // 4-dot M-cycle: a spare dot exists, model the hardware delay
    GbSpeed::Double => 0,   // 2-dot M-cycle: no spare dot — apply immediately instead
};

Removing the stagger outright in double speed beats leaving it in to be silently truncated. The single-speed path is untouched, so nothing that already passed regresses.

What actually happened to the numbers

m3-bg-lcdc-ds@cgbBCE went from a 2,304-pixel failure to a pixel-exact pass, moving the AGE suite from 10 to 11 of 59, and the Gambatte double-speed rows improved on the accurate engine. Five more rows fell sharply without reaching zero:

Reference row (double speed) Before After
m3-bg-lcdc-ds@cgbBCE2,304PASS
bgtiledata_spx08_ds_31,0328
bgtiledata_spx08_ds_42,06416
bgtiledata_spx09_ds_11,1448
bgtiledata_spx09_ds_31,1448
bgtiledata_spx09_ds_42,04816

Those remainders are a different, smaller timing discrepancy that the stagger bug was masking. Eight wrong pixels are reported as eight, so the next person to open that scoreboard sees exactly how much is left to chase.

The regression is locked in with a test that has teeth: reverting the stagger to a fixed 1 unconditionally reproduces the original 2,304-pixel failure, so the mistake cannot quietly come back.

A note on counting pixels

Not every "wrong pixels" number on the scoreboard is a raw count of differing pixels. The Mealybug Tearoom comparisons use a shade-rank distance, which is deliberately unforgiving: a frame that introduces one extra distinct shade can report on the order of 23,040 "wrong" no matter how much of the rest is perfect, and a four-pixel error can move the total by tens of thousands. Four genuinely wrong pixels there scores 22,070.

So these figures are never converted to a percentage. The 2,304 and the residual counts above are direct pixel counts from the AGE and Gambatte comparisons; a shade-rank distance is treated as an ordering signal, not a pixel tally.

Why this needed the accurate engine at all

A one-dot, mid-scanline error is invisible to a fast emulator by construction — which is exactly why TerminalGB carries two picture engines rather than one.

The standard engine draws a whole scanline in a single step at the end of mode 3, from whatever the registers hold at that instant. It is fast and it is correct for the overwhelming majority of software, but it physically cannot represent "the wrong tile source for one dot in the middle of a line" — it never looks at the registers mid-line. The identical engine walks the real hardware pixel pipeline dot by dot: the background fetcher, the eight-pixel FIFO, sprite fetches, the window restart, the SCX & 7 discard. A mid-scanline write lands exactly where a Game Boy puts it — which means a one-dot timing bug lands exactly there too, and gets caught.

The two are not a quality or an appearance setting. On ordinary games they produce byte-identical framebuffers — verified across 600 frames of dmg-acid2 plus Blargg's cpu_instrs. The only axis between them is exactness against speed:

Test suite standard identical
Mooneye GB acceptance67 / 7575 / 75
Mealybug Tearoom (pixel-exact rows)3 / 7929 / 79
Gambatte (5,225 scored rows)3,1763,622
GBMicrotest252 / 513339 / 513
GB Emulator Shootout (264 rows)223243
Frame cost, Pokémon Blue (one P-core)0.094 ms0.373 ms

The accurate engine costs roughly four times as much per frame — 0.373 ms against 0.094 ms, still only a couple of percent of a frame's budget on a desktop core. That is cheap enough to be the default, and it is: identical has been the shipping default since it stopped losing accuracy rows the fast engine kept. But four times the cost is not free everywhere. On the PSP frontend, where the CPU budget is tight, the emulator deliberately pins standard — an unplayable-but-exact picture helps no one.

That is the whole reason the split exists. The fast engine is what makes the emulator usable on modest hardware; the accurate engine is what makes a bug like this one findable. Ship only the fast one and the double-speed stripe would still be on screen, unmeasured and unfixed, because nothing in the pipeline would ever have looked closely enough to see it.


Every figure here is drawn from TerminalGB's own conformance notes and accuracy scoreboard. The reference ROMs cited (AGE, Gambatte, Mooneye GB, Mealybug Tearoom) are public test suites; the comparisons are run against hardware-captured images and re-run on every build.

← Back to devlog