From f2a255e47209395a7a9b96f76047d39111b0cf1d Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Tue, 21 Jul 2026 19:56:23 +0100 Subject: [PATCH 1/4] video: restart the Denise H counter at 0 on programmable scans Under BEAMCON0 VARBEAMEN the horizontal comparators were evaluated with the standard 15 kHz counter phase (H_COUNTER_LINE_ORIGIN at the line start). The DIW values 31 kHz modes program only make sense against a counter that restarts at 0 with the programmable line: Linux/m68k amifb opens its display window at $45 against a programmed HSYNC of $0C..$1A, which is the documented back porch under a zero origin but would open the window inside the sync pulse under the standard phase - and the window comparator then matched off the left canvas edge, so the fb console lost its leading column and sat pinned to the canvas edge with no border. The KS3.1 DblPAL screen's DIW $5B places its picture flush under the same zero origin, which accounts for the last ~14 px of its long-standing left-edge offset against the FS-UAE reference. A render-scoped comparator-origin shift (set from the frame geometry like the debug layer masks) now moves every comparator-anchored position - the DIW window flip-flop scan and sprite HSTART matches - by H_COUNTER_LINE_ORIGIN on programmable frames. Beam-anchored positions (fetch slots, register-write landings, colour-write landings) are physical line positions and do not move. Standard scans use shift 0 and render byte-identically: the probe-golden suite and the standard image-regression baselines pass unchanged. The wide-FMODE SHRES fetch-to-display phase on these modes still shows a residual (leading fetch gulps of a row are discarded where real silicon buffers them across the window opening); that calibration is separate and under investigation. --- src/video/bitplane.rs | 47 ++++++++++++++++++++++++++++++++---- src/video/bitplane/sprite.rs | 11 ++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index db4fc52d..a4ec04ee 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; } @@ -3424,6 +3430,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 +3465,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..4092f906 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)) } From 47f576ab1958b3852b97e57fa12b2a3beb89f062 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Tue, 21 Jul 2026 21:04:17 +0100 Subject: [PATCH 2/4] video: scale DIW-relative placement terms for super-hi-res fetch_origin_native_shift converts DIW H units (lo-res pixels) into native samples with *2/pixel_repeat, which is right for lo-res (one sample per unit) and hi-res (two) but half the real factor in super-hi-res, where a native sample is half a framebuffer pixel and one H unit spans four samples. The DDF-derived term already used the correct per-resolution scale, so any screen whose DIW sits away from the standard fetch reference had its SHRES picture placed at half the intended offset. Every calibrated case was immune: lo-res and hi-res scale correctly, and standard-DIW SHRES screens zero the term. Linux/m68k amifb's 31 kHz console is the first decoupled case: DIW $45 with DDF $18 came out 194 native px left of the window, hiding the first 24 bytes of every row - the console lost its leading ~22 characters on every line (the BPLCON1 scroll of 19 covers the difference). With the unit fixed the picture sits flush with the window and the console is complete. The clamped-window correction now measures against the frame's active comparator anchor instead of the fixed standard one, so a programmable scan whose window is on-canvas no longer gets a spurious off-canvas clip compensation; standard frames use the same anchor as before. Verified: full workspace suite (probe-golden renders byte-identical), image-regression baselines including DblPAL, and Inside The Machine screenshots byte-identical to main at 45s and 90s. --- src/video/bitplane.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index a4ec04ee..475c9261 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -1274,8 +1274,19 @@ impl ControlState { } fn fetch_origin_native_shift(&self, diw_h_start: u16, pixel_repeat: usize) -> 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()) * 2) / pixel_repeat as i32; + (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 { @@ -1336,8 +1347,10 @@ impl ControlState { // not push the content to the right. Without this correction the // 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; + let clamped_window_native = ((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 From eb8c6c3bb536185e85eb039ad0841eb9115ce0c8 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Tue, 21 Jul 2026 21:20:19 +0100 Subject: [PATCH 3/4] video: anchor the programmable-scan presentation at the sync pulse A programmable (VARBEAMEN) frame was presented by mapping the whole HTOTAL line onto the glass time-linearly from the capture aperture's left edge, which pinned the picture toward the left and left the sync and blanking interval as a black band on the right. A multisync monitor locks its horizontal deflection to the sync pulse: the visible raster runs from the HSYNC trailing edge to the next pulse, and the picture sits wherever the mode's own porches place it. When the mode programs its sync generator (VARHSYEN with a sane HSSTRT..HSSTOP), the presentation now resamples that interval onto the glass through a windowed variant of the row stretch; the source origin may sit left of the captured aperture, clamping to the border columns. Modes that leave sync unprogrammed keep the previous time-linear whole-line map, and standard scans are untouched. The Linux amifb console gains its proper left border and porch-set centring; the DblPAL structural regression and the full suite pass unchanged. Also wraps three overlong lines from the previous two commits that had escaped rustfmt; docs/internals/video.md gains the programmable-scan counter-origin and presentation model. --- docs/internals/video.md | 13 +++++++++++-- src/bin/bench.rs | 1 + src/bus.rs | 22 ++++++++++++++++++++++ src/chipset/agnus.rs | 17 +++++++++++++++++ src/cpu.rs | 8 ++++++++ src/screenshot.rs | 34 ++++++++++++++++++++++++++++++++++ src/video/bitplane.rs | 23 ++++++++++++++++------- src/video/bitplane/sprite.rs | 6 +++--- src/video/present_common.rs | 14 +++++++++++--- src/video/window.rs | 2 ++ src/video/window/present.rs | 10 ++++++++-- 11 files changed, 133 insertions(+), 17 deletions(-) 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..cf05b660 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -4614,6 +4614,28 @@ 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)> { + const CAPTURE_APERTURE_START_CCK: i32 = 31; + let geometry = self.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/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 475c9261..e4b57611 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -1284,9 +1284,9 @@ impl ControlState { // 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 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 { @@ -1347,10 +1347,10 @@ impl ControlState { // not push the content to the right. Without this correction the // 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 - active_canvas_shift_h() - diw_h_start as i32) - .max(0) - * native_per_h_unit_num) - / pixel_repeat as i32; + let clamped_window_native = + ((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 @@ -3250,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, @@ -3285,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(), @@ -3320,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(); @@ -3369,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 } diff --git a/src/video/bitplane/sprite.rs b/src/video/bitplane/sprite.rs index 4092f906..d148c09e 100644 --- a/src/video/bitplane/sprite.rs +++ b/src/video/bitplane/sprite.rs @@ -1455,9 +1455,9 @@ pub(super) fn sprite_base_framebuffer_x( // horizontal comparator match (crate::bus::SPRITE_OUTPUT_DELAY_LORES, // 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 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()) 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, From 2c925b77aaf1910ee9b67dfc39447de14252de5b Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Tue, 21 Jul 2026 21:43:14 +0100 Subject: [PATCH 4/4] video: latch the presentation window at the frame wrap; fix web build The sync-anchored presentation window was computed from the live Agnus sync latches at present time while the geometry it pairs with is latched at the frame wrap, so sync registers rewritten after the wrap could present the previous frame through the next frame's window (a one-frame horizontal jump in programmable modes). The window is now computed once at the wrap beside the frame geometry, promoted to the last-frame slot with it, recomputed after a state load, and carried as a serde-skipped presentation cache so the save-state layout is unchanged. Also passes the new presentation-window argument in the browser frontend's present call, which the desktop-focused sweep missed and which broke the wasm32 CI check. --- crates/copperline-web/src/lib.rs | 1 + src/bus.rs | 28 +++++++++++++++++++++++++++- src/bus/frame_capture.rs | 2 ++ 3 files changed, 30 insertions(+), 1 deletion(-) 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/src/bus.rs b/src/bus.rs index cf05b660..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(); @@ -4624,8 +4639,19 @@ impl Bus { /// 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.frame_geometry(); + let geometry = self.current_frame_geometry; if !geometry.programmable { return None; } 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;