Skip to content

RustyNES v2.5.6 — "Vestige" (sprite evaluation closes, and a byte index that outlives the walk that set it)

Choose a tag to compare

@github-actions github-actions released this 24 Aug 00:26
afed6fc

v2.5.6 "Vestige"

Sprite evaluation closes, and a byte index that outlives the walk that set it.

A vestige is what remains after the thing that made it is gone. That is exactly the residual address the 2C02 leaves behind when evaluation ends — and getting it wrong is what kept this gate red.

The plan named sprite evaluation the hardest single item in the programme. It was, for a reason nothing in the plan anticipated.

The gate

make -C tb cpu-bus-gate compares what a CPU read of $2004 returns while rendering, cycle for cycle:

All 59993 overlapping cycles match on pc, bus_addr, bus_data and bus_access

Mutations: nine of nine behavioural mutants CAUGHT, baseline verified passing first, with two further mutants proved inert by byte comparison rather than excused.

mutation outcome
phase 4 pins m to 0 — the wrong fix, kept as a mutant CAUGHT
phase 4 does not advance n CAUGHT
phase 4's write dot presents OAM instead of secondary OAM CAUGHT
the overflow search never halts CAUGHT
the overflow halt does not clear m CAUGHT
the overflow search increments n only — the sane version CAUGHT
attribute bytes are not masked with $E3 CAUGHT
the 3a tail reads two entries instead of three CAUGHT
the overflow halt fires one step early CAUGHT
eval_ovf_cnt is not reset per line INERT — unreachable
the hit does not set sprite_overflow INERT — out of scope

The gate observed a model the diagnostic did not expose

ppu-state-trace carries sprite_eval_n, sprite_eval_m and sprite_eval_found. Those belong to the oracle's real evaluation FSM.

What $2004 returns does not come from that machine at all. It comes from tick_oam_bus, a second, side-effect-free model kept alongside it — and the gate reads the second one.

So every counter alignment made against the traced fields was an alignment to the wrong model. Two of them were made, and both were reverted as regressions:

change result
align found's increment with the traced field 41 → 112
halt the walk when the overflow flag is set 39 → 68

Both were faithful to sprite_eval_*. Both moved the DUT away from the observable.

Adding oam_bus_copybuffer to PpuStateRecord at schema 2 is what made every later measurement valid — filled from oam_data_bus_observed(), which applies the same validity guard as the $2004 register read, so the trace cannot report a stale secondary-OAM byte off a rendering scanline. It immediately showed the FSM sitting frozen at n = 34, m = 2, done = 1 while the bus kept walking for the rest of the line.

A change rejected against a broken baseline is not a rejected change

The overflow halt was recorded as a regression at 39 → 68. It had been measured while phase 4 was itself mis-implemented, so it moved the DUT into a broken destination.

Re-measured against a correct phase 4, the same idea is right — and the measurement says exactly where to stop. Both sides walk identically to dot 176, reaching n = 34, m = 2. Counting back from the in-range hit at (30, 2) gives (31,3) (32,0) (33,1) (34,2): the hit plus the three entries the wiki's step 3a owes. It is worth 28 of the 39.

The final fix is the opposite of the obvious one

The wiki says phase 4 copies OAM[n][0]. Pinning the byte index to 0 is right on scanline 55 and wrong on scanline 58.

Phase 4 advances only the high half of the address. The low half keeps whatever ended the walk — and three of the four paths that finish evaluation clear it, while the sprite-eval bug path does not. Line 55 ends via the overflow count and walks at m = 0; line 58 ends via the bug wrap and walks at m = 3.

eval_hold, a latched byte two earlier findings had been built on, became dead the moment the write dot presented sec_oam[sec_idx]. Verilator's UNUSEDSIGNAL said so before the gate ran.

Two inert mutants, for two different reasons — and a retraction

A first pass reported the eval_ovf_cnt mutation as an uncaught defect the stimulus could not reach, and kept the reset as a fix. That was wrong. The stale count it guards against cannot occur at all:

  • a probe reporting eval_ovf_cnt != 0 at the window end fires zero times across three ROMs, while its inverted predicate fires 528 — so the zero is a result, not a silence;
  • removing the reset yields a byte-identical bus and CPU trace;
  • the bound is structural: 96 decide steps in the window, at most 8x4 + 56 = 88 to the latest possible hit, consumed 3 steps later at 91. Five steps of margin, independent of the ROM.

The reset is kept as defensive code, not as a fix — the oracle clears its counterpart at cycle 65, and five steps is thin enough that an 8x16 mode or a window change could close it.

And the bound is no longer a measurement in a document. The probe was promoted into an always-on testbench invariant: every gate run now checks eval_ovf_cnt at each window end, exits non-zero if it is ever non-zero, and prints its denominator — 0 violations in 528 window ends — because a bare "0 violations" is indistinguishable from a check that never executed. Across the suite: sprite 528, render 1,048, fetch 792, nestest 524, all zero. Demonstrated firing in both directions, on a mutant that removes the count's decrement.

It lives in the testbench and deliberately not in rtl/: a SystemVerilog assert property would put a simulation-only construct into a module that must also pass Quartus, which is what ADR 0037's no-DPI-C decision exists to keep out.

So the eighth mutation cannot be made to catch anything — the state is unreachable — but the reason it cannot is now enforced on every run. If a future change makes the count survive, the suite says so on the commit that does it.

The second inert mutant is inert for a different reason, and separating them is the point. sprite_overflow reaches the CPU only through $2002, and this gate reads $2004. ppusprite.nes contains zero LDA $2002 and zero BIT $2002 against sixteen LDA $2004 per iteration — scanned from the PRG opcode bytes, not assumed. That is the declared compare surface appearing as a measurement rather than a promise; the overflow flag is v2.5.7's subject.

Both were classified by byte comparison, not by reading the code. NOT CAUGHT has meant four different things in this project — the gate is blind, the stimulus is blind, the mutant was a no-op, or the mutant never built — and only a comparison separates the third from the first two.

Verified

  • AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diffrustynes-ppu changed, so both were re-run rather than asserted.
  • Workspace tests: 0 failures. rustynes-core still builds for thumbv7em-none-eabihf.
  • Every earlier rung re-run: ppu-render-gate all 61,440 pixels match, ppu-fetch-gate all 6,247 compared fetches match, nestest-gate all 59,554 overlapping cycles match, check_rtl_subset.py clean.

What this does not pin

Sprite rendering — priority, sprite-0 hit, the overflow flag reaching $2002 — is v2.5.7. VBlank and NMI timing is v2.5.8, and rung 3 closes there. There is still no sys/, no APU, no mapper and no .rbf.