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 at PBR:PC (pinNone), three stack writes (PCH, PCL, P — pinVDA), two vector reads at $FFFE/$FFFF with pinVDA | pinVPB asserted (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:

  • DisasmAnchorBank joins DisasmAnchor on the model, and it persists — the state JSON gains a disasm_anchor_bank field (omitempty, so the schema is additively extended the same way mem_view_bank was 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 through syncDisasm (the DAP snapshot fetch anchors at disasmBank()<<16 | pc), disasmView (the header and row addresses show the pinned bank, not the live one), and disasmScroll (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).
  • :da is 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. parseDisasmTarget does 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:

  • readByte24 is the bank-aware, side-effect-free playground read: banks 1–255 go through Banked24 (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.
  • bankBus is a small type — a 16-bit cpu.Bus whose Read prepends a fixed bank to every address and delegates to readByte24 — so cpu.DisasmCPUAt (the bus-explicit disassembler from v1.10) can walk any bank. Its Write is a no-op; disassembly never writes. This is the part I like about the change: there’s no new disassembly logic. The same DisasmCPUAt the DAP server and TUI already exercise gets pointed at a different bank via a nine-line adapter.
  • jsState exposes pbr, and the JS side anchors the disassembly pane at PBR:PC from 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 updateBankVisibility shows only when the variant dropdown says 65816, because the 8-bit cores have a single 64 KiB space and a bank selector would be noise. Both panes render $BB:XXXX via a shared fmtBankAddr helper that degrades to plain $XXXX when 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.