From 7ae2df16b8a70d000b28880a601caa3f812960ee Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 11 Jul 2026 16:52:10 +0100 Subject: [PATCH] video: do not synthesize a picture for a line whose fetch never ran Agnus arms a line's bitplane fetch run at the DDFSTRT comparator match. When BPLCON0 is raised mid-line only after that point has passed (the Rampage bottom-scroller band entry: display off since v227, then at WAIT v248,h14 the copper re-points BPLxPT and lands DDFSTRT $38->$28 at ~h42 and BPLCON0=$3200 at ~h50), no run starts until the next line. The DMA side already models this (the line allocates no bitplane slots), but the renderer's register-derived fallback still synthesized a fetch for the line, painting a phantom, per-plane word-skewed copy of the first bitmap row one line above the real first fetch row. Gate the synthesized re-fetch path on the DMA capture: when the frame carries captured bitplane rows, a line whose capture slot is empty fetched nothing and paints no playfield. A captured row that merely disagrees with the register-derived geometry still takes the re-fetch path, and frames rendered without any capture (COPPERLINE_RENDER_LIVE_CHIP_RAM) keep the synthesized path. This is the same capture-authority rule the sub-$18 DDFSTRT gate (vAmigaTS oldhwstop3/4) already applied, generalized. The faint second dot row on the line below is authentic: the demo's scroller ring buffer carries stray bits in its first bitmap row, and the real fetch displays them. Regression test: bus::tests::line_without_captured_fetch_paints_no_playfield (fails without the gate). Golden probe renders are unchanged; gen-x 80s/150s screenshots are byte-identical. --- src/bus/tests.rs | 81 +++++++++++++++++++++++++++++++++++++++++++ src/video/bitplane.rs | 19 ++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/bus/tests.rs b/src/bus/tests.rs index 5a3e939..5909b71 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -10011,6 +10011,87 @@ fn render_input_refill_from_bus_matches_fresh_snapshot() { ); } +#[test] +fn line_without_captured_fetch_paints_no_playfield() { + // Agnus arms a line's fetch run at the DDFSTRT comparator match; a + // BPLCON0 raised mid-line only after that point starts no run until the + // next line. The DMA capture records which lines ran, so a line with no + // captured fetch fetched nothing and the renderer must not synthesize a + // picture for it from the register-derived window (regression: the + // Rampage bottom-scroller band entry painted a phantom, word-skewed + // copy of the first bitmap row one line above the real first fetch row). + let mut bus = empty_bus(); + bus.agnus.dmacon = DMACON_DMAEN | DMACON_BPLEN; + bus.denise.diwstrt = 0x2C81; + bus.denise.diwstop = 0x2FC1; + bus.denise.ddfstrt = 0x0038; + bus.denise.ddfstop = 0x00D0; + bus.denise.bplcon0 = 0x1000; + bus.denise.palette.write_ocs(1, 0x0FFF); + bus.denise.bplpt[0] = 0x0100; + let words_per_row = bitplane_words_per_row( + bus.agnus.revision(), + bus.denise.bplcon0, + bus.agnus.fmode(), + bus.denise.ddfstrt, + bus.denise.ddfstop, + bus.harddis_active(), + ); + // Non-zero bitmap behind the pointer so a synthesized re-fetch of the + // uncaptured line would visibly paint. + for word in 0..words_per_row { + write_chip_word(&mut bus, 0x0100 + word * 2, 0xFFFF); + } + bus.current_frame_render_base = bus.capture_render_snapshot(); + let captured_row = || { + Some(CapturedBitplaneRow { + nplanes: 1, + words_per_row, + fetch_origin_cck: None, + planes: std::array::from_fn(|plane| { + if plane == 0 { + vec![0xFFFF; words_per_row] + } else { + vec![0; words_per_row] + } + }), + }) + }; + + let lit_rows = |fb: &[u32]| -> Vec { + let background = fb[0]; + (0..FB_HEIGHT) + .filter(|row| { + fb[row * FB_WIDTH..(row + 1) * FB_WIDTH] + .iter() + .any(|&px| px != background) + }) + .collect() + }; + + // Only line 1 recorded a fetch: line 0's sequencer never ran. + bus.current_frame_bitplane_rows[1] = captured_row(); + let mut fb_skipped = vec![0u32; FB_PIXELS]; + bitplane::render_from_input(&bitplane::RenderInput::from_bus(&bus), &mut fb_skipped); + + // Both lines recorded fetches: shows where line 0's picture would sit. + bus.current_frame_bitplane_rows[0] = captured_row(); + let mut fb_both = vec![0u32; FB_PIXELS]; + bitplane::render_from_input(&bitplane::RenderInput::from_bus(&bus), &mut fb_both); + + let skipped = lit_rows(&fb_skipped); + let both = lit_rows(&fb_both); + assert!(!skipped.is_empty(), "the captured line must paint"); + assert!( + both.first() < skipped.first(), + "the two-capture render must start a line above ({both:?} vs {skipped:?})" + ); + assert!( + skipped.iter().all(|row| both.contains(row)), + "the uncaptured line must not add pixels of its own" + ); +} + #[test] fn beam_trap_fires_at_exact_position_and_one_shot_disarms() { let mut bus = empty_bus(); diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index a965bd6..7d3d3df 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -3807,6 +3807,25 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { if captured_row.is_none() && !control.bitplane_dma_enabled() { continue; } + // The DMA capture is the authority for whether the bitplane + // sequencer ran on a line. A line with no captured fetch at all + // fetched nothing - e.g. BPLCON0 raised mid-line only after the + // line's DDFSTRT comparator had already passed (the Rampage + // bottom-scroller band entry), where Agnus starts no run until + // the next line. Synthesizing a picture from the register-derived + // window would paint a phantom row that hardware never fetched. + // (A captured row that merely disagrees with the register-derived + // geometry still takes the re-fetch path below; frames rendered + // without any capture - COPPERLINE_RENDER_LIVE_CHIP_RAM - keep + // the synthesized path.) + if has_captured_bitplane_rows + && captured_bitplane_rows + .get(y) + .and_then(Option::as_ref) + .is_none() + { + continue; + } // A DDFSTRT comparator below the hardwired start window ($18) // only arms a run when the sequencer's SHW latch survived from // the previous line (OCS clears it when a fetch run completes,