aboutsummaryrefslogtreecommitdiffstats
ModeNameSize
-rw-r--r--.gitignore64logstatsplainblame
-rw-r--r--Makefile513logstatsplainblame
-rw-r--r--README.md17185logstatsplainblame
-rwxr-xr-xgbctl14489logstatsplainblame
-rw-r--r--slop_drive.sh2266logstatsplainblame
d---------src549logstatsplain
d---------tools267logstatsplain

gbc — a Game Boy Color emulator in C, rendered to the terminal

A from-scratch Game Boy / Game Boy Color emulator written in plain C11. It renders graphics directly to the terminal using 24-bit (truecolor) ANSI escape codes and the Unicode upper-half-block trick (): each character cell packs two vertical pixels (foreground = top pixel, background = bottom pixel), giving a 160×144 GB screen in 160×72 terminal cells.

To keep terminal load low, output is minimized two ways: SGR color escapes are only emitted when the fg/bg changes, and inter-frame diffing redraws only the cells that changed since the previous frame (jumping over unchanged runs with ESC[row;colH). A fully static screen writes nothing after the first frame; on the acid2 screens this cut output from ~7.1 MB to ~65 KB over 2 s (~108×). Terminal resize (SIGWINCH) triggers a full repaint.

Build & run

make
./build/gbc path/to/rom.gb             # interactive (ROM is the final argument)
./build/gbc --test 30 rom.gb           # run 30s, log serial to stderr (test roms)
./build/gbc --shot 60 out.ppm rom.gb   # run 60 frames, dump a PPM screenshot
./build/gbc --sixel 3 rom.gb           # sixel graphics output at 3x zoom
./build/gbc --sixel 3 --chrome rom.gb  # ...wrapped in a Game Boy body/frame
./build/gbc --bios rom.gb              # boot through the BIOS before the game
./build/gbc --sock rom.gb              # open a control/debug socket (/tmp/gbc.sock)
./build/gbc --headless rom.gb          # no video; serial <-> stdio (OS console)
./build/gbc --help                     # full option list

Boot ROM / BIOS (--bios)

By default the emulator skips the boot ROM and starts the cartridge directly with the documented post-boot register state. Pass --bios [path] to instead run a real boot ROM first, so you get the authentic power-on animation (the Nintendo logo drop, and on a CGB the color splash + chime) before control is handed to the game:

./build/gbc --bios roms/pky.gbc                 # defaults to bios/gbc_bios.bin
./build/gbc --bios path/to/boot.bin roms/pky.gbc
./build/gbc --bios --sixel 3 --chrome roms/pky.gbc   # boot inside the shell

The boot ROM is mapped over low memory (with the CGB header hole at 0x0100-0x01FF where the cartridge shows through) and unmaps itself when the ROM writes 0xFF50, exactly like hardware. A 256-byte image boots as DMG; a 2304-byte image (like the bundled bios/gbc_bios.bin) boots as CGB — which also colorizes plain DMG games via the boot ROM's compatibility palette.

Headless serial console (--headless)

For bring-up and debugging of an OS/monitor running on the emulated Game Boy, --headless disables all rendering and the terminal UI and instead wires the serial port straight to stdio:

  • transmitted bytes (the GB writing SB/SC with the internal clock) go to stdout
  • stdin is fed back as the received byte on each transfer (non-blocking; 0xFF open-bus when nothing is waiting)

Emulation is paced to native speed by default; add --uncapped to run flat out. Because it's plain stdio it pipes and scripts cleanly:

./build/gbc --headless os.gb                  # interactive byte console
./build/gbc --headless --uncapped os.gb       # as fast as possible
printf 'ls\n' | ./build/gbc --headless os.gb  # feed input, capture output
./build/gbc --headless os.gb | tee session.log

The serial console model matches what a GB program expects: write the byte to SB ($FF01), then kick a transfer with SC = $81 ($FF02, start + internal clock). Output appears immediately; the transfer-complete serial interrupt still fires. (--test keeps its old behavior, logging serial to stderr.)

Requires a truecolor-capable terminal (COLORTERM=truecolor) at least 160 columns wide.

Sixel output

On terminals that support sixel graphics (xterm -ti vt340, foot, WezTerm, mlterm, Contour, recent iTerm2, etc.) pass --sixel [scale] to render true pixels instead of half-blocks:

./build/gbc --sixel rom.gb        # default 2x zoom (320×288 pixels)
./build/gbc --sixel 4 rom.gb      # 4x zoom

Each frame builds a per-frame palette from the framebuffer (≤256 entries — 4 shades on DMG, a small set on CGB; colors are quantized more coarsely only if a frame ever overflows), then emits standard sixel bands with per-column run-length encoding. scale is an integer pixel zoom (1–6). Sixel frames are sent in full (no inter-frame diffing), so combine with --frameskip on slower terminals.

Game Boy chrome

Add --chrome (sixel only) to draw a DMG-style sl0pboy handheld around the LCD — screen bezel with accent stripe, power LED, silk-screened branding (sl0pboy logo, DOT MATRIX WITH STEREO SOUND, button labels), a D-pad, A/B buttons, Start/Select and a speaker grille. The controls light up while the matching button is held, so it doubles as an on-screen input display:

./build/gbc --sixel 3 --chrome rom.gb

The shell is drawn with flat colors so it adds only a handful of palette entries. It composites into a larger canvas (240×342 px before scaling), so a given scale produces a proportionally bigger image than the bare LCD.

LCD palette (--palette)

By default the LCD shows the game's true colors (the greenish DMG shades, or full CGB color). Pass --palette NAME to remap every LCD pixel to a 4-shade palette by luminance for the classic monochrome-LCD look — this even greens out full-color CGB games for that retro vibe:

./build/gbc --sixel 3 --chrome --palette dmg rom.gb   # classic DMG-01 pea green
./build/gbc --sixel 3 --green rom.gb                  # alias for --palette dmg
./build/gbc --palette gray rom.gb                     # GB-Pocket grayscale

Names: dmg/green (the iconic #9bbc0f pea-green ramp) and pocket/gray (grayscale). The recolor applies to every render path — half-block, sixel and chrome — and only touches the LCD, not the drawn shell.

Speed & frame skipping

The emulator always advances every Game Boy frame at the correct rate, so games run at their intended speed. Because writing a frame to the terminal is by far the most expensive step, drawing can be decoupled from emulation via frame skipping — the machine keeps running in real time while the terminal is refreshed less often:

./build/gbc --frameskip 2 rom.gb   # emulate 60 fps, draw every 3rd frame (20 fps)
./build/gbc --fps 120 rom.gb        # run the game at 2x speed
./build/gbc --uncapped rom.gb       # run as fast as possible (turbo)
Flag Meaning
--frameskip N draw 1 of every N+1 emulated frames (default 0)
--fps N emulation speed cap in fps (default 59.73 native)
--uncapped/--turbo run emulation as fast as possible

Frame skip and turbo are also adjustable live with [ / ] and f; in turbo mode terminal redraws are additionally clamped to ~60 Hz. On exit the emulator prints how many frames it emulated vs. actually drew.

Controls

Key Button
arrows / WASD D-pad
z / j A
x / k B
enter Start
space Select
f toggle fast-forward (turbo)
[ / ] decrease / increase frame skip
q / Ctrl-C Quit

Since terminals don't report key-release, buttons are held for a few frames after each keypress (relying on the terminal's key-repeat for sustained holds).

External control channel (FIFO)

Start with --fifo [path] (default /tmp/gbc.fifo) to open a named pipe that accepts button commands from any process. Unlike the terminal keyboard, this can send true key-release events, so it gives deterministic input for scripting and automated testing.

./build/gbc --fifo /tmp/gbc.fifo rom.gb &
echo 'a' > /tmp/gbc.fifo          # momentary tap of A
echo 'start' > /tmp/gbc.fifo      # tap Start
echo '+left' > /tmp/gbc.fifo      # press and HOLD Left
echo 'a b' > /tmp/gbc.fifo        # tap A and B together (Left still held)
echo '-left' > /tmp/gbc.fifo      # release Left
echo 'release' > /tmp/gbc.fifo    # release everything held
echo 'a:30' > /tmp/gbc.fifo       # tap A, held for 30 frames

Commands (one line, space/comma separated tokens, case-insensitive):

Token Effect
a b start select up down left right momentary tap
name:N tap held for N frames
+name press and hold until released
-name release a held button
release release all held buttons

Set GBC_INPUT_DEBUG=1 to trace resulting joypad state to stderr.

The FIFO is one-way (input only) and can't reply. For introspection/debugging use the socket control channel below, which is a strict superset.

Socket control & debug channel

Start with --sock [path] (default /tmp/gbc.sock) to open a Unix-domain stream socket. It accepts everything the FIFO does plus emulator introspection commands, and — being a socket — it replies to each command and pushes asynchronous events when execution stops. Connect any line-oriented client:

./build/gbc --sock /tmp/gbc.sock rom.gb &
socat - UNIX-CONNECT:/tmp/gbc.sock       # interactive
# or: nc -U /tmp/gbc.sock

The protocol is line based (one command per line). Replies start with ok or err; asynchronous notifications start with event. Numbers accept 0x hex or decimal; memory bytes are hex (so read output feeds straight back into write).

Command Effect
ping pong liveness check
help one-line command summary
state ok state running\|paused
cpu (regs) dump CPU context (af/bc/de/hl/sp/pc/ime/halted/cycles)
reg <name> <val> set a register/flag (a..l, af..hl, sp, pc, ime)
read <addr> [len] read len bytes (bus read), returned as hex
write <addr> <bytes…> write bytes: de ad be ef or a deadbeef string
step [n] single-step n instructions, replies with new context
continue (c) resume free-running emulation
pause halt CPU advancement at the next instruction
break <addr> add a PC breakpoint (no arg lists them)
delete <0xaddr\|#idx\|all> remove a breakpoint
watch <addr> value-change watchpoint on a byte (no arg lists)
unwatch <0xaddr\|#idx\|all> remove a watchpoint
record start <path> [everyN] capture frames to <path> (1 of every N); build a GIF with tools/gbgif.py
record stop finish the capture (replies with the frame count)
record (status) report whether a capture is active
input play <path> [reset] replay a synthesized input movie (frame-locked); reset = replay from a clean power-on
input stop stop movie playback (releases all buttons)
input (status) report movie playback state
quit shut the emulator down
(any button tokens) same vocabulary as the FIFO (tap/hold/release)

Video capture & input replay (demo GIFs)

Capture the displayed frame to a flat file and turn it into a GIF:

./gbctl record start /abs/out.gbv 2   # capture 1 of every 2 frames (~30fps)
...
./gbctl record stop
python3 tools/gbgif.py out.gbv out.gif --scale 3

Recording captures exactly what's on screen: if the emulator was started with --palette and/or --chrome, those are baked into the capture (the .gbv header records the resulting dimensions, so e.g. chrome frames are 240×362 and gbgif.py picks that up automatically). --shot screenshots honor the same options. Start without them for the bare 160×144 true-color LCD.

Drive the ROM from a synthesized input movie instead of live input, for reproducible demos. A movie is a text file of <frames> [buttons...] lines (a b start select up down left right; none/- = released); the emulator applies one line's worth of joypad state per emulated frame, so replay is deterministic. Build one by hand or with tools/gbmovie.py:

from gbmovie import Movie
m = Movie()
m.wait(340)                 # boot splash + fastboot -> overworld
m.hold("select", 8)         # open the SL0P menu
m.tap("down", repeat=13)    # cursor to the last item
m.hold("a", 6); m.wait(600)
m.save("demo.gbmv")

input play <path> reset gives a clean, repeatable power-on (keeps cart ROM+SRAM, zeroes all other state), so a movie replays byte-identically every time. For a frame-exact demo GIF, arm both while paused so they start on the same frame:

./gbctl pause
./gbctl input play /abs/demo.gbmv reset
./gbctl record start /abs/demo.gbv 2
./gbctl continue
...                                   # let the movie play
./gbctl record stop
python3 tools/gbgif.py demo.gbv demo.gif --scale 3 --start 172

When free-running (continue) hits a breakpoint or watchpoint, every connected client receives an async line and the machine pauses:

event stop breakpoint af=0x0840 bc=0x0800 … pc=0x0048 … cycles=72654560
event stop watch 0xFF44 af=0x90C0 … pc=0x4812 …

There is also a stopinfo command (alias why/laststop) that reports the last reason execution halted, so a client that wasn't connected when an async event fired can still recover it (ok stop breakpoint 0x0150 …).

step runs synchronously and replies immediately with the stop reason and the resulting context (ok stop step … / ok stop breakpoint … / ok stop watch …). Example session:

> pause
ok paused af=0x9040  pc=0x021D 
> break 0x0150
ok break #0 0x0150
> read 0xFF44 1
ok read 0xFF44 1 90
> write 0xC000 de ad be ef
ok write 0xC000 4
> reg pc 0x0150
ok reg pc=0x0150
> continue
ok running
event stop breakpoint  pc=0x0150 

gbctl — CLI driver (agent/tmux friendly)

./gbctl wraps the socket in a terse CLI that also spawns/stops a tmux pane running the emulator, so an LLM agent (or you) can drive it by tool-calling one command at a time. Socket auto-resolves to the single live instance.

./gbctl spawn roms/game.gb --uncapped   # tmux pane + emulator --sock; waits ready
./gbctl cpu ; ./gbctl read 0xC000 16 ; ./gbctl write 0xC000 deadbeef
./gbctl break 0x0150 ; ./gbctl continue 5   # blocks up to 5s for the stop event
./gbctl press a b ; ./gbctl hold left ; ./gbctl release
./gbctl screen ; ./gbctl stop               # view the pane / tear down

Run ./gbctl with no args for the full command list. This is driven end-to-end by the gbc-driver skill.

Architecture

File Responsibility
types.h fixed-width integer typedefs
cart.c ROM loading, header parsing, MBC1/2/3/5, battery saves
gb.c system bus / memory map, joypad, OAM-DMA, HDMA/GDMA, reset
cpu.c Sharp SM83 core (full opcode set, cycle-accurate accesses)
timer.c DIV/TIMA/TMA/TAC with falling-edge accuracy
ppu.c LCD controller, scanline renderer (BG/window/sprites), CGB
render.c terminal truecolor output + raw-mode keyboard/FIFO input
control.c socket control/debug channel: memory, CPU, step, breakpoints
main.c argument parsing, frame loop, timing, screenshot/test modes

Emulation status

Passing:

  • blargg cpu_instrs — all 11 sub-tests
  • blargg instr_timing
  • blargg mem_timing / mem_timing-2 — all 3
  • dmg-acid2 — PPU test renders correctly
  • cgb-acid2 — CGB PPU test renders correctly

Implemented:

  • Full SM83 CPU incl. HALT bug, EI delay, interrupt dispatch
  • Accurate timer edge behavior
  • PPU modes 0–3, STAT/LYC/VBlank interrupts, window line counter
  • CGB: double VRAM banks, BG/OBJ color palettes, tile attributes, master priority, WRAM banking, double-speed (KEY1), HDMA/GDMA
  • MBC1/2/3/5 with RAM banking and battery .sav files

Not yet implemented:

  • APU (audio) — registers are stubbed
  • MBC3 RTC advancement
  • Sub-instruction PPU FIFO timing edge cases

License

For educational use. Test ROMs are downloaded separately and not included.