The release where the 65816 gets its address space

v1.9 was the accuracy capstone for the 65816 core: TestHarte65816BusTrace made every opcode per-cycle bus-exact in both emulation and native mode, down to the eight pin bits (VDA/VPA/VPB/RWB/E/M/X/MLB) of the Tom Harte cycle strings. After that release the core was, cycle for cycle and pin for pin, the most validated CPU chippy ships.

And then there’s the embarrassing part I’d been carrying since the core landed in v1.7: the production 24-bit bus was a lie. The 65816 forms full 24-bit addresses — PBR:PC for instruction fetches, long and [dp] addressing, MVN/MVP block moves across banks — but the bus behind it was Bus24From16, an adapter that truncated every access to uint16(a). Every bank aliased bank 0. A program that jumped to bank 2 executed bank 0’s bytes at the same offset. A store to $03:8000 landed at $0000:8000. The core computed the right 24-bit address on every cycle (v1.9 proved it does) and then the bus threw the top byte away.

The whole tooling stack shared the cap: the loader couldn’t place data past $FFFF, DAP readMemory and writeMemory clamped at 0xFFFF, the TUI memory panel had no concept of a bank. The CPU was pin-perfect against silicon and the rest of the program acted like a 6502 with delusions.

v1.10 is a small, focused cut — two feature PRs (#506, #508), 681 insertions across 23 files — that fixes exactly this: a real bank-aware bus, threaded through the four layers that touch memory, plus cross-bank disassembly so the disasm panel follows the program bank. Five pieces, from the bus outward.

1. Banked24 — the real 24-bit bus

The design question wasn’t “how do I store 16 MB.” It was “where does bank 0 live.” Chippy’s whole debugger stack — MMIO peripherals, watchpoints, the TUI’s memory and disasm panels — hangs off the 16-bit Bus chain (RAM → WBus → MMIO). If the new 24-bit bus had its own private bank 0, every one of those would silently desync from what the CPU actually reads.

So cpu.Banked24 splits the space:

  • Bank 0 routes through the supplied 16-bit Bus — the existing MMIO/watchpoint/RAM chain. Peripherals stay live, watchpoints keep firing, the TUI’s bank-0 panels render the real RAM.
  • Banks 1–255 are backed by a flat 16 MB store — make([]byte, 1<<24), indexed directly by the 24-bit address. The bank-0 slice (mem[:0x10000]) is allocated but never touched; bank 0 always goes through the chain.

The read path is the whole idea in six lines: mask to 0xFFFFFF, and if the address is below 0x10000, delegate to bank0.Read(uint16(a)), otherwise index the flat store.

The flat allocation is a deliberate trade. A sparse map[byte][]byte or lazily-allocated pages would save resident memory for programs that touch two banks, at the cost of a per-access branch-or-hash and GC pressure on the hot path — and the hot path here is every CPU memory cycle. Sixteen megabytes of flat slice on a modern host is nothing; the simplest possible indexing wins.

Bus24From16 — the old mirror — isn’t deleted. It’s demoted to a unit-test adapter. Production no longer uses it anywhere.

Wiring it up found a bonus bug: the sole production wiring site was cmd/chippy/main.go, but the DAP launch path had never called SetBus24 for the 65816 at all — a latent nil-bus panic waiting for the first person to launch a 65816 program through a DAP editor instead of the TUI. Nobody had, which is its own small lesson about which paths your users actually exercise. The launch handler now builds cpu.NewBanked24(mmio) whenever the variant is cpu.VariantW65816.

2. The loader: Intel HEX type-04 records

A 16 MB bus is decorative if nothing can load data into it. Of chippy’s four input formats, three are inherently 16-bit — .bin is a raw blob at an address, .prg carries a 2-byte load address, .o links through ld65 into a 64 KiB layout. They stay bank-0, correctly.

Intel HEX is the one format that already had an answer: the type-04 Extended Linear Address record, which sets the upper 16 bits of a 32-bit base for all subsequent data records. :02000004000290 means “the next data records live in bank 2.” The loader had been ignoring it.

v1.10 wires it: loader.Options gains a Bus24 field, and data records whose effective address lands past bank 0 load through it. Two invariants held from the existing loader design:

  • Bank 0 still loads into ram directly, bypassing MMIO — the long-standing loader invariant that loading a program must not trigger peripheral side effects. Banks 1–255 go through Banked24, which for non-bank-0 addresses is a plain slice write anyway.
  • A beyond-bank-0 record with no Bus24 wired errors instead of aliasing. If you feed a banked HEX file to a 6502 run, you get told, not silently corrupted.

Malformed type-04 records (count != 2) error with a line number. The loader Result now reports the bank of the lowest load address, and cmd/chippy/main.go builds the bus chain before loading so the loader receives the Banked24 — a small ordering change that the old code didn’t need because the old code had nothing to hand over.

3. DAP: readMemory/writeMemory go 24-bit

The DAP server’s memory handlers had two hard caps: handleReadMemory clamped at 0xFFFF, handleWriteMemory at 0x10000. Both now clamp at memMax() — 16 MB when a bank-aware bus is wired, 64 KiB otherwise, so the 8-bit variants see zero behavior change.

Underneath, two helpers carry the same bank-0 split as the bus itself: peekByte24 routes bank 0 through the existing MMIO-aware side-effect-free path (the one that knows not to trigger read-sensitive peripherals) and banks 1–255 through Banked24; writeByte24 writes bank 0 to ram directly and banks up through the bus. Addresses render 6-digit when banked, 4-digit when not — a small thing, but a memory view that prints $028000 next to $8000 and means different bytes is a debugging footgun.

The fiddly part was the in-process case. The TUI attaches its LocalSource in New, before the CPU and bus exist — so AttachConfig gains a Banked field and Server.SetBanked lets the host hand the bus over once it’s built. That’s the same share-the-bus pattern the v1.5 host hooks established: the DAP server never owns hardware, it borrows the host’s.

TestMem_BankAwareReadWrite covers the round trip: write to bank 3 over DAP, read it back, confirm bank 0 didn’t move.

4. TUI: :bank and the $BB:XXXX memory panel

The user-facing end. The memory panel gets one new piece of state — MemViewBank byte on the model — and one new command:

:bank $03

The panel anchors its window at MemViewBank:MemViewAddr, the header becomes Memory (bank $03), and every row address renders $03:XXXX. Edits in a bank > 0 write through Banked24 (m.Banked.Write24(uint32(m.MemViewBank)<<16 | uint32(addr), v)); bank-0 edits stay on the watchpoint chain, so poking bank 0 from the panel still trips a write watchpoint like it always has.

Source.ReadMemory widens its address parameter to uint32 — the Source interface is internal, so this is a compile-time-checked rename-and-widen, not a compatibility dance.

MemViewBank persists in the TUI state file as an additive omitempty field (mem_view_bank), which keeps the state-schema-v1 contract from v1.0 intact: old state files load with bank 0, new fields cost nothing when zero. Same move as v1.6’s Watch.Fields. The schema has survived ten releases of feature growth without a bump, which I’m going to keep bragging about until it breaks.

5. Cross-bank disassembly — DisasmCPUAt and the bankView

Shipping #506 exposed the follow-up immediately (it’s even called out in the commit message as a documented deferral): the memory panel was bank-aware but the disassembly path still read the CPU’s 16-bit c.Bus. A 65816 program executing in bank 2 disassembled bank 0’s bytes at the same offset — plausible-looking, wrong opcodes. Wrong-but-plausible is the worst failure mode a debugger has.

The fix (#508) is small because the decode was never the problem. Opcode tables and the M/X/E-dependent immediate widths are bank-independent; only the byte source carries the bank. So:

  • cpu.DisasmCPUAt and cpu.WalkBackAt are bus-explicit siblings of DisasmCPU/WalkBack — same decode, instruction bytes read through a supplied Bus instead of c.Bus. The originals become one-line wrappers that pass c.Bus.
  • The DAP side introduces bankView: a 16-bit Bus whose Read calls peekByte24(bank | uint32(a)) for a fixed bank, and whose Write is a no-op — disassembly never writes. handleDisassemble parses a 24-bit memory reference, holds the bank constant across the whole window, and keeps all the offset arithmetic in the low 16 bits so operand fetches wrap within the bank the way the chip does.
  • The TUI widens Source.Disassemble to a uint32 anchor; syncDisasm anchors at PBR:PC for the 65816, so the panel follows the program bank live. Rows render $BB:XXXX and the panel title grows a (bank $NN) suffix when PBR ≠ 0.

TestMem_DisassembleCrossBank pins the behavior: plant different bytes at the same offset in bank 0 and bank 2, disassemble with a bank-2 reference, get bank 2’s instructions.

One honest limitation: symbols, the source map, and data ranges stay a bank-0 concept. The cc65 .dbg format addresses a 64 KiB space; there’s nothing to map bank-2 code onto. Bank > 0 lines decode cleanly but carry no symbol or source annotation, and the pinned-scroll anchor uses the current PBR, so you can’t scroll the disasm panel across banks. That’s the same “don’t fake what the debug info can’t say” line I drew in v1.3 with struct overlays, and I’d rather hold it than invent annotations.

What stayed bank-0 on purpose

The scope fence around this release matters as much as the features:

  • MMIO and watchpoints are bank-0-only. That’s chippy’s documented memory model — peripherals live in bank 0, there is no cross-bank MMIO. Banks 1–255 are flat storage, full stop. This is why the bank-0-through- the-16-bit-chain split works at all.
  • Remote 65816 over DAP is out of scope. RemoteSource serves reads from its 64 KiB bank-0 mirror, and no remote 65816 host exists yet to make the work testable against something real.
  • .bin/.prg/.o stay 16-bit formats. Banked loading is HEX-only because HEX is the only format with a standard way to say “bank 2.”

What v1.10 actually closed

Item Status
Bank-0 mirror (Bus24From16) in production Replaced by Banked24; mirror demoted to test adapter
Loader: banks past 0 Intel HEX type-04 → Options.Bus24
DAP readMemory/writeMemory 64 KiB clamp Lifted to memMax() (16 MB banked)
DAP launch path for 65816 Latent nil-Bus24 panic fixed
TUI memory panel :bank N, $BB:XXXX, bank-aware edits
Disassembly in bank ≠ 0 DisasmCPUAt/WalkBackAt + bankView, panel follows PBR

What v1.11 looks like

The 65816 tail keeps tapering. The core is opcode-complete and pin-exact, but the Harte corpus is opcodes-only — it never exercises hardware interrupts, so IRQ/NMI/ABORT handling in both modes is the next correctness gap. After that: extending bank-awareness to the remote DAP path (the RemoteSource mirror deferral above), and exposing the 65816 in the hosted WASM playground.

In the meantime, v1.10 is the release where the 65816 stops being a perfectly-validated core bolted to a 64 KiB fiction. The program bank register means something now. The memory panel can see all sixteen megabytes, the disasm panel follows the code wherever PBR points, and the loader can actually put something there for it to find. Onward.