The big architecture release that doesn’t ship the architecture

v1.5 is the largest release in chippy’s post-1.0 history by commit count, by changed-line count, and by length of the post you’re about to read. The thesis is unambiguous: derisk the v2.0 architecture, fuzz the CPU against every standard test corpus, and expose opt-in hooks that let nessy build a Mesen-class debugger on top of chippy without forking it.

The v2.0 architecture is “the TUI is just another DAP client; every panel reads through the protocol; local mode is just a different transport.” That’s been the direction since v1.1’s Source abstraction. v1.5 doesn’t ship v2.0. v1.5 ships the load-bearing pieces of v2.0 and proves them on one panel — the Registers panel — so the v2.0 migration is mechanical instead of speculative.

Three big threads. I’ll do them in the order they made sense:

  1. The DAP onramp: an in-process zero-marshal transport, a unix-socket transport, the Registers panel migrated to render via DAP even in local mode, and a chippy-state custom event that pushes live state at 60 Hz.
  2. Complete CPU ROM coverage: every standard 6502 corpus wired into CI (AllSuiteA, Wolfgang Lorenz, the Klaus interrupt test, the Tom Harte ProcessorTests), and a Visual6502 goal reframed into a Tom Harte bus-trace check that catches a real JSR bug.
  3. Host debug hooks: four hooks (CustomRequestHandler, cpu.SetAccessHook, RAM.Freeze, expr.HostVarResolver
    • Server.SetStopPredicate) that let nessy expose NES-specific debug semantics through standard DAP without forking chippy’s protocol.

Stay with me. There’s a JSR bug story in here that I’ve been waiting four releases to tell.

Thread 1 — The DAP onramp

Why this matters

Through v1.4, chippy’s local TUI ran on one path and the remote TUI ran on another. Local mode read CPU fields directly (m.cpu.A, m.cpu.X, m.cpu.RAM.Data[...]). Remote mode talked to a DAP server over TCP and kept shadow copies of the CPU and RAM in the TUI mirror. They were two code paths that happened to render the same panels, but every panel update had a branch: “local? read fields. Remote? translate to DAP.”

This is the kind of duplication that’s fine at small scale and intolerable at large scale. Two code paths means two places to keep correct on every refactor. Two failure modes per panel. Two places where someone might helpfully add a feature that works in local but not remote, or vice versa. The v2.0 direction is to collapse the two: the TUI is a DAP client, period; local mode means “the DAP server happens to be in the same process.”

The two questions to answer before going there:

  1. Is the in-process DAP path fast enough? A panel that polls 60 times a second and does a JSON-RPC round-trip for every poll is a non-starter. The wire path takes single-digit-to-tens of microseconds per round-trip; even “single-digit” times 60 panels times 60 Hz is a substantial overhead for zero wire benefit in the local case.
  2. Does it actually work on a real panel? The way to validate (1) is to migrate a panel and measure.

v1.5 answers both with the same change.

The transports

Server was already io.Reader/io.Writer-shaped (a v1.1 decision). So adding a unix-socket transport was mechanical: a -dap unix:PATH flag, a net.UnixListener, the same Server wired to it. The transport was already abstract; this was just a different net.Conn.

The interesting transport is the in-process one. Both ends of the connection live in the same Go process; no file descriptor; no socket; nothing the kernel touches. The trick: every server send funnels through one function (writeJSON), which consults an optional sink func(any). When sink is set — meaning we’re in inproc mode — the function passes the already-constructed response or event struct to sink directly, skipping the JSON marshal entirely. The struct is unencoded. The client gets a Go struct on the other side, no allocations for the wire envelope, no json.Marshal/json.Unmarshal round-trip.

// NewInprocServer wires a Server such that responses/events go directly to a
// matching InprocClient as Go values — no JSON marshaling round-trip.
func NewInprocServer(opts AttachConfig) (*Server, *InprocClient) {
    client := &InprocClient{...}
    server := &Server{
        cfg:  opts,
        sink: func(msg any) { client.deliver(msg) },
    }
    return server, client
}

The client mirror is the same shape — a Request call that, instead of writing JSON to a Writer, dispatches directly into the server’s switch and gets the response struct back as a Go value. Nil-args requests + all responses round-trip with zero serialization.

The numbers, measured against an identical stepIn request:

Transport per-request relative
inproc 0.34 µs
unix socket ~30 µs ~90× slower
TCP localhost ~70 µs ~200× slower

Inproc is so close to “free” that a panel reading through it 60 times a second adds noise-level overhead. That answers question (1).

The Registers panel

To answer question (2) I picked the cheapest panel in the TUI — Registers — and reworked it to render via DAP regardless of local/remote mode.

The Registers panel renders six things: A, X, Y, SP, PC, and a decoded P flag string. Up through v1.4, the panel read those directly from m.cpu.A, m.cpu.X, etc. In v1.5, the panel reads them from m.Regs, a RegSnapshot struct populated by Source.Registers(), which is a single DAP variables round-trip against the “Registers” scope.

In remote mode the variables request goes over the wire. In local mode it goes through the inproc transport. Either way, the panel reads from m.Regs, not from m.cpu. The panel doesn’t know which transport it’s using and doesn’t care.

The implementation has one ergonomic shim: LocalSource now owns an in-process DAP server attached to the same CPU. That server is invisible from outside the TUI; nothing external dials it. The TUI’s local code path constructs a Source that wires to it directly. Local mode looks like remote mode from the panel’s perspective.

The migration pattern is written up at docs/dap-tui-migration.md — I made the document because the rest of the panels (stack, flags, memory, disassembly) are deferred to v2.0 but will follow the same shape, and I wanted the recipe captured before I forgot it.

chippy-state: live state at 60 Hz

The remaining issue with “read through DAP at 60 Hz” is that 60 polls a second is a lot of round-trips for state that the server already knows is changing. The DAP spec has an answer for this — custom events. The server emits an event when something happens; the client subscribes; no polling.

The chippy-state event is a server→client custom event the server emits during a remote free-run. It carries the current register file plus a reserved dirtyRanges field (which is empty in v1.5; populating it is the v1.6 follow-up). The event is throttled to 60 Hz in the run loop. The TUI subscribes to it via the DAP client’s event channel and updates m.Regs directly from the event, skipping the polling path entirely while the remote is running.

The Mesen/DAP-extension convention is that custom events use a namespaced name (chippy-state here), and standard clients that don’t recognize the namespace silently ignore the event. So adding the event is purely additive — standard editors keep working, chippy’s TUI gains live state — and there’s no protocol break.

The wider lesson here: when you have a 60-Hz polling loop talking to a server that knows state is changing, the right move is almost always to flip the direction — let the server push, let the client subscribe. The costs are bounded (one event type, one rate-limit, one client subscription) and the savings compound.

What v1.5 ships, what v2.0 ships

v1.5 ships:

  • An in-process transport that is ~90× faster than unix, ~200× faster than TCP.
  • A unix-socket transport for non-local out-of-process attach (lower-overhead than TCP loopback).
  • The Registers panel reading from Source.Registers() over DAP, in both local and remote mode.
  • The chippy-state event for live state during remote free-runs.
  • A documented migration pattern at docs/dap-tui-migration.md.

v2.0 will ship:

  • The stack, flags, memory, and disassembly panels migrated to the same pattern.
  • The TUI’s local mode dropping its direct-CPU-access paths entirely.
  • A single rendering codebase, indistinguishable behavior in local and remote.

The reason for the split: each panel migration is a few hours’ work, low risk. The architectural decision — “is this the right shape” — is the load-bearing call. If the inproc transport had been too slow, or the Registers panel had developed weird edge cases under DAP, v2.0 would have been worth rethinking. None of those things happened. v1.5 proves the path; v2.0 is mechanical follow-through.

Thread 2 — Complete CPU ROM coverage

This is the thread where I wrote less code and learned the most. Through v1.4 chippy’s correctness story was the Klaus functional test, Klaus’s 65C02 test, and Bruce Clark’s exhaustive BCD sweep. That’s a pretty good bar — Klaus alone covers a substantial fraction of the addressing-mode × opcode matrix — but it’s not complete. The 6502 corpora that exist are bigger than that, and v1.5’s accuracy epic (#402) was the work of wiring all of them into CI.

The four corpora

The four I picked, in increasing order of test count:

Corpus Tests What it covers
AllSuiteA ~1 ROM, ~50 sub-checks Compact 6502 smoke ROM, fast
Wolfgang Lorenz ~20 sub-tests C64 CPU subset (BCD, illegal opcodes, register-loads, branches)
Klaus interrupt ~6 phases Interrupt timing + IRQ vector handling
Tom Harte ProcessorTests 256 opcodes × ~10000 cases ≈ 2.6M Per-opcode state + cycle count

Each one is wired the same way: a CI job with the test ROM either sha-pinned + downloaded into a user cache (D11 from the v1.0 ADR: GPL-licensed ROMs are never vendored) or — in the case of test inputs that ship as upstream source rather than ROM — ported into a vendorable form via a ca65 port checked into the repo.

The Klaus interrupt test is the ca65 port case. Klaus’s original interrupt test is as65 source, not a binary, and as65 is a different assembler from ca65. So I ported the source to ca65 syntax (one of the assemblers chippy already supports via loader), set up an active-low/active-high feedback-port harness inside the test runner, and checked the ported source into chippy’s test/ tree. It runs in CI on every push.

The Tom Harte ProcessorTests are the headline. They’re a corpus of ~10000 test cases per opcode — 256 opcodes × ~10000 ≈ 2.6 million cases — each one a tuple of (initial CPU + RAM state, expected final CPU + RAM state, expected cycle count). You set up the initial state, run one instruction, compare the final state. If anything’s wrong, the test reports the case ID and the diff.

This corpus is massive enough to be its own CI job (~ a minute and a half to run all of it, parallelized 8 ways). But the value is exactly proportional to that size: it caught a real bug.

The JSR $20 stack/operand-overlap bug

JSR (Jump to Subroutine, opcode $20) is one of the most-executed instructions on a 6502. It pushes the return address — actually, it pushes PC - 1, with the high byte first and the low byte second — onto the stack, then loads PC with the target address from the operand. The target address is two bytes: a low byte at PC+1 and a high byte at PC+2.

On a real 6502, JSR has a specific cycle sequence that includes a peculiar order:

  1. Fetch opcode.
  2. Fetch low byte of operand.
  3. Internal operation (the silicon needs a cycle to do nothing visible).
  4. Push high byte of return address.
  5. Push low byte of return address.
  6. Fetch high byte of operand, then jump.

The cycle in step (3) is the famous “JSR is 6 cycles, not 5” cycle. And steps (4)–(5)–(6) are the load-bearing quirk: the high byte of the target is fetched after the return address has been pushed to the stack.

Why does this matter? It matters because there’s an evil ROM that puts the JSR’s high-byte operand at $0100 + SP - 1 (i.e., at the stack address that the JSR’s own push will overwrite). On a real 6502, the pushes overwrite the operand byte before it’s fetched, and the JSR effectively jumps to a location that the program just constructed on the stack. Chippy, up through v1.4, fetched both operand bytes before the pushes, which meant the JSR jumped to the original target — different behavior from real silicon.

The Tom Harte corpus has this case. I had been blissfully ignoring it for the life of the project, because none of the pre-v1.5 tests exercised it. The Harte run flagged it at case ID 20-7f4-.... About 30 minutes of debugging later, I had the diagnosis: my opJSR was reading both operand bytes at the start, then pushing. The fix was to split the operand read: read the low byte at the start, push the return address, then read the high byte.

The fix is a real CPU bug that had been in chippy since v0.0.1 (the May 11 commit) and would have stayed there forever if I hadn’t wired Tom Harte. Two minutes of test-suite execution. Eight months of code-in-the-wild. This is the single most valuable five lines of code I wrote in v1.5.

Visual6502 → Tom Harte bus traces

A long-standing item on my chippy roadmap was a “compare per-cycle bus activity against Visual6502” check. Visual6502 (the website that simulates the 6502 at the transistor level) lets you set up a state and step through the cycles, capturing every bus access. The plan was to set up a corpus of “interesting” test sequences, run them on Visual6502 by hand, capture the bus traces, and compare against chippy’s per-cycle bus traces (the ones the per-cycle path from v1.2 emits).

I had been quietly dreading this work because manually sourcing test sequences from Visual6502 is laborious. I also kept revisiting whether the test would be worth its construction cost.

The reframe that landed in v1.5: Tom Harte’s corpus already contains the per-cycle bus traces I wanted. Each test case has a cycles field — an ordered list of (address, value, kind) tuples per CPU cycle. The corpus is already ~100× the coverage I’d have hand-sourced from Visual6502, with no manual sourcing required. The infrastructure to compare chippy’s bus activity against Harte’s cycles is a recording hook on the bus plus a trace differ.

Two implementation notes:

  • The per-cycle bus check runs on VariantNMOS, because the Harte corpus is for NMOS 6502. The per-cycle path from v1.2 is VariantNES-gated. So the bus-trace test runner has to force-enable the per-cycle path on VariantNMOS (the --per-cycle-force flag from the v1.2 post). That flag was already there for exactly this scenario.
  • The recording hook on the bus is the same hook the access-tracking heatmap (Thread 3 below) uses. One hook, two use cases.

The result: chippy passes Tom Harte at 228/238 bus-exact on the first run. Ten opcodes have per-cycle bus differences that look like specific quirks:

  • 8 taken page-crossing branches — the dummy read of the pre-fixup address is at the wrong address.
  • JSR — see the bug above.
  • RTS — a dummy read at the wrong address (pulled+1 instead of pulled).

These ten are skip-listed (harteBusSkip) at v1.5 and tracked as issue #428. They all get fixed in v1.6 (spoiler).

The state-and-cycle-count check — the one that doesn’t depend on per-cycle bus matching — passes 238/238 on the first run after the JSR fix lands. That’s the headline. The bug found by the corpus is the headline behind the headline.

What’s skip-listed

Honesty about what isn’t passing:

  • 12 JAM/KIL opcodes (the ones that lock the CPU on a real chip) — chippy halts the CPU, the corpus’s expected state doesn’t include “halted.” Skip-listed at the corpus level, not a chippy bug.
  • 6 unstable illegal opcodes whose silicon behavior depends on temperature/voltage/lot. Real silicon doesn’t have one correct answer. Skip-listed.
  • 10 per-cycle bus quirks (above). Fixed in v1.6.
  • ARR ($6B) decimal-mode result + flags. There’s a subtle BCD path I didn’t get right. Fixed in v1.6.
  • 65C02 (the CMOS data set) — deferred to v1.6.

Every skip is documented with the reason in harteSkip / harteBusSkip arrays in cpu/harte_test.go. The v1.6 post is partly the story of clearing the v1.5-deferred skips.

Thread 3 — Host debug hooks

This is the thread that lets nessy build NES-aware debugging on top of chippy. Four hooks. The design constraint on all of them: zero cost when unused.

Hook 1 — CustomRequestHandler (recap)

Already shipped in v1.4.0. A DAP request handler the host can register for protocol extensions. The cost when unused is one nil-check at dispatch’s default arm. Already discussed in the v1.4 post.

Hook 2 — cpu.SetAccessHook

func (c *CPU) SetAccessHook(h func(addr uint16, kind AccessKind)) {
    c.accessHook = h
}

type AccessKind int
const (
    AccessRead AccessKind = iota
    AccessWrite
    AccessExec
)

Called for every CPU-driven bus access (read, write, instruction fetch). The host registers it to build a memory heatmap, an access log, or — as it turns out in v1.6 — a dirty-region tracker for streaming changed memory.

The cost when unused: one nil-check per access. The perfgate threshold I committed to in v1.0 is “the bare debugger loop stays in single-digit ns per step.” Adding a nil-check per access is roughly one cycle of branch prediction overhead; the perfgate runs in CI on every change and it stayed green through this hook landing.

The cost when used: whatever your hook does. The hook signature is intentionally minimal — (addr, kind), no value, no PC, no extra context — because a heatmap doesn’t need them. Callers who do need more context can chain hooks (the v1.6 post explains how the server chains in front of a host’s hook for the dirty-range streaming case, restoring on stop).

Hook 3 — RAM.Freeze(addr, value) / Unfreeze(addr)

A debugger feature with one specific use: pin a memory cell to a constant value. The CPU can read it, but its own writes are silently swallowed. nessy uses this for the classic NES debugger trick of “freeze the player’s health at maximum to test late-game content without dying.”

func (r *RAM) Freeze(addr uint16, value byte)
func (r *RAM) Unfreeze(addr uint16)

The implementation is a small map[uint16]byte on RAM. RAM.Write checks the map; if the address is frozen, the write is a no-op and the existing frozen value stays. RAM.Read returns the frozen value when present (so CPU reads also see the frozen value), falling back to the underlying data byte.

The cost when unused: one len(map) == 0 check on Write. Empty maps are fast; the branch predictor will pin this to “skip” within the first cycle.

The cost when used: a map lookup per write to a frozen address. For the dozen-or-so cells a debugger user is realistically freezing, this is noise.

I considered whether to expose Freeze/Unfreeze over DAP — i.e., as a request a remote debugger could send to the server. The decision: no, not yet. Freeze is a host-side debugger feature, not a protocol surface. nessy wires its own UI to call RAM.Freeze directly when the user clicks “freeze cell.” Adding a DAP request for this later is purely additive; not adding it now keeps the surface honest.

Hook 4 — expr.HostVarResolver + Server.SetStopPredicate

These two hooks together let the host inject identifiers into the chippy expr language and inject step granularity into the server’s run loop.

The motivation: nessy wants to write a breakpoint condition like scanline == 30. scanline is not a 6502 register; it’s nessy’s PPU state. Through v1.4, the only identifiers expr knew about were the registers (A, X, Y, etc.), the flags (Z, C, V, N), and memory dereferences ([$F004]). nessy could expose $2000 through MMIO and force the user to write [$2000], but that gives them the register value, not the scanline — the scanline is internal to nessy’s PPU implementation, not addressable.

The fix:

// HostVarResolver: given an identifier, look up its current value.
// Returns ok=false if the identifier isn't a host variable.
type HostVarResolver func(name string) (value int, ok bool)

func Compile(src string, syms *symbols.Table, host HostVarResolver) (*Expr, error)

When the parser sees an identifier it doesn’t recognize (not a register, not a flag, not a symbol from the .dbg), it falls back to the host resolver. nessy registers a resolver that knows about scanline, dot, frame, ppu_v, ppu_t, and so on. From the user’s perspective, :bp $C123 if scanline == 30 just works.

The companion hook on the server side is the stop predicate:

func (s *Server) SetStopPredicate(p func() bool)

The server’s run loop calls the predicate at instruction boundaries. If it returns true, the server pauses (and emits a stopped event). nessy uses this to implement step granularities that don’t map to a single instruction: “run to next NMI,” “step-scanline” (run until the scanline counter advances), “step-frame.” All of these are loops over step that check a condition; the predicate is the negation of the condition the host wants to step to.

The cost when unused: one nil-check per instruction boundary in the run loop. Same perfgate as the access hook.

What this composes to

With these four hooks in place, nessy can — and will — build a Mesen-class NES debugger entirely as additive code on top of chippy:

  • CustomRequestHandler exposes a nessy/debugState channel for PPU + OAM + mapper inspection.
  • SetAccessHook builds the memory heatmap and the per-byte access log.
  • RAM.Freeze powers the freeze-cell debugger feature.
  • HostVarResolver + SetStopPredicate make scanline == 30-shaped breakpoints work and step-scanline-shaped steps work.

None of these require nessy to fork chippy’s DAP server. None of these require chippy to know anything about NES semantics. The hooks are nil-by-default. The perfgate is green. The chippy library stays general-purpose.

The pattern I want to call out: a downstream consumer’s “we need this protocol to know about our semantics” problem is usually a “give them four small hooks that compose” problem, not a “fork the protocol” problem. The hook-shaped solution is more work to design than a fork-shaped one — you have to find the smallest set of seams — but it’s enormously less work to maintain.

What I’m doing differently after v1.5

The biggest shift in how I think about future chippy work, after writing this release, is that the v2.0 architecture is now a low-risk migration. v1.5’s onramp work proved the inproc transport, the DAP-through-protocol panel pattern, and the chippy-state event. None of them were the showstopper I had been half-expecting. The remaining work to ship v2.0 is “migrate the remaining four panels using the documented pattern” — bounded, mechanical, well-understood.

The other thing v1.5 shifted: I now believe much more in “wire every standard corpus, even the big ones.” The Tom Harte run finds a real bug. The Wolfgang Lorenz suite confirmed BCD behavior I had been fairly sure of. The Klaus interrupt port closed an entire category of interrupt-timing case I’d been hand-waving. The cost of wiring each corpus is bounded — a few hours of CI plumbing per corpus — and the catch rate is high enough to be worth it. If chippy ever ships a 65816 variant (the 16-bit big brother of the 6502; the Apple IIgs CPU), the first thing I’ll do is find the equivalent corpora.

v1.6 preview

v1.6 is the cleanup-pass release I drafted in the v1.5 epic’s wake. The accuracy gaps from v1.5 close:

  • ARR ($6B) decimal-mode result + flags.
  • 8 taken page-crossing branches + JSR + RTS per-cycle bus quirks (the harteBusSkip items).
  • The Tom Harte 65C02 (CMOS variant) data set.

The 65C02 set shakes out five real CMOS accuracy bugs, all of which are mine. One of them is the ADC-decimal V flag (it has to be computed pre-correction). One is the cycle counts for ASL/ROL/LSR/ROR abs,X — they’re 6+1 on-cross, not flat 7. Three more. I’ll save them for next week.

v1.6 also ships the manual struct-overlay watch from v1.3 (the cc65 .dbg-doesn’t-carry-struct-layout finding finally pays out), the dirty-range streaming the chippy-state event was waiting on, and a goreleaser brews:homebrew_casks: migration that future-proofs the release config ahead of a goreleaser bump.

After v1.6, the nessy post.