The last core catches up

v1.7 shipped the WDC 65C816 core — a second interpreter (step816), a 24-bit bus, width-aware registers, all 256 opcodes validated against the Tom Harte 65816 corpus for final state and cycle count. v1.8 was the NES accuracy tail: the DmaReadBus seam and the two DMC-DMA steal-timing fixes that finally converged dma_2007_read against a MesenCE cycle trace — plus, tucked under the same tag, the first chunk of the work this post covers.

But there was an asymmetry sitting in the test matrix that I’d been carrying since v1.7. The 6502 core has a per-cycle bus-trace test (TestHarte6502BusTrace, the v1.5/v1.6 arc that ended with 238/238 bus-exact). The 65C02 core has one (TestHarte65C02BusTrace, the v1.7 deferral that landed). The 65816 — the newest, largest, most cycle-diverse core in the library — was validated on what it computed and how long it took, but not on what it drove onto the bus while computing it.

v1.9 closes that gap. It’s a narrow release — one decision, delivered in four chunks plus a stinger — but it’s the deepest single accuracy pass chippy has had. By the end, every 65816 opcode is per-cycle bus-exact in both emulation and native mode, and the trace validates something the 6502 and 65C02 traces never could: the full eight-bit pin string on every cycle.

1. The gap: cycles counted, not emitted

The v1.7 core computed cycle counts abstractly. An opcode kernel in step816 looked like cyc := 2; cyc += extras — start from the base, add the width penalty, the DL≠0 penalty, the page-cross penalty, return the total. The functional accesses (operand fetches, data reads/writes) went through read24/write24 and hit the bus. The internal cycles — the direct-page add, the index-carry fixup, the RMW modify cycle, the stack push setup, the branch-taken bubble — were numbers in an accumulator. They took time but touched nothing.

Real silicon doesn’t work that way. The 65816 drives the address bus on every cycle, including the internal ones — it just deasserts the VDA/VPA “this address is valid” pins so the system knows to ignore it. The Tom Harte 65816 corpus records this: each test case carries a cycle list of [addr, value, pin-string] triples, one per cycle, dummy cycles included. Chippy’s cycle counts matched the length of those lists. Whether the addresses matched, nobody had checked.

Why care? The same reason as the v1.6 branch dummy-read fix: reads have side effects on real buses. A dummy read at the wrong address against MMIO can trigger the wrong thing. If the trace is wrong, some host someday debugs a phantom register access back to me. Better to pin it now, while the corpus is wired and the core is fresh.

2. The harness: busRecorder816

The test is TestHarte65816BusTrace (#495), the 16-bit/24-bit sibling of TestHarte65C02BusTrace. The recorder is a sparse 24-bit memory that logs every Read24/Write24:

func (b *busRecorder816) Read24(a uint32) byte {
    a &= 0xFFFFFF
    v := b.ram[a]
    b.trace = append(b.trace, busCycle816{int(a), int(v), b.cpu.pinString(false)})
    return v
}

harte816BusDiff walks chippy’s recorded accesses against the corpus cycle list. Two parsing details worth writing down: the corpus pin string’s index-3 character is w for a write (else read), and a null data value marks an internal cycle whose byte is a don’t-care — the corpus knows the bus value on a dummy cycle is whatever happens to be floating, so it wildcards it. The diff matches address and read/write strictly and treats null values as pass.

The corpus is 512 files — 256 opcodes × emulation/native — pinned at commit dff67125, same as the v1.7 state harness. Same loader, same cache, one more CI job.

3. Chunk 1: io816 and the easy opcodes

I split the work into four chunks mirroring how the core itself was built in v1.7. Chunk 1 is the register/flag/transfer/immediate opcodes — the ones whose only non-fetch cycle is a single internal bubble. (Honest ledger note: chunk 1 actually slipped in under the v1.8.0 tag — it’s the decision that opened this cycle. Chunks 2-4 and the pin work are the v1.9 cut. I’m telling the whole arc here because splitting one subsystem’s story across two posts serves nobody.)

The mechanism is one helper:

// io816 performs one internal (dummy) cycle: the 65816 drives the bus with a
// dummy read of PBR:PC.

INX is fetch + one io816. Immediate ops were already exact — every one of their cycles is a real read. The two that weren’t boring: XBA emits two internal cycles (the byte swap takes an extra bubble), and SEP/REP re-read the operand address (PC-1) for their internal cycle rather than PC — the chip stalls staring at the mask byte it just fetched.

State and count untouched: io816 changes no register and the counts were already right. That invariant holds across all four chunks — TestHarte65816 passed before and after every one.

4. Chunk 2: addressing modes, and the un-carry-corrected address

The addressing-mode chunk is where the internal cycles stop being “dummy read at PC” and start having specific correct addresses. Three helpers in addr816.go:

  • dpIO — the direct-page +1 penalty. When the D register’s low byte is nonzero, every direct-page resolution pays one extra cycle, and that cycle is a dummy read at PC-1 (the last operand byte), not at the direct-page address.
  • ioPC1 — the dp-index and stack-relative adds. LDA dp,X computes D + dp + X during an internal cycle that also reads PC-1.
  • indexIO / unfixedAddr — the interesting one. On abs,X, abs,Y, and (dp),Y, the index-cross cycle drives the un-carry-corrected address: base high byte glued to the post-add low byte, before the carry into the high byte is applied. This is the 16-bit echo of the NMOS 6502 page-cross dummy read that v1.6 fixed for branches. Same silicon lineage, same quirk, one chip generation later.

When the cross cycle fires also matters: reads pay it on an actual page cross or whenever the index is 16-bit (crossLoad); writes and RMW ops pay it always (crossStore). The corpus is unambiguous about the asymmetry.

And one genuine oddball: (sr),Y — stack-relative indirect indexed. Its +Y add cycle doesn’t re-read PC-1 like everything else. It re-reads the pointer high byte’s address — the last stack-relative byte it fetched. No documentation I have states this; the corpus does, on every case. Pinned empirically, documented at the call site, exactly like the v1.7 emulation-mode wrap quirks.

5. Chunk 3: RMW, stack, control flow — where the modes disagree

Chunk 3 is the RMW, stack, and control-flow opcodes, and it contains my favorite finding of the release: the RMW modify cycle is mode-dependent.

Between the read of the old value and the write of the new one, the 65816 spends a cycle doing the actual modify. In emulation mode, that cycle is a dummy write of the original value back to the same address — the NMOS 6502’s famous double-write behavior, preserved for compatibility. In native mode, it’s a dummy read. Same opcode, same address, different bus direction depending on the E flag. And 16-bit RMW writes the result high byte first, then low — reverse of what you’d guess.

The rest of the chunk, compressed:

  • Accumulator RMW (INC A, ASL A, …): one internal cycle at PC.
  • Stack pushes take one internal cycle before the write; pulls take two before the read. PER puts its internal cycle at PC-1; PEA has none at all (it’s effectively an immediate 16-bit push).
  • Taken branches add an internal cycle at PC-1, plus a second on an emulation-mode page cross — native mode doesn’t pay the cross penalty.
  • JSR/JSL/RTS/RTL/RTI/BRK/COP each emit their full interleaved sequence: JSR/JSL push the return address mid-operand-fetch (the JSRABS lesson from v1.6, now in 24-bit), BRK/COP read their signature byte, vector reads land where the silicon puts them, and RTS keeps its trailing internal cycle.

6. Chunk 4: all 256, and the four permanent skips

Chunk 4 flipped the test from per-chunk opcode lists to iterating all 256 opcodes in both modes, gated by harteBusSkip816. The skip list ended at four entries, none of them accuracy bugs:

var harteBusSkip816 = map[byte]string{
    0xCB: "WAI halts with a no-address (None) bus cycle the recorder can't model",
    0xDB: "STP halts with a no-address (None) bus cycle the recorder can't model",
    0x44: "MVN moves the whole block in one Step (debugger model); the corpus caps each case mid-block",
    0x54: "MVP moves the whole block in one Step (debugger model); the corpus caps each case mid-block",
}

WAI/STP halt with a bus cycle whose address is literally None in the corpus — the recorder has no representation for “no address,” and modeling one for two halt opcodes isn’t worth the type change. MVN/MVP are the v1.7 D6 decision surfacing again: chippy deliberately moves the whole block in one Step for debugger sanity, while the corpus caps each block-move case mid-instruction (a generator artifact). Same reason they’re excluded from the state harness. Documented divergence, not a gap.

7. The stinger: the full pin string

At chunk 4 I’d written “the corpus’s VDA/VPA/E/M/X pin bits are left as a future enhancement” and considered the release done. Then I looked at how close it was and did it anyway (#503).

The Harte 65816 cycle string is eight characters — VDA, VPA, VPB, RWB, E, M, X, MLB. The 6502 and 65C02 corpora only give addr + value + read/write, so those traces can’t validate more. The 65816 corpus records the whole electrical truth of every cycle, and after chunks 1-3 every access in step816 already knew what kind of access it was. The remaining work was tagging.

Each access sets c.busPins via four tiny helpers before touching the bus — pinData (VDA), pinProg (VPA), pinNone (internal), pinVector (VDA+VPB) — and the test-only pinString renders the comparison string. The mapping: the opcode fetch asserts VDA+VPA, operand fetches VPA, data/stack/pointer accesses VDA, internal cycles nothing, BRK/COP/interrupt vector reads VDA+VPB. RMW ops assert MLB (memory lock) across the whole read-modify-write, and the modify cycle itself is MLB-only with VDA off — the cycle where the chip tells the system “I’m mid-atomic, hands off, and this address isn’t valid either.”

The subtle one is E/M/X. Those three status pins are snapshotted at instruction start (busE/busM/busX), not read live — so SEP, REP, and XCE report their old widths across all of their own cycles, which is what the silicon pins actually show and what the corpus asserts. Computing them live fails exactly three opcodes; the snapshot passes all of them.

Net: all 256 opcodes, both modes, every cycle’s address, value, direction, and all eight pin bits, exact — minus the four documented skips. The 65816 trace is now stricter than the 6502 and 65C02 traces, which is a funny place for the youngest core to land.

What v1.9 actually is

Lining it up:

Piece Status
TestHarte65816BusTrace harness Shipped (busRecorder816, null-value wildcards)
Chunk 1: register/flag/transfer/immediate Bus-exact (io816; XBA ×2, SEP/REP at PC-1)
Chunk 2: addressing modes Bus-exact (dpIO, ioPC1, unfixedAddr, the (sr),Y quirk)
Chunk 3: RMW/stack/control-flow Bus-exact (mode-dependent modify cycle)
Chunk 4: all 256 × both modes 252 exact, 4 documented skips
Full pin string VDA/VPA/VPB/RWB/E/M/X/MLB Validated — stricter than the 8-bit traces

Everything is test-code plus internal-cycle emission on the step816 path. The 8-bit cores never touch the new helpers, the state harness is unchanged, the public API didn’t move — a minor bump that happens to contain a few hundred silicon facts.

What’s next

The accuracy ledger for all three cores is now effectively closed: state, cycle count, and per-cycle bus for the 6502, 65C02, and 65816. What the 65816 still carries from v1.7 is a plumbing limitation, not an accuracy one: the production 24-bit bus is Bus24From16, which mirrors every bank onto bank 0. The core forms correct 24-bit addresses — this release proves it per-cycle — and then the bus throws the bank byte away. A program that writes to bank 2 silently scribbles on bank 0.

So the next cut is the bank-aware bus: a real 16 MB backing store, bank 0 still routed through the MMIO/watchpoint chain so peripherals and the TUI panels keep working, and the loader/DAP/TUI paths lifted past the 64 KiB clamps that assume a 16-bit world. That’s a plumbing release the way this was an accuracy release — wide but mechanical, now that the core underneath it is pinned to silicon on every cycle.

The 65816 took three releases to go from “not in the library” to bus-exact-with-pin-flags. The 6502 took the whole road from v0.0.1 to v1.6. Corpus-first development is a hell of a drug.