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..f3728a1 100644 --- a/crates/rustysnes-frontend/src/app.rs +++ b/crates/rustysnes-frontend/src/app.rs @@ -1251,6 +1251,21 @@ impl App { scratch.extend_from_slice(&active.present_staging); scratch }; + // 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.is_multiple_of(239) { + crop_overscan(fb, dims) + } else { + (fb, dims) + }; (fb, dims, info, audio_samples, debug, save_slots) }; @@ -1860,6 +1875,31 @@ 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 (`render`'s own +/// `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. +/// +/// 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 +2078,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)