# 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 ```sh 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]`: ```sh ./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: ```sh 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: ```sh ./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: ```sh ./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: ```sh ./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: ```sh ./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: ```sh ./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. ```sh ./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: ```sh ./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 ` | set a register/flag (a..l, af..hl, sp, pc, ime) | | `read [len]` | read `len` bytes (bus read), as hex | | `write ` | 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 ` | add a PC breakpoint (no arg lists them) | | `delete <0xaddr\|#idx\|all>` | remove a breakpoint | | `watch ` | value-change watchpoint on a byte (no arg lists) | | `unwatch <0xaddr\|#idx\|all>`| remove a watchpoint | | `record start [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 [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: ```sh ./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 ` [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`: ```python 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 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: ```sh ./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. ```sh ./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 `.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.