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,XcomputesD + dp + Xduring an internal cycle that also reads PC-1.indexIO/unfixedAddr— the interesting one. Onabs,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.
PERputs its internal cycle at PC-1;PEAhas 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/COPeach 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.