I've been working through Game Boy Color hardware and assembly as an ongoing hobby, and building a set of teaching modules and exercises out of what I learn along the way. That turned into a problem fairly quickly. Every exercise has an expected result, and the only way to check one was to build the ROM, open an emulator, and press buttons until I reached the part I cared about. At a couple dozen modules that stops being feasible. I wanted to change a shared macro and find out in thirty seconds which exercises I'd just broken.
Boyless is that check. It loads a ROM, runs a script of timed button presses, and asserts on the screen and on memory as it goes. No window, no controller, no person.
Built on SameBoy
It links the SameBoy core as a static library and uses only SameBoy's public API. The core is pinned as a git submodule under third_party/ and left unpatched, so picking up upstream changes is a submodule bump and nothing else. Keeping that boundary clean was worth more to me than the convenience of reaching into the internals would have been.
Scripts
One command per line, # starts a comment. wait advances frames, press / down / up drive the buttons, screenshot captures the current screen, compare asserts it matches a stored reference exactly, differ asserts it doesn't, noblank fails on a screen that's gone uniformly flat, and memory reads or asserts a byte at a 16-bit address.
wait 300 # boot ROM animation runs first
settle 10
screenshot 1 # title screen
press start
settle 10
memory {wGameState} 2
compare 2
noblank
The command I lean on most is settle, which advances frames until the screen has held steady for N of them. A fixed wait encodes a guess about how long something takes, and that guess breaks every time the ROM's timing shifts underneath it. settle waits on the condition instead, bounded by the hang timeout so a script can't spin forever on a screen that never stabilizes.
Assertions against source symbols
Pass an RGBDS .sym file with --sym and any {symbol} token in a script expands to that symbol's address before the line is parsed. Offsets work too, for struct fields:
memory {wValue} $42 # {wValue} -> $C000
memory {wPlayer+4}
memory {wGrid-1}
This matters more for a curriculum than it does for a single project. Exercise ROMs get relaid out constantly as modules are reordered or a shared routine moves, and assertions written against named state survive that. Assertions written against raw addresses quietly stop testing what you think they're testing.
Running in CI
By default it runs the entire script, reports every failure it found, and exits non-zero if there were any. I'd rather see all six broken exercises than the first one. --fail-fast stops at the first failure. --report-only prints failures but always exits 0, for when I'm inspecting rather than checking.
References are golden files. Run a script once with --update and compare writes the reference images instead of asserting against them; run it again without and it checks against what it wrote. A hang detector flags N seconds of frozen video on top of that, which catches an exercise that has locked up rather than one that's merely wrong.
Testing the tester
make test covers the script parser, the screenshot writer, and the hang detector, and needs no ROM at all. make integration assembles the test ROMs in testroms/ and runs boyless against them: fill.asm for the DMG path and hang detection, input.asm for per-button dispatch on CGB, mem.asm for the compare and memory assertion paths. GitHub Actions builds the project and runs both on every push and pull request.
Writing Game Boy assembly in order to test the thing that tests Game Boy assembly feels circular while doing it, but it's the only way to demonstrate that the harness actually reports a failure when there is one. A test runner that always passes is essentially no test runner.
Status
Active. The GBC research and the curriculum it feeds are both ongoing, and boyless grows whichever assertion I turn out to need next.


