aboutsummaryrefslogtreecommitdiffstats
ModeNameSize
-rw-r--r--.gitignore64logstatsplainblame
-rw-r--r--Makefile517logstatsplainblame
-rw-r--r--README.md15336logstatsplainblame
-rwxr-xr-xgbctl14501logstatsplainblame
d---------src549logstatsplain
d---------tools267logstatsplain

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

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

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
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 opcodes, 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: memory, CPU, step, breakpoints
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 .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.