Skip to content

Latest commit

 

History

History
178 lines (151 loc) · 10.9 KB

File metadata and controls

178 lines (151 loc) · 10.9 KB

Architecture

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.

Unified state

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.

The synchronization loop (src/main.c)

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).

Modules

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

Frontends and the platform layer

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).

Memory map

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

The MMU is the only door

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_writeraw, 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.

Interrupts

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).

Accuracy

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_timing and 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 → active pipeline; OAM stays readable through the start delay, a mid-transfer write restarts cleanly, and the CPU reads the locked bus as 0xFF.
  • 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-acid2 pixel-perfect (0/23040); Mooneye ppu/ 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_sound 12/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, raising INT_SERIAL on 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.