feat(vi): framebuffer scan-out to RGBA8 (T-31-004 part 2) - #60
Conversation
Phase 3, Sprint 1's fourth ticket, part 2 -- the scan-out conversion that turns the FILL pipeline's framebuffer into a presentable picture. - Bus::scanout(&self, out) reads VI_ORIGIN/VI_WIDTH/VI_CTRL and the active height from VI_V_VIDEO ((V_END - V_START) half-lines -> lines), and converts the framebuffer to RGBA8: 16-bit RGBA5551 (each 5-bit channel widened to 8 by replicating the high bits, the 1-bit alpha to 0/255) and 32-bit RGBA8888 (a direct copy). TYPE 0/1 (blank) returns (0, 0) and writes nothing. Scope: a 1:1 scan -- VI_X_SCALE/VI_Y_SCALE resampling and the analog post-filters (AA, divot, de-dither) are deferred, recorded as open residual R-5 in the accuracy ledger; the pixel conversion itself is exact and cited. There is no per-frame driver yet: the scheduler tick that calls scanout lands with the V_CURRENT advance (T-31-004 part 3). The harness golden-frame comparison (T-31-005) is the first real consumer. Tests: byte-for-byte 16- and 32-bit conversion (white/black 5551 cases, direct 8888 copy) and the blank-VI case. The 5->8-bit expansion is mutation-checked (dropping the low-bit replication makes 0xFFFF map to 0xF8F8F8 instead of white). Gates: fmt, clippy --workspace -D warnings, cargo test --workspace, rustdoc -D warnings, thumbv7em no_std, markdownlint -- all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughSummary by CodeRabbit
Walkthrough
ChangesVI framebuffer scan-out
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant VI
participant Scanout
participant RDRAM
participant Output
VI->>Scanout: provide framebuffer configuration
Scanout->>RDRAM: read framebuffer pixels
RDRAM-->>Scanout: return pixel data
Scanout->>Output: copy or convert to RGBA8
Possibly related PRs
🚥 Pre-merge checks | ✅ 9 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (9 passed)
Comment |
Antigravity review (Gemini via Ultra)This PR implements 1:1 VI framebuffer scan-out ( Blocking issues
Suggestions
Nitpicks
Automated first-pass review by |
…4 review) Adjudicating the Antigravity review of #60: - Validate the destination up front: scanout returns (0, 0) and writes nothing when out is smaller than width*height*4, rather than aborting mid-frame and claiming full dimensions with truncated data. This also removes the per-pixel bounds check. Mutation-checked (removing the guard panics on the out-of-bounds write). - Mask expand5's input to 5 bits so an out-of-range argument cannot overflow the shift in debug (all call sites already pass <= 31; belt and suspenders). - Replace the 32-bit byte-copy loop with rdram_read_u32().to_be_bytes() into a slice copy -- clearer than the nitpick's cast shuffle, and no per- byte trait calls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adjudication of the Antigravity reviewFixes in the latest commit. Blocking
Suggestions
Nitpicks
|
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 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-core/src/bus.rs`:
- Around line 447-449: Update the frame-writing logic around the dst + 4 bounds
check to preflight the complete required RGBA output length before modifying
out. When the buffer is insufficient, return the established explicit
insufficient-buffer result rather than (width, height), and ensure no partial
frame data is written.
- Around line 1209-1215: Strengthen scanout_is_blank_when_the_vi_is_off to
pre-fill the destination with a nonzero sentinel, then verify it remains
unchanged after blank scan-out. Extend the test to cover both blank VI types,
TYPE == 0 and TYPE == 1, while preserving the expected (0, 0) result and
confirming neither path writes to the output.
- Around line 1198-1206: Update the scanout fixture in the RGBA5551 test around
bus.scanout to include a non-uniform pixel such as 0x0887, replacing or
supplementing the uniform white/black cases. Assert its observable RGBA output
is [0x08, 0x10, 0x18, 0xFF] so component ordering and field shifts are covered.
In `@docs/rdp.md`:
- Around line 280-282: Correct the scheduler description near the “VI_V_CURRENT
firing / the scheduler tick” heading: describe the scheduler advancing
VI_V_CURRENT and raising the VI interrupt at VI_V_INTR, rather than saying
VI_V_CURRENT fires. Preserve the surrounding scan-out scheduling and conversion
context.
- Around line 296-314: The scan-out verification record needs explicit
validation status. In docs/rdp.md lines 296-314, state the measured
n64-systemtest failing-assertion count, or explicitly document that it was not
measured and why; in docs/accuracy-ledger.md line 188, replace “are validated”
with wording that describes validation as planned while keeping R-5 open.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: a6c3c18f-90de-4c56-ac09-0cf346028202
📒 Files selected for processing (4)
CHANGELOG.mdcrates/rustyn64-core/src/bus.rsdocs/accuracy-ledger.mddocs/rdp.md
…es (T-31-004 review) Adjudicating the CodeRabbit review of #60: - RGBA5551 test now uses non-uniform channels (0x0887 -> [08,10,18,FF] and 0x0886 -> [08,10,18,00]) so component order, the field shifts, and both alpha states are covered -- white/black could not distinguish channels. - Blank-scanout test pre-fills a 0xA5 sentinel and asserts it is untouched, and covers both blank types (TYPE 0 and 1); a zero-filled buffer would have passed a blank path that wrongly wrote zeroes. - docs/rdp.md: describe the scheduler advancing VI_V_CURRENT and raising the VI interrupt at VI_V_INTR, not VI_V_CURRENT 'firing'; and record the oracle effect explicitly (not measured -- scanout has no runtime driver, so it is unreachable by n64-systemtest; count stands at 93). - accuracy-ledger R-5: 'are validated' -> 'will be validated' (the entry is open until the fuzz suite runs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
crates/rustyn64-core/src/bus.rs (3)
1191-1204: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winAssert the complete RGBA8888 frame.
The 2×2 fixture checks only the first and last pixels, so an implementation that skips or corrupts either middle pixel still passes. Assert all four output pixels to cover the row/column traversal.
As per coding guidelines, tests must verify observable effects and choose cases that fail when the implementation is broken.
🤖 Prompt for 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. In `@crates/rustyn64-core/src/bus.rs` around lines 1191 - 1204, Update the scanout test around the 2×2 framebuffer fixture and its existing output assertions to validate all four RGBA8888 pixels in row-major order. Replace the partial first/last-pixel checks with complete coverage of the output buffer, preserving the expected px32 values for both middle pixels.Source: Coding guidelines
29-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMake the
expand5guard mutation-testable or remove it.Every current caller already masks its extracted channel with
0x1F, so deletingv5 & 0x1Fdoes not change anyscanoutresult and the tests will still pass. Either remove the redundant guard or add a focused test for its out-of-range contract.As per coding guidelines, tests must mutation-check guards by proving that they fail without the fix.
🤖 Prompt for 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. In `@crates/rustyn64-core/src/bus.rs` around lines 29 - 34, The `expand5` mask guard is currently redundant and untested. In `expand5`, either remove the `v5 & 0x1F` masking or add a focused test that passes an out-of-range value and verifies the documented masked result, ensuring the test fails if the guard is removed.Source: Coding guidelines
444-445: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winHandle reversed
VI_V_VIDEOranges explicitly (crates/rustyn64-core/src/bus.rs:444).docs/rdp.mdand accuracy ledger R-5 only define height as(V_END − V_START)half-lines;saturating_subadds an unlisted zero-height fallback forV_START > V_END. Either reject that state or record the measured policy indocs/accuracy-ledger.md.🤖 Prompt for 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. In `@crates/rustyn64-core/src/bus.rs` around lines 444 - 445, Update the height calculation near the VI timing validation in bus.rs to handle V_START > V_END explicitly instead of silently using saturating_sub; reject the reversed VI_V_VIDEO range through the existing invalid-timing path, or document the chosen measured behavior in docs/accuracy-ledger.md while preserving the defined (V_END − V_START) half-line calculation.Source: Path instructions
🤖 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 `@docs/accuracy-ledger.md`:
- Line 188: The R-5 accuracy-ledger entry lacks precise, falsifiable provenance
for its conversion and deferred VI behavior claims. Update the R-5 row to cite
exact N64brew manual/wiki sections or named measured test matrices covering
RGBA5551 5→8-bit widening, RGBA8888 channel ordering, VI_X_SCALE/VI_Y_SCALE
sampling, and AA/divot/de-dither filtering, while preserving the existing
open-status and scope.
---
Outside diff comments:
In `@crates/rustyn64-core/src/bus.rs`:
- Around line 1191-1204: Update the scanout test around the 2×2 framebuffer
fixture and its existing output assertions to validate all four RGBA8888 pixels
in row-major order. Replace the partial first/last-pixel checks with complete
coverage of the output buffer, preserving the expected px32 values for both
middle pixels.
- Around line 29-34: The `expand5` mask guard is currently redundant and
untested. In `expand5`, either remove the `v5 & 0x1F` masking or add a focused
test that passes an out-of-range value and verifies the documented masked
result, ensuring the test fails if the guard is removed.
- Around line 444-445: Update the height calculation near the VI timing
validation in bus.rs to handle V_START > V_END explicitly instead of silently
using saturating_sub; reject the reversed VI_V_VIDEO range through the existing
invalid-timing path, or document the chosen measured behavior in
docs/accuracy-ledger.md while preserving the defined (V_END − V_START) half-line
calculation.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: c85bd60c-3303-4536-89f3-df3b573be2f9
📒 Files selected for processing (3)
crates/rustyn64-core/src/bus.rsdocs/accuracy-ledger.mddocs/rdp.md
Adjudicating CodeRabbit: R-5 now cites exact N64brew sections rather than article titles -- VI_CTRL.TYPE[1:0] for the RGBA5551/8888 format select, Set Color Image / Video DAC for the 5551 bit layout and the standard 5->8 high-bit-replication widening, and VI_X_SCALE/VI_Y_SCALE + VI_CTRL (AA_MODE/DIVOT_ENABLE/dedither) for the deferred sampling and post-filters. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@docs/accuracy-ledger.md`:
- Line 188: Update the R-5 entry in the accuracy ledger to include an explicit
n64-systemtest impact statement, indicating whether the failing-assertion count
was measured; if not, state that it was not measured because the timing set has
not been run.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 6f3150bf-b9cc-4680-8c9f-f55d722d01e7
📒 Files selected for processing (1)
docs/accuracy-ledger.md
Adjudicating CodeRabbit: R-5 now carries the explicit oracle-impact statement in the entry itself -- not measured, because Bus::scanout has no runtime driver and is unreachable by the suite (count stands at 93). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lamp (T-31-004 review) Adjudicating the Antigravity review of #62: - Clear the entire frame buffer on the black fallback (was only the default region), so no stale pixels from a previous larger frame survive. - Extract the geometry decision into a pure presentable_geometry() so the FB_MAX clamp is unit-testable without configuring the core VI; new test covers zero, exact-FB_MAX, too-wide, and too-tall. - Correct the doc comment: Bus::scanout self-guards against a buffer overrun (it returns (0,0) when its output cannot hold w*h*4, the guard added in the #60 review), so a ROM cannot overrun frame.rgba; the FB_MAX clamp is a blit-geometry bound, not overrun prevention. Rejected the overrun framing: scanout's internal capacity guard (core- tested by scanout_refuses_an_undersized_buffer) makes an overrun impossible, so the post-hoc FB_MAX check is defensive, not a safety hole. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audio::tick opened by computing period_ticks() -- MASTER_HZ / sample_rate, a 64-bit divide -- and only then asked whether a sample was due. The scheduler calls it on every RCP step, about 1.04M times a frame, while at ~32 kHz the period is ~5,859 master ticks. So roughly 1,950 of every 1,951 calls divided and threw the quotient away. That one line was 3.67% of a rendering frame, the largest source line outside the CPU pipeline. Ordering, not caching: return before the divide when a schedule already exists and its next sample is still ahead. Precisely that -- the divide is still performed on the two paths that need it, when a sample is due and when next_sample_tick is zero and the first one must be anchored. "Only when due" would overstate it. Behavior-identical rather than approximately so: on the skipped path the old code either returned at the period == 0 guard or fell into a while whose condition is exactly the negation of the new test, and neither route touches a field. A memo field was rejected -- the quotient is derived from sample_rate, and caching derived state in a serialized struct would change the save-state layout (ADR 0005) to buy what the reordering buys for nothing. A-B-A in one sitting, Super Mario 64, --release, frame_cost_probe: A before 107.413 / 107.587 ms B after 103.447 / 103.175 ms A before again 107.652 ms Three A legs within 0.22%, so the session did not drift. 107.55 -> 103.31 ms, 1.041x; the conservative pairing gives 1.038x. The profile predicted 3.67%. The R-16 debug_assert is kept reachable on the new fast path, where it compiles out of release entirely, rather than firing only on the ~0.05% of calls that emit. It asserts the same invariant in the same situations the old guard did: period == 0 is reachable only when sample_rate == 0, and AI_DACRATE is 14 bits so the divide cannot floor to zero. Mutation-checked, and the existing suite failed the check. Breaking the early-out grossly is caught by seven tests, but the off-by-one that defers every sample by one RCP step left the whole workspace green -- the other AI tests advance `now` in strides of a full period and never land on the boundary. That case now has a test which goes red under the mutation and green without it. Two review rounds corrected claims that did not match the code: the new test called tick() first and asserted it anchored the schedule, when write_reg had already set next_sample_tick so that call took the early return; and docs/audio.md said the period is computed "only when a sample is actually due". Also in this change, documentation only: - The render-phase attribution map, re-measured. The previous one was taken at 138.7 ms/frame, before the scan-out memo (#216) and the RDP split-borrow skip (#219), so it was stale for exactly those buckets. Both columns now carry an explicit remainder and total, with a note that they are not like-for-like. - The 60 FPS target, bounded by measurement. CPU buckets plus scheduler dispatch are 53.5% of a frame; setting both to zero caps the win at 2.15x (48.0 ms, 20.8 FPS). ADR 0011 remains the largest single win left but is not a path to 60 FPS, and the task list's claim that it was is retired. A dynarec is presented as a proposed route with its costs -- ADR 0011 leaves the mechanism open, and unsafe is already permitted in the frontend and FFI, so it is a placement and own-ADR question rather than an impossibility. - A null result that retires the "split Latch" task without implementing it. Six pipeline.rs lines are all the same inter-stage latch copy and sum to 16.1% of a frame. Adding [u64; 9] of padding to Latch -- 120 to 192 bytes, per-cycle latch traffic 840 to 1344, +60% -- produced no sign of the roughly +10% that a transfer-bound cost predicts. perf is charging each stage's retired work to the store that ends it. Recorded with full provenance, and explicitly NOT quoting its ~1% delta: that probe was two-leg, not A-B-A, and 1% is inside session drift. Refuting a 10% prediction does not require resolving 1%. Refs #55. Retires #60.
Motivation
Phase 3 (v0.4.0 "Rasteriser"), Sprint 1 — T-31-004 part 2, the framebuffer
scan-out. Part 1 (#59) landed the VI register file; this converts the
framebuffer the FILL pipeline writes into a presentable RGBA8 frame — the
conversion that makes the pixels a picture.
Changes
Bus::scanout(&self, out) -> (w, h)readsVI_ORIGIN/VI_WIDTH/VI_CTRLand the active height from
VI_V_VIDEO, converting the framebuffer to RGBA8:16-bit RGBA5551 (5→8-bit channel expansion, 1-bit alpha to 0/255) and
32-bit RGBA8888 (direct copy).
TYPE0/1 (blank) returns(0, 0).Scope (honest)
A 1:1 scan —
VI_X_SCALE/VI_Y_SCALEresampling and the AA/divot/de-ditherpost-filters are deferred (open residual R-5); the pixel conversion itself is
exact and cited. No per-frame driver yet — the scheduler tick that calls
scanoutlands with theV_CURRENTadvance (part 3). The harness golden-framecomparison (T-31-005) is the first real consumer.
Verification
the blank-VI case. The 5→8-bit expansion is mutation-checked (dropping the
low-bit replication makes
0xFFFFmap to0xF8F8F8instead of white).cargo fmt --check,clippy --workspace --all-targets -- -D warnings,cargo test --workspace,RUSTDOCFLAGS="-D warnings" cargo doc, thethumbv7em-none-eabihfno_std build,pre-commit run markdownlint.🤖 Generated with Claude Code