| Mode | Name | Size | |
|---|---|---|---|
| -rw-r--r-- | .clang-format | 729 | logstatsplainblame |
| -rw-r--r-- | .gitignore | 64 | logstatsplainblame |
| -rw-r--r-- | Makefile | 517 | logstatsplainblame |
| -rw-r--r-- | README.md | 15451 | logstatsplainblame |
| -rwxr-xr-x | gbctl | 14501 | logstatsplainblame |
| d--------- | hooks | 38 | logstatsplain |
| d--------- | src | 549 | logstatsplain |
| d--------- | tools | 267 | logstatsplain |
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).
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 --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.
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/SCw/ internal clock) → stdout - stdin feeds back as the received byte each transfer (non-blocking;
0xFFopen-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.
Game Boy chrome
--chrome (sixel only) 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
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, 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) |
[ / ] |
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.
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/control.c |
socket control/debug: memory, CPU, step, breakpoints |
src/main.c |
arg 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 + 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
.savfiles
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.
