Skip to content

RustyNES v2.4.4 — "Ignition" (the first RTL, and the reset sequence that corrected our own spec)

Choose a tag to compare

@github-actions github-actions released this 22 Aug 23:13
ecfde9b

v2.4.4 "Ignition" — the first real RTL, and the reset sequence that corrected our own spec

The first SystemVerilog of the programme, unblocked because v2.4.3 settled both risks the plan required answered before any RTL existed: the subset is fitted and the licence is GPL-3.0-or-later.

The emulation core is untouched. No behaviour change to rustynes-{cpu,ppu,apu,mappers,core}, no API movement. AccuracyCoin remains 141/141 (100.00%, RAM decoder) and nestest 0-diff. The RTL lives in the sibling repository, pinned at RustyNES_MiSTer@7f092bd; this release is where the version line and the programme plan record it.


What matches

All 29 aligned records match under the chosen comparator
  (skip-fields: ["scanline", "dot", "frame", "flags"])

Twenty-nine records, compared on cycle, pc, a, x, y, p, s: the twenty-two instructions the test program executes, plus seven of the trailing NOPs that fill the rest of the ROM.

Three counts appear in this release and they are different things — worth stating because two of them were conflated in an earlier draft. Seventeen distinct opcodes are implemented. Twenty-two instruction instances run, because the program repeats DEX/INX/DEY/INY to walk the $00 → $FF → $00 boundary. Twenty-nine records are compared over cycles 0..64.

The reset sequence contributes no record at all: the first record is the first opcode fetch, and it carries cycle 8 — which is how the eight-cycle reset length was established. scanline, dot and frame are skipped because there is no PPU at this rung; they become gates at rung 3, and skipping them is a scope statement rather than a convenience.

The DUT is the third writer of the oracle's CpuBootTrace format, after the oracle itself and scripts/mesen2_cpu_boot_trace.lua. cpu_boot_trace_diff reads it with no modification at all, and --skip-fields already existed — so this rung needed no oracle-side change. That is precisely what replay-rather-than-lockstep was chosen for: the comparison tooling does not know or care that one side is hardware.

The oracle settled a question our own documentation could not

Reset is eight cycles, not seven. The oracle's first record carries cycle 8, so reset occupies 0..7.

docs/cpu-6502.md said both"Reset is a special 7-cycle sequence" in its Reset section, and "runs its 8 cycles" in the v2.0.0 note. The first draft of the RTL implemented seven, from the prose, and diverged on its first record.

Prose that contradicts itself cannot be the spec; an executable oracle can. The document is corrected here, with the reason recorded in place rather than silently edited — a second, independent implementation written from a specification is a way of testing that specification, and this is what it found on day one.

Scope, so the gaps read as decisions

Implemented: the eight-cycle reset and the seventeen single-byte implied opcodes — NOP, six transfers, four inc/dec, six flag operations. All two cycles, no addressing modes. That group is deliberate: it exercises the sequencer, the register file and the N/Z logic with no address arithmetic in the way, so a divergence names one opcode.

Everything else decodes to OP_UNIMPL and halts. It does not behave as a NOP: an unimplemented opcode quietly costing two cycles would desynchronise the trace and report the divergence far from its cause.

Every cycle is a real bus access

No busless internal cycles. Two-cycle implied instructions read the byte after the opcode and discard it. Reset cycles 3–5 are the suppressed pushes, where S decrements but the access stays a read of the stack address.

Modelling those as writes would corrupt $0100-$01FF on a real cartridge, and the cycle count is identical either way — so only a per-cycle bus comparison catches it. That comparison is rung 2.


Three findings worth more than the code

A mutation the test ROM was built to catch came back NOT CAUGHT, and the fault was the ROM. TSX leaves X = $FD, so N=1, Z=0. A TXS that wrongly computes flags from X computes N=1, Z=0the values already there. The wrong answer coincided with the right one and the test was silently inert. Two instructions now land Y at $00 with Z=1, N=0 before TXS; mkrom.py says so at the site, because the pair looks like filler and deleting it disarms the test with no symptom.

A harness bug made every mutation report a catch, including the baseline. cargo run was invoked from the wrong repository and failed identically every time. Only the baseline control made it visible — without it, four failed invocations would have read as four successes. A positive control alone is satisfiable by a comparison that always agrees; so is a negative one.

ADR 0037 nearly eroded without a symptom. The first draft put observation ports (pc_o, a_o, …) on the synthesisable module. Unconnected outputs optimise away identically, so nothing visibly breaks — which is exactly how that rule erodes, and "it optimises away anyway" is how erosion sounds. Observation now lives in tb/cpu6502_cosim.sv, reached by hierarchical reference, and the synthesisable module is precisely what Quartus will see.

Removing those ports left two signals written-but-unread. unimpl was deletedST_HALT already carries that fact, and two registers holding one truth is how they come to disagree. p took a narrow, dated lint waiver: nothing in the implied group branches on a flag, which is a true statement about this release's scope rather than a defect. (It is retired in v2.4.5.)

Also removed: a FLAG_B constant. Bit 4 is not a register bit on the 6502 — it exists only in the copy of P pushed to the stack — and declaring a constant for it invites an implementation that stores it.

Mutations

mutation result
baseline MATCH
TXS wrongly routed through the N/Z path DIVERGE
reset back to 7 cycles DIVERGE
INX increments by 2 DIVERGE
reset decrements S by 2 instead of 3 DIVERGE
restored MATCH

What CI does and does not check

The sibling repository gains cpu-smoke, and its workflow step name says it is not the accuracy gate — because it cannot be. The oracle's golden is not vendored there: it is reproducible from a pinned oracle commit, and a second copy would be a drifting copy. The step asserts only that the core builds and runs the ROM to completion without halting.

The accuracy comparison is make -C tb cpu-gate GOLDEN=…. Automating it needs golden fetching from a pinned commit, which is not built yet and is not pretended to be.

Provenance

Written from public documentation only — the NESdev wiki's 6502 cycle-times, instruction and addressing-mode pages, and this repository's own docs/cpu-6502.md. No reference NES core was opened; none is present in either tree, and CI asserts their absence.

Compatibility

Additive and documentation-only on the RustyNES side. No save-state, movie, netplay, or public-API change in any shipped or default-build package; .rns and .rnm are unchanged.