The release where the open tabs finally close

v1.5 was the architecture and accuracy release. The DAP onramp landed, the four host hooks shipped, and the Tom Harte ProcessorTests went from “not wired” to passing 238/238 on state and 228/238 on per-cycle bus traces. The v1.5 release notes ended with a harteSkip array of opcodes I knew were wrong and a harteBusSkip array of opcodes whose per-cycle bus traces didn’t match silicon. The plan was always to close them in the next release.

v1.6 is that release. It’s the close-the-open-tabs cut: clear the v1.5 deferrals, ship the manual struct-overlay watch that the cc65 .dbg finding from v1.3 made the only honest path, populate the dirtyRanges field that v1.5’s chippy-state event left empty, and migrate goreleaser brews: to homebrew_casks: before goreleaser’s deprecation forces the issue.

Seven decisions land in this release. I’ll do them in priority order — accuracy first, because the receipts are real, and the rest after.

1. ARR ($6B) decimal mode

ARR is one of the “undocumented” 6502 opcodes — opcodes the silicon implements but that aren’t part of the official MOS Technology programmer’s reference. The opcode does roughly A = (A & imm) ROR 1 with some specific flag side effects, and it has a decimal mode variant where the result and flags get a per-nibble BCD fixup.

Chippy had been getting the non-decimal ARR right since v0.0.1. The decimal mode I had skipped, because no one notices a wrong ARR-in-decimal except a test corpus, and chippy hadn’t wired one until v1.5. The Tom Harte corpus did notice. ARR was the single stable opcode left on harteSkip at v1.5.

The 64doc reference has the algorithm. The result side is straightforward: N, Z, V come from the binary rotate of A & imm. Then A and C take a per-nibble BCD fixup. The implementation is twenty lines, the fixup code is somehow worse than the documentation made it sound.

There’s a small but interesting bug I found while writing it: the high-nibble sum can overflow a byte. The fixup adds $60 to the high nibble when the high nibble exceeds a threshold; with the wrong intermediate type (byte instead of int), $F0 + $60 = 256 wraps to 0 and the comparison against the threshold passes when it shouldn’t. The fix is to do the arithmetic in int and convert back at the end. It’s the kind of bug that only shows up in maybe one of ten thousand test cases — and Tom Harte runs ten thousand per opcode.

With the fix: 6b passes 10000/10000. 0x6B comes off harteSkip. The Wolfgang Lorenz arrb probe (the decimal-mode ARR test, which I’d left skip-listed) comes back on the suite. Two skips cleared by one fix.

2. The ten per-cycle bus quirks

The Tom Harte bus-trace harness from v1.5 found ten opcodes whose per-cycle bus activity didn’t match silicon even though the state and cycle count were right. The mismatches fell into three buckets:

Taken page-crossing branches

A taken branch that crosses a page boundary takes 4 cycles instead of 3 — the extra cycle is the time the CPU spends computing the high byte of the target address. During that extra cycle, real silicon issues a dummy read at the pre-fixup address (oldPCH | newPCL — old high byte, new low byte). Chippy had been issuing the dummy at the fixed-up address. Reads with side effects can be sensitive to this; the bus-trace check caught it.

The fix: in the branch path, when the page crosses, read the pre-fixup address before fixing up the high byte. Three-line change. Cleared eight bus-trace skips at once (one per branch opcode: BCC, BCS, BEQ, BMI, BNE, BPL, BVC, BVS).

JSR — the JSRABS addressing mode

JSR was already correct in state and cycle count (the v1.5 fix from the post last week). It was not correct in bus order. The standard Absolute addressing mode fetches both operand bytes up front. JSR doesn’t — it fetches the low byte first, internal-operation, pushes the high byte of the return address, pushes the low byte, then fetches the high byte of the target operand. The high-byte fetch lands last. That order is silicon-load-bearing for one specific edge case I described in the v1.5 post: the case where the JSR’s high-byte operand sits at an address the return-address push will overwrite.

The clean fix is a new addressing mode — JSRABS — used only by JSR. resolve for JSRABS fetches only the low operand byte. opJSR does the push, then reads the high byte. The cycle sequence comes out exactly in silicon order. The bonus: the old NMOS-only “re-read” special case I’d added in v1.2 to handle the v1.5 stack/operand overlap collapses into the standard JSRABS path. There’s no longer a nesCycle branch for it. One opcode, one addressing mode, no special cases.

RTS — the dummy read at the right address

RTS (return from subroutine) does six cycles. After pulling the return address off the stack, the CPU issues a dummy read, then jumps. The question is: dummy read at pulled or pulled + 1? Chippy had pulled + 1. The correct answer per silicon is pulled — the dummy read is at the same address as the last byte just pulled.

Two-line change. Cleared the last bus-trace skip.

Net result

harteBusSkip at v1.5: ten opcodes. harteBusSkip at v1.6: zero. All 238 NMOS 6502 opcodes pass the per-cycle bus-trace check against the Tom Harte corpus.

The 65C02 (CMOS) bus trace is deferred to v1.7 — same harness, different data set. The deferral is calendar, not architectural; the test runner already accepts a CMOS data set, the work is one more CI job and the per-cycle quirks the CMOS variant has that NMOS doesn’t.

3. Tom Harte 65C02 — five CMOS bugs come out of hiding

The Tom Harte ProcessorTests corpus has two flavors. The 6502 set (NMOS) was wired in v1.5. The 65C02 set (CMOS, specifically the WDC 65C02 variant) was the deferral. v1.6 wires it.

The harness factors out a harteSuite descriptor: subpath under the corpus, variant ID, per-suite skip list, per-case filter. The NMOS suite is harteSuite{path: "6502/v1", variant: VariantNMOS, ...}. The 65C02 suite is harteSuite{path: "wdc65c02/v1", variant: VariantCMOS65C02, ...}. The same test runner walks both.

Why specifically WDC 65C02 and not Rockwell 65C02 or any of the other CMOS variants? Because chippy’s 65C02 implementation matches WDC’s specifically — it implements WAI (wait for interrupt) and STP (stop) — and those two opcodes are absent from the Rockwell variant. The WDC data set is the matching corpus.

The run found five real CMOS accuracy bugs, all of which were mine. Going through them:

Bug 1 — ADC-decimal V flag computed post-correction

On the CMOS path, ADC in decimal mode computes a binary result, then applies a +$60 correction to the high nibble if needed. The V flag is supposed to come from the binary result before correction, not after. Chippy was computing V from the corrected result, which gives the wrong answer for cases where the correction changes the sign bit’s “trip” condition.

The fix is to capture the binary result before correction, compute V from it, then apply the correction. Four lines. Cleared a few hundred 6d (ADC absolute) and 7d (ADC absolute,X) cases.

Bug 2 — ASL/ROL/LSR/ROR abs,X cycle counts

These four CMOS opcodes are 6 cycles base, +1 on a page cross. Chippy had them as flat 7. The CMOS spec is clear, the silicon does what the spec says, my opcode table was wrong. The fix is the cycle column in four rows of the CMOS opcode table.

There’s an INC/DEC twin to this — INC abs,X and DEC abs,X are also abs,X RMW opcodes, but they’re flat 7, no +1-on-cross. Don’t conflate them. The Harte run does flag the cycle-count divergence per-opcode, which is how I caught the asymmetry instead of fixing all six and accidentally breaking the two that were already right.

Bug 3 — JMP (abs) is 6 cycles on CMOS

On NMOS, JMP (abs) is 5 cycles and has the famous page-cross bug. On CMOS, the bug is fixed and the cycle count is 6 (one extra cycle for the safe fixup). Chippy had it as 5 on both variants. The CMOS table was wrong. One-line fix.

Bug 4 — $5C NOP

The CMOS 65C02 has a wide-NOP at $5C that takes 4 cycles. Chippy had it as 8. Eight is the NMOS quirky behavior for the same opcode (the NMOS treats $5C as a 4-byte read with absurd timing). The fix is to use the CMOS spec for the CMOS table. One line.

Bug 5 — BBR/BBS is flat 6, no branch penalty

The bit-test-and-branch CMOS opcodes (BBR0BBR7, BBS0BBS7) take a flat 6 cycles whether or not the branch is taken. They don’t pay the +1 page-cross penalty regular branches pay. Chippy was applying the branch penalty. Eight-line fix (one per opcode pair).

What the skip list looks like now

Five fixes. The remaining skips on the 65C02 set:

  • WAI and STP: chippy correctly halts the CPU; the Harte corpus doesn’t model “halted.” Suite-level skip, not a chippy bug.
  • Invalid-BCD decimal ADC/SBC: when the inputs to a decimal-mode add/sub aren’t valid BCD digits, real silicon’s behavior is undefined. Each silicon datasheet gives a different answer; Mesen2 documents this; the Harte corpus assumes one specific undocumented answer that doesn’t match chippy’s (which is itself plausible). The skip is per-case — I added a filter that resolves the effective operand via the production resolve() path and drops the case if it would feed invalid BCD. Valid-BCD inputs match silicon on every case.

That’s a deliberate, narrow exclusion. The behavior chippy ships for invalid-BCD is documented and matches one real silicon trace; the Harte expectation matches a different one. Neither is “wrong”; the spec is.

4. Manual struct-overlay watch (the cc65 .dbg payoff)

This is the feature whose scope I had to reshape in v1.3 because cc65’s .dbg doesn’t carry struct member layout — the v1.3 finding that landed as reference-cc65-dbg-no-types in my memory. The honest scope at v1.3 was array-only expansion. The next-step plan was “let the user declare the struct shape inline since the debug info can’t.”

v1.6 ships that:

:watch playerX as {hp:byte, x:word, y:word}

The watch panel renders four rows: a header row showing the base address and the symbol, then hp, x, y each on its own row, each read at playerX + offset. Offsets auto-advance by width (byte is 1 wide, word is 2 wide). The user can override the auto-advance with name@N:width if a field is at an explicit offset:

:watch frame as {x:word, y:word, _pad@4:byte, flags:byte}

That syntax is intentionally minimal. There are no nested structs (you can re-watch a sub-address with its own overlay if you want them). There’s no enum decoding (a value-renderer hook would be a fine v1.7 addition; it’s not necessary now). There’s no signed-vs-unsigned distinction (the watch panel shows hex by default; if you need signed decimal, the immediate-window expression S(myvar) does it).

The implementation: Watch.Fields []WatchField is added to the watch struct as an omitempty field. State schema v1 is unbumped — the contract from v1.0 holds, the golden file at internal/tui/testdata/state-v1.json loads with the new field as zero-valued. The TUI’s render path checks len(w.Fields) > 0 and either renders the field tree or falls back to the v1.3 array-expansion path.

This composes cleanly with the v1.3 array-expansion machinery: an array of structs (:watch enemies x4 as {hp:byte, x:word, y:word}) is exactly the syntax you’d guess. Each array element gets a struct overlay; each field within the overlay gets a row.

The thing I want to flag about this whole feature arc — v1.3’s finding through v1.6’s payoff — is that the restraint in v1.3 was the right call. If I’d shipped a fake .dbg-driven struct expansion at v1.3 that silently failed for any csym whose type collapsed to void (which is all of them), I would now be carrying the technical debt of explaining why the feature was broken for half the use cases the user tried. Instead I shipped the array-only minimum and explicitly deferred struct expansion until I had an honest design. The manual overlay is that honest design. The cost of the deferral was three releases; the cost of the alternative would have been ongoing user confusion.

5. DAP Globals scope + array children

This is the same feature — struct + array inspection in a standard DAP editor (VS Code / Cursor / nvim-dap) mirroring what the TUI watch panel does — exposed over the protocol.

The DAP variables handler had previously exposed only Registers and Flags (the two scopes the v1.5 Registers panel migration consumed). Globals — the user’s program’s data symbols from the .dbg — weren’t a scope. So in a standard editor, you saw your CPU registers in the Variables pane and… that was it. No playerX, no score, no frame_counter. The data symbols from the .dbg were invisible.

v1.6 adds a Globals scope, advertised in the DAP scopes response only when a .dbg is loaded. The scope enumerates data symbols — symbols with a known size, or symbols whose address falls inside a declared data range (.bss, .data). Code labels are filtered out (a .dbg symbol named main_loop is a PC label, not a variable, and putting it in the Variables pane would be misleading).

For a symbol with size > 1, the editor sees an expandable variable. Expanding it lists indexed byte children ([0], [1], [2], …). The indexed expansion uses a dynamically-allocated variablesReference — every expanded symbol gets a fresh handle. This is a departure from the Registers/Flags scopes, which use static constant handles (1 for registers, 2 for flags). I added a small server-side handle table for the dynamic ones, plus paging (start / count) for large arrays so a 4096-byte symbol doesn’t return a 4096-element response.

The supportsVariablePaging capability is advertised on initialize. Editors that support paging get paged responses. Editors that don’t support paging get the first 4096 children (which is the soft cap I picked; a nvim-dap user with a literal 65535-byte expansion would, charitably, want paging).

setVariable on an indexed child — i.e., “write 42 to playerX[3]” — is not wired in v1.6. The reason: the variables handler for the Globals scope reads sized symbols via MMIO.Peek (side-effect-free); writes need to go through the bus the same way the v1.3 :mem command does, and the wiring is its own follow-up. v1.7 issue #454.

6. chippy-state dirtyRanges: streaming changed memory

The chippy-state event from v1.5 carried registers but the dirtyRanges field was an empty reserved slot. In remote free-run mode, this meant register state was live but memory and disassembly panels were stale — they didn’t update until the next stopped event triggered a full readMemory refresh. For a long free-run, the memory panel could be wrong for seconds.

The fix is structurally simple and architecturally pleasing because it composes cleanly with v1.5’s host hooks: when the server enters free-run mode, it arms a cpu.SetAccessHook (the v1.5 hook for the heatmap) that stamps a dirty bitmap on every write. Each throttled chippy-state emission flushes the bitmap into coalesced [start, end) spans, packs the changed bytes inline with the spans (no follow-up readMemory), and clears the bitmap. The TUI applies the spans to its mirror.

The one trick: the server needs to chain its dirty-stamp hook in front of whatever hook the host has already registered (nessy’s heatmap, for example). The hook is a single function pointer; the server saves the previous one, installs its own that calls both, and restores the previous one when the run loop stops. There’s a new cpu.AccessHook() getter on the CPU for reading the current hook without modifying it. Zero cost when nothing is wired (the chain is itself nil-guarded; the loop’s per-write overhead stays in the noise the perfgate permits).

On stopped, the TUI still triggers a full memory reconcile via readMemory — that’s the authoritative path and chippy-state is the streaming complement. If the streaming misses a range (it doesn’t, but if it did), the reconcile picks it up. The streaming is the optimization; the reconcile is the guarantee.

End-to-end: remote run, memory panel updates live (~60 Hz), disasm panel follows, no per-frame round-trip, nothing about the standard DAP behavior changes for editors that don’t know about chippy-state.

7. goreleaser brews → homebrew_casks

The least technically interesting but most release-engineering-relevant change in v1.6.

goreleaser (the tool that publishes chippy binaries to GitHub Releases and the Homebrew tap on tag push) deprecated its brews: key — the one that publishes a Homebrew formula for pre-built binaries — in favor of homebrew_casks:, which publishes a Homebrew cask. The deprecation was announced; a goreleaser version bump would have broken the publish job; I needed to migrate before the bump forced it.

The mechanics:

  • release.yml: brews:homebrew_casks:, binaries: [chippy], directory: Casks.
  • Post-install hook: xattr -d com.apple.quarantine. The chippy binary is cosign-signed but not Apple-notarized, so macOS Gatekeeper quarantines it on first run. The cask post-install clears the xattr so the user doesn’t see a “this app is from an unidentified developer” dialog.
  • Install command changes: brew install chippybrew install --cask chippy. README updated accordingly.
  • README’s Homebrew section is now macOS-only. Linuxbrew has no cask support; Linux installs continue through the .deb / .rpm / .apk artifacts and the AUR package.

The reason I want to call this out: release-pipeline migrations are the kind of work where doing it before you have to is materially cheaper than doing it after. A goreleaser bump that breaks brews: would have meant a red release pipeline for the duration of “diagnose, migrate, test, publish.” Doing the migration ahead means the next goreleaser bump is uneventful. The cost difference is hours-when-planned vs. days-when-emergent. The trick is noticing the deprecation in the changelog and not letting it slide.

What v1.6 actually closed

Lining up the v1.5-deferred backlog against what shipped:

Deferral Status
ARR ($6B) decimal mode Cleared (10000/10000 Tom Harte cases)
10 per-cycle bus quirks Cleared (238/238 6502 bus-exact)
Tom Harte 65C02 (WDC) Wired; 5 CMOS bugs fixed
Manual struct overlay watch Shipped (:watch X as {…})
DAP Globals + array children Shipped (with paging)
chippy-state dirtyRanges Streaming memory live during free-run
goreleaser brews → casks Migrated before forced bump

What didn’t ship: setVariable for Globals children (v1.7 #454). 65C02 per-cycle bus trace (v1.7 #455). All TUI panels beyond Registers migrated to DAP (v2.0, mechanical).

That’s the smallest open backlog chippy has had since v0.0.1.

What v1.7 and v2.0 look like

v1.7 is a small accuracy + DAP-completion cut. setVariable on Globals indexed children. The 65C02 per-cycle bus trace. Whatever the inevitable “post-corpus surprise” turns out to be (every release that wires a new corpus finds at least one bug; I expect 65C02 bus to find one or two).

v2.0 is the panel-migration release — TUI stack, flags, memory, disasm panels all routed through DAP, local mode becomes “in-process DAP server” with no special-case render paths. The v1.5 onramp proved the pattern; the migration is now bounded mechanical work.

And then: nessy

Next week is the nessy post. One single piece covering the eight-release arc from v0.1.0 (NROM, three demo ROMs, NMI works) to v0.8.0 (every Konami expansion-audio chip, MMC3 with A12 scanline IRQ, headless recorder, PAL + Dendy timing, Mesen2 as the cycle-precision oracle). It’s also the post that closes the six-release chippy chapter — the entire chippy story from v1.0 to v1.6 was always also a story about nessy, because the two projects are tightly entangled enough that you can’t tell one without the other.

In the meantime, v1.6 is the cleanup-pass release where every open accuracy tab from v1.5 closes, the cc65 .dbg finding from v1.3 finally pays out as a working feature, and the release pipeline gets future-proofed ahead of an upcoming goreleaser bump. The CPU is as correct as it has ever been. The debugger is as full-featured as it has ever been. The library API is stable, the host hooks are exposed, and the path to v2.0 is mechanical.

There’s nothing left on the open-tab pile that can’t wait. Onward.