GB is built around a flat machine state and a tight, cycle-accurate loop where the
CPU drives the clock: every bus access ticks the rest of the machine before it
completes, and the timing-sensitive subsystems advance one T-cycle at a time. There
are no classes, handles, or contexts to thread around — components read and write the
active machine through the global gb pointer directly.
All machine state lives in one GB struct (include/gb.h). It holds the CPU
registers, a 64 KB system-bus backing array, the cartridge image + MBC state, and the
timer / PPU / APU / joypad / serial state. The struct is grouped into one
sub-struct per subsystem — gb->cpu, gb->cart, gb->timer, gb->ppu,
gb->dma, gb->serial, gb->boot, gb->joypad, gb->apu — with hardware-named
fields inside each (gb->cart.rom_bank, gb->timer.div_counter, gb->ppu.mode,
…). The big gb->memory bus array stays at top level. Because only one cartridge
mapper is ever live, each mapper's private registers (MBC1/MBC3/MBC5/MMM01/HuC1/
HuC3/TAMA5/Camera) share storage in an anonymous union inside gb->cart; the
shared banking (rom_bank, ram_bank, …) and cart identity (type,
has_battery) sit outside it.
The frontend owns two instances — GB gb_cores[2] — and a pointer GB *gb to the
active one. Single-player and the web build only ever use gb_cores[0]; the --split
link cable runs both cores in one process (see Frontends), wiring their serial
ports together and stepping each in turn with gb repointed. Component code always
goes through gb, so it neither knows nor cares which core is live.
each frame:
poll SDL input -> gb.buttons[]
until the PPU signals VBlank (gb.frame_ready), with a cycle cap as a
stall guard for when the LCD is off:
cpu_step() # one instruction; each bus access inside it
# ticks every subsystem 4 T-cycles first
# (tick-then-access) — see below
cpu_handle_interrupts() # service a pending IRQ, if any
mmu_sync(leftover) # apply any cycles not already ticked in-line
upload gb.framebuffer -> SDL texture -> present
cap to ~59.7 fps
The CPU is the clock, and timing is cycle-accurate, not instruction-stepped: the
CPU never advances the rest of the machine in one lump after an instruction. Every
memory access goes through cpu_read / cpu_write (and internal delays through
cpu_idle), and each of those first ticks the whole machine by its 4 T-cycles and
then performs the access (sys_step → timer / PPU / OAM-DMA / APU / serial /
camera). Latching memory at the end of the M-cycle is the hardware-correct order
and is what makes accesses land on the right sub-instruction cycle.
Within that M-cycle, sys_step advances the two interrupt-critical subsystems — the
timer and the PPU — one T-cycle at a time, recording the pending interrupt lines
(IE & IF) into a 128-entry per-T ring after every T (gb->pend_ring, indexed by the
gb->master_t T-counter). The CPU then samples that ring at each source's true
sub-M-cycle offset (see Interrupts), which is what lands the tightest Mooneye
timing tests on the right T rather than only at the M-cycle boundary. DMA, APU,
serial, and camera still advance once per access (they assume 4-T alignment); their
IRQ raises are folded into the ring's last T slot.
cpu_step keeps a running tally of the T-cycles it ticked in-line; the loop reads it
with mmu_take_cycles and uses mmu_sync only to apply the small remainder (e.g.
the interrupt-dispatch cycles) so nothing is double-counted.
--split link cable. The frontend runs both gb_cores for one frame each,
alternating run_core_slice between them in small slices so neither drifts more than
a slice (well under a serial byte's ~4096 T-cycles) ahead — close enough for the two
cores' serial ports to exchange bytes in step. After the frame, apu_mix_flush
combines them into one stereo stream (player 1 → left, player 2 → right).
| File | Responsibility |
|---|---|
main.c |
Native frontend: SDL init, window/texture, input mapping, the loop, frame cap, hotkeys, --split, screenshots; implements the plat_* platform hooks |
web.c |
Web frontend (Emscripten): the same gb_cores globals + plat_* hooks, plus a small C API the browser shell drives (built only by make web) |
config.c |
Config-file + key-binding parsing (defaults, config_load); native-only |
cpu.c |
SM83 fetch/decode/execute, flags, interrupt servicing, post-boot init |
mmu.c |
Bus routing + register side-effects; the timed cpu_read/cpu_write M-cycle; OAM-DMA progress. All CPU access goes here |
cartridge.c |
ROM/.zip load, header parse, every MBC behind a per-family Mapper vtable (read/write/reset/save/load) chosen once at load, RTC clocks, battery SRAM + RTC save footers |
camera.c / camera_sdl.c |
Game Boy Camera M64282FP sensor pipeline; camera_sdl.c is the portable SDL3 webcam source (synthetic fallback when no camera/permission) |
printer.c |
Game Boy Printer (serial slave): packet state machine → a printed bitmap |
serial.c |
Serial link port: byte-exchange timing, peer/printer/open-bus reply, INT_SERIAL |
timer.c |
DIV / TIMA / TMA / TAC (divider-driven falling-edge detector) |
ppu.c |
PPU mode machine + dot-driven pixel FIFO (background/window fetcher, on-demand sprite fetch, pixel mixer); variable mode-3 length |
apu.c |
Frame sequencer, 4 channels, mixer; hands samples to the frontend's audio sink |
state.c |
Full-machine save states (serializes the whole GB + external RAM) |
puff.c |
Vendored public-domain zlib inflate, used to read .zip ROMs |
main.c (native, SDL3) and web.c (browser, Emscripten) are interchangeable
frontends: each owns the gb_cores / gb / link_enabled globals and implements a
tiny platform interface the core calls — plat_audio_init / plat_audio_push (audio
sink), plat_camera_capture (a webcam frame for the Game Boy Camera), and
plat_printer_output (a finished print). Everything else — CPU, MMU, PPU, APU,
cartridge — is shared and frontend-agnostic. make builds the native binary
(excluding web.c); make web builds the wasm (excluding main.c, config.c, and
the native camera_sdl.c — the browser supplies its own webcam frame via web.c).
| Range | Region | Served by |
|---|---|---|
0000-3FFF |
ROM bank 0 (fixed) | cart_read |
4000-7FFF |
ROM bank N (switch) | cart_read |
8000-9FFF |
VRAM | gb.memory |
A000-BFFF |
External cart RAM | cart_read / cart_write |
C000-DFFF |
WRAM | gb.memory |
E000-FDFF |
Echo of C000-DDFF |
gb.memory (mirror) |
FE00-FE9F |
OAM (sprite attrs) | gb.memory |
FEA0-FEFF |
Unusable | — |
FF00-FF7F |
I/O registers | gb.memory + side-effects |
FF80-FFFE |
HRAM | gb.memory |
FFFF |
IE | gb.memory |
Every CPU read/write goes through the MMU. That is where banking and register quirks
live: writing DIV resets it, the serial port arms a transfer, JOYP returns a
button matrix, echo RAM mirrors WRAM, wave RAM is gated, and read-only register bits
are enforced. There are deliberately no macros that alias gb.memory[...] for I/O
registers — use the ADDR_* constants with the MMU instead. (A component may read its
own backing region directly, e.g. the PPU scanning VRAM, but the CPU never does.)
The MMU exposes two layers:
cpu_read/cpu_write/cpu_idle— the timed accesses the CPU uses. Each is one M-cycle: it advances the whole machine 4 T-cycles, then does the raw access (see the loop above). This is the only path the CPU should take.mmu_read/mmu_write— raw, side-effecting bus access with no timing. Used for internal peeks (the IE/IF interrupt check, the OAM-DMA copy) and by components acting on their own behalf, where ticking the clock would be wrong or recursive.
IE (0xFFFF) and IF (0xFF0F) share bit positions
(INT_VBLANK=0, INT_STAT, INT_TIMER, INT_SERIAL, INT_JOYPAD=4).
cpu_request_interrupt(bit) sets IF; cpu_handle_interrupts() pushes PC and jumps
to vector 0x40 + bit*8. EI enables IME only after the following instruction,
modeled by ime_delay. All five sources are live — including INT_SERIAL, which
serial.c raises when a transfer completes.
Rather than reading IE & IF live at the M-cycle boundary, both HALT-wake and
dispatch read the per-T interrupt ring through mmu_irq_pending_back(back), each
at its hardware-correct sub-M-cycle T-offset: HALT wakes on the line as seen 4 T back
(it ticks its idle M-cycle first), and dispatch samples VBlank one T further back than
the others (VBlank is raised at the line-144 boundary, a different dot than the
mid-line STAT/timer edges). These small per-source offsets — not a single global one
— are what close the sub-cycle IRQ-timing tests (di_timing, halt_ime1_timing2).
The emulator is cycle-accurate, validated against the standard test suites (see tests/):
- Timing — the M-cycle bus (tick-then-access) plus T-cycle interrupt sampling
clear Blargg
mem_timing/instr_timingand 66/75 Mooneye acceptance tests. The 9 remaining all need non-DMG (SGB / early-DMG-0 / MGB) power-on state, out of scope here — no sub-cycle timing failures remain. - OAM DMA — cycle-exact: one byte per M-cycle behind a
requested → starting → activepipeline; OAM stays readable through the start delay, a mid-transfer write restarts cleanly, and the CPU reads the locked bus as0xFF. - PPU — the dot-driven FIFO gives variable mode-3 length (SCX fine-scroll
discard, window restart, per-sprite penalty), models the DMG STAT-write IRQ glitch
and the line-144 STAT pulse, and blanks the first frame after LCD-enable. Renders
dmg-acid2pixel-perfect (0/23040); Mooneyeppu/12/12. - APU — all four channels, envelopes, sweep, and the DIV-driven 512 Hz frame
sequencer, with sub-cycle wave-RAM access timing. Blargg
dmg_sound12/12. - CGB — VRAM/WRAM banking, BG/OBJ colour palettes + tile attributes, KEY1
double-speed, and HDMA, all gated behind
gb_is_cgb()so the DMG paths stay byte-identical; DMG carts get a compatibility colourisation on CGB hardware. - Serial — a working local link cable (
--split) and Game Boy Printer, raisingINT_SERIALon completion.
Remaining gaps live in TODO.md: a per-title DMG-on-CGB compatibility palette, a networked (socket) link cable, and one HuC3 in-game path.