Skip to content

nkane/chippy

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

217 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

chippy

ci codecov release license

A TUI 6502 emulator and debugger, written in Go.

chippy onboarding screencast

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.


Why chippy

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 .dbg files turn .bin addresses into file:line and 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 peripheral package 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.

Install

Homebrew (macOS / Linux)

brew tap nkane/tap
brew install chippy

Debian / Ubuntu

curl -LO https://github.com/nkane/chippy/releases/latest/download/chippy_<VERSION>_linux_amd64.deb
sudo dpkg -i chippy_<VERSION>_linux_amd64.deb

Fedora / RHEL

sudo rpm -i https://github.com/nkane/chippy/releases/latest/download/chippy_<VERSION>_linux_amd64.rpm

Alpine

sudo apk add --allow-untrusted chippy_<VERSION>_linux_amd64.apk

Arch Linux (AUR)

yay -S chippy-bin
# or any other AUR helper

Prebuilt tarballs

Grab 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.

From source

go install github.com/nkane/chippy/cmd/chippy@latest

Or 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.

nessy (NES emulator, experimental)

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/nessy

v0.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).

One-shell debug launch

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.nes

Press 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.

Demos

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.nes

Rebuild from ca65 source (requires brew install cc65 / apt-get install cc65):

make -C roms/demos all

Headless recording

cmd/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.gif

The 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.


Quick start

# 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_add

Once it's running, press ? for an in-app help modal, or : to open the command line.


Loading programs

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 file

ca65 / cc65 workflow

Recommended 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.bin

Or hand chippy the .o and let it run ld65:

./chippy -rom prog.o -cfg linker.cfg

In 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 init rather than JSR $8042)
  • Labels are printed inline above their target instruction
  • :bp main, :goto main, :watch score etc. accept symbol names
  • Source-line breakpoints (:bp main.s:42) work β€” see below
  • Source view (v) shows your .s file with the current line highlighted

Keybinds

Execution

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)

Breakpoints

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.

Views

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

Other

Key Action
: Open command line
? Help modal (q to dismiss)
q Quit (saves state)

Commands

Type : to open the command line, then any of:

Navigation

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)

Breakpoints

: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.

Sigils (gutter glyphs)

Sigil Meaning
πŸ›‘ Plain breakpoint
πŸ”Ά Conditional breakpoint
πŸ“œ Log point (prints, never pauses)
πŸ’© Rejected (unresolved source line)
πŸ‘‰ Current PC

Memory watchpoints

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

Watches

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.

Misc

Command Effect
:help / :? Open help modal
:q / :quit (Hint to use q outside the prompt)

Expression syntax (conditions and log templates)

Used by :bp ... if EXPR and :bpw ... if EXPR for conditions, and inside {...} substitutions in log MSG templates.

Operands

  • Registers: A, X, Y, P, SP (S is an alias), PC
  • Flags: N, V, B, D, I, Z, C β€” evaluate to 0 or 1
  • 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])

Operators

== != < <= > >= && || ! + - * / % & | ^ << >> ( )

Examples

A == $FF
X > 0 && Y < 10
[score] >= $64
P & $80 != 0
(A + X) == $42

Log point template

{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.


Persistence

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.


Layout

β”Œ 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.


Status

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.


Tests

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.

TUI smoke tests (VHS)

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 output

Requires vhs, ttyd, and ffmpeg on $PATH. Output lands in test/smoke/out/ (gitignored).


Project layout

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

Support

If chippy is useful to you, consider buying me a coffee:

Buy Me A Coffee

About

Go-based 6502 emulator with a Bubble Tea TUI debugger and ca65/cc65 toolchain support

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors