The wire learns a second shape: Game Boy first light
Rusty Nail is a fantasy console on an STM32H753: it boots to a cart library on the TV, runs PICO-8 games under a vendored Lua, and streams its framebuffer to an RP2040 that scans it out as HDMI. Two days ago I sat down to answer a question that has been in the future-plans file since the start: can this thing also be a Game Boy?
Tonight a Game Boy program drew this on my TV, through the whole stack - Ethernet fetch, cartridge-header gate, emulator core, SPI wire, RP2040 scanout:

That is dmg-acid2, the PPU acid test. If any of roughly a dozen renderer behaviours is wrong, the face comes out wrong - eyelashes vanish, the nose doubles, the chin tears. It matched, byte for byte, the reference image published for real hardware.
Deciding what to build on
The feasibility question died quickly, because someone else already answered the hard half: the hacked Game & Watch runs Game Boy Color at 60fps with sound on a 280MHz Cortex-M7 under retro-go's gnuboy port, and my H753 is the same core at 400-480MHz. CPU was never the problem. The real constraints turned out to be ROM residency (an 8MB MBC5 cartridge does not fit anywhere pleasant on a chip with 1MB of SRAM) and, as always on this project, the display wire.
Picking the emulator core took a day of comparing licences, test-suite results and - most tellingly - microcontroller track records. The winner was Peanut-GB: MIT, one C99 header, zero allocation (you hand it one struct and some callbacks), and already proven at full speed on a slower Cortex-M7 than mine, inside the Playdate. The same evaluation killed some tempting options: one otherwise tidy GBC core had no licence file at all, which makes vendoring legally impossible no matter how clean the code is, and it failed all four of Blargg's memory-timing suites - exactly the corners an emulator lives or dies on.
The vendoring pattern was already sitting in the tree. The PICO-8 runtime is a vendored C Lua built with the cc crate behind a small no_std Rust wrapper, so the Game Boy core became the same shape: a crate that owns the C translation unit and exposes a safe surface. One rule mattered enough to enforce in the shim itself: every callback crosses the FFI with fixed-width types only, because peanut_gb.h's signatures use uint_fast32_t, and that type is a different width on the host than on thumbv7em. A subtle ABI mismatch you only meet when the same C compiles for two targets.
Proving it on the host first
Nothing touched the hardware until the core had receipts. The host harness runs a ROM headless, captures the serial port (which is how Blargg's test ROMs report), and dumps frames:
PASS blargg/cpu_instrs/cpu_instrs.gb (3175 frames)
PASS blargg/instr_timing/instr_timing.gb (38 frames)
PASS dmg-acid2: frame matches the golden byte-exactly
All eleven cpu_instrs sub-tests and the instruction-timing suite, in about 0.4 seconds of wall clock. Then the acid test: I rendered dmg-acid2 for 400 frames, decoded the official reference PNG, and diffed the 23,040 pixels. Zero differed. The committed golden now encodes real-hardware behaviour, not just my own output agreeing with itself.
Test ROMs never enter the repo, and the reason is a licensing split worth knowing: the Mooneye suite, dmg-acid2 and friends are MIT, but Blargg's suites - the most famous of all - carry no licence at all and survive on mirrors. Everything arrives through a pinned, checksummed fetch of the game-boy-test-roms collection instead.
The wire problem
The console's display path streams frames over SPI to the RP2040 as raw RGB332, one byte per pixel, 128 lines of 128 pixels with a checksum byte per line. A Game Boy frame is 160x144. Different byte count, different line length, different everything - and the launcher still needs its 128x128 frames on the same wire, because the pause overlay draws in console pixels over a frozen game.
The numbers said the wire would hurt: a console frame is 16,524 bytes and the 7.8MHz link already tops out around 56 of those a second. A Game Boy frame is 23,196 bytes, which works out to roughly 42fps of presentation. I wrote that prediction into the planning docs before touching anything.
The fix for the geometry (not yet the speed) turned out to be one byte. Each frame already begins with a four-byte magic so the receiver can self-locate it in the byte stream; the fourth byte now selects the shape:
// The 4th magic byte doubles as the geometry selector.
if (b == MAGIC_GEO_128 || b == MAGIC_GEO_GB) {
cur_w = (b == MAGIC_GEO_GB) ? GB_FRAME_W : FRAME_W; // 160 : 128
cur_h = (b == MAGIC_GEO_GB) ? GB_FRAME_H : FRAME_H; // 144 : 128
in_lines = true;
row = col = sum = bad = 0;
}
The RP2040 side re-centres at runtime when the geometry changes - source width, x-offset and top border become variables instead of compile-time constants - and clears the framebuffer so no stale border pixels survive the switch. On the 800x600 output mode the Game Boy image lands at 4x: 640x576 with 80-pixel side borders. Console frames keep their exact old bytes on the wire, which meant the new receiver could go on first and keep working against the old sender.
Two traps surfaced on the way. The receiver draws little diagnostic status cells into the left border - which the wider Game Boy image overlaps, so the cells now hide themselves outside console geometry. And on the H7 side, the bigger DMA buffers could not simply be declared: this firmware once spent a full day dying of "impossible" boot panics because AXI statics silently collided with the boot stack, so every new AXI byte is funded by shrinking the Lua heap by the same amount. The Game Boy statics cost a 104K levy. The Lua heap does not miss it while a Game Boy cart is running, because the Lua VM does not exist then.
First light
The bench session was short, which after the SPI panel saga and the HDMI funnel work still feels illegal. Flash the H7, flash the bridge, add the acid2 ROM to the cart library, and pick it from the launcher like any other game. The defmt log tells it flat:
163.504937 INFO eth cart: fetched 32768 bytes over Ethernet -> hot-swap
163.512184 INFO gb cart loaded: DMG-ACID2 - ROM only 32K rom 0K ram
180.678458 INFO picodvi: 42 fps out SPI5
The face appeared on the TV, correct. The pause chord flipped the screen back to the 128-pixel library mid-stream and resumed to the 160-pixel game
- the wire changing shape live, both directions. Swapping from the Game Boy cart to a PICO-8 cart and back went through the same hot-swap path that has carried Lua carts for weeks. Zero fault callbacks from the core across every launch.
And the wire ran at 42fps in Game Boy mode. Exactly the predicted number - the emulation underneath runs at full speed and the ping-pong buffers drop the difference on the floor. I do not think I have ever hit a back-of-the-envelope this cleanly, so I am writing it down before the universe corrects itself. (The link clock has headroom on paper; pushing SPI5 to 15.6MHz for full-rate presentation is its own carefully-reverted experiment for another evening.)
One incident mid-session: the ST-LINK dropped with an SwdApFault while I was juggling USB cables for the bridge reflash, which looks exactly like a firmware crash until you read the log tail and find the console still happily reporting 58fps. The board never blinked; only the debugger did.
What is honestly not done
No sound yet - the four APU channels are the next milestone, riding the existing I2S DAC path. No battery saves - cart RAM lives in RAM and evaporates, until the journalled save design lands. Big ROMs (the multi-megabyte MBC5 era) need a staging tier that does not exist yet; today's ceiling is what fits the sideload path. And the scanline renderer will never do the mid-scanline raster tricks a handful of games pull - that is a documented non-goal, the same accuracy tier every microcontroller port chooses.
One small discovery for anyone else vendoring Peanut-GB v1.3.0: gb_tick_rtc is deprecated and the real-time clock now ticks itself from emulated cycles, so the RTC-restore story belongs entirely to the save system. And pin the C core's optimisation level in the build script - a debug cargo profile compiles C at -O0, which turns an emulator into a slideshow and you into a very confused profiler.
The cart library currently lists two Game Boy entries between three hundred PICO-8 carts. The second one renders this on the host, pixel for pixel:

It is staged on the console. The pad test is the morning's first job.
Earlier in this series: threading the HDMI funnel by hand, why I didn't replace the PICO-8 runtime, and the console that files its own bug reports.