| Mode | Name | Size | |
|---|---|---|---|
| -rw-r--r-- | .gitignore | 57 | logstatsplainblame |
| -rw-r--r-- | Makefile | 513 | logstatsplainblame |
| -rw-r--r-- | README.md | 14133 | logstatsplainblame |
| -rwxr-xr-x | gbctl | 14461 | logstatsplainblame |
| -rw-r--r-- | slop_drive.sh | 2266 | logstatsplainblame |
| d--------- | src | 549 | logstatsplain |
| d--------- | tools | 267 | logstatsplain |
gbc — a Game Boy Color emulator in C, rendered to the terminal
A from-scratch Game Boy / Game Boy Color emulator written in plain C11. It
renders graphics directly to the terminal using 24-bit (truecolor) ANSI escape
codes and the Unicode upper-half-block trick (▀): each character cell packs
two vertical pixels (foreground = top pixel, background = bottom pixel),
giving a 160×144 GB screen in 160×72 terminal cells.
To keep terminal load low, output is minimized two ways: SGR color escapes
are only emitted when the fg/bg changes, and inter-frame diffing redraws
only the cells that changed since the previous frame (jumping over unchanged
runs with ESC[row;colH). A fully static screen writes nothing after the
first frame; on the acid2 screens this cut output from ~7.1 MB to ~65 KB
over 2 s (~108×). Terminal resize (SIGWINCH) triggers a full repaint.
Build & run
make
./build/gbc path/to/rom.gb # interactive
./build/gbc rom.gb --test 30 # run 30s, log serial to stderr (test roms)
./build/gbc rom.gb --shot 60 out.ppm # run 60 frames, dump a PPM screenshot
./build/gbc rom.gb --sixel 3 # sixel graphics output at 3x zoom
./build/gbc rom.gb --sock # open a control/debug socket (/tmp/gbc.sock)
./build/gbc rom.gb --headless # no video; serial <-> stdio (OS console)
Headless serial console (--headless)
For bring-up and debugging of an OS/monitor running on the emulated Game Boy,
--headless disables all rendering and the terminal UI and instead wires the
serial port straight to stdio:
- transmitted bytes (the GB writing
SB/SCwith the internal clock) go to stdout - stdin is fed back as the received byte on each transfer (non-blocking;
0xFFopen-bus when nothing is waiting)
Emulation is paced to native speed by default; add --uncapped to run flat out.
Because it's plain stdio it pipes and scripts cleanly:
./build/gbc os.gb --headless # interactive byte console
./build/gbc os.gb --headless --uncapped # as fast as possible
printf 'ls\n' | ./build/gbc os.gb --headless # feed input, capture output
./build/gbc os.gb --headless | tee session.log
The serial console model matches what a GB program expects: write the byte to
SB ($FF01), then kick a transfer with SC = $81 ($FF02, start + internal
clock). Output appears immediately; the transfer-complete serial interrupt still
fires. (--test keeps its old behavior, logging serial to stderr.)
Requires a truecolor-capable terminal (COLORTERM=truecolor) at least
160 columns wide.
Sixel output
On terminals that support sixel graphics (xterm -ti vt340, foot, WezTerm,
mlterm, Contour, recent iTerm2, etc.) pass --sixel [scale] to render true
pixels instead of half-blocks:
./build/gbc rom.gb --sixel # default 2x zoom (320×288 pixels)
./build/gbc rom.gb --sixel 4 # 4x zoom
Each frame builds a per-frame palette from the framebuffer (≤256 entries — 4
shades on DMG, a small set on CGB; colors are quantized more coarsely only if a
frame ever overflows), then emits standard sixel bands with per-column
run-length encoding. scale is an integer pixel zoom (1–6). Sixel frames are
sent in full (no inter-frame diffing), so combine with --frameskip on slower
terminals.
Speed & frame skipping
The emulator always advances every Game Boy frame at the correct rate, so games run at their intended speed. Because writing a frame to the terminal is by far the most expensive step, drawing can be decoupled from emulation via frame skipping — the machine keeps running in real time while the terminal is refreshed less often:
./build/gbc rom.gb --frameskip 2 # emulate 60 fps, draw every 3rd frame (20 fps)
./build/gbc rom.gb --fps 120 # run the game at 2x speed
./build/gbc rom.gb --uncapped # run as fast as possible (turbo)
| Flag | Meaning |
|---|---|
--frameskip N |
draw 1 of every N+1 emulated frames (default 0) |
--fps N |
emulation speed cap in fps (default 59.73 native) |
--uncapped/--turbo |
run emulation as fast as possible |
Frame skip and turbo are also adjustable live with [ / ] and f; in turbo
mode terminal redraws are additionally clamped to ~60 Hz. On exit the emulator
prints how many frames it emulated vs. actually drew.
Controls
| Key | Button |
|---|---|
| arrows / WASD | D-pad |
z / j |
A |
x / k |
B |
| enter | Start |
| space | Select |
f |
toggle fast-forward (turbo) |
[ / ] |
decrease / increase frame skip |
q / Ctrl-C |
Quit |
Since terminals don't report key-release, buttons are held for a few frames after each keypress (relying on the terminal's key-repeat for sustained holds).
External control channel (FIFO)
Start with --fifo [path] (default /tmp/gbc.fifo) to open a named pipe that
accepts button commands from any process. Unlike the terminal keyboard, this
can send true key-release events, so it gives deterministic input for
scripting and automated testing.
./build/gbc rom.gb --fifo /tmp/gbc.fifo &
echo 'a' > /tmp/gbc.fifo # momentary tap of A
echo 'start' > /tmp/gbc.fifo # tap Start
echo '+left' > /tmp/gbc.fifo # press and HOLD Left
echo 'a b' > /tmp/gbc.fifo # tap A and B together (Left still held)
echo '-left' > /tmp/gbc.fifo # release Left
echo 'release' > /tmp/gbc.fifo # release everything held
echo 'a:30' > /tmp/gbc.fifo # tap A, held for 30 frames
Commands (one line, space/comma separated tokens, 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 all held buttons |
Set GBC_INPUT_DEBUG=1 to trace resulting joypad state to stderr.
The FIFO is one-way (input only) and can't reply. For introspection/debugging use the socket control channel below, which is a strict superset.
Socket control & debug channel
Start with --sock [path] (default /tmp/gbc.sock) to open a Unix-domain
stream socket. It accepts everything the FIFO does plus emulator
introspection commands, and — being a socket — it replies to each command and
pushes asynchronous events when execution stops. Connect any line-oriented
client:
./build/gbc rom.gb --sock /tmp/gbc.sock &
socat - UNIX-CONNECT:/tmp/gbc.sock # interactive
# or: nc -U /tmp/gbc.sock
The protocol is line based (one command per line). Replies start with ok or
err; asynchronous notifications start with event. Numbers accept 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), returned as hex |
write <addr> <bytes…> |
write bytes: de ad be ef or a deadbeef string |
step [n] |
single-step n instructions, replies with new context |
continue (c) |
resume free-running emulation |
pause |
halt CPU advancement 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 to <path> (1 of every N); build a GIF with tools/gbgif.py |
record stop |
finish the capture (replies with the frame count) |
record (status) |
report whether a capture is active |
input play <path> [reset] |
replay a synthesized input movie (frame-locked); reset = replay from a clean power-on |
input stop |
stop movie playback (releases all buttons) |
input (status) |
report movie playback state |
quit |
shut the emulator down |
| (any button tokens) | same vocabulary as the FIFO (tap/hold/release) |
Video capture & input replay (demo GIFs)
Capture the RGB framebuffer to a flat file and turn it into a GIF:
./gbctl record start /abs/out.gbv 2 # capture 1 of every 2 frames (~30fps)
...
./gbctl record stop
python3 tools/gbgif.py out.gbv out.gif --scale 3
Drive the ROM from a synthesized input movie instead of live input, for
reproducible demos. 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 worth of joypad state per emulated frame, so replay is
deterministic. Build one by hand or with tools/gbmovie.py:
from gbmovie import Movie
m = Movie()
m.wait(340) # boot splash + 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 all other state), so a movie replays byte-identically every
time. For a frame-exact demo 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 the movie play
./gbctl record stop
python3 tools/gbgif.py demo.gbv demo.gif --scale 3 --start 172
When free-running (continue) hits a breakpoint or watchpoint, every connected
client receives 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 …
There is also a stopinfo command (alias why/laststop) that reports the
last reason execution halted, so a client that wasn't connected when an async
event fired can still recover it (ok stop breakpoint 0x0150 …).
step runs synchronously and replies immediately with the stop reason and the
resulting context (ok stop step … / ok stop breakpoint … / ok stop watch …).
Example 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 — CLI driver (agent/tmux friendly)
./gbctl wraps the socket in a terse CLI that also spawns/stops a tmux pane
running the emulator, so an LLM agent (or you) can drive it by tool-calling one
command at a time. Socket auto-resolves to the single live instance.
./gbctl spawn roms/game.gb --uncapped # tmux pane + emulator --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 event
./gbctl press a b ; ./gbctl hold left ; ./gbctl release
./gbctl screen ; ./gbctl stop # view the pane / tear down
Run ./gbctl with no args for the full command list. This is driven end-to-end
by the gbc-driver skill.
Architecture
| File | Responsibility |
|---|---|
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 opcode set, 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 channel: memory, CPU, step, breakpoints |
main.c |
argument 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 — PPU test renders correctly
- cgb-acid2 — CGB PPU test renders 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 implemented:
- APU (audio) — registers are stubbed
- MBC3 RTC advancement
- Sub-instruction PPU FIFO timing edge cases
License
For educational use. Test ROMs are downloaded separately and not included.
