The release where the 65816 stops being mine alone
v1.10 was the bank-aware release. Banked24 killed the bank-0 mirror, a 65816 program finally reached 16 MB of
distinct storage instead of silently aliasing everything into one 64 KiB page, and the memory and disassembly
panels learned to render $BB:XXXX. By the end of that cycle, the 65816 core — Tom Harte-validated for all 256
opcodes, state and full pin bus trace — was correct and inspectable if you were me, running the local TUI, with
no interrupts firing.
That’s a narrow demographic. v1.11 is the widening cut: make hardware interrupts actually work on the 65816 (the one thing the Harte corpus structurally cannot test), extend bank-awareness to the remote DAP path so an attached debugger sees banks 1–255, and put the chip in the hosted WASM playground so anyone with a browser can run native-mode 65816 code without installing anything.
Three decisions, plus a demo ROM that turned out to be its own small engineering story. Priority order, as always — correctness first.
1. 65816 hardware interrupts — the corpus blind spot
Here’s the uncomfortable fact this release starts from: you can pass 256/256 opcodes on the Tom Harte ProcessorTests, state and per-cycle bus trace, and still have completely broken hardware interrupt handling. The corpus is opcodes-only. Every test case starts at an instruction fetch and ends when the instruction retires. Nothing in it ever asserts the IRQ or NMI line. The corpus validates the instruction set; it says nothing about what happens between instructions.
What was actually between instructions on VariantW65816: serviceVector in cpu/cpu.go, which is 6502-only.
A hardware IRQ or NMI on the 65816 would:
- jump through the 6502 vectors (
$FFFE/$FFFA) even in native mode, where the 65816 has its own vector table ($FFEEfor IRQ,$FFEAfor NMI), - push the return state through the page-locked 8-bit stack — the emulation-mode stack that wraps within page 1 — even when the native-mode 16-bit stack pointer was somewhere else entirely,
- never push PBR, so an interrupt taken while executing in bank
$12had no way to return there, and - never clear D, so a handler entered with decimal mode on would do BCD arithmetic until it noticed.
Four distinct wrongs, all invisible to every test I had, because no test fired an interrupt at the 65816.
The fix is serviceInterrupt816(vecEmu, vecNat). The interesting design point: the 65816 already has two
correct interrupt-shaped sequences in cpu/ctrl816.go — brk and cop, which were corpus-validated, because
BRK and COP are opcodes. A hardware interrupt is the same dance minus the signature-byte read, and with the B
bit pushed clear instead of set (that’s how an emulation-mode handler tells a hardware IRQ from a software BRK
— they share a vector). So the new function mirrors brk/cop:
- Emulation mode (E=1): push PC16, push P with B clear (and U forced set), vector through
$FFFE/$FFFA. 7 cycles. - Native mode (E=0): push PBR, push PC16, push P as-is, vector through
$FFEE/$FFEA. 8 cycles — the PBR push is the extra one. - Both: force PBR to 0 (handlers run in bank 0), set I, clear D, and read the vector through the 24-bit bus
via
pinVector, matchingbrk/cop.
There’s a nuance in the “push P as-is” for native mode: native-mode P has no B flag at all — bit 4 is the X
(index width) flag. The B-clear trick only exists in emulation mode, where hardware and BRK share a vector and
the handler needs a discriminator. Native mode gives BRK its own vector ($FFE6), so no bit-twiddling is
needed. The code reflects exactly that: (c.P | FlagU) &^ FlagB on the emulation path, bare c.P on the
native path.
serviceNMI and serviceIRQ branch to the new function when c.Variant == VariantW65816. The 6502, 65C02,
and NES interrupt paths are byte-for-byte untouched — the whole change is 38 added lines in cpu/cpu.go,
zero modified ones.
Tests: TestW65816_IRQEmulation and TestW65816_NMINative in cpu/cpu816_test.go. The emulation test checks
the vector, the I-set/D-clear, the exact stack layout (PCH at $01FF, PCL at $01FE, P at $01FD, SP landing
on $01FC), and that the pushed P has B clear. The native test runs with PBR=$12 and the stack at $1FFF —
outside page 1, which is precisely where the old code would have corrupted the wrong page — and checks that
PBR=$12 got pushed and PBR reads 0 inside the handler.
Two things deliberately out of scope. ABORT ($FFF8/$FFE8) isn’t wired because chippy has no abort source —
nothing in the system can assert the line, so an implementation would be dead code (tracked in the 65816 epic,
#517/#518). And per-cycle bus-trace validation of the interrupt entry is deferred (#519): the entry passes
state and cycle-count checks, but Harte gives no hardware-interrupt reference to trace against, so a bus-exact
claim would be unverifiable. I’d rather say “state-correct, trace-unverified” than imply more than I can prove.
2. Remote 65816 bank reads over DAP
v1.10’s bank-awareness went through four layers — core, loader, DAP server, TUI — but there was a fifth I
explicitly deferred: RemoteSource (internal/tui/source_remote.go), the TUI’s data source when you
-dap-attach to a remote debuggee. It served all memory from its 64 KiB bank-0 mirror. Attach to a remote
65816 and banks 1–255 simply didn’t exist: the memory panel’s :bank 2 would show you bank 0’s bytes wearing
bank 2’s addresses.
ADR 0013 sketched a fix for this: a lazy per-bank page cache, mirroring each bank as it’s touched. v1.11 shipped something much smaller, because the sketch turned out to be over-designed. The actual fix is a branch:
- Bank 0 stays on the fast local mirror. This is the hot path — it’s reconciled by
RefreshMemoryplus the #440dirtyRangesstreaming from v1.6, so the panel updates live during a free-run with no per-frame wire round-trip. Nothing changes. - Banks 1–255 fetch lazily over the wire via
s.client— the remote DAP server has been bank-aware since #505/#507, sofetchMemandfetchDisasmwith a 24-bit address just work.
The test that makes the branch exact rather than approximate: memWindowFor clamps every memory-panel window
within 64 KiB, so a window never straddles a bank boundary. That means addr>>16 != 0 is a per-window
decision, not a per-byte one — one check, then the whole window goes down one path or the other. No cache, no
mirror widening, no cross-bank dirtyRanges protocol extension. Seventeen lines in source_remote.go.
The honest caveat, same one the ADR carries: no remote 65816 host exists in-tree. Every -dap-attach target
chippy ships today is 6502 or NES. So this is correctness-on-paper — the path is right and tested
(TestRemoteSource_ReadMemory_BankOverWire spins up a real dap.Server over a net.Pipe, plants $5A at
$02:8000 through Banked24, and proves the bank-2 read comes over the wire while the bank-0 read stays on
the mirror) — but no user has hit it yet. I shipped it anyway because the alternative was a known-wrong code
path waiting for the first host that embeds the 65816, and “the mirror silently lies about banks” is exactly
the class of bug the v1.10 work existed to kill.
3. The 65816 in the WASM playground
The hosted playground (cmd/chippy-wasm + web/) has had a variant dropdown since it launched — NMOS, 65C02,
NES. The 65816 wasn’t in it, for a reason that’s almost embarrassing given how much work the core took:
jsSetVariant lacked the case, and rebuild() never called SetBus24. The fully-built, corpus-validated,
bank-aware 65816 was invisible to anyone who didn’t clone the repo.
The second omission is the one worth pausing on, because it was a latent crash, not just a missing feature. The
65816 core reads and writes through its 24-bit bus; a VariantW65816 CPU with no SetBus24 call has a nil
bus. v1.10 found and fixed the same latent nil-bus panic in the DAP launch path. The WASM path had it too —
it just could never fire, because the variant case that would have exposed it didn’t exist. Adding the dropdown
option without the Banked24 wiring would have turned “invisible feature” into “instant panic.”
The wiring follows the v1.10 production pattern exactly: rebuild() constructs cpu.NewBanked24(mmio) for the
65816 — bank 0 routed through MMIO so the playground’s readMem/disasm panes and the $F001 text-output
peripheral stay accurate, banks 1–255 backed by real storage — and jsSetVariant accepts 65816, 65c816,
or w65816. The dropdown gains “WDC 65C816.” Existing bank-0 demos run on it immediately, since the 65816
resets into emulation mode and emulation mode is 6502-compatible.
One scoped deferral: the browser panes are bank-0 only. A playground bank selector is #521 — the plumbing exists server-side (well, WASM-side), it’s a UI question, and it can wait for someone to actually write a multi-bank demo.
4. hello816 — a demo ROM that verifies itself
Exposing the chip in the playground raised the obvious follow-up: a dropdown entry nobody can see doing anything is marketing, not engineering. The playground needed a demo that runs the 65816 as a 65816 — native mode, wide registers — not a 6502 program that happens to boot in emulation mode.
web/demos/hello816.bin is that demo. The source of record is example/hello816.s (ca65 syntax, .p816), and
it’s deliberately a tour of the things a 6502 can’t do:
CLC/XCE— the carry-exchange dance that drops the core out of emulation mode into native,REP #$10/SEP #$20— width control: 16-bit index registers, 8-bit accumulator (one byte per MMIO write),LDA msg,Xwith a 16-bit X — an 8-bit load through a 16-bit indexed loop,STA $F001— the playground’s text-output MMIO,STP— halt the core when the NUL terminator lands.
It prints Hello from the 65816! and stops.
The interesting decision is how the .bin gets built. The obvious answer is “check in the ca65 output,” but
that makes the demo’s correctness depend on a toolchain that isn’t in the repo’s build path — CI doesn’t have
cc65, and a stale or miscompiled binary blob in web/demos/ would fail silently until a user clicked the
dropdown. Instead, example/gen_hello816.go (a //go:build ignore file, run with go run) holds the
hand-assembled machine code as a byte slice, builds the 32 KiB $8000–$FFFF image with all three vectors
pointing at $8000, and — before writing a single byte to disk — executes it on chippy’s own 65816 core:
real Banked24 bus, real TextOutput peripheral at $F001, up to 10,000 steps. If the core doesn’t halt via
STP, or the text output isn’t exactly the banner, the generator exits nonzero and writes nothing.
Yes, there’s a circularity: the demo is verified against the emulator it demos. But the core it verifies
against is the one Harte validated for all 256 opcodes with full pin bus traces, so the check isn’t
self-congratulation — it’s a regression tripwire. If a future core change breaks XCE or REP or 16-bit
indexed addressing, go run example/gen_hello816.go fails before the demo can ship stale. The .s file keeps
the door open for anyone who’d rather trust ca65: the assembly and linker invocations are in its header
comment.
The last piece is one line of playground JS with outsized UX weight: loadDemo now applies a demo’s declared
variant before loading it. Selecting the hello816 demo switches the playground to the 65816 automatically —
without that, the demo dropdown and the variant dropdown were two controls the user had to keep consistent by
hand, and the failure mode (native-mode 65816 code fed to an NMOS core) decodes as garbage.
What v1.11 closed, and the tail it leaves
| Item | Status |
|---|---|
| 65816 hardware IRQ/NMI (native + emulation vectors) | Fixed (#512) — 6502/CMOS/NES paths untouched |
Remote 65816 banks over -dap-attach |
Shipped (#516) — bank 0 on mirror, banks 1–255 over the wire |
| 65816 in the hosted WASM playground | Shipped (#514) — Banked24 wired, latent nil-bus closed |
| Native-mode playground demo | Shipped (#515) — self-verifying generator, auto variant switch |
The deferred tail is small and each item has a reason attached: ABORT vectors need an abort source to exist first (#517/#518), interrupt-entry bus-trace validation needs a reference that Harte doesn’t provide (#519), and the playground bank selector needs a demo that would use it (#521).
The through-line of this release is a pattern I keep relearning: a validated core is not a finished feature. The 65816 passed every opcode test in existence and still couldn’t take an interrupt, couldn’t be inspected past bank 0 over the wire, and couldn’t be run by anyone without a Go toolchain. v1.10 made the chip correct in the large; v1.11 made it reachable — by interrupt handlers, by remote debuggers, by a browser tab. The next cut picks up the interrupt bus-trace question and whatever the epic surfaces next.
Onward.