From b194f671ec89dc8e95b76432f7442c39382aaa66 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Fri, 3 Jul 2026 22:23:38 +0100 Subject: [PATCH 1/2] denise: model the horizontal DIW comparator flip-flop Replace the span-based horizontal display-window clip with the hardware comparator model (vAmiga Denise::updateBorderBuffer, hardware-verified): Denise keeps one window flip-flop that is SET on an exact 9-bit HSTART counter match and CLEARED on an exact HSTOP match, evaluated per lores position with the register values current at that position. No match means no change, so degenerate windows never open or never close and the state carries across lines and frames. The per-line scan follows the hardware counter: it starts each line at the hblank edge (lores 0x24), wraps 0x1C8 -> 2 near the line end (so positions 2..0x23 are a line's tail, not its head), and on OCS lines 0-8 free-runs modulo 0x200, which lets otherwise-unreachable stop values (0x1C8..0x1FF) fire during vertical blank. Vertical sync leaves the flip-flop set. DIWSTRT/DIWSTOP writes reach the comparators one colour clock after the write cycle. Per-row open spans now drive the background border split, a per-pixel window gate in the playfield painter (replacing the display_window_x span pair), and a post-compositing mask that repaints closed intervals as border (keeping sprites out of closed regions; border sprites exempt). The painter's shifter-origin anchor stays paired with the DIWSTRT value whose comparator fired, so mid-line rewrites move where the window opens without moving the fetched picture. Also fix a palette bleed the diw tests exposed: a colour write in the horizontal-blank tail attributes to the previous output row, behind same-beam-line events that already seeded the next row's base palette; the written entry is now patched into that base so a one-line COLOR00 marker set during hblank still paints its row. Three unit tests encoded artifacts of the old span clip (re-clipping an already-open window on a later-HSTART rewrite, a window edge at the write position instead of the comparator match, and a never-closing OCS stop byte >= 0xC8 treated as closing); they now assert the comparator behaviour. vAmigaTS Agnus/DIW/OLDDIW: diw10 28.3% -> 9.0%, diw9 65.5% -> 60.0%, diw1 63.5% -> 60.7%, diw8 57.1% -> 54.7%, diw2 50.3% -> 48.0%; diw1's remaining divergence is a single uniform 2-lores-pixel picture offset (the Agnus/DDF fetch-placement class, tracked separately) - with that shift factored out diw1 measures 7.9%. Denise/DIW/DIWH and Agnus/DIW/ DIWV also improve across the board. KS3.1/A1200 boot, Zool, Gen-X, Inside The Machine, and Hamazing screenshots are byte-identical to main. --- src/bus/tests.rs | 16 +- src/video/bitplane.rs | 425 ++++++++++++++++++++++++++++++----- src/video/bitplane/output.rs | 26 ++- src/video/bitplane/tests.rs | 66 +++++- 4 files changed, 465 insertions(+), 68 deletions(-) diff --git a/src/bus/tests.rs b/src/bus/tests.rs index 87120af..ceaf3a2 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -3447,7 +3447,7 @@ fn beam_timed_display_window_changes_clip_later_bitplane_rows() { } #[test] -fn beam_timed_display_window_clips_later_bitplane_pixels_on_same_line() { +fn beam_timed_diwstrt_rewrite_after_window_open_does_not_reclip_line() { let mut bus = empty_bus(); bus.agnus.dmacon = DMACON_DMAEN | DMACON_BPLEN; bus.denise.diwstrt = 0x2C81; @@ -3483,8 +3483,11 @@ fn beam_timed_display_window_clips_later_bitplane_pixels_on_same_line() { let mut fb = vec![0; FB_PIXELS]; bitplane::render(&mut bus, &mut fb); + // The window flip-flop opened at the original HSTART before the + // rewrite reached the comparators; a later HSTART only re-matches an + // already-open window, so the line shows bitplanes continuously. assert_eq!(fb[68], rgb12_to_rgba8(0x0F00)); - assert_eq!(fb[106], rgb12_to_rgba8(0x0000)); + assert_eq!(fb[106], rgb12_to_rgba8(0x0F00)); assert_eq!(fb[108], rgb12_to_rgba8(0x0F00)); } @@ -3566,9 +3569,14 @@ fn beam_timed_diwstrt_extends_later_bitplane_pixels_left_on_same_line() { let mut fb = vec![0; FB_PIXELS]; bitplane::render(&mut bus, &mut fb); - assert_eq!(fb[94], rgb12_to_rgba8(0x0000)); - assert_eq!(fb[96], rgb12_to_rgba8(0x0F00)); + // The rewrite reaches the comparators before the new HSTART's match + // position, so the window opens there (not at the write position) and + // shows bitplanes up to the fetched row's end. + assert_eq!(fb[64], rgb12_to_rgba8(0x0000)); + assert_eq!(fb[68], rgb12_to_rgba8(0x0F00)); + assert_eq!(fb[94], rgb12_to_rgba8(0x0F00)); assert_eq!(fb[132], rgb12_to_rgba8(0x0F00)); + assert_eq!(fb[160], rgb12_to_rgba8(0x0000)); } #[test] diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index 6341fca..93a7194 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -368,6 +368,281 @@ fn display_window_unprogrammed(diwstrt: u16, diwstop: u16) -> bool { diwstrt == 0 && diwstop == 0 } +/// Horizontal display-window state for one framebuffer row, from the +/// hardware comparator model: Denise's window flip-flop opens on an exact +/// HSTART match of its horizontal counter and closes on an exact HSTOP +/// match, evaluated per lores position with the register values current +/// at that position (vAmiga's hardware-verified model). No match means no +/// change, so degenerate windows never close (or never open) and the +/// state carries across lines. `open_runs` lists the [start, end) spans +/// in framebuffer hires x where the window is open on this row. +#[derive(Clone, Debug, Default)] +pub(super) struct HWindowRow { + open_runs: Vec<(usize, usize)>, + /// Framebuffer x of the first HSTART comparator match on this row + /// (None when the row's openness is only carried in from a previous + /// line). Feeds the playfield painter's shifter-origin anchor. + comparator_anchor: Option, +} + +impl HWindowRow { + fn full() -> Self { + Self { + open_runs: vec![(0, FB_WIDTH)], + comparator_anchor: None, + } + } + + /// Envelope for consumers that only handle a single span (sprite + /// windows, scroll bookkeeping): first open edge to last close edge. + pub(super) fn span(&self) -> (usize, usize) { + match (self.open_runs.first(), self.open_runs.last()) { + (Some(&(start, _)), Some(&(_, end))) => (start, end), + _ => (0, 0), + } + } + + pub(super) fn open_runs(&self) -> &[(usize, usize)] { + &self.open_runs + } + + /// Whether the window flip-flop is open at framebuffer x. + fn open_at(&self, x: usize) -> bool { + self.open_runs.iter().any(|&(s, e)| x >= s && x < e) + } + + /// First open/close boundary right of x (for run chunking). + fn next_boundary_after(&self, x: usize) -> usize { + self.open_runs + .iter() + .flat_map(|&(s, e)| [s, e]) + .filter(|&b| b > x) + .min() + .unwrap_or(FB_WIDTH) + } +} + +/// Denise's horizontal comparator counter starts each line at the hblank +/// edge (lores position 0x24 = HBLANK_MIN colour clocks), runs 454 lores +/// positions and wraps 0x1C8 -> 2 near the line end, so positions 2..0x23 +/// are a line's tail, not its head. OCS Denise does not reset the counter +/// on lines 0-8: it free-runs modulo 0x200 there, which lets otherwise +/// unreachable comparator values (0x1C8..0x1FF) fire during vertical +/// blank. Framebuffer hires x maps as x = (counter - DIW_HSTART_FB0) * 2. +/// (vAmiga's hardware-verified model, Denise::updateBorderBuffer.) +const H_COUNTER_LINE_ORIGIN: i32 = 0x24; +const H_COUNTER_TICKS_PER_LINE: i32 = 454; +const H_COUNTER_WRAP: i32 = 0x1C8; +const H_COUNTER_WRAP_TARGET: i32 = 2; +/// Scan tick (lores steps from the line origin) at which the framebuffer +/// begins. +const H_COUNTER_FB_START_TICK: i32 = DIW_HSTART_FB0 - H_COUNTER_LINE_ORIGIN; +/// A DIWSTRT/DIWSTOP write reaches Denise's window comparators one colour +/// clock after the write cycle (vAmiga applies the Denise-side register +/// change with a DMA_CYCLES(1) delay). +const DIW_COMPARATOR_WRITE_DELAY_FB: i32 = 4; + +/// Scan tick at which a control segment's DIW values reach the window +/// comparators. Segment x is in the copper/register coordinate; the +/// comparators sit with the bitplane controls on the other side of the +/// fetch -> display pipeline, plus the one-colour-clock write delay. +fn diw_segment_effect_tick(seg_x: usize) -> i32 { + (seg_x as i32 - BITPLANE_CONTROL_PIPELINE_FB as i32 + DIW_COMPARATOR_WRITE_DELAY_FB) / 2 + + H_COUNTER_FB_START_TICK +} + +/// Run the window flip-flop over one beam line, updating `flop` in place. +/// When `record` is given, [start, end) open spans clipped to the +/// framebuffer are appended to it. +fn scan_h_window_line( + flop: &mut bool, + beam_line: i32, + is_ecs: bool, + mut control: ControlState, + segs: &[ControlSegment], + mut record: Option<&mut HWindowRow>, +) { + let free_run = beam_line < 9 && !is_ecs; + let mut counter = if free_run { + (H_COUNTER_LINE_ORIGIN + beam_line * 0x1C6) & 0x1FF + } else { + H_COUNTER_LINE_ORIGIN + }; + let mut hstrt = control.diw_h_start() as i32; + let mut hstop = control.diw_h_stop() as i32; + let mut seg_idx = 0usize; + let mut open_from: Option = None; + for tick in 0..H_COUNTER_TICKS_PER_LINE { + while seg_idx < segs.len() && diw_segment_effect_tick(segs[seg_idx].x) <= tick { + control = segs[seg_idx].control; + hstrt = control.diw_h_start() as i32; + hstop = control.diw_h_stop() as i32; + seg_idx += 1; + } + if record.is_some() && tick == H_COUNTER_FB_START_TICK && *flop { + open_from = Some(0); + } + let was_open = *flop; + if counter == hstrt { + *flop = true; + } + if counter == hstop { + *flop = false; + } + if *flop != was_open { + if let Some(row) = record.as_deref_mut() { + let fb_tick = tick - H_COUNTER_FB_START_TICK; + if (0..FB_WIDTH as i32 / 2).contains(&fb_tick) { + let x = (fb_tick * 2) as usize; + if *flop { + open_from = Some(x); + if row.comparator_anchor.is_none() { + row.comparator_anchor = Some(x); + } + } else if let Some(start) = open_from.take() { + if x > start { + row.open_runs.push((start, x)); + } + } + } else if fb_tick >= FB_WIDTH as i32 / 2 && !*flop { + // Closed right of the framebuffer (or in the wrapped + // tail): the visible part of the run reaches the edge. + // A reopening out here only matters as carry into the + // next line. + if let Some(start) = open_from.take() { + row.open_runs.push((start, FB_WIDTH)); + } + } + } + } + counter = (counter + 1) & 0x1FF; + if counter == H_COUNTER_WRAP && !free_run { + counter = H_COUNTER_WRAP_TARGET; + } + } + if let Some(row) = record { + if let Some(start) = open_from.take() { + if FB_WIDTH > start { + row.open_runs.push((start, FB_WIDTH)); + } + } + } +} + +fn compute_h_window_rows( + base_controls: &[ControlState], + control_segments: &[Vec], + visible_line0: i32, +) -> Vec { + let rows = base_controls.len(); + let mut out = vec![HWindowRow::default(); rows]; + if rows == 0 { + return out; + } + let is_ecs = base_controls[0].agnus_revision.is_ecs(); + // Vertical sync leaves the flip-flop set (vAmiga vsyncHandler). The + // pre-visible lines then run with the frame-start values: register + // writes before the visible window are already folded into row 0's + // base control. + let mut flop = true; + for beam_line in 0..visible_line0.max(0) { + scan_h_window_line(&mut flop, beam_line, is_ecs, base_controls[0], &[], None); + } + for y in 0..rows { + let beam_line = visible_line0 + y as i32; + let mut row = HWindowRow::default(); + scan_h_window_line( + &mut flop, + beam_line, + is_ecs, + base_controls[y], + &control_segments[y], + Some(&mut row), + ); + // Software that never programs DIW keeps the pragmatic whole- + // framebuffer window (matching display_window_x). + if display_window_unprogrammed(base_controls[y].diwstrt, base_controls[y].diwstop) + && control_segments[y].is_empty() + { + out[y] = HWindowRow::full(); + } else { + out[y] = row; + } + } + out +} + +/// Repaint the horizontal window's closed intervals with border pixels +/// after compositing (see the call site). Only rows inside the vertical +/// window need it: rows outside are already all border. +#[allow(clippy::too_many_arguments)] +fn enforce_h_window_closed_intervals( + fb: &mut [u32], + base_palettes: &[Palette], + palette_segments: &[Vec], + base_controls: &[ControlState], + control_segments: &[Vec], + h_window_rows: &[HWindowRow], + visible_line0: i32, + rows: usize, +) { + 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 mut x = 0usize; + while x < FB_WIDTH { + let next_open = open_runs + .iter() + .map(|&(s, _)| s) + .filter(|&s| s > x) + .min() + .unwrap_or(FB_WIDTH); + if open_runs.iter().any(|&(s, e)| x >= s && x < e) { + // Inside an open run: skip to its end. + let end = open_runs + .iter() + .find(|&&(s, e)| x >= s && x < e) + .map(|&(_, e)| e) + .unwrap_or(FB_WIDTH); + x = end; + continue; + } + let closed_end = next_open.min(FB_WIDTH); + // Repaint [x, closed_end) as border, walking the control and + // palette segments for the correct border colour, and skipping + // border-sprite segments. + let mut sx = x; + while sx < closed_end { + let control = control_at_x(base_controls[y], &control_segments[y], sx); + let next_ctl = control_segments[y] + .iter() + .map(|seg| seg.x) + .filter(|&b| b > sx) + .min() + .unwrap_or(FB_WIDTH) + .min(closed_end); + if !control.display_window_contains_line(y, visible_line0) { + // Row is outside the vertical window here: already border. + sx = next_ctl; + continue; + } + if control.border_sprite_enabled() { + sx = next_ctl; + continue; + } + let palette = palette_at_x(base_palettes[y], &palette_segments[y], sx); + let pixel = background_pixel(&control, palette[0], true); + row[sx..next_ctl].fill(pixel); + sx = next_ctl; + } + x = closed_end; + } + } +} + impl ControlState { fn from_render_state(state: &RenderState) -> Self { Self { @@ -1859,6 +2134,17 @@ fn apply_render_events_and_collect_display_plan_events_with_visible_line0( loct, color_register_value(event.value), ); + // A colour write in the horizontal-blank tail attributes to the + // previous output row, behind an event of the same beam line + // that already seeded the next row's base palette (line + // attribution is not monotonic across write kinds). Patch the + // written entry into that base so the change still reaches the + // row it precedes. + if color_wraps_to_previous_line && next_base_line > line + 1 { + if let Some(base) = base_palettes.get_mut(line + 1) { + base.write_entry(usize::from(entry), loct, color_register_value(event.value)); + } + } if let Some(events_by_line) = display_line_events.as_deref_mut() { if line < events_by_line.len() { events_by_line[line].push(DisplayLinePlanEvent::PaletteChange { @@ -2146,19 +2432,60 @@ fn line_has_valid_ddf_window( .any(|segment| segment.control.has_valid_ddf_window()) } +fn merge_display_window_anchor( + anchor: &mut Option, + control: ControlState, + line: usize, + visible_line0: i32, + run_start: usize, + run_stop: usize, +) { + if run_start >= run_stop || !control.display_window_contains_line(line, visible_line0) { + return; + } + let (window_x_start, _) = control.display_window_x(); + if window_x_start >= run_start && window_x_start <= run_stop { + // The horizontal DIW start comparator fired while this control was + // active, so it establishes the shifter origin: the playfield + // painter's fetch-alignment math pairs plan.x_start with the + // control's DIWSTRT-derived offsets. + *anchor = Some(anchor.map_or(window_x_start, |a| a.min(window_x_start))); + } +} + fn line_display_window_bounds( base_control: ControlState, control_segments: &[ControlSegment], line: usize, visible_line0: i32, + h_row: &HWindowRow, ) -> Option<(usize, usize)> { - let mut bounds = None; + // Horizontal reach from the comparator flip-flop model: a window that + // never closes reaches the framebuffer edge; one that opened on an + // earlier line starts at 0. Interior closed gaps are handled by the + // per-pixel window gate and the closed-interval border mask. + let (env_start, env_stop) = h_row.span(); + if env_start >= env_stop { + return None; + } + // Vertical window: open if any control active on the line has it open. + let vertical_open = std::iter::once(&base_control) + .chain(control_segments.iter().map(|seg| &seg.control)) + .any(|control| control.display_window_contains_line(line, visible_line0)); + if !vertical_open { + return None; + } + // The paint start is the shifter-origin anchor of the control whose + // DIWSTRT comparator fired, not the flip-flop's first open pixel: a + // mid-line DIWSTRT rewrite moves where the window opens without moving + // the fetched picture. + let mut anchor = None; let mut control = base_control; let mut run_start = 0usize; for segment in control_segments { let run_stop = segment.x.min(FB_WIDTH); - merge_display_window_run_bounds( - &mut bounds, + merge_display_window_anchor( + &mut anchor, control, line, visible_line0, @@ -2168,52 +2495,24 @@ fn line_display_window_bounds( control = segment.control; run_start = run_stop; } - merge_display_window_run_bounds( - &mut bounds, + merge_display_window_anchor( + &mut anchor, control, line, visible_line0, run_start, FB_WIDTH, ); - bounds.filter(|(x_start, x_stop)| x_start < x_stop) -} - -fn merge_display_window_run_bounds( - bounds: &mut Option<(usize, usize)>, - control: ControlState, - line: usize, - visible_line0: i32, - run_start: usize, - run_stop: usize, -) { - if run_start >= run_stop || !control.display_window_contains_line(line, visible_line0) { - return; - } - let (window_x_start, window_x_stop) = control.display_window_x(); - if window_x_start >= run_start && window_x_start <= run_stop { - // The horizontal DIW start comparator has fired while this control was - // active, so it establishes the shifter origin even if a same-line - // write clips all visible pixels before the next control run. - match bounds { - Some((bounds_start, _)) => { - *bounds_start = (*bounds_start).min(window_x_start); - } - None => *bounds = Some((window_x_start, window_x_start)), - } - } - let x_start = window_x_start.max(run_start); - let x_stop = window_x_stop.min(run_stop); - if x_start >= x_stop { - return; - } - match bounds { - Some((bounds_start, bounds_stop)) => { - *bounds_start = (*bounds_start).min(x_start); - *bounds_stop = (*bounds_stop).max(x_stop); - } - None => *bounds = Some((x_start, x_stop)), - } + // A carried-open row whose comparator never fired inside the frame + // keeps the base control's DIWSTRT anchor so the picture stays at its + // fetch-derived position. + let x_start = match (h_row.comparator_anchor, anchor) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => base_control.display_window_x().0, + }; + (x_start < env_stop).then_some((x_start, env_stop)) } fn line_max_display_planes( @@ -3154,12 +3453,14 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { let mut dma_output_start_x_by_line = vec![None; rows]; let background_started = Instant::now(); + let h_window_rows = compute_h_window_rows(&base_controls, &control_segments, visible_line0); fill_background_with_visible_line0( fb, &base_palettes, &palette_segments, &base_controls, &control_segments, + &h_window_rows, visible_line0, ); render_timing.background_nanos = background_started.elapsed().as_nanos(); @@ -3271,6 +3572,7 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { row_control_segments, y, visible_line0, + &h_window_rows[y], ) else { continue; }; @@ -3523,6 +3825,7 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { base_controls[y].bplcon1, block_start, bpl_output_start_x, + &h_window_rows[y], visible_line0, input.emulated_seconds, input.emulated_frames, @@ -3664,6 +3967,23 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { .finish_register_and_sprite_only_lines(captured_sprite_lines, visible_line0); display_frame_plan.log_summary(); } + // Final authority for the horizontal window: repaint composited + // content in the flip-flop's closed intervals with the border pixel. + // The span-based clip paths above only handle a single open span per + // row; a mid-line close/reopen (or a window that never closes and + // carries into the next line) produces multiple runs that only this + // comparator-model mask captures. BRDSPRT (ECS/AGA border sprites) + // segments keep their composited pixels. + enforce_h_window_closed_intervals( + fb, + &base_palettes, + &palette_segments, + &base_controls, + &control_segments, + &h_window_rows, + visible_line0, + rows, + ); apply_programmable_blanking( input.programmable_vertical_blank, input.programmable_horizontal_blank, @@ -3780,6 +4100,7 @@ fn render_planned_playfield_line( base_scroll_bplcon1: u16, suppress_prefetch_scroll_fill: bool, bpl_output_start_x: usize, + h_row: &HWindowRow, visible_line0: i32, emulated_seconds: f64, emulated_frames: u64, @@ -3853,6 +4174,11 @@ fn render_planned_playfield_line( if segment_idx < palette_segments.len() { run_stop = run_stop.min(palette_segments[segment_idx].x); } + // The horizontal window flip-flop state is constant between its + // boundaries; fold them into the chunking so `window_open` can be + // hoisted out of the pixel loop. + let window_open = h_row.open_at(x); + run_stop = run_stop.min(h_row.next_boundary_after(x)); let pixel_repeat = pixel_control.framebuffer_pixel_repeat(); let native_per_pixel = pixel_control.native_samples_per_framebuffer_pixel(); @@ -3875,7 +4201,6 @@ fn render_planned_playfield_line( 0 }; let shres = pixel_control.shres(); - let (win_x_start, win_x_stop) = pixel_control.display_window_x(); 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()); @@ -3924,12 +4249,10 @@ fn render_planned_playfield_line( }; let native_x = relative_native_x + native_x_offset; let visible_sample = line_visible + && window_open && (0..pixel_repeat).any(|dx| { let pixel_x = x + dx; - pixel_x < plan.x_stop - && pixel_x >= bpl_output_start_x - && pixel_x >= win_x_start - && pixel_x < win_x_stop + pixel_x < plan.x_stop && pixel_x >= bpl_output_start_x }); // Debugger layer isolation masks the colour-resolution index // only; `sample.idx` stays true for collisions and priority. @@ -4021,8 +4344,8 @@ fn render_planned_playfield_line( pixel_control.diwstop, pixel_control.ddfstrt, pixel_control.ddfstop, - win_x_start, - win_x_stop, + plan.x_start, + plan.x_stop, ); } } @@ -4041,7 +4364,7 @@ fn render_planned_playfield_line( *clxdat |= collision.clxdat_bits(); for dx in 0..pixel_repeat { let pixel_x = x + dx; - if pixel_x >= plan.x_stop || pixel_x < win_x_start || pixel_x >= win_x_stop { + if pixel_x >= plan.x_stop || !window_open { continue; } let fb_idx = plan.y * FB_WIDTH + pixel_x; diff --git a/src/video/bitplane/output.rs b/src/video/bitplane/output.rs index 79a3acf..346c566 100644 --- a/src/video/bitplane/output.rs +++ b/src/video/bitplane/output.rs @@ -39,12 +39,14 @@ pub(super) fn fill_background( base_controls: &[ControlState], control_segments: &[Vec], ) { + let h_window_rows = compute_h_window_rows(base_controls, control_segments, PAL_VISIBLE_LINE0); fill_background_with_visible_line0( fb, base_palettes, palette_segments, base_controls, control_segments, + &h_window_rows, PAL_VISIBLE_LINE0, ); } @@ -70,6 +72,7 @@ pub(super) fn fill_background_with_visible_line0( palette_segments: &[Vec], base_controls: &[ControlState], control_segments: &[Vec], + h_window_rows: &[HWindowRow], visible_line0: i32, ) { for y in 0..base_palettes.len() { @@ -111,20 +114,21 @@ pub(super) fn fill_background_with_visible_line0( x = run_end; continue; } - // In the vertical window: border holds outside [x_start, x_stop). - let (x_start, x_stop) = control.display_window_x(); + // In the vertical window: border holds wherever the horizontal + // window flip-flop is closed (hardware comparator model; the + // open runs already reflect this row's mid-line DIW writes). + let open_runs = h_window_rows[y].open_runs(); let mut sx = x; while sx < run_end { - let border = sx < x_start || sx >= x_stop; - let flip = if sx < x_start { - x_start - } else if sx < x_stop { - x_stop - } else { - FB_WIDTH - }; + let open = open_runs.iter().any(|&(s, e)| sx >= s && sx < e); + let flip = open_runs + .iter() + .flat_map(|&(s, e)| [s, e]) + .filter(|&b| b > sx) + .min() + .unwrap_or(FB_WIDTH); let sub_end = flip.min(run_end).max(sx + 1); - row[sx..sub_end].fill(background_pixel(&control, color0, border)); + row[sx..sub_end].fill(background_pixel(&control, color0, !open)); sx = sub_end; } x = run_end; diff --git a/src/video/bitplane/tests.rs b/src/video/bitplane/tests.rs index 4bd741f..9d76f0c 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -7,6 +7,50 @@ use super::*; use crate::bus::{BeamRegisterWrite, BeamWriteSource}; +/// Single-span window row matching the control's display_window_x, for +/// direct render_planned_playfield_line tests. +fn h_row_for(control: ControlState) -> HWindowRow { + HWindowRow { + open_runs: vec![control.display_window_x()], + comparator_anchor: Some(control.display_window_x().0), + } +} + +#[test] +fn h_window_flop_carries_open_past_line_with_unreachable_hstart() { + // Standard window rows, then a late-line DIWSTOP rewrite to $2C00 + // before the standard stop matched: the flip-flop stays open across + // the line boundary. The following row (hstart $00 unreachable, + // hstop $100) is open from the left framebuffer edge until $100. + let standard = ControlState { + diwstrt: 0x2C81, + diwstop: 0x2CC1, + agnus_revision: AgnusRevision::Ocs, + ..ControlState::default() + }; + let degenerate = ControlState { + diwstrt: 0x2C00, + diwstop: 0x2C00, + ..standard + }; + let base_controls = [standard, standard, degenerate]; + let mut control_segments = vec![Vec::new(); 3]; + // Late write on row 1 (copper-x 644 = cck $C9), before the standard + // stop's comparator position. + control_segments[1].push(ControlSegment { + x: 644, + control: degenerate, + }); + let rows = compute_h_window_rows(&base_controls, &control_segments, PAL_VISIBLE_LINE0); + // Row 0: standard window. + assert_eq!(rows[0].open_runs(), &[(64, 704)]); + // Row 1: opens at the standard hstart; the rewritten stop never + // matches before the line ends, so the run reaches the edge. + assert_eq!(rows[1].open_runs(), &[(64, FB_WIDTH)]); + // Row 2: carried open, closes at hstop $100. + assert_eq!(rows[2].open_runs(), &[(0, 318)]); +} + #[test] fn programmable_blanking_blanks_vbstrt_vbstop_rows_under_varvben() { use crate::chipset::agnus::{ @@ -723,8 +767,13 @@ fn line_start_diw_write_replaces_previous_horizontal_display_bounds() { control: narrowed, }]; + let h_rows = compute_h_window_rows( + &[base], + std::slice::from_ref(&segments.to_vec()), + PAL_VISIBLE_LINE0, + ); assert_eq!( - line_display_window_bounds(base, &segments, 0, PAL_VISIBLE_LINE0), + line_display_window_bounds(base, &segments, 0, PAL_VISIBLE_LINE0, &h_rows[0]), Some(narrowed.display_window_x()) ); } @@ -909,7 +958,11 @@ fn beam_timed_bplcon3_brdrblnk_latches_until_ecsena_enables_effect() { bplcon0: 0, bplcon3: 0, diwstrt: ((PAL_VISIBLE_LINE0 as u16) << 8) | (DIW_HSTART_FB0 as u16 + 80), - diwstop: (((PAL_VISIBLE_LINE0 + 1) as u16) << 8) | (DIW_HSTART_FB0 as u16 + 120), + // OCS DIWSTOP carries an implied H8, so the stop byte must map + // inside the comparator's reach (H <= 0x1C7) for the window to + // close; 0x99 -> 0x199 closes mid-line, leaving a left border for + // the BRDRBLNK assertions below. + diwstop: (((PAL_VISIBLE_LINE0 + 1) as u16) << 8) | 0x0099, ..blank_state() }; state.palette.write_ocs(0, 0x0F00); @@ -1220,6 +1273,7 @@ fn late_lowres_ddf_stop_hold_keeps_left_origin_unadvanced() { control.bplcon1, false, 0, + &h_row_for(control), PAL_VISIBLE_LINE0, 0.0, 0, @@ -4926,6 +4980,7 @@ fn planned_ham_dma_uses_current_bitplane_sample_at_fetch_edge() { control.bplcon1, false, 0, + &h_row_for(control), PAL_VISIBLE_LINE0, 0.0, 0, @@ -4970,6 +5025,7 @@ fn planned_ham_dma_advances_hold_through_edge_fetch_phase() { control.bplcon1, false, 0, + &h_row_for(control), PAL_VISIBLE_LINE0, 0.0, 0, @@ -5015,6 +5071,7 @@ fn planned_ham_dma_ignores_extra_early_ddf_history_before_diw() { control.bplcon1, false, 0, + &h_row_for(control), PAL_VISIBLE_LINE0, 0.0, 0, @@ -5063,6 +5120,7 @@ fn bplcon1_write_at_diw_right_edge_does_not_retap_current_ham_line() { control.bplcon1, false, 0, + &h_row_for(control), PAL_VISIBLE_LINE0, 0.0, 0, @@ -5101,6 +5159,7 @@ fn bplcon2_color_key_uses_color_register_transparency_bit() { control.bplcon1, false, 0, + &h_row_for(control), PAL_VISIBLE_LINE0, 0.0, 0, @@ -5139,6 +5198,7 @@ fn bplcon2_bitplane_key_uses_selected_bitplane_sample() { control.bplcon1, false, 0, + &h_row_for(control), PAL_VISIBLE_LINE0, 0.0, 0, @@ -5177,6 +5237,7 @@ fn bplcon3_zdclken_disables_internal_genlock_keys() { control.bplcon1, false, 0, + &h_row_for(control), PAL_VISIBLE_LINE0, 0.0, 0, @@ -5211,6 +5272,7 @@ fn planned_playfield_line_feeds_clxdat_from_rendered_dual_playfield_sample() { control.bplcon1, false, 0, + &h_row_for(control), PAL_VISIBLE_LINE0, 0.0, 0, From 60b10c79668e8ba0f6452b9d47ee97393839d184 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Fri, 3 Jul 2026 23:16:55 +0100 Subject: [PATCH 2/2] tests: pin hblank-tail colour write reaching the following row --- src/bus/tests.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/bus/tests.rs b/src/bus/tests.rs index ceaf3a2..f488a68 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -3446,6 +3446,41 @@ fn beam_timed_display_window_changes_clip_later_bitplane_rows() { assert_eq!(fb[FB_WIDTH + STANDARD_VISIBLE_X0], rgb12_to_rgba8(0x0000)); } +#[test] +fn hblank_tail_color_write_paints_following_row_from_left_edge() { + // A copper COLOR00 write in the horizontal-blank tail (hpos < 0x12) + // belongs to the previous output row's invisible tail; the following + // row must show the new colour from its first framebuffer column. + let mut bus = empty_bus(); + bus.denise.diwstrt = 0x2C81; + bus.denise.diwstop = 0x2DC1; + bus.denise.bplcon0 = 0x0200; + bus.denise.palette.write_ocs(0, 0x0000); + bus.current_frame_render_base = bus.capture_render_snapshot(); + bus.current_frame_render_events.push(BeamRegisterWrite { + vpos: RENDER_VISIBLE_START_VPOS + 20, + hpos: 0x06, + offset: 0x180, + value: 0x0F00, + source: BeamWriteSource::Copper, + }); + bus.current_frame_render_events.push(BeamRegisterWrite { + vpos: RENDER_VISIBLE_START_VPOS + 20, + hpos: 0xDE, + offset: 0x180, + value: 0x0333, + source: BeamWriteSource::Copper, + }); + + let mut fb = vec![0; FB_PIXELS]; + bitplane::render(&mut bus, &mut fb); + + let row = 20 * FB_WIDTH; + assert_eq!(fb[row], rgb12_to_rgba8(0x0F00), "x=0"); + assert_eq!(fb[row + 8], rgb12_to_rgba8(0x0F00), "x=8"); + assert_eq!(fb[row + 20], rgb12_to_rgba8(0x0F00), "x=20"); +} + #[test] fn beam_timed_diwstrt_rewrite_after_window_open_does_not_reclip_line() { let mut bus = empty_bus();