Skip to content

perf(frontend): port the triple-buffer present handoff (GUI stall, step 1) - #206

Merged
doublegate merged 6 commits into
mainfrom
fix/dk64-boot
Jul 30, 2026
Merged

perf(frontend): port the triple-buffer present handoff (GUI stall, step 1)#206
doublegate merged 6 commits into
mainfrom
fix/dk64-boot

Conversation

@doublegate

Copy link
Copy Markdown
Owner

Motivation — a user-reported, measured GUI stall

Reported symptoms: menu clicks taking 15-45 seconds, roughly one presented frame every 30-60 seconds, Super Mario 64 showing a frame then hanging, and audio cycling ~1 s on / ~1 s off.

Diagnosed to three separable defects, of which this PR lands the primitive for the first.

1. Frontend lock starvation — the catastrophic one

emu_thread.rs:70-80 holds the emu mutex across its entire coordinator.step():

let audio = emu.lock()... { core.set_controllers(ports); coordinator.step(&mut core) };
next += frame_interval;
if next > now { sleep(next - now) } else { next = now }   // <-- no sleep, no yield

Its comment says "under a brief lock." It is the whole frame. And when it falls behind it resets the phase and re-acquires immediately — so it holds the mutex ~100% of the time in a tight loop. app.rs:144 snapshot() needs that same lock every UI frame merely to clone the framebuffer, so the UI thread starves on an unfair mutex.

2. The core is independently ~6.5x too slow

Free measurement from the real-PIF corpus census: 462 s of emulated time took 2,985 s wall in release — ~9.3 FPS equivalent. So even with the lock fixed, that is the ceiling. Tracked as task #55.

3. Audio hiccup is probably downstream

The ~1 s period matches the AudioRing capacity filling in bursts and draining to empty — what a starved producer looks like. Fixing it before #1/#2 would be tuning against a starved producer rather than a real defect.

What this PR does — a port, not an invention

The sibling projects already solve #1, and RustySNES/.../present_buffer.rs (itself ported from RustyNES) documents this exact bug in its module header:

copying it out of EmuCore::framebuffer() under the emu mutex "would serialize the present against the emulation thread's whole run_frame (which the emu-thread feature exists specifically to decouple)"

RustyN64 shipped the emu-thread feature without the handoff, so it does precisely what that module documents as wrong. Ported here — same author throughout, so licence-clean.

Triple-buffer SPSC: the producer writes the slot neither side reads (back) and publishes by swapping back<->ready; the consumer swaps front<->ready. The packed index's three 2-bit fields stay a permutation of {0,1,2}, so producer and consumer always touch disjoint slots, and the small dedicated mutex is held only for one memcpy — never across emulation.

N64-specific adaptations:

  • FB_LEN is the PAL worst case (720x576) and is a sizing hint only — the N64's scan-out is variable (625x237 typical NTSC), so slots resize per publish rather than zero-padding a small frame up.
  • Dims travel with their bytes through the same lock. Not hypothetical: ledger R-18 records a single-point sample catching Super Mario 64 with H_VIDEO = 0 mid-VI-reprogram — exactly the mismatch a separately-queried dims read produces.
  • The status-bar readings (frame count, master_ticks, loaded, paused) publish beside the frame as plain atomics, because they were the other thing snapshot() took the emu lock for. Decoupling only the bytes would have left the starvation in place for the sake of a status line.

Determinism (ADR 0004) unaffected: this moves where already-produced deterministic bytes are copied. No emulated state, no audio.

Tests

Six, each aimed at a way the index could be wrong rather than at coverage:

Test What it would catch
publish_then_take_roundtrips basic handoff
a_take_with_nothing_new_leaves_the_previous_frame a None take clobbering out (would flash black)
publishing_twice_between_takes_yields_the_newest a mis-packed index returning stale bytes
dims_travel_with_their_bytes_across_a_size_change pairing new dims with old bytes
the_slot_index_stays_a_permutation slot aliasing over 32 cycles — the SPSC invariant
reset_clears_the_published_state a stale frame surviving a ROM load

Deliberately NOT wired in

This lands the primitive with its tests so the behavior-changing half — emu_thread publishing outside the lock, app.rs ceasing to take the emu lock, and the missing yield when behind — is reviewable on its own rather than buried under a 350-line port. The exact wiring recipe (with the verified EmuCore accessor names) is recorded in task #54 so it is not re-derived.

Next after that: RustyNES's perf_log.rs (588 lines) before any core optimization, since this project's rule is profile-before-optimize and #55 must not be guessed at; then resampler.rs for the audio; then the cargo full-build/full-run aliases (task #57 — RustyN64 has no .cargo/config.toml at all).

Gates

cargo fmt --all --check · cargo clippy --workspace --all-targets -- -D warnings · cargo test --workspace · RUSTDOCFLAGS="-D warnings" cargo doc · check_en_us.sh — all green.

🤖 Generated with Claude Code

…RustySNES

First step on the user-reported GUI stall: menu clicks taking 15-45s, one
presented frame every 30-60s, SM64 showing a frame then hanging.

DIAGNOSED, not guessed. The emu thread (emu_thread.rs:70-80) holds the emu mutex
across its ENTIRE `coordinator.step()` -- not "under a brief lock" as its own
comment claims -- and when it falls behind it sets `next = now` and re-acquires
immediately with no sleep and no yield. Since the core is ~6.5x slower than real
time (measured: 462s of emulated time took 2985s wall in the corpus census), it
is ALWAYS behind, so it holds the lock ~100% of the time in a tight loop.
`app.rs:144 snapshot()` needs that same lock every UI frame just to clone the
framebuffer, so the UI starves on an unfair mutex.

DO NOT REINVENT -- the user pointed out the sibling projects already solve this,
and they do. `RustySNES/.../present_buffer.rs` (itself ported from RustyNES) is
exactly this handoff, and its module doc names RustyN64's bug verbatim: copying
the framebuffer out from under the emu mutex "would serialize the present against
the emulation thread's whole run_frame (which the emu-thread feature exists
specifically to decouple)". RustyN64 shipped `emu-thread` WITHOUT the handoff, so
it does the thing that module documents as wrong.

Ported here, same author throughout so it is licence-clean. Triple-buffer SPSC:
producer writes `back` and publishes by swapping back<->ready; consumer swaps
front<->ready. Producer and consumer touch disjoint slots (the packed index's
three 2-bit fields stay a permutation of {0,1,2}), and the small dedicated mutex
is held only for one memcpy -- never across emulation.

N64-specific adaptations:
- FB_LEN is the PAL worst case (720x576) and is a SIZING HINT ONLY; the N64's
  scan-out is variable (625x237 typical NTSC) so slots resize per publish rather
  than zero-padding a small frame up to the maximum.
- Dims travel WITH their bytes through the same lock. Not hypothetical here:
  ledger R-18 records a single-point sample catching Super Mario 64 with
  `H_VIDEO = 0` mid-VI-reprogram, which is exactly the mismatch a separately
  queried dims read would produce.
- The status-bar readings (frame count, master_ticks, loaded, paused) are
  published beside the frame as plain atomics, because they were the OTHER thing
  `snapshot()` took the emu lock for -- decoupling only the bytes would have left
  the starvation in place for the sake of a status line.

Determinism (ADR 0004) is unaffected: this moves WHERE already-produced
deterministic bytes are copied. No emulated state, no audio.

Six tests, each aimed at a way the index could be wrong rather than at coverage:
a publish/take round trip; a take with nothing new preserving the previous frame;
two publishes between takes yielding the NEWEST (a mis-packed index would return
stale bytes); dims surviving a size change; the index staying a permutation of
{0,1,2} over 32 cycles; and reset clearing the published state.

NOT YET WIRED -- this lands the primitive with its tests so the wiring change
(emu_thread publishes, app.rs stops taking the emu lock, and the missing yield
when behind) is reviewable on its own. Tasks #54-#57 track the rest, including
RustyNES's perf_log.rs (the instrumentation task #55 needs before optimizing
anything) and resampler.rs (the audio hiccup).

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us -- all green.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@doublegate, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4aece79f-7fa7-4cdf-8181-b9a5372b1a1f

📥 Commits

Reviewing files that changed from the base of the PR and between 952d45c and aedc012.

📒 Files selected for processing (2)
  • crates/rustyn64-frontend/src/present_buffer.rs
  • scripts/check_en_us.sh
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a triple-buffered framebuffer handoff to improve presentation responsiveness and prevent display updates from blocking emulation.
    • Added frame status reporting, including frame count, timing, ROM loading, and pause state.
    • Added support for keeping the latest available frame when multiple frames are produced.
  • Bug Fixes

    • Improved checks to include new, untracked files when validating English-language content.

Walkthrough

The frontend now exposes a triple-buffer framebuffer handoff with frame dimensions, status atomics, reset support, and tests. The English-check script also scans untracked non-ignored files and reports the combined file count.

Changes

Framebuffer presentation handoff

Layer / File(s) Summary
Present buffer contract and storage
crates/rustyn64-frontend/src/lib.rs, crates/rustyn64-frontend/src/present_buffer.rs
The frontend exports present_buffer; PresentStatus, PresentBuffer, and three reusable framebuffer slots define the handoff data model.
Publish, consume, and validate frames
crates/rustyn64-frontend/src/present_buffer.rs
Publishing copies bytes and dimensions into the back slot, consumption selects the newest frame, status fields remain atomic, reset clears state, and unit tests cover these behaviours.

English-check file discovery

Layer / File(s) Summary
Tracked and untracked file scan
scripts/check_en_us.sh
The English-check file list combines tracked files with untracked non-ignored files, and the success output reports the combined count.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EmuThread
  participant PresentBuffer
  participant PresentPath
  EmuThread->>PresentBuffer: publish framebuffer bytes and dimensions
  PresentBuffer-->>PresentPath: signal new frame
  PresentPath->>PresentBuffer: take_into output buffer
  PresentBuffer-->>PresentPath: return newest bytes and dimensions
Loading
🚥 Pre-merge checks | ✅ 8 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title matches the frontend present-handoff change, but it exceeds the 72-character limit in the commit-style rule. Shorten it to 72 characters or fewer while keeping the same perf(frontend): prefix and subject.
Measured, Never Tuned ⚠️ Warning R-5 records the NTSC/PAL scanout geometry, but present_buffer.rs adds an uncited FB_LEN = 720 * 576 * 4. Add a manual/wiki citation or a docs/accuracy-ledger.md entry for 720x576; if kept, also provenance the 15-45s / 30-60s / ~6.5x slowdown claims.
✅ Passed checks (8 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly relates to the triple-buffer present-handoff and associated frontend starvation fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Oracle Number Is Stated ✅ Passed PASS: the PR only adds an unused frontend primitive and a gate script; emu_thread/app.rs wiring is explicitly deferred, so no oracle count is expected.
Docs-As-Spec Sync ✅ Passed PASS: only frontend and scripts changed; no rustyn64-{cpu,rsp,rdp,audio,cart,core} files moved, so docs/.md sync is not triggered.
Changelog Entry For User-Visible Changes ✅ Passed PresentBuffer is only added/exported; app, emu_thread and wasm still use the old framebuffer path, so no user-visible change landed.
Unsafe Stays Out Of The Chip Crates ✅ Passed No unsafe code was added outside rustyn64-frontend, and the diff does not remove any #![forbid(unsafe_code)] attributes.

Comment @coderabbitai help to get the list of available commands.

Found while correcting a doc error in present_buffer.rs: the en-US gate reported
PASS on commit 25b5212, and that commit contains a `licence`.

ROOT CAUSE: the gate enumerated files with `git ls-files`, which lists only
TRACKED files. A brand-new file is untracked until it is staged, so it was
invisible to the check for exactly as long as it took to `git add` it -- and a new
file is precisely where a new violation arrives. The pre-commit hook would not
have caught it until the NEXT commit, by which point the bad spelling is already
on main. My own gate, blind in the one place it most needed to see.

FIX: enumerate tracked files PLUS `git ls-files --others --exclude-standard`
(untracked but not ignored). `--exclude-standard` keeps the original property that
scratch files under a gitignored path never fail the gate.

Verified three ways, because only the set of three shows it is right:
  1. clean tree                                    -> passes
  2. an UNTRACKED new file containing "colour"      -> now CAUGHT (was invisible)
  3. a file under the gitignored target/            -> still correctly exempt

Also in this commit, both from the wiring survey of the frontend:

- CORRECTED A FACTUAL ERROR in present_buffer.rs's module doc: it referred to
  `EmuApp::snapshot()` twice. There is no `EmuApp` in this crate -- the winit
  struct is `App` (app.rs:59). The name came in with the port from RustySNES and I
  did not check it against this codebase. A doc that names a type which does not
  exist sends the next reader grepping for nothing.

- RECORDED A MEASUREMENT that strengthens the case for the handoff: `Frame::blank`
  sizes `rgba` at `FB_MAX_W * FB_MAX_H * 4` = 1,228,800 bytes and `produce_frame`
  never resizes it, so `snapshot()`'s `frame.rgba.clone()` copied the ENTIRE
  backing store every UI frame whatever the active resolution. The UI was not just
  waiting on the mutex, it was doing a 1.2 MB memcpy while holding it. Publishing
  the `w * h * 4` prefix instead is 592,500 bytes at 625x237 and 307,200 at
  320x240 -- under a quarter the copy, and off the emu mutex entirely.

Gates: fmt, clippy -D warnings, cargo test, rustdoc -D warnings, markdownlint,
check_no_roms, check_en_us -- all green.
@doublegate

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Adopted from the Antigravity review of #206, and it is a violation of this
module's own stated contract: the SPSC section claims the mutex guards the
index, the has_new flag, the bytes and the dims *together*, while reset()
cleared has_new BEFORE acquiring the lock and never touched the dims.

The window is real, not theoretical -- reset() runs on the UI thread while the
emu thread publishes. A publish landing between the store and the lock set
has_new = true and then had its bytes wiped, so the next take_into returned
Some(dims) over a zero-length buffer. The present path slices out[..w * h * 4]
from exactly that pair, which panics.

Two independent halves, so the invariant does not rest on locking alone:
  - has_new is now cleared inside the lock, making the state unreachable;
  - the dims are cleared, making "nonzero dims, no bytes" unrepresentable.

generation stays outside the lock on purpose: clippy's significant_drop_tightening
is right that this is the present hot path, and the counter is a diagnostic. The
residual race is now documented with its bound -- one UI frame of the previous
image instead of black, and never a slice, because has_new and the dims are
under the lock.

Tests (the review's nitpick, and the mutation check for the fix):
  - reset_clears_the_dims_so_none_can_be_paired_with_empty_bytes -- verified red
    against the old reset with exactly the stale (2, 2) the review predicted;
  - status_round_trips_and_reset_zeroes_only_the_counters -- pins that reset
    leaves rom_loaded/paused to the caller, so a closed ROM must republish them.

Also records that status() can tear ACROSS its four independent atomics, not
only lag by a frame, and that a seqlock -- not stronger orderings -- is the fix
if a consistent (frames, ticks) pair is ever needed.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh -- all green.
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the Antigravity review

All four findings answered. Fixed in 2a2ca7c.

Blocking: reset() races and leaves dimsADOPTED, and it was worse than a race

Correct, and worth stating precisely why: it is a violation of this module's own
documented contract
. The SPSC section of the module doc claims the mutex guards
"the index, the has_new flag, the slot bytes and the slot dims together" —
and reset() did neither. This repo has a standing lesson that "a comment stating
a rule is not an implementation of it"; this is another instance.

The window is genuinely reachable, not theoretical: reset() runs on the UI thread
(ROM load / power cycle) while the emu thread publishes, and nothing pauses the
producer first. The consequence is also exactly as described, and it is a panic
rather than a glitch — the present path being wired up next slices
out[..w * h * 4] from precisely that (Some(dims), empty buf) pair.

Both halves applied, and they are deliberately independent so the invariant does
not rest on the locking alone:

  • has_new.store(false) moved inside the lock → the state is unreachable;
  • slots.dims = [(0, 0); 3] → the pair "nonzero dims, no bytes" is
    unrepresentable, so even a hypothetical future leak of has_new degrades to
    Some((0, 0)) and a zero-length slice rather than an out-of-bounds one.

Mutation-checked, which is this repo's rule for any new guard: the new test was
run against the old reset() and fails with exactly the stale dims you predicted —

assertion `left == right` failed: every slot's dims must be cleared
  left: [(0, 0), (0, 0), (2, 2)]
 right: [(0, 0), (0, 0), (0, 0)]

One deliberate deviation from your prescription: generation stays outside the
lock. Moving it in made clippy::significant_drop_tightening (nursery, -D warnings
here) fire, and the lint is right — this is the present hot path and the counter is
a diagnostic, not part of the frame contract. Rather than #[allow] it, the residual
race is now documented with its bound: an increment landing just after reset zeroes
the counter leaves has_published() true over an empty handoff, so the present path
re-shows its previous frame for one UI frame instead of black. It can never yield a
slice, because has_new and the dims are under the lock, so take_into still
returns None.

Suggestion: pre-allocate slots with Vec::with_capacity(FB_LEN)REJECTED

The premise is accurate (the first three publishes allocate) but the trade is bad
here, for a reason specific to the N64: FB_LEN is 720*576*4, so three slots is
4.75 MiB of permanently resident capacity — bought to avoid three allocations
over the entire process lifetime, since extend_from_slice into a cleared Vec
reallocates only when capacity is short.

Worse, it is the wrong size for essentially every real frame. That constant is the
PAL worst case; a typical NTSC scan-out here is 625x237x4 = 592,500 bytes and the
common mode is 320x240x4 = 307,200. So the steady state would over-allocate
2.8x to 5.4x, permanently, and the module doc already declares fb_len() "an
informational sizing hint only" precisely because the N64 scan-out is variable and a
title can reprogram the VI mid-run.

The underlying concern is also narrower than "the emulation path allocates": this is
a normal worker thread, not the real-time audio callback (which does not touch this
type). One allocation per increase in frame size, bounded and rare, is acceptable.

Suggestion: status() can tear across the four atomics — ADOPTED as documentation, no code change

Real, and your own framing is conditional ("if atomicity across status fields is
needed later"). It is not needed: these are status-bar readings, the counters are
never compared against one another, and the field docs already accept a frame of
staleness. What was missing is that cross-field tearing is a distinct property from
staleness, so the doc now says so explicitly — and records that the fix, should one
ever be wanted, is a seqlock generation rather than stronger orderings, which cannot
fix cross-field tearing at all.

Nitpick: no coverage for publish_status/status/reset dims — ADOPTED

Two tests added:

  • reset_clears_the_dims_so_none_can_be_paired_with_empty_bytes — the mutation check
    quoted above;
  • status_round_trips_and_reset_zeroes_only_the_counters — also pins the deliberate
    asymmetry that reset zeroes the two counters but leaves rom_loaded/paused to
    the caller, so closing a ROM must republish those rather than expect reset to
    infer them. That is a real constraint on the wiring in the next PR, and it is
    better as an assertion than as a comment.

Gates on 2a2ca7c: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc, and
scripts/check_en_us.sh — all green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/rustyn64-frontend/src/present_buffer.rs`:
- Around line 265-280: Update PresentBuffer::reset to also restore rom_loaded
and paused to their default false values, ensuring status() reports
PresentStatus::default() after a reset. Extend reset_clears_the_published_state
to assert the reset status equals PresentStatus::default().

In `@scripts/check_en_us.sh`:
- Around line 101-105: Update the file collection pipeline in check_en_us.sh so
failures from the git ls-files/grep producer are explicitly captured and checked
before mapfile consumes its output. Preserve the NUL-delimited file list and
ensure the script exits or reports failure when producer status is nonzero,
rather than continuing with an incomplete set.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 22a6e68c-5674-40be-9469-fd9fdf9e2823

📥 Commits

Reviewing files that changed from the base of the PR and between c43f3e8 and 952d45c.

📒 Files selected for processing (3)
  • crates/rustyn64-frontend/src/lib.rs
  • crates/rustyn64-frontend/src/present_buffer.rs
  • scripts/check_en_us.sh

Comment thread crates/rustyn64-frontend/src/present_buffer.rs
Comment thread scripts/check_en_us.sh Outdated
Adopted from the second Antigravity review of #206. `pack` takes three
positional slot ids in field order, so the calls the transitions needed --
`pack(front, back, ready)` to publish and `pack(ready, front, back)` to take --
read like transpositions of pack's own parameters.

The hazard is that such a slip is SILENT: every permutation of {0,1,2} satisfies
the disjointness invariant, so `the_slot_index_stays_a_permutation` keeps passing
while the wrong slot is handed over. This project has already paid for one
transposed positional-argument call this cycle (the TEX_BLOCK base/step swap), so
the concern is not hypothetical.

swap_ready_back / swap_front_ready are const fns over the same `pack`, so the bit
layout is unchanged and the port stays structurally comparable to the RustySNES
original. Pinned by the_named_transitions_swap_exactly_one_pair, which asserts
each transition moves exactly one pair and leaves the third field alone --
mutation-checked by transposing swap_ready_back's first two arguments, which
takes that test and five others red.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh -- all green.
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the second Antigravity review

Blocking issues: none — the reset() fix in 2a2ca7c is confirmed clear. The three
remaining items:

pack positional args are easy to misread as a transposition — ADOPTED (7fefdc1)

The best finding of either pass, because it identifies a silent failure mode: any
permutation of {0,1,2} satisfies the disjointness invariant, so a transposed
pack call keeps the_slot_index_stays_a_permutation passing while handing over the
wrong slot. And it is not hypothetical for this project — a transposed
positional-argument call (the TEX_BLOCK base/step swap) cost real time earlier in
this same work cycle.

Implemented as your suggested swap_ready_back / swap_front_ready, as const fns
over the same pack, so the bit layout is unchanged and the port stays structurally
comparable to the RustySNES original it came from. Pinned by
the_named_transitions_swap_exactly_one_pair, which asserts each transition moves
exactly one pair and leaves the third field untouched — mutation-checked by
transposing swap_ready_back's first two arguments, which takes that test and five
others red.

Pre-allocate slots with Vec::with_capacity(FB_LEN)REJECTED (repeat)

Already adjudicated in the previous pass; the reasoning is unchanged and is in the
comment above. Briefly: three slots at 720*576*4 is 4.75 MiB permanently
resident
to avoid three allocations over the process lifetime, and it is the
PAL worst case — the common 320x240 mode would over-allocate 5.4x forever. This
is also a normal worker thread, not the real-time audio callback.

mapfile -d '' needs Bash 4.4+ — NO CHANGE, and the premise is checked

The version claim is right (mapfile -d is 4.4+), but the job that runs this script
is runs-on: ubuntu-latest (.github/workflows/ci.yml), which ships Bash 5.x, and
the script is bash scripts/check_en_us.sh — not sh, and not part of the
macOS/Windows matrix. So no supported CI environment is below the minimum.

The property that would actually matter for a gate is that an unsupported shell
fails loudly rather than silently passing, and it does: mapfile is the outer
command under set -euo pipefail, so an invalid option error aborts the script
with a nonzero status. Marking that as reasoned from set -e semantics rather than
measured — this machine has Bash 5.3 and no 3.2 was available to run it against.

Worth noting because it is the same failure mode this script was last fixed for: the
gate previously gave a false PASS on a real violation (git ls-files without
--others could not see new files). A gate that cannot fail is worse than no gate,
so the fail-closed direction is the one to protect.

Gates on 7fefdc1: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc,
scripts/check_en_us.sh — all green.

…e must fail closed

Both findings from the CodeRabbit review of #206, both adopted.

reset() left rom_loaded and paused untouched
--------------------------------------------
The doc said "reset to the empty state" and the body did not reach it, which is
this repo's recurring comment-versus-code split. This REVERSES a choice made
one commit earlier -- and pinned in a test -- that the two flags were the
caller's to republish. The review's reasoning is better: the failure directions
are not symmetric. Clearing them costs at most one UI frame of "no ROM" right
after a load, which the next publish corrects. NOT clearing them leaves a closed
ROM reading as loaded until something publishes again, and with the emu-thread
feature off nothing ever does. A stale positive outlives a stale negative, so
the gate clears all four and status() now equals PresentStatus::default() after
a reset. The test asserts that whole equality rather than field-by-field.

The en-US gate could still report PASS over zero files
------------------------------------------------------
`mapfile -d '' -t files < <(producer)` discards the producer's exit status, and
`set -euo pipefail` cannot see inside a process substitution -- so a failing
`git ls-files` produced an empty array and the script then reported success over
nothing.

MEASURED against the previous commit's script with a stub `git` whose `ls-files`
exits 128:

  en-US check passed: 0 files (tracked + untracked), no en-GB or malformed spellings.
  exit=0

This is the SECOND false PASS in this one file -- the first was the missing
`--others`, which hid new files. Same lesson each time: a gate whose failure
mode is a silent pass is worse than no gate.

The listing now goes through a file so each `git ls-files` is a simple command
`set -e` can see, and an empty result is treated as a broken gate rather than a
clean tree. The grep filters stay status-tolerant on purpose (`grep -v` exits 1
when it selects nothing, which is not an error), so the emptiness check is what
carries the guarantee.

Verified three ways: normal run passes over 444 files; a `git` that fails exits
128; a `git` that succeeds while returning nothing exits 1 with an explicit
"the file listing is empty" diagnostic.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh, markdownlint -- all green.
Adopted from the third Antigravity review of #206. `publish` established the
(bytes, dims) pair that this whole type exists to keep consistent, and checked
nothing -- so an inconsistent pair was invisible here and would surface far away
as an out-of-bounds slice in whatever presents `out[..w * h * 4]`.

It found a real bug immediately: publishing_twice_between_takes_yields_the_newest
offered 8 bytes for (4, 1), a 16-byte frame. That test had been passing since the
port, and an eyeball audit of the nine publish call sites in this file missed it
-- which is the argument for the assert rather than against it.

Kept as a debug_assert, not a release branch. The release alternatives are to drop
the frame silently or to clamp the geometry, and both hide the caller's bug while
producing a wrong picture. The release-mode defense belongs at the consumer, which
will bound its upload by the bytes it actually received when the present path is
wired up. The expected length is computed with saturating_mul so the check itself
cannot overflow on a 32-bit usize.

The should_panic test is #[cfg(debug_assertions)] -- without that gate it would
fail under `cargo test --release`, since the assert compiles out. Verified: the
release run passes with the test absent rather than failing.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh -- all green.
@doublegate

Copy link
Copy Markdown
Owner Author

Adjudication of the third Antigravity review

Blocking: publish does not check that frame.len() matches the dims — ADOPTED (aedc012)

Right, and it found a real bug the moment it was added: publishing_twice_between_takes_yields_the_newest was publishing 8 bytes for (4, 1), a 16-byte frame. That test has been passing since the port landed, and — worth admitting — I audited these nine publish call sites by eye before implementing your finding and concluded they were all consistent. They were not. That is the argument for the assert rather than against it.

Implemented as a debug_assert_eq! at the one place the pair is established, with the expected length computed via saturating_mul so the check itself cannot overflow on a 32-bit usize — your parenthetical about dims.0 * dims.1 * 4 overflow.

Deliberately not a release-mode branch, and the reasoning is worth recording: the release options are to drop the frame silently or to clamp the geometry, and both would hide the caller's bug while putting a wrong picture on screen. The release defense belongs at the consumer, which will bound its upload by the bytes it actually received when the present path is wired up in the follow-up. Producer asserts the contract; consumer never trusts it.

The #[should_panic] test carries #[cfg(debug_assertions)], without which it would fail under cargo test --release once the assert compiles out. Verified both ways: debug run panics as expected, release run passes with the test absent rather than failing.

Suggestion: generation.fetch_add after the lock races reset()ACKNOWLEDGED, no change (already documented)

Correct, and already recorded as a deliberate residual — this is the scenario the comment immediately above that fetch_add describes, including the same conclusion that has_published() can read true over an empty handoff.

The reason it stays: moving it inside the lock makes clippy::significant_drop_tightening (nursery, -D warnings in this workspace) fire, and the lint is right that this is the present hot path. Rather than #[allow] a correct lint, the race is bounded and documented: the consequence is that the present path re-shows its previous frame for one UI frame instead of black, and it can never yield a bad slice, because has_new and the dims are under the lock so take_into still returns None. That was the previous pass's blocking finding and is fixed.

Suggestion: Vec::with_capacity(FB_LEN)REJECTED (third occurrence)

Standing answer, unchanged: three slots at 720*576*4 is 4.75 MiB permanently resident to avoid three allocations over the process lifetime, and FB_LEN is the PAL worst case — the common 320x240 mode would over-allocate 5.4x forever.

One further fact that settles it for this frontend specifically: EmuCore::produce_frame routes through presentable_geometry, which rejects anything above 640x480 and falls back to a black 320x240 frame. So no frame exceeding 640*480*4 = 1,228,800 bytes can ever reach publish through this application at all — FB_LEN is not merely a rare worst case here, it is unreachable. Pre-sizing to it would reserve capacity for a frame the pipeline cannot produce.

Nitpick: mktemp paths not validated — NO CHANGE, already handled by set -e

list="$(mktemp)" is an assignment from a command substitution, and set -e aborts on those when the command fails. Measured rather than assumed:

$ bash -c 'set -euo pipefail; v="$(false)"; echo "REACHED: should not print"'
$ echo $?
1

Nothing prints and the status is nonzero, so a mktemp failure aborts the script before any redirect is attempted. Adding an explicit check would be dead code with set -euo pipefail already in force at the top of the file.

Gates on aedc012: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc, scripts/check_en_us.sh — all green.

@github-actions

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

Ports a decoupled triple-buffer lock-free/short-mutex SPSC framebuffer handoff (PresentBuffer) to eliminate UI starvation on the emulation mutex, and updates scripts/check_en_us.sh to include untracked files and prevent false-pass pipeline failures.

Blocking issues

None found.

Suggestions

  • crates/rustyn64-frontend/src/present_buffer.rs:L148: PresentBuffer::new() initializes slot buffers with Vec::new(), forcing memory allocations and reallocations on the emulation thread during early frame publishes. Pre-allocate slot capacity using Vec::with_capacity(FB_LEN) to keep the hot path allocation-free.
  • crates/rustyn64-frontend/src/present_buffer.rs:L208-L215: In publish(), the length validation relies on debug_assert_eq!, which is compiled out in release builds. If the producer passes mismatched frame bytes and dims, take_into() will return Some(dims) with an undersized buffer, causing downstream presentation code to panic when slicing out[..w * h * 4]. Consider enforcing a release-mode guard or dropping mismatched frames.

Nitpicks

  • crates/rustyn64-frontend/src/present_buffer.rs:L73: FB_LEN is exported via fb_len() as a sizing hint, but is not referenced anywhere within the module itself.
  • scripts/check_en_us.sh:L115-L116: Appending || true to the grep pipeline swallows unexpected filter errors before checking file count.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

@doublegate
doublegate merged commit 63e08e9 into main Jul 30, 2026
12 checks passed
@doublegate
doublegate deleted the fix/dk64-boot branch July 30, 2026 04:18
doublegate added a commit that referenced this pull request Jul 30, 2026
… pacer yield (GUI stall, steps 0b+0c) (#208)

* perf(frontend): take the present path off the emu mutex, and make the pacer yield

Steps 0b and 0c of the GUI-stall fix. #206 added the PresentBuffer; this wires it
in and replaces the pacer. Two independent halves of one defect, and fixing only
the first would have left the emu thread hogging the mutex in a tight loop.

0b -- the UI stops taking the emu mutex
---------------------------------------
App::snapshot took the emu mutex every UI frame merely to clone the framebuffer,
while the emu thread held that same mutex across an entire emulated frame. It is
replaced by App::take_present, which reads the handoff and takes no emu lock at
all. The only emu-lock takers left on the UI thread are the menu actions that
genuinely mutate the core, which happen on a click rather than per frame.

Producers publish through one path, EmuCore::publish_into, so the emu-thread and
the emu-thread-off build cannot drift about what a published frame is. It sends
only the w*h*4 prefix, not the 1,228,800-byte FB_MAX backing store that
produce_frame never resizes.

The consumer bounds its upload by the bytes it actually received. That is the
release-mode half of the contract PresentBuffer::publish debug-asserts, so a
short buffer is skipped rather than sliced out of bounds.

0c -- the pacer
---------------
The old fell-behind branch was `next = now` with no sleep and no yield. This core
is ~6.5x slower than real time, so it was taken every iteration: the thread
re-locked the emu mutex immediately and held it ~100% of the time.

Ported from RustyNES: a bounded catch-up burst then a snap forward, then
block_until_native -- capped 2 ms naps down to a 2 ms margin, then a precise
spin. The nap cap is load-bearing; with one long sleep an OS oversleep blows past
the target and the spin never engages.

The pacing decisions are extracted into a `Schedule` so they are testable with
synthetic instants rather than a real clock -- no timing flake. Deviation from the
port: the emu lock is taken PER FRAME, not across a whole burst, because an N64
frame costs this core ~100 ms and holding across three would be a ~300 ms UI
stall.

Measured
--------
A new #[ignore]d stopwatch times a competing UI thread's read both ways against a
live emu thread (release, 120 samples, development machine):

  UI read via the handoff    p50 894 ns    p99 163 us     max 163 us
  UI read via the emu mutex  p50 97.8 ms   p99 113.6 ms   max 118 ms

The same run reported 60 snap-forwards over 60 produced frames -- the core is
behind on every iteration, which confirms directly that the old no-yield branch
was always the one taken.

Known cost, recorded rather than tuned away: because the core is behind every
iteration, the snap imposes a full period of wait after each frame, so the
effective rate is 1/(frame_cost + period) -- about 8.1 FPS against a ~9.3 FPS
core ceiling, ~13%. That is the ported behavior and it is what buys the UI its
window. Whether a shorter yield is better is a question for the perf work with
measured data, not a constant to adjust by feel.

Thread-priority elevation is deliberately not ported: it is the only unsafe in
RustyNES's frontend and should wait for measurement.

Both new guards are mutation-checked: removing the catch-up cap makes frames_due
report 225,001; removing the publish_into call takes the end-to-end test red.

Determinism (ADR 0004) is untouched -- this moves where already-produced bytes
are copied and when wall-clock waits happen, neither of which the core observes.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh, markdownlint, no_std thumbv7em -- all green.

* fix(frontend): blanking the handoff must blank the presented frame too

Adopted from the Antigravity review of #208, and it is another instance of this
repo's recurring failure mode: the comment I wrote at the reset site claimed the
window would "show black until the new ROM produces", and the code did not do it.

Two independent traps, and each one alone leaves the previous ROM's last frame on
screen:

1. PresentBuffer::reset clears the HANDOFF, but take_into then returns None, so
   App::frame_staging keeps its old bytes and keeps being uploaded. This is what
   the review found.
2. Merely EMPTYING frame_staging is also not enough, because the upload is then
   skipped -- and Gfx::upload_framebuffer writes into a persistent wgpu texture
   that render() keeps sampling. The stale image would stay on the GPU.

So the new App::blank_presentation fills the staging buffer with actual black at
the default geometry, which the next upload writes over the texture, and it is a
named helper rather than three call sites precisely so the two halves cannot drift
apart again. load_rom and dispatch take &mut self to reach it.

Also documents that SPIN_MARGIN and SLEEP_CHUNK being equal is coincidental --
they answer different questions and the port defines them separately -- so nobody
collapses them into one constant.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh -- all green. Smoke-tested by running the release binary against
tests/roms/homebrew/render_fill.z64 for 12 s: no panic, clean exit on SIGTERM.

* fix(frontend): blank the presentation even when the ROM load fails

Adopted from the CodeRabbit and Antigravity reviews of #208.

A failed ROM load skipped the blanking entirely via the `?`, and that is not
harmless: EmuCore::load_rom performs its warm reset BEFORE the fallible boot, so a
rejected image has already discarded the previous machine. The UI was left showing
the old ROM's picture, describing a core that no longer exists. The load now
blanks unconditionally and propagates the error afterwards.

Also from the reviews:

  - publish_into gained a debug_assert on the bytes/dims guard. It stays tolerant
    in release, but presentable_geometry makes the guard unreachable today, so a
    trip is a core-side bug that deserves a panic in development rather than a
    silently frozen picture.
  - blank_presentation uses saturating_mul, matching the other two framebuffer
    length computations. The defaults cannot overflow; one form everywhere is
    cheaper to audit than three.
  - docs/frontend.md: two missing commas and en dashes for the two ranges.

Gates: fmt, clippy -D warnings, cargo test --workspace, rustdoc -D warnings,
check_en_us.sh, markdownlint -- all green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant