Skip to content

Commit ff9379e

Browse files
doublegateclaude
andauthored
ci(test): gate the goldens on PRs; keep run-ahead opt-in on measurement (#277)
* ci(test): run the goldens on PRs; measure run-ahead instead of assuming Third and final PR of the `v1.26.0` hardening rung. ## The golden vectors ran in no pull-request job Only `lint`, `test-light`, and `accuracysnes` run on a PR. `test-light` never passes `--features test-roms`, and the `accuracysnes` job scoped itself to `--test accuracysnes`. So the 53 rendered-scene goldens and every framebuffer golden were reached only by `full-test`, which is `mode == 'full'` -- `main`, tags, the weekly cron. A PR could shift PPU behaviour, move a golden, and hear nothing until merge-to-main. That is the same structure behind this project's own coprocessor-golden staleness (goldens left stale by post-bless PPU accuracy fixes): a golden vector nothing executes only accumulates drift. Here it was not that nothing ran them ever, but that nothing ran them while it was still cheap to fix. The job now also runs the scene, undisbeliever, rainwarrior, coprocessor, and save-state suites. Cost is bounded and known: ~5 minutes for the three with a committed corpus (scenes ~84s, undisbeliever ~158s, rainwarrior ~54s). The `*_oncart` suites need gitignored commercial dumps and firmware, so they self-skip for free; they are listed anyway so the intent is explicit rather than inferred from an omission, and so they start gating the moment a runner ever has that corpus. Found while looking for RustySNES's analogue of the sibling's `expansion_level_tripwire`. That mechanism does NOT port, and saying so matters more than inventing a stand-in: the only `commercial-roms`-gated suite here (`commercial_screenshots`) is a screenshot generator that asserts nothing, so it holds no golden that can go stale, and PPU accuracy is not parameterised by a small constant set the way expansion-audio levels are. Looking for it surfaced a larger real gap. ## Run-ahead stays opt-in -- measured, not assumed `docs/frontend.md` recorded the per-frame save-state allocation as THE blocker on run-ahead going default-on. `v1.25.0` removed that allocation, so the question was re-measured rather than assumed resolved: save_state ~119 us load_state ~285 us save/load round trip ~0.40 ms one emulated frame 6.39 ms (headless_frame_steady_state) NTSC frame budget 16.64 ms The round trip is 2.4% of the budget. It was never the dominant cost, and removing the allocation -- a real improvement -- did not move the decision. What run-ahead costs is the extra frame of emulation, inherent to the technique: `frames = 1` needs 79% of the budget, leaving ~3.5 ms for present/UI/audio on a fast development machine; `frames = 2` needs 118% and cannot hold 60 fps at all. So it stays opt-in. The plan for this rung said "default-on only if the measurement supports it, and report the number either way" -- it does not, and this is the number. One thing did change as a result. `RunAheadConfig`'s `Default` is now hand-written so `throttle_ms` defaults to 14 ms (ARMED) rather than the derived `0.0` (DISABLED). The throttle is what its own doc calls the thing that keeps run-ahead from turning a latency win into stutter, and the derived default left it off for precisely the user who had just enabled run-ahead from Settings and had no `throttle_ms` line in their config. 14 ms sits below the 16.64 ms NTSC deadline with headroom and is conservative against PAL's 20 ms. An existing config that spells the field out keeps its value -- `#[serde(default)]` fills only absent fields, pinned by a negative-control test alongside the positive one. ## Lockstep Log row for RustyNES `v2.2.1`-`v2.2.4`, and the two 2026-07-12 flags CORRECTED: the GIF/WAV capture subsystem and CRT shader-preset depth were both closed by `v1.25.0` (`av_record.rs`; the multi-pass stack + richer CRT + NTSC pass + `.slangp` import with the naga bridge), yet the roadmap still asked for a maintainer go/no-go on them two weeks later. Also carries the verification comment for #276's declined blocking finding (that `git/matching-refs` 404s on an absent tag -- it returns HTTP 200 with an empty array; `git/ref/tags` is the one that 404s, which is why it was rejected). Documentation of that adjudication, not a fix. Verified: `cargo fmt --check`, frontend clippy `-D warnings`, the new config tests, and the golden suites run under the exact multi-`--test` invocation the CI step uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(config): apply review — drop the epsilon dance, derive the NTSC budget Two review suggestions applied, both verified rather than taken on description -- and the first one corrected my own wrong assumption. `(x - 0.0).abs() < f32::EPSILON` -> `x == 0.0`. I expected this to trip `clippy::float_cmp` (the workspace runs `pedantic` at `-D warnings`) and was going to decline it on that basis; testing showed clippy exempts a constant comparison, so the suggestion is right and the epsilon form was just noise. The FORM matters though, and the comment now says so: the apparently-more-idiomatic `assert_eq!(x, 0.0)` DOES fire `float_cmp` and fails the gate, so `assert!(x == 0.0)` is the spelling that works. The magic `1000.0 / 60.0988` now derives from `Region::Ntsc.frame_rate()` so the two cannot drift apart, compared in `f64` (what `frame_rate()` returns) to avoid a lossy `as f32` the pedantic cast lints would object to. Declined: the PR title is 71 characters, not the 73 the review reported, so it is already inside the 72-character limit. Verified: `cargo fmt --check`, frontend clippy `-D warnings`, and `config::tests` 13/13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(test): --no-fail-fast so one broken golden doesn't hide the other nine Review round two. Two applied, one declined. **`--no-fail-fast` on the golden-vector step.** Real, and verified by injection rather than taken on description: cargo's default stops after the first failing test BINARY, across binaries and not merely within one. Injecting a failure into `save_state_backward_compat` and running it alongside `save_state_determinism` showed the second suite never ran at all. With ten suites in this step that means one broken golden hides the status of the other nine — an author fixes one, pushes, and only then discovers the next, serialising a debug loop over a ~6-minute job. Fixed with `--no-fail-fast` rather than the suggested split into ten separate steps: one flag against ten cargo startups and ten log sections, and re-verified that the step still FAILS the job while reporting every suite. **`DEFAULT_RUN_AHEAD_THROTTLE_MS`.** The `14.0` is now a named public constant so the value, the doc comment justifying it, and the test that bounds it cannot drift apart; the test asserts against the constant instead of restating the literal. **Declined: "the CHANGELOG entry is too verbose, keep it focused on user-facing behaviour."** This project's CHANGELOG is not a summary — per `docs/adr/0007` the annotated tag body IS the CHANGELOG section verbatim, and `release-auto.yml` reuses it as the GitHub release notes with no separate hand-authored text. Entries are therefore written at release-note quality by design, and the measurements here are the whole justification for a decision (run-ahead staying opt-in) that would otherwise read as an unexplained non-change. Trimming to "user-facing behaviour" would leave the release notes asserting a conclusion with its evidence deleted. Verified: `cargo fmt --check`, frontend clippy `-D warnings`, `cargo doc` with warnings-as-errors, `config::tests` 13/13, ci.yml parses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(changelog): mark the timings host-specific; record why PAL isn't asserted Review round three. One applied, two declined with measurements — and the PAL suggestion is worth recording because I applied it first and it was wrong. **Applied: the absolute timings are host-specific.** The CHANGELOG now says so, and says why they are there anyway: the ratios are what decide run-ahead's default, and a reader can only check a ratio by re-running `save_state_cost` + `headless_frame` and comparing. The percentages are the portable part; the microseconds are the working. **Declined: add a matching `Region::Pal` assertion.** I applied this, then checked the arithmetic before pushing: NTSC's frame budget is 16.64 ms and PAL's is 20.00 ms, so PAL is the LOOSER bound by 3.36 ms and `throttle_ms < NTSC` strictly implies `throttle_ms < PAL`. The added assertion could never fail while the existing one passed — a vacuous assertion sitting beside a real one and reading like extra coverage. That is the exact shape `CLAUDE.md` names under "a guard must not subsume the assertion it protects", and the project already has four recorded instances of the vacuous-test trap. Reverted, with the arithmetic left in the test so the next reader does not re-derive it. (My first pass also wrote a comment claiming NTSC was the bound that would "silently carry a PAL-violating value through", which is backwards — a PAL-violating value exceeds the NTSC bound too and is caught. Fixed.) **Declined: merge the two harness `cargo test` steps.** Measured: a second cargo invocation against an already-built target dir costs 0.58 s — 0.16% of the ~6-minute step. Against that, merging would apply the battery step's `-- --nocapture` to all ten golden suites (flooding the log) and collapse two separately-attributed CI steps into one, so a failure no longer says at a glance whether the battery or the goldens broke. Verified: `cargo fmt --check`, frontend clippy `-D warnings`, `config::tests` 13/13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(config): note the float_cmp form at the second assertion too Review round four, nitpick applied: the `assert!(x == y)` at the default check lacked the note its sibling assertion carries about why `assert_eq!` fails clippy's `float_cmp` under `-D warnings`. Two identical-looking lines where only one explains itself is how the next person "simplifies" the unexplained one and breaks the gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 81baef5 commit ff9379e

7 files changed

Lines changed: 256 additions & 16 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,45 @@ jobs:
338338
run: |
339339
cargo test -p rustysnes-test-harness --features test-roms \
340340
--test accuracysnes -- --nocapture
341+
# `v1.26.0`: the golden vectors were executed by NO pull-request job.
342+
#
343+
# Only `lint`, `test-light`, and this job run on a PR. `test-light` does not pass
344+
# `--features test-roms`, and the step above scopes itself to `--test accuracysnes` — so the
345+
# 53 rendered-scene goldens and every framebuffer golden were reached only by `full-test`,
346+
# which is `mode == 'full'` (main, tags, the weekly cron). A PR could shift PPU behaviour,
347+
# move a golden, and hear nothing about it until merge-to-main.
348+
#
349+
# That is the same structure that produced the coprocessor-golden staleness this project
350+
# already hit once (goldens left stale by post-bless PPU accuracy fixes): a golden vector
351+
# nothing executes only accumulates drift. Here it was not that nothing executed them ever,
352+
# but that nothing executed them when it could still be cheap to fix.
353+
#
354+
# Cost is bounded and known: the three suites with a committed corpus (~5 min total in a
355+
# debug build — scenes ~84s, undisbeliever ~158s, rainwarrior ~54s). The `*_oncart`
356+
# coprocessor suites need gitignored commercial dumps and firmware, so they self-skip here
357+
# for free; they are listed anyway so the intent is explicit rather than inferred from an
358+
# omission, and so they start gating the moment a runner ever has that corpus.
359+
#
360+
# `--no-fail-fast` is load-bearing, not decoration. Cargo's default stops after the FIRST
361+
# failing test binary, and it stops across binaries, not just within one — verified by
362+
# injecting a failure into `save_state_backward_compat` and watching
363+
# `save_state_determinism` never run at all. With ten suites here that would mean one broken
364+
# golden hides the status of the other nine, so an author fixes one, pushes, and only then
365+
# discovers the next: a serialized debug loop over a ~6-minute job. With the flag, every
366+
# suite reports and the step still fails.
367+
- name: Run the golden vectors
368+
run: |
369+
cargo test -p rustysnes-test-harness --features test-roms --no-fail-fast \
370+
--test accuracysnes_scenes \
371+
--test undisbeliever_golden \
372+
--test rainwarrior_golden \
373+
--test dsp1_oncart \
374+
--test sa1_oncart \
375+
--test superfx_oncart \
376+
--test dsp3_st011_oncart \
377+
--test srtc_st018_oncart \
378+
--test save_state_determinism \
379+
--test save_state_backward_compat
341380
342381
# v1.5.0 "Bedrock": the one stable required-check name branch protection points at
343382
# (`docs/adr/0011`). Individual jobs above are conditionally skipped depending on `setup`'s mode,

‎.github/workflows/release-auto.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,19 @@ jobs:
123123
# ref is compared in `jq` — that is not theoretical, `v1.2` prefix-matches several real
124124
# tags while exact-matching none.
125125
#
126+
# VERIFIED against the live API, because review challenged exactly this and asserted the
127+
# opposite (that `matching-refs` 404s on an absent tag, which would fail every release
128+
# closed):
129+
#
130+
# matching-refs/tags/v99.99.99 -> HTTP 200, body `[]`, gh exit 0
131+
# matching-refs/tags/zzz-not-a-tag -> HTTP 200, body `[]`, gh exit 0
132+
# git/ref/tags/v99.99.99 -> 404, gh exit 1 <- the rejected endpoint
133+
#
134+
# So a non-zero exit here means a REAL failure (network, auth, rate limit) and nothing
135+
# else, which is what makes aborting on it correct rather than over-strict. Do not "fix"
136+
# this by treating 404 as absent: that would reintroduce the exact three-way collapse
137+
# this replaced, letting an auth failure read as "no tag, go ahead and release".
138+
#
126139
# Every failure path aborts the job (the script runs under `set -euo pipefail`) rather
127140
# than resolving to a release decision.
128141
if ! matching="$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${tag}")"; then

‎CHANGELOG.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
1010
## [Unreleased]
1111

12+
### Fixed
13+
14+
- **The golden vectors ran in no pull-request job.** Only `lint`, `test-light`, and `accuracysnes`
15+
run on a PR; `test-light` never passes `--features test-roms`, and the `accuracysnes` job scoped
16+
itself to `--test accuracysnes`. So the **53 rendered-scene goldens and every framebuffer golden**
17+
were reached only by `full-test`, which runs on `main`, tags, and the weekly cron. A PR could
18+
shift PPU behaviour, move a golden, and hear nothing about it until merge-to-main.
19+
20+
That is the same structure behind this project's own coprocessor-golden staleness (goldens left
21+
stale by post-bless PPU accuracy fixes): a golden vector nothing executes only accumulates drift.
22+
Here it was not that nothing ran them ever, but that nothing ran them while it was still cheap to
23+
fix. The `accuracysnes` job now also runs the scene, undisbeliever, rainwarrior, coprocessor, and
24+
save-state suites — ~5 minutes for the three with a committed corpus; the `*_oncart` suites need
25+
gitignored dumps and self-skip for free, listed anyway so the intent is explicit.
26+
27+
Found while looking for RustySNES's analogue of the sibling's `expansion_level_tripwire`. That
28+
mechanism does **not** port: the only `commercial-roms`-gated suite here
29+
(`commercial_screenshots`) is a screenshot generator that asserts nothing, so it holds no golden
30+
that can go stale, and PPU accuracy is not parameterised by a small constant set the way
31+
expansion-audio levels are. Looking for it surfaced a real and larger gap instead.
32+
33+
### Changed
34+
35+
- **Run-ahead stays opt-in, and the reason is now measured rather than assumed.**
36+
`docs/frontend.md` recorded the per-frame save-state allocation as *the* blocker on making
37+
run-ahead default-on. `v1.25.0` removed that allocation, so the question was re-measured.
38+
39+
Absolute timings below are from one development machine and will differ on other hardware — they
40+
are recorded because the **ratios** are what decide this, and a reader can only check a ratio by
41+
re-running the same two benches (`save_state_cost`, `headless_frame`) and comparing. The
42+
percentages are the portable part; the microseconds are the working:
43+
44+
| | cost |
45+
|---|---:|
46+
| `save_state` | ~119 µs |
47+
| `load_state` | ~285 µs |
48+
| **save/load round trip** | **~0.40 ms** |
49+
| one emulated frame (`headless_frame_steady_state`) | **6.39 ms** |
50+
| NTSC frame budget | 16.64 ms |
51+
52+
The round trip is **2.4%** of the budget — it was never the dominant cost, and removing the
53+
allocation did not move the decision. What run-ahead costs is the extra frame of emulation, which
54+
is inherent: `frames = 1` needs **79%** of the NTSC budget on a fast development machine, leaving
55+
~3.5 ms for present, UI, and audio; `frames = 2` needs **118%** and cannot hold 60 fps at all.
56+
Defaulting that on would miss deadlines on ordinary hardware to buy latency nobody asked for.
57+
58+
- **`RunAheadConfig`'s `Default` is hand-written so the throttle is armed.** A derived `Default`
59+
gave `throttle_ms: 0.0`, which *disables* the throttle — leaving the safety net off for exactly
60+
the user who had just enabled run-ahead from Settings and had no `throttle_ms` line in their
61+
`config.toml`. It now defaults to 14 ms (below the 16.64 ms NTSC deadline with headroom, and
62+
conservative against PAL's 20 ms). An existing config that spells the field out keeps its value;
63+
`#[serde(default)]` fills only absent fields, pinned by a negative-control test.
64+
1265
### Security
1366

1467
- **CI supply-chain hardening — every workflow credential, action pin, and the release gate.**

‎crates/rustysnes-frontend/src/config.rs‎

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,26 @@ impl Default for RewindConfig {
580580
}
581581

582582
/// Run-ahead settings (`crate::rewind::step_with_run_ahead`).
583-
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
583+
///
584+
/// **Run-ahead stays opt-in, and `v1.26.0` measured why.** `docs/frontend.md` recorded the
585+
/// per-frame save-state allocation as "the blocker on making run-ahead default-on"; `v1.25.0`
586+
/// removed that allocation, so the question was re-measured rather than assumed resolved:
587+
///
588+
/// | | cost |
589+
/// |---|---:|
590+
/// | `save_state` | ~119 µs |
591+
/// | `load_state` | ~285 µs |
592+
/// | save/load round trip | **~0.40 ms** |
593+
/// | one emulated frame (`headless_frame_steady_state`) | **6.39 ms** |
594+
/// | NTSC frame budget | 16.64 ms |
595+
///
596+
/// The round trip is **2.4%** of the budget — it was never the dominant cost. What run-ahead
597+
/// actually costs is the extra frame of emulation, which is inherent to the technique and cannot
598+
/// be optimised away: `frames = 1` consumes **79%** of the NTSC budget on a fast development
599+
/// machine, leaving ~3.5 ms for present, UI, and audio; `frames = 2` needs **118%** and cannot
600+
/// hold 60 fps at all. Defaulting that on would make the emulator miss frames on ordinary
601+
/// hardware to buy latency the user never asked for.
602+
#[derive(Debug, Clone, Serialize, Deserialize)]
584603
#[serde(default)]
585604
pub struct RunAheadConfig {
586605
/// Frames to peek ahead each displayed frame. `0` disables run-ahead entirely
@@ -591,11 +610,37 @@ pub struct RunAheadConfig {
591610
///
592611
/// Run-ahead multiplies emulation cost by `frames + 1`, so on a machine that is already
593612
/// missing its frame deadline it converts a latency improvement into visible stutter. RustyNES
594-
/// throttles on a frame budget for exactly this reason; without it the feature cannot safely
595-
/// be on by default.
613+
/// throttles on a frame budget for exactly this reason.
596614
pub throttle_ms: f32,
597615
}
598616

617+
/// The run-ahead budget throttle's default, in milliseconds.
618+
///
619+
/// Sits below the 16.64 ms NTSC deadline with headroom for present, UI, and audio, and is
620+
/// conservative against PAL's 20 ms too. Named rather than inlined so the value, the doc comment
621+
/// that justifies it, and the test that bounds it cannot drift apart.
622+
pub const DEFAULT_RUN_AHEAD_THROTTLE_MS: f32 = 14.0;
623+
624+
impl Default for RunAheadConfig {
625+
/// Run-ahead off, **but the throttle armed** for whenever it is turned on.
626+
///
627+
/// This is hand-written rather than derived for one reason: a derived `Default` gives
628+
/// `throttle_ms: 0.0`, which *disables* the throttle. The safety net that
629+
/// [`RunAheadConfig::throttle_ms`] exists to provide would then be off for exactly the user
630+
/// who just enabled run-ahead from the Settings panel and has no `throttle_ms` line in their
631+
/// `config.toml` — the one person who most needs it. Enabling a feature should not require
632+
/// separately discovering the knob that keeps it from stuttering.
633+
///
634+
/// An existing `config.toml` that already spells out `throttle_ms` keeps whatever it says;
635+
/// only an absent field picks [`DEFAULT_RUN_AHEAD_THROTTLE_MS`] up.
636+
fn default() -> Self {
637+
Self {
638+
frames: 0,
639+
throttle_ms: DEFAULT_RUN_AHEAD_THROTTLE_MS,
640+
}
641+
}
642+
}
643+
599644
/// Autofire ("turbo") settings — hold a face button and the frontend pulses it (`v1.25.0`).
600645
///
601646
/// Purely a host-input convenience: the pulsing happens where host input is sampled, so the core
@@ -880,6 +925,64 @@ mod tests {
880925
assert_eq!(back.p1.binds.len(), 12);
881926
}
882927

928+
#[test]
929+
fn run_ahead_is_off_by_default_but_its_throttle_is_armed() {
930+
// The trap this pins: a DERIVED `Default` gives `throttle_ms: 0.0`, which *disables* the
931+
// throttle. Run-ahead's own doc calls that throttle the thing that keeps the feature from
932+
// turning a latency win into visible stutter — so the derived default left the safety net
933+
// off for exactly the user who just enabled run-ahead and has no `throttle_ms` line in
934+
// their config.
935+
let cfg = RunAheadConfig::default();
936+
assert_eq!(cfg.frames, 0, "run-ahead must stay additive-default-off");
937+
// `assert!(x == y)` rather than `assert_eq!`: clippy's `float_cmp` exempts this form but
938+
// fires on the `assert_eq!` spelling, so the more idiomatic-looking one fails the
939+
// `-D warnings` gate. Same reason as the note in the test below.
940+
assert!(cfg.throttle_ms == DEFAULT_RUN_AHEAD_THROTTLE_MS);
941+
assert!(
942+
cfg.throttle_ms > 0.0,
943+
"the throttle must be armed by default, or enabling run-ahead silently ships \
944+
without its safety net"
945+
);
946+
// Below the NTSC deadline with headroom for present/UI/audio. A throttle at or above the
947+
// deadline can never fire before the frame is already late. Derived from
948+
// `Region::frame_rate()` rather than a literal 60.0988 so the two cannot drift apart;
949+
// compared in `f64` because that is what `frame_rate()` returns, which also avoids a
950+
// lossy `as f32` the pedantic cast lints would (rightly) object to.
951+
//
952+
// ONLY NTSC is asserted, deliberately. Review suggested adding a matching `Region::Pal`
953+
// check, since the constant's doc justifies itself against both. Arithmetic says that
954+
// assertion could never fail: NTSC's budget is 16.64 ms and PAL's is 20.00 ms, so PAL is
955+
// the LOOSER bound by 3.36 ms and `throttle_ms < NTSC` strictly implies
956+
// `throttle_ms < PAL`. Adding it would be a vacuous assertion sitting next to a real one
957+
// and reading like extra coverage — the exact shape `CLAUDE.md` warns about under "a guard
958+
// must not subsume the assertion it protects".
959+
assert!(
960+
f64::from(cfg.throttle_ms) < 1000.0 / Region::Ntsc.frame_rate(),
961+
"a throttle at or above the frame budget fires too late to prevent anything"
962+
);
963+
}
964+
965+
#[test]
966+
fn an_explicit_throttle_of_zero_survives_the_new_default() {
967+
// Negative control for the test above. The new hand-written `Default` must not override a
968+
// config that deliberately spells out `throttle_ms = 0.0` to disable the throttle —
969+
// `#[serde(default)]` fills only ABSENT fields, and conflating the two would silently
970+
// change behaviour for a user who opted out on purpose.
971+
let cfg: Config =
972+
toml::from_str("[run_ahead]\nframes = 1\nthrottle_ms = 0.0\n").expect("deserialize");
973+
assert_eq!(cfg.run_ahead.frames, 1);
974+
// `assert!(x == 0.0)` rather than an epsilon dance: this is an exact literal round-tripped
975+
// through TOML, not a computed value. Note the form matters — clippy's `float_cmp` exempts
976+
// this constant comparison but DOES fire on the `assert_eq!(x, 0.0)` spelling, so the
977+
// apparently more idiomatic version fails the `-D warnings` gate.
978+
assert!(cfg.run_ahead.throttle_ms == 0.0);
979+
980+
// ...while an absent field does pick the armed default up.
981+
let cfg: Config = toml::from_str("[run_ahead]\nframes = 1\n").expect("deserialize");
982+
assert_eq!(cfg.run_ahead.frames, 1);
983+
assert!(cfg.run_ahead.throttle_ms > 0.0);
984+
}
985+
883986
#[test]
884987
fn region_frame_rates_and_heights() {
885988
assert!((Region::Ntsc.frame_rate() - 60.0988).abs() < 1e-3);

‎docs/frontend.md‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,14 +584,45 @@ no new dependency** — a rewind buffer pulling in a compression crate to store
584584

585585
### Run-ahead's per-frame allocation (`save_state_into`)
586586

587-
Run-ahead snapshots the machine **every frame**. `EmuCore::save_state` allocates a fresh
588-
hundreds-of-kilobyte `Vec` each time, which `docs/frontend.md` has recorded as the blocker on making
587+
Run-ahead snapshots the machine **every frame**. `EmuCore::save_state` allocated a fresh
588+
hundreds-of-kilobyte `Vec` each time, which this document long recorded as *the* blocker on making
589589
run-ahead default-on.
590590

591591
`System::save_state_into` (and its `SaveWriter::with_buffer`) reuse a caller-owned buffer, clearing
592592
it but **keeping its capacity**. Output is byte-for-byte identical; the only difference is that the
593593
allocation happens once instead of sixty times a second.
594594

595+
#### …was not actually the blocker (`v1.26.0`, measured)
596+
597+
With the allocation gone, the default-on question was **re-measured rather than assumed resolved**:
598+
599+
| | cost |
600+
|---|---:|
601+
| `save_state` (`save_state_cost` bench) | ~119 µs |
602+
| `load_state` | ~285 µs |
603+
| **save/load round trip** | **~0.40 ms** |
604+
| one emulated frame (`headless_frame_steady_state`) | **6.39 ms** |
605+
| NTSC frame budget | 16.64 ms |
606+
607+
The round trip is **2.4% of the frame budget**. It was never the dominant cost, and removing the
608+
allocation — while a real improvement — did not move the decision.
609+
610+
What run-ahead actually costs is the **extra frame of emulation**, which is inherent to the
611+
technique and cannot be optimised away. `frames = 1` needs 13.18 ms of a 16.64 ms budget (**79%**),
612+
leaving ~3.5 ms for present, UI, and audio on a fast development machine; `frames = 2` needs
613+
19.57 ms (**118%**) and cannot hold 60 fps at all.
614+
615+
**So run-ahead stays opt-in.** Defaulting it on would spend most of every frame's budget — and miss
616+
deadlines outright on ordinary hardware — to buy latency the user never asked for. The
617+
`run_ahead.throttle_ms` budget throttle exists to make the feature safe *when a user opts in*, not
618+
to make it safe as a default.
619+
620+
One thing did change as a result: `RunAheadConfig`'s `Default` is now hand-written so
621+
`throttle_ms` defaults to **14 ms (armed)** rather than the derived `0.0` (**disabled**). The
622+
derived default left the safety net off for precisely the user who had just enabled run-ahead and
623+
had no `throttle_ms` line in their `config.toml`. An existing config that spells the field out
624+
keeps whatever it says.
625+
595626
The buffer is owned by the caller for a reason: a local `Vec` inside the run-ahead function would
596627
allocate just the same. The synchronous path keeps one on `Active`; the `emu-thread` build keeps its
597628
own in `emu_thread::run_loop`, because frame production there lives on a different thread — and a

0 commit comments

Comments
 (0)