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
BusisRead(uint16); the 65816 needs 16 MB. Rather than widen the existing interface and break every host, there’s a separateBus24(Read24/Write24 uint32) installed viaSetBus24, used exclusively by the 65816 core. For the runnable TUI,Bus24From16mirrors the existing 16-bit MMIO/watchpoint bus into bank 0 — every bank aliases bank 0 — so-cpu 65816runs 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
CPUstruct. High halves (B,XH,YH,SPHi), 16-bitD, the bank registers, and theEflag are new fields the 8-bit cores simply never touch.mWide()/xWide()read the M/X bits (native-only; locked set in emulation), andA16()/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],Ylong 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=$01at 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 (
rmwDummyis 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)andJMP (abs,X)take 6 cycles: pointer low, a dummy high read at the NMOS-wrap address, then the correct high atptr+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.