The release that was supposed to be small

The v1.6 post called v1.7 “a small accuracy + DAP-completion cut.” Three items: setVariable on Globals children (#454), the 65C02 per-cycle bus trace (#455), and “whatever the inevitable post-corpus surprise turns out to be.” The panel migration — every TUI panel routed through DAP — was penciled in for v2.0.

That is not what shipped. Two things changed the plan. First, the panel migration turned out to be more tractable than “v2.0-scale” once I actually started it, so I pulled the v2.0-deferred items into the v1.7 plan (#464) and finished the flip. Second, and much bigger: chippy now has a complete, Tom Harte-validated WDC 65C816 core. Not a scaffold, not an emulation-mode subset — all 256 opcodes, 16-bit native mode, 24-bit addressing, validated against the full corpus in both emulation and native modes.

So v1.7 is the release where chippy stops being “a 6502/65C02 emulator” and becomes “a 65xx-family emulator,” and simultaneously the release where the local TUI and a remote editor finally drive execution through the same protocol surface. Eight things worth writing down, in priority order.

1. The 65816 gets its own interpreter, not an opcode-table variant

Chippy’s variant seam since v1.2 has been c.opcodes *[256]Instr — a per-CPU opcode table, dispatched by a shared step loop. NMOS, CMOS, and the NES variant all live behind that seam, and it works because they share three things: an 8-bit datapath, 16-bit addressing, and a fixed cycle-per-addressing-mode shape.

The 65816 breaks all three. A, X, Y, SP, and the direct-page register D are 16-bit-capable. The address space is 24-bit, with data-bank and program-bank registers (DBR/PBR). And operand sizes and cycle counts depend on the live M/X width flags — LDA # is a 2-byte instruction or a 3-byte instruction depending on processor state, which means a static table can’t even tell you how long an instruction is.

The decision (D1 in the release ADR): the 65816 gets its own interpreter. Step() branches to step816 for VariantW65816 after the shared interrupt/halt boundary; step816 is a direct opcode switch — no table — that fetches at PBR:PC and operates on width-aware registers. The 65816 logic is fully isolated in cpu/{cpu816,addr816,arith816,ctrl816,exec816,disasm816}.go. The cost to the 8-bit cores is one variant compare; the perfgate stayed green throughout.

Two companion decisions make it hang together:

  • A distinct 24-bit bus. Chippy’s Bus is Read(uint16); the 65816 needs 16 MB. Rather than widen the existing interface and break every host, there’s a separate Bus24 (Read24/Write24 uint32) installed via SetBus24, used exclusively by the 65816 core. For the runnable TUI, Bus24From16 mirrors the existing 16-bit MMIO/watchpoint bus into bank 0 — every bank aliases bank 0 — so -cpu 65816 runs bank-0 programs through the same RAM the panels render and the reset vector resolves normally. Cross-bank aliasing is a documented limitation until a bank-aware bus lands.
  • The width model lives on the existing CPU struct. High halves (B, XH, YH, SPHi), 16-bit D, the bank registers, and the E flag are new fields the 8-bit cores simply never touch. mWide()/xWide() read the M/X bits (native-only; locked set in emulation), and A16()/X16()/… compose the full registers. Every opcode kernel becomes a single width-branch.

The core landed in four PRs over the release: an emulation-mode scaffold (#479), then three chunks — register/flag/transfer/immediate ops (#485, 41 opcodes), the width-aware memory engine and every addressing mode (#486, up to 122 opcodes), and RMW + stack + control flow (#487, up to 254). Unimplemented opcodes panicked with their hex during the buildout, so the test harness always told me exactly what was left.

2. Validating against the Harte 65816 corpus — and the quirks no spec captures

The Tom Harte 65816 corpus (pinned at dff67125) is 512 files: 256 opcodes × emulation/native, 10,000 cases each, with 16-bit state and a 24-bit-address cycle list. The harness (TestHarte65816, behind //go:build harte) validates final register/memory state plus cycle count — not the per-cycle bus trace. That mirrors how the 6502 and 65C02 went: state suite first, bus trace as a later accuracy pass. The 65816 bus trace is the new open tab.

The exact cycle model had to be reverse-engineered from the corpus: +1 for a 16-bit accumulator/index access (twice for RMW), +1 direct-page penalty when DL≠0, indexed-read +1 when the index is 16-bit or the add crosses a page, indexed write/RMW always pay the extra index cycle — and, unlike the 65C02, no extra decimal-mode cycle, with N/Z/V staying valid in BCD.

The part I want on the record is the emulation-mode quirk pile, because documentation states these inconsistently and the corpus is the only ground truth I trust:

  • The DL=0 page-wrap applies to the direct-page base offset; pointer bytes then increment flat 16-bit — except the [dp],Y long pointer, whose three bytes do page-wrap (readDPLongWrap), while plain [dp] stays flat.
  • The new 16-bit stack instructions (PEA/PEI/PER/PHD/PLD/JSL/RTL) use a full 16-bit SP mid-instruction and reforce SPHi=$01 at the end; the legacy ops page-wrap within page 1.
  • JMP/JSR (abs,X) read the pointer high byte wrapping within the program bank.
  • PEI’s direct-page word read page-wraps where the (dp) addressing modes do not.

Each of those is pinned by a Harte case and documented at its call site. None of them appear, correctly stated, in any single reference I checked.

Two deliberate divergences. MVN/MVP — the block-move opcodes — move the entire block in one Step (7 cycles per byte) rather than re-running the opcode per byte as silicon does, because a debugger stepping one byte of a 64 KB move 65,536 times is nobody’s idea of sanity. The Harte corpus caps block-move tests at ~100 cycles mid-instruction (a generator artifact), so MVN/MVP are validated by a dedicated unit test instead. Net: 254 of 256 opcodes are state-and-count exact against the corpus in both modes, and the remaining two are unit-tested against the architectural definition.

And one honest process failure: the MVN/MVP PR (#488) inadvertently dropped the STP ($DB) case from step816. Its tom-harte CI run failed on exactly that — but the job isn’t a required check, so the PR merged and main was briefly broken. The closing PR (#489) restored it. The lesson is written down: an advisory CI job on the thing you’re actively changing is a required check you haven’t admitted to yet.

The last piece is Disasm816 (D7): a dedicated 256-entry decoder that reads the live M/X/E state to size immediates and renders the new syntaxes — long $123456, [dp], sr,S, MVN src,dst. DisasmCPU dispatches to it for the variant, so the TUI’s disassembly panel renders 65816 programs correctly in bank 0.

3. The 65C02 bus trace — the promised deferral, and the ~64-opcode surprise

v1.6 closed the NMOS bus-trace skip list and deferred the 65C02 per-cycle bus trace to v1.7 with the words “the deferral is calendar, not architectural.” The harness half of that was true — TestHarte65C02BusTrace is the CMOS sibling of the NMOS runner, same busRecorder, different data set. The accuracy half was not: wiring it surfaced that chippy’s per-cycle interleave was NMOS-modeled everywhere, and about 64 opcodes diverged across the documented NMOS-vs-CMOS dummy-cycle classes. This is the “post-corpus surprise” the v1.6 post predicted, except I guessed “one or two” and got sixty-four.

The classes, all now fixed bus-exact and variant-gated so NMOS is untouched:

  • The RMW dummy cycle is a read on the 65C02, not a write-back of the old value (rmwDummy is variant-gated); TRB/TSB/RMB/SMB take that dummy cycle too.
  • The indexed page-cross dummy re-reads the last instruction byte (c.PC-1) instead of the un-fixed effective address (indexedDummyAddr).
  • JMP (abs) and JMP (abs,X) take 6 cycles: pointer low, a dummy high read at the NMOS-wrap address, then the correct high at ptr+1 — the extra cycle is literally the page-boundary bug fix, visible on the bus.
  • PHX/PHY/PLX/PLY emit the same dummy cycles as PHA/PLA.
  • The WDC 3-byte NOPs ($5C/$DC/$FC) re-read the high operand byte (opNOPAbs65C02) rather than dereferencing the operand.

That left exactly one entry on harteBusSkip65C02: the sixteen BBR/BBS opcodes, whose 6-cycle pattern I couldn’t model from documentation. A follow-up (#483) pinned it from the corpus: zero-page operand read, zero-page bit-test read, a dummy write-back of that byte (65C02 RMW-style), relative operand read, then a dummy read of the branch target — and the target read happens always, taken or not, using the un-fixed old-high-byte address on a page cross. Flat 6 cycles, no fixup cycle. The dummy write-back writes the same value, so final state is unchanged and the state suite kept passing the whole time.

harteBusSkip65C02 is now empty. Both the 6502 and the 65C02 are per-cycle bus-exact for all 256 opcodes.

4. The TUI-via-DAP flip: one protocol surface for local and remote

This is the architecture item the v1.6 post filed under v2.0 as “bounded mechanical work.” The render half was mechanical. The control half was not, and it’s why the original estimate said v2.0.

Render path (#461): the stack, flags, memory, and disassembly panels joined the Registers panel from v1.5, each migrated in its own PR. All five panels plus navigation now read DAP-sourced snapshots; zero direct cpu/RAM access remains in render or nav. A few protocol details fell out: the DAP stackTrace response gained two additive chippy-extension fields (chippyStackAddr, chippyCallee) so the panel keeps its hardware-stack-page layout, and the disassemble handler became data-range-aware — it renders .byte $XX when the source map says an address is data, for any DAP client, not just the TUI.

Control path (#471): run, step-x16, step-over, and run-to-line now route through a new synchronous Server.RunBudget(maxSteps, step, stopAt) — the server owns breakpoint, data-breakpoint, halt, and BRK enforcement, plus an optional caller predicate (step-over passes the return PC; run-to-line passes a line-change check). The TUI’s own shouldBreakAt is deleted.

The interesting decision is synchronous RunBudget over the async continue + events model a remote client uses. The in-process dispatch self-locks cpuMu, so an async run goroutine would deadlock against the TUI’s m.step lock and the :dap co-running server — the mutex is non-reentrant. Sync runs on the TUI goroutine: no goroutine race, the TargetHz throttle is preserved, and because step is the TUI’s own m.step, the rewind ring keeps filling during a server-driven run. The rich TUI rewind stays the documented local-engine exception; single-step and memory-edit stay direct (routing a run’s per-step through stepIn would regress run performance, and mem-edit via writeMemory would bypass MMIO — a behavior change, not a refactor).

The whole flip is internal/-only. The public Go API stays additive, which is why this shipped as v1.7.0 and not v2.0.0 — the “v2.0” label was always about the API surface, and it turned out the migration never needed to break it.

5. Freeze beyond RAM

The debugger freeze facility (RAM.Freeze, from v1.4’s #422) suppresses writes to an address so a value holds — the classic infinite-lives cheat, as a debugging tool. It was RAM-only, and that scoping had a hole: a CPU write to a peripheral- or cart-mapped address never reaches RAM, because MMIO intercepts it. RAM-level freeze couldn’t hold those values at all.

v1.7 moves the guard to the bus: MMIO.Freeze/Unfreeze/Frozen/FrozenAddrs. The check is a single length test in MMIO.Write — zero cost when nothing is frozen, perfgate green. Freeze writes the value through once via the normal dispatch (so it lands in the peripheral or inner RAM), then adds the address to the suppress set. Works uniformly for peripheral-, cart-, and RAM-mapped addresses; RAM.Freeze stays for direct-RAM contexts. The consumer that wanted this is nessy — freezing a mapper register or an APU-adjacent value was previously impossible.

6. DAP data breakpoints and setVariable — the v1.6 deferrals close

The two items the v1.6 post explicitly promised:

Data breakpoints (#453): setDataBreakpoints and dataBreakpointInfo expose chippy’s memory watchpoints over the protocol. dataBreakpointInfo resolves a hex/decimal address or a loaded .dbg symbol name to a "$XXXX" dataId with read/write/readWrite access types; condition and hitCondition reuse the instruction-breakpoint metadata path. Enforcement reuses the chained access hook from v1.6’s dirtyRanges work — a matching watched access flags a pending stop, and the run loop stops with reason “data breakpoint” after the instruction completes. Zero hot-path cost when none are set. This also became the enforcement engine for the TUI’s own watchpoints once RunBudget landed — one implementation, two frontends.

setVariable on Globals (#454): handleSetVariable now writes Globals scalars (resolved via syms.LookupName) and array children (a dynamic reference plus an "[i]" name). Both write through s.ram directly, deliberately bypassing MMIO — a debugger poke is not a program access — and are refused while running. An editor can now edit playerX[3] from the Variables pane, which closes the read/write story the v1.6 Globals scope opened.

7. One cycle short: the DMC DMA dummy read

The accuracy fix that isn’t 65xx-corpus work: chippy’s ProcessPendingDma — the NES DMA model ported from Mesen2 back in v1.5 — omitted Mesen’s _needDummyRead. Real DMC DMA runs halt → dummy read → align → DMC read; chippy ran halt → align → DMC read. One cycle short, which landed the DMC sample fetch and the APU bytesRemaining decrement one CPU cycle early.

The fix mirrors Mesen’s NesCpu.cpp exactly: set needDummyRead when a DMC DMA is scheduled, gate the DMC read on !needHalt && !needDummyRead, clear it after the halt in the per-cycle bookkeeping. Verified non-regressing against nessy via a go.mod replace: cpu_interrupts_v2 5/5, apu_test 8/8, ppu_vbl_nmi, instr_timing, and mmc3_test all still pass. It’s a prerequisite for the dmc_dma_during_read* ROM family, whose remainder needs an open-bus model — that’s tracked, not shipped.

8. The WASM playground gets drag-and-drop

The smallest change in the release closes the oldest-feeling gap. The in-browser playground — chippy compiled to WASM, deployed to GitHub Pages — was already live: demos dropdown, file picker, registers and disasm panes, keyboard-to-MMIO. The page copy said “drop your own ROM.” Dropping a ROM did nothing.

Now it works: document-level drag handlers feed the existing loadUserFile path (the same one the file picker uses), with a dashed-outline affordance while dragging. Seventeen lines of JavaScript. .dbg symbol drag-drop is deferred — the wasm load() API doesn’t expose symbol loading yet.

What v1.7 actually closed

Item Status
setVariable on Globals children (v1.6 → #454) Shipped
65C02 per-cycle bus trace (v1.6 → #455) Shipped; ~64 interleave fixes; skip list empty
The “post-corpus surprise” BBR/BBS 6-cycle pattern, pinned from the corpus
TUI panel migration (was v2.0) All five panels + control path DAP-sourced
Full 65816 core 256 opcodes, 254 Harte-exact e+n, MVN/MVP unit-tested
Freeze beyond RAM MMIO.Freeze, bus-level
DAP data breakpoints Shipped
WASM drag-and-drop Shipped

The new open tabs, all deliberate: the 65816 per-cycle bus trace (state-and-count shipped first, same as the 6502 and 65C02 arcs), a bank-aware bus and bank-aware panels (cross-bank currently aliases to bank 0), the NES open-bus model behind the remaining dmc_dma_during_read* cases, and .dbg drag-drop for the playground.

What’s left for v2.0

The funny thing about pulling the panel migration into v1.7 is that it emptied most of what “v2.0” meant. The API break that would justify a major version hasn’t materialized — the flip stayed additive. What remains on the far horizon is the stuff the 65816 core just created: a real bank-aware Bus24 host story, 65816 symbols and debugging (the current .dbg pipeline is 16-bit-addressed), and the 65816 bus trace.

Three CPU families, per-cycle bus-exact on two of them, state-exact on the third, one debugger protocol surface for every frontend, and a playground you can drop a ROM onto from a browser. v1.7 was supposed to be the small release. I’ve stopped predicting which ones will be.