diff --git a/crates/copperline-web/src/lib.rs b/crates/copperline-web/src/lib.rs index ab1a398..9d7ae63 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,10 +322,13 @@ 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, 0, Overscan::Tv, @@ -334,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, @@ -360,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 30f7923..b60bb34 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 dd40904..187e4c6 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 3a53e21..580bc08 100644 --- a/docs/internals/video.md +++ b/docs/internals/video.md @@ -222,12 +222,33 @@ 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: 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 84a7b3f..51a30de 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,10 +141,13 @@ 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, 0, Overscan::Tv, @@ -153,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 47e983a..58656af 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 @@ -4670,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/bus/frame_capture.rs b/src/bus/frame_capture.rs index 008193c..da3a9f1 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 6d60f19..7c5f974 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/control/exec.rs b/src/control/exec.rs index 7b526e9..9e30df8 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 418eeda..f8055c6 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1247,38 +1247,49 @@ 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(); 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, + canvas_width, + geometry.visible_lines, + 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, + canvas_width, + geometry.visible_lines, + geometry.line_cck, + 227, + ); + } + present_rows = crate::video::present_common::apply_presentation_v_window( &mut fb, - crate::video::FB_WIDTH, + canvas_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, - crate::video::present_height() as u32, + canvas_width as u32, + present_rows 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 5e1ed12..0e919aa 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -160,6 +160,49 @@ 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 { + // 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 { + 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))); + } + } + } +} + /// 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 @@ -252,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.rs b/src/video/bitplane.rs index 40e0079..04a8e81 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; @@ -3283,6 +3285,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 +3324,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 +3361,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 +3415,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 } @@ -3419,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 @@ -3506,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) } @@ -3524,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(); @@ -4347,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 @@ -4381,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); } } } @@ -4404,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); } } } @@ -4529,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()); @@ -4620,17 +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( - 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( @@ -4676,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); @@ -4699,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 { @@ -4927,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 { @@ -5032,22 +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) { @@ -5136,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 dff9e64..7083a26 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; @@ -214,30 +218,46 @@ 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) } -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) { + 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 { - 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; 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/sprite.rs b/src/video/bitplane/sprite.rs index d148c09..5ad7f0e 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 07443a6..472b71d 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -6164,24 +6164,92 @@ 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. + let (left, right) = denise_shres_playfield_output_pair(control, palette, 1, 1, &mut ham_color); assert_eq!( - denise_shres_playfield_output(palette, 0, 1, &mut ham_color), + blend_shres_outputs(left, right), 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 (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(palette, 1, 1, &mut ham_color).color, - rgb12_to_rgb24(0x000F) + blend_shres_outputs(left, right), + DenisePlayfieldOutput { + color: rgb24_blend_halves(rgb12_to_rgb24(0x0000), rgb12_to_rgb24(0x00F0)), + color_latch: 0x00F0, + pf_mask: 2, + } + ); +} + +#[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 + }; + // Standard scans never double, SHRES or not. + assert_eq!(canvas_scale_for(false, 0x4240, &[]), 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); + 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, + // 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; + + let (left, right) = + denise_shres_playfield_output_pair(control, palette, 14, 14, &mut ham_color); + assert_eq!( + 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!( + blend_shres_outputs(left, right).color, + palette.rgb24(7) & 0x00FF_FFFF ); } diff --git a/src/video/deinterlace.rs b/src/video/deinterlace.rs index 784843e..4e26db7 100644 --- a/src/video/deinterlace.rs +++ b/src/video/deinterlace.rs @@ -63,8 +63,17 @@ 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, + /// 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 @@ -101,7 +110,10 @@ impl Deinterlacer { have: [false; 2], have2: [false; 2], field_rows: FB_HEIGHT, + 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]), @@ -121,6 +133,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 +148,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 +167,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 +207,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 +219,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; @@ -204,43 +243,46 @@ 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 moved = &mut self.moved[..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 +293,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 +354,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 +370,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 +385,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 +397,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 +420,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 +443,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 +458,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 +467,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 +478,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 +510,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 +525,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 ce5c26e..2341dd3 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 51b95bc..9caddfc 100644 --- a/src/video/present_common.rs +++ b/src/video/present_common.rs @@ -96,36 +96,98 @@ 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 { 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 - // 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); } - field_rows + apply_presentation_v_window(fb, canvas_width, 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], + 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() / canvas_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 * 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) * 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) * canvas_width; + let dst = (pad_top + row) * canvas_width; + fb.copy_within(src..src + canvas_width, dst); + } + } + fb[..pad_top * canvas_width].fill(black); + fb[(pad_top + content_rows) * canvas_width..glass_rows * canvas_width].fill(black); + glass_rows } /// `[display] overscan = "tv"`: black out the deep-overscan margins like the @@ -258,4 +320,58 @@ 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, FB_WIDTH, 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, 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 + // 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, 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, 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 72381db..b06eb92 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 1734ab5..1ec8eeb 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,10 +5384,13 @@ 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, h_shift, self.overscan, @@ -5362,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, @@ -5946,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)); @@ -7316,6 +7355,7 @@ impl App { path, &self.present_fb, src_rows, + self.present_width, self.overscan, self.present_standard_tv_aperture, ); @@ -7389,6 +7429,7 @@ impl App { &path, &self.present_fb, src_rows, + self.present_width, self.overscan, self.present_standard_tv_aperture, ); @@ -7484,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(); } @@ -7506,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) { @@ -7535,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); @@ -7660,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); @@ -7701,10 +7746,13 @@ 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, h_shift, self.overscan, @@ -7715,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 78a8236..cf22188 100644 --- a/src/video/window/present.rs +++ b/src/video/window/present.rs @@ -27,11 +27,15 @@ 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, h_shift, overscan, @@ -40,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 { @@ -54,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, } @@ -367,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 @@ -383,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( @@ -415,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); } } @@ -681,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, @@ -706,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 d654ad4..04d795d 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());