From 038080d33584d8a4238cfc2db2042ed57b42feb8 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Mon, 6 Jul 2026 23:33:40 +0100 Subject: [PATCH] agnus: anchor below-hard-start DDFSTRT runs at their raw fetch grid A DDFSTRT below the hardwired start window ($18) fires its comparator while SHW is still down, so a fresh line starts no run; but OCS only clears SHW when a fetch run completes, so the latch survives a run-less line and the next line's match does arm a run - anchored at the raw comparator position and draining through the hardware stop, on alternating rasters. The sequencer walk already produced this DMA pattern; the renderer discarded it: captured origins below $18 were dropped, placement clamped sub-$18 starts to the hard start, and the no-run rasters were painted from the register window anyway. Renderer changes (hardware-verified by the vAmigaTS Agnus/DDF oldhwstop3/4 A500 photos): - apply_captured_fetch_geometry accepts comparator-anchored origins below the hard start (origin 0 keeps the register view: DDFSTRT=0 is the no-window sentinel, TODO noted). - effective_ddf_start_hpos / effective_ddf_window keep sub-$18 starts linear instead of flooring to $18; FMODE=0 placement drops the hard-start clamp on its round-up grid alignment (wide FMODE keeps it). - A row whose register DDFSTRT sits below $18 with no captured fetch paints nothing (the no-run rasters of the alternation). - Lines beginning inside a run carried across the line wrap report no run origin (the tail is not a comparator anchor) and keep the register view as before. - A captured run origin also rewrites the row's control segments and is authoritative for the row's word count, so band-entry rows paint the captured run instead of a synthetic register-window fallback. vAmigaTS Agnus/DDF: oldhwstop3_ocs 9.52% -> 0.25%, oldhwstop4_ocs 7.53% -> 0.23%, hwstop3-6_ocs 6.3/6.4/6.4/3.7% -> 0.4-0.7%, single4/5 and the ECS hwstop family improve as well; no case in the bucket regresses by more than 0.12pp. The 14-demo byte-identity gate holds. --- docs/internals/timing.md | 10 +++- src/bus/ddf_line.rs | 71 ++++++++++++++++++++++++++- src/chipset/ddf_sequencer.rs | 59 +++++++++++++++++++++++ src/video/bitplane.rs | 93 ++++++++++++++++++++++++++++-------- src/video/bitplane/tests.rs | 33 ++++++++++++- 5 files changed, 242 insertions(+), 24 deletions(-) diff --git a/docs/internals/timing.md b/docs/internals/timing.md index 5e0bcd2..ab7ff72 100644 --- a/docs/internals/timing.md +++ b/docs/internals/timing.md @@ -39,7 +39,15 @@ carries across line boundaries. Missed comparators therefore produce the hardware's "old stop" behaviours: a rewritten-too-late DDFSTOP lets the run continue to the hardware-stop drain, a DDFSTRT match past $D8 starts a run that wraps through horizontal blanking into the next line, and an -early-blanked DDFSTRT ($10 with SHW still down) never starts on OCS. The +early-blanked DDFSTRT ($10 with SHW still down) never starts on OCS on a +fresh line. Because OCS only clears SHW when a fetch run completes, the +latch survives a run-less line: the next line's below-$18 DDFSTRT match +then does arm a run, anchored at its raw comparator position, so such +lines fetch on alternating rasters with the picture sitting linearly left +of the standard grid (its early words run through the left border). The +renderer honours this through the captured run geometry: a below-$18 run +origin keeps its raw fetch grid, and a line without a captured fetch +paints nothing (vAmigaTS Agnus/DDF/DDF/oldhwstop3/4 A500 photos). The per-line fetch table is rebuilt when DDFSTRT/DDFSTOP/BPLCON0/DMACON/DIW writes land (DDF writes commit to the comparators four colour clocks after the write slot; an old DDFSTOP still fires on its commit clock, an old diff --git a/src/bus/ddf_line.rs b/src/bus/ddf_line.rs index 7aea95b..19db022 100644 --- a/src/bus/ddf_line.rs +++ b/src/bus/ddf_line.rs @@ -50,7 +50,11 @@ pub(super) struct DdfSeqLine { /// First fetch colour clock of the line, if any. pub first_fetch_cck: Option, /// The run's first fetch-unit boundary (first fetch minus its unit - /// offset): the position that anchors word 0 on the display. + /// offset): the position that anchors word 0 on the display. `None` + /// when the line began inside a run carried across the line wrap: the + /// continuation tail is not a comparator-anchored origin (its unit + /// counter carries over mid-word), so the renderer keeps the register + /// view for such lines. pub run_origin_cck: Option, /// Sequencer state after the line's walk (becomes the next line's /// initial state). @@ -227,6 +231,10 @@ impl Bus { let (_, hard_stop) = crate::chipset::agnus::ddf_hard_bounds(self.harddis_active()); let signals = seq::line_signals_with_hard_stop(0xFFFF, 0xFFFF, hard_stop, line_ccks, &extra); + // A line that begins with BPRUN already up continues a run carried + // across the line wrap; its first fetches are a mid-unit tail, not + // a comparator-anchored run origin. + let run_carried_in = state.bprun; let fetches = seq::walk_line( self.aga_enabled(), self.ddf_seq_ecs_rules(), @@ -268,7 +276,9 @@ impl Bus { line.words_per_plane[plane].max(line.word_idx_at[idx] + 1); if line.first_fetch_cck.is_none() { line.first_fetch_cck = Some(f.cck); - line.run_origin_cck = Some(f.cck.saturating_sub(u16::from(f.counter))); + if !run_carried_in { + line.run_origin_cck = Some(f.cck.saturating_sub(u16::from(f.counter))); + } } } line @@ -572,6 +582,63 @@ mod tests { assert_eq!(table.first_fetch_cck, None); } + #[test] + fn run_carried_across_the_line_wrap_reports_no_origin() { + let mut bus = empty_bus(); + bus.agnus.dmacon = DMACON_DMAEN | DMACON_BPLEN; + bus.denise.diwstrt = 0x2C81; + bus.denise.diwstop = 0x2CC1; + // A start past the hardware stop arms a run the missed RHW cannot + // stop: it wraps through horizontal blanking into the next line. + bus.denise.ddfstrt = 0x00E0; + bus.denise.ddfstop = 0x00FF; + bus.denise.bplcon0 = 0x4200; + // Roll over from a line above the vertical window so the armed + // line starts from a clean (not carried) state. + bus.agnus.vpos = 0x2C; + bus.ddf_seq_on_line_rollover(0x2B); + { + let table = bus.ddf_seq_line_table(); + assert_eq!(table.run_origin_cck, Some(0xE0)); + assert!(table.end_state.bprun, "run carries across the wrap"); + } + bus.agnus.vpos = 0x2D; + bus.ddf_seq_on_line_rollover(0x2C); + let table = bus.ddf_seq_line_table(); + // The wrapped line fetches from its start, but the tail is not a + // comparator-anchored origin: the renderer keeps the register view. + assert!(table.first_fetch_cck.is_some()); + assert_eq!(table.run_origin_cck, None); + } + + #[test] + fn start_below_hard_window_reports_its_raw_origin_on_the_armed_line() { + let mut bus = empty_bus(); + bus.agnus.dmacon = DMACON_DMAEN | DMACON_BPLEN; + bus.denise.diwstrt = 0x2C81; + bus.denise.diwstop = 0x2CC1; + bus.denise.ddfstrt = 0x0010; + bus.denise.ddfstop = 0x0010; + bus.denise.bplcon0 = 0x4200; + // The rolled-over line (above the vertical window) fires the $10 + // comparator with SHW still down, so nothing fetches; SHW armed at + // $18 survives into this line. + bus.agnus.vpos = 0x2C; + bus.ddf_seq_on_line_rollover(0x2B); + { + // The surviving SHW latch arms the run at the raw $10 grid and + // the missed stop drains through the hardware stop. + let table = bus.ddf_seq_line_table(); + assert_eq!(table.run_origin_cck, Some(0x10)); + assert_eq!(table.words_per_plane[0], 26); + } + bus.agnus.vpos = 0x2D; + bus.ddf_seq_on_line_rollover(0x2C); + // The completed run cleared SHW: the next line starts nothing. + let table = bus.ddf_seq_line_table(); + assert_eq!(table.first_fetch_cck, None); + } + #[test] fn mid_line_stop_rewrite_reaches_the_walk() { let mut bus = empty_bus(); diff --git a/src/chipset/ddf_sequencer.rs b/src/chipset/ddf_sequencer.rs index ba57e93..2ae3548 100644 --- a/src/chipset/ddf_sequencer.rs +++ b/src/chipset/ddf_sequencer.rs @@ -609,6 +609,65 @@ mod tests { assert_eq!(fetches.first().map(|f| f.cck), Some(0x61)); } + #[test] + fn ocs_start_below_hard_window_runs_on_alternating_lines_from_its_raw_grid() { + // DDFSTRT below the hardwired start ($10 < $18): the comparator + // fires while SHW is still down, so a fresh line starts no run. SHW + // set at $18 survives the line end on OCS (only a completed fetch + // run clears it), so the NEXT line's $10 match arms a run anchored + // at the raw $10 grid; the missed DDFSTOP leaves it to the RHW + // drain ($D8 unit). The run then alternates: line with run clears + // SHW, line without re-arms it. Hardware-verified by the vAmigaTS + // Agnus/DDF oldhwstop3/4 A500 photos (via the vAmiga sequencer). + let mut state = ready_state(lores4()); + // Preceding line with a standard window: its completed run leaves + // SHW cleared. + let _ = walk_static(false, 0x60, 0xA0, &mut state); + assert!(!state.shw); + + let first = walk_static(false, 0x10, 0x10, &mut state); + assert!(first.is_empty(), "fresh line: SHW still down at $10"); + assert!(state.shw, "SHW armed at $18 survives the line end"); + + let second = walk_static(false, 0x10, 0x10, &mut state); + assert_eq!( + second.first().map(|f| f.cck), + Some(0x11), + "run anchors at the raw $10 unit, not the hard start" + ); + assert_eq!(second.last().map(|f| f.cck), Some(0xDF), "RHW drain"); + assert_eq!(words_for_plane(&second, 0), 26); + assert!(!state.shw, "the completed run clears SHW again"); + + let third = walk_static(false, 0x10, 0x10, &mut state); + assert!(third.is_empty(), "alternating lines stay empty"); + + // A reachable DDFSTOP still ends the armed run at its position. + let stopped = walk_static(false, 0x10, 0xA0, &mut state); + assert_eq!(stopped.first().map(|f| f.cck), Some(0x11)); + assert_eq!(stopped.last().map(|f| f.cck), Some(0xA7)); + assert_eq!(words_for_plane(&stopped, 0), 19); + } + + #[test] + fn ecs_start_below_hard_window_latches_and_runs_every_line_from_shw() { + // Same registers on ECS: BPHSTART is a latch, so the $10 match arms + // it every line and the run starts at the hard window ($18) on + // every line (SHW is cleared at each line end on ECS). + let mut state = ready_state(lores4()); + let _ = walk_static(true, 0x60, 0xA0, &mut state); + + for line in 0..3 { + let fetches = walk_static(true, 0x10, 0xA0, &mut state); + assert_eq!( + fetches.first().map(|f| f.cck), + Some(0x19), + "line {line}: run starts at the hard window start" + ); + assert_eq!(fetches.last().map(|f| f.cck), Some(0xA7), "line {line}"); + } + } + #[test] fn mid_line_stop_rewrite_before_match_moves_the_stop() { // A DDFSTOP rewrite landing before the old value matches replaces diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index 9da556d..86e2e85 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -1183,14 +1183,18 @@ impl ControlState { // colour-clock origin, not the hard DDF start $18. let align = |hpos: i32| -> i32 { let gulp = self.fetch_period() as i32; - let aligned = if self.fetch_quantum() == 1 { + if self.fetch_quantum() == 1 { + // FMODE=0 placement rounds UP to the gulp grid and stays + // linear below the hardwired start window: a run armed from + // a DDFSTRT below $18 (surviving SHW latch) places its + // picture at the raw grid position, left of the standard + // slots (vAmigaTS Agnus/DDF oldhwstop3/4 A500 photos). hpos.div_euclid(gulp) * gulp + if hpos.rem_euclid(gulp) != 0 { gulp } else { 0 } } else { - hpos.div_euclid(gulp) * gulp - }; - // Clamped to the DDF hard start: placement before the first usable - // fetch position is not visible. - aligned.max(BITPLANE_DDF_HARD_START as i32) + // Wide-FMODE gulps clamp to the DDF hard start: placement + // before the first usable fetch position is not visible. + (hpos.div_euclid(gulp) * gulp).max(BITPLANE_DDF_HARD_START as i32) + } }; let ddf_native_shift = (align(effective_ddf_start_hpos( self.agnus_revision, @@ -2413,13 +2417,22 @@ fn line_control_at_x( /// Rewrite a row's DDFSTRT/DDFSTOP so the register-derived fetch geometry /// matches the captured sequencer run (origin colour clock + word count). -/// FMODE=0 only; runs that wrap through horizontal blanking (origin before -/// the hardware start window) keep the register view. +/// FMODE=0 only. A run origin below the hardware start window ($18) is kept +/// as-is: a DDFSTRT comparator match below $18 starts the run at its raw +/// position when the sequencer's SHW latch survived from the previous line, +/// and the fetched picture sits linearly left of the standard grid +/// (hardware-verified by the vAmigaTS Agnus/DDF oldhwstop3/4 A500 photos). fn apply_captured_fetch_geometry(control: &mut ControlState, origin: u16, words: usize) { if control.fetch_quantum() != 1 || words == 0 { return; } - if origin < BITPLANE_DDF_HARD_START { + // DDFSTRT 0 doubles as the "no window programmed" sentinel throughout + // the register-derived paths, so a run genuinely armed at colour clock + // 0 (DDFSTRT=$00 with a surviving SHW latch) cannot be expressed as a + // synthesized window; keep the register view. TODO: replace the zero + // sentinel with an explicit window-valid flag so origin-0 runs can be + // placed exactly. + if origin == 0 { return; } let words_per_unit = (8 / control.fetch_cck_per_word() as usize).max(1); @@ -3477,10 +3490,20 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { // row's DDFSTRT/DDFSTOP from them so every register-derived fetch and // paint derivation (word plans, words-per-row, picture origin) agrees // with what the DMA actually did. - for (y, control) in base_controls.iter_mut().enumerate() { + for y in 0..base_controls.len() { if let Some(row) = captured_bitplane_rows.get(y).and_then(Option::as_ref) { if let Some(origin) = row.fetch_origin_cck { - apply_captured_fetch_geometry(control, origin, row.words_per_row); + apply_captured_fetch_geometry(&mut base_controls[y], origin, row.words_per_row); + // The row's control segments (mid-row register writes) must + // agree as well: the sequencer already folded those writes + // into the captured run, and a segment still carrying the + // raw DDFSTRT/DDFSTOP values would re-derive a different + // word count or picture origin for the same row (the + // oldhwstop band-entry rows, where the copper rewrite lands + // at the start of the next line's segment list). + for segment in &mut control_segments[y] { + apply_captured_fetch_geometry(&mut segment.control, origin, row.words_per_row); + } } } } @@ -3646,7 +3669,18 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { if !line_has_valid_ddf_window(control, row_control_segments) { continue; } - let words_per_row = line_words_per_row(base_controls[y], row_control_segments); + // A captured sequencer run with a comparator-anchored origin is + // the authority for the row's word count: mid-row DDF rewrites + // logged as control segments describe windows the sequencer + // never ran (the oldhwstop band-entry rows), and the register- + // derived maximum would otherwise disagree with the captured + // planes and push the row onto the fallback re-fetch path. + let words_per_row = captured_bitplane_rows + .get(y) + .and_then(Option::as_ref) + .filter(|row| row.fetch_origin_cck.is_some() && row.words_per_row != 0) + .map(|row| row.words_per_row) + .unwrap_or_else(|| line_words_per_row(base_controls[y], row_control_segments)); let beam_y = visible_line0 as u32 + y as u32; replay_bitplane_pointer_events_through_beam( render_events, @@ -3679,6 +3713,25 @@ pub fn render_from_input(input: &RenderInput, fb: &mut [u32]) -> RenderResult { if captured_row.is_none() && !control.bitplane_dma_enabled() { 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, + // so such runs happen on alternating lines at most). The DMA + // capture records which lines actually ran; a line without a + // captured fetch fetched nothing, so the register-derived + // window must not synthesize a picture for it (vAmigaTS + // Agnus/DDF oldhwstop3/4: the black no-run lines between the + // early-origin runs). + if captured_row.is_none() + && control.fetch_quantum() == 1 + && (1..BITPLANE_DDF_HARD_START).contains(&effective_ddf_start_hpos_raw( + control.agnus_revision, + control.hires() || control.shres(), + control.ddfstrt, + )) + { + continue; + } // Denise's playfield output arms on BPL1DAT loads. A mode whose // fetch table carries no plane streams at all (overprogrammed // hi-res/SHRES BPU) never loads BPL1DAT, so nothing displays - @@ -4906,11 +4959,12 @@ fn effective_ddf_stop_hpos(revision: AgnusRevision, hires: bool, raw: u16) -> u1 fn effective_ddf_start_hpos(revision: AgnusRevision, hires: bool, raw: u16) -> u16 { let start = effective_ddf_start_hpos_raw(revision, hires, raw); - if start == 0 { - 0 - } else { - start.clamp(BITPLANE_DDF_HARD_START, BITPLANE_DDF_HARD_STOP) - } + // A DDFSTRT below the hardwired start window ($18) is NOT clamped: the + // comparator match position anchors the fetch grid at its raw value + // whenever the sequencer arms a run at all (the SHW latch can survive + // from the previous line), so word counts and picture placement stay + // linear in the raw register (vAmigaTS Agnus/DDF oldhwstop3/4 photos). + start.min(BITPLANE_DDF_HARD_STOP) } fn effective_ddf_window( @@ -4920,7 +4974,9 @@ fn effective_ddf_window( ddfstop: u16, harddis: bool, ) -> Option<(u16, u16)> { - let (hard_start, hard_stop) = ddf_hard_bounds(harddis); + // The start is not floored to the hardwired window: a DDFSTRT below $18 + // keeps its raw fetch-grid position (see `effective_ddf_start_hpos`). + let (_, hard_stop) = ddf_hard_bounds(harddis); let start = effective_ddf_start_hpos_raw(revision, hires, ddfstrt); let mut stop = effective_ddf_stop_hpos(revision, hires, ddfstop); if start == 0 || start > hard_stop { @@ -4929,7 +4985,6 @@ fn effective_ddf_window( if matches!(revision, AgnusRevision::Ocs) && stop == start { stop = hard_stop; } - let start = start.max(hard_start); let stop = stop.min(hard_stop); (stop >= start).then_some((start, stop)) } diff --git a/src/video/bitplane/tests.rs b/src/video/bitplane/tests.rs index f299f8d..2bbd98a 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -1636,12 +1636,16 @@ fn ddf_overscan_fetches_still_advance_bitplane_pointers() { assert_eq!(state.words_per_row(true, 640), 42); assert_eq!(state.words_per_row(false, 320), 21); - let hard_clipped = RenderState { + // A DDFSTRT below the hardwired start keeps its raw fetch-grid anchor + // (an armed run really starts at $10; vAmigaTS oldhwstop3/4), so the + // word count stays linear in the raw value; only the stop is clipped + // to the hardware stop window. + let early_start = RenderState { ddfstrt: 0x0010, ddfstop: 0x00E0, ..blank_state() }; - assert_eq!(hard_clipped.words_per_row(false, 320), 25); + assert_eq!(early_start.words_per_row(false, 320), 26); let ocs_equal = RenderState { ddfstrt: 0x0038, @@ -6795,3 +6799,28 @@ fn unchanged_control_writes_do_not_create_render_segments() { assert_eq!(control_segments[line][0].x, 32); assert_eq!(control_segments[line][0].control.bplcon1, 0x0022); } + +#[test] +fn fetch_origin_below_hard_start_stays_linear_in_ddfstrt() { + // A run armed from a DDFSTRT below the hardwired start window ($18) + // anchors its fetch grid at the raw comparator position (vAmigaTS + // Agnus/DDF oldhwstop3/4 A500 photos: the DDFSTRT=$10 rows sit exactly + // ($38-$10)*2 lo-res pixels left of the standard picture, with the + // early words running through the left border). The placement shift + // must stay linear below $18 instead of clamping to the hard start. + let mk = |strt: u16| RenderState { + ddfstrt: strt, + ddfstop: 0x00D0, + diwstrt: 0x2C81, + diwstop: 0x2CC1, + ..blank_state() + }; + assert_eq!(mk(0x38).fetch_origin_native_shift(false, 2), 0); + // Early-DDF placement is linear (diw1-calibrated) ... + assert_eq!(mk(0x30).fetch_origin_native_shift(false, 2), 16); + // ... and keeps the same line below the hard start: 5 words of the + // $10-anchored row lie left of the standard window edge. + assert_eq!(mk(0x10).fetch_origin_native_shift(false, 2), 80); + assert_eq!(mk(0x10).native_x_offset(false, 2), 80); + assert_eq!(mk(0x10).fetch_start_native_x(false, 2), 0); +}