diff --git a/docs/internals/timing.md b/docs/internals/timing.md index 2b0e2da..6550cd7 100644 --- a/docs/internals/timing.md +++ b/docs/internals/timing.md @@ -92,12 +92,17 @@ save state). ### CPU vs blitter DMACON's BLTPRI ("blitter nasty") is modelled as on hardware: with BLTPRI -set, BBUSY holds the BLS line asserted for the whole blit, so the CPU is -denied every chip-bus cycle until BLTDONE -- including the pipeline's -bus-free micro-cycles (the Copper and fixed DMA still use them). Without -it, the blitter yields a slot after the CPU has missed three consecutive -slots, matching the Minimig RTL. The counter and the back-pressure rule -are detailed under [](#cpu-contention) below. +set the blitter wins every slot it requests, and the request line stays +asserted through the blit's warm-up -- the startup ladder and, for +D-writing blits, the first word's cycles including the empty first-D +bubble -- so the CPU is fenced out of those holes too (the Copper and +fixed DMA still use them). Once the pipeline is primed, genuinely bus-free +micro-cycles (line-mode Bresenham cycles, fill's idle cycle, +disabled-channel gaps) release the request and stay CPU-available even +under BLTPRI, matching FS-UAE/vAmiga per-slot arbitration. Without +BLTPRI, the blitter yields a slot after the CPU has missed three +consecutive slots, matching the Minimig RTL. The counter and the +back-pressure rule are detailed under [](#cpu-contention) below. ### Sprite DMA control rewrites @@ -445,16 +450,21 @@ blitter yield earlier. After `BLITTER_SLOWDOWN_CPU_MISS_LIMIT` (3) consecutive pressure misses the blitter yields one slot, matching the HRM "one bus cycle in four" rule while also matching UAE's `blitter_nasty` distinction between normal idle cycles and fill/line BUSIDLE. Idle blit -pipeline cycles never claim the bus and stay CPU-available while BLTPRI is -clear; with BLTPRI set the asserted BLS line fences the CPU out of them -too, for the entire blit. Software depends on that fence: MFM-decode -trackloaders (e.g. Jim Power's) save the word below a decode blit's -destination, write BLTSIZE, and restore it two instructions later, relying -on the restore write being held past the blit's first D write -- granting -the CPU the first-D bubble let the blitter overwrite the restored word and -corrupted the last data word of every decoded sector. The counter resets -when the CPU gets the bus, when BLTPRI is set, or when blitter DMA cannot -run. +pipeline cycles never claim the bus and stay CPU-available -- with one +exception: with BLTPRI set, the blit's warm-up holes (the startup ladder +and, for D-writing blits, the first word's cycles including the empty +first-D bubble) fence the CPU, because the sequencer's bus request is held +asserted by its queued back-to-back first fetches. Software depends on the +warm-up fence: MFM-decode trackloaders (e.g. Jim Power's) save the word +below a decode blit's destination, write BLTSIZE, and restore it two +instructions later; granting the CPU the startup and first-D holes let the +restore (and its prefetches) land mid-blit and the blitter overwrite the +restored word, corrupting the last data word of every decoded sector. Once +the pipeline is primed, bus-free micro-cycles are CPU-available even under +BLTPRI -- line-heavy demo main loops (Rampage's vector parts) depend on +that CPU time, and a whole-blit fence overshoots the FS-UAE/vAmiga +references on timing-test row 26 by ~+70 cck. The counter resets when the +CPU gets the bus, when BLTPRI is set, or when blitter DMA cannot run. A 68000 `TAS` read-modify-write is unsafe on chip RAM (the HRM warns against it); the `m68k` backend exposes it as a byte read then a byte diff --git a/src/bus.rs b/src/bus.rs index 395dfb6..7e9bba0 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -230,6 +230,8 @@ const DMACON_SPREN: u16 = 1 << 5; const DMACON_BLTEN: u16 = 1 << 6; const DMACON_BPLEN: u16 = 1 << 8; const DMACON_BLTPRI: u16 = 1 << 10; +#[cfg(test)] +const BLTCON0_USE_A: u16 = 1 << 11; const BLTCON0_USE_C: u16 = 1 << 9; const BLTCON0_USE_D: u16 = 1 << 8; const BLTCON1_LINE: u16 = 1 << 0; diff --git a/src/bus/dma_slots.rs b/src/bus/dma_slots.rs index e70ddf9..ad9fba7 100644 --- a/src/bus/dma_slots.rs +++ b/src/bus/dma_slots.rs @@ -425,16 +425,26 @@ impl Bus { return ChipBusOwner::Copper; } if self.blitter.busy && self.blitter_dma_enabled() { - // With BLTPRI set, BBUSY holds the BLS line asserted for the whole - // blit, so the CPU is denied every chip-bus cycle until BLTDONE -- - // including the pipeline's bus-free micro-cycles below. Regression + // With BLTPRI set, BLS fences the CPU (not the Copper or fixed + // DMA) during the blit's warm-up: the startup ladder and, for + // D-writing blits, the first word's cycles including the empty + // first-D bubble, while the sequencer's bus request is held + // asserted by the queued back-to-back first fetches. Regression // example: the Jim Power trackloader saves the word below a // descending MFM-decode blit's destination, writes BLTSIZE, and - // restores the word two instructions later, relying on the nasty - // lockout to hold that CPU write until after the blit's final - // D write; letting it into the first-D bubble corrupts the last - // word of every decoded sector. - if for_cpu && self.agnus.dmacon & DMACON_BLTPRI != 0 { + // restores the word two instructions later; the fence keeps the + // restore's prefetches out of the startup holes so the CPU write + // lands only after the blit completes. Once the pipeline is + // primed the request line drops on genuine bus-free micro-cycles + // (line-mode Bresenham, fill idle, disabled-channel gaps) and + // the CPU uses them even under BLTPRI -- line-heavy demo loops + // (Rampage's vector parts) rely on that CPU time, and FS-UAE and + // vAmiga agree (timing-test row 26: 25095/25097; a whole-blit + // fence overshoots to 25161). + if for_cpu + && self.agnus.dmacon & DMACON_BLTPRI != 0 + && self.blitter.bltpri_warmup_fences_cpu() + { return ChipBusOwner::Blitter; } // Idle blit pipeline cycles (the "-" slots in the HRM cycle diagrams, diff --git a/src/bus/tests.rs b/src/bus/tests.rs index df5e687..5a3e939 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -13,8 +13,8 @@ use super::{ BeamRegisterWrite, BeamWriteSource, Bus, CapturedBitplaneRow, CapturedSpriteLine, ChipBusOwner, CpuBusAccessKind, DeviceClock, DisplaySpriteDmaState, DisplaySpriteLineData, FrameBusTrace, LiveCollisionControl, LiveCollisionLineReplay, LiveSpriteCollisionSource, - RenderRegisterSnapshot, BLITTER_SLOWDOWN_CPU_MISS_LIMIT, BLTCON0_USE_C, BLTCON0_USE_D, - BLTCON1_DOFF, BLTCON1_LINE, BPLCON0_ECSENA, BPLCON3_BRDRBLNK, BPLCON3_BRDSPRT, + RenderRegisterSnapshot, BLITTER_SLOWDOWN_CPU_MISS_LIMIT, BLTCON0_USE_A, BLTCON0_USE_C, + BLTCON0_USE_D, BLTCON1_DOFF, BLTCON1_LINE, BPLCON0_ECSENA, BPLCON3_BRDRBLNK, BPLCON3_BRDSPRT, BPLCON3_SPRES_HIRES, DENISE_HPOS_LAG_CCK, DMACON_BLTEN, DMACON_BLTPRI, DMACON_BPLEN, DMACON_SPREN, PAL_SPRITE_DMA_FIRST_ACTIVE_VPOS, RENDER_COPPER_WAIT_HPOS_FB0, RENDER_DIW_HSTART_FB0, RENDER_MIN_OVERSCAN_START_VPOS, RENDER_VISIBLE_LINES, @@ -8900,17 +8900,19 @@ fn bltpri_stalls_cpu_chip_access_through_blitter_access_cycles() { bus.grant_cpu_bus_access(2, CpuBusAccessKind::Read); - // With BLTPRI set BBUSY holds the BLS line asserted for the entire - // blit, so the CPU is denied even the idle D-bubble cycle after the A - // fetch (the bubble is bus-free, but BLS fences the CPU, not the bus). - // The CPU waits through the A fetch, the D bubble, the D write, and - // the terminal cycles, then spends its granted slot plus the bus-free - // tail: 6 color clocks. Software depends on this fence: MFM-decode - // trackloaders (e.g. Jim Power's) restore the word below a decode - // blit's destination right after BLTSIZE, relying on that CPU write - // landing only after the blit's first D write. + // With BLTPRI set, BLS fences the CPU through the D pipeline's warm-up: + // the idle first-D bubble after the A fetch is bus-free, but the + // sequencer's bus request is still asserted (its first fetches are + // queued back-to-back), so the CPU is denied it. The first cycle the + // CPU wins is the internal E flush, where BBUSY has already dropped; + // its granted slot and bus-free tail ride E/F while the blitter's + // terminal F cycle writes the queued word: 4 color clocks in all. + // Software depends on the closed bubble: MFM-decode trackloaders + // (e.g. Jim Power's) restore the word below a decode blit's destination + // right after BLTSIZE, relying on the startup and first-D holes staying + // shut so the restore's prefetches queue behind the blit instead. let (cck, _) = bus.take_slice_bus_advance(); - assert_eq!(cck, 6); + assert_eq!(cck, 4); assert!(!bus.blitter.busy); assert_eq!(&bus.mem.chip_ram[0x20..0x22], &[0x12, 0x34]); assert_ne!(bus.paula.intreq & INT_BLIT, 0); @@ -8950,17 +8952,75 @@ fn blithog_set_blocks_cpu_slowdown_back_pressure_until_blitter_finishes() { bus.grant_cpu_bus_access(2, CpuBusAccessKind::Read); - // With BLTPRI set the CPU gets no starvation yield and BLS stays - // asserted until BBUSY drops: it waits through all twelve A/B/C - // accesses of the four words and the terminal E/F cycles, then spends - // its granted slot plus the bus-free tail cck, costing 16 color - // clocks. + // With BLTPRI set the CPU gets no starvation yield: it waits through + // all twelve A/B/C accesses of the four words (a source-only blit has + // no bus-free micro-cycles to release the request line), then spends + // its granted slot plus the bus-free tail cck, costing 14 color + // clocks. The terminal (internal) E/F cycles ride the CPU's + // granted/tail clocks -- BBUSY has already dropped -- so the engine + // finishes within the same span and the interrupt is asserted off the + // terminal cycle. let (cck, _) = bus.take_slice_bus_advance(); - assert_eq!(cck, 16); + assert_eq!(cck, 14); assert!(!bus.blitter.busy); assert_ne!(bus.paula.intreq & INT_BLIT, 0); } +#[test] +fn bltpri_line_blit_bresenham_cycles_stay_cpu_available_after_warmup() { + let mut bus = empty_bus(); + bus.agnus.dmacon = DMACON_DMAEN | DMACON_BLTEN | DMACON_BLTPRI; + bus.agnus.hpos = 0x20; + // Canonical 4-pixel line draw: USEA|USEC|USED, octant 0. The per-pixel + // cadence is [L1 -, L2 C-fetch, L3 -, L4 D-write]: two bus accesses and + // two bus-free Bresenham cycles. + bus.blitter.bltcon0 = BLTCON0_USE_A | BLTCON0_USE_C | BLTCON0_USE_D | 0x00CA; + bus.blitter.bltcon1 = BLTCON1_LINE; + bus.blitter.bltafwm = 0xFFFF; + bus.blitter.bltalwm = 0xFFFF; + bus.blitter.bltapt = 0; + bus.blitter.bltcpt = 0x1000; + bus.blitter.bltdpt = 0x1000; + bus.blitter.start_scheduled((4 << 6) | 2, &bus.mem.chip_ram); + + // The startup ladder holds the BLS fence: with BLTPRI set the CPU is + // denied the register-commit/BLT_STRT/Init cycles even though they do + // not access the bus. + let mut warmup_cycles = 0; + while bus.blitter.bltpri_warmup_fences_cpu() { + assert_eq!( + bus.scheduled_dma_owner(true), + ChipBusOwner::Blitter, + "warm-up cycle must fence the CPU under BLTPRI" + ); + bus.advance_chipset(1); + warmup_cycles += 1; + assert!(warmup_cycles < 8, "line warm-up should end with the ladder"); + } + + // Body: line mode's internal Bresenham cycles (L1/L3) release the bus + // request, so the CPU may use them even under BLTPRI -- line-heavy demo + // main loops (Rampage's vector parts) rely on that CPU time. Bus cycles + // (C fetch, D write) still deny the CPU. + let mut cpu_available = 0; + let mut guard = 0; + while bus.blitter.busy { + let owner = bus.scheduled_dma_owner(true); + if bus.blitter.current_slot_needs_bus() { + assert_eq!(owner, ChipBusOwner::Blitter); + } else if owner == ChipBusOwner::Idle { + cpu_available += 1; + } + bus.advance_chipset(1); + guard += 1; + assert!(guard < 64, "4-pixel line must finish within 64 cck"); + } + assert!( + cpu_available >= 8, + "expected at least 2 CPU-available cycles per pixel, got {cpu_available}" + ); +} + #[test] fn front_panel_power_led_follows_cia_a_led_bit() { let mut bus = empty_bus(); diff --git a/src/chipset/blitter.rs b/src/chipset/blitter.rs index 3f18b76..5f3ae38 100644 --- a/src/chipset/blitter.rs +++ b/src/chipset/blitter.rs @@ -743,6 +743,32 @@ impl Blitter { } } + /// Whether the pending pipeline cycle falls inside the blit's warm-up + /// window, during which BLTPRI's BLS assertion fences the CPU off the + /// chip bus even though the cycle itself is bus-free. From the BLTSIZE + /// poke until the D pipeline is primed (the first D slot has passed) the + /// sequencer's bus request stays asserted: its first fetches are queued + /// back-to-back, so the startup ladder and the first-word bubble never + /// release the request line. MFM-decode trackloaders (e.g. Jim Power's) + /// depend on this: they restore a saved word below a decode blit's + /// destination right after writing BLTSIZE, relying on the nasty lockout + /// to keep that CPU write (and the instruction prefetches before it) out + /// of the startup and first-D holes. Once the pipeline is primed the + /// request drops on genuine bus-free micro-cycles -- disabled-channel + /// gaps, fill's idle cycle, line-mode Bresenham cycles -- and the CPU + /// may use them even under BLTPRI, which line-drawing main loops rely + /// on for CPU time (2 of the 4 line cycles per pixel are bus-free). + pub fn bltpri_warmup_fences_cpu(&self) -> bool { + if !self.busy { + return false; + } + match self.pending.as_ref() { + Some(PendingBlit::Normal(state)) => state.bltpri_warmup_fences_cpu(), + Some(PendingBlit::Line(state)) => state.bltpri_warmup_fences_cpu(), + None => false, + } + } + /// Arbitration class of the pipeline cycle the next `tick_scheduled_slot` /// will process (see BlitSlotClass). `Internal` when no blit is pending /// so callers need no extra guard. @@ -1277,6 +1303,15 @@ impl LineBlitState { } } + /// Line-mode warm-up window for the BLTPRI CPU fence (see + /// `Blitter::bltpri_warmup_fences_cpu`): only the startup ladder. Line + /// mode has no D pipeline bubble -- the first D write is a real access -- + /// so once the per-pixel cadence starts, the bus-free Bresenham cycles + /// (L1/L3) release the request line and stay CPU-available. + fn bltpri_warmup_fences_cpu(&self) -> bool { + matches!(self.phase, LineBlitPhase::StartDelay | LineBlitPhase::Init) + } + /// Access pattern of the next `limit` scheduled slots (bit k = slot k /// consumes a blitter-eligible colour clock: a bus access or a bus-free /// micro-cycle; clear = internal): the line startup ladder (register @@ -1645,6 +1680,25 @@ impl NormalBlitState { } } + /// Warm-up window for the BLTPRI CPU fence (see + /// `Blitter::bltpri_warmup_fences_cpu`): the startup ladder plus, for + /// D-writing blits, the body cycles until the first D slot has primed + /// the hold register. That covers the first-word pipeline bubble; from + /// the second word on, bus-free micro-cycles release the request line. + /// The terminal E/F cycles are past the fence: BBUSY has already + /// dropped at the last body cycle. + fn bltpri_warmup_fences_cpu(&self) -> bool { + match self.phase { + NormalBlitPhase::StartDelay | NormalBlitPhase::Init => true, + NormalBlitPhase::A + | NormalBlitPhase::B + | NormalBlitPhase::C + | NormalBlitPhase::D + | NormalBlitPhase::FillIdle => self.use_d && !self.pipeline_full, + NormalBlitPhase::E | NormalBlitPhase::F | NormalBlitPhase::Done => false, + } + } + /// A D slot claims the bus only when a destination word is queued in /// the hold register. The first word's D slot is always the HRM "-" /// pipeline bubble -- including in D-only blits: real hardware writes diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index 2c081a5..a965bd6 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -1028,6 +1028,73 @@ impl ControlState { } } + /// One-gulp reload advance (in native px) for a playfield whose BPLCON1 + /// scroll covers the fetch's off-grid phase. + /// + /// The row's placement (`fetch_origin_native_shift`) quantizes an + /// FMODE=0 fetch that starts off the shifter reload grid UP to the next + /// grid slot: the data arrives too late for its own slot and waits. But + /// the playfield's BPLCON1 delay taps the shifter S pixels late, so when + /// the scroll covers the phase lateness (lateness <= S) the data DOES + /// catch its own (floor) slot and the picture sits one full gulp left of + /// the rounded-up origin. vAmiga-verified with the ddfprobe-phase/-phase2 + /// probes (lo-res, marker per (DDFSTRT, BPLCON1) band): DDFSTRT $66 with + /// scroll 15 sits exactly 1 lo-res px left of $68 with scroll 0, while + /// $66 with scroll 0 sits at the $68 slot, and on-grid starts never move + /// with scroll beyond the scroll itself. Regression example: Rampage's + /// dot-cube part pans by walking DDFSTRT $66->$68 against a BPLCON1 wrap + /// $FF->$00; without the covered-phase advance the pan jumps 16 px a few + /// times a second. Scroll 0 (every previously calibrated case) is + /// unchanged: the advance only triggers when scroll covers the phase. + fn reload_advance_for_scroll(&self, scroll: usize) -> i32 { + if self.fetch_quantum() != 1 { + return 0; + } + let gulp = self.fetch_period() as i32; + let start = effective_ddf_start_hpos( + self.agnus_revision, + self.hires() || self.shres(), + self.ddfstrt, + ) as i32; + let phase = start.rem_euclid(gulp); + if phase == 0 { + return 0; + } + let native_per_cck = if self.shres() { + 8 + } else if self.hires() { + 4 + } else { + 2 + }; + let lateness = phase * native_per_cck; + if lateness <= scroll as i32 { + gulp * native_per_cck + } else { + 0 + } + } + + /// The row's reload advance: the largest advance over the playfields the + /// plane count uses. `fetch_origin_native_shift` extends the fetch span + /// this far left; `sample_delay_for_plane` rebases each plane against it. + fn row_reload_advance(&self) -> i32 { + let mut advance = self.reload_advance_for_scroll(self.pf1_scroll()); + if self.nplanes() >= 2 { + advance = advance.max(self.reload_advance_for_scroll(self.pf2_scroll())); + } + advance + } + + /// Per-plane sample delay against the advance-extended row origin: the + /// BPLCON1 scroll, plus the row advance not consumed by this plane's own + /// reload advance. A plane that catches the floor reload slot keeps + /// `scroll`; a plane that waits for the next slot is one gulp later. + fn sample_delay_for_plane(&self, plane: usize) -> i32 { + let scroll = self.scroll_for_plane(plane); + scroll as i32 + self.row_reload_advance() - self.reload_advance_for_scroll(scroll) + } + fn playfield_priority_code(&self, playfield: u8) -> u8 { if playfield == 1 { (self.bplcon2 & 0x0007) as u8 @@ -1275,7 +1342,13 @@ impl ControlState { if (self.hires() || self.shres()) && self.fetch_quantum() == 1 && ddf_native_shift < 0 { origin_shift = origin_shift.max(-ddf_native_shift); } - origin_shift + // A playfield whose BPLCON1 scroll covers an off-grid fetch phase + // catches the floor reload slot instead of the rounded-up one (see + // `reload_advance_for_scroll`): extend the fetch span left by the + // row's advance so those planes' earlier samples exist in the plan; + // `sample_delay_for_plane` rebases every plane against this origin, + // leaving uncovered planes (and every scroll-0 row) in place. + origin_shift + self.row_reload_advance() } } @@ -1329,7 +1402,7 @@ impl<'a> DenisePlannedPlayfieldLine<'a> { #[cfg(test)] fn sample(&self, control: ControlState, native_x: usize) -> DeniseBitplaneSample { let nplanes = control.nplanes().min(self.plane_words.len()); - let delays = std::array::from_fn(|plane| control.scroll_for_plane(plane)); + let delays = std::array::from_fn(|plane| control.sample_delay_for_plane(plane)); self.sample_prepared(nplanes, &delays, 0, native_x) } @@ -1351,7 +1424,7 @@ impl<'a> DenisePlannedPlayfieldLine<'a> { fn sample_prepared( &self, nplanes: usize, - delays: &[usize; 8], + delays: &[i32; 8], min_fetch_x: usize, native_x: usize, ) -> DeniseBitplaneSample { @@ -1361,7 +1434,7 @@ impl<'a> DenisePlannedPlayfieldLine<'a> { fn sample_prepared_with_final_fetch_hold( &self, nplanes: usize, - delays: &[usize; 8], + delays: &[i32; 8], min_fetch_x: usize, native_x: usize, hold_final_fetch_sample: bool, @@ -1369,12 +1442,15 @@ impl<'a> DenisePlannedPlayfieldLine<'a> { let mut idx = 0u8; let mut active = false; for (plane, words) in self.plane_words.iter().enumerate().take(nplanes) { + // A negative delay is the covered-phase reload advance (see + // `sample_delay_for_plane`): the plane's data caught the floor + // reload slot, one gulp left of the row's rounded-up origin. let delay = delays[plane]; - if native_x < delay { + if (native_x as i32) < delay { active = true; continue; } - let fetch_x = native_x - delay; + let fetch_x = (native_x as i32 - delay) as usize; if fetch_x < min_fetch_x { active = true; continue; @@ -4311,7 +4387,7 @@ fn render_planned_playfield_line( 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()); - let delays = std::array::from_fn(|plane| sample_control.scroll_for_plane(plane)); + let delays = std::array::from_fn(|plane| sample_control.sample_delay_for_plane(plane)); let hold_final_fetch_sample = pixel_control.holds_final_lowres_fetch_sample_at_diwstop(); let ham_mode = sample_control.hold_and_modify(); let ham_history_start_native_x = if ham_mode { diff --git a/src/video/bitplane/tests.rs b/src/video/bitplane/tests.rs index 97dbf2f..f852c06 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -1337,7 +1337,7 @@ fn late_lowres_ddf_reaches_diwstop_with_undelayed_planes_active() { plane2[16] = 0x0001; let planes = vec![plane1, plane2]; let plan = DenisePlannedPlayfieldLine::new(0, 94, 702, &planes, 17 * 16); - let delays = std::array::from_fn(|plane| control.scroll_for_plane(plane)); + let delays = std::array::from_fn(|plane| control.sample_delay_for_plane(plane)); let sample = plan.sample_prepared_with_final_fetch_hold( control.nplanes(), &delays, @@ -1551,7 +1551,7 @@ fn bplcon1_delay_drops_prefetch_samples_at_block_start() { }; let plane_words = [vec![0xE000]]; let line = DenisePlannedPlayfieldLine::new(0, 0, 32, &plane_words, 16); - let delays = std::array::from_fn(|plane| control.scroll_for_plane(plane)); + let delays = std::array::from_fn(|plane| control.sample_delay_for_plane(plane)); assert_eq!(line.sample_prepared(1, &delays, 0, 3).idx, 1); assert_eq!(line.sample_prepared(1, &delays, 2, 3).idx, 0); @@ -4823,6 +4823,56 @@ fn bplcon1_exposes_separate_playfield_scroll_nibbles() { assert_eq!(control.scroll_for_plane(1), 10); } +#[test] +fn covered_scroll_catches_floor_reload_slot_for_off_grid_ddfstrt() { + // Lo-res FMODE=0 fetch with DDFSTRT $66: 6 cck past the 8-cck reload + // grid, i.e. the data is 12 native px late for its own slot. With + // scroll 0 it waits for the next slot (round-up placement, no advance); + // a BPLCON1 delay that covers the lateness (S >= 12) catches the floor + // slot one full gulp (16 native px) earlier. vAmiga-verified with the + // ddfprobe-phase/-phase2 probes; regression example: Rampage's dot-cube + // pan (DDFSTRT $66->$68 against a scroll wrap $FF->$00) jumps 16 px + // without the advance. + // ECS Agnus: the DDFSTRT comparator has 2-cck resolution, so $66 keeps + // its phase-6 position (OCS masks to 4-cck, making the lateness 8 px). + let control = |bplcon1: u16| ControlState { + agnus_revision: AgnusRevision::Ecs8372Rev4, + bplcon0: 0x2200, // 2 planes, lo-res + ddfstrt: 0x0066, + ddfstop: 0x00C8, + bplcon1, + ..ControlState::default() + }; + + // Scroll 0: no advance, delays are the plain scroll values. + assert_eq!(control(0x0000).row_reload_advance(), 0); + assert_eq!(control(0x0000).sample_delay_for_plane(0), 0); + // Scroll 15 on both playfields covers the 12 px lateness. + assert_eq!(control(0x00FF).row_reload_advance(), 16); + assert_eq!(control(0x00FF).sample_delay_for_plane(0), 15); + assert_eq!(control(0x00FF).sample_delay_for_plane(1), 15); + // Scroll 11 does not cover 12 px: round-up placement stands. + assert_eq!(control(0x00BB).row_reload_advance(), 0); + assert_eq!(control(0x00BB).sample_delay_for_plane(0), 11); + // Split scrolls: PF2 (odd planes) covered, PF1 not. The row origin + // extends by PF2's advance; PF1 planes rebase one gulp later so their + // placement is unchanged. + assert_eq!(control(0x00F0).row_reload_advance(), 16); + assert_eq!(control(0x00F0).sample_delay_for_plane(0), 16); + assert_eq!(control(0x00F0).sample_delay_for_plane(1), 15); + // On-grid DDFSTRT never advances, whatever the scroll. + let on_grid = ControlState { + agnus_revision: AgnusRevision::Ecs8372Rev4, + bplcon0: 0x2200, + ddfstrt: 0x0068, + ddfstop: 0x00C8, + bplcon1: 0x00FF, + ..ControlState::default() + }; + assert_eq!(on_grid.row_reload_advance(), 0); + assert_eq!(on_grid.sample_delay_for_plane(0), 15); +} + #[test] fn aga_bplcon1_decodes_expanded_scroll_fields() { let control = ControlState { diff --git a/timing-test/README.md b/timing-test/README.md index ac697b4..3045159 100644 --- a/timing-test/README.md +++ b/timing-test/README.md @@ -143,15 +143,23 @@ rows again: Copperline now reads `10002 / 18362 / 346 / 25091` few colour clocks of the FS-UAE/real references; row 25 matches the vAmiga line-mode reference and is within a couple of colour clocks of FS-UAE. -The 2026-07-11 BLTPRI fence fix (with BLTPRI set, BLS locks the CPU off the -chip bus for the whole blit, bus-free micro-cycles included) moved row 26 to -25161 (`0x6249`, +66 vs the FS-UAE 25095 / vAmiga 25097 references, from -22 -before). The old proximity was a cancellation: the polling CPU used to sneak -DMACONR reads into the blit's idle bubbles and observed completion early, -masking blit-under-display-contention drift that is now visible. The fence -itself is hardware-proven (MFM-decode trackloaders rely on it -- see -docs/internals/timing.md); the residual row-26 drift is a separate -display-contention question. +The 2026-07-11 BLTPRI fence went through two iterations. A whole-blit BLS +fence (CPU locked off the chip bus until BLTDONE, bus-free micro-cycles +included) fixed MFM-decode trackloaders (Jim Power) but overshot: it moved +the row-26 golden from 25073 to 25161 (+66 vs the FS-UAE 25095 / vAmiga +25097 references) and starved line-heavy demo main loops of their +Bresenham-cycle CPU time (Rampage's vector parts dropped frames and the +music slowed). The fence was then narrowed to the blit's warm-up window -- +the startup ladder plus, for D-writing blits, the first word's cycles +including the empty first-D bubble -- which is all the trackloader ordering +needs; post-warm-up bus-free cycles are CPU-available again even under +BLTPRI, matching FS-UAE/vAmiga per-slot arbitration. The row-26 golden now +reads 25089 (`0x6201`, within 8 cck of both references; the polling CPU no +longer sneaks reads into the warm-up holes, which is where the old -22 +early-observation cancellation came from). Row 27 shifts by a few cck with +each of these changes -- its poll-catch phase rides on where row 26's blit +completes -- and stays within one poll-loop iteration. See +docs/internals/timing.md "CPU vs blitter" for the model. Row 31 found (and now guards) a second real bug (2026-07-07). On the plain 68000 Copperline matches vAmiga on every other row -- including the CPU-vs-6-bitplane diff --git a/timing-test/ddfprobe-phase.asm b/timing-test/ddfprobe-phase.asm new file mode 100644 index 0000000..921579e --- /dev/null +++ b/timing-test/ddfprobe-phase.asm @@ -0,0 +1,63 @@ +; DDFSTRT sub-unit phase placement probe (lo-res, FMODE=0). +; 15 bands of 8 lines walk DDFSTRT through $38-$42 and $60-$70 in steps of 2 +; while the copper re-anchors BPL1PT to the same marker bitmap on every line +; (BPLCON1=0, mods 0). Each band therefore displays identical data through an +; identical pipeline except for the DDFSTRT phase, so the marker bar's X per +; band IS the phase->placement map: it answers whether placement is linear in +; DDFSTRT (4 px per step), quantized to the fetch grid with round-down, or +; quantized with round-up, and where the quantum boundary sits. Regression +; example: Rampage's dot-cube part pans by walking DDFSTRT $66->$68 against a +; BPLCON1 wrap, so a wrong boundary shows as 16px jumps a few times a second. +; Marker: $FFFF $F000 (16px bar, then a 4px tick) at the start of the row. +CUST equ $dff000 +BMP equ $40000 +CLIST equ $60000 +NPHASE equ 15 + lea CUST,a6 + move.w #$7fff,$9a(a6) + move.w #$7fff,$9c(a6) + move.w #$7fff,$96(a6) + lea BMP,a0 + move.w #$ffff,(a0)+ + move.w #$f000,(a0)+ + move.w #62-1,d0 +.z: clr.w (a0)+ + dbra d0,.z + lea CLIST,a1 + move.l #$01001200,(a1)+ ; BPLCON0: 1 plane, lo-res + move.l #$01020000,(a1)+ ; BPLCON1 = 0 + move.l #$01080000,(a1)+ ; BPL1MOD = 0 + move.l #$010a0000,(a1)+ ; BPL2MOD = 0 + move.l #$01800008,(a1)+ ; COLOR00 dark blue + move.l #$01820fff,(a1)+ ; COLOR01 white + move.l #$009400c8,(a1)+ ; DDFSTOP $C8 + move.l #$008e2c81,(a1)+ ; DIWSTRT + move.l #$00902cc1,(a1)+ ; DIWSTOP + move.l #$00920070,(a1)+ ; DDFSTRT preview above the bands + move.l #$00e00004,(a1)+ ; BPL1PTH = $40000 + move.l #$00e20000,(a1)+ ; BPL1PTL + lea phases(pc),a2 + moveq #NPHASE-1,d2 + move.w #$3c00,d3 ; first band line $3C; WAIT hp = $07 +.band: move.w (a2)+,d4 + moveq #8-1,d5 +.line: move.w d3,d0 + or.w #$0007,d0 + move.w d0,(a1)+ ; WAIT (v,$07) + move.w #$fffe,(a1)+ + move.w #$0092,(a1)+ ; DDFSTRT = phase + move.w d4,(a1)+ + move.w #$00e0,(a1)+ ; BPL1PTH + move.w #$0004,(a1)+ + move.w #$00e2,(a1)+ ; BPL1PTL + move.w #$0000,(a1)+ + add.w #$0100,d3 + dbra d5,.line + dbra d2,.band + move.l #$fffffffe,(a1)+ + move.l #CLIST,$80(a6) + move.w d0,$88(a6) + move.w #$8380,$96(a6) ; DMAEN|BPLEN|COPEN +.l: bra.s .l + cnop 0,2 +phases: dc.w $38,$3a,$3c,$3e,$40,$42,$60,$62,$64,$66,$68,$6a,$6c,$6e,$70 diff --git a/timing-test/ddfprobe-phase.bin b/timing-test/ddfprobe-phase.bin new file mode 100644 index 0000000..07fcff0 Binary files /dev/null and b/timing-test/ddfprobe-phase.bin differ diff --git a/timing-test/ddfprobe-phase2.asm b/timing-test/ddfprobe-phase2.asm new file mode 100644 index 0000000..7818a30 --- /dev/null +++ b/timing-test/ddfprobe-phase2.asm @@ -0,0 +1,69 @@ +; DDFSTRT phase x BPLCON1 scroll placement probe (lo-res, FMODE=0). +; Bands of 8 lines set (DDFSTRT, BPLCON1) pairs while the copper re-anchors +; BPL1PT to the same marker bitmap every line. Bands 1/2 replicate the exact +; frame pair of Rampage's dot-cube pan (scroll wrap $FF->$00 with DDFSTRT +; $66->$68): on real hardware the pan is smooth, so their markers must sit +; 1 lo-res px apart. Bands 3/4 isolate the scroll term, bands 5-7 cover the +; arosddf1 photo case ($3C) with its on-grid neighbours. +; Marker: $FFFF $F000 at the start of the row. +CUST equ $dff000 +BMP equ $40000 +CLIST equ $60000 +NBAND equ 7 + lea CUST,a6 + move.w #$7fff,$9a(a6) + move.w #$7fff,$9c(a6) + move.w #$7fff,$96(a6) + lea BMP,a0 + move.w #$ffff,(a0)+ + move.w #$f000,(a0)+ + move.w #62-1,d0 +.z: clr.w (a0)+ + dbra d0,.z + lea CLIST,a1 + move.l #$01001200,(a1)+ ; BPLCON0: 1 plane, lo-res + move.l #$01020000,(a1)+ ; BPLCON1 = 0 + move.l #$01080000,(a1)+ ; BPL1MOD = 0 + move.l #$010a0000,(a1)+ ; BPL2MOD = 0 + move.l #$01800008,(a1)+ ; COLOR00 dark blue + move.l #$01820fff,(a1)+ ; COLOR01 white + move.l #$009400c8,(a1)+ ; DDFSTOP $C8 + move.l #$008e2c81,(a1)+ ; DIWSTRT + move.l #$00902cc1,(a1)+ ; DIWSTOP + move.l #$00920070,(a1)+ ; DDFSTRT preview above the bands + move.l #$00e00004,(a1)+ ; BPL1PTH = $40000 + move.l #$00e20000,(a1)+ ; BPL1PTL + lea pairs(pc),a2 + moveq #NBAND-1,d2 + move.w #$3c00,d3 ; first band line $3C; WAIT hp = $07 +.band: move.w (a2)+,d4 ; DDFSTRT + move.w (a2)+,d6 ; BPLCON1 + moveq #8-1,d5 +.line: move.w d3,d0 + or.w #$0007,d0 + move.w d0,(a1)+ ; WAIT (v,$07) + move.w #$fffe,(a1)+ + move.w #$0092,(a1)+ ; DDFSTRT + move.w d4,(a1)+ + move.w #$0102,(a1)+ ; BPLCON1 + move.w d6,(a1)+ + move.w #$00e0,(a1)+ ; BPL1PTH + move.w #$0004,(a1)+ + move.w #$00e2,(a1)+ ; BPL1PTL + move.w #$0000,(a1)+ + add.w #$0100,d3 + dbra d5,.line + dbra d2,.band + move.l #$fffffffe,(a1)+ + move.l #CLIST,$80(a6) + move.w d0,$88(a6) ; COPJMP1 + move.w #$8380,$96(a6) ; DMAEN|BPLEN|COPEN +.l: bra.s .l + cnop 0,2 +pairs: dc.w $66,$00ff ; band 1: cube frame A (P=$66, S=15) + dc.w $68,$0000 ; band 2: cube frame B (P=$68, S=0) + dc.w $66,$0000 ; band 3 + dc.w $68,$00ff ; band 4 + dc.w $38,$0000 ; band 5 + dc.w $3c,$0000 ; band 6: arosddf1 photo case + dc.w $40,$0000 ; band 7 diff --git a/timing-test/ddfprobe-phase2.bin b/timing-test/ddfprobe-phase2.bin new file mode 100644 index 0000000..4c0f781 Binary files /dev/null and b/timing-test/ddfprobe-phase2.bin differ diff --git a/timing-test/golden/timing-test.png b/timing-test/golden/timing-test.png index 299c930..ee71413 100644 Binary files a/timing-test/golden/timing-test.png and b/timing-test/golden/timing-test.png differ