aboutsummaryrefslogtreecommitdiffstats
ModeNameSize
-rw-r--r--.gitignore57logstatsplainblame
-rw-r--r--Makefile513logstatsplainblame
-rw-r--r--README.md14133logstatsplainblame
-rwxr-xr-xgbctl14461logstatsplainblame
-rw-r--r--slop_drive.sh2266logstatsplainblame
d---------src549logstatsplain
d---------tools267logstatsplain

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/SC with the internal clock) go to stdout
  • stdin is fed back as the received byte on each transfer (non-blocking; 0xFF open-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 .sav files

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.