The release that exists because of one test ROM

v1.7 was the big-surface release: the TUI-via-DAP flip finished, freeze moved beyond RAM, the WASM playground shipped, and the headline was a complete WDC 65C816 core — a second interpreter (step816), a 24-bit bus, and 254 of 256 opcodes state-and-cycle-exact against the Tom Harte 65816 corpus in both emulation and native modes.

v1.8 is the opposite kind of release. It’s small — four code changes — and every one of them exists because of a single NES test ROM: dmc_dma_during_read4, specifically its dma_2007_read sub-test, which has been open as nessy issue #20 for most of nessy’s life. It was the last accuracy ROM nessy had marked knownFail. Closing it took one new public API seam in chippy, two CPU-side timing fixes that each moved a DMA steal by exactly one cycle, and a cycle-by-cycle diff against a headless Mesen build that stayed bit-identical for 62,741 instructions before showing me where I was wrong.

There’s also a fourth item that has nothing to do with DMA: the first chunk of the 65816 per-cycle bus trace, paying down the deferral that ADR 0010 explicitly made when the 65816 core validated state and cycle count but not per-cycle bus activity.

Priority order, as always.

1. DmaReadBus: tagging DMA reads so the host can model the 2A03 glitch

First, the hardware story. On the NES, the DMC channel periodically halts the CPU to fetch a sample byte — the “DMC DMA steal.” If that fetch happens to land while the CPU is mid-read of an internal register ($4000-$401F — the APU and controller ports live there), the 2A03 does something ugly: those addresses are invisible to external chips, so the data bus doesn’t carry a clean value, and the CPU latches an open-bus / internal-register bus conflict instead. If the register was $4016 or $4017, the glitched read clocks the controller shift register an extra time and a button bit gets eaten. Real hardware does this. dmc_dma_during_read4 tests for it.

Chippy’s problem was structural. The DMA loop — ProcessPendingDma, which has handled OAMDMA and DMC-DMA scheduling since v1.5’s NES cycle work — issued bare Bus.Read(addr) calls for everything: halt-cycle dummy reads, the 256 sprite-page reads of an OAMDMA, and the DMC sample fetch. From the host’s side of the bus, a DMC fetch was indistinguishable from an ordinary CPU read. nessy literally could not tell which reads to apply the conflict semantics to. An earlier attempt at handling just the $4015 case died for exactly this reason: no DMA context on the read.

The v1.8 answer is an optional Bus extension:

type DmaKind uint8

const (
    DmaDummyRead DmaKind = iota // halt-cycle or alignment dummy read
    DmaSpriteRead               // OAMDMA source-byte read
    DmaDmcRead                  // the DMC sample fetch
)

type DmaReadBus interface {
    ReadDma(addr uint16, kind DmaKind) byte
}

When the installed bus implements DmaReadBus, ProcessPendingDma routes its reads through ReadDma with the appropriate tag. When it doesn’t, everything falls back to plain Bus.Read, byte-for-byte identical to v1.7 behavior. The type assertion is cached once at SetBus (a dmaBus field, mirroring the existing busTicker cache), so the 256-read sprite loop pays zero per-read type-assert cost.

The design decision worth writing down — it’s D1 in ADR 0011 — is what the CPU doesn’t do. Chippy contributes only the tag. No open-bus latch, no conflict mask, no prevReadValue moved into cpu.CPU. Open-bus state is host-owned: nessy already sees every external read and write and can latch the last bus value without any CPU help, and the exact conflict formula is NES-platform behavior that chippy — which has no NES peripherals — cannot even validate. This is the same host-hook pattern as DMCFetcher, PPURunner, and the per-access hook: the CPU exposes a typed extension point, the consumer supplies the platform-specific behavior. Additive, minor-bump-safe, zero cost for every non-NES variant.

Chippy’s side ships with unit tests: a fake DmaReadBus that asserts the tag on each of the DMA loop’s reads, and a plain-Bus fallback test proving the untagged path is unchanged.

2. The 62,741-instruction diff

With the seam wired and nessy’s side implemented, dma_2007_read still hung. This is where the release earns its blog post.

The ROM is nasty by design. It has no $6000 result shell and no zero-page pass/fail byte — it runs a self-calibrating poll loop that only escapes when the single-byte DMC DMA steals its cycle coincident with the $4015/$2007 read it’s polling. If your steal timing is wrong by even one cycle, the loop doesn’t fail — it never terminates. You can’t diff a result byte against hardware. You have to diff execution.

So I built the oracle: a headless MesenCE core (make core plus the pgohelper harness), with a per-instruction trace hook on NesCpu::Exec, and ran a from-boot (PC, cycle) diff of MesenCE against chippy running the same ROM.

My going hypothesis was some kind of cumulative cycle-parity offset — chippy drifting slowly out of phase across the boot sequence. The diff falsified that immediately, and this is why I love this technique: the two emulators were bit-identical for 62,741 instructions. Same PC, same cycle count, instruction after instruction, through the entire boot and calibration setup. Then they diverged at exactly one DMC steal:

  • MesenCE halted the CPU on a taken branch’s dummy-read cycle — PC $E078, odd cycle, 4-cycle steal.
  • Chippy halted one cycle later, at the branch’s target — PC $E062, even cycle, 3-cycle steal.

One cycle late, and because the steal length depends on cycle parity, one cycle shorter. And here’s the mechanism that turns a one-cycle error into an infinite loop: with a constant 3-cycle steal, the poll loop’s period gets pinned to exactly the DMC sample period — 8 samples × 428 cycles = 3424 cycles per iteration. Zero phase drift. The steal lands at the same loop offset every iteration, forever, and never walks onto the $2007 read the calibration needs. The ROM’s escape condition depends on the 3-vs-4-cycle steal alternation creating phase drift; chippy’s timing error deleted the drift.

The root cause was a polling asymmetry. busRead — the real-read path — drains a pending DMA halt at its top. idle() — the path for dummy and internal cycles, including the taken-branch dummy read (branch() calls c.idle(c.PC)) — did not. Mesen polls ProcessPendingDma on every CPU cycle; chippy was skipping the poll on idle cycles, so a halt armed going into an idle cycle silently waited for the next real read.

The fix is the smallest change in the release: idle() polls ProcessPendingDma at the top when needHalt is set, exactly as busRead already did. It’s gated by c.nesCycle (VariantNES with a bus ticker) and needHalt, so the non-NES variants never touch it — Klaus, both Tom Harte state suites, both per-cycle bus traces, and cpu_interrupts all run the identical code they ran in v1.7, and the full suite stays green under -count=1. TestIdle_DrainsPendingDmaHalt pins the behavior: a halt armed into an idle cycle drains there, not one cycle later.

3. getCycle parity: the stale-counter bug hiding behind the first bug

Fixing the halt cycle exposed a second, quieter bug in the same code path.

ProcessPendingDma uses a get/put cycle-parity check — Mesen calls it getCycle — to decide whether the DMC read fires on the current cycle or eats an alignment cycle first. That decision is the 3-vs-4-cycle steal length. Chippy computed it as c.Cycles & 1.

The problem: c.Cycles only advances at the instruction boundary. exec.go accumulates the in-flight instruction’s cycles in instrCycles and folds them into c.Cycles after the opcode completes. So mid-instruction, c.Cycles is stale by instrCycles, and any steal landing mid-instruction computes its parity from a counter that’s up to seven cycles behind reality. Mesen’s _cycleCount ticks every cycle, so its parity check is always true.

The fix is one expression:

getCycle := (c.Cycles+uint64(c.instrCycles))&1 == 0

What’s instructive is why this bug survived every prior test. Steals that land on an opcode fetch have instrCycles == 0 — the stale counter and the true counter agree — and that’s where cpu_interrupts_v2 and apu_test happen to steal. The bug only bites a steal landing on an operand read, instrCycles > 0. The BIT $4015 poll at the heart of dma_2007_read is exactly that case. Two test suites passing for years, and the entire time the parity computation was wrong for a case they never exercised.

TestProcessPendingDma_StealParityUsesInstrCycles pins it now: at a fixed c.Cycles, the steal length toggles between 3 and 4 as instrCycles parity flips.

With D2 and D3 both in, dma_2007_read escapes its calibration loop and reaches the same terminal state as MesenCE — a deliberate hang at $E72F, which is the ROM’s way of saying “pass.”

4. Downstream: nessy retires its last knownFail

The convergence itself happened in nessy, and it’s worth sketching because it shows what the D1 seam was for.

nessy’s side is a dmaBus wrapper around chippy’s MMIO: Read/Write latch the external open-bus value on every access, and ReadDma ports MesenCE’s NesCpu::ProcessDmaRead logic. The halt-cycle DmaDummyRead captures the CPU’s pending read address — so a dummy read of $2007 still increments PPUADDR, which is precisely the side effect the ROM’s name refers to. A DmaDmcRead whose halt landed in $4000-$401F gets redirected to $4000 | (dmcAddr & $1F): the internal-register bus conflict, including the $4016/$4017 controller bit-deletion. Off the DMA path it’s a pure pass-through — normal gameplay reads are untouched.

The accuracy harness needed a new grader too: since the ROM has no result byte, runTerminalLoop watches for the $E72F-$E735 terminal hang — validated cycle-for-cycle against the MesenCE reference — and treats reaching it as a pass. With that, nessy bumped its dependency to chippy v1.8.0, dropped the temporary local-path replace from go.mod, retired the knownFail marker, and closed #20. That was the last open accuracy ROM in nessy’s suite.

The split of responsibilities held exactly as ADR 0011 drew it: chippy shipped the tag and the cycle-correct steal timing; nessy shipped the open-bus latch and the conflict formula; neither had to know the other’s half beyond the three-value DmaKind enum.

5. 65816 per-cycle bus trace, chunk 1

The non-DMA item. ADR 0010’s D4 was explicit that the 65816 Harte harness validated final state plus cycle count, deferring the per-cycle bus trace “mirroring how the 6502/65C02 bus traces followed their state suites.” The 6502 went state-first (v1.5) then bus-exact (v1.6); the 65C02 followed the same two-step. v1.8 starts the 65816’s second step.

TestHarte65816BusTrace is the 24-bit sibling of TestHarte65C02BusTrace. A busRecorder816 records every Read24/Write24 as [addr, value, rw]; harte816BusDiff parses the corpus’s pin string (index 3 w means write) and treats a null value — an internal cycle — as a don’t-care wildcard. A harteBusSkip816 map gates opcodes whose traces aren’t modeled yet, same pattern as the 6502-era skip lists.

Chunk 1 covers the 40 register/flag/transfer/immediate opcodes — the ones with no data-memory addressing — in both emulation and native modes. Making them bus-exact needed a new io816() helper in step816: on an internal cycle, the real 65816 drives the bus with a dummy read of PBR:PC, so the trace has to show it. Chippy was counting those cycles but not emitting them. Two silicon quirks fell out of the corpus along the way: XBA emits two internal cycles, and SEP/REP re-read the operand address (PC-1) on their internal cycle rather than PC. The immediate-mode ops needed nothing — all their cycles were already real reads.

All 40 chunk-1 opcodes are per-cycle bus-exact in both modes, and harteBusSkip816 is empty. The state-and-count suite (TestHarte65816, all 256 opcodes) is unaffected — io816 changes no state and no cycle totals. Chunks 2-4 — the addressing-mode ALU ops, RMW/stack/control-flow, and MVN/MVP with pin flags — are the future cuts.

What v1.8 actually is

Item Result
DmaReadBus seam (ReadDma + DmaKind) Shipped; additive, zero cost when unimplemented
idle() drains pending DMA halt Steal lands on the dummy-read cycle, matching Mesen
getCycle true-cycle parity 3-vs-4 steal length correct on operand-read steals
dma_2007_read / nessy #20 Converged; nessy’s last knownFail retired
65816 per-cycle bus trace Chunk 1: 40 opcodes bus-exact, both modes

Two of the three DMA changes are one-expression fixes. That’s not a coincidence; it’s the shape accuracy work takes at this depth. The expensive part was never the fix — it was building a headless MesenCE reference, hooking NesCpu::Exec, and running a from-boot diff patient enough to stay bit-identical for 62,741 instructions before telling me the truth. The “cumulative drift” hypothesis I walked in with was wrong, and the diff proved it wrong before I spent a week fixing the wrong thing. Cheap hypotheses, expensive instrumentation, one-line fixes: that ordering is the whole method.

The other thing v1.8 confirms is that the host-hook architecture keeps paying. The 2A03 open-bus glitch is deeply NES-specific behavior, and none of it lives in chippy — no NES conflict formula, no open-bus latch, no peripheral knowledge. The CPU grew a three-value enum and a five-line dispatch, and the weirdness stayed in the project that can actually test it against hardware behavior.

Next on the chippy side: 65816 bus-trace chunks 2 through 4, and whatever the corpus finds when the RMW and control-flow traces come online — every bus-trace pass so far has flushed out at least one dummy-read address I had wrong, and I don’t expect the 65816 to be the exception.