A cycle-scheduled GBA emulator — on C++20 and an ESP32-S3
C++20 · ARM7TDMI · ESP32-S3 (Xtensa LX7) · CMake · 8/13 test suites passing
Most "emulators" are framebuffer apps with a CPU bolted on — they run instructions and draw pixels, but the timing is whatever the host happens to be. That works on a fast desktop and falls apart the moment you target constrained hardware, because the relationships between subsystems (when DMA steals a cycle from the CPU, when a timer fires an IRQ mid-scanline) are what real games actually depend on.
gba-embedded takes the opposite end of the design space: it models the Game Boy Advance as a hardware simulator driven by a single master cycle domain. Every subsystem — the ARM7TDMI core, the memory bus, the PPU, timers, DMA, the IRQ controller, and Direct Sound audio — advances against one 16.78 MHz scheduler. The payoff: the same core runs deterministically under host-side tests and on a 240 MHz microcontroller, because no timing decision is host-dependent.
The core decision: one clock to rule them all
The GBA's master cycle counter ticks at 16,777,216 Hz — 1,232 cycles per scanline, 228 scanlines per frame, 280,896 cycles per frame. The emulator's main loop asks every subsystem when its next event falls due, runs the CPU only until that cycle, then services all hardware at the precise boundary:
refresh_schedule() // query each subsystem's next_event_cycle()
cpu_run_until(next) // ARM7TDMI: ARM / Thumb dispatch
if hardware_due:
service_due_hardware() // Timers · DMA · PPU · APU · IRQ
if frame_ready: present_frame()
Keeping all timing decisions in one place is exactly what makes the core portable across wildly different hosts. The scheduler itself is a fixed six-slot array — PPU, Timers, DMA, APU, Serial, IRQ — with no heap and no dynamic queue. Emulator owns every subsystem; Bus holds references for MMIO routing; the CPU gets the bus and the IRQ controller.
The ARM7TDMI, and why the bus matters more
The CPU is a switch-dispatched interpreter for both ARM and Thumb, with banked registers, all 15 CPSR conditions, exception entry/return, and per-instruction cycle accounting that includes bus timing. But the honest lesson of emulation is that the memory bus is where accuracy lives: I implemented waitstate-aware GamePak reads, an 8-halfword prefetch buffer, BIOS pipeline-latch protection, and the real VRAM/palette/OAM byte-write rules. Get the bus wrong and "CPU-accurate" means nothing — games DMA themselves into glitches.
Validating against reality
You can't trust an emulator you can't test. The host toolchain runs CPU traces, the GBA test suite, full frames, and a bug harness — and crucially does differential testing against mGBA (a mature reference emulator) via an optional frontend. "8 of 13 hardware test suites passing" isn't a vanity number; it's the measurable distance between this core and a known-good one, suite by suite.
Porting to a microcontroller
The in-tree embedded target is an ESP32-S3 — Xtensa LX7, dual-core 240 MHz, octal PSRAM — driving an SPI LCD and I²S audio. The emulator core stays platform-neutral; only the frontend changes. The PPU has a dedicated embedded render path: a direct 128×128 center-crop mode that writes straight into a PSRAM framebuffer instead of compositing a full frame nobody will display.
And because a 240 MHz MCU has no cycles to waste, the hot paths follow a strict discipline: no std::function, no virtual dispatch, and no per-instruction heap allocation anywhere in the interpreter or scheduler. On the ESP32-S3 specifically, hot loops live in IRAM and large buffers in PSRAM.
Where it stands
Bitmap video modes (3/4/5), CPU/DMA/timer timing, and the HLE BIOS (13 always-on SWIs, 14 more behind a build flag, with stub-vs-real auto-detection and skip-BIOS boot) are working. Tile/sprite rendering and full DMA timing are still in progress — hence "WIP" rather than "done." The point of a project like this isn't to ship a product; it's to prove you can reason about a whole machine, cycle by cycle, and keep it portable from a desktop test bench to a microcontroller.