A TUI 6502 emulator and debugger, written in Go.
chippy emulates the NMOS 6502 CPU and presents a terminal UI built with
Bubble Tea for inspecting
registers, flags, the stack, live disassembly, and memory while you single-step
or free-run a program.
It speaks the ca65/cc65 toolchain natively: load a .bin, .prg, .hex, or
even an unlinked .o (chippy will run ld65 for you), and any sibling
.dbg symbol file is auto-detected so the disassembly shows real names and
breakpoints can resolve to source lines.
Plenty of 6502 emulators exist. chippy's pitch is debugger-first:
- TUI + DAP + WASM, one engine. Same NMOS / 65C02 core powers the terminal UI, the Debug Adapter Protocol server (so VS Code / nvim-dap / JetBrains can drive it), and the in-browser WASM playground. One implementation, three surfaces.
- Source-level debugging from C and ca65. Auto-detected
.dbgfiles turn.binaddresses intofile:lineand symbol names β breakpoints, watches, and conditional expressions resolve against the same names you wrote. - Reverse-step that actually scales. Page-level copy-on-write snapshots cost hundreds of bytes per step instead of 64 KiB, so the rewind ring works during free-run too (a 1000-iter tight loop fits in <1 MiB).
- MMIO peripherals you can poke from BASIC-era ROMs. Apple-1 style
TextOutput at $F001 and KeyboardInput at $F004/$F005 ship out of the
box; the same
peripheralpackage will host VIA 6522 next. - Real 6502 + 65C02 compliance. Klaus Dormann's functional tests pass end-to-end for both variants; an exhaustive BCD sweep covers every ADC/SBC input combination.
Compared to:
- py65 β Python, no source-level debug, no DAP. Good for scripting.
- lib6502 β C library to embed in a host; no debugger of its own.
- visual6502 β gate-level transistor simulation. Slower and not interactive; chippy is for development workflow, visual6502 is for hardware archaeology.
brew tap nkane/tap
brew install chippycurl -LO https://github.com/nkane/chippy/releases/latest/download/chippy_<VERSION>_linux_amd64.deb
sudo dpkg -i chippy_<VERSION>_linux_amd64.debsudo rpm -i https://github.com/nkane/chippy/releases/latest/download/chippy_<VERSION>_linux_amd64.rpmsudo apk add --allow-untrusted chippy_<VERSION>_linux_amd64.apkyay -S chippy-bin
# or any other AUR helperGrab a release archive for your platform from the
releases page and drop the
chippy binary on your $PATH. Builds available for darwin/linux on
amd64+arm64 and windows on amd64. cosign verify-blob recipe in
SECURITY.md verifies the bundled signature.
go install github.com/nkane/chippy/cmd/chippy@latestOr build from a checkout:
git clone git@github.com:nkane/chippy.git
cd chippy
go build ./cmd/chippy
go test ./...Optional: install cc65 to assemble your own programs.
cmd/nessy is an NES emulator built on chippy's CPU + bus + DAP server.
It's behind the nessy build tag because it pulls in
Ebiten for the game window, which needs X11 / GL
dev headers on Linux that the default CI runners don't carry.
# darwin / windows
go build -tags=nessy -o nessy ./cmd/nessy
./nessy game.nes
# linux (one-time install)
sudo apt-get install -y libgl1-mesa-dev xorg-dev libasound2-dev \
libxcursor-dev libxinerama-dev libxi-dev libxrandr-dev
go build -tags=nessy -o nessy ./cmd/nessyv0.1 scope: iNES mapper 0 (NROM) ROMs, background-only rendering at 60 fps,
standard joypad input on player 1, DAP server on :14785 so
chippy -dap-attach tcp:localhost:14785 can attach the TUI. No sprites, no
audio, no scrolling β see docs/plans/nessy.md for
the v0.2+ roadmap.
Controls: Arrows = D-pad, Z = A, X = B, Enter = Start, Right Shift = Select.
Gamepad support: any standard-layout controller (Xbox / DualSense / 8BitDo / etc.) auto-routes to P1. D-pad + left analog stick both drive the D-pad; bottom face button = A, right face button = B, Start / Select = the centre buttons. Hot-plug notified on stderr.
Player hotkeys: Tab (hold) = 4Γ fast-forward, F11 = fullscreen toggle,
F12 = save a PNG of the current frame to ~/.nessy/screenshots/.
Save states: F1βF4 save into slots 1β4, F5βF8 load the matching slot.
Saves live in ~/.nessy/states/<rom-hash>-slot<N>.state (gzip-compressed
gob). Slots are keyed by ROM SHA-256 β a save from one game can't
accidentally restore into another.
Recent ROMs: nessy with no args prints the last 5 ROMs you booted
from ~/.nessy/recent; nessy N (1..5) opens the Nth recent slot.
Controller config: ~/.nessy/controller.json re-maps any NES button
to any Ebiten key. Example:
{ "p1": { "A": "Space", "B": "LeftShift", "Start": "Enter" } }Missing entries keep the default mapping (Arrows / Z / X / Enter / Right Shift).
Skip the two-terminal dance. chippy -nessy ROM spawns nessy in the
background, dials its DAP listener, and opens the TUI in attach mode
paused at the reset vector:
chippy -nessy roms/demos/hello-bg/hello-bg.nesPress r to run, s to step, b to toggle a breakpoint at the
current PC, q to quit (also shuts down the nessy game window).
If the chippy binary can't find nessy on $PATH or as a sibling,
pass -nessy-binary PATH. Build a local nessy with go build -tags=nessy -o nessy ./cmd/nessy.
Homemade demos ship under roms/demos/ β hand-rolled
ca65 sources + checked-in .nes artifacts. Each doubles as a framebuffer-hash
or audio-presence regression test under cmd/nessy/demo_*_test.go:
| Demo | What it tests | Source |
|---|---|---|
hello-bg |
PPU bg renderer + palette + nametable + reset path | Static "HELLO NESSY" title screen |
input-echo |
$4016 joypad strobe + serial shift + per-frame VRAM writes | 8 indicator boxes light up under live joypad input |
vblank-bounce |
PPU NMI line + CPU NMI service + JMP self idle |
Single tile bounces inside the playfield |
triangle-arpeggio |
APU triangle channel + NMI-driven note rotation | A-major arpeggio (audio only) |
noise-drum |
APU noise channel + LFSR feedback path | Low/high noise drum hit (audio only) |
all-channels |
Non-linear DAC mixer under multi-channel load | Pulse 1+2 + triangle + noise chord (audio only) |
dmc-sample |
DMC channel DMA fetch + delta-PCM + loop bit | 65-byte alternating-bit sample looped (audio only) |
mmc1-banks |
MMC1 serial-shift PRG bank switching (prgMode 3) | Background flashes between two colours twice per second |
oam-grid |
$4014 OAMDMA + 64-sprite OAM walk + sprite priority | 8Γ8 grid of solid squares centred on the playfield |
state-counter |
Save-state round-trip probe (frame_cnt β $3F00) | BG colour cycles through the palette as frame_cnt advances |
vrc6-chord |
VRC6 cart + 3-channel audio expansion (mapper 24) | Sustained low chord (audio only) |
sunsoft5b-chord |
FME-7 cart + Sunsoft 5B audio (mapper 69) | Three-tone chord via YM2149 clone (audio only) |
scroll-split |
Mid-frame horizontal scroll split (per-scanline $2005) |
Vertical stripes offset between top + bottom halves |
mmc3-split |
MMC3 scanline-IRQ split bar (A12-counted) | Top half blue, bottom half green |
Run any of them:
./nessy roms/demos/hello-bg/hello-bg.nes
./nessy roms/demos/input-echo/input-echo.nes
./nessy roms/demos/vblank-bounce/vblank-bounce.nesRebuild from ca65 source (requires brew install cc65 / apt-get install cc65):
make -C roms/demos allcmd/nessy-record captures a ROM run as a GIF or MP4 (video + audio +
scripted input) with no window, no OpenGL, no screen grab β it
synthesizes the recording straight from the emulator, so it's
deterministic and CI-friendly.
go build -o nessy-record ./cmd/nessy-record
# GIF (stdlib only, video):
./nessy-record -rom roms/demos/vblank-bounce/vblank-bounce.nes -frames 120 -o out.gif
# MP4 with audio (needs ffmpeg):
./nessy-record -rom roms/demos/all-channels/all-channels.nes -frames 120 -o out.mp4
# Scripted joypad input (JSON keyframe timeline):
echo '{"20":["Up"],"40":["Up","A"],"60":[]}' > in.json
./nessy-record -rom game.nes -script in.json -frames 90 -o out.gifThe CI smoke job renders demo GIFs + an audio MP4 on every PR and
embeds them in a sticky comment. make -C test/smoke nessy-record
reproduces those locally.
# Built-in dummy program (no flags)
./chippy
# Load a raw binary at $8000 (the default load address)
./chippy -rom program.bin
# Load and let chippy invoke ld65 for you
./chippy -rom program.o -cfg linker.cfg
# Try the bundled examples (see example/README.md for descriptions)
cd example
make # builds every demo .bin + .dbg
../chippy -rom load_five.bin
../chippy -rom fibonacci.bin # or count_to_ten / stack_demo / bcd_addOnce it's running, press ? for an in-app help modal, or : to open the
command line.
chippy auto-detects format by extension:
| Extension | Format |
|---|---|
.bin |
Raw bytes β placed at -addr (default $8000) |
.prg |
Commodore-style: first 2 bytes = LE load address |
.hex |
Intel HEX (record types 00 data, 01 EOF) |
.o |
ca65/cc65 object β linked via ld65 (requires -cfg) |
Flags:
| Flag | Default | Meaning |
|---|---|---|
-rom |
β | Path to program (.bin, .prg, .hex, .o) |
-addr |
32768 |
Load address for raw .bin (ignored for other types) |
-cfg |
β | ld65 linker config (required for .o) |
-dbg |
auto | cc65 .dbg symbol file; <rom>.dbg is tried by default |
-reset |
0 |
Override reset vector. 0 keeps the existing $FFFC/D bytes (or falls back to the load address if those are zero) |
--cpu |
nmos |
CPU variant: nmos (MOS 6502), 65c02 (WDC/Rockwell CMOS), or nes (Ricoh 2A03 β NMOS minus decimal-mode arithmetic) |
-trace |
β | Write per-instruction execution trace to this file. Also toggleable at runtime via :trace PATH | :trace on | :trace off. |
-run-on-start |
false |
Start the CPU running instead of paused. Pair with -trace for non-interactive capture (chippy -rom prog.bin -trace t.log -run-on-start). |
-dap |
β | Run as a Debug Adapter Protocol server instead of the TUI. Accepts stdio (editor pipes stdin/stdout) or tcp:PORT (server listens, editor connects out). See docs/dap.md for the request list, supported launch arguments, and VS Code / nvim-dap onboarding. |
-dap-attach |
β | Connect out to a remote DAP server (tcp:HOST:PORT). Phase A: drives the initialize + attach handshake, prints capabilities + the first events, then disconnects. The TUI does not run yet β Phase B/C (CPUSource interface + DAP-backed source) wires the remote into the live TUI in a follow-up PR. Mutually exclusive with -rom and -dap. |
-text-buf-cap |
65536 |
TextOutput ($F001) buffer cap in bytes. Older bytes are evicted when full. 0 disables the bound. Dump the live buffer with :textsave PATH. |
-theme |
default |
Color palette: default / mono / protan (red-green safe) / tritan (blue-yellow safe). NO_COLOR=1 env forces mono regardless. Switch at runtime with :theme NAME; the choice persists across launches. |
-trace-replay |
β | Path to a prior .trace file. Opens the TUI in replay mode β s and < scroll through recorded frames instead of running the live CPU. The CPU register state is synced from the active frame so every panel renders as if paused at that PC. |
Examples:
./chippy -rom program.bin -addr 0x8000 -reset 0x8000
./chippy -rom program.prg # load addr from header
./chippy -rom program.hex # load addr from records
./chippy -rom program.o -cfg nes.cfg # ld65 invoked for you
./chippy -rom program.bin -dbg /tmp/x.dbg # explicit debug fileRecommended path β assemble + link yourself, then load the .bin. The
sibling .dbg is picked up automatically:
ca65 -g prog.s -o prog.o
ld65 -C linker.cfg -o prog.bin --dbgfile prog.dbg prog.o
./chippy -rom prog.binOr hand chippy the .o and let it run ld65:
./chippy -rom prog.o -cfg linker.cfgIn this mode the .dbg is generated in a temp directory and loaded
automatically.
When symbols are loaded:
- Disassembly shows names instead of raw addresses (
JSR initrather thanJSR $8042) - Labels are printed inline above their target instruction
:bp main,:goto main,:watch scoreetc. accept symbol names- Source-line breakpoints (
:bp main.s:42) work β see below - Source view (
v) shows your.sfile with the current line highlighted
| Key | Action |
|---|---|
s |
Single-step one instruction |
S |
Step 16 instructions (stops on bp / mem watch) |
n |
Step over (skips JSR by setting one-shot bp at return) |
f |
Run to next source line (uses .dbg info) |
r |
Toggle run / pause |
R |
Hard reset CPU |
+ = |
Increase target speed |
- _ |
Decrease target speed |
0 |
Speed: max (no throttle) |
| Key | Action |
|---|---|
b |
Toggle plain breakpoint at current PC |
B |
Open breakpoint manager modal |
In the BP manager modal: j/k move cursor, e toggle enable, d (or x,
Delete) delete, enter jump PC to bp, esc/q/B close.
| Key | Action |
|---|---|
v |
Toggle source view β disassembly view |
[ ] |
Scroll disasm by 1 |
{ } |
Scroll disasm by 8 |
' |
Re-anchor disasm to follow PC |
j k |
Scroll memory view by $10 (also β/β) |
J K |
Scroll memory view by $100 (also PgDn/PgUp) |
g G |
Memory view to $0000 / $FF00 |
| Key | Action |
|---|---|
: |
Open command line |
? |
Help modal (q to dismiss) |
q |
Quit (saves state) |
Type : to open the command line, then any of:
| Command | Effect |
|---|---|
:goto $XXXX / :g $XXXX |
Jump memory view to address (or symbol) |
:pc $XXXX |
Force PC to address |
:run $XXXX |
Set one-shot bp at address and start running |
:speed N |
Throttle to N Hz (0 = unthrottled) |
:bp accepts an address, symbol, or file.s:line source location, plus
optional modifiers:
| Form | Effect |
|---|---|
:bp $8042 |
Toggle plain bp at address |
:bp main |
Toggle bp at symbol main |
:bp main.s:14 |
Toggle bp at source line (needs .dbg) |
:bp $8042 once |
One-shot bp (deletes itself on hit) |
:bp loop hits 5 |
Break on the 5th hit |
:bp main if A==$FF |
Conditional (πΆ) β see expression syntax |
:bp $8000 log A={A} PC={PC} |
Log point (π) β prints, never pauses |
Modifiers can be combined: :bp main.s:42 if A==$FF hits 3 log A={A} X={X}.
If a file.s:line reference can't be resolved (missing .dbg, file not in
debug info, no instruction emitted on that line), the bp is created in the
rejected state (π©) so you see it in the BP manager rather than just a
status flash.
| Sigil | Meaning |
|---|---|
| π | Plain breakpoint |
| πΆ | Conditional breakpoint |
| π | Log point (prints, never pauses) |
| π© | Rejected (unresolved source line) |
| π | Current PC |
Trigger when the CPU reads or writes a tracked address. Same modifier
syntax as :bp (once, hits N, if EXPR, log MSG).
| Command | Effect |
|---|---|
:bpr $0200 |
Break on any read of $0200 |
:bpw ram_flag |
Break on write to symbol |
:bprw $FFFC |
Break on either read or write |
:bpw $0200 if A==$FF |
Conditional |
:bpw score log score={[$0200]} PC={PC} |
Log point β never pauses |
:rmbpr $0200 / :rmbpw β¦ / :rmbprw β¦ |
Remove |
Watched bytes are colored in the memory hex view:
| Color | Meaning |
|---|---|
| Blue | π read watch |
| Red | β write watch |
| Magenta | π read + write |
The watch panel shows live values of registers and memory cells.
| Command | Effect |
|---|---|
:watch $0200 |
Watch byte at $0200 |
:watch $0200 word |
Watch 16-bit LE word |
:watch score |
Watch by symbol |
:watch $0200 byte player x |
Watch with custom label |
:watch reg A |
Watch CPU register |
:watch reg A accumulator |
Register watch with label |
:rmwatch $0200 / :rmwatch reg A |
Remove a single watch |
:clearwatch |
Remove all watches |
Aliases: :w for :watch, :unwatch for :rmwatch.
| Command | Effect |
|---|---|
:help / :? |
Open help modal |
:q / :quit |
(Hint to use q outside the prompt) |
Used by :bp ... if EXPR and :bpw ... if EXPR for conditions, and inside
{...} substitutions in log MSG templates.
- Registers:
A,X,Y,P,SP(Sis an alias),PC - Flags:
N,V,B,D,I,Z,Cβ evaluate to0or1 - Numeric literals:
$FF/0xFF/255/0b1010 - Symbols: any name in the loaded
.dbg(resolves to its address) - Memory deref:
[$XXXX]β the byte at that address (works with symbols too:[score])
== != < <= > >= && || ! + - * / % & | ^ << >> ( )
A == $FF
X > 0 && Y < 10
[score] >= $64
P & $80 != 0
(A + X) == $42
{EXPR} substitutes the value of EXPR (formatted as $XX for bytes, $XXXX
for words). Anything else is literal text.
:bp main log entered main, A={A} X={X} PC={PC}
:bpw score log score={[score]} cycle={cycles}
{cycles} is recognized as a special token that prints the CPU cycle count.
Per-ROM state is saved to ~/.chippy/state-<basename>.json and reloaded on
next launch. Persisted:
- Plain + rich breakpoints (with conditions, hit limits, log messages, source tags)
- Memory watchpoints
- Memory view position
- Watch panel entries
- Throttle speed
- Disasm scroll anchor
Conditions are recompiled at load time; if a previously-good condition
becomes invalid (e.g. you removed the symbol it referenced), the bp loads
with condFn nil and effectively becomes a plain bp.
β Registers βββ β Disassembly βββββββββββββββββ
β A:00 X:00 Y β β > $8000 LDA #$00 β
β SP:FD PC:80 β β $8002 TAX β
βββββββββββββββ β ... β
β Flags βββββββ βββββββββββββββββββββββββββββββ
β n v U b d I β β Memory ββββββββββββββββββββββ
βββββββββββββββ β $0000: 00 00 ... ........ β
β Stack βββββββ βββββββββββββββββββββββββββββββ
β $01FE: 00 β β Watches βββββββββββββββββββββ
βββββββββββββββ β score $0200 $00 β
βββββββββββββββββββββββββββββββ
status: ready
The disassembly panel is replaced by a source view when you press v (if
a .dbg is loaded). A breakpoint manager modal opens with B; the help
modal opens with ?.
The stack panel detects JSR-pushed return-address pairs and renders them as
ret $XXXX callee file:NN rows, collapsing adjacent non-frame bytes into
single (N bytes) lines so the call chain stays readable. Press T to
toggle back to the raw one-byte-per-row layout.
The memory panel has a byte-level cursor (arrow keys move Β±1 / Β±$10, view
auto-scrolls). Press e at the cursor to poke a byte: type 1β2 hex digits,
Enter commits, Esc cancels. Edits are runtime-only β R (reset) reloads
the original program bytes.
The command prompt (:) remembers up to 100 entries in ~/.chippy/history.
Up / Down walk recent commands; Tab completes the verb (when there's no
space yet) or the symbol after :bp / :goto / :watch etc.; Ctrl-R
opens a reverse-incremental search through history β each keystroke
narrows the match, Ctrl-R again walks to the next older one, Esc restores
the original line, Enter accepts.
Press < to rewind one instruction. Each explicit step (s, S, n,
f) records a full CPU + RAM snapshot beforehand, kept in a 256-entry
FIFO ring; the status bar shows rwd:N while non-empty. Free-run via
r does NOT snapshot β the 64 KiB-per-step cost would dominate at multi-MHz
throughput β so reverse-step covers single-stepping sessions, not whole
program executions.
Implements all official NMOS 6502 opcodes, including packed-BCD ADC/SBC
when the D flag is set (NMOS semantics β the binary path drives N/V/Z and
the decimal path drives C and A).
Verified against Klaus Dormann's 6502 functional test suite (the de-facto correctness gold standard for 6502 emulators) β passes the full ~30M-instruction sweep. Run locally with:
go test -tags=klaus -timeout 5m -run TestKlaus ./internal/cpu/...The ROM is GPL-3.0 so it is downloaded on demand into the user cache dir
on first run rather than vendored. Set CHIPPY_KLAUS_BIN=/path/to/rom to
use a local copy.
Stable undocumented ("illegal") opcodes are also implemented:
LAX, SAX, DCP, ISC, SLO, RLA, SRE, RRA, ANC, ALR, ARR,
SBX, the SBC alias at $EB, and the family of multi-byte/multi-cycle
NOPs that real silicon decodes at undocumented slots. RRA and ISC honor
decimal mode. The unstable opcodes (AHX/SHA, SHY, SHX, TAS, LAS, XAA, KIL/JAM)
remain decoded as 1-byte NOPs since their behavior depends on bus capacitance
or halts the CPU.
go test ./...The TUI package has headless tests for the memory watchpoint data plane
(internal/tui/membp_test.go) that exercise WBus + processMemHits
without the bubble-tea runtime.
End-to-end TUI behavior is recorded by VHS
tapes under test/smoke/. Each .tape drives chippy through a real
TTY and renders a .gif; reviewers scrub the artifacts on PRs and CI
checks that the render didn't crash.
make smoke # render chippy tapes only
make smoke-all # render every tape, incl. nessy-attach
make smoke-clean # remove rendered outputRequires vhs, ttyd, and ffmpeg on $PATH. Output lands in
test/smoke/out/ (gitignored).
cmd/chippy/ CLI entrypoint, flag parsing, loader/wiring
internal/cpu/ 6502 core: CPU, RAM, opcodes, disassembler
internal/loader/ .bin/.prg/.hex/.o loaders
internal/symbols/ cc65 .dbg parser, symbol + source-line tables
internal/tui/ Bubble Tea model, panels, modals, commands
example/ Bundled ca65 demo programs (source + shared linker cfg + Makefile)
example/c/ Same idea, but the source is C β cc65 β ca65 β ld65 pipeline
test/smoke/ VHS tape scripts + Makefile for TUI smoke tests
If chippy is useful to you, consider buying me a coffee:

