The release that drains the 65816 epic
v1.11 was the 65816 tail’s first cut: serviceInterrupt816 gave the 65816 real hardware-interrupt handling
(native-mode vectors, PBR push, D clear — instead of silently reusing the 6502’s serviceVector), the remote
DAP path learned to fetch banks 1–255 lazily over the wire, and the WASM playground gained a WDC 65C816
dropdown option with a hand-assembled native-mode demo. That release also opened epic #517 — the 65816
follow-up epic — with four items on it: #518 (ABORT interrupt), #519 (interrupt-entry per-cycle bus trace),
#520 (pinned cross-bank disassembly scroll), and #521 (playground bank selector).
v1.12 drains three of the four. It’s a small, focused cut — three decisions, all in the same corner of the project: making the 65816’s banks navigable everywhere the 6502’s flat 64 KiB already was, and closing the one validation gap the Tom Harte corpus structurally can’t cover.
I’ll do them in priority order — accuracy first, as always, because the receipts are real.
1. Hardware-interrupt entry, bus-exact
Here’s the gap. The 65816 core has been Tom Harte-validated since v1.7 — all 256 opcodes, emulation and native mode, state plus (since v1.9) the full per-cycle pin-level bus trace, which is stricter than what the 6502 and 65C02 get because the 65816 trace checks eight pin bits per cycle (VDA/VPA/VPB/RWB/E/M/X/MLB), not just address and value. But the Harte corpus is opcodes-only. Every test case starts at an instruction fetch and ends when the instruction retires. Hardware IRQ and NMI are never exercised — there is no external reference for what the bus does during interrupt entry.
So v1.11’s serviceInterrupt816 shipped cycle-count correct (7 cycles emulation, 8 native — verified by
Step’s before/after cycle delta) but nothing checked the per-cycle sequence. And it turned out the sequence
was wrong in a way only a bus trace would catch: the real WDC part spends the first two cycles of interrupt
entry doing internal work — dummy reads at PBR:PC with neither VDA nor VPA asserted — before it touches the
stack. Chippy emitted no bus activity for those two cycles at all. The cycle count was added abstractly, so
the state and count checks passed while the bus was silent for two cycles it should have been (dummy-)reading.
The fix is five lines in cpu/cpu.go: two c.io816() calls at the top of serviceInterrupt816, before the
stack pushes. io816 is the existing internal-cycle helper the 65816 addressing modes already use — it issues
a dummy read at PBR:PC with pinNone (VDA=VPA=0), which is exactly what the WDC sequence calls for.
The validation is the more interesting half, because there’s no corpus to lean on. New cpu/interrupt816_test.go
builds an irqTraceBus — a Bus24 that records every access as an irqCycle{addr, write, pins} triple,
capturing the CPU’s live busPins at the moment of the access. Then two tests pin the full entry sequence
against a hand-authored reference transcribed from the WDC datasheet:
TestW65816_IRQBusTrace_Emulation— the 7-cycle emulation-mode IRQ entry: two internal cycles atPBR:PC(pinNone), three stack writes (PCH, PCL, P —pinVDA), two vector reads at$FFFE/$FFFFwithpinVDA | pinVPBasserted (VPB is the vector-pull pin; real hardware uses it to let a peripheral substitute a vector).TestW65816_NMIBusTrace_Native— the 8-cycle native-mode NMI entry: two internal cycles, then four stack writes (PBR first, then PCH/PCL/P), then the vector reads at$FFEA/$FFEB.
Every cycle is checked: 24-bit address, read-vs-write, and pin state. If the push order drifts, or a vector read forgets VPB, or the internal cycles land at the wrong address, the test names the cycle index that diverged.
One thing worth calling out about blast radius: serviceInterrupt816 is unreachable from brk/cop (those
have their own paths in cpu/ctrl816.go, with the signature-byte read the hardware entry doesn’t do), so the
Harte state, count, and bus-trace harnesses are all byte-for-byte unaffected. This change surfaces only on
a hardware interrupt, and the only observers of a hardware interrupt’s bus cycles are the new tests and any
host with a bus that cares about dummy reads. Which is precisely the kind of host chippy exists to support.
Interrupt entry is now bus-exact, not just count-exact. #519 closed.
2. Pinned cross-bank disassembly and :da
The TUI’s disassembly panel has had cross-bank support since v1.10 (#507): a 65816 program executing in bank
2 disassembles bank 2’s bytes, and the rows render as $02:9000 instead of pretending everything is bank 0.
But the bank always came from the live PBR — the program bank register of the running CPU. That’s correct
when the panel is following PC. It’s wrong the moment you pin the view.
Pinning is the disasm-follow-off mode: you scroll, the panel stops re-anchoring on PC, and DisasmAnchor
holds the address at the top of the window. The bug was that DisasmAnchor was a uint16 — the bank wasn’t
part of the pin. So if you pinned the view in bank 2 and the program jumped to bank 5, your “pinned” view
silently started disassembling bank 5’s bytes at your bank-2 offsets. The one thing a pinned view promises —
this exact memory, held still — broke across banks.
The fix has three parts:
DisasmAnchorBankjoinsDisasmAnchoron the model, and it persists — the state JSON gains adisasm_anchor_bankfield (omitempty, so the schema is additively extended the same waymem_view_bankwas in v1.10; old state files load fine).disasmBank()is the single source of truth for “which bank is the panel viewing”: the live PBR when following, the pinned bank otherwise, and always 0 for the 8-bit cores so their panels stay plain 16-bit. It’s threaded throughsyncDisasm(the DAP snapshot fetch anchors atdisasmBank()<<16 | pc),disasmView(the header and row addresses show the pinned bank, not the live one), anddisasmScroll(the first scroll captures the live PBR into the pin, then movement stays in that bank). The status line follows suit:disasm @ $02:9000 (' to follow PC).:dais the new prompt verb — pin the disassembly to an arbitrary target. It accepts$XXXX(bank 0),$BB:XXXX(explicit bank:offset), a bare 24-bit hex like$029000(split into bank + offset), or a symbol.parseDisasmTargetdoes the resolution; anything that isn’t bank-qualified falls through to the existing 16-bit address/symbol resolver. Asking for a bank ≠ 0 without a 65816 gets a straight answer:bank addressing needs -cpu 65816.
TestDisasm_PinnedCrossBank covers the pin surviving what it needs to survive. The diff is 126 insertions
across seven files, and internal/tui/disasm.go actually got smaller — the old inline “if 65816, OR in the
live PBR” logic collapsed into the disasmBank() helper.
One honest limitation, straight from the ADR: cross-bank scrolling within a snapshot still clamps at bank
edges, because the DAP disassembly window is per-bank. You can’t scroll from $02:FFFF into $03:0000 —
:da $03:0000 is the cross-bank jump. Given that 65816 code doesn’t fall through bank boundaries either
(PC wraps within the bank; only the data-fetch paths cross), the clamp matches the machine.
3. The playground bank selector
Same story, third venue. The hosted WASM playground got the 65816 in v1.11 (#514/#515), but its memory and
disassembly panes were still hardwired to bank 0 — jsReadMem masked addresses with & 0xFFFF and read
straight from the 64 KiB RAM, and jsDisasm used cpu.DisasmCPU, which reads through the CPU’s default bus.
A native-mode program running in bank 2 was invisible from the browser. The TUI had :bank and cross-bank
disasm; the playground had nothing.
v1.12 widens the WASM glue to 24 bits:
readByte24is the bank-aware, side-effect-free playground read: banks 1–255 go throughBanked24(the flat 16 MB store from v1.10), bank 0 reads raw RAM directly — deliberately not through MMIO, so inspecting memory in the browser can’t trigger a peripheral’s read side effects. That matches the old behavior exactly for the 8-bit cores.bankBusis a small type — a 16-bitcpu.BuswhoseReadprepends a fixed bank to every address and delegates toreadByte24— socpu.DisasmCPUAt(the bus-explicit disassembler from v1.10) can walk any bank. ItsWriteis a no-op; disassembly never writes. This is the part I like about the change: there’s no new disassembly logic. The sameDisasmCPUAtthe DAP server and TUI already exercise gets pointed at a different bank via a nine-line adapter.jsStateexposespbr, and the JS side anchors the disassembly pane atPBR:PCfrom state — so when the demo program is executing in bank 2, the disasm pane follows it there without the user doing anything.- The web UI gains a bank input feeding the memory pane — a two-character hex field that
updateBankVisibilityshows only when the variant dropdown says65816, because the 8-bit cores have a single 64 KiB space and a bank selector would be noise. Both panes render$BB:XXXXvia a sharedfmtBankAddrhelper that degrades to plain$XXXXwhen the bank is 0.
The whole thing is 71 insertions across four files. The browser panes now reach the same bank-aware inspection the TUI has, and the Go side reuses paths that were already DAP-tested — the new surface is JS and wasm glue, which is exactly where I want the new surface to be.
What v1.12 actually closed
Lining up epic #517 against what shipped:
| Epic item | Status |
|---|---|
| #519 interrupt-entry per-cycle bus trace | Closed — entry is bus-exact, hand-authored WDC reference tests |
| #520 pinned cross-bank disassembly scroll | Closed — DisasmAnchorBank + :da, persisted |
| #521 playground bank selector | Closed — 24-bit jsReadMem/jsDisasm, bank input, $BB:XXXX panes |
| #518 ABORT interrupt | Parked — no abort source exists in-tree to drive it |
Minor version bump: everything here is additive UX plus a 65816-only interrupt-emission change that the opcode-level harnesses can’t even observe.
What’s next: ABORT
The one item left on #517 is the one I parked twice, and it’s parked for a real reason: ABORT ($FFF8
emulation / $FFE8 native) is the 65816’s memory-management interrupt, and the true silicon behavior is
asserted mid-instruction — the chip finishes the instruction’s cycles with its register and memory writes
inhibited, then vectors while pushing the aborted instruction’s PC so RTI re-runs it. Modeling that
faithfully needs cycle-accurate rollback and a peripheral (an MMU) that asserts the abort line mid-op.
Chippy has neither, and shipping the machinery without a consumer is how you end up with untested
speculative complexity.
The plan for v1.13 is the honest middle: a boundary-recognized ABORT that matches how chippy already
services IRQ and NMI. Step services interrupts at the instruction boundary, so an asserted ABORT aborts the
upcoming instruction before it executes — push the current PC (which is the aborted instruction’s own
address), vector through serviceInterrupt816(0xFFF8, 0xFFE8), and RTI re-runs it. No rollback needed,
because the instruction never started. ABORT gets priority over NMI and ignores the I flag, per the
datasheet. That lands the complete 65816 interrupt model — IRQ, NMI, ABORT, all through the same validated
entry path — and closes the epic.
In the meantime, v1.12 is the release where the 65816 stopped being a core you could run and finished becoming a machine you can navigate: every bank inspectable from the TUI, the DAP client, and a browser tab; every pinned view honest about which bank it’s holding; and the one interrupt-timing gap the corpus couldn’t see now pinned by tests transcribed from the datasheet. Three tabs closed, one to go. Onward.