Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions crates/rustysnes-frontend/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};

Expand Down Expand Up @@ -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<u8>, dims: (u32, u32)) -> (Vec<u8>, (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.
///
Expand Down Expand Up @@ -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<u8> = (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()]);
}
}
10 changes: 10 additions & 0 deletions crates/rustysnes-frontend/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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 {
Expand All @@ -233,6 +242,7 @@ impl Default for VideoConfig {
hqx_strength: 0.6,
xbrz_strength: 0.6,
hd_pack_name: None,
hide_overscan: false,
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/rustysnes-frontend/src/ui_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ impl ShellState {
}
}
});
// TODO(impl-phase): overscan.
ui.checkbox(&mut cfg.video.hide_overscan, "Hide Overscan");
});

ui.menu_button("Debug", |ui| {
Expand Down
20 changes: 18 additions & 2 deletions docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading