A unit test can prove that a Python function returned the expected value. It cannot, by calling that function directly, prove that an emulated machine fetched the same instruction from a cartridge, decoded it, routed its memory access through the bus, advanced the connected hardware, and reported the right result.
That is why I built Obscuretone Test ROM, or OTR: a self-checking diagnostic for the original monochrome Game Boy. It runs as a real cartridge image, shows its progress on the Game Boy display, streams structured events through the serial port, and halts at the first failure.
OTR began as a way to test PyGameBoy, but it is maintained as its own project with its own generator, tests, build, protocol, and release artifacts. It does not wrap the Blargg test ROMs or the Mooneye Test Suite. Those projects remain invaluable; OTR approaches the same problem from a deliberately integrated and talkative direction.
Testing The Machine, Not Just The Method
An emulator can pass a test that calls an opcode method directly and still fail to run the same opcode from a ROM. The decoder might choose the wrong method. The program counter might advance incorrectly. A memory write might bypass an MBC or an I/O side effect. An interrupt might arrive at the wrong point.
OTR enters through the cartridge interface. The emulator reads its header, selects a memory controller, fetches instructions, and advances the connected timer, serial, PPU, and APU components. The core ROM executes every runnable legal base opcode and all 256 CB-prefixed opcodes. STOP has a dedicated probe, as do the eleven undefined encodings, because neither category can safely return to an ordinary sequential test runner.
The ROMs check CPU-visible behavior across:
- registers, flags, loads, arithmetic, stack operations, and control flow;
- interrupts, timer overflow, divider behavior, and serial transfers;
- WRAM, echo RAM, HRAM, cartridge RAM, and unusable memory;
- joypad selection, OAM DMA, and PPU state;
- APU power, register behavior, and all four audio channels;
- MBC0, MBC1, MBC2, MBC3 with RTC registers, and MBC5 with its ninth ROM-bank bit.
Rendered pixels and produced audio sit outside what cartridge software can inspect. Those assertions belong to the emulator's host harness, which complements the ROM's CPU-visible checks. The same boundary applies to STOP and undefined-opcode behavior: the cartridge can enter those states, but something outside the cartridge has to observe what happened.
One header cannot identify a cartridge as five controller types, so controller behavior lives in separate generated ROMs. MBC5 can select 512 ROM banks through a ninth bank bit, requiring an eight-megabyte image to exercise the top of its address range. That file is almost entirely zero-filled and compresses extremely well.
A ROM That Says What It Is Doing
Many test ROMs appear frozen while they work. If one stops making progress, it can be difficult to tell whether the emulator crashed, the test is slow, or the result reporter itself failed.
OTR includes a tiny tile font and a live status console. The first background row shows the active group:
20 CB OPCODES
At the same time, the ROM emits line-oriented ASCII over the Game Boy serial port:
OTR/1 01 CPU LOAD MATRIX
OTR/1 20 CB OPCODES
OTR/1 35 APU
OTR/1 PASS
The OTR/1 prefix is a versioned protocol boundary. A headless runner can parse it without screen capture or emulator-specific hooks.
On failure, OTR records the current group and case, prints the diagnostic before the terminal event, disables interrupts, and executes HALT:
OTR/1 ERROR
AT 20:7C
OTR/1 FAIL
The core ROM also exposes an OTR1 mailbox in cartridge RAM. It contains the running, pass, or fail state; the current group and case; and a NUL-terminated result message. Serial is the human-readable path, while the mailbox gives automated runners a second way to recover the same result.
A Build That Audits Itself
OTR is generated by a dependency-free Python program. It does not require an external assembler, which keeps direct builds reproducible across development machines and CI environments.
The generator is more than a byte emitter. Whenever it emits an instruction, it records that encoding in a coverage ledger. Generation fails if the core suite does not account for every legal base opcode or every CB opcode. The output manifest records the protocol version, exact coverage lists, artifact sizes, and SHA-256 hashes for the core, controller, and probe ROMs.
That manifest creates a small contract between OTR and any emulator that consumes it. OTR owns the source and generation tests. An emulator can vendor released artifacts, verify every byte against the upstream hashes, and run them through its complete machine graph without importing OTR's generator into its own source tree.
The CI workflow runs the generator tests, builds the ROM set on each supported Python version, checks reproducibility, and publishes the generated .gb files as an artifact. The source of truth remains in one small repository rather than becoming an emulator-specific test fixture that quietly drifts.
What Every Opcode Means
OTR executes every legal encoding, but no finite ROM can test every possible Game Boy state. Two eight-bit operands already create 65,536 input pairs before carry, flags, memory timing, interrupts, and peripheral state enter the picture. The complete machine state is vastly larger.
The suite uses independent result and flag oracles with deliberately selected boundary vectors. That catches encoding, register-selection, flag, memory, and integration errors without pretending to exhaust an effectively unbounded state space.
Accuracy comes from combining layers: ordinary unit tests for focused state transitions, generated ROMs for the integrated machine, host-side checks for output the cartridge cannot see, and established hardware suites for timing details OTR does not claim to replace.
Building OTR
Python 3.10 or newer is the only runtime requirement for a direct build:
git clone https://github.com/Obscuretone/obscuretone-test-rom.git
cd obscuretone-test-rom
python generate.py --output dist
python generate.py --output dist --check
The output includes:
otr.gb, the 32 KiB core diagnostic;- controller-specific MBC ROMs;
STOPand undefined-opcode probes;manifest.json, containing hashes and the complete coverage ledger.
The generated ROMs intentionally leave the Nintendo logo area blank. They are meant for emulators that skip the original boot ROM. The DMG boot process checks the cartridge logo before handing over execution, so original hardware running its boot ROM will reject these images. OTR does not redistribute the logo data merely to make a diagnostic pass that gate.
Why Build Another Test ROM?
Because readable failures change how emulator work feels.
Instead of leaving a black window to mean either "still running" or "hopelessly dead," OTR shows the active subsystem. Instead of returning a generic timeout, it reports a stable group and case. Instead of trusting that a collection of opcode bytes happens to be complete, the generator refuses to build an incomplete ledger. Instead of binding the diagnostic to one emulator's internals, the manifest defines a verifiable contract between independent projects.
OTR will not replace focused timing suites, hardware measurements, or carefully written unit tests. It gives them a cartridge-shaped companion that travels through the assembled machine and explains what it is doing along the way.
That makes failures easier to reproduce, easier to understand, and harder to ignore.