aboutsummaryrefslogtreecommitdiffstats

sl0pboy — a Game Boy Color emulator that lives in your terminal

A from-scratch GB / GBC emulator in plain C11 that paints straight to the terminal — no window, no GPU. Two ways to draw it:

  • half-blocks (the default): truecolor ANSI + the upper-half-block trick (▀), where every character cell = two stacked pixels (fg on top, bg on bottom), squeezing the 160×144 screen into 160×72 cells. Works anywhere with truecolor.
  • sixel (--sixel): real pixels on terminals that speak it (see below).
  • kitty graphics (--kitty): real pixels without sixel's 256-color palette, plus a translucent HUD layered over the game (see below).

Resize the terminal and SIGWINCH triggers a full repaint.

Build & run

git config core.hooksPath hooks   # once: clang-format pre-commit hook
make
./build/sl0pboy path/to/rom.gb             # just play it (ROM goes last)
./build/sl0pboy --test 30 rom.gb           # run 30s, serial -> stderr (test roms)
./build/sl0pboy --shot 60 out.ppm rom.gb   # 60 frames in, dump a PPM
./build/sl0pboy --sixel 3 rom.gb           # real pixels via sixel, 3x zoom
./build/sl0pboy --kitty 3 rom.gb           # ...or via the kitty protocol
./build/sl0pboy --sixel 3 --chrome rom.gb  # ...in a lil' handheld body
./build/sl0pboy --bios rom.gb              # boot through the real BIOS first
./build/sl0pboy --sock rom.gb              # control/debug socket (/tmp/sl0pboy.sock)
./build/sl0pboy --headless rom.gb          # no video, serial <-> stdio
./build/sl0pboy --help                     # the whole menu

Needs a truecolor terminal (COLORTERM=truecolor) at least 160 columns wide.

Boot ROM / BIOS (--bios)

By default we skip the boot ROM and drop you into the cartridge with the documented post-boot register state. Want the real power-on show — the Nintendo logo drop, the CGB color splash + chime? Pass --bios [path]:

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

It maps the boot ROM over low memory (with the CGB header hole at 0x0100-0x01FF where the cart peeks through) and unmaps itself when the ROM pokes 0xFF50, just like hardware. 256 bytes → boots as DMG; 2304 bytes (like the bundled bios/gbc_bios.bin) → boots as CGB, which also colorizes plain DMG games via the boot ROM's compatibility palette.

SL0PBOY boot logo (tools/mk_sl0pboy_bios.py)

Fork the CGB boot ROM so the power-on animation reads SL0PBOY instead of GAME BOY — same italic drop, colorize and chime:

pip install pillow                      # needs Pillow + a bold-italic TTF
tools/mk_sl0pboy_bios.py                 # bios/gbc_bios.bin -> bios/sl0pboy_bios.bin
./build/sl0pboy --bios bios/sl0pboy_bios.bin --sixel 3 --chrome roms/pky.gbc
SL0P_TEXT='SL0P' tools/mk_sl0pboy_bios.py    # or brand it whatever you like

It also drops a bios/sl0pboy_bios.bin.preview.png so you can eyeball the wordmark before booting. Env knobs: SL0P_TEXT (default SL0PBOY), SL0P_FONT (default LiberationSans-BoldItalic).

How it works. The stock CGB boot ROM draws its GAME BOY wordmark from a 192-byte blob at 0x0607 — the routine at 0x03F0 copies it to VRAM tiles 0x08-0x37: 48 tiles (16×3, tile-sequential), 4 bytes each, every byte one 8-px row that the copy loop doubles vertically (plane 1 stays 0). The script re-encodes that blob from a bold-italic bitmap and patches it in place — no code moves, and the cart's own Nintendo logo (0x0104) is untouched, so the boot ROM's integrity check still passes and it hands off to the game normally.

bios/ is git-ignored (the stock boot ROM is copyrighted Nintendo material), so the forked binary lives only in your working tree — regenerate it any time from your local bios/gbc_bios.bin. The script itself ships only our original logo art + the patch offset.

Headless serial console (--headless)

Bringing up an OS/monitor on the emulated Game Boy? --headless kills all rendering and wires the serial port straight to stdio:

  • bytes the GB transmits (SB/SC w/ internal clock) → stdout
  • stdin feeds back as the received byte each transfer (non-blocking; 0xFF open-bus when nothing's waiting)

Paced to native speed by default; --uncapped runs flat out. It's just stdio, so it pipes clean:

./build/sl0pboy --headless os.gb                  # interactive byte console
./build/sl0pboy --headless --uncapped os.gb       # gotta go fast
printf 'ls\n' | ./build/sl0pboy --headless os.gb  # feed input, grab output
./build/sl0pboy --headless os.gb | tee session.log

The model is what a GB program expects: write byte → SB ($FF01), kick with SC = $81 ($FF02, start + internal clock). Output shows up instantly and the transfer-complete interrupt still fires. (--test still logs serial to stderr like before.)

Sixel output

On sixel-capable terminals (xterm -ti vt340, foot, WezTerm, mlterm, Contour, recent iTerm2…) --sixel [scale] draws real pixels instead of half-blocks:

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

Each frame builds its own ≤256-color palette from the framebuffer (4 shades on DMG, a small set on CGB; quantized coarser only if a frame overflows) and emits standard sixel bands with per-column RLE. scale is 1–6. Sixel frames go out in full (no diffing) — pair with --frameskip on slow terminals.

Kitty graphics (--kitty)

Sixel is a 1987 printer format wearing a terminal costume: 256 colors per image, no alpha, no compositing, one flat picture. On kitty, ghostty, WezTerm, Konsole and friends there's a better option:

./build/sl0pboy --kitty rom.gb            # 2x zoom
./build/sl0pboy --kitty 4 rom.gb          # 4x zoom (1-8)
./build/sl0pboy --kitty 3 --chrome rom.gb # the handheld body works here too

What it buys over --sixel:

  • Truecolor, unquantized. The framebuffer goes over as raw RGB888. No per-frame palette build, no coarser quantization when a CGB game overflows 256 colors — what the PPU drew is what you see, byte for byte.
  • A HUD that floats above the game. Cart title, live render rate, frameskip, turbo, and a per-button readout that lights up as you play — drawn as a separate alpha-blended image on z-index 1, so it's composited over the frame by the terminal instead of being burned into the picture. h toggles it, --no-hud starts without it.
  • Idle frames cost nothing. Each image is diffed against the last one and dropped if identical, so a pause menu or a text box stops the traffic dead.
  • Constant bandwidth at any zoom. The pixels ship at 1x and the terminal scales them into a cell box, so --kitty 6 costs the same bytes as --kitty 1 (the image is padded to a whole number of cells first, so the zoom stays exact instead of stretching by a few percent).

How the pixels get there

transfer escape carries when
shm the name of a POSIX shared-memory object terminal is on this machine
b64 the pixel bytes, base64, in 4KB chunks anywhere (ssh, tmux)

Shared memory means a ~60-byte escape per frame instead of ~90KB, so it's the default when we can tell we're talking to a local kitty/ghostty; everything else (including anything inside tmux, which might be attached from another machine) uses inline base64. Force either with --kitty-shm / --kitty-b64. In shm mode the frame is upscaled here rather than by the terminal, so pixel edges stay hard. Objects are named /sl0pboy-<pid>-<n>; the terminal unlinks each one after reading it and we reclaim any it didn't a few frames later.

Inside tmux every command is wrapped in a passthrough DCS (needs set -g allow-passthrough on). tmux doesn't track images, so it repaints over them — harmless here, since we redraw every frame anyway.

--kitty and --sixel are mutually exclusive; the kitty one wins.

Poking at the wire format

Nothing about a graphics stream is human-readable, so tools/kitty_decode.py reassembles a capture back into images (chunked payloads, tmux wrapping and all) and can assert a frame is pixel-identical to a --shot:

./build/sl0pboy --shot 1 /tmp/shot.ppm rom.gb
./build/sl0pboy --kitty 1 --kitty-b64 rom.gb > /tmp/cap.bin   # ^C after a bit
tools/kitty_decode.py /tmp/cap.bin -o /tmp/frames --check /tmp/shot.ppm

Game Boy chrome

--chrome (sixel/kitty) wraps the LCD in a DMG-style sl0pboy handheld: bezel + accent stripe, power LED, silk-screened branding (sl0pboy logo, DOT MATRIX WITH STEREO SOUND, button labels), D-pad, A/B, Start/Select, speaker grille. Buttons light up while held, so it doubles as an input display:

./build/sl0pboy --sixel 3 --chrome rom.gb
./build/sl0pboy --kitty 3 --chrome rom.gb

Flat colors → only a handful of extra palette entries. It composites onto a bigger canvas (240×342 px pre-scale), so a given scale is proportionally larger than the bare LCD.

LCD palette (--palette)

By default the LCD shows true colors (greenish DMG shades or full CGB). Want the retro monochrome-LCD look? --palette NAME remaps every LCD pixel to a 4-shade ramp by luminance — this even greens out full-color CGB games:

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

Names: dmg/green (the iconic #9bbc0f pea-green ramp) and pocket/gray. Applies to every render path — half-block, sixel, kitty, chrome — and only touches the LCD, never the shell.

Speed & frame skipping

Games always run at the correct rate. But writing a frame to the terminal is the expensive part, so drawing is decoupled from emulation — the machine keeps ticking in real time while the terminal refreshes less often:

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

Frame skip and turbo are live-adjustable with [ / ] and f (turbo also clamps redraws to ~60 Hz). On exit it prints frames emulated vs. actually drawn.

Controls

Key Button
arrows / WASD D-pad
z / j A
x / k B
enter Start
space Select
f toggle fast-forward (turbo)
h toggle the HUD overlay (--kitty)
[ / ] frame skip down / up
q / Ctrl-C Quit

Terminals don't report key-release, so buttons stay held for a few frames after each press (leaning on key-repeat for sustained holds).

External control channel (FIFO)

--fifo [path] (default /tmp/sl0pboy.fifo) opens a named pipe any process can poke. Unlike the keyboard it can send true key-release events → deterministic input for scripting and tests.

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

One line, space/comma-separated, 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 everything held

GBC_INPUT_DEBUG=1 traces joypad state to stderr. The FIFO is input-only and can't reply — for introspection use the socket below (it's a strict superset).

Socket control & debug channel

--sock [path] (default /tmp/sl0pboy.sock) opens a Unix stream socket. It does everything the FIFO does plus CPU/memory introspection, and — being a socket — it replies and pushes async events when execution stops:

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

Line-based: one command per line. Replies start ok/err; async notifications start event. Numbers take 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), as hex
write <addr> <bytes…> write bytes: de ad be ef or deadbeef
step [n] single-step n instructions, replies w/ new context
continue (c) resume free-running emulation
pause halt 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 (1 of every N); GIF w/ tools/gbgif.py
record stop finish the capture (replies w/ frame count)
record (status) is a capture active?
input play <path> [reset] replay an input movie (frame-locked); reset = from clean power-on
input stop stop playback (releases all buttons)
input (status) movie playback state
quit shut it down
(any button tokens) same vocabulary as the FIFO

Video capture & input replay (demo GIFs)

Dump the on-screen frame to a flat file, then bake a GIF:

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

Capture grabs exactly what's on screen — start with --palette/--chrome and they're baked in (the .gbv header stores the real dimensions, so chrome frames come out 240×362 and gbgif.py figures it out automatically). --shot screenshots honor the same options. Skip them for the bare 160×144 LCD.

Want reproducible demos? Drive the ROM from a synthesized input movie instead of live input. 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 joypad state per frame, so replay is deterministic. Build by hand or with tools/gbmovie.py:

from gbmovie import Movie
m = Movie()
m.wait(340)                 # boot + 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 everything else), so a movie replays byte-identically. For a frame-exact 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 it play
./gbctl record stop
python3 tools/gbgif.py demo.gbv demo.gif --scale 3 --start 172

When free-running hits a breakpoint/watchpoint, every connected client gets 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 …

Missed the event because you weren't connected yet? stopinfo (aka why/laststop) reports the last stop reason (ok stop breakpoint 0x0150 …). step runs synchronously and replies immediately with the stop reason + new context. A quick 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 — the CLI driver (agent/tmux friendly)

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

./gbctl spawn roms/game.gb --uncapped   # tmux pane + --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
./gbctl press a b ; ./gbctl hold left ; ./gbctl release
./gbctl screen ; ./gbctl stop               # peek at the pane / tear down

Run ./gbctl bare for the full command list. Driven end-to-end by the gbc-driver skill.

In the browser (make wasm)

The same core, compiled to WebAssembly, so anyone can try a ROM without building anything:

make wasm                 # -> web/sl0pboy.wasm (~44KB)
cp ~/dev/gbos/gbos.gb web/roms/     # the demo ROM (gitignored; deploy.sh ships it)
cd web && python3 -m http.server 8000
# /           the network lab: several Game Boys on a virtual switch
# /play.html  one Game Boy, drop in your own .gb/.gbc

Built with zig cc (no emscripten): it carries its own wasm backend, linker and wasi-libc, so make wasm needs nothing but zig on PATH. Only the portable core compiles in — render.c, kitty.c, control.c and main.c are terminal- and POSIX-bound and are left out; src/wasm.c is the browser's host layer instead. It's a reactor module, so the page calls _initialize() and then drives the exports directly:

export does what
gbw_alloc / gbw_free staging buffer for handing a ROM to wasm
gbw_load(ptr,len) parse cart + reset (0 = ok)
gbw_load_bootrom(ptr,len) boot through a real BIOS, logo and all
gbw_run_frame() advance exactly one frame
gbw_set_buttons(mask) BTN_* bits; browsers report key-release, so this is the true live state
gbw_framebuffer_rgba() 160×144×4, straight into putImageData
gbw_framebuffer_rgb() the raw PPU buffer, same bytes --shot writes
gbw_sram_ptr/_size/_load cartridge RAM — is the save file here

The whole host interface is four WASI fd_* imports (stdio, from the cart info banner), shimmed in ~20 lines of JS; the page routes them to the devtools console. No filesystem, no sockets, no WASI runtime needed.

web/index.html is the front-end: drag-drop a ROM, pixelated canvas at any zoom, keyboard mapped like the native build, and battery saves persisted to localStorage per cart title (autosaved every 5s and on unload). Frame pacing is an accumulator against 59.7275Hz with capped catch-up, so a backgrounded tab skips ahead instead of running hundreds of frames in one burst.

No audio — the APU is still stubbed (see below).

The network lab (web/index.html)

The landing page runs several Game Boys in one tab, wired to a virtual switch: each one boots gbos, gets a DHCP lease, and they ping each other. Plug one in, plug in another, unplug either; addresses go back to the pool and get reused.

It is tools/gbhub from the gbos repo (a virtual switch + DHCP server) ported to ~200 lines of JS in web/gbhub.js — SLIP framing, DHCP OFFER/ACK, unicast routing, broadcast flooding, and ICMP echo for the gateway. The one thing it cannot do is NAT to the real internet: that needs a TUN device, and a browser tab has no raw sockets. So it is a closed network — the Game Boys reach each other and 10.0.0.1, and nothing leaves the page.

Multiple machines are multiple instantiations of the same compiled module (WebAssembly.compile once, instantiate N times). The core keeps its machine in a static, so each Game Boy gets its own linear memory and total isolation for free — no handle-based API in C.

The page caps at 8, which is a judgement call on top of two real limits. Measured on one thread, emulation only:

Game Boys per machine headroom wasm memory
1 748 fps 12.5x 5MB
4 185 fps 3.1x 19MB
8 92 fps 1.5x 38MB
12 62 fps 1.0x 57MB
16 46 fps 0.8x 76MB

So ~12 is where a single thread runs out of road with nothing left for rendering; 8 holds a measured 60fps each in a real browser. Above that you'd want a Worker per machine. The other ceiling is the DHCP pool (10.0.0.2– .39, inherited from gbhub) at 38.

Each instance costs ~4.8MB of linear memory, which is mostly the 1MB ROM plus allocator slack — it was 20.5MB until the wasm stack was capped at 256KB (-Wl,-z,stack-size): zig defaults it to 16MB, and that lands in the module's initial memory, so every Game Boy paid 16.25MB before executing anything.

Bytes that arrive outside a SLIP frame are console I/O, not packets — the same split gbhub makes. That is why each Game Boy on the page has a text box: typing into the link port lands on the shell's stdin, exactly like tools/gbtype does natively. No on-screen keyboard needed to run ping.

The C side of this is two byte queues (gb_serial_rx_push / gb_serial_tx_drain) standing in for the file descriptors the native build uses, exposed as gbw_serial_drain / gbw_serial_push. The native link (--serial-sock) is untouched: preprocessed for a native target, gb.c is byte-identical to before the change.

Proving it's the same emulator

Both builds advance on the identical frame boundary (gb_run_frame in gb.c), so the wasm core can be checked against the native one byte for byte rather than by eyeballing a canvas:

./build/sl0pboy --shot 60 /tmp/ref.ppm roms/cgb-acid2.gbc
node tools/wasm_check.mjs roms/cgb-acid2.gbc 60 --check /tmp/ref.ppm
# OK: wasm frame 60 matches /tmp/ref.ppm exactly (160x144)

Any mismatch is a real portability bug (struct layout, endianness, UB the two backends resolved differently), not a rendering difference. Currently exact on cgb-acid2, dmg-acid2, 600 frames of cpu_instrs, MBC5 + battery with an imported .sav, and a full CGB BIOS boot animation. --sram / --bios / -o out.ppm are supported too.

Architecture

File Does what
src/types.h fixed-width integer typedefs
src/cart.c ROM loading, header parsing, MBC1/2/3/5, battery saves
src/gb.c system bus / memory map, joypad, OAM-DMA, HDMA/GDMA, reset
src/cpu.c Sharp SM83 core (full opcodes, cycle-accurate accesses)
src/timer.c DIV/TIMA/TMA/TAC with falling-edge accuracy
src/ppu.c LCD controller, scanline renderer (BG/window/sprites), CGB
src/render.c terminal truecolor output + raw-mode keyboard/FIFO input
src/kitty.c kitty graphics protocol: shm/base64 transfer, tmux wrap
src/control.c socket control/debug: memory, CPU, step, breakpoints
src/main.c arg parsing, frame loop, timing, screenshot/test modes
src/wasm.c browser host layer: exports the core to JS (wasm build)
web/index.html the browser front-end (canvas, input, localStorage saves)

Emulation status

Passing:

  • blargg cpu_instrs — all 11 sub-tests
  • blargg instr_timing
  • blargg mem_timing / mem_timing-2 — all 3
  • dmg-acid2 + cgb-acid2 — both render 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:

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

License

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