From 4ab0a4e1cdc79cb47e9be5ce628774f878006647 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 22 Jul 2026 11:03:42 +0100 Subject: [PATCH 1/4] denise: resolve each SHRES 35 ns sample through the full palette Super-hi-res output built the framebuffer pixel's colour as palette[(left & 3) | (right & 3) << 2] - an encoded pair of the two 35 ns samples covered by the 70 ns framebuffer pixel, truncating every sample to two bitplanes. That encoding only ever made sense for ECS Denise's 2-plane SHRES limit; real Denise/Lisa resolve every 35 ns sample through the full palette pipeline. On a 4-plane AGA SHRES screen (the Linux/m68k amifb 31 kHz console, FMODE=3) the pair encoding turned solid colour 14 into palette[10], colour 7 into palette[15], and glyph edges into palette[12]/palette[3] - a green Tux, whitened grey text, and red/cyan edge fringes that looked like a bitplane phase error (the captured plane words and per-plane delays were verified identical). Resolve each half independently through denise_playfield_output (which routes the AGA BPLAM/dual-playfield/EHB path or the OCS/ECS path by itself) and blend the two resolved colours per channel into the one hires-pitch framebuffer pixel. The blend is a framebuffer-pitch compromise, not hardware; a true 35 ns output path stays a TODO shared with SHRES sprite resolution. The composite sample now also keeps the full index bits for collisions and sprite priority instead of masking to two planes. --- docs/internals/video.md | 10 ++++++++ src/video/bitplane/output.rs | 29 +++++++++++++-------- src/video/bitplane/tests.rs | 49 +++++++++++++++++++++++++++++++----- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/docs/internals/video.md b/docs/internals/video.md index 3a53e21c..7d2e7cf8 100644 --- a/docs/internals/video.md +++ b/docs/internals/video.md @@ -229,6 +229,16 @@ next pulse, so the picture sits where the mode's own porches place it; without a programmed sync the whole line maps onto the glass time-linearly (each colour clock covers 227/line_cck of a standard clock's width). +Super-hi-res output: the framebuffer pixel pitch is hi-res (70 ns), so each +framebuffer pixel covers two 35 ns SHRES samples. Denise/Lisa resolve every +35 ns sample through the full palette pipeline (ECS Denise carries at most +two bitplanes into SHRES; AGA Lisa runs the complete 8-bit index path, +e.g. the 4-plane FMODE=3 Linux amifb console), so the renderer resolves +each half independently and blends the two resulting colours into the +framebuffer pixel. The blend is a framebuffer-pitch compromise, not +hardware; a true 35 ns output path is a TODO shared with SHRES sprite +resolution. + Two vertical edge cases the replay honours: - A display window can open above the captured canvas. Bitplane pointers are diff --git a/src/video/bitplane/output.rs b/src/video/bitplane/output.rs index dff9e646..fe2f79dc 100644 --- a/src/video/bitplane/output.rs +++ b/src/video/bitplane/output.rs @@ -214,30 +214,37 @@ pub(super) fn shres_composite_sample( right: DeniseBitplaneSample, ) -> DeniseBitplaneSample { DeniseBitplaneSample { - idx: (left.idx | right.idx) & 0x03, - nplanes: left.nplanes.max(right.nplanes).min(2), + idx: left.idx | right.idx, + nplanes: left.nplanes.max(right.nplanes), active: left.active || right.active, } } -pub(super) fn shres_palette_index(left_idx: u8, right_idx: u8) -> usize { - ((left_idx as usize) & 0x03) | (((right_idx as usize) & 0x03) << 2) +/// Per-channel mean of two 24-bit colours without intermediate overflow. +pub(super) fn rgb24_blend_halves(a: u32, b: u32) -> u32 { + (a & b) + (((a ^ b) & 0x00FE_FEFE) >> 1) } +/// Super-hi-res output at the framebuffer's 70 ns pitch. Denise/Lisa resolve +/// every 35 ns sample through the full palette pipeline (ECS Denise carries +/// at most two bitplanes into SHRES; AGA Lisa runs the complete 8-bit index +/// path), so resolve each half independently and blend the two colours into +/// the one framebuffer pixel. The blend is a framebuffer-pitch compromise, +/// not hardware. TODO: emit true 35 ns samples once the output path grows a +/// super-hi-res canvas; the sprite path carries the same limitation. pub(super) fn denise_shres_playfield_output( + control: ControlState, palette: Palette, left_idx: u8, right_idx: u8, ham_color: &mut u32, ) -> DenisePlayfieldOutput { - let color_idx = shres_palette_index(left_idx, right_idx); - let color_latch = palette[color_idx]; - let color = rgb12_to_rgb24(color_rgb12(color_latch)); - *ham_color = color; + let left = denise_playfield_output(control, palette, left_idx, ham_color); + let right = denise_playfield_output(control, palette, right_idx, ham_color); DenisePlayfieldOutput { - color, - color_latch, - pf_mask: u8::from((left_idx | right_idx) & 0x03 != 0) * 2, + color: rgb24_blend_halves(left.color, right.color), + color_latch: right.color_latch, + pf_mask: left.pf_mask | right.pf_mask, } } diff --git a/src/video/bitplane/tests.rs b/src/video/bitplane/tests.rs index 07443a6a..d7848a11 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -6164,24 +6164,61 @@ fn aga_playfield_output_resolves_ham8_ehb_and_bplam() { } #[test] -fn shres_playfield_output_selects_encoded_35ns_color_pair() { +fn shres_playfield_output_resolves_each_35ns_sample_through_the_palette() { let mut palette = Palette::new(); palette.write_ocs(1, 0x00F0); palette.write_ocs(4, 0x0F00); palette.write_ocs(5, 0x000F); + let control = ControlState::default(); let mut ham_color = 0; + // A solid run of colour 1 keeps colour 1: each 35 ns half resolves + // palette[1], never a pair-encoded entry. assert_eq!( - denise_shres_playfield_output(palette, 0, 1, &mut ham_color), + denise_shres_playfield_output(control, palette, 1, 1, &mut ham_color), DenisePlayfieldOutput { - color: rgb12_to_rgb24(0x0F00), - color_latch: 0x0F00, + color: rgb12_to_rgb24(0x00F0), + color_latch: 0x00F0, pf_mask: 2, } ); + // A background/colour-1 pair blends the two resolved colours into the + // 70 ns framebuffer pixel. assert_eq!( - denise_shres_playfield_output(palette, 1, 1, &mut ham_color).color, - rgb12_to_rgb24(0x000F) + denise_shres_playfield_output(control, palette, 0, 1, &mut ham_color), + DenisePlayfieldOutput { + color: rgb24_blend_halves(rgb12_to_rgb24(0x0000), rgb12_to_rgb24(0x00F0)), + color_latch: 0x00F0, + pf_mask: 2, + } + ); +} + +#[test] +fn aga_shres_playfield_output_keeps_four_plane_indices() { + // Regression: the Debian/m68k amifb console (SHRES, 4 bitplanes, + // FMODE=3) rendered index 14 (yellow) as index 10 (light green) and + // index 7 (grey) as index 15 (white) while the pair encoding truncated + // every 35 ns sample to two planes. + let mut palette = Palette::new(); + palette.write_ocs(7, 0x0AAA); + palette.write_ocs(10, 0x05F5); + palette.write_ocs(14, 0x0FF5); + palette.write_ocs(15, 0x0FFF); + let control = ControlState { + agnus_revision: AgnusRevision::AgaAlice, + bplcon0: 0x4240, // 4 planes, SHRES + ..ControlState::default() + }; + let mut ham_color = 0; + + assert_eq!( + denise_shres_playfield_output(control, palette, 14, 14, &mut ham_color).color, + palette.rgb24(14) & 0x00FF_FFFF + ); + assert_eq!( + denise_shres_playfield_output(control, palette, 7, 7, &mut ham_color).color, + palette.rgb24(7) & 0x00FF_FFFF ); } From 11e269b42e885b865482886c5f3d805c39dda3e6 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 22 Jul 2026 11:04:38 +0100 Subject: [PATCH 2/4] video: anchor the programmable-scan presentation at the vertical sync pulse The horizontal half of this shipped in #247: with VARHSYEN programmed, the glass shows the line from the HSYNC trailing edge to the next pulse. Vertically, a programmable frame still stretched its captured visible rows over the full glass height, so the amifb 31 kHz console's 400 lines filled all 537 glass rows and the picture looked vertically inflated next to a real multisync (whose vertical deflection locks to the programmed sync: 400 of the frame's 445 post-sync lines). Mirror the horizontal model: programmable_vsync_window (VARBEAMEN + VARVSYEN, sane VSSTRT < VSSTOP <= VTOTAL) exposes the programmed pulse, the bus latches a presentation v-window at the frame wrap beside the h-window (serde-skipped, recomputed after a state load), RenderInput carries it to the presentation pass, and apply_presentation_v_window places the captured rows inside the frame-minus-sync glass span with blanked border rows where the mode's own porches put them. Modes without a programmed vertical sync (and all standard scans) present exactly as before. The COPPERLINE_DBG_SHOT debug-screenshot path applies the same window. --- crates/copperline-web/src/lib.rs | 1 + docs/internals/video.md | 16 +++-- src/bin/bench.rs | 1 + src/bus.rs | 41 ++++++++++++ src/bus/frame_capture.rs | 2 + src/chipset/agnus.rs | 17 +++++ src/cpu.rs | 36 ++++++----- src/video/bitplane.rs | 11 ++++ src/video/present_common.rs | 104 ++++++++++++++++++++++++++++++- src/video/window.rs | 2 + src/video/window/present.rs | 1 + 11 files changed, 210 insertions(+), 22 deletions(-) diff --git a/crates/copperline-web/src/lib.rs b/crates/copperline-web/src/lib.rs index ab1a398f..fd319fe0 100644 --- a/crates/copperline-web/src/lib.rs +++ b/crates/copperline-web/src/lib.rs @@ -326,6 +326,7 @@ impl WebEmu { &mut self.fb, geometry, self.emu.bus().frame_presentation_h_window(), + self.emu.bus().frame_presentation_v_window(), visible_start_vpos, 0, Overscan::Tv, diff --git a/docs/internals/video.md b/docs/internals/video.md index 7d2e7cf8..7fa7d89c 100644 --- a/docs/internals/video.md +++ b/docs/internals/video.md @@ -222,12 +222,16 @@ origin. Under VARBEAMEN, Denise's horizontal counter restarts at 0 with the programmable line rather than free-running at the standard 15 kHz phase, so the DIW and sprite comparators sit later on the canvas by that origin difference (Linux/m68k amifb and the KS3.1 DblPAL screen both program their -windows against the zero origin). Horizontally, a programmable frame is -presented like a multisync monitor: when the mode programs its sync pulse -(VARHSYEN), the glass shows the line from the HSYNC trailing edge to the -next pulse, so the picture sits where the mode's own porches place it; -without a programmed sync the whole line maps onto the glass time-linearly -(each colour clock covers 227/line_cck of a standard clock's width). +windows against the zero origin). A programmable frame is presented like a +multisync monitor on both axes: when the mode programs its sync pulses, the +glass shows the line from the HSYNC trailing edge to the next pulse +(VARHSYEN) and the frame from the VSYNC trailing edge to the next pulse +(VARVSYEN), so the picture sits where the mode's own porches place it, with +blanked border rows above and below the programmed vertical window. Without +a programmed horizontal sync the whole line maps onto the glass +time-linearly (each colour clock covers 227/line_cck of a standard clock's +width); without a programmed vertical sync the captured rows keep covering +the full glass height. Super-hi-res output: the framebuffer pixel pitch is hi-res (70 ns), so each framebuffer pixel covers two 35 ns SHRES samples. Denise/Lisa resolve every diff --git a/src/bin/bench.rs b/src/bin/bench.rs index 84a7b3fc..759acc66 100644 --- a/src/bin/bench.rs +++ b/src/bin/bench.rs @@ -145,6 +145,7 @@ fn main() -> Result<()> { &mut fb, geometry, emu.bus().frame_presentation_h_window(), + emu.bus().frame_presentation_v_window(), visible_start_vpos, 0, Overscan::Tv, diff --git a/src/bus.rs b/src/bus.rs index 47e983ac..d0a53d6a 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -869,6 +869,10 @@ pub struct Bus { current_frame_presentation_h_window: Option<(i32, u32)>, #[serde(skip)] last_frame_presentation_h_window: Option<(i32, u32)>, + #[serde(skip)] + current_frame_presentation_v_window: Option<(i32, u32)>, + #[serde(skip)] + last_frame_presentation_v_window: Option<(i32, u32)>, lazy_collision_vpos: u32, lazy_collision_hpos: u32, /// Sticky gate for the per-frame collision accumulation. Collision results @@ -2405,6 +2409,8 @@ impl Bus { ), current_frame_presentation_h_window: None, last_frame_presentation_h_window: None, + current_frame_presentation_v_window: None, + last_frame_presentation_v_window: None, last_frame_geometry: FrameGeometry::standard( RENDER_VISIBLE_START_VPOS, PAL_LINES, @@ -3062,6 +3068,8 @@ impl Bus { FrameGeometry::standard(RENDER_VISIBLE_START_VPOS, frame_lines, false); self.current_frame_presentation_h_window = None; self.last_frame_presentation_h_window = None; + self.current_frame_presentation_v_window = None; + self.last_frame_presentation_v_window = None; self.lazy_collision_vpos = RENDER_VISIBLE_START_VPOS; self.lazy_collision_hpos = RENDER_COPPER_WAIT_HPOS_FB0; self.collision_tracking_active = false; @@ -3288,6 +3296,8 @@ impl Bus { self.last_frame_geometry.visible_start_vpos = self.current_frame_visible_start_vpos; self.current_frame_presentation_h_window = self.compute_presentation_h_window(); self.last_frame_presentation_h_window = self.current_frame_presentation_h_window; + self.current_frame_presentation_v_window = self.compute_presentation_v_window(); + self.last_frame_presentation_v_window = self.current_frame_presentation_v_window; self.lazy_collision_vpos = self.current_frame_visible_start_vpos; self.ocs_same_line_diw_start_blocked_vpos = None; self.reset_frame_capture_buffers(); @@ -4662,6 +4672,37 @@ impl Bus { Some((src_x0, visible_cck * 4)) } + /// The vertical glass window for presenting a programmable scan, as + /// (captured rows above the glass top negated, glass rows): a multisync + /// monitor locks its vertical deflection to the programmed sync pulse, + /// so the glass covers the frame from the sync trailing edge to the + /// next pulse and the picture sits where the mode's own porches place + /// it. The offset is the captured window's first line relative to + /// VSSTOP. None on standard frames or when the mode leaves vertical + /// sync unprogrammed (the presentation then keeps stretching the + /// captured rows over the full glass height). + pub fn frame_presentation_v_window(&self) -> Option<(i32, u32)> { + if self.last_frame_render_base.is_some() { + self.last_frame_presentation_v_window + } else { + self.current_frame_presentation_v_window + } + } + + /// Vertical counterpart of `compute_presentation_h_window`, latched at + /// the same frame wrap. + pub(crate) fn compute_presentation_v_window(&self) -> Option<(i32, u32)> { + let geometry = self.current_frame_geometry; + if !geometry.programmable { + return None; + } + let (vsstrt, vsstop) = self.agnus.programmable_vsync_window()?; + let sync_len = vsstop - vsstrt; + let glass_lines = geometry.frame_lines.saturating_sub(sync_len).max(1); + let offset = geometry.visible_start_vpos as i32 - vsstop as i32; + Some((offset, glass_lines)) + } + pub fn frame_geometry(&self) -> FrameGeometry { if self.last_frame_render_base.is_some() { self.last_frame_geometry diff --git a/src/bus/frame_capture.rs b/src/bus/frame_capture.rs index 008193c4..da3a9f16 100644 --- a/src/bus/frame_capture.rs +++ b/src/bus/frame_capture.rs @@ -25,6 +25,7 @@ impl Bus { self.last_frame_visible_start_vpos = self.current_frame_visible_start_vpos; self.last_frame_geometry = self.current_frame_geometry; self.last_frame_presentation_h_window = self.current_frame_presentation_h_window; + self.last_frame_presentation_v_window = self.current_frame_presentation_v_window; self.last_frame_render_events = std::mem::take(&mut self.current_frame_render_events); } else { self.last_frame_render_base = None; @@ -143,6 +144,7 @@ impl Bus { self.current_frame_render_base.long_field = self.agnus.lof; self.current_frame_geometry = self.compute_frame_geometry(); self.current_frame_presentation_h_window = self.compute_presentation_h_window(); + self.current_frame_presentation_v_window = self.compute_presentation_v_window(); if self.current_frame_geometry.programmable { self.current_frame_visible_start_vpos = self.current_frame_geometry.visible_start_vpos; self.lazy_collision_vpos = self.current_frame_visible_start_vpos; diff --git a/src/chipset/agnus.rs b/src/chipset/agnus.rs index 6d60f196..7c5f974f 100644 --- a/src/chipset/agnus.rs +++ b/src/chipset/agnus.rs @@ -801,6 +801,23 @@ impl Agnus { (strt < stop && stop <= u32::from(self.htotal)).then_some((strt, stop)) } + /// The programmed vertical sync pulse (VSSTRT..VSSTOP, lines) of a + /// VARBEAMEN scan whose vertical sync generator is under programmable + /// control, or None when the mode leaves sync on the hardware defaults + /// or programs a degenerate pulse. + pub fn programmable_vsync_window(&self) -> Option<(u32, u32)> { + const BEAMCON0_VARVSYEN: u16 = 1 << 9; + if !self.revision.is_ecs() + || self.beamcon0 & BEAMCON0_VARBEAMEN == 0 + || self.beamcon0 & BEAMCON0_VARVSYEN == 0 + { + return None; + } + let strt = u32::from(self.vsstrt); + let stop = u32::from(self.vsstop); + (strt < stop && stop <= u32::from(self.vtotal)).then_some((strt, stop)) + } + pub fn programmable_line_cck(&self) -> Option { if !self.revision.is_ecs() || self.beamcon0 & BEAMCON0_VARBEAMEN == 0 { return None; diff --git a/src/cpu.rs b/src/cpu.rs index 418eedab..1b6b6b86 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1250,34 +1250,42 @@ impl M68kMachine { let mut fb = vec![0u32; crate::video::MAX_FB_PIXELS]; crate::video::bitplane::render(&mut self.bus.bus, &mut fb); let geometry = self.bus.bus.frame_geometry(); + let mut present_rows = geometry.visible_lines; if !geometry.programmable { let visible_start = self.bus.bus.frame_visible_start_vpos(); crate::video::present_common::center_present_frame_for_visible_start( &mut fb, visible_start, ); - } else if let Some((src_x0, src_w)) = self.bus.bus.frame_presentation_h_window() { - crate::screenshot::stretch_rows_x_window( - &mut fb, - crate::video::FB_WIDTH, - geometry.visible_lines, - src_x0, - src_w, - ); - } else if geometry.line_cck != 227 { - crate::screenshot::stretch_rows_x( + } else { + if let Some((src_x0, src_w)) = self.bus.bus.frame_presentation_h_window() { + crate::screenshot::stretch_rows_x_window( + &mut fb, + crate::video::FB_WIDTH, + geometry.visible_lines, + src_x0, + src_w, + ); + } else if geometry.line_cck != 227 { + crate::screenshot::stretch_rows_x( + &mut fb, + crate::video::FB_WIDTH, + geometry.visible_lines, + geometry.line_cck, + 227, + ); + } + present_rows = crate::video::present_common::apply_presentation_v_window( &mut fb, - crate::video::FB_WIDTH, geometry.visible_lines, - geometry.line_cck, - 227, + self.bus.bus.frame_presentation_v_window(), ); } match crate::screenshot::save_scaled_y( std::path::Path::new(&path), &fb, crate::video::FB_WIDTH as u32, - geometry.visible_lines as u32, + present_rows as u32, crate::video::present_height() as u32, ) { Ok(()) => log::info!(" screenshot: {path}"), diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index 40e00793..860bcefa 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -3283,6 +3283,9 @@ pub struct RenderInput { /// Sync-anchored glass window for programmable scans /// ([`crate::bus::Bus::frame_presentation_h_window`]). presentation_h_window: Option<(i32, u32)>, + /// Vertical counterpart + /// ([`crate::bus::Bus::frame_presentation_v_window`]). + presentation_v_window: Option<(i32, u32)>, visible_start_vpos: u32, palette_split: (Palette, Palette, bool), render_base: RenderRegisterSnapshot, @@ -3319,6 +3322,7 @@ impl RenderInput { Self { geometry: bus.frame_geometry(), presentation_h_window: bus.frame_presentation_h_window(), + presentation_v_window: bus.frame_presentation_v_window(), visible_start_vpos: bus.frame_visible_start_vpos(), palette_split: bus.frame_palette_split(), render_base: bus.frame_render_base(), @@ -3355,6 +3359,7 @@ impl RenderInput { } self.geometry = bus.frame_geometry(); self.presentation_h_window = bus.frame_presentation_h_window(); + self.presentation_v_window = bus.frame_presentation_v_window(); self.visible_start_vpos = bus.frame_visible_start_vpos(); self.palette_split = bus.frame_palette_split(); self.render_base = bus.frame_render_base(); @@ -3408,6 +3413,10 @@ impl RenderInput { self.presentation_h_window } + pub fn presentation_v_window(&self) -> Option<(i32, u32)> { + self.presentation_v_window + } + pub fn visible_start_vpos(&self) -> u32 { self.visible_start_vpos } @@ -4626,6 +4635,7 @@ fn render_planned_playfield_line( ( shres_composite_sample(left, right), denise_shres_playfield_output( + pixel_control, palette, left.idx & plane_mask, right.idx & plane_mask, @@ -5037,6 +5047,7 @@ fn draw_manual_bpl_word( .sample(source_control, native_idx + 1) .unwrap_or_default(); denise_shres_playfield_output( + source_control, source_palette, left_sample.idx & plane_mask, right_sample.idx & plane_mask, diff --git a/src/video/present_common.rs b/src/video/present_common.rs index 51b95bc5..ff7b8af8 100644 --- a/src/video/present_common.rs +++ b/src/video/present_common.rs @@ -97,6 +97,7 @@ pub fn post_process_rendered_field( fb: &mut [u32], geometry: FrameGeometry, presentation_h_window: Option<(i32, u32)>, + presentation_v_window: Option<(i32, u32)>, visible_start_vpos: u32, h_shift: usize, overscan: Overscan, @@ -112,7 +113,9 @@ pub fn post_process_rendered_field( if overscan == Overscan::Tv { mask_present_frame_to_tv(fb, h_shift, standard_window_top_row(visible_start_vpos)); } - } else if let Some((src_x0, src_w)) = presentation_h_window { + return field_rows; + } + if let Some((src_x0, src_w)) = presentation_h_window { // A multisync monitor locks its horizontal deflection to the // programmed sync pulse: the glass shows the line from the sync // trailing edge to the next pulse, centring the picture the way @@ -125,7 +128,53 @@ pub fn post_process_rendered_field( // would). screenshot::stretch_rows_x(fb, FB_WIDTH, field_rows, geometry.line_cck, 227); } - field_rows + apply_presentation_v_window(fb, field_rows, presentation_v_window) +} + +/// Vertical counterpart of the sync-anchored horizontal window: place the +/// captured rows inside the glass's full vertical span (the frame minus the +/// programmed sync pulse), bordered with blanked rows where the mode's own +/// porches put them, and return the new row count. Without a programmed +/// vertical sync the captured rows keep covering the whole glass height. +pub fn apply_presentation_v_window( + fb: &mut [u32], + field_rows: usize, + presentation_v_window: Option<(i32, u32)>, +) -> usize { + let Some((v_offset, glass_rows)) = presentation_v_window else { + return field_rows; + }; + let buf_rows = fb.len() / FB_WIDTH; + let glass_rows = (glass_rows as usize).min(buf_rows).max(1); + if glass_rows <= field_rows { + return field_rows; + } + // Rows the sync-anchored glass top cuts off the capture (offset < 0) + // vs. blanked glass rows above the captured window (offset > 0). + let skip_top = (-v_offset).max(0) as usize; + let pad_top = (v_offset).max(0) as usize; + let black = rgba(0, 0, 0); + if skip_top >= field_rows || pad_top >= glass_rows { + fb[..glass_rows * FB_WIDTH].fill(black); + return glass_rows; + } + let content_rows = (field_rows - skip_top).min(glass_rows - pad_top); + if pad_top > skip_top { + for row in (0..content_rows).rev() { + let src = (skip_top + row) * FB_WIDTH; + let dst = (pad_top + row) * FB_WIDTH; + fb.copy_within(src..src + FB_WIDTH, dst); + } + } else if pad_top < skip_top { + for row in 0..content_rows { + let src = (skip_top + row) * FB_WIDTH; + let dst = (pad_top + row) * FB_WIDTH; + fb.copy_within(src..src + FB_WIDTH, dst); + } + } + fb[..pad_top * FB_WIDTH].fill(black); + fb[(pad_top + content_rows) * FB_WIDTH..glass_rows * FB_WIDTH].fill(black); + glass_rows } /// `[display] overscan = "tv"`: black out the deep-overscan margins like the @@ -258,4 +307,55 @@ mod tests { // shows masked black columns. assert!(TV_PAL_CAPTURED_SOURCE_X >= tv_source_h_bounds().0); } + + fn v_window_fixture(field_rows: usize, total_rows: usize) -> Vec { + // Each captured row is tagged with its index + 1 in every column so + // relocation is visible; rows past field_rows start as garbage the + // applier must overwrite or ignore. + let mut fb = vec![0xDEAD_BEEF; total_rows * FB_WIDTH]; + for row in 0..field_rows { + fb[row * FB_WIDTH..(row + 1) * FB_WIDTH].fill(row as u32 + 1); + } + fb + } + + #[test] + fn presentation_v_window_places_rows_by_the_modes_porches() { + let mut fb = v_window_fixture(4, 12); + let rows = apply_presentation_v_window(&mut fb, 4, Some((3, 10))); + assert_eq!(rows, 10); + let black = rgba(0, 0, 0); + for row in 0..10 { + let expected = match row { + 3..=6 => (row - 2) as u32, + _ => black, + }; + assert_eq!(fb[row * FB_WIDTH], expected, "row {row}"); + } + } + + #[test] + fn presentation_v_window_clips_rows_above_the_glass_top() { + let mut fb = v_window_fixture(4, 12); + let rows = apply_presentation_v_window(&mut fb, 4, Some((-2, 10))); + assert_eq!(rows, 10); + let black = rgba(0, 0, 0); + // Captured rows 0-1 fall above the sync-anchored glass; rows 2-3 + // land at the top. + assert_eq!(fb[0], 3); + assert_eq!(fb[FB_WIDTH], 4); + for row in 2..10 { + assert_eq!(fb[row * FB_WIDTH], black, "row {row}"); + } + } + + #[test] + fn presentation_v_window_absent_or_degenerate_keeps_the_field() { + let mut fb = v_window_fixture(4, 12); + assert_eq!(apply_presentation_v_window(&mut fb, 4, None), 4); + assert_eq!(fb[0], 1); + // A glass no taller than the captured field cannot add borders. + assert_eq!(apply_presentation_v_window(&mut fb, 4, Some((1, 4))), 4); + assert_eq!(fb[0], 1); + } } diff --git a/src/video/window.rs b/src/video/window.rs index 1734ab56..aca1d3bb 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -5354,6 +5354,7 @@ impl App { &mut self.fb, geometry, self.emu.bus().frame_presentation_h_window(), + self.emu.bus().frame_presentation_v_window(), visible_start_vpos, h_shift, self.overscan, @@ -7705,6 +7706,7 @@ impl App { &mut self.fb, geometry, self.emu.bus().frame_presentation_h_window(), + self.emu.bus().frame_presentation_v_window(), visible_start_vpos, h_shift, self.overscan, diff --git a/src/video/window/present.rs b/src/video/window/present.rs index 78a82362..0a81f6c6 100644 --- a/src/video/window/present.rs +++ b/src/video/window/present.rs @@ -32,6 +32,7 @@ pub(super) fn render_job_to_presentation( fb, geometry, input.presentation_h_window(), + input.presentation_v_window(), visible_start_vpos, h_shift, overscan, From 04072dc688ab2af68292c8d361a8bfc42384da95 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 22 Jul 2026 11:55:42 +0100 Subject: [PATCH 3/4] video: render programmable super-hi-res scans on a 35 ns canvas The framebuffer's hi-res (70 ns) pixel pitch halved SHRES horizontal resolution: the 640-pixel amifb console occupied 320 canvas pixels, every 35 ns sample pair blended into one, and the presentation then stretched those onto the glass - readable but visibly soft next to a reference emulator rendering SHRES natively. Frames now carry a canvas supersample factor (canvas_scale_for): a programmable scan that drives super-hi-res anywhere in the frame paints a double-width canvas whose columns are 35 ns apart, and each half of the resolved SHRES pair lands as its own pixel (with its own genlock transparency) instead of blending. Every logical coordinate in the replay - comparators, fetch origins, sprite positions, the collision and playfield-mask buffers - stays in the classic hi-res-pitch domain; only the framebuffer writes fan out, with non-SHRES pixels and sprites doubled. Standard 15 kHz scans keep the single-width canvas byte-identical (probe goldens unchanged), and their SHRES screens keep the blended pair on the classic pitch. True 35 ns sprite placement remains a TODO. The canvas width threads through the presentation: the post-render stretch and vertical glass window scale with it, the deinterlacer carries per-push width (weave history drops on a pitch change like a row-count change), the desktop window shows the 1432-pixel canvas 1:1 on a 2x HiDPI texture (nearest on non-HiDPI), screenshots save 1432x1074 keeping the 4:3 glass shape and SHOT_RAW saves the native width, the video recorder folds back to its fixed classic width, the frame-analyzer underlay samples at the canvas pitch, the browser frontend passes the doubled width straight to its canvas, and the CCP capture methods report the width. The Linux/m68k amifb 31 kHz console (640 px SHRES, FMODE=3) is the regression example: text is now pixel-sharp at the full programmed resolution. --- crates/copperline-web/src/lib.rs | 11 ++- docs/debugger/control.md | 7 +- docs/guide/browser.md | 4 +- docs/internals/video.md | 25 +++-- src/bin/bench.rs | 7 +- src/bus.rs | 11 +++ src/control/exec.rs | 30 +++--- src/cpu.rs | 17 ++-- src/screenshot.rs | 31 ++++++ src/video/bitplane.rs | 157 +++++++++++++++++++++++++------ src/video/bitplane/output.rs | 37 +++++--- src/video/bitplane/sprite.rs | 9 +- src/video/bitplane/tests.rs | 41 +++++++- src/video/deinterlace.rs | 138 +++++++++++++++++---------- src/video/mod.rs | 6 ++ src/video/present_common.rs | 54 +++++++---- src/video/ui.rs | 9 +- src/video/window.rs | 75 ++++++++++++--- src/video/window/present.rs | 59 +++++++++--- src/video/window/tests.rs | 68 ++++++++++++- 20 files changed, 608 insertions(+), 188 deletions(-) diff --git a/crates/copperline-web/src/lib.rs b/crates/copperline-web/src/lib.rs index fd319fe0..9d7ae63c 100644 --- a/crates/copperline-web/src/lib.rs +++ b/crates/copperline-web/src/lib.rs @@ -20,7 +20,7 @@ use copperline::config::{Config, Overscan}; use copperline::emulator::{build_machine, Emulator}; use copperline::serial::{ChannelSerialHandle, ChannelSerialSink}; use copperline::video::deinterlace::Deinterlacer; -use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_FB_PIXELS}; +use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_CANVAS_PIXELS}; use wasm_bindgen::prelude::*; #[wasm_bindgen(start)] @@ -242,7 +242,7 @@ impl WebEmu { Ok(WebEmu { emu, audio, - fb: vec![0u32; MAX_FB_PIXELS], + fb: vec![0u32; MAX_CANVAS_PIXELS], deinterlacer: Deinterlacer::new(), present: Vec::new(), present_width: FB_WIDTH, @@ -322,9 +322,11 @@ impl WebEmu { let visible_start_vpos = self.emu.bus().frame_visible_start_vpos(); bitplane::render(self.emu.bus_mut(), &mut self.fb); let geometry = self.emu.bus().frame_geometry(); + let canvas_scale = self.emu.bus().frame_canvas_scale(); let field_rows = present_common::post_process_rendered_field( &mut self.fb, geometry, + canvas_scale, self.emu.bus().frame_presentation_h_window(), self.emu.bus().frame_presentation_v_window(), visible_start_vpos, @@ -335,6 +337,7 @@ impl WebEmu { self.deinterlacer.push_field( &self.fb, field_rows, + FB_WIDTH * canvas_scale, base.bplcon0 & 0x0004 != 0, base.long_field, !geometry.programmable, @@ -361,9 +364,9 @@ impl WebEmu { dst.copy_from_slice(&woven[src..src + present_common::TV_PAL_CAPTURED_WIDTH]); } } else { - self.present_width = FB_WIDTH; + self.present_width = self.deinterlacer.output_width(); self.present_rows = woven_rows; - let active = woven_rows * FB_WIDTH; + let active = woven_rows * self.present_width; self.present.resize(active, 0); self.present.copy_from_slice(&woven[..active]); } diff --git a/docs/debugger/control.md b/docs/debugger/control.md index 30f79234..b60bb348 100644 --- a/docs/debugger/control.md +++ b/docs/debugger/control.md @@ -270,9 +270,10 @@ Diagnostic captures: `trace.start {path?, max_lines?}`, `trace.status`, State and capture: `state.save {path}`, `state.load {path}` (re-arms the reverse-debug ring on the loaded timeline), `capture.screenshot -{path?}` (raw framebuffer PNG, 716 pixels wide; with an active RTG -screen it is the board frame downsampled to that width at the board's -native row count), `capture.digest` +{path?}` (raw framebuffer PNG, 716 pixels wide -- 1432 for a +programmable super-hi-res scan's 35 ns canvas; with an active RTG +screen it is the board frame downsampled to 716 at the board's +native row count; the response reports the width), `capture.digest` (FNV-1a hash of the rendered frame -- the cheap change-detection primitive, identical in both server modes), `machine.reset {kind: "warm"|"cold"}`. diff --git a/docs/guide/browser.md b/docs/guide/browser.md index dd409041..187e4c65 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -192,7 +192,9 @@ through wasm-bindgen; the page's JavaScript drives everything from symmetric overscan margins, so the canvas carries none of the bezel-mask black columns of the full framebuffer; non-standard frames (true overscan, NTSC, programmable scans) keep the full 716-pixel width, as on - the desktop (see [the presentation internals](../internals/video.md)). + the desktop, and a programmable super-hi-res scan carries its double + (1432-pixel, 35 ns pitch) canvas straight to the browser canvas (see + [the presentation internals](../internals/video.md)). There is no wgpu in the build, which keeps the wasm around 1.4 MiB (about 0.6 MiB over the wire). - **Audio**: Paula's 44.1 kHz stereo mix is drained once per animation frame diff --git a/docs/internals/video.md b/docs/internals/video.md index 7fa7d89c..580bc08a 100644 --- a/docs/internals/video.md +++ b/docs/internals/video.md @@ -233,15 +233,22 @@ time-linearly (each colour clock covers 227/line_cck of a standard clock's width); without a programmed vertical sync the captured rows keep covering the full glass height. -Super-hi-res output: the framebuffer pixel pitch is hi-res (70 ns), so each -framebuffer pixel covers two 35 ns SHRES samples. Denise/Lisa resolve every -35 ns sample through the full palette pipeline (ECS Denise carries at most -two bitplanes into SHRES; AGA Lisa runs the complete 8-bit index path, -e.g. the 4-plane FMODE=3 Linux amifb console), so the renderer resolves -each half independently and blends the two resulting colours into the -framebuffer pixel. The blend is a framebuffer-pitch compromise, not -hardware; a true 35 ns output path is a TODO shared with SHRES sprite -resolution. +Super-hi-res output: Denise/Lisa resolve every 35 ns sample through the +full palette pipeline (ECS Denise carries at most two bitplanes into +SHRES; AGA Lisa runs the complete 8-bit index path, e.g. the 4-plane +FMODE=3 Linux amifb console). A programmable scan that drives SHRES +renders a double-width canvas at the 35 ns pixel pitch +(`canvas_scale_for`): each of the two per-column samples is emitted as +its own framebuffer pixel, and the presentation, screenshots, and the +browser canvas carry the doubled width through (the desktop window shows +it 1:1 on a 2x HiDPI texture). Every logical coordinate in the replay -- +comparators, fetch origins, sprite positions, the collision buffers -- +stays in the classic hi-res-pitch domain; only the framebuffer writes +fan out, with non-SHRES pixels and sprites doubled. Standard 15 kHz +scans keep the classic single-width canvas byte-identical; their SHRES +screens still blend each 35 ns pair into the 70 ns pixel. Sprite +positions remain at hi-res resolution on either canvas (true 35 ns +sprite placement is a remaining TODO). Two vertical edge cases the replay honours: diff --git a/src/bin/bench.rs b/src/bin/bench.rs index 759acc66..51a30de7 100644 --- a/src/bin/bench.rs +++ b/src/bin/bench.rs @@ -18,7 +18,7 @@ use copperline::config::{Config, ConfigOverrides, Overscan}; use copperline::emulator::build_machine; use copperline::timebase::Instant; use copperline::video::deinterlace::Deinterlacer; -use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_FB_PIXELS}; +use copperline::video::{bitplane, present_common, FB_WIDTH, MAX_CANVAS_PIXELS}; use std::path::PathBuf; struct StdoutLogger; @@ -121,7 +121,7 @@ fn main() -> Result<()> { emu.set_paced(false); emu.reset_stats(); - let mut fb = vec![0u32; MAX_FB_PIXELS]; + let mut fb = vec![0u32; MAX_CANVAS_PIXELS]; let mut deinterlacer = Deinterlacer::new(); let mut last_rendered: Option = None; let mut rendered_frames: u64 = 0; @@ -141,9 +141,11 @@ fn main() -> Result<()> { let visible_start_vpos = emu.bus().frame_visible_start_vpos(); bitplane::render(emu.bus_mut(), &mut fb); let geometry = emu.bus().frame_geometry(); + let canvas_scale = emu.bus().frame_canvas_scale(); let field_rows = present_common::post_process_rendered_field( &mut fb, geometry, + canvas_scale, emu.bus().frame_presentation_h_window(), emu.bus().frame_presentation_v_window(), visible_start_vpos, @@ -154,6 +156,7 @@ fn main() -> Result<()> { deinterlacer.push_field( &fb, field_rows, + FB_WIDTH * canvas_scale, base.bplcon0 & 0x0004 != 0, base.long_field, !geometry.programmable, diff --git a/src/bus.rs b/src/bus.rs index d0a53d6a..58656af4 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -4711,6 +4711,17 @@ impl Bus { } } + /// Canvas supersample factor of the frame the renderer is about to + /// draw (see `bitplane::canvas_scale_for`): callers that render + /// straight from the bus size their buffer and stride with this. + pub fn frame_canvas_scale(&self) -> usize { + crate::video::bitplane::canvas_scale_for( + self.frame_geometry().programmable, + self.frame_render_base().bplcon0, + self.frame_render_events(), + ) + } + /// Beam-line count for the frame the renderer is about to draw. This is /// latched with `FrameGeometry`; the fallback covers old in-process /// snapshots whose transient geometry field predates `frame_lines`. diff --git a/src/control/exec.rs b/src/control/exec.rs index 7b526e9b..9e30df86 100644 --- a/src/control/exec.rs +++ b/src/control/exec.rs @@ -15,7 +15,7 @@ use crate::debugger::{BreakCond, CondOp, CondOperand, DebugStop, WatchSource}; use crate::emulator::Emulator; use crate::inputsched::JoyState; use crate::timetravel::ReverseOutcome; -use crate::video::{FB_WIDTH, MAX_FB_PIXELS}; +use crate::video::{FB_WIDTH, MAX_CANVAS_PIXELS}; use serde_json::{json, Map, Value}; use std::path::PathBuf; @@ -1424,20 +1424,15 @@ pub fn exec_core(emu: &mut Emulator, ctx: &mut SessionCtx, op: &CoreOp) -> Resul } CoreOp::Digest => Ok(digest_value(emu)), CoreOp::Screenshot { path } => { - let (fb, lines) = render_frame(emu); + let (fb, lines, width) = render_frame(emu); let path = path .clone() .unwrap_or_else(crate::screenshot::auto_filename); - crate::screenshot::save( - &path, - &fb[..FB_WIDTH * lines], - FB_WIDTH as u32, - lines as u32, - ) - .map_err(|e| CtlError::io(format!("saving screenshot: {e:#}")))?; + crate::screenshot::save(&path, &fb[..width * lines], width as u32, lines as u32) + .map_err(|e| CtlError::io(format!("saving screenshot: {e:#}")))?; Ok(json!({ "path": path.display().to_string(), - "width": FB_WIDTH, + "width": width, "height": lines, })) } @@ -1713,7 +1708,7 @@ fn wave_status_value(status: &crate::waveform::WaveStatus) -> Value { /// display path, returning the buffer and its visible line count. Both /// `capture.digest` and `capture.screenshot` use this in BOTH server /// modes, so captures are mode-identical and comparable. -fn render_frame(emu: &Emulator) -> (Vec, usize) { +fn render_frame(emu: &Emulator) -> (Vec, usize, usize) { // An RTG board driving the display supersedes the chipset output, // exactly as the window presentation does. let mut fb = Vec::new(); @@ -1721,12 +1716,13 @@ fn render_frame(emu: &Emulator) -> (Vec, usize) { if let Some((rows, _, _)) = crate::video::present_common::compose_rtg_present(emu.bus(), &mut scratch, &mut fb) { - return (fb, rows); + return (fb, rows, FB_WIDTH); } - fb = vec![0u32; MAX_FB_PIXELS]; + fb = vec![0u32; MAX_CANVAS_PIXELS]; crate::video::bitplane::render_display_only(emu.bus(), &mut fb); let lines = emu.bus().frame_geometry().visible_lines; - (fb, lines) + let width = FB_WIDTH * emu.bus().frame_canvas_scale(); + (fb, lines, width) } /// FNV-1a over the framebuffer words (little-endian byte order), for @@ -1745,12 +1741,12 @@ fn fnv1a64(words: &[u32]) -> u64 { /// Frame digest payload shared by the request/response capture method and the /// opt-in streaming frame notification. pub(crate) fn digest_value(emu: &Emulator) -> Value { - let (fb, lines) = render_frame(emu); - let digest = fnv1a64(&fb[..FB_WIDTH * lines]); + let (fb, lines, width) = render_frame(emu); + let digest = fnv1a64(&fb[..width * lines]); json!({ "algo": "fnv1a64", "digest": format!("{digest:016x}"), - "width": FB_WIDTH, + "width": width, "height": lines, "frame": emu.bus().emulated_frames(), }) diff --git a/src/cpu.rs b/src/cpu.rs index 1b6b6b86..f8055c61 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1247,9 +1247,11 @@ impl M68kMachine { let Some(path) = self.dbg.as_mut().and_then(|d| d.next_shot_path()) else { return; }; - let mut fb = vec![0u32; crate::video::MAX_FB_PIXELS]; + let mut fb = vec![0u32; crate::video::MAX_CANVAS_PIXELS]; crate::video::bitplane::render(&mut self.bus.bus, &mut fb); let geometry = self.bus.bus.frame_geometry(); + let canvas_scale = self.bus.bus.frame_canvas_scale(); + let canvas_width = crate::video::FB_WIDTH * canvas_scale; let mut present_rows = geometry.visible_lines; if !geometry.programmable { let visible_start = self.bus.bus.frame_visible_start_vpos(); @@ -1261,15 +1263,15 @@ impl M68kMachine { if let Some((src_x0, src_w)) = self.bus.bus.frame_presentation_h_window() { crate::screenshot::stretch_rows_x_window( &mut fb, - crate::video::FB_WIDTH, + canvas_width, geometry.visible_lines, - src_x0, - src_w, + src_x0 * canvas_scale as i32, + src_w * canvas_scale as u32, ); } else if geometry.line_cck != 227 { crate::screenshot::stretch_rows_x( &mut fb, - crate::video::FB_WIDTH, + canvas_width, geometry.visible_lines, geometry.line_cck, 227, @@ -1277,6 +1279,7 @@ impl M68kMachine { } present_rows = crate::video::present_common::apply_presentation_v_window( &mut fb, + canvas_width, geometry.visible_lines, self.bus.bus.frame_presentation_v_window(), ); @@ -1284,9 +1287,9 @@ impl M68kMachine { match crate::screenshot::save_scaled_y( std::path::Path::new(&path), &fb, - crate::video::FB_WIDTH as u32, + canvas_width as u32, present_rows as u32, - crate::video::present_height() as u32, + (crate::video::present_height() * canvas_scale) as u32, ) { Ok(()) => log::info!(" screenshot: {path}"), Err(e) => log::warn!(" screenshot failed ({path}): {e:#}"), diff --git a/src/screenshot.rs b/src/screenshot.rs index 5e1ed124..2962b944 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -160,6 +160,37 @@ pub fn scale_y_into(fb: &[u32], width: usize, height: usize, out: usize, scaled: } } +/// Narrow a wide canvas to `dst_width` pixels per row by averaging each +/// source pixel group (cleared and resized). Consumers whose frame width +/// is fixed (the video recorder) use this to fold a 35 ns-pitch canvas +/// back to the classic width. +pub fn downsample_x_into( + fb: &[u32], + src_width: usize, + rows: usize, + dst_width: usize, + out: &mut Vec, +) { + debug_assert!(fb.len() >= src_width * rows); + debug_assert!(src_width >= dst_width && src_width.is_multiple_of(dst_width)); + let group = (src_width / dst_width).max(1); + out.clear(); + out.resize(dst_width * rows, 0); + for y in 0..rows { + let src = &fb[y * src_width..(y + 1) * src_width]; + let dst = &mut out[y * dst_width..(y + 1) * dst_width]; + for (x, px) in dst.iter_mut().enumerate() { + if group == 2 { + let a = src[x * 2]; + let b = src[x * 2 + 1]; + *px = ((a ^ b) & 0xFEFE_FEFE) / 2 + (a & b); + } else { + *px = src[x * group]; + } + } + } +} + /// Centre-aligned bilinear horizontal resample, in place, of the leading /// `rows` rows of the `width`-pixel-wide `fb`: output pixel x samples /// source position x * src_num / src_den. The presentation uses this to diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index 860bcefa..04a8e815 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -619,12 +619,14 @@ fn enforce_h_window_closed_intervals( visible_line0: i32, rows: usize, ) { + let canvas_scale = active_canvas_scale(); + let out_w = FB_WIDTH * canvas_scale; for y in 0..rows { let open_runs = h_window_rows[y].open_runs(); if open_runs.len() == 1 && open_runs[0] == (0, FB_WIDTH) { continue; } - let row = &mut fb[y * FB_WIDTH..(y + 1) * FB_WIDTH]; + let row = &mut fb[y * out_w..(y + 1) * out_w]; let mut x = 0usize; while x < FB_WIDTH { let next_open = open_runs @@ -675,7 +677,7 @@ fn enforce_h_window_closed_intervals( } let palette = palette_at_x(base_palettes[y], &palette_segments[y], sx); let pixel = background_pixel(&control, palette[0], true); - row[sx..next_bound].fill(pixel); + row[sx * canvas_scale..next_bound * canvas_scale].fill(pixel); sx = next_bound; } x = closed_end; @@ -3428,6 +3430,17 @@ impl RenderInput { pub fn emulated_frames(&self) -> u64 { self.emulated_frames } + + /// Output canvas supersample factor for this frame (see + /// [`canvas_scale_for`]): callers size the render buffer as + /// `FB_WIDTH * canvas_scale()` pixels per row. + pub fn canvas_scale(&self) -> usize { + canvas_scale_for( + self.geometry.programmable, + self.render_base.bplcon0, + &self.frame_render_events, + ) + } } /// Outputs of `render_from_input`. Render timing is always recorded back on @@ -3515,6 +3528,47 @@ pub(super) fn active_canvas_shift_h() -> i32 { ACTIVE_CANVAS_SHIFT_H.with(|shift| shift.get()) } +thread_local! { + /// The running render's output supersample factor, captured from the + /// [`RenderInput`] at [`render_from_input`]'s entry like the debug + /// masks. 1 paints the classic hi-res-pitch (70 ns per pixel) canvas. + /// 2 paints a double-width canvas whose columns are 35 ns apart, so a + /// super-hi-res playfield emits each of its two per-column samples as + /// its own pixel instead of blending the pair. Every logical + /// coordinate in the replay (comparators, fetch origins, sprite + /// positions, collision buffers) stays in the hi-res-pitch domain; + /// only the framebuffer writes fan out. + static ACTIVE_CANVAS_SCALE: std::cell::Cell = const { std::cell::Cell::new(1) }; +} + +pub(super) fn active_canvas_scale() -> usize { + ACTIVE_CANVAS_SCALE.with(|scale| scale.get()) +} + +/// The canvas supersample factor for a frame: 2 (35 ns pixel pitch) when a +/// programmable scan drives super-hi-res at any point in the frame, else 1 +/// (the classic 70 ns pitch). Standard 15 kHz scans always use 1, keeping +/// every calibrated presentation byte-identical; their SHRES screens keep +/// the blended hi-res-pitch canvas for now. +pub fn canvas_scale_for( + programmable: bool, + base_bplcon0: u16, + render_events: &[BeamRegisterWrite], +) -> usize { + if !programmable { + return 1; + } + let shres = base_bplcon0 & BPLCON0_SHRES != 0 + || render_events + .iter() + .any(|event| event.offset & 0x01FE == 0x100 && event.value & BPLCON0_SHRES != 0); + if shres { + 2 + } else { + 1 + } +} + fn active_debug_plane_mask() -> u8 { ACTIVE_DEBUG_MASKS.with(|masks| masks.get().0) } @@ -3533,14 +3587,17 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { 0 }) }); + let canvas_scale = input.canvas_scale(); + ACTIVE_CANVAS_SCALE.with(|scale| scale.set(canvas_scale)); + let out_w = FB_WIDTH * canvas_scale; let mut render_timing = VideoRenderFrameTiming::default(); let mut state = RenderState::from_snapshot(input.render_base); let geometry = input.geometry; // Rows rendered this frame: the frame geometry's scan height, bounded // by the caller's buffer (legacy fixed-size callers keep the classic // field height). - let rows = geometry.visible_lines.min(fb.len() / FB_WIDTH); - debug_assert!(fb.len() >= FB_WIDTH * rows); + let rows = geometry.visible_lines.min(fb.len() / out_w); + debug_assert!(fb.len() >= out_w * rows); let visible_line0 = input.visible_start_vpos as i32; let (beam_top_palette, beam_bottom_palette, beam_bottom_palette_valid) = input.palette_split; let frame_render_events = input.frame_render_events.as_slice(); @@ -4356,11 +4413,10 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { /// fetched past the image. Hardware is in vertical blank there; force black. fn blank_rows_past_frame_end(frame_lines: u32, fb: &mut [u32], visible_line0: i32, rows: usize) { const BLANK_RGBA: u32 = 0xFF00_0000; + let out_w = FB_WIDTH * active_canvas_scale(); let frame_lines = frame_lines as i32; let first_blank_row = (frame_lines - visible_line0).clamp(0, rows as i32) as usize; - for row in fb[first_blank_row * FB_WIDTH..rows * FB_WIDTH].chunks_exact_mut(FB_WIDTH) { - row.fill(BLANK_RGBA); - } + fb[first_blank_row * out_w..rows * out_w].fill(BLANK_RGBA); } /// ECS programmable blanking (plan 1.2): force the composite blank windows @@ -4390,11 +4446,13 @@ fn apply_programmable_blanking( } }; + let canvas_scale = active_canvas_scale(); + let out_w = FB_WIDTH * canvas_scale; if let Some((strt, stop)) = programmable_vertical_blank { for y in 0..rows { let vpos = (visible_line0 + y as i32).max(0) as u32; if in_window(vpos, strt, stop) { - fb[y * FB_WIDTH..(y + 1) * FB_WIDTH].fill(BLANK_RGBA); + fb[y * out_w..(y + 1) * out_w].fill(BLANK_RGBA); } } } @@ -4413,10 +4471,10 @@ fn apply_programmable_blanking( } } if any { - for row in fb.chunks_exact_mut(FB_WIDTH) { - for (x, px) in row.iter_mut().enumerate() { - if blank_cols[x] { - *px = BLANK_RGBA; + for row in fb.chunks_exact_mut(out_w) { + for (x, blank) in blank_cols.iter().enumerate() { + if *blank { + row[x * canvas_scale..(x + 1) * canvas_scale].fill(BLANK_RGBA); } } } @@ -4538,6 +4596,8 @@ fn render_planned_playfield_line( 0 }; let shres = pixel_control.shres(); + let canvas_scale = active_canvas_scale(); + let out_w = FB_WIDTH * canvas_scale; let line_visible = pixel_control.display_window_contains_line(plan.y, visible_line0); let background_rgb24 = rgb12_to_rgb24(color_rgb12(palette[0])); let nplanes = sample_control.nplanes().min(plan.plane_words.len()); @@ -4629,18 +4689,20 @@ fn render_planned_playfield_line( } continue; } - let (sample, output) = if shres { + let (sample, output, shres_pair) = if shres { let left = plan.sample_prepared(nplanes, &delays, min_fetch_x, native_x); let right = plan.sample_prepared(nplanes, &delays, min_fetch_x, native_x + 1); + let (left_out, right_out) = denise_shres_playfield_output_pair( + pixel_control, + palette, + left.idx & plane_mask, + right.idx & plane_mask, + &mut ham_color, + ); ( shres_composite_sample(left, right), - denise_shres_playfield_output( - pixel_control, - palette, - left.idx & plane_mask, - right.idx & plane_mask, - &mut ham_color, - ), + blend_shres_outputs(left_out, right_out), + Some((left_out, right_out)), ) } else { let sample = plan.sample_prepared_with_final_fetch_hold( @@ -4686,7 +4748,7 @@ fn render_planned_playfield_line( ); } } - (sample, output) + (sample, output, None) }; if ham_mode || !shres { next_ham_native_x = next_ham_native_x.max(native_x + 1); @@ -4709,9 +4771,32 @@ fn render_planned_playfield_line( playfield_mask[fb_idx] = pf_mask; } collision_pixels[fb_idx] = collision; + let out_base = plan.y * out_w + pixel_x * canvas_scale; + if let (2, Some((left_out, right_out))) = (canvas_scale, shres_pair) { + // 35 ns canvas: each half of the SHRES pair is its own + // output pixel with its own genlock transparency. + let left_transparent = pixel_control.genlock_transparent( + left_out.color_latch, + Some(sample), + false, + ); + let right_transparent = pixel_control.genlock_transparent( + right_out.color_latch, + Some(sample), + false, + ); + fb[out_base] = rgb24_to_rgba8_alpha(left_out.color, !left_transparent); + fb[out_base + 1] = rgb24_to_rgba8_alpha(right_out.color, !right_transparent); + continue; + } let transparent = pixel_control.genlock_transparent(output.color_latch, Some(sample), false); - fb[fb_idx] = rgb24_to_rgba8_alpha(output.color, !transparent); + let pixel = rgb24_to_rgba8_alpha(output.color, !transparent); + if canvas_scale == 1 { + fb[out_base] = pixel; + } else { + fb[out_base..out_base + canvas_scale].fill(pixel); + } } x += pixel_repeat; if x >= run_stop { @@ -4937,7 +5022,8 @@ fn manual_bpl_ham_seed_color( )); } let previous_x = (seg.x - 1).min(FB_WIDTH.saturating_sub(1) as i32) as usize; - rgba8_to_rgb24(fb[seg.line * FB_WIDTH + previous_x]) + let out_w = FB_WIDTH * active_canvas_scale(); + rgba8_to_rgb24(fb[seg.line * out_w + previous_x * active_canvas_scale()]) } fn manual_bpl_ham_seed_select(seg: &ManualBplSegment, ham_select_pixels: &[u8]) -> u8 { @@ -5042,23 +5128,29 @@ fn draw_manual_bpl_word( } else { masked_idx }; - let source_output = if source_control.shres() { + let (source_output, manual_shres_pair) = if source_control.shres() { let right_sample = shifter .sample(source_control, native_idx + 1) .unwrap_or_default(); - denise_shres_playfield_output( + let (left_out, right_out) = denise_shres_playfield_output_pair( source_control, source_palette, left_sample.idx & plane_mask, right_sample.idx & plane_mask, ham_color, + ); + ( + blend_shres_outputs(left_out, right_out), + Some((left_out, right_out)), ) } else { let output = denise_playfield_output(source_control, source_palette, output_idx, ham_color); *ham_select = masked_idx; - output + (output, None) }; + let canvas_scale = active_canvas_scale(); + let out_w = FB_WIDTH * canvas_scale; for dx in 0..pixel_repeat { let x = x_cursor + dx as i32; if !(0..FB_WIDTH as i32).contains(&x) { @@ -5147,9 +5239,20 @@ fn draw_manual_bpl_word( ) }; *ham_color = pixel_color; + let out_base = seg.line * out_w + x * canvas_scale; + if let (2, Some((left_out, right_out))) = (canvas_scale, manual_shres_pair) { + let left_transparent = + pixel_control.genlock_transparent(left_out.color_latch, Some(sample), false); + let right_transparent = + pixel_control.genlock_transparent(right_out.color_latch, Some(sample), false); + fb[out_base] = rgb24_to_rgba8_alpha(left_out.color, !left_transparent); + fb[out_base + 1] = rgb24_to_rgba8_alpha(right_out.color, !right_transparent); + continue; + } let transparent = pixel_control.genlock_transparent(pixel_color_latch, Some(sample), false); - fb[fb_idx] = rgb24_to_rgba8_alpha(pixel_color, !transparent); + let pixel = rgb24_to_rgba8_alpha(pixel_color, !transparent); + fb[out_base..out_base + canvas_scale].fill(pixel); } x_cursor += pixel_repeat as i32; native_idx += native_step; diff --git a/src/video/bitplane/output.rs b/src/video/bitplane/output.rs index fe2f79dc..7083a264 100644 --- a/src/video/bitplane/output.rs +++ b/src/video/bitplane/output.rs @@ -98,8 +98,10 @@ pub(super) fn fill_background_with_visible_line0( h_window_rows: &[HWindowRow], visible_line0: i32, ) { + let canvas_scale = super::active_canvas_scale(); + let out_w = FB_WIDTH * canvas_scale; for y in 0..base_palettes.len() { - let row = &mut fb[y * FB_WIDTH..(y + 1) * FB_WIDTH]; + let row = &mut fb[y * out_w..(y + 1) * out_w]; let pal_segs = &palette_segments[y]; let ctl_segs = &control_segments[y]; let mut palette = base_palettes[y]; @@ -133,7 +135,8 @@ pub(super) fn fill_background_with_visible_line0( if !control.display_window_contains_line(y, visible_line0) { // Whole run is border: a single fill. - row[x..run_end].fill(background_pixel(&control, color0, true)); + row[x * canvas_scale..run_end * canvas_scale] + .fill(background_pixel(&control, color0, true)); x = run_end; continue; } @@ -151,7 +154,8 @@ pub(super) fn fill_background_with_visible_line0( .min() .unwrap_or(FB_WIDTH); let sub_end = flip.min(run_end).max(sx + 1); - row[sx..sub_end].fill(background_pixel(&control, color0, !open)); + row[sx * canvas_scale..sub_end * canvas_scale] + .fill(background_pixel(&control, color0, !open)); sx = sub_end; } x = run_end; @@ -225,22 +229,31 @@ pub(super) fn rgb24_blend_halves(a: u32, b: u32) -> u32 { (a & b) + (((a ^ b) & 0x00FE_FEFE) >> 1) } -/// Super-hi-res output at the framebuffer's 70 ns pitch. Denise/Lisa resolve -/// every 35 ns sample through the full palette pipeline (ECS Denise carries -/// at most two bitplanes into SHRES; AGA Lisa runs the complete 8-bit index -/// path), so resolve each half independently and blend the two colours into -/// the one framebuffer pixel. The blend is a framebuffer-pitch compromise, -/// not hardware. TODO: emit true 35 ns samples once the output path grows a -/// super-hi-res canvas; the sprite path carries the same limitation. -pub(super) fn denise_shres_playfield_output( +/// Resolve the two 35 ns samples of one 70 ns framebuffer column, each +/// through the full palette pipeline (ECS Denise carries at most two +/// bitplanes into SHRES; AGA Lisa runs the complete 8-bit index path). On +/// a 35 ns-pitch canvas each half is emitted as its own pixel; on the +/// classic hi-res-pitch canvas the pair is blended by +/// [`denise_shres_playfield_output`]. +pub(super) fn denise_shres_playfield_output_pair( control: ControlState, palette: Palette, left_idx: u8, right_idx: u8, ham_color: &mut u32, -) -> DenisePlayfieldOutput { +) -> (DenisePlayfieldOutput, DenisePlayfieldOutput) { let left = denise_playfield_output(control, palette, left_idx, ham_color); let right = denise_playfield_output(control, palette, right_idx, ham_color); + (left, right) +} + +/// Merge a resolved 35 ns pair into one hi-res-pitch output: the blend is +/// a framebuffer-pitch compromise, not hardware, used when the canvas +/// carries one colour per 70 ns pixel. +pub(super) fn blend_shres_outputs( + left: DenisePlayfieldOutput, + right: DenisePlayfieldOutput, +) -> DenisePlayfieldOutput { DenisePlayfieldOutput { color: rgb24_blend_halves(left.color, right.color), color_latch: right.color_latch, diff --git a/src/video/bitplane/sprite.rs b/src/video/bitplane/sprite.rs index d148c09e..5ad7f0ea 100644 --- a/src/video/bitplane/sprite.rs +++ b/src/video/bitplane/sprite.rs @@ -1143,7 +1143,9 @@ pub(super) fn render_attached_sprite_pair_lines( } else { rgb12_to_rgb24(color_rgb12(color_latch)) }; - fb[fb_idx] = rgb24_to_rgba8_alpha(color, !transparent); + let canvas_scale = super::active_canvas_scale(); + let out_base = y * FB_WIDTH * canvas_scale + x_usize * canvas_scale; + fb[out_base..out_base + canvas_scale].fill(rgb24_to_rgba8_alpha(color, !transparent)); } } clxdat @@ -1378,7 +1380,10 @@ pub(super) fn draw_sprite_line( } else { rgb12_to_rgb24(color_rgb12(color_latch)) }; - fb[fb_idx] = rgb24_to_rgba8_alpha(color, !transparent); + let canvas_scale = super::active_canvas_scale(); + let out_base = y * FB_WIDTH * canvas_scale + x * canvas_scale; + fb[out_base..out_base + canvas_scale] + .fill(rgb24_to_rgba8_alpha(color, !transparent)); } x_cursor += sprite_pixel_repeat; } diff --git a/src/video/bitplane/tests.rs b/src/video/bitplane/tests.rs index d7848a11..9a26d3ac 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -6174,8 +6174,9 @@ fn shres_playfield_output_resolves_each_35ns_sample_through_the_palette() { // A solid run of colour 1 keeps colour 1: each 35 ns half resolves // palette[1], never a pair-encoded entry. + let (left, right) = denise_shres_playfield_output_pair(control, palette, 1, 1, &mut ham_color); assert_eq!( - denise_shres_playfield_output(control, palette, 1, 1, &mut ham_color), + blend_shres_outputs(left, right), DenisePlayfieldOutput { color: rgb12_to_rgb24(0x00F0), color_latch: 0x00F0, @@ -6183,9 +6184,13 @@ fn shres_playfield_output_resolves_each_35ns_sample_through_the_palette() { } ); // A background/colour-1 pair blends the two resolved colours into the - // 70 ns framebuffer pixel. + // 70 ns framebuffer pixel (the classic-pitch canvas path); a 35 ns + // canvas emits the halves separately. + let (left, right) = denise_shres_playfield_output_pair(control, palette, 0, 1, &mut ham_color); + assert_eq!(left.color, rgb12_to_rgb24(0x0000)); + assert_eq!(right.color, rgb12_to_rgb24(0x00F0)); assert_eq!( - denise_shres_playfield_output(control, palette, 0, 1, &mut ham_color), + blend_shres_outputs(left, right), DenisePlayfieldOutput { color: rgb24_blend_halves(rgb12_to_rgb24(0x0000), rgb12_to_rgb24(0x00F0)), color_latch: 0x00F0, @@ -6194,6 +6199,29 @@ fn shres_playfield_output_resolves_each_35ns_sample_through_the_palette() { ); } +#[test] +fn canvas_scale_doubles_only_for_programmable_shres_frames() { + let shres_write = BeamRegisterWrite { + vpos: 100, + hpos: 20, + offset: 0x100, + value: 0x4240, + source: BeamWriteSource::Copper, + }; + let lores_write = BeamRegisterWrite { + value: 0x4200, + ..shres_write.clone() + }; + // Standard scans never double, SHRES or not. + assert_eq!(canvas_scale_for(false, 0x4240, &[]), 1); + assert_eq!(canvas_scale_for(false, 0x4200, &[shres_write.clone()]), 1); + // Programmable scans double when SHRES is active at the frame start or + // arrives mid-frame. + assert_eq!(canvas_scale_for(true, 0x4240, &[]), 2); + assert_eq!(canvas_scale_for(true, 0x4200, &[shres_write]), 2); + assert_eq!(canvas_scale_for(true, 0x4200, &[lores_write]), 1); +} + #[test] fn aga_shres_playfield_output_keeps_four_plane_indices() { // Regression: the Debian/m68k amifb console (SHRES, 4 bitplanes, @@ -6212,12 +6240,15 @@ fn aga_shres_playfield_output_keeps_four_plane_indices() { }; let mut ham_color = 0; + let (left, right) = + denise_shres_playfield_output_pair(control, palette, 14, 14, &mut ham_color); assert_eq!( - denise_shres_playfield_output(control, palette, 14, 14, &mut ham_color).color, + blend_shres_outputs(left, right).color, palette.rgb24(14) & 0x00FF_FFFF ); + let (left, right) = denise_shres_playfield_output_pair(control, palette, 7, 7, &mut ham_color); assert_eq!( - denise_shres_playfield_output(control, palette, 7, 7, &mut ham_color).color, + blend_shres_outputs(left, right).color, palette.rgb24(7) & 0x00FF_FFFF ); } diff --git a/src/video/deinterlace.rs b/src/video/deinterlace.rs index 784843ee..7a0a2176 100644 --- a/src/video/deinterlace.rs +++ b/src/video/deinterlace.rs @@ -63,8 +63,14 @@ pub struct Deinterlacer { /// Field row count of the history buffers; a geometry change drops /// the history (fields of different scans must not weave together). field_rows: usize, + /// Field row width (pixels per row) of the history buffers; a canvas + /// pitch change (a 35 ns super-hi-res scan arriving) drops the history + /// like a row-count change. + field_width: usize, /// Active rows in `out` after the last push. out_rows: usize, + /// Pixels per row of `out` after the last push. + out_width: usize, enabled: bool, /// CRT phosphor persistence: each presented frame keeps this fraction /// of the previous one (0 = off), expressed as an alpha in 0..=243 @@ -101,7 +107,9 @@ impl Deinterlacer { have: [false; 2], have2: [false; 2], field_rows: FB_HEIGHT, + field_width: FB_WIDTH, out_rows: OUT_HEIGHT, + out_width: FB_WIDTH, enabled, phosphor_alpha, presented: (phosphor_alpha > 0).then(|| vec![0; MAX_OUT_PIXELS]), @@ -121,6 +129,13 @@ impl Deinterlacer { self.out_rows } + /// Pixels per row of [`Self::output`] after the last pushed field: + /// FB_WIDTH for the classic canvas, twice that for a 35 ns + /// super-hi-res canvas. + pub fn output_width(&self) -> usize { + self.out_width + } + /// Decay the presented frame towards the freshly woven one: each /// channel keeps `phosphor_alpha`/256 of its previous value, an /// exponential trail like CRT phosphor persistence. @@ -129,7 +144,7 @@ impl Deinterlacer { return; }; let a = self.phosphor_alpha; - let active = self.out_rows * FB_WIDTH; + let active = self.out_rows * self.out_width; for (shown, &new) in presented[..active] .iter_mut() .zip(self.out[..active].iter()) @@ -148,23 +163,36 @@ impl Deinterlacer { &mut self, field: &[u32], rows: usize, + width: usize, lace: bool, long_field: bool, double_rows: bool, ) { - debug_assert!(field.len() >= rows * FB_WIDTH); + debug_assert!(field.len() >= rows * width); let rows = rows.clamp(1, MAX_VISIBLE_LINES); - if rows != self.field_rows { + if rows != self.field_rows || width != self.field_width { // Fields of a different scan must not weave with the old // history (mode switch); drop it. self.have = [false; 2]; self.have2 = [false; 2]; self.field_rows = rows; + self.field_width = width; + } + self.out_width = width; + // A 35 ns-pitch canvas outgrows the standard-canvas buffers. + let out_need = rows * width * if double_rows || lace { 2 } else { 1 }; + if self.out.len() < out_need { + self.out.resize(out_need, 0); + } + if let Some(presented) = &mut self.presented { + if presented.len() < out_need { + presented.resize(out_need, 0); + } } if !lace && !double_rows { // Programmable progressive scan: every output line is already // in the field; present at native height. - self.out[..rows * FB_WIDTH].copy_from_slice(&field[..rows * FB_WIDTH]); + self.out[..rows * width].copy_from_slice(&field[..rows * width]); self.out_rows = rows; self.have = [false; 2]; self.have2 = [false; 2]; @@ -175,9 +203,9 @@ impl Deinterlacer { // Progressive: line-double. Field history would pair lines // from unrelated displays across a mode switch; drop it. for y in 0..rows { - let row = &field[y * FB_WIDTH..(y + 1) * FB_WIDTH]; - self.out[2 * y * FB_WIDTH..(2 * y + 1) * FB_WIDTH].copy_from_slice(row); - self.out[(2 * y + 1) * FB_WIDTH..(2 * y + 2) * FB_WIDTH].copy_from_slice(row); + let row = &field[y * width..(y + 1) * width]; + self.out[2 * y * width..(2 * y + 1) * width].copy_from_slice(row); + self.out[(2 * y + 1) * width..(2 * y + 2) * width].copy_from_slice(row); } self.out_rows = rows * 2; self.have = [false; 2]; @@ -187,11 +215,18 @@ impl Deinterlacer { } let parity = usize::from(!long_field); + // The lace history buffers must hold this scan's field size. + let field_need = rows * width; + for buf in self.prev.iter_mut().chain(self.prev2.iter_mut()) { + if buf.len() < field_need { + buf.resize(field_need, 0); + } + } // This field's rows land on its own parity lines. for y in 0..rows { - let row = &field[y * FB_WIDTH..(y + 1) * FB_WIDTH]; + let row = &field[y * width..(y + 1) * width]; let r = 2 * y + parity; - self.out[r * FB_WIDTH..(r + 1) * FB_WIDTH].copy_from_slice(row); + self.out[r * width..(r + 1) * width].copy_from_slice(row); } self.out_rows = rows * 2; @@ -207,40 +242,40 @@ impl Deinterlacer { let prev_same = &self.prev[parity]; let prev_opp = &self.prev[opposite]; let prev2_opp = &self.prev2[opposite]; + let mut moved = vec![false; width]; for y in 0..rows { let r = 2 * y + opposite; // The current-parity field rows directly above and below // output row r (clamped at the frame edges). let above = if r == 0 { 0 } else { (r - 1 - parity) / 2 }; let below = (((r + 1 - parity) / 2).min(rows - 1)).max(above); - let above_row = &field[above * FB_WIDTH..(above + 1) * FB_WIDTH]; - let below_row = &field[below * FB_WIDTH..(below + 1) * FB_WIDTH]; - let out_row = &mut self.out[r * FB_WIDTH..(r + 1) * FB_WIDTH]; + let above_row = &field[above * width..(above + 1) * width]; + let below_row = &field[below * width..(below + 1) * width]; + let out_row = &mut self.out[r * width..(r + 1) * width]; if !self.have[opposite] { - for x in 0..FB_WIDTH { + for x in 0..width { out_row[x] = avg_rgba(above_row[x], below_row[x]); } continue; } - let opp_row = &prev_opp[y * FB_WIDTH..(y + 1) * FB_WIDTH]; - let opp2_row = &prev2_opp[y * FB_WIDTH..(y + 1) * FB_WIDTH]; + let opp_row = &prev_opp[y * width..(y + 1) * width]; + let opp2_row = &prev2_opp[y * width..(y + 1) * width]; let check_same = self.have[parity]; let check_opp = self.have2[opposite]; if check_same || check_opp { - let prev_above = &prev_same[above * FB_WIDTH..(above + 1) * FB_WIDTH]; - let prev_below = &prev_same[below * FB_WIDTH..(below + 1) * FB_WIDTH]; - let mut moved = [false; FB_WIDTH]; - for x in 0..FB_WIDTH { + let prev_above = &prev_same[above * width..(above + 1) * width]; + let prev_below = &prev_same[below * width..(below + 1) * width]; + for x in 0..width { let same_moved = check_same && (above_row[x] != prev_above[x] || below_row[x] != prev_below[x]); moved[x] = same_moved || (check_opp && opp_row[x] != opp2_row[x]); } - for x in 0..FB_WIDTH { + for x in 0..width { // Dilate the motion mask one pixel sideways so dithered // moving art bobs as a region instead of weaving and // interpolating on alternate pixels. let near_motion = - moved[x] || (x > 0 && moved[x - 1]) || (x + 1 < FB_WIDTH && moved[x + 1]); + moved[x] || (x > 0 && moved[x - 1]) || (x + 1 < width && moved[x + 1]); if near_motion { out_row[x] = avg_rgba(above_row[x], below_row[x]); } @@ -251,7 +286,10 @@ impl Deinterlacer { } std::mem::swap(&mut self.prev[parity], &mut self.prev2[parity]); - self.prev[parity][..rows * FB_WIDTH].copy_from_slice(&field[..rows * FB_WIDTH]); + if self.prev[parity].len() < field_need { + self.prev[parity].resize(field_need, 0); + } + self.prev[parity][..rows * width].copy_from_slice(&field[..rows * width]); self.have2[parity] = self.have[parity]; self.have[parity] = true; self.present_with_phosphor(); @@ -309,7 +347,7 @@ mod tests { fn progressive_fields_are_line_doubled() { let mut d = Deinterlacer::with_options(true, 0.0); let f = field_filled_rows(|y| y as u32 + 1); - d.push_field(&f, FB_HEIGHT, false, true, true); + d.push_field(&f, FB_HEIGHT, FB_WIDTH, false, true, true); for y in 0..FB_HEIGHT { assert_eq!(out_row(&d, 2 * y), y as u32 + 1); assert_eq!(out_row(&d, 2 * y + 1), y as u32 + 1); @@ -325,10 +363,10 @@ mod tests { let short = field_filled_rows(|y| 0x2000 + y as u32); // Two full field pairs: the second pair is static against the // first, so every opposite-parity line weaves. - d.push_field(&long, FB_HEIGHT, true, true, true); - d.push_field(&short, FB_HEIGHT, true, false, true); - d.push_field(&long, FB_HEIGHT, true, true, true); - d.push_field(&short, FB_HEIGHT, true, false, true); + d.push_field(&long, FB_HEIGHT, FB_WIDTH, true, true, true); + d.push_field(&short, FB_HEIGHT, FB_WIDTH, true, false, true); + d.push_field(&long, FB_HEIGHT, FB_WIDTH, true, true, true); + d.push_field(&short, FB_HEIGHT, FB_WIDTH, true, false, true); for y in 0..FB_HEIGHT { assert_eq!(out_row(&d, 2 * y), 0x1000 + y as u32, "even row {y}"); assert_eq!(out_row(&d, 2 * y + 1), 0x2000 + y as u32, "odd row {y}"); @@ -340,10 +378,10 @@ mod tests { let mut d = Deinterlacer::with_options(true, 0.0); let long_a = field_filled_rows(|_| 0x10); let short_a = field_filled_rows(|_| 0x30); - d.push_field(&long_a, FB_HEIGHT, true, true, true); - d.push_field(&short_a, FB_HEIGHT, true, false, true); - d.push_field(&long_a, FB_HEIGHT, true, true, true); - d.push_field(&short_a, FB_HEIGHT, true, false, true); + d.push_field(&long_a, FB_HEIGHT, FB_WIDTH, true, true, true); + d.push_field(&short_a, FB_HEIGHT, FB_WIDTH, true, false, true); + d.push_field(&long_a, FB_HEIGHT, FB_WIDTH, true, true, true); + d.push_field(&short_a, FB_HEIGHT, FB_WIDTH, true, false, true); // Static so far: full weave. assert_eq!(out_row(&d, 10), 0x10); assert_eq!(out_row(&d, 11), 0x30); @@ -352,20 +390,20 @@ mod tests { // short lines back in as a ghost - it interpolates its own // neighbours there until the short content settles. let short_b = field_filled_rows(|_| 0x50); - d.push_field(&short_b, FB_HEIGHT, true, false, true); + d.push_field(&short_b, FB_HEIGHT, FB_WIDTH, true, false, true); assert_eq!(out_row(&d, 11), 0x50); - d.push_field(&long_a, FB_HEIGHT, true, true, true); + d.push_field(&long_a, FB_HEIGHT, FB_WIDTH, true, true, true); assert_eq!(out_row(&d, 10), 0x10); assert_eq!(out_row(&d, 11), avg_rgba(0x10, 0x10)); // The short content settles: weave resumes with its next pair. - d.push_field(&short_b, FB_HEIGHT, true, false, true); - d.push_field(&long_a, FB_HEIGHT, true, true, true); + d.push_field(&short_b, FB_HEIGHT, FB_WIDTH, true, false, true); + d.push_field(&long_a, FB_HEIGHT, FB_WIDTH, true, true, true); assert_eq!(out_row(&d, 11), 0x50); // Now the long field moves: its own rows update immediately and // the short-parity rows interpolate the new long field instead of // keeping stale short_b lines. let long_b = field_filled_rows(|_| 0x70); - d.push_field(&long_b, FB_HEIGHT, true, true, true); + d.push_field(&long_b, FB_HEIGHT, FB_WIDTH, true, true, true); assert_eq!(out_row(&d, 10), 0x70); assert_eq!(out_row(&d, 11), avg_rgba(0x70, 0x70)); } @@ -375,15 +413,15 @@ mod tests { let mut d = Deinterlacer::with_options(true, 0.0); let long = field_filled_rows(|_| 0x11); let short = field_filled_rows(|_| 0x22); - d.push_field(&long, FB_HEIGHT, true, true, true); - d.push_field(&short, FB_HEIGHT, true, false, true); + d.push_field(&long, FB_HEIGHT, FB_WIDTH, true, true, true); + d.push_field(&short, FB_HEIGHT, FB_WIDTH, true, false, true); let prog = field_filled_rows(|_| 0x33); - d.push_field(&prog, FB_HEIGHT, false, true, true); + d.push_field(&prog, FB_HEIGHT, FB_WIDTH, false, true, true); assert_eq!(out_row(&d, 10), 0x33); assert_eq!(out_row(&d, 11), 0x33); // Lace resumes: no stale pre-switch lines weave back in; the // missing parity interpolates until its field arrives. - d.push_field(&long, FB_HEIGHT, true, true, true); + d.push_field(&long, FB_HEIGHT, FB_WIDTH, true, true, true); assert_eq!(out_row(&d, 10), 0x11); assert_eq!(out_row(&d, 11), 0x11); } @@ -398,7 +436,7 @@ mod tests { for y in 0..rows { f[y * FB_WIDTH..(y + 1) * FB_WIDTH].fill(y as u32 + 1); } - d.push_field(&f, rows, false, true, false); + d.push_field(&f, rows, FB_WIDTH, false, true, false); assert_eq!(d.output_rows(), rows); for y in (0..rows).step_by(97) { assert_eq!(out_row(&d, y), y as u32 + 1); @@ -413,8 +451,8 @@ mod tests { let mut d = Deinterlacer::with_options(true, 0.0); let long = field_filled_rows(|_| 0x11); let short = field_filled_rows(|_| 0x22); - d.push_field(&long, FB_HEIGHT, true, true, true); - d.push_field(&short, FB_HEIGHT, true, false, true); + d.push_field(&long, FB_HEIGHT, FB_WIDTH, true, true, true); + d.push_field(&short, FB_HEIGHT, FB_WIDTH, true, false, true); assert_eq!(d.output_rows(), OUT_HEIGHT); // A shorter laced scan arrives: nothing from the 285-row fields @@ -422,7 +460,7 @@ mod tests { let rows = 200usize; let mut f = vec![0u32; rows * FB_WIDTH]; f.fill(0x77); - d.push_field(&f, rows, true, true, true); + d.push_field(&f, rows, FB_WIDTH, true, true, true); assert_eq!(d.output_rows(), rows * 2); assert_eq!(out_row(&d, 10), 0x77); assert_eq!(out_row(&d, 11), 0x77); @@ -433,8 +471,8 @@ mod tests { let mut d = Deinterlacer::with_options(false, 0.0); let long = field_filled_rows(|y| y as u32); let short = field_filled_rows(|y| 0x8000 + y as u32); - d.push_field(&long, FB_HEIGHT, true, true, true); - d.push_field(&short, FB_HEIGHT, true, false, true); + d.push_field(&long, FB_HEIGHT, FB_WIDTH, true, true, true); + d.push_field(&short, FB_HEIGHT, FB_WIDTH, true, false, true); for y in 0..FB_HEIGHT { assert_eq!(out_row(&d, 2 * y), 0x8000 + y as u32); assert_eq!(out_row(&d, 2 * y + 1), 0x8000 + y as u32); @@ -465,13 +503,13 @@ mod tests { let mut d = Deinterlacer::with_options(true, 0.5); let bright = field_filled_rows(|_| 0x00FF_FFFF); let black = field_filled_rows(|_| 0); - d.push_field(&bright, FB_HEIGHT, false, true, true); + d.push_field(&bright, FB_HEIGHT, FB_WIDTH, false, true, true); // First frame over a black presented buffer: half brightness. assert_eq!(out_row(&d, 10), 0x007F_7F7F); - d.push_field(&bright, FB_HEIGHT, false, true, true); + d.push_field(&bright, FB_HEIGHT, FB_WIDTH, false, true, true); // Converging towards full brightness. assert_eq!(out_row(&d, 10), 0x00BF_BFBF); - d.push_field(&black, FB_HEIGHT, false, true, true); + d.push_field(&black, FB_HEIGHT, FB_WIDTH, false, true, true); // A black frame keeps half of the previous output as the trail. assert_eq!(out_row(&d, 10), 0x005F_5F5F); } @@ -480,7 +518,7 @@ mod tests { fn zero_phosphor_presents_the_woven_frame_untouched() { let mut d = Deinterlacer::with_options(true, 0.0); let f = field_filled_rows(|_| 0x0012_3456); - d.push_field(&f, FB_HEIGHT, false, true, true); + d.push_field(&f, FB_HEIGHT, FB_WIDTH, false, true, true); assert_eq!(out_row(&d, 10), 0x0012_3456); assert!(d.presented.is_none(), "no blend buffer when disabled"); } diff --git a/src/video/mod.rs b/src/video/mod.rs index ce5c26e1..2341dd3c 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -41,6 +41,12 @@ pub const FB_PIXELS: usize = FB_WIDTH * FB_HEIGHT; pub const MAX_VISIBLE_LINES: usize = 626; pub const MAX_FB_PIXELS: usize = FB_WIDTH * MAX_VISIBLE_LINES; +/// Largest render canvas: the tallest scan at the 35 ns (double-width) +/// pixel pitch a programmable super-hi-res frame paints +/// (`bitplane::canvas_scale_for`). Buffers passed to the render paths are +/// sized for this so any frame's canvas fits. +pub const MAX_CANVAS_PIXELS: usize = 2 * MAX_FB_PIXELS; + /// Per-frame display geometry, latched at the frame wrap (like the /// interlace long-field flag). Standard PAL/NTSC frames report exactly /// the fixed-canvas values (FB_HEIGHT rows, 227-cck lines) so the diff --git a/src/video/present_common.rs b/src/video/present_common.rs index ff7b8af8..9caddfce 100644 --- a/src/video/present_common.rs +++ b/src/video/present_common.rs @@ -96,18 +96,23 @@ const _: () = { pub fn post_process_rendered_field( fb: &mut [u32], geometry: FrameGeometry, + canvas_scale: usize, presentation_h_window: Option<(i32, u32)>, presentation_v_window: Option<(i32, u32)>, visible_start_vpos: u32, h_shift: usize, overscan: Overscan, ) -> usize { - let field_rows = geometry.visible_lines.min(fb.len() / FB_WIDTH); + let canvas_width = FB_WIDTH * canvas_scale; + let field_rows = geometry.visible_lines.min(fb.len() / canvas_width); // Vertical centring, optional full-overscan horizontal recentring, and the // TV bezel mask are 15 kHz CRT concepts anchored to the standard PAL/NTSC // window; a programmable scan defines its own window and presents in full, // like a multisync monitor. if !geometry.programmable { + // Standard scans always render the classic canvas pitch + // (`bitplane::canvas_scale_for`). + debug_assert_eq!(canvas_scale, 1); center_present_frame_for_visible_start(fb, visible_start_vpos); center_present_frame_horizontally(fb, h_shift); if overscan == Overscan::Tv { @@ -119,16 +124,23 @@ pub fn post_process_rendered_field( // A multisync monitor locks its horizontal deflection to the // programmed sync pulse: the glass shows the line from the sync // trailing edge to the next pulse, centring the picture the way - // the mode's own porches place it. - screenshot::stretch_rows_x_window(fb, FB_WIDTH, field_rows, src_x0, src_w); + // the mode's own porches place it. The window is computed in + // classic-canvas pixels; scale it to this frame's pitch. + screenshot::stretch_rows_x_window( + fb, + canvas_width, + field_rows, + src_x0 * canvas_scale as i32, + src_w * canvas_scale as u32, + ); } else if geometry.line_cck != 227 { // No programmed sync to anchor on: fall back to the time-linear // whole-line map (each colour clock of this scan's shorter/longer // line covers 227/line_cck of the glass a standard line's clock // would). - screenshot::stretch_rows_x(fb, FB_WIDTH, field_rows, geometry.line_cck, 227); + screenshot::stretch_rows_x(fb, canvas_width, field_rows, geometry.line_cck, 227); } - apply_presentation_v_window(fb, field_rows, presentation_v_window) + apply_presentation_v_window(fb, canvas_width, field_rows, presentation_v_window) } /// Vertical counterpart of the sync-anchored horizontal window: place the @@ -138,13 +150,14 @@ pub fn post_process_rendered_field( /// vertical sync the captured rows keep covering the whole glass height. pub fn apply_presentation_v_window( fb: &mut [u32], + canvas_width: usize, field_rows: usize, presentation_v_window: Option<(i32, u32)>, ) -> usize { let Some((v_offset, glass_rows)) = presentation_v_window else { return field_rows; }; - let buf_rows = fb.len() / FB_WIDTH; + let buf_rows = fb.len() / canvas_width; let glass_rows = (glass_rows as usize).min(buf_rows).max(1); if glass_rows <= field_rows { return field_rows; @@ -155,25 +168,25 @@ pub fn apply_presentation_v_window( let pad_top = (v_offset).max(0) as usize; let black = rgba(0, 0, 0); if skip_top >= field_rows || pad_top >= glass_rows { - fb[..glass_rows * FB_WIDTH].fill(black); + fb[..glass_rows * canvas_width].fill(black); return glass_rows; } let content_rows = (field_rows - skip_top).min(glass_rows - pad_top); if pad_top > skip_top { for row in (0..content_rows).rev() { - let src = (skip_top + row) * FB_WIDTH; - let dst = (pad_top + row) * FB_WIDTH; - fb.copy_within(src..src + FB_WIDTH, dst); + let src = (skip_top + row) * canvas_width; + let dst = (pad_top + row) * canvas_width; + fb.copy_within(src..src + canvas_width, dst); } } else if pad_top < skip_top { for row in 0..content_rows { - let src = (skip_top + row) * FB_WIDTH; - let dst = (pad_top + row) * FB_WIDTH; - fb.copy_within(src..src + FB_WIDTH, dst); + let src = (skip_top + row) * canvas_width; + let dst = (pad_top + row) * canvas_width; + fb.copy_within(src..src + canvas_width, dst); } } - fb[..pad_top * FB_WIDTH].fill(black); - fb[(pad_top + content_rows) * FB_WIDTH..glass_rows * FB_WIDTH].fill(black); + fb[..pad_top * canvas_width].fill(black); + fb[(pad_top + content_rows) * canvas_width..glass_rows * canvas_width].fill(black); glass_rows } @@ -322,7 +335,7 @@ mod tests { #[test] fn presentation_v_window_places_rows_by_the_modes_porches() { let mut fb = v_window_fixture(4, 12); - let rows = apply_presentation_v_window(&mut fb, 4, Some((3, 10))); + let rows = apply_presentation_v_window(&mut fb, FB_WIDTH, 4, Some((3, 10))); assert_eq!(rows, 10); let black = rgba(0, 0, 0); for row in 0..10 { @@ -337,7 +350,7 @@ mod tests { #[test] fn presentation_v_window_clips_rows_above_the_glass_top() { let mut fb = v_window_fixture(4, 12); - let rows = apply_presentation_v_window(&mut fb, 4, Some((-2, 10))); + let rows = apply_presentation_v_window(&mut fb, FB_WIDTH, 4, Some((-2, 10))); assert_eq!(rows, 10); let black = rgba(0, 0, 0); // Captured rows 0-1 fall above the sync-anchored glass; rows 2-3 @@ -352,10 +365,13 @@ mod tests { #[test] fn presentation_v_window_absent_or_degenerate_keeps_the_field() { let mut fb = v_window_fixture(4, 12); - assert_eq!(apply_presentation_v_window(&mut fb, 4, None), 4); + assert_eq!(apply_presentation_v_window(&mut fb, FB_WIDTH, 4, None), 4); assert_eq!(fb[0], 1); // A glass no taller than the captured field cannot add borders. - assert_eq!(apply_presentation_v_window(&mut fb, 4, Some((1, 4))), 4); + assert_eq!( + apply_presentation_v_window(&mut fb, FB_WIDTH, 4, Some((1, 4))), + 4 + ); assert_eq!(fb[0], 1); } } diff --git a/src/video/ui.rs b/src/video/ui.rs index 72381dbd..b06eb924 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -1550,6 +1550,9 @@ impl AnalyzerTraceView { pub struct AnalyzerUnderlayView { pub fb: std::rc::Rc>, pub rows: usize, + /// Pixels per row: FB_WIDTH classically, twice that for a 35 ns + /// super-hi-res canvas. + pub width: usize, } pub struct FrameAnalyzerView { @@ -2837,9 +2840,11 @@ fn underlay_sample( if !(0..FB_WIDTH as i64).contains(&fb_x) || !(0..underlay.rows as i64).contains(&fb_y) { return None; } + // The underlay canvas may carry a 35 ns pixel pitch; sample at its scale. + let canvas_scale = underlay.width / FB_WIDTH; underlay .fb - .get(fb_y as usize * FB_WIDTH + fb_x as usize) + .get(fb_y as usize * underlay.width + fb_x as usize * canvas_scale) .copied() } @@ -5163,6 +5168,7 @@ mod tests { let underlay = AnalyzerUnderlayView { fb: std::rc::Rc::new(fb), rows: 285, + width: FB_WIDTH, }; let rect = Rect { x: 0, @@ -6323,6 +6329,7 @@ mod tests { underlay: Some(AnalyzerUnderlayView { fb: std::rc::Rc::new(under_fb), rows: underlay_rows, + width: FB_WIDTH, }), })); let mut panel = FrameAnalyzerPanel::new(); diff --git a/src/video/window.rs b/src/video/window.rs index aca1d3bb..1ec8eeb5 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -10,7 +10,7 @@ use super::launcher::{LauncherField, LauncherState, MachineSetup, StatusMessage} use super::ui::{self, Panel, UiControl, UiState}; use super::{ bitplane, font, present_height, FB_HEIGHT, FB_PIXELS, FB_WIDTH, HOST_SHORTCUT_MODIFIER_LABEL, - MAX_FB_PIXELS, MAX_VISIBLE_LINES, + MAX_CANVAS_PIXELS, MAX_VISIBLE_LINES, }; use crate::audio::{AudioSink, CpalSink}; use crate::bus::{BeamWriteSource, FrontPanelStatus, PortDevice, VideoRenderFrameTiming}; @@ -577,6 +577,9 @@ pub struct App { /// post-processed. The first `present_rows * FB_WIDTH` pixels are valid. present_fb: Vec, present_rows: usize, + /// Pixels per `present_fb` row: FB_WIDTH classically, twice that for + /// a 35 ns super-hi-res canvas. + present_width: usize, present_standard_tv_aperture: bool, /// Scratch for composing an RTG board frame (Z3660 scanout); reused /// across frames to avoid a per-frame allocation. @@ -604,6 +607,8 @@ pub struct App { analyzer_underlay_fb: std::rc::Rc>, /// Rows valid in `analyzer_underlay_fb` (the traced frame's scan height). analyzer_underlay_rows: usize, + /// Pixels per `analyzer_underlay_fb` row (the frame's canvas width). + analyzer_underlay_width: usize, /// Emulated frame `analyzer_underlay_fb` was rendered for. analyzer_underlay_frame: Option, /// Recycled snapshot buffers for the underlay's side-effect-free render. @@ -788,6 +793,9 @@ pub struct App { /// Scratch presentation-scaled framebuffer for the recorder (same /// vertical resample as screenshots). record_fb: Vec, + /// Scratch for narrowing a 35 ns-canvas presentation to the recorder's + /// fixed FB_WIDTH frame. + record_scratch_fb: Vec, } #[derive(Debug, Clone, Copy)] @@ -899,6 +907,7 @@ struct RenderWorkerResult { timing: VideoRenderFrameTiming, presentation_fb: Vec, present_rows: usize, + present_width: usize, standard_tv_aperture: bool, /// The job's frame snapshot, handed back for buffer reuse. input: bitplane::RenderInput, @@ -917,7 +926,7 @@ impl RenderWorker { let handle = std::thread::Builder::new() .name("copperline-render".to_string()) .spawn(move || { - let mut fb = vec![0u32; MAX_FB_PIXELS]; + let mut fb = vec![0u32; MAX_CANVAS_PIXELS]; let mut deinterlacer = Deinterlacer::with_phosphor(phosphor); while let Ok(job) = job_rx.recv() { let result = render_job_to_presentation(job, &mut fb, &mut deinterlacer); @@ -1037,10 +1046,11 @@ impl App { realtime_priority, sampler, sampler_stream: None, - fb: vec![0u32; MAX_FB_PIXELS], + fb: vec![0u32; MAX_CANVAS_PIXELS], deinterlacer: Deinterlacer::with_phosphor(phosphor), present_fb: vec![0u32; FB_WIDTH * OUT_HEIGHT], present_rows: OUT_HEIGHT, + present_width: FB_WIDTH, rtg_fb: Vec::new(), rtg_present_dims: None, present_standard_tv_aperture: true, @@ -1054,6 +1064,7 @@ impl App { console_panel: None, analyzer_underlay_fb: std::rc::Rc::new(Vec::new()), analyzer_underlay_rows: 0, + analyzer_underlay_width: FB_WIDTH, analyzer_underlay_frame: None, analyzer_underlay_input: None, render_worker, @@ -1128,6 +1139,7 @@ impl App { hunt: None, recorder: None, record_fb: Vec::new(), + record_scratch_fb: Vec::new(), }; // Attach the sampler now for a directly-booted machine; the config-screen // placeholder passes a disabled request and attaches on Run instead. @@ -1647,7 +1659,7 @@ impl ApplicationHandler for App { if !self.powered_on { paint_test_screen(&mut self.fb); self.deinterlacer - .push_field(&self.fb, FB_HEIGHT, false, true, true); + .push_field(&self.fb, FB_HEIGHT, FB_WIDTH, false, true, true); self.refresh_present_from_deinterlacer(); } self.request_redraw(); @@ -2244,6 +2256,7 @@ impl ApplicationHandler for App { copy_window_present_frame( &self.present_fb, self.present_rows, + self.present_width, frame, r.texture_scale, self.overscan, @@ -4322,10 +4335,12 @@ impl App { .analyzer_underlay_input .as_ref() .expect("underlay render input just filled"); + let underlay_width = FB_WIDTH * input.canvas_scale(); let fb = std::rc::Rc::make_mut(&mut self.analyzer_underlay_fb); - fb.resize(MAX_FB_PIXELS, 0); + fb.resize(MAX_CANVAS_PIXELS, 0); fb.fill(0); let _ = bitplane::render_from_input(input, fb.as_mut_slice()); + self.analyzer_underlay_width = underlay_width; self.analyzer_underlay_rows = self .emu .bus() @@ -5113,13 +5128,32 @@ impl App { if let Some(rec) = self.recorder.as_mut() { rec.push_audio(&samples); if rendered { - screenshot::scale_y_into( - &self.present_fb, - FB_WIDTH, - self.present_rows, - present_height(), - &mut self.record_fb, - ); + // The recorder's frame size is fixed at FB_WIDTH; average a + // 35 ns canvas's pixel pairs down to it first. + if self.present_width != FB_WIDTH { + screenshot::downsample_x_into( + &self.present_fb, + self.present_width, + self.present_rows, + FB_WIDTH, + &mut self.record_scratch_fb, + ); + screenshot::scale_y_into( + &self.record_scratch_fb, + FB_WIDTH, + self.present_rows, + present_height(), + &mut self.record_fb, + ); + } else { + screenshot::scale_y_into( + &self.present_fb, + FB_WIDTH, + self.present_rows, + present_height(), + &mut self.record_fb, + ); + } if let Err(e) = rec.push_frame(&self.record_fb) { failure = Some(e); } @@ -5350,9 +5384,11 @@ impl App { }; bitplane::render_display_only(self.emu.bus(), &mut self.fb); let geometry = self.emu.bus().frame_geometry(); + let canvas_scale = self.emu.bus().frame_canvas_scale(); let field_rows = post_process_rendered_field( &mut self.fb, geometry, + canvas_scale, self.emu.bus().frame_presentation_h_window(), self.emu.bus().frame_presentation_v_window(), visible_start_vpos, @@ -5363,6 +5399,7 @@ impl App { self.deinterlacer.push_field( &self.fb, field_rows, + FB_WIDTH * canvas_scale, base.bplcon0 & 0x0004 != 0, base.long_field, !geometry.programmable, @@ -5947,6 +5984,7 @@ impl App { ui::AnalyzerUnderlayView { fb: std::rc::Rc::clone(&self.analyzer_underlay_fb), rows: self.analyzer_underlay_rows, + width: self.analyzer_underlay_width, } }); let selected_vpos = usize::from(panel.selected_vpos).min(trace.rows.saturating_sub(1)); @@ -7317,6 +7355,7 @@ impl App { path, &self.present_fb, src_rows, + self.present_width, self.overscan, self.present_standard_tv_aperture, ); @@ -7390,6 +7429,7 @@ impl App { &path, &self.present_fb, src_rows, + self.present_width, self.overscan, self.present_standard_tv_aperture, ); @@ -7485,7 +7525,7 @@ impl App { self.last_fdd_track = None; paint_test_screen(&mut self.fb); self.deinterlacer - .push_field(&self.fb, FB_HEIGHT, false, true, true); + .push_field(&self.fb, FB_HEIGHT, FB_WIDTH, false, true, true); self.refresh_present_from_deinterlacer(); } @@ -7507,11 +7547,13 @@ impl App { fn refresh_present_from_deinterlacer(&mut self) { let rows = self.deinterlacer.output_rows(); - let active = rows * FB_WIDTH; + let width = self.deinterlacer.output_width(); + let active = rows * width; self.present_fb.resize(active, 0); self.present_fb .copy_from_slice(&self.deinterlacer.output()[..active]); self.present_rows = rows; + self.present_width = width; } fn reset_render_pipeline(&mut self) { @@ -7536,6 +7578,7 @@ impl App { let old = std::mem::replace(&mut self.present_fb, result.presentation_fb); self.render_recycle_fb = old; self.present_rows = result.present_rows; + self.present_width = result.present_width; self.present_standard_tv_aperture = result.standard_tv_aperture; self.rtg_present_dims = None; self.last_rendered_emulated_frame = Some(result.emulated_frame); @@ -7661,6 +7704,7 @@ impl App { // FB_WIDTH version the screenshot path reads. self.rtg_present_dims = Some((native_w, native_h)); self.present_rows = rows; + self.present_width = FB_WIDTH; self.present_standard_tv_aperture = false; self.last_rendered_emulated_frame = Some(emulated_frame); self.last_submitted_render_frame = Some(emulated_frame); @@ -7702,9 +7746,11 @@ impl App { }; bitplane::render(self.emu.bus_mut(), &mut self.fb); let geometry = self.emu.bus().frame_geometry(); + let canvas_scale = self.emu.bus().frame_canvas_scale(); let field_rows = post_process_rendered_field( &mut self.fb, geometry, + canvas_scale, self.emu.bus().frame_presentation_h_window(), self.emu.bus().frame_presentation_v_window(), visible_start_vpos, @@ -7717,6 +7763,7 @@ impl App { self.deinterlacer.push_field( &self.fb, field_rows, + FB_WIDTH * canvas_scale, base.bplcon0 & 0x0004 != 0, base.long_field, !geometry.programmable, diff --git a/src/video/window/present.rs b/src/video/window/present.rs index 0a81f6c6..cf221888 100644 --- a/src/video/window/present.rs +++ b/src/video/window/present.rs @@ -27,10 +27,13 @@ pub(super) fn render_job_to_presentation( } = job; let render_result = bitplane::render_from_input(&input, fb); let geometry = input.geometry(); + let canvas_scale = input.canvas_scale(); + let canvas_width = FB_WIDTH * canvas_scale; let visible_start_vpos = input.visible_start_vpos(); let field_rows = post_process_rendered_field( fb, geometry, + canvas_scale, input.presentation_h_window(), input.presentation_v_window(), visible_start_vpos, @@ -41,12 +44,14 @@ pub(super) fn render_job_to_presentation( deinterlacer.push_field( fb, field_rows, + canvas_width, base.bplcon0 & 0x0004 != 0, base.long_field, !geometry.programmable, ); let present_rows = deinterlacer.output_rows(); - let active = present_rows * FB_WIDTH; + let present_width = deinterlacer.output_width(); + let active = present_rows * present_width; presentation_fb.resize(active, 0); presentation_fb.copy_from_slice(&deinterlacer.output()[..active]); RenderWorkerResult { @@ -55,6 +60,7 @@ pub(super) fn render_job_to_presentation( timing: render_result.timing, presentation_fb, present_rows, + present_width, standard_tv_aperture: uses_standard_pal_tv_aperture(geometry, present_rows, &base), input, } @@ -368,15 +374,17 @@ pub(super) fn owner_name_from_code(code: u8) -> &'static str { pub(super) fn copy_present_frame( src_fb: &[u32], src_rows: usize, + src_width: usize, frame: &mut [u8], texture_scale: usize, ) { - debug_assert!(src_fb.len() >= src_rows * FB_WIDTH); + debug_assert!(src_fb.len() >= src_rows * src_width); debug_assert_eq!( frame.len(), texture_width(texture_scale) * texture_height(texture_scale) * 4 ); - let dst_stride = texture_width(texture_scale) * 4; + let dst_stride_px = texture_width(texture_scale); + let dst_stride = dst_stride_px * 4; let out_rows = present_height() * texture_scale; // The 570 woven scanlines map onto the 537-row 4:3 presentation (times // the HiDPI texture scale). Select whole source rows instead of blending @@ -384,9 +392,31 @@ pub(super) fn copy_present_frame( // intermediate colours from line-to-line dithering. for y in 0..out_rows { let src_y = screenshot::scaled_source_row(y, src_rows, out_rows); - let row = &src_fb[src_y * FB_WIDTH..(src_y + 1) * FB_WIDTH]; + let row = &src_fb[src_y * src_width..(src_y + 1) * src_width]; let dst_off = y * dst_stride; + if src_width == dst_stride_px { + // A 35 ns canvas whose width matches the HiDPI texture row + // (the common Retina case): every canvas pixel is one texture + // pixel, no resampling. + unsafe { + std::ptr::copy_nonoverlapping( + row.as_ptr() as *const u8, + frame.as_mut_ptr().add(dst_off), + src_width * 4, + ); + } + continue; + } + if src_width != FB_WIDTH { + // Generic canvas-to-texture width map (nearest sample). + let dst = &mut frame[dst_off..dst_off + dst_stride]; + for x in 0..dst_stride_px { + let src_x = x * src_width / dst_stride_px; + dst[x * 4..x * 4 + 4].copy_from_slice(&row[src_x].to_le_bytes()); + } + continue; + } match texture_scale { 1 => unsafe { std::ptr::copy_nonoverlapping( @@ -416,15 +446,18 @@ pub(super) fn copy_present_frame( pub(super) fn copy_window_present_frame( src_fb: &[u32], src_rows: usize, + src_width: usize, frame: &mut [u8], texture_scale: usize, overscan: Overscan, standard_tv_aperture: bool, ) { - if overscan == Overscan::Tv && standard_tv_aperture { + // The TV aperture is a standard-scan crop; standard scans always render + // the classic canvas width. + if overscan == Overscan::Tv && standard_tv_aperture && src_width == FB_WIDTH { copy_tv_aperture_to_window(src_fb, src_rows, frame, texture_scale); } else { - copy_present_frame(src_fb, src_rows, frame, texture_scale); + copy_present_frame(src_fb, src_rows, src_width, frame, texture_scale); } } @@ -682,19 +715,20 @@ pub(super) fn save_present_frame( path: &std::path::Path, present_fb: &[u32], src_rows: usize, + src_width: usize, overscan: Overscan, standard_tv_aperture: bool, ) -> anyhow::Result<()> { if crate::envcfg::flag("COPPERLINE_SHOT_RAW") { return screenshot::save( path, - &present_fb[..src_rows * FB_WIDTH], - FB_WIDTH as u32, + &present_fb[..src_rows * src_width], + src_width as u32, src_rows as u32, ); } - if overscan == Overscan::Tv && standard_tv_aperture { + if overscan == Overscan::Tv && standard_tv_aperture && src_width == FB_WIDTH { return screenshot::save_cropped_black_padded( path, present_fb, @@ -707,11 +741,14 @@ pub(super) fn save_present_frame( ); } + // A double-width (35 ns) canvas saves at double height too, keeping the + // 4:3 glass shape at the higher resolution. + let out_rows = present_height() * src_width / FB_WIDTH; screenshot::save_scaled_y( path, present_fb, - FB_WIDTH as u32, + src_width as u32, src_rows as u32, - present_height() as u32, + out_rows as u32, ) } diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index d654ad40..04d795dc 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -1408,7 +1408,7 @@ fn present_frame_copy_scales_texture_rows_at_hidpi() { src[(OUT_HEIGHT - 1) * FB_WIDTH] = 0xAABB_CCDD; let mut frame = vec![0u8; texture_width(scale) * texture_height(scale) * 4]; - copy_present_frame(&src, OUT_HEIGHT, &mut frame, scale); + copy_present_frame(&src, OUT_HEIGHT, FB_WIDTH, &mut frame, scale); // The top output row samples the top source row exactly (the // centre-aligned position clamps at the edge), and horizontal @@ -1423,6 +1423,42 @@ fn present_frame_copy_scales_texture_rows_at_hidpi() { ); } +#[test] +fn present_frame_copy_passes_35ns_canvas_through_on_matching_hidpi_texture() { + // A double-width (35 ns) canvas whose row equals the HiDPI texture row + // copies 1:1: adjacent SHRES pixels stay distinct on the glass. + let scale = 2; + let src_width = FB_WIDTH * 2; + let rows = 400usize; + let mut src = vec![0u32; src_width * rows]; + src[0] = 0x1122_3344; + src[1] = 0x5566_7788; + let mut frame = vec![0u8; texture_width(scale) * texture_height(scale) * 4]; + + copy_present_frame(&src, rows, src_width, &mut frame, scale); + + assert_eq!(pixel(&frame, 0, 0, 1), src[0].to_le_bytes()); + assert_eq!(pixel(&frame, 1, 0, 1), src[1].to_le_bytes()); +} + +#[test] +fn present_frame_copy_downmaps_35ns_canvas_on_single_scale_texture() { + // The same canvas on a non-HiDPI texture maps nearest: each texture + // pixel samples one of its pair. + let scale = 1; + let src_width = FB_WIDTH * 2; + let rows = 400usize; + let mut src = vec![0u32; src_width * rows]; + src[0] = 0x1122_3344; + src[2] = 0x5566_7788; + let mut frame = vec![0u8; texture_width(scale) * texture_height(scale) * 4]; + + copy_present_frame(&src, rows, src_width, &mut frame, scale); + + assert_eq!(pixel(&frame, 0, 0, 1), src[0].to_le_bytes()); + assert_eq!(pixel(&frame, 1, 0, 1), src[2].to_le_bytes()); +} + #[test] fn tv_window_copy_centres_reference_aperture_in_live_texture() { use crate::video::deinterlace::{OUT_HEIGHT, OUT_PIXELS}; @@ -1442,7 +1478,15 @@ fn tv_window_copy_centres_reference_aperture_in_live_texture() { src[row_y * FB_WIDTH + standard_right] = right_marker; let mut frame = vec![0u8; texture_width(scale) * texture_height(scale) * 4]; - copy_window_present_frame(&src, OUT_HEIGHT, &mut frame, scale, Overscan::Tv, true); + copy_window_present_frame( + &src, + OUT_HEIGHT, + FB_WIDTH, + &mut frame, + scale, + Overscan::Tv, + true, + ); let dst_standard_left = TV_PAL_LIVE_PAD_X + (standard_left - TV_PAL_PRESENT_SOURCE_X); let dst_standard_right = dst_standard_left + 320 * 2 - 1; @@ -1479,7 +1523,15 @@ fn tv_window_copy_black_pads_aperture_past_framebuffer() { src[row_y * FB_WIDTH + FB_WIDTH - 1] = edge; let mut frame = vec![0u8; texture_width(scale) * texture_height(scale) * 4]; - copy_window_present_frame(&src, OUT_HEIGHT, &mut frame, scale, Overscan::Tv, true); + copy_window_present_frame( + &src, + OUT_HEIGHT, + FB_WIDTH, + &mut frame, + scale, + Overscan::Tv, + true, + ); let dst_fb_right = TV_PAL_LIVE_PAD_X + (FB_WIDTH - 1 - TV_PAL_PRESENT_SOURCE_X); assert_eq!( @@ -1509,7 +1561,15 @@ fn tv_window_copy_preserves_true_overscan_fetches() { src[TV_PAL_PRESENT_SOURCE_X] = standard_crop_edge; let mut frame = vec![0u8; texture_width(scale) * texture_height(scale) * 4]; - copy_window_present_frame(&src, OUT_HEIGHT, &mut frame, scale, Overscan::Tv, false); + copy_window_present_frame( + &src, + OUT_HEIGHT, + FB_WIDTH, + &mut frame, + scale, + Overscan::Tv, + false, + ); assert_eq!(pixel(&frame, 0, 0, scale), left_overscan.to_le_bytes()); assert_ne!(pixel(&frame, 0, 0, scale), standard_crop_edge.to_le_bytes()); From 14783415bc47e50ced7dfade0ed365cc47e5c4ac Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 22 Jul 2026 12:06:24 +0100 Subject: [PATCH 4/4] video: reuse the deinterlacer motion mask; average every downsample group Review follow-ups: the weave path's per-push motion-mask allocation moves onto the Deinterlacer as a reusable buffer, downsample_x_into now averages every source pixel group (not just the 2:1 fast path) matching its comment, and the canvas-scale test drops redundant clones of the Copy event type flagged by clippy with --all-targets. --- src/screenshot.rs | 28 +++++++++++++++++++++++++++- src/video/bitplane/tests.rs | 4 ++-- src/video/deinterlace.rs | 9 ++++++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/screenshot.rs b/src/screenshot.rs index 2962b944..0e919aa2 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -181,11 +181,23 @@ pub fn downsample_x_into( let dst = &mut out[y * dst_width..(y + 1) * dst_width]; for (x, px) in dst.iter_mut().enumerate() { if group == 2 { + // Overflow-free per-channel mean of the pair (the 35 ns + // canvas case). let a = src[x * 2]; let b = src[x * 2 + 1]; *px = ((a ^ b) & 0xFEFE_FEFE) / 2 + (a & b); } else { - *px = src[x * group]; + let mut sums = [0u32; 4]; + for &p in &src[x * group..(x + 1) * group] { + for (lane, sum) in sums.iter_mut().enumerate() { + *sum += (p >> (lane * 8)) & 0xFF; + } + } + let n = group as u32; + *px = sums + .iter() + .enumerate() + .fold(0u32, |acc, (lane, sum)| acc | ((sum / n) << (lane * 8))); } } } @@ -283,6 +295,20 @@ mod tests { assert!(scaled.iter().all(|px| *px == a || *px == b)); } + #[test] + fn downsample_averages_each_source_pixel_group() { + // 2:1 (the 35 ns canvas case): per-channel mean of the pair. + let fb = [0xFF00_00FF, 0xFF00_0001, 0x0000_0000, 0x0808_0808]; + let mut out = Vec::new(); + downsample_x_into(&fb, 4, 1, 2, &mut out); + assert_eq!(out, vec![0xFF00_0080, 0x0404_0404]); + + // Wider groups average too, matching the documented behaviour. + let fb = [0x0000_0003, 0x0000_0006, 0x0000_0009]; + downsample_x_into(&fb, 3, 1, 1, &mut out); + assert_eq!(out, vec![0x0000_0006]); + } + #[test] fn cropped_view_black_pads_outside_source() -> Result<()> { let fb = vec![1, 2, 3, 4, 5, 6]; diff --git a/src/video/bitplane/tests.rs b/src/video/bitplane/tests.rs index 9a26d3ac..472b71dc 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -6210,11 +6210,11 @@ fn canvas_scale_doubles_only_for_programmable_shres_frames() { }; let lores_write = BeamRegisterWrite { value: 0x4200, - ..shres_write.clone() + ..shres_write }; // Standard scans never double, SHRES or not. assert_eq!(canvas_scale_for(false, 0x4240, &[]), 1); - assert_eq!(canvas_scale_for(false, 0x4200, &[shres_write.clone()]), 1); + assert_eq!(canvas_scale_for(false, 0x4200, &[shres_write]), 1); // Programmable scans double when SHRES is active at the frame start or // arrives mid-frame. assert_eq!(canvas_scale_for(true, 0x4240, &[]), 2); diff --git a/src/video/deinterlace.rs b/src/video/deinterlace.rs index 7a0a2176..4e26db7d 100644 --- a/src/video/deinterlace.rs +++ b/src/video/deinterlace.rs @@ -71,6 +71,9 @@ pub struct Deinterlacer { out_rows: usize, /// Pixels per row of `out` after the last push. out_width: usize, + /// Reusable motion mask for the weave path (one flag per canvas + /// column); kept on the struct so a laced push does not allocate. + moved: Vec, enabled: bool, /// CRT phosphor persistence: each presented frame keeps this fraction /// of the previous one (0 = off), expressed as an alpha in 0..=243 @@ -110,6 +113,7 @@ impl Deinterlacer { field_width: FB_WIDTH, out_rows: OUT_HEIGHT, out_width: FB_WIDTH, + moved: vec![false; FB_WIDTH], enabled, phosphor_alpha, presented: (phosphor_alpha > 0).then(|| vec![0; MAX_OUT_PIXELS]), @@ -239,10 +243,13 @@ impl Deinterlacer { // parity (content moving within the woven line itself, e.g. an // animation drawn one field ago that has since moved on). let opposite = parity ^ 1; + if self.moved.len() < width { + self.moved.resize(width, false); + } let prev_same = &self.prev[parity]; let prev_opp = &self.prev[opposite]; let prev2_opp = &self.prev2[opposite]; - let mut moved = vec![false; width]; + let moved = &mut self.moved[..width]; for y in 0..rows { let r = 2 * y + opposite; // The current-parity field rows directly above and below