Skip to content

RustyNES v2.6.16 — "Interlock" (the arbiter's numbers describe a stimulus, not the console)

Latest

Choose a tag to compare

@github-actions github-actions released this 05 Sep 03:22
50426e6

The plan for this release was to build a slot-scheduled arbiter, because tb/sdram_arb_main.cpp has reported the same two numbers since v2.6.13 — the CHR fetch with seven cycles of margin, the CPU short of its deadline by four. It was not built. Measuring what those numbers are numbers about refuted the premise, and that measurement is the release.

No Rust changed. The emulation core is untouched, so AccuracyCoin 141/141 (RAM decoder) and nestest 0-diff hold by construction.

The gate's worst case is a collision the console cannot produce

That gate issues a CHR and a PRG request on the same cycle, so the loser waits a whole access. A new probe reports separation, latency and over-budget counts at the arbiter's own ports on an off-die build. Across ppurender, ppusprender and mapper4mmc3064, all three agreeing on the worst values, over fifteen million SDRAM cycles: zero coincidences, a minimum separation of two cycles, and the separations bimodal — PRG lands 2–4 cycles behind CHR, CHR lands 12–13 behind PRG — because both requesters are locked to one master clock at a fixed alignment.

So the published pair is wrong about the console in both directions at once. The gate says the fetch has seven cycles of margin and the CPU misses by four. The console says the CPU meets its deadline — worst 24 against 24, on all 240,303 requests, at zero margin — and the fetch reaches 23 against the arbiter's derived 22, exceeding it on 30 of 385,539 fetches on ppurender, about eight in a hundred thousand.

docs/sdram.md has asserted the distinction between stimulus and console since v2.6.13. Nothing had ever put a number on either side of it, so the block-level figures were quoted as though they described the console — including in that file, and in this repository's to-dos/mister/IMPLEMENTATION_PLAN.md.

The fetch's exceedance is not a defect, and the control is why

nes_top's CHR_LAT adds whole master clocks — four SDRAM cycles each — to the CHR byte, so raising it off-die asks the console directly how much spare the fetch has at its real worst case. ppurender matches the oracle on all 61,440 pixels at +0, +1 and +2, and fails at +3 (233 pixels) and +4 (32,187).

The failure is the control: four consecutive passes look identical to a knob that never reached the compiler. So at a worst case of 23 the fetch has at least eight and fewer than twelve spare cycles, and the derived budget of 22 understates the real tolerance by at least nine. A derivation is not wrong to be conservative; it is wrong to be quoted as a measurement.

Why the arbiter was not rewritten, and why it is still worth writing

The shortfall was decomposed first. Running the same PRG cadence with CHR silent gives worst PRG latency 15 against the same budget of 24 — the access itself has nine cycles of margin and every cycle of the synthetic shortfall is contention. That is what would make a schedule the right lever, and the console's own phase already separates the requesters.

It remains worth building for a reason the plan did not give: the CPU meets its deadline at zero margin, so one added cycle anywhere on that path breaks it. That is the shape v2.6.13 recorded when a control and the deployed code differed by a single cycle. tb/sdram_latency_gate.py fails the moment it happens, which is the part that could not wait for a redesign.

A prediction refuted, kept with the measurement that refuted it

The gate drove both streams from one 64-byte window — every access a row hit, no alternation able to force a PRECHARGE — a geometry the shipped map cannot produce, since PRG and CHR sit four mebibytes apart in the same bank. Moving the stimulus onto the real bases was expected to make the worst case worse, and left both figures byte-identical. The worst case is not an alternation at all: refresh precharges every bank, so the access after one is a miss whatever the addresses are. Geometry moves the average, not the bound.

The address map had four copies in three languages

cart_sdram.sv's parameter defaults, emu.sv, tb/cpu6502_cosim.sv, and the new tb/cart_map.h. A divergence would point the $2007 port at a different region of the part than fetches use — silently, and only on a banked board. That is v2.6.13's defect shape exactly, and it cost a release to find the first time. tb/check_cart_map.py gates all six sites in CI, and its self-test's most important case is a pattern that matches nothing, because an absent match and an agreeing file are otherwise indistinguishable.

Gates

Two new gates, eight mutations, all CAUGHT — including an on-die build, which sdram_latency_gate.py refuses rather than reporting a maximum over zero requests, and gating CHR at the derived 22, which fails and is the finding stated as a test. That refusal also makes the off-die configuration self-verifying: its PASS is evidence about what ran, where the stamp file and symbol count it replaces described what was built — the distinction v2.6.13 lost a whole result to.

A ninth mutation came from review, and it is the one that matters most. The gate ran the console and parsed its report without ever checking the subprocess return code — and cpu_main.cpp prints that report near the end and then still has two ways to exit non-zero after it, a broken eval_ovf_cnt invariant and return halted ? 1 : 0. So a console that jammed on an unknown opcode would print a well-formed report of the latencies it saw before jamming, and the gate would find them inside budget and pass. That is worse than a missing check usually is, because the numbers would be real — nothing about them would look wrong to a reader. Fixed, and demonstrated: without the check, a stub printing an in-budget report and exiting 1 yields PASS: the off-die memory system meets both deadlines on the console.

The eight mutations I wrote covered the thresholds, the vacuity guard and the parse guard. Not one asked what happens when the numbers are fine and the run is not.

Suite: 147 passed, 0 failed on the die; 148 passed, 0 failed off-die. The difference is exactly the new gate, which reports N/A rather than SKIP on the die, because a gate on the SDRAM path cannot exist in a build with no SDRAM and "skipped" reads as something that could have run.

The bitstream is byte-identical, and this time it was rebuilt rather than renamed

v2.6.15 withdrew v2.6.14's "byte-identical" claim on the grounds that it was achieved by renaming a file, not by rebuilding one. This release earns it: a full Quartus compile of the v2.6.16 tree produces md5 7346a490ce3f4ab9e4fa65e252f00d13, exactly the artifact v2.6.15 shipped, and git status releases/ is empty.

That is the expected result and it is worth stating why it is evidence rather than a formality. The only RTL this release touches is comments — measured, not assumed: across rtl/, sys/, the .qsf, the .sdc and files.qip, the diff is 85 insertions and 10 deletions with zero non-comment lines. Identical synthesis inputs plus the pinned seed and effort settings give an identical placement, and the timing report agrees at all four corners: worst setup +0.473 ns (Slow -40C), worst hold +0.078 ns (Fast -40C) — the same pair as v2.6.15.

A fitter seed sweep was considered and declined, with that as the reason. SEED re-rolls placement for a given netlist; v2.6.10 established that at full effort all six seeds close, so the effort settings move the whole distribution across zero and the seed only picks where in it you land. An unchanged netlist re-measures the same distribution and returns the same seed. The byte-identical artifact makes that provable rather than argued.

It is flagged for the next release that changes the netlist, though, and the slot-scheduled arbiter is exactly that: it adds logic to a design whose hold margin is +0.078 ns. The correction to the instinct is also on the record — v2.6.7 recovered 0.769 ns of which 0.595 came from converting work RAM to an M10K and only 0.174 from the seed. The lever is utilisation, not the seed, and a re-seed is a symptom that the design is riding the edge.

BUILD_DATE is 20260904 and matches the filename datecode, which scripts/release-rbf.sh refuses to publish without — the check v2.6.15 added after finding that the build date is a constant in the design, so reproducibility is day-scoped.

Not claimed

No hardware has run the bitstream. Rung 6 stays open. The palette, the video timing constants, the audio's absolute level and band-limiting remain unverified by construction, downstream of every gate in the ladder. The off-die configuration is still not shipped — what ships is on-die, and this release does not change that.