1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
|
# 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 <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:
```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 `<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`:
```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 <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:
```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.
|