From 517c0d025921ec1cd5c032bdca4c7c9dfc1320df Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 15 Jul 2026 00:49:36 -0400 Subject: [PATCH 1/3] feat(frontend): wire the View -> Hide Overscan toggle Crops the trailing "overscan" scanlines a real 4:3 CRT wouldn't reliably show. SETINI (rustysnes_ppu) already distinguishes the standard 224-line display from a game-opted-in 239-line extension; crop_overscan crops exactly that extra 15-line extension back off, once per frame, at the point where both the synchronous and emu-thread render paths converge on the bytes actually being presented - after HD-pack compositing, run-ahead, and the PresentBuffer handoff have all already run. Crops a fraction (15/239) of the current height rather than a fixed pixel count, so it stays exact under an HD-pack integer upscale too. Presentation-only, additive, false by default. 3 real unit tests. Phase A.3 of the new UI/UX-parity ladder. --- CHANGELOG.md | 13 ++++ crates/rustysnes-frontend/src/app.rs | 77 +++++++++++++++++++++++ crates/rustysnes-frontend/src/config.rs | 10 +++ crates/rustysnes-frontend/src/ui_shell.rs | 2 +- docs/frontend.md | 20 +++++- 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9268d0b..2f27878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 fix — it's also what blocks Super Multitap sub-pad 1-3 host input specifically, tracked separately in the UI/UX-parity plan's backlog. +### Added + +- **View → Hide Overscan** (Phase A.3 of the UI/UX-parity ladder). Crops the trailing "overscan" + scanlines a real 4:3 CRT wouldn't reliably show — the SNES's own `SETINI` register extends the + standard 224-line display to 239 lines (`rustysnes_ppu`); the new `app.rs`'s `crop_overscan` + crops exactly that extra 15-line extension back off, once per frame, after every other buffer + transform (HD-pack compositing, run-ahead, the `emu-thread` build's `PresentBuffer` handoff) + has already settled on the bytes actually being presented. Crops a FRACTION (`15/239`) of the + current height rather than a fixed pixel count, so it stays exact under an HD-pack integer + upscale too. Presentation-only, additive, `false` by default — byte-identical to every prior + release when unchanged. 3 real unit tests cover native resolution, an HD-pack-scaled + resolution, and that the kept bytes are untouched. + ## [1.19.0] "Afterburner" - 2026-07-15 Fifteenth release of the RustyNES-parity roadmap: an optional PGO/BOLT pipeline for the diff --git a/crates/rustysnes-frontend/src/app.rs b/crates/rustysnes-frontend/src/app.rs index 953ea5b..7b5569c 100644 --- a/crates/rustysnes-frontend/src/app.rs +++ b/crates/rustysnes-frontend/src/app.rs @@ -949,6 +949,14 @@ impl App { // cheats/watchpoints/breakpoints/port2-peripheral/voice-mute re-syncs below (post- // `v1.3.0` — see the `emu-thread` `audio_samples` block further down). let mut emu = active.core.lock().unwrap_or_else(PoisonError::into_inner); + // View -> Hide Overscan (`v1.20.0`): whether the game is CURRENTLY using the SNES's + // extended 239-line `SETINI` overscan mode, checked once here (before this frame's + // own production) and applied to `fb`/`dims` at the end of this block, after every + // other buffer transform (HD-pack, run-ahead, the `emu-thread` `PresentBuffer` + // handoff) has already settled on the bytes actually being presented — see + // `crop_overscan`'s own doc for why cropping a FRACTION of the final height there, + // rather than a fixed pixel count here, stays correct under HD-pack's own upscale. + let overscan_active = emu.fb_dims().1 > 224; // When NOT threaded, run as many whole emulated frames as real elapsed time has earned // (fixed-timestep), so emulation tracks the region rate, not the display refresh. #[cfg(not(feature = "emu-thread"))] @@ -1251,6 +1259,11 @@ impl App { scratch.extend_from_slice(&active.present_staging); scratch }; + let (fb, dims) = if config.video.hide_overscan && overscan_active { + crop_overscan(fb, dims) + } else { + (fb, dims) + }; (fb, dims, info, audio_samples, debug, save_slots) }; @@ -1860,6 +1873,30 @@ impl App { } } +/// View → Hide Overscan (`v1.20.0`): crop the trailing "overscan" scanlines a real 4:3 CRT +/// wouldn't reliably show — the SNES's own `SETINI` register extends the standard 224-line +/// display to 239 lines (`rustysnes_ppu::Ppu::visible_height`); this crops exactly that extra +/// 15-line extension back off, on the presentation side only. +/// +/// Crops a FRACTION (`15/239`) of `dims`'s CURRENT height rather than a fixed `224` pixel +/// count, so this stays correct even after HD-pack's own integer upscale has already run +/// (`app.rs`'s HD-pack compositing block runs before this) — `239 * scale * 15 / 239` reduces +/// to exactly `15 * scale` with no rounding, for any integer `scale`. Only called when the +/// caller has already confirmed the frame is actually in the 239-line mode (see `render`'s own +/// `overscan_active` capture) — this function assumes that, it does not re-check it, since a +/// 224-line frame's `dims.1 * 15 / 239` would NOT reliably equal a clean crop-to-224 amount. +/// +/// Truncates `fb` in place rather than allocating a fresh buffer, matching this module's own +/// "steady-state zero allocations" convention for per-frame buffer transforms. +fn crop_overscan(mut fb: Vec, dims: (u32, u32)) -> (Vec, (u32, u32)) { + let (w, h) = dims; + let crop_rows = h * 15 / 239; + let new_h = h - crop_rows; + let stride = w as usize * 4; + fb.truncate(stride * new_h as usize); + (fb, (w, new_h)) +} + /// Read a ROM file into `emu`, then best-effort install any required coprocessor firmware and a /// `.srm` battery save sitting next to the ROM. Returns a human-readable status line. /// @@ -2038,3 +2075,43 @@ mod hotkey_tests { ); } } + +#[cfg(test)] +mod overscan_tests { + use super::crop_overscan; + + #[test] + fn crops_exactly_15_of_239_lines_at_native_resolution() { + let (w, h) = (256u32, 239u32); + let fb = vec![0xAAu8; w as usize * h as usize * 4]; + let (cropped, dims) = crop_overscan(fb, (w, h)); + assert_eq!(dims, (256, 224)); + assert_eq!(cropped.len(), 256 * 224 * 4); + } + + #[test] + fn crops_the_same_fraction_under_an_hd_pack_upscale() { + // HD-pack scales both dims by an integer factor; the crop must scale with it too. + const SCALE: u32 = 3; + let (w, h) = (256 * SCALE, 239 * SCALE); + let fb = vec![0xBBu8; w as usize * h as usize * 4]; + let (cropped, dims) = crop_overscan(fb, (w, h)); + assert_eq!(dims, (256 * SCALE, 224 * SCALE)); + assert_eq!( + cropped.len(), + (256 * SCALE) as usize * (224 * SCALE) as usize * 4 + ); + } + + #[test] + fn keeps_the_leading_bytes_untouched() { + // The crop must drop trailing rows only -- the leading (kept) bytes are byte-identical, + // not re-derived. + let (w, h) = (4u32, 239u32); + let fb: Vec = (0..w as usize * h as usize * 4) + .map(|i| u8::try_from(i % 256).unwrap()) + .collect(); + let (cropped, _dims) = crop_overscan(fb.clone(), (w, h)); + assert_eq!(cropped, fb[..cropped.len()]); + } +} diff --git a/crates/rustysnes-frontend/src/config.rs b/crates/rustysnes-frontend/src/config.rs index 373b90f..7992129 100644 --- a/crates/rustysnes-frontend/src/config.rs +++ b/crates/rustysnes-frontend/src/config.rs @@ -219,6 +219,15 @@ pub struct VideoConfig { /// posture (`port2_peripheral`, `rewind`, …) — an inert value in a build that can't act on /// it, not a compile-time-gated field. pub hd_pack_name: Option, + /// Crop the trailing "overscan" scanlines a real 4:3 CRT wouldn't reliably show (`v1.20.0`, + /// View → Hide Overscan). SNES hardware's own `SETINI` register (`rustysnes_ppu`) already + /// distinguishes the standard 224-line display from an extended 239-line one a game can + /// opt into — this toggle crops exactly that extra 15-line extension back off on the + /// PRESENTATION side only (`app.rs`'s `crop_overscan`), the same "display-only, never the + /// deterministic core" boundary every other post-filter in this module already respects. + /// Additive, `false` by default — byte-identical presentation to every prior release when + /// unchanged. + pub hide_overscan: bool, } impl Default for VideoConfig { @@ -233,6 +242,7 @@ impl Default for VideoConfig { hqx_strength: 0.6, xbrz_strength: 0.6, hd_pack_name: None, + hide_overscan: false, } } } diff --git a/crates/rustysnes-frontend/src/ui_shell.rs b/crates/rustysnes-frontend/src/ui_shell.rs index 286caf0..40d1b0b 100644 --- a/crates/rustysnes-frontend/src/ui_shell.rs +++ b/crates/rustysnes-frontend/src/ui_shell.rs @@ -579,7 +579,7 @@ impl ShellState { } } }); - // TODO(impl-phase): overscan. + ui.checkbox(&mut cfg.video.hide_overscan, "Hide Overscan"); }); ui.menu_button("Debug", |ui| { diff --git a/docs/frontend.md b/docs/frontend.md index bd1641e..04327f7 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -197,8 +197,24 @@ Settings → Video (a radio row + per-filter strength sliders) or the View → P display) is still worth the maintainer confirming on their own machine before release. - **Not built** (documented scope cuts, not silent gaps — unrevisited from `v1.2.0`'s original call, not a `v1.12.0` finding): RustyNES's NTSC composite-signal simulation and RetroArch - `.slangp`/`.cgp` shader-preset import both remain explicitly out of scope. Overscan cropping - remains a separate, pre-existing `TODO(impl-phase)` in the View menu. + `.slangp`/`.cgp` shader-preset import both remain explicitly out of scope. + +### Hide Overscan (`v1.20.0`) + +View → Hide Overscan crops the trailing "overscan" scanlines a real 4:3 CRT wouldn't reliably +show. This is distinct from every other post-filter above — it's a scanline COUNT crop, not a +pixel-shader effect, and it's tied to a real SNES hardware register: `SETINI` (`rustysnes_ppu`) +lets a game extend the standard 224-line display to 239 lines; `app.rs`'s `crop_overscan` crops +exactly that extra 15-line extension back off, once per frame, after every other buffer transform +(HD-pack compositing, run-ahead, the `emu-thread` build's `PresentBuffer` handoff) has already +settled on the bytes actually being presented. Crops a FRACTION (`15/239`) of the current height +rather than a fixed `224` pixel count, so it stays exact under an HD-pack integer upscale too +(`239 * scale * 15 / 239` reduces to exactly `15 * scale`, no rounding, for any integer `scale`). +Presentation-only — the deterministic core's own framebuffer is untouched, matching every other +filter's determinism-boundary posture (`docs/adr/0004`). Additive, `config.video.hide_overscan` +defaults to `false` — byte-identical presentation to every prior release when unchanged. 3 real +unit tests (`app.rs`'s `overscan_tests` module) cover native resolution, an HD-pack-scaled +resolution, and that the kept (leading) bytes are untouched, not re-derived. ## HD texture packs (`v1.3.0`, `hd-pack` feature) From f14e40040aca8a470419a4402a9f1bfefe018f2b Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 15 Jul 2026 01:10:40 -0400 Subject: [PATCH 2/3] fix(frontend): recompute overscan flag from finalized presented dims emu.fb_dims() was read before run-ahead/HD-pack/the emu-thread PresentBuffer handoff settle on the actual presented (fb, dims), so a resolution switch mid-frame could desync the two, cropping a 224-line frame or skipping the crop on a 239-line one. Check dims.1 % 239 == 0 against the finalized dims at the crop site instead (found in review: gemini-code-assist and Copilot both independently flagged this). Co-Authored-By: Claude Sonnet 5 --- crates/rustysnes-frontend/src/app.rs | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/rustysnes-frontend/src/app.rs b/crates/rustysnes-frontend/src/app.rs index 7b5569c..3ba6ddd 100644 --- a/crates/rustysnes-frontend/src/app.rs +++ b/crates/rustysnes-frontend/src/app.rs @@ -949,14 +949,6 @@ impl App { // cheats/watchpoints/breakpoints/port2-peripheral/voice-mute re-syncs below (post- // `v1.3.0` — see the `emu-thread` `audio_samples` block further down). let mut emu = active.core.lock().unwrap_or_else(PoisonError::into_inner); - // View -> Hide Overscan (`v1.20.0`): whether the game is CURRENTLY using the SNES's - // extended 239-line `SETINI` overscan mode, checked once here (before this frame's - // own production) and applied to `fb`/`dims` at the end of this block, after every - // other buffer transform (HD-pack, run-ahead, the `emu-thread` `PresentBuffer` - // handoff) has already settled on the bytes actually being presented — see - // `crop_overscan`'s own doc for why cropping a FRACTION of the final height there, - // rather than a fixed pixel count here, stays correct under HD-pack's own upscale. - let overscan_active = emu.fb_dims().1 > 224; // When NOT threaded, run as many whole emulated frames as real elapsed time has earned // (fixed-timestep), so emulation tracks the region rate, not the display refresh. #[cfg(not(feature = "emu-thread"))] @@ -1259,7 +1251,17 @@ impl App { scratch.extend_from_slice(&active.present_staging); scratch }; - let (fb, dims) = if config.video.hide_overscan && overscan_active { + // View -> Hide Overscan (`v1.20.0`): whether the presented frame is CURRENTLY in the + // SNES's extended 239-line `SETINI` overscan mode, checked HERE against the finalized + // `dims` — after every buffer transform (HD-pack, run-ahead, the `emu-thread` + // `PresentBuffer` handoff) has already settled on the bytes actually being presented + // — rather than an earlier `emu.fb_dims()` read, which can desync from `dims` right + // at a resolution switch (found in review: PR #115, both bots independently). `dims.1` + // is always a multiple of 224 or 239 (times an integer upscale), so this check is + // race-free and never confuses the two. See `crop_overscan`'s own doc for why cropping + // a FRACTION of the height, rather than a fixed pixel count, stays correct under + // HD-pack's own upscale. + let (fb, dims) = if config.video.hide_overscan && dims.1 % 239 == 0 { crop_overscan(fb, dims) } else { (fb, dims) @@ -1882,9 +1884,10 @@ impl App { /// count, so this stays correct even after HD-pack's own integer upscale has already run /// (`app.rs`'s HD-pack compositing block runs before this) — `239 * scale * 15 / 239` reduces /// to exactly `15 * scale` with no rounding, for any integer `scale`. Only called when the -/// caller has already confirmed the frame is actually in the 239-line mode (see `render`'s own -/// `overscan_active` capture) — this function assumes that, it does not re-check it, since a -/// 224-line frame's `dims.1 * 15 / 239` would NOT reliably equal a clean crop-to-224 amount. +/// caller has already confirmed the frame is actually in the 239-line mode (`render`'s own +/// `dims.1 % 239 == 0` check against the FINALIZED presented dims) — this function assumes +/// that, it does not re-check it, since a 224-line frame's `dims.1 * 15 / 239` would NOT +/// reliably equal a clean crop-to-224 amount. /// /// Truncates `fb` in place rather than allocating a fresh buffer, matching this module's own /// "steady-state zero allocations" convention for per-frame buffer transforms. From 3a58070c3252793477f1dd327a37d233729d4145 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 15 Jul 2026 01:55:33 -0400 Subject: [PATCH 3/3] fix(lint): use is_multiple_of instead of manual modulo check CI's --features emu-thread lint job (a combination not covered by --features full, which excludes emu-thread) flagged clippy::manual_is_multiple_of on the dims.1 % 239 == 0 overscan check. Co-Authored-By: Claude Sonnet 5 --- crates/rustysnes-frontend/src/app.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rustysnes-frontend/src/app.rs b/crates/rustysnes-frontend/src/app.rs index 3ba6ddd..f3728a1 100644 --- a/crates/rustysnes-frontend/src/app.rs +++ b/crates/rustysnes-frontend/src/app.rs @@ -1261,7 +1261,7 @@ impl App { // race-free and never confuses the two. See `crop_overscan`'s own doc for why cropping // a FRACTION of the height, rather than a fixed pixel count, stays correct under // HD-pack's own upscale. - let (fb, dims) = if config.video.hide_overscan && dims.1 % 239 == 0 { + let (fb, dims) = if config.video.hide_overscan && dims.1.is_multiple_of(239) { crop_overscan(fb, dims) } else { (fb, dims) @@ -1885,7 +1885,7 @@ impl App { /// (`app.rs`'s HD-pack compositing block runs before this) — `239 * scale * 15 / 239` reduces /// to exactly `15 * scale` with no rounding, for any integer `scale`. Only called when the /// caller has already confirmed the frame is actually in the 239-line mode (`render`'s own -/// `dims.1 % 239 == 0` check against the FINALIZED presented dims) — this function assumes +/// `dims.1.is_multiple_of(239)` check against the FINALIZED presented dims) — this function assumes /// that, it does not re-check it, since a 224-line frame's `dims.1 * 15 / 239` would NOT /// reliably equal a clean crop-to-224 amount. ///