devlog.

TerminalGB · Hardware modelling

The transfer controller owns the bus

Object DMA — the routine every Game Boy game uses to push sprite data into object memory — is usually modelled as a copy that takes 640 cycles and locks the CPU out of most of the memory map. That is the data half of one physical rule. The address half is the interesting one.

238 → 239
Shootout, of 264
3,613 → 3,617
Gambatte rows
0
Rows lost
80
Dots the scan reads nothing

The transfer controller has no address bus of its own. While a transfer runs it drives the object-memory address lines — and the picture processor's object scan is on the other end of them.

So for the eighty dots of the scan the PPU cannot read a single entry. Its Y/X latch keeps whatever it last held, and all forty object slots are judged against that one stale pair.

Modelling that took the Shootout from 238 to 239 of 264 and Gambatte from 3,613 to 3,617 scored rows, with zero losses and everything else byte-identical on both picture engines. One row — but it was ashiepaws/strikethrough.gb, the first row that suite has ever scored here.

The constant was derived, not chosen

The controller takes the bus a fixed number of dots after the write to $FF46, and that number is the one free parameter in the whole model. It came out of eight ROMs.

Gambatte's late_sp{00,01,02,39}x_{1,2} each put the write one machine cycle either side of one scan slot. sp00x_2 finds object 0 while sp01x_1 misses object 1, which brackets the arm. The sp02x/sp39x pair — same DMA phase, different slot — rules out every uniform shift.

Then it was swept across the whole 811-row oamdma/ group:

Arm delay (dots)01234
Gambatte oamdma/ rows passing, of 811742744744738738

The scan reads on even dots, so 1 and 2 are the same machine. A curve with a flat top two dots wide, and a physical reason for the width — that is what a derived constant looks like, as opposed to the one value that made a favourite test go green.

The data half came first, and was worth four hundred rows

Three days earlier the same physical rule was modelled from the other side: while the transfer runs, the CPU and the controller share one bus, so the CPU's reads of most of the map come back as whatever the transfer is putting on it.

Gambatte went from 2,682 to 3,083 of the rows scored at the time. Four hundred rows, from modelling a bus conflict instead of a copy.

A peripheral that moves bytes is doing two things at once, and only one of them is the copy.

And then a sibling project found the flag

GBSelfTest's object-DMA check found a real bug in this model on the day it was written: the flag the PPU keeps about a running transfer was cleared on one of the two paths that can notice the transfer has ended, and not the other. Once it latched, the object scan never read object memory again for the rest of the session.

Thousands of rows across five public suites were byte-identical either way. Why they could not see it →


The sweep and the suite scores are from TerminalGB's own conformance baselines, re-run in CI. The Shootout figure is measured through the Shootout's own published manifest.

← Back to devlog