I Built a Game Boy Recompiler. It Was Still an Emulator.
How translating Game Boy ROMs into native C exposed the machine's timing complexity—and why performance work eventually forced a product rethink.
One of the most promising optimizations in GB Recompiled removed 61.84% of the calls that synchronized the Game Boy’s picture processor. It preserved the same final state and processed the same number of display cycles. On the three games I used for the decision, the median runtime improved by 9.70%, 1.94%, and 1.22%.
I reverted it.
The experiment had missed its keep gate: at least 10% on two of the three workloads. It also added cached scheduling state, more observer boundaries, a savestate change, and another diagnostic mode. Two games barely moved.
That result exposed a problem larger than one rejected optimization. I had built a program that translated Game Boy machine code ahead of time, compiled it into native executables, and ran long tested paths through several games without divergence. I had also spent months making the hardware side of the system into a better emulator.
The project worked. Its reason to exist had become less clear.
Moving the decoder out of the frame loop
A classic interpreter-style emulator repeatedly performs a loop shaped roughly like this:
read the opcode at the guest program counter
decode the opcode
apply its effects to simulated registers and memory
advance the simulated hardware clock
repeat
A static recompiler moves the first two jobs—and as much control-flow reasoning as possible—to build time.
GB Recompiled reads a ROM, discovers reachable code, follows bank changes, builds an intermediate representation, emits portable C, and lets an ordinary host compiler produce the executable.1
build time
ROM bytes
→ decoded instructions
→ bank-aware control-flow analysis
→ intermediate representation
→ generated C
→ native compiler
run time
generated native functions
→ Game Boy memory and device runtime
→ video, audio, input, saves
Instead of decoding LD A,(HL+) every time the game reaches it, the generated project already contains the corresponding operation. Direct calls and branches can become C calls and jumps. The output is a normal CMake project linked with a runtime that models memory-mapped I/O, timers, interrupts, the PPU, the APU, DMA, cartridge mappers, persistence, and the host platform.
This comparison is deliberately against an interpreter. Real emulators may cache decoded instructions, use a just-in-time compiler, or mix several execution strategies. “Static” describes when translation happens; it does not mean that every other emulator is mechanically identical.
It also does not mean the hardware disappears. Native CPU instructions still have to produce the behavior of a Game Boy CPU connected to Game Boy devices. That qualification became the central fact of the project.
The trick was allowing uncertainty
The first repository commit was on January 3, 2026. Five days later, Tetris was running. Audio and bank-switching fixes followed during the next week.
That pace was possible because the first architecture did not require perfect whole-ROM analysis. The recompiler starts from known entry points and follows reachable control flow. Symbols and annotations can provide trusted starts; captured execution addresses are weaker discovery hints. When an indirect target or RAM-resident routine remains uncertain, the generated executable can dispatch through a safe path or enter the interpreter fallback.
That fallback is not an embarrassing exception. It is what lets the analyzer be conservative. A wrong native call can jump into data or into the correct address in the wrong physical ROM bank. An unknown target is slower, but recoverable.
Using C as the output language was another intentionally unglamorous choice. I did not need to write one machine-code backend per host architecture. The recompiler could concentrate on Game Boy semantics and leave register allocation, instruction selection, linking, and platform object formats to Clang, GCC, or MSVC.
By July, I was also using a coding agent as an implementation and verification partner. That is material to how the project moved. I chose the direction, defined the keep or reject gates, and interpreted the results; the agent could instrument several layers, write focused fixtures, regenerate projects, and run long verification sequences. I did not treat generated code—or a confident explanation—as evidence. The workflow depended on state hashes, frame and audio captures, differential execution, external hardware-test ROMs, and result documents that recorded why an experiment stayed or was removed.
The agent made breadth cheaper. It did not make the machine less exacting.
The Game Boy is small, not forgiving
The original Game Boy has an 8-bit, 8080-like Sharp CPU and a 16-bit address bus.2 That sounds pleasantly finite. The instruction set fits in a compact decoder. The screen is 160 by 144 pixels. The clock is about 4.19 MHz.
The difficulty is that these small parts are tightly coupled through time.
Only 16 KiB of cartridge ROM is normally fixed at addresses $0000–$3FFF. Another 16 KiB window at $4000–$7FFF can point at different physical banks. A write into the apparent ROM range may actually command the cartridge’s memory-bank controller. The same CPU address can therefore mean different bytes depending on prior execution.
Programs can also copy code into work RAM or high RAM and execute it there. The Color model adds banked video and work RAM, DMA modes, and a CPU double-speed mode in which CPU cycles and display dots no longer have the same ratio.
Then there are the timing edges.
One differential run of Link’s Awakening diverged at a generated arithmetic instruction that read through HL. The arithmetic was correct. The address was correct. The generated code read the PPU’s LY register too early.
The guest instruction performs its memory read on its final machine cycle. The generated path had read first and advanced time afterward; the interpreter advanced to the correct phase before reading. During that small interval, LY could change. The fix for eight arithmetic and comparison forms was effectively:
advance 7 cycles
perform the memory read
advance the final cycle
After that change, the generated and interpreted paths matched for 500,000 steps with no fallback on that run.
This is the kind of bug that makes “the instruction computes the right value” an insufficient test. The value must be sampled at the right time, while the PPU, timer, interrupts, and DMA engine are in the right state.
Other edges are stranger. The HALT bug can suppress one program-counter increment. On monochrome hardware, certain CPU accesses during the PPU’s OAM scan can corrupt sprite memory. Timer overflow has a delayed reload window in which writes have cycle-dependent effects. Each behavior is small enough to describe. Their composition is the system.
The Game Boy is simple enough that I can keep much of it in my head. It is not simple enough to ignore when something happens.
The benchmark that ended an idea
Once correctness was credible enough to measure performance, I stopped accepting “fewer calls” or “more cache hits” as success. The project used three repeatable full-headless workloads: a small monochrome game, a mapper-heavy monochrome game, and a Color game. Host presentation and pacing were disabled, while device timing, pixel work, audio, input replay, and final-state capture remained enabled.
The July experiments produced a useful mix of wins and refusals:3
| Experiment | Measured result | Decision |
|---|---|---|
| Register-only CPU batching | Removed 0.23% of tick commits; generated C grew 17.7%; executable grew 5.9%; runtime was about 1% slower | Rejected |
| Stable PPU spans | Improved the three workloads by 18.5%, 15.3%, and 7.7% | Kept |
| Arithmetic timer advancement | Improved them by 16.1%, 24.5%, and 10.7% | Kept |
| Batched audio advancement | Removed 94.3% of APU advancement calls; improved runtime by 20.3%, 14.7%, and 18.9% | Kept |
| Lazy PPU synchronization | Removed 61.8% of PPU calls; improved runtime by 9.7%, 1.9%, and 1.2% | Rejected and reverted |
These are local measurements from matched generated builds and recorded inputs, not universal emulator benchmarks. The retained timer and audio paths also kept scalar or eager implementations as same-binary oracles, and the measured runs required matching final-state hashes.
The rejected results changed my model more than the wins.
The first CPU-region experiment assumed that repeated transitions between generated code and the runtime were the obvious waste. They were not. A directional Tetris profile later placed the dispatcher at only 0.8% of top-of-stack samples, while the PPU tick path occupied 32.3%. A follow-up estimator predicted that larger visibility-aware compiled regions would remove enough device commits on only one of the three games.
The remaining cost was not primarily “machine code that had not become C yet.” It was the work required to preserve the behavior of the timer, display, audio, DMA, interrupts, and their observation boundaries.
I could continue improving that runtime, and some improvements would be worthwhile. But every generic win made GB Recompiled a better compatibility system. It did not make the project meaningfully different from an emulator with an ahead-of-time CPU frontend.
That was the wall.
Exact-ROM patches change the contract
The way through was not to pretend the accurate runtime was unnecessary. It was to use information a generic emulator cannot assume: the exact ROM revision, its discovered functions, and their stable identities.
The first tracer bullet is a native replacement SDK. A patch manifest includes the ROM’s SHA-256 and size, source files to compile with the generated project, and bindings to function IDs such as:
gbfn:v1:0000:0160
That ID means a function at a specific guest address in a specific physical ROM bank. It survives changes to generated C names and chunk layout, but it is intentionally meaningless for a different ROM hash.
A binding can run a pre-hook, replace the function, run a post-hook, or ask to execute the original generated body. The last case exposed a subtle boundary between guest execution and native execution.
A generated C body may return to the runtime at a frame or interrupt safepoint before the Game Boy function reaches its RET. Calling the original synchronously on the patch callback’s native C stack would therefore run the post-hook too early. The retained design treats “call original” as a scheduling disposition. The runtime keeps a per-context invocation frame alive and runs post only after the matching guest return.
The legal synthetic test makes that visible. After one frame, the pre-hook and replacement have run, but the original and post-hook have not finished. After two frames, the same invocation resumes, the generated body returns, and post runs exactly once.
The manifest fails closed. A wrong ROM hash, unknown function, unsafe source path, callback error, or modified embedded ROM stops generation or execution instead of silently retargeting a patch. Unpatched generated wrappers keep their direct call shape and do not link the patch runtime. At the tracer bullet’s closeout, all 35 repository tests passed; the no-op patch benchmark produced a small apparent speedup that I treated as layout variance, not as a performance claim.
This does not yet produce a native renderer or a complete port. It establishes the seam needed to build one without editing generated files after every regeneration.
More importantly, it changes the promise. The unpatched accurate path remains the compatibility default for supported ROMs. Native-port work is opt-in, revision-locked, and allowed to replace higher-level game behavior deliberately. That can support durable mods, accessibility changes, modern platform services, or game-specific rendering while the original implementation remains available as a fallback and reference.
The question after the wall
I originally thought the project’s strongest argument would be performance: do enough work ahead of time, remove enough decoding and dispatch, and the native executable would justify the machinery.
Performance still matters. The retained timer, PPU, memory, and audio work produced substantial local gains. But speed is not the clearest boundary between a static recompiler and a good emulator. Emulators can interpret, cache, specialize, and dynamically compile too.
The more durable distinction is authorability.
Once a particular ROM has stable function identities and metadata, the generated output can become more than a playback artifact. It can become a semantic game library, an editable data and asset project, a native debugging surface, or a certified build that proves where fallback remains. Those are proposals, not completed product claims. The patch SDK is only the first retained experiment in that direction.
The accurate emulator-shaped core is still necessary. It is the compatibility path, the original-behavior fallback, and one of the oracles used to test changes. The mistake was expecting its removal to be the definition of success.
The next question is no longer only, “How much faster can I run this Game Boy?” It is, “What can I build once this exact game has become a native, inspectable program?”
Footnotes
-
GB Recompiled contains the recompiler, generated-C pipeline, runtime, and public build instructions. ROMs are not included; users supply legally obtained images. ↩
-
Pan Docs’ memory map documents the 16-bit address space and switchable cartridge window. Its CPU comparison explains why the Game Boy CPU is similar to, but not the same as, a Z80. ↩
-
The table summarizes dated project result reports from July 14, 2026. Those reports record the matched builds, inputs, repeated medians, state hashes, and accept-or-reject gates; the values here are rounded for readability. ↩