Skip to content

RustyNES v2.3.3 — "Cadence" (display pacing + the run-ahead throttle)

Latest

Choose a tag to compare

@github-actions github-actions released this 14 Aug 13:16
b5c7d57

RustyNES v2.3.3 "Cadence" is the display-pacing release. It closes the one measured artefact whose signature matches the long-standing report that the picture "shudders" — and the cause turned out to be a stale statistic rather than a bad threshold.

No emulation-core changes. Every change is frontend or output-only, so AccuracyCoin holds at exactly 141/141 and nestest is 0-diff — verified after the fact, not asserted by construction.

What was actually wrong

The run-ahead throttle was oscillating: at run_ahead = 2 it changed depth 6-7 times per 24 s, and every depth change displaces the displayed frame by the run-ahead depth. That is the picture jumping forward and back.

Six mechanisms were proposed and falsified by measurement before this one surfaced. The throttle is gated to one depth change per median window — but the gate counted 120 frames, and the ring feeding it holds 600:

value
gate, as written 120 frames
ring capacity (perf::WINDOW) 600 samples
where the p50 sits index 300 of 600
turnover after 120 frames 20%

A p50 at index 300 cannot be moved off the previous depth by a fifth of a window. Per-evaluation logging showed transitions arriving in immediate pairs sharing a median to three decimal places:

THR check depth=2 steps=0 cost=12.958 engage_band=12.479 engage=true
THR check depth=1 steps=1 cost=12.958 engage_band=12.479 engage=true

The second line of each pair is decided on a measurement of the depth the first line just left. The release predicate was never wrong — its input was.

The fix, and what it measures

Expressing the gate in terms of the ring it reads, so the two cannot drift apart again:

before after
depth changes / 24 s (run_ahead = 2) 6-7 1
spurious releases 2 0
frames held for the wrong duration 1.31%

At depth 1 the honest median now reads 8.670 ms, matching the independently measured depth-1 cost of 8.671 ms, predicts 13.005 ms one depth up, and correctly declines to release.

Converging without waiting

The corrected window is right and it cost convergence speed: run_ahead = 3 reached a sustainable depth but spent ~12 s over budget getting there, one full window per step.

The naive asymmetry — engage on less evidence — is a bug, not a fix: at depth 3 the cost exceeds the band at every depth, so it would cascade to depth 0 and discard the feature. Instead the engage arm computes the next depth's cost from the per-frame-linear model, stepping while the prediction is still over budget, and stops where it fits. Releasing is deliberately unchanged and still demands a full window and a real measurement.

arm converge frames wrong underruns
previous 12.12 s 4.82% 0.60
shipped 2.80 s 2.24% 0.20
ring-reset (rejected) 4.00 s 2.02% 1.00, every capture

5/5 paired Latin-square rounds favour the adopted arm on both metrics, exact one-sided sign p = 0.0312 — five rounds rather than three because the exact paired test floors at 1/2^N.

The apparatus the diagnosis needed

  • Compositor refresh from wp_presentation. winit's current_monitor() answers None for an entire session on compositors that advertise no wl_output, so the refresh is read from the compositor's own presented event.
  • Divisor-based display-sync — 1:1-only excluded every 120/144 Hz panel.
  • A validity gate that fails closed. An occluded window makes the compositor discard every frame, so the refresh estimator never settles and the session silently rides the wall-clock fallback — worth 61-147 dropped frames per 45 s against 6-15. PresentationClock::discarded() had existed since scanout tracing landed and was called by nobody. A diagnostic nobody surfaces is not a diagnostic.
  • A per-frame trace plus an analysis script that reports lag-1 autocorrelation, run-lengths, and cadence.

Dropped frames fell from 135-254 to 1-9 per capture, and audio underruns to zero.

Rejections, recorded with their numbers

  • Slim run-ahead restore — projected ~110 µs, measured 6.9 µs: 0.25% of the run-ahead increment. The estimate came from "the framebuffer is 94% of the snapshot bytes" carried silently into a claim about time. Bytes are not time.
  • Ring-reset throttle gate — converged in 4.0 s and matched on cadence, but produced an audio underrun in every one of its three captures.
  • Ten core hot-path candidates were measured and rejected in v2.3.1 and remain so.

Corrections to this project's own published numbers

This release also retracts and corrects earlier claims from the same campaign, in place rather than quietly:

  • A display-duration metric was computed from producer-side timestamps and was wrong by 20× — the display was ~98.4% correct while the document said 65-74%.
  • A percentile was derived by subtracting other percentiles, producing a published "p95 below p50", which is impossible.
  • A statistical claim quoted an unpaired p-value for a paired design; alternation balances drift direction but does not buy exchangeability.

What this does not claim

It does not claim the maintainer's reported shudder is resolved. That is a subjective report from a different machine whose frame-budget margin has never been measured, and this campaign already declared victory once on counter evidence and was wrong. What it closes is the one measured artefact matching that description.

Full detail — including every rejected mechanism and its numbers — is in docs/performance.md under the v2.3.3 F1-F28 entries.