diff --git a/crates/copperline-web/src/lib.rs b/crates/copperline-web/src/lib.rs index 9b72de22..ab1a398f 100644 --- a/crates/copperline-web/src/lib.rs +++ b/crates/copperline-web/src/lib.rs @@ -325,6 +325,7 @@ impl WebEmu { let field_rows = present_common::post_process_rendered_field( &mut self.fb, geometry, + self.emu.bus().frame_presentation_h_window(), visible_start_vpos, 0, Overscan::Tv, diff --git a/docs/internals/video.md b/docs/internals/video.md index 2d59ab95..2a7bbd5b 100644 --- a/docs/internals/video.md +++ b/docs/internals/video.md @@ -199,7 +199,16 @@ acts as the hardware display-window flip-flop: it decides when the frame's chip-RAM snapshot and bitplane DMA capture begin, but changing DIWSTRT later in the field does not recenter the already-visible top border. Programmable VARBEAMEN scans instead use their programmed visible window as the render -origin. +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). Two vertical edge cases the replay honours: @@ -299,7 +308,7 @@ either the render worker or the synchronous fallback. The frontend-independent half of this pass lives in `video/present_common.rs`: the post-render pipeline (vertical/horizontal -recentring, the TV bezel mask, programmable-scan stretch) plus the +recentring, the TV bezel mask, programmable-scan presentation) plus the standard-window and TV-aperture constants and the geometry predicates that key on them. `window/present.rs` re-exports everything there, so the desktop path is unchanged; headless consumers -- `cpu.rs`'s debug diff --git a/src/bin/bench.rs b/src/bin/bench.rs index 9ab211ab..84a7b3fc 100644 --- a/src/bin/bench.rs +++ b/src/bin/bench.rs @@ -144,6 +144,7 @@ fn main() -> Result<()> { let field_rows = present_common::post_process_rendered_field( &mut fb, geometry, + emu.bus().frame_presentation_h_window(), visible_start_vpos, 0, Overscan::Tv, diff --git a/src/bus.rs b/src/bus.rs index 3db0087c..47e983ac 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -860,6 +860,15 @@ pub struct Bus { /// vs ECS/AGA VARBEAMEN programmable scan); see `FrameGeometry`. current_frame_geometry: FrameGeometry, last_frame_geometry: FrameGeometry, + /// Sync-anchored presentation window latched at the frame wrap with the + /// geometry, so the presented frame never mixes its latched geometry + /// with sync registers rewritten after the wrap. A pure presentation + /// cache: never serialized (restored None and recomputed after a state + /// load, like `FrameGeometry::frame_lines`). + #[serde(skip)] + current_frame_presentation_h_window: Option<(i32, u32)>, + #[serde(skip)] + last_frame_presentation_h_window: Option<(i32, u32)>, lazy_collision_vpos: u32, lazy_collision_hpos: u32, /// Sticky gate for the per-frame collision accumulation. Collision results @@ -2394,6 +2403,8 @@ impl Bus { PAL_LINES, false, ), + current_frame_presentation_h_window: None, + last_frame_presentation_h_window: None, last_frame_geometry: FrameGeometry::standard( RENDER_VISIBLE_START_VPOS, PAL_LINES, @@ -3049,6 +3060,8 @@ impl Bus { FrameGeometry::standard(RENDER_VISIBLE_START_VPOS, frame_lines, false); self.last_frame_geometry = FrameGeometry::standard(RENDER_VISIBLE_START_VPOS, frame_lines, false); + self.current_frame_presentation_h_window = None; + self.last_frame_presentation_h_window = None; self.lazy_collision_vpos = RENDER_VISIBLE_START_VPOS; self.lazy_collision_hpos = RENDER_COPPER_WAIT_HPOS_FB0; self.collision_tracking_active = false; @@ -3273,6 +3286,8 @@ impl Bus { } self.last_frame_visible_start_vpos = self.current_frame_visible_start_vpos; 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.lazy_collision_vpos = self.current_frame_visible_start_vpos; self.ocs_same_line_diw_start_blocked_vpos = None; self.reset_frame_capture_buffers(); @@ -4614,6 +4629,39 @@ impl Bus { /// Display geometry of the frame the renderer is about to draw /// (the completed frame once one exists, like `frame_render_base`). + /// The horizontal glass window for presenting a programmable scan, as + /// (source x, width) in framebuffer pixels: a multisync monitor + /// anchors its visible raster at the horizontal sync pulse, showing + /// the line from the sync trailing edge to the next pulse. The + /// captured aperture starts 31 colour clocks into the line (fb x = 0 + /// sits at Denise tick 62), so the sync-anchored window's origin can + /// be negative. None on standard frames or when the mode leaves sync + /// unprogrammed (the presentation then falls back to the time-linear + /// whole-line map). + pub fn frame_presentation_h_window(&self) -> Option<(i32, u32)> { + if self.last_frame_render_base.is_some() { + self.last_frame_presentation_h_window + } else { + self.current_frame_presentation_h_window + } + } + + /// Compute the window from the live Agnus sync latches and the + /// just-latched frame geometry; called at the frame wrap (and after a + /// state load) so presentation always sees a consistent snapshot. + pub(crate) fn compute_presentation_h_window(&self) -> Option<(i32, u32)> { + const CAPTURE_APERTURE_START_CCK: i32 = 31; + let geometry = self.current_frame_geometry; + if !geometry.programmable { + return None; + } + let (hsstrt, hsstop) = self.agnus.programmable_hsync_window()?; + let sync_len = hsstop - hsstrt; + let visible_cck = geometry.line_cck.saturating_sub(sync_len).max(1); + let src_x0 = (hsstop as i32 - CAPTURE_APERTURE_START_CCK) * 4; + Some((src_x0, visible_cck * 4)) + } + 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 f41c78f7..008193c4 100644 --- a/src/bus/frame_capture.rs +++ b/src/bus/frame_capture.rs @@ -24,6 +24,7 @@ impl Bus { self.last_frame_render_base = Some(self.current_frame_render_base); 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_render_events = std::mem::take(&mut self.current_frame_render_events); } else { self.last_frame_render_base = None; @@ -141,6 +142,7 @@ impl Bus { // LOF; record the settled value for the field about to render. 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(); 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 1a05db1d..6d60f196 100644 --- a/src/chipset/agnus.rs +++ b/src/chipset/agnus.rs @@ -784,6 +784,23 @@ impl Agnus { self.revision.is_ecs() && self.beamcon0 & BEAMCON0_LOLDIS != 0 } + /// The programmed horizontal sync pulse (HSSTRT..HSSTOP, colour + /// clocks) of a VARBEAMEN scan whose 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_hsync_window(&self) -> Option<(u32, u32)> { + const BEAMCON0_VARHSYEN: u16 = 1 << 8; + if !self.revision.is_ecs() + || self.beamcon0 & BEAMCON0_VARBEAMEN == 0 + || self.beamcon0 & BEAMCON0_VARHSYEN == 0 + { + return None; + } + let strt = u32::from(self.hsstrt); + let stop = u32::from(self.hsstop); + (strt < stop && stop <= u32::from(self.htotal)).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 01a836a3..418eedab 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1256,6 +1256,14 @@ impl M68kMachine { &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( &mut fb, diff --git a/src/screenshot.rs b/src/screenshot.rs index 1ea74fb6..5e1ed124 100644 --- a/src/screenshot.rs +++ b/src/screenshot.rs @@ -195,6 +195,40 @@ pub fn stretch_rows_x(fb: &mut [u32], width: usize, rows: usize, src_num: u32, s } } +/// Like [`stretch_rows_x`], but resampling an explicit source window: +/// output pixel x samples source position src_x0 + (x + 0.5) * src_w / +/// width - 0.5. The presentation uses this to show the visible interval +/// of a programmable scan the way a multisync monitor does: the glass is +/// anchored at the horizontal sync pulse, not at the capture buffer's +/// left edge, so `src_x0` may be negative (the sync tail sits left of +/// the captured aperture) and positions outside the buffer clamp to its +/// edge columns (the border colour). +pub fn stretch_rows_x_window(fb: &mut [u32], width: usize, rows: usize, src_x0: i32, src_w: u32) { + debug_assert!(fb.len() >= width * rows); + if width == 0 || src_w == 0 || (src_x0 == 0 && src_w as usize == width) { + return; + } + let mut scratch = vec![0u32; width]; + for y in 0..rows { + let row = &mut fb[y * width..(y + 1) * width]; + scratch.copy_from_slice(row); + for (x, out) in row.iter_mut().enumerate() { + // Source-pixel centre in 24.8 fixed point: + // src_x0 + (x + 0.5) * src_w / width - 0.5. + let pos = ((src_x0 as i64) << 8) + + ((2 * x as i64 + 1) * src_w as i64 * 128 / width as i64 - 128); + let pos = pos.clamp(0, ((width - 1) as i64) << 8) as usize; + let src_x0i = pos >> 8; + let frac = (pos & 0xFF) as u32; + *out = if frac == 0 || src_x0i + 1 >= width { + scratch[src_x0i] + } else { + crate::video::blend_rgba(scratch[src_x0i], scratch[src_x0i + 1], frac) + }; + } + } +} + /// Pick a default filename for an interactive screenshot grab. pub fn auto_filename() -> PathBuf { let ts = crate::timestamp::compact_now(); diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index db4fc52d..e4b57611 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -491,10 +491,15 @@ fn scan_h_window_line( mut record: Option<&mut HWindowRow>, ) { let free_run = beam_line < 9 && !is_ecs; + let fb_start_tick = H_COUNTER_FB_START_TICK; + // On a programmable (VARBEAMEN) scan Denise's counter restarts with + // the line instead of free-running at the standard 15 kHz phase; see + // ACTIVE_CANVAS_SHIFT_H (the shift equals H_COUNTER_LINE_ORIGIN + // there, making the line-start counter 0). let mut counter = if free_run { (H_COUNTER_LINE_ORIGIN + beam_line * 0x1C6) & 0x1FF } else { - H_COUNTER_LINE_ORIGIN + H_COUNTER_LINE_ORIGIN - active_canvas_shift_h() }; let mut hstrt = control.diw_h_start() as i32; let mut hstop = control.diw_h_stop() as i32; @@ -507,7 +512,7 @@ fn scan_h_window_line( hstop = control.diw_h_stop() as i32; seg_idx += 1; } - if record.is_some() && tick == H_COUNTER_FB_START_TICK && *flop { + if record.is_some() && tick == fb_start_tick && *flop { open_from = Some(0); } let was_open = *flop; @@ -519,7 +524,7 @@ fn scan_h_window_line( } if *flop != was_open { if let Some(row) = record.as_deref_mut() { - let fb_tick = tick - H_COUNTER_FB_START_TICK; + let fb_tick = tick - fb_start_tick; if (0..FB_WIDTH as i32 / 2).contains(&fb_tick) { let x = (fb_tick * 2) as usize; if *flop { @@ -821,13 +826,14 @@ impl ControlState { if display_window_unprogrammed(self.diwstrt, self.diwstop) { return (0, FB_WIDTH); } + let anchor = DIW_HSTART_FB0 - active_canvas_shift_h(); let start = self.diw_h_start() as i32; let mut stop = self.diw_h_stop() as i32; if stop <= start { stop += 0x100; } - let left = ((start - DIW_HSTART_FB0).max(0) as usize * 2).min(FB_WIDTH); - let mut right = ((stop - DIW_HSTART_FB0).max(0) as usize * 2).min(FB_WIDTH); + let left = ((start - anchor).max(0) as usize * 2).min(FB_WIDTH); + let mut right = ((stop - anchor).max(0) as usize * 2).min(FB_WIDTH); if FB_WIDTH.saturating_sub(right) <= 2 { right = FB_WIDTH; } @@ -1268,8 +1274,19 @@ impl ControlState { } fn fetch_origin_native_shift(&self, diw_h_start: u16, pixel_repeat: usize) -> i32 { - let display_native_shift = - ((diw_h_start as i32 - self.fetch_reference()) * 2) / pixel_repeat as i32; + // Native samples per DIW H unit (one lo-res pixel): 1 in lo-res, + // 2 in hi-res, 4 in super-hi-res. The last factor comes from the + // two native samples per framebuffer pixel in SHRES; without it a + // non-standard DIW<->DDF relation placed SHRES content at half its + // real offset (Linux amifb: DIW $45 with DDF $18, 60 units left of + // standard - the picture landed 194 native px left of the window + // and the console lost its first 24 columns; lo-res/hi-res and + // every standard-DIW SHRES screen are unchanged, the terms below + // are zero or scale-independent there). + let native_per_h_unit_num = 2 * self.native_samples_per_framebuffer_pixel() as i32; + let display_native_shift = (diw_h_start as i32 - self.fetch_reference()) + * native_per_h_unit_num + / pixel_repeat as i32; let standard_ddf = if self.hires() || self.shres() { 0x003C } else { @@ -1331,7 +1348,9 @@ impl ControlState { // content shifted right by (DIW_HSTART_FB0 - diw_h_start) lores pixels // and lost its right edge off the framebuffer. let clamped_window_native = - ((DIW_HSTART_FB0 - diw_h_start as i32).max(0) * 2) / pixel_repeat as i32; + ((DIW_HSTART_FB0 - active_canvas_shift_h() - diw_h_start as i32).max(0) + * native_per_h_unit_num) + / pixel_repeat as i32; // Lo-res FMODE=0 placement is linear in DDFSTRT: moving DDFSTRT one // 8-cck fetch period earlier moves the picture exactly 16 lo-res // pixels left. Real hardware confirms this (vAmigaTS @@ -3231,6 +3250,9 @@ fn palette_event_sequences_equivalent(a: &[BeamRegisterWrite], b: &[BeamRegister /// end-of-frame swap, so rendering is a pure function of this bundle. pub struct RenderInput { geometry: FrameGeometry, + /// Sync-anchored glass window for programmable scans + /// ([`crate::bus::Bus::frame_presentation_h_window`]). + presentation_h_window: Option<(i32, u32)>, visible_start_vpos: u32, palette_split: (Palette, Palette, bool), render_base: RenderRegisterSnapshot, @@ -3266,6 +3288,7 @@ impl RenderInput { pub fn from_bus(bus: &Bus) -> Self { Self { geometry: bus.frame_geometry(), + presentation_h_window: bus.frame_presentation_h_window(), visible_start_vpos: bus.frame_visible_start_vpos(), palette_split: bus.frame_palette_split(), render_base: bus.frame_render_base(), @@ -3301,6 +3324,7 @@ impl RenderInput { dst.extend_from_slice(src); } self.geometry = bus.frame_geometry(); + self.presentation_h_window = bus.frame_presentation_h_window(); self.visible_start_vpos = bus.frame_visible_start_vpos(); self.palette_split = bus.frame_palette_split(); self.render_base = bus.frame_render_base(); @@ -3350,6 +3374,10 @@ impl RenderInput { self.geometry } + pub fn presentation_h_window(&self) -> Option<(i32, u32)> { + self.presentation_h_window + } + pub fn visible_start_vpos(&self) -> u32 { self.visible_start_vpos } @@ -3424,6 +3452,30 @@ thread_local! { const { std::cell::Cell::new((0xFF, 0xFF)) }; } +thread_local! { + /// The running render's comparator-origin shift in DIW H units, + /// captured from the [`RenderInput`] at [`render_from_input`]'s entry + /// like the debug masks. Standard scans use 0 and keep every + /// calibrated mapping bit-identical. On a programmable (VARBEAMEN) + /// scan Denise's horizontal counter restarts at 0 with the line + /// instead of free-running at the standard phase, so every + /// comparator-anchored position (the DIW window and sprite HSTART + /// matches) sits H_COUNTER_LINE_ORIGIN units later on the canvas; + /// beam-anchored positions (fetch slots, register-write landings) do + /// not move. Calibrated on two independent guests: Linux/m68k amifb + /// programs DIW $45 against HSYNC $0C..$1A, which only leaves the + /// documented back porch under a zero line-start counter (under the + /// standard phase the window would open inside the sync pulse and + /// the console's first column falls off the canvas), and the KS3.1 + /// DblPAL screen's DIW $5B places its picture flush with the same + /// zero origin. + static ACTIVE_CANVAS_SHIFT_H: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(super) fn active_canvas_shift_h() -> i32 { + ACTIVE_CANVAS_SHIFT_H.with(|shift| shift.get()) +} + fn active_debug_plane_mask() -> u8 { ACTIVE_DEBUG_MASKS.with(|masks| masks.get().0) } @@ -3435,6 +3487,13 @@ pub(super) fn active_debug_sprite_mask() -> u8 { pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { let render_started = Instant::now(); ACTIVE_DEBUG_MASKS.with(|masks| masks.set((input.debug_plane_mask, input.debug_sprite_mask))); + ACTIVE_CANVAS_SHIFT_H.with(|shift| { + shift.set(if input.geometry.programmable { + H_COUNTER_LINE_ORIGIN + } else { + 0 + }) + }); let mut render_timing = VideoRenderFrameTiming::default(); let mut state = RenderState::from_snapshot(input.render_base); let geometry = input.geometry; diff --git a/src/video/bitplane/sprite.rs b/src/video/bitplane/sprite.rs index fc63f548..d148c09e 100644 --- a/src/video/bitplane/sprite.rs +++ b/src/video/bitplane/sprite.rs @@ -1453,15 +1453,20 @@ pub(super) fn sprite_base_framebuffer_x( ) -> i32 { // The serializer's first pixel appears one lo-res pixel after the // horizontal comparator match (crate::bus::SPRITE_OUTPUT_DELAY_LORES, - // ruler-probed against FS-UAE and vAmiga). - let base_x = (hstart + crate::bus::SPRITE_OUTPUT_DELAY_LORES - DIW_HSTART_FB0) * 2; + // ruler-probed against FS-UAE and vAmiga). The anchor carries the + // active canvas shift like every comparator mapping. + let base_x = (hstart + crate::bus::SPRITE_OUTPUT_DELAY_LORES - DIW_HSTART_FB0 + + active_canvas_shift_h()) + * 2; let sample_x = base_x.clamp(0, FB_WIDTH.saturating_sub(1) as i32) as usize; let control = control_at_x(base_control, control_segments, sample_x); base_x + i32::from(hsub_70ns && control.shres()) } pub(super) fn sprite_nominal_base_framebuffer_x(pos: u16, ctl: u16) -> i32 { - (sprite_hstart(pos, ctl) + crate::bus::SPRITE_OUTPUT_DELAY_LORES - DIW_HSTART_FB0) * 2 + (sprite_hstart(pos, ctl) + crate::bus::SPRITE_OUTPUT_DELAY_LORES - DIW_HSTART_FB0 + + active_canvas_shift_h()) + * 2 + i32::from(sprite_hsub_70ns(ctl)) } diff --git a/src/video/present_common.rs b/src/video/present_common.rs index 1b00baaf..51b95bc5 100644 --- a/src/video/present_common.rs +++ b/src/video/present_common.rs @@ -96,6 +96,7 @@ const _: () = { pub fn post_process_rendered_field( fb: &mut [u32], geometry: FrameGeometry, + presentation_h_window: Option<(i32, u32)>, visible_start_vpos: u32, h_shift: usize, overscan: Overscan, @@ -111,10 +112,17 @@ 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 { + // 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); } else if geometry.line_cck != 227 { - // A multisync monitor's horizontal deflection is time-linear: - // each colour clock of this scan's shorter/longer line covers - // 227/line_cck of the glass a standard line's clock would. + // 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); } field_rows diff --git a/src/video/window.rs b/src/video/window.rs index 66c42048..1734ab56 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -5353,6 +5353,7 @@ impl App { let field_rows = post_process_rendered_field( &mut self.fb, geometry, + self.emu.bus().frame_presentation_h_window(), visible_start_vpos, h_shift, self.overscan, @@ -7703,6 +7704,7 @@ impl App { let field_rows = post_process_rendered_field( &mut self.fb, geometry, + self.emu.bus().frame_presentation_h_window(), visible_start_vpos, h_shift, self.overscan, diff --git a/src/video/window/present.rs b/src/video/window/present.rs index a9168692..78a82362 100644 --- a/src/video/window/present.rs +++ b/src/video/window/present.rs @@ -28,8 +28,14 @@ pub(super) fn render_job_to_presentation( let render_result = bitplane::render_from_input(&input, fb); let geometry = input.geometry(); let visible_start_vpos = input.visible_start_vpos(); - let field_rows = - post_process_rendered_field(fb, geometry, visible_start_vpos, h_shift, overscan); + let field_rows = post_process_rendered_field( + fb, + geometry, + input.presentation_h_window(), + visible_start_vpos, + h_shift, + overscan, + ); let base = input.render_base(); deinterlacer.push_field( fb,