From 0c85c8e7cffdff672078f2f7506ba1c6a803d402 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Tue, 7 Jul 2026 21:39:12 +0100 Subject: [PATCH 1/2] denise: follow per-pixel COLOR00 in the closed-DIW border repaint The horizontal display-window flip-flop's closed intervals are repainted with the border pixel after compositing. That repaint walked only the control (BPLCON/DDF) segments to bound each fill run and sampled COLOR00 once at the run's start. When the copper rewrites COLOR00 across the border to draw a colour bar -- the copper-chunky technique behind demo logo banners -- the write lands mid-run, so the whole border run was painted with the frame-start COLOR00 and the colour change was dropped, clipping the bar's edge back to the previous control boundary. Denise applies COLOR00 changes per pixel in the border exactly as it does inside the display window, so end each repaint run at the next control OR palette (colour) segment. The border now follows COLOR00 write-for-write. Regression example: the "binary" demo (r.adf) red logo bar rendered short on the left and grew stray colour fragments as the copper's border COLOR00 writes were sampled at the wrong x. --- src/video/bitplane.rs | 17 ++++++++---- src/video/bitplane/tests.rs | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index c2e775f..b721ab3 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -637,26 +637,33 @@ fn enforce_h_window_closed_intervals( 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] + // The border colour follows COLOR00 per pixel, so a run ends at + // the next control OR palette (colour) segment -- not just the + // next control change. Splitting on control alone painted a + // whole control-run with COLOR00 sampled at its start, dropping + // any mid-run colour write (a copper COLOR00 change inside the + // left border, as in copper-chunky banners). + let next_bound = control_segments[y] .iter() .map(|seg| seg.x) + .chain(palette_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; + sx = next_bound; continue; } if control.border_sprite_enabled() { - sx = next_ctl; + sx = next_bound; 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; + row[sx..next_bound].fill(pixel); + sx = next_bound; } x = closed_end; } diff --git a/src/video/bitplane/tests.rs b/src/video/bitplane/tests.rs index 0c06f42..3ee5c03 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -1013,6 +1013,58 @@ fn beam_timed_bplcon3_brdrblnk_latches_until_ecsena_enables_effect() { assert_eq!(fb[40], rgb12_to_rgba8_alpha(0, false)); } +#[test] +fn closed_interval_repaint_follows_copper_color00_writes() { + // Copper-chunky banners (e.g. the "binary" logo's red bar) paint colour + // bars by rewriting COLOR00 across the horizontal border, where the DIW + // flip-flop has closed the display window. The closed-interval border + // repaint must sample COLOR00 per colour-write, not once per control run: + // sampling once painted the whole left border with the frame-start COLOR00 + // and dropped the copper's mid-border colour change, clipping the bar's + // left edge back to the first control boundary. + let control = visible_lowres_control(0); + let mut palette = Palette::from_ocs([0; 32]); + palette.write_ocs(0, 0x0123); // dark frame-start COLOR00 + let base_palettes = [palette]; + // Copper writes COLOR00 = red partway across the closed left border. + let palette_segments = vec![vec![PaletteSegment { + x: 40, + entry: 0, + loct: false, + value: 0x0C00, + }]]; + let base_controls = [control]; + let control_segments = vec![Vec::new()]; + // Flip-flop closed over [0, 100); open to the right of it. + let h_window_rows = vec![HWindowRow { + open_runs: vec![(100, FB_WIDTH)], + comparator_anchor: Some(100), + }]; + let mut fb = vec![0u32; FB_WIDTH]; + + enforce_h_window_closed_intervals( + &mut fb, + &base_palettes, + &palette_segments, + &base_controls, + &control_segments, + &h_window_rows, + PAL_VISIBLE_LINE0, + 1, + ); + + let dark = background_pixel(&control, 0x0123, true); + let red = background_pixel(&control, 0x0C00, true); + // Border left of the copper write keeps the frame-start colour... + assert_eq!(fb[20], dark); + assert_eq!(fb[38], dark); + // ...and follows the red COLOR00 write for the rest of the closed border. + assert_eq!(fb[40], red); + assert_eq!(fb[98], red); + // The open interval to its right is left untouched by the repaint. + assert_eq!(fb[100], 0); +} + #[test] fn native_x_offset_accounts_for_diw_and_ddf_alignment() { let standard_hires = RenderState { From 1e476cb401fa644eeb2cd139dda459c1500197fd Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Tue, 7 Jul 2026 23:26:14 +0100 Subject: [PATCH 2/2] denise: place hi-res bitplane content flush at the 2H-196 window edge Standard hi-res ($81 DIWSTRT / $3C DDFSTRT) put its first fetched sample two framebuffer pixels inside the display window instead of flush at the window edge, so a 40-word row overran the window's closing edge and its rightmost fetched pixel was masked as border. On real hardware (and vAmiga) the picture fills the window flush to both edges. The 2H-196 comparator move set DIW_HSTART_FB0 to 0x62 and bumped both fetch references +1 "in lockstep". Lo-res stayed flush, but hi-res has one framebuffer pixel per sample where lo-res has two, so the same +1 bump shifted hi-res twice as far and left it two pixels into the window. Give hi-res the same 0x81 reference as lo-res so both place sample 0 at the window edge; late-DDF hi-res (its border shows the pre-window samples) follows the corrected reference too. Regression example: the AmigaDOS window's right vertical border on KS1.3 (the "binary" demo, r.adf) reappears, matching vAmiga. vAmigaTS Denise, Agnus/DDF and BPLCON1 buckets improve with no regressions; the standard hi-res BPLCON0/modes references reach 0.000%. --- src/bus/tests.rs | 12 +++++++----- src/video/bitplane.rs | 31 ++++++++++++++++++------------- src/video/bitplane/tests.rs | 36 ++++++++++++++++++++++-------------- 3 files changed, 47 insertions(+), 32 deletions(-) diff --git a/src/bus/tests.rs b/src/bus/tests.rs index f87da27..c05985b 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -4548,11 +4548,13 @@ fn beam_timed_bplcon0_hires_narrows_later_bitplane_pixels() { let mut fb = vec![0; FB_PIXELS]; bitplane::render(&mut bus, &mut fb); - // Content columns sit 2 fb px right of the hardware window edge - // (bitmap positions are beam-anchored; STANDARD_VISIBLE_X0 moved to 62). - assert_eq!(fb[STANDARD_VISIBLE_X0 + 34], rgb12_to_rgba8(0x0000)); - assert_eq!(fb[STANDARD_VISIBLE_X0 + 36], rgb12_to_rgba8(0x0F00)); - assert_eq!(fb[STANDARD_VISIBLE_X0 + 37], rgb12_to_rgba8(0x0000)); + // Content columns sit flush against the hardware window edge + // (STANDARD_VISIBLE_X0 = 62): standard hi-res places its first fetched + // sample at the window edge, so the set bit lands two fb px left of the + // old beam-anchored placement. + assert_eq!(fb[STANDARD_VISIBLE_X0 + 32], rgb12_to_rgba8(0x0000)); + assert_eq!(fb[STANDARD_VISIBLE_X0 + 34], rgb12_to_rgba8(0x0F00)); + assert_eq!(fb[STANDARD_VISIBLE_X0 + 35], rgb12_to_rgba8(0x0000)); } #[test] diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index b721ab3..47f2cc0 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -44,22 +44,27 @@ const PAL_VISIBLE_LINE0: i32 = 0x2C; // which show the bitmap's first lo-res pixel fully visible at the edge). const DIW_HSTART_FB0: i32 = 0x62; const STANDARD_DIW_HSTART: i32 = 0x81; -// Standard DIWSTRT $81 is the visible window edge. The first standard -// bitplane sample at DDFSTRT $38 is already one lowres native sample into the -// fetched word, so the fetch/output phase is referenced one color clock earlier. +// Standard DIWSTRT $81 is the visible window edge. Both a standard lo-res +// ($38 DDF) and standard hi-res ($3C DDF) picture start their first fetched +// sample flush at that edge: the references position bitplane sample 0 at +// framebuffer x = (reference - DIW_HSTART_FB0) * 2, and DIW_HSTART_FB0 (0x62) +// with reference 0x81 puts sample 0 at x = 62, exactly where the 2H-196 +// comparator opens the window. A standard 20-word lo-res / 40-word hi-res row +// then fills the window's 320 lo-res / 640 hi-res samples flush to both edges, +// matching vAmiga (verified on the vAmigaTS Denise/Registers/BPLCON0/modes +// A500 references, which reach 0.000% only when the hi-res picture sits flush). // -// The lo-res fetch/display phase sits 3 colour clocks later than the hi-res -// phase: a hi-res fetch slot delivers its word to Denise's shifter on a -// different beam edge than a lo-res slot, so the reference the renderer uses to -// place the first fetched pixel differs by resolution. The references position -// bitplane sample 0 at framebuffer x = (reference - DIW_HSTART_FB0) * 2, so -// they moved +1 in lockstep with the DIW_HSTART_FB0 comparator fix to keep the -// hardware-calibrated bitmap positions (lo-res sample 0 at x = 62, hi-res -// standard $81/$3C flush at x = 64). The standard $81 window edge now sits at -// x = 62 too, exposing the lo-res bitmap's first sample as on real hardware. +// Hi-res briefly used reference 0x82: that kept the picture "beam-anchored" at +// x = 64 after the comparator moved to x = 62 (2H-196), which left the picture +// two hi-res pixels inside the window and clipped its rightmost fetched pixel +// against the window's closing edge -- e.g. the AmigaDOS window's right border +// on KS1.3 (the "binary" demo, r.adf) vanished. Because hi-res has one +// framebuffer pixel per sample (lo-res has two), the +1 reference bump that +// kept lo-res flush over-shifted hi-res by two, so hi-res needs the same 0x81 +// reference as lo-res to stay flush at the window edge. // See `fetch_reference` below. const DIW_HSTART_FETCH_REFERENCE_LORES: i32 = 0x81; -const DIW_HSTART_FETCH_REFERENCE_HIRES: i32 = 0x82; +const DIW_HSTART_FETCH_REFERENCE_HIRES: i32 = 0x81; // Register/copper-write x=0 anchor, in colour clocks. Moved left by 8 colour // clocks in lockstep with DIW_HSTART_FB0 (16 lo-res pixels) so register writes // and bitplane pixels still register against each other after widening. diff --git a/src/video/bitplane/tests.rs b/src/video/bitplane/tests.rs index 3ee5c03..24a9029 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -279,9 +279,11 @@ fn early_ddf_hires_origin_snaps_to_word_boundary() { assert_eq!(standard.native_x_offset(standard.diw_h_start(), repeat), 0); // Kickstart 2.05 insert-disk screen: hi-res but DDFSTRT=$40 (late), so - // there is no pre-fetch word. The late fetch keeps its beam-anchored - // position (absolute picture x is unchanged by the DIW comparator fix; - // the offset shrinks by the 2 hi-res px the window edge moved left). + // there is no pre-fetch word; the fetch origin sits inside the window and + // the left border shows the pre-window samples. With the hi-res fetch + // reference sharing the lo-res 0x81 anchor (both flush at the 2H-196 window + // edge), the window's first visible sample is 24 hi-res samples into the + // fetched row. The Agnus/DDF vAmigaTS references improve, none regress. let ks_boot = ControlState { diwstrt: 0x2C95, diwstop: 0x2CAD, @@ -289,7 +291,7 @@ fn early_ddf_hires_origin_snaps_to_word_boundary() { ddfstop: 0x00D0, ..xsysinfo }; - assert_eq!(ks_boot.native_x_offset(ks_boot.diw_h_start(), repeat), 22); + assert_eq!(ks_boot.native_x_offset(ks_boot.diw_h_start(), repeat), 24); } fn ocs_snapshot(diwstrt: u16, diwstop: u16, ddfstrt: u16, ddfstop: u16) -> RenderRegisterSnapshot { @@ -1077,15 +1079,17 @@ fn native_x_offset_accounts_for_diw_and_ddf_alignment() { // FMODE=0: the fetch gulp equals the DDF granularity, so the picture // follows DDFSTRT continuously. KS 2.05's insert-disk screen (DDF - // $40, DIW $95) keeps the same late-DDF relation after the standard - // $81/$3C hi-res phase is aligned to the display-window edge. + // $40, DIW $95) is a late-DDF hi-res picture whose left border shows the + // samples fetched before the window opens: with the hi-res reference flush + // at the 2H-196 window edge the first visible sample is 24 hi-res samples + // into the row (Agnus/DDF vAmigaTS references improve, none regress). let kickstart_hires = RenderState { bplcon0: 0x8000, diwstrt: 0x6395, ddfstrt: 0x0040, ..blank_state() }; - assert_eq!(kickstart_hires.native_x_offset(true, 1), 22); + assert_eq!(kickstart_hires.native_x_offset(true, 1), 24); // Wide FMODE fetches quantize the displayed shifter origin to the gulp // grid: AGA system screens program DDFSTRT $38 or $3C interchangeably @@ -1118,10 +1122,10 @@ fn native_x_offset_accounts_for_diw_and_ddf_alignment() { // FS-UAE. DDFSTRT $38 and $3C are still equivalent because both align // to the same wide-FMODE gulp. assert_eq!(wb_hires_overscan_fetch.native_x_offset(true, 1), 0); - // The hi-res picture keeps its beam-anchored position (x = 64); the - // hardware window edge (62) now opens one lo-res pixel earlier, so the - // picture starts 2 hi-res px into the window. - assert_eq!(wb_hires_overscan_fetch.fetch_start_native_x(true, 1), 2); + // The hi-res picture sits flush against the 2H-196 window edge (x = 62), + // same as lo-res: its first fetched sample is the window's first visible + // pixel, so no samples are consumed in the border before it opens. + assert_eq!(wb_hires_overscan_fetch.fetch_start_native_x(true, 1), 0); // The placement gulp grid runs on absolute colour-clock multiples of // the fetch period. Lores FMODE=1 has a 16-cck gulp: DDFSTRT $30 is @@ -1161,9 +1165,13 @@ fn native_x_offset_accounts_for_diw_and_ddf_alignment() { assert_eq!(diagrom_hires.display_window_x().0, 62); assert_eq!(diagrom_hires.clipped_display_pixels_before_frame(), 0); assert_eq!(diagrom_hires.native_x_offset(true, 1), 0); - // Standard hi-res keeps its beam-anchored position (x = 64), 2 hi-res - // px inside the hardware window edge (62). - assert_eq!(diagrom_hires.fetch_start_native_x(true, 1), 2); + // Standard hi-res ($81/$3C) sits flush against the 2H-196 window edge + // (x = 62): its first fetched sample is the window's first visible pixel, + // so a full 40-word row fills the 640-sample window to both edges. When + // hi-res used a +1 (0x82) reference it started 2 hi-res px inside the + // window and clipped its rightmost fetched pixel -- the AmigaDOS window's + // right border vanished on KS1.3 (the "binary" demo, r.adf). + assert_eq!(diagrom_hires.fetch_start_native_x(true, 1), 0); let lores_extra_fetch_word = RenderState { bplcon0: 0,