From 843b4ee64d501a3746c0184e5ff65fa7abf90654 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 4 Jul 2026 18:32:57 +0100 Subject: [PATCH 1/2] agnus: evaluate sprite DMA lines at both hardware slots Sprite N's line was captured in one shot at its second DMA slot; now the first slot ($15+4N) runs the line entry logic (vstop comparator, descriptor chain, arming) and samples the DATA word(s) at that slot's beam time, and the second slot ($17+4N) samples DATB at its own beam time and assembles the display line. Chip RAM rewritten between the two slots is therefore seen by DATB but not DATA, and a mid-line DMACON SPREN edge fetches exactly one word of the pair, matching the real bus timing. A skipped slot leaves SPRxPT one fetch behind the line-derived stream position; the display line reuses the stale latch on that side and a missed DATA slot never arms the sprite. The single-shot path remains for the pre-display replay wrapper and as the second-slot fallback when the first slot did not run. Stream re-seeds (descriptor reload, SPRxPT retarget) clear the pending state, and the per-line markers default to unset so a fresh state cannot false-match line 0. STATE_VERSION 17 (DisplaySpriteDmaState gained fields). Byte-identical on the demo regression set (Gen-X 110s/620s, ITM, Hamazing, Zool, Second Nature, Kickstart 1.3 and A1200 boots) vs main. --- docs/internals/chipset.md | 11 ++ src/bus.rs | 38 +++- src/bus/frame_capture.rs | 393 +++++++++++++++++++++++++++++++++----- src/bus/tests.rs | 102 +++++----- src/savestate.rs | 4 +- 5 files changed, 457 insertions(+), 91 deletions(-) diff --git a/docs/internals/chipset.md b/docs/internals/chipset.md index a5710d4..ba186ff 100644 --- a/docs/internals/chipset.md +++ b/docs/internals/chipset.md @@ -47,6 +47,17 @@ Alice adds the FMODE wide-fetch latch, which scales the bitplane and sprite fetch quanta (FMODE=0 stays byte-identical to the OCS/ECS slot timing). +A sprite line's two DMA slots ($15+4N and $17+4N) are evaluated at their +own colour clocks: the first slot runs the line's descriptor/arming logic +and samples the DATA word(s) at that slot's beam time, the second samples +DATB at its own beam time and assembles the display line, so chip RAM +rewritten between the two slots is seen by DATB but not DATA. DMACON's +SPREN is sampled by each slot individually, so a mid-line edge fetches +exactly one word of the pair; the skipped word's slot leaves SPRxPT one +fetch behind and the data stream shifts accordingly, while the display +line reuses the stale latch on that side (a missed DATA slot never arms +the sprite). + Sprite DMA retains its latched POS/CTL descriptor independently from the SPRxPT registers while a sprite data stream is active or waiting for VSTART. Standard PAL/NTSC hard vertical blank inhibits sprite DMA at the top of the diff --git a/src/bus.rs b/src/bus.rs index 7c111a3..e0b114b 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -307,7 +307,7 @@ pub struct HeldSpriteLine { pub vstop: i32, } -#[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] struct DisplaySpriteDmaState { control: Option, #[serde(skip, default = "unset_sprite_control_loaded_vpos")] @@ -316,12 +316,48 @@ struct DisplaySpriteDmaState { terminated: bool, data_dma_active: bool, last_line: Option, + /// Words the hardware pointer lags behind the line-derived stream + /// position: slots skipped by mid-line SPREN edges never fetch, so + /// every later fetch of the stream reads that many words earlier. + data_word_skew: u32, + /// DATA word(s) fetched by the sprite's first DMA slot, sampled at that + /// slot's beam time, awaiting the second slot to assemble the line. + /// None when the first slot was skipped (or fetched nothing). + pending_data: Option<(u16, [u16; 3])>, + /// Line the first slot's evaluation ran for (entry logic + DATA fetch), + /// so the second slot completes rather than re-runs it. + #[serde(skip, default = "unset_sprite_control_loaded_vpos")] + pending_line_vpos: i32, + /// Line the per-line entry logic (vstop comparator, descriptor chain) + /// already ran for, keeping it idempotent across the two slots. + #[serde(skip, default = "unset_sprite_control_loaded_vpos")] + entry_line_vpos: i32, } fn unset_sprite_control_loaded_vpos() -> i32 { i32::MIN } +impl Default for DisplaySpriteDmaState { + fn default() -> Self { + // The line-marker fields must start unset, not at line 0: a derived + // zero default would make a fresh state claim its per-line work + // already ran when the beam is on line 0. + Self { + control: None, + control_loaded_vpos: unset_sprite_control_loaded_vpos(), + next_ptr: None, + terminated: false, + data_dma_active: false, + last_line: None, + data_word_skew: 0, + pending_data: None, + pending_line_vpos: unset_sprite_control_loaded_vpos(), + entry_line_vpos: unset_sprite_control_loaded_vpos(), + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SpriteControlRegisterWrite { Pos, diff --git a/src/bus/frame_capture.rs b/src/bus/frame_capture.rs index 684d890..1b3c940 100644 --- a/src/bus/frame_capture.rs +++ b/src/bus/frame_capture.rs @@ -8,6 +8,23 @@ use super::*; +/// Which of a sprite pair's two DMA slots a `sprite_line_eval` call models. +#[derive(Clone, Copy)] +enum SpriteSlotPhase { + /// Both words in one call: the pre-display replay path, and the second + /// slot's fallback when the first slot did not run (its SPREN sample + /// was low or the stream was re-seeded between the slots). + Pair { + slot1_enabled: bool, + slot2_enabled: bool, + }, + /// First hardware slot ($15+4N): entry logic plus the DATA fetch, + /// sampled at this slot's beam time. + Slot1, + /// Second hardware slot ($17+4N): DATB fetch and line assembly. + Slot2 { slot2_enabled: bool }, +} + impl Bus { pub(super) fn begin_new_beam_frame(&mut self) { self.diag_log_frame_start(); @@ -371,6 +388,10 @@ impl Bus { terminated: false, data_dma_active: false, last_line: None, + data_word_skew: 0, + pending_data: None, + pending_line_vpos: i32::MIN, + entry_line_vpos: i32::MIN, }; } @@ -507,6 +528,10 @@ impl Bus { state.control_loaded_vpos = beam_y; state.next_ptr = Some(control.next_ptr); state.terminated = false; + state.data_word_skew = 0; + state.pending_data = None; + state.pending_line_vpos = unset_sprite_control_loaded_vpos(); + state.entry_line_vpos = unset_sprite_control_loaded_vpos(); state.data_dma_active = in_window && (reaches_current_fetch_slot || keep_held_line || keep_active_dma_line); if !keep_held_line && !keep_active_dma_line { @@ -575,6 +600,10 @@ impl Bus { state.control = Some(control); state.next_ptr = Some(control.next_ptr); state.terminated = false; + state.data_word_skew = 0; + state.pending_data = None; + state.pending_line_vpos = unset_sprite_control_loaded_vpos(); + state.entry_line_vpos = unset_sprite_control_loaded_vpos(); state.data_dma_active = beam_y >= control.vstart && beam_y < control.vstop; state.last_line = None; self.display_dma_sprite_state[sprite] = state; @@ -844,7 +873,7 @@ impl Bus { // No sprite DMA slot lies in [old_hpos, new_hpos): nothing below can // run (the per-sprite loop checks the same window), so skip the // sprite-state scan on the vast majority of beam advances. - if old_hpos > SPRITE_DMA_SLOT1_HPOS[7] || new_hpos <= SPRITE_DMA_SLOT1_HPOS[0] { + if old_hpos > SPRITE_DMA_SLOT1_HPOS[7] + 2 || new_hpos <= SPRITE_DMA_SLOT1_HPOS[0] { return; } if self.sprite_dma_inhibited_by_vertical_blank_at(vpos) { @@ -869,41 +898,74 @@ impl Bus { let mut fetched_lines = 0usize; let bitplane_bplcon0 = self.effective_bitplane_bplcon0(); let bitplane_dmacon = self.effective_bitplane_dmacon(); - for (sprite, &capture_hpos) in SPRITE_DMA_SLOT1_HPOS.iter().enumerate() { - if old_hpos > capture_hpos || new_hpos <= capture_hpos { + for (sprite, &slot1_hpos) in SPRITE_DMA_SLOT1_HPOS.iter().enumerate() { + // Each sprite line uses two hardware DMA slots: $15+4N fetches + // POS or DATA, $17+4N fetches CTL or DATB. Both crossings are + // evaluated at their own beam time, so a mid-line DMACON edge + // between the two slots fetches exactly one of the pair, and + // memory rewritten between them is sampled per slot. + let slot2_hpos = slot1_hpos + 2; + if old_hpos > slot2_hpos || new_hpos <= slot1_hpos { continue; } - // SPREN is sampled by each sprite's own DMA slot (the sprena/ + // SPREN is sampled by each DMA slot individually (the sprena/ // sprdis vAmigaTS sweeps step DMACON writes in two-colour-clock // increments around individual slots), honouring the DMACON // write commit delay. - let slot_cck = - old_emulated_cck.saturating_add(u64::from(capture_hpos.saturating_sub(old_hpos))); - let sprite_dma_enabled = self.effective_bitplane_dmacon_at(slot_cck) - & (DMACON_DMAEN | DMACON_SPREN) - == (DMACON_DMAEN | DMACON_SPREN); - if !sprite_dma_enabled && !sprite_vertical_bar_active { + let slot_cck = |hpos: u32| { + old_emulated_cck.saturating_add(u64::from(hpos.saturating_sub(old_hpos))) + }; + let spren_at = |dmacon: u16| { + dmacon & (DMACON_DMAEN | DMACON_SPREN) == (DMACON_DMAEN | DMACON_SPREN) + }; + let ddf_blocked = sprite_dma_disabled_by_bitplane_ddf( + sprite, + self.agnus.revision(), + bitplane_bplcon0, + self.agnus.fmode(), + bitplane_dmacon, + self.denise.ddfstrt, + self.denise.ddfstop, + self.harddis_active(), + ); + if ddf_blocked { + continue; + } + if (old_hpos..new_hpos).contains(&slot1_hpos) { + let slot1_enabled = + spren_at(self.effective_bitplane_dmacon_at(slot_cck(slot1_hpos))); + if slot1_enabled { + self.sprite_slot1_fetch(sprite, vpos); + } + } + if !(old_hpos..new_hpos).contains(&slot2_hpos) { continue; } - if sprite_dma_enabled { + let slot2_enabled = spren_at(self.effective_bitplane_dmacon_at(slot_cck(slot2_hpos))); + let slot1_ran = self.display_dma_sprite_state[sprite].entry_line_vpos == vpos as i32; + if !slot1_ran && !slot2_enabled && !sprite_vertical_bar_active { + continue; + } + if slot1_ran || slot2_enabled { pair_slots += 1; } let mut captured_line = false; { - if sprite_dma_disabled_by_bitplane_ddf( - sprite, - self.agnus.revision(), - bitplane_bplcon0, - self.agnus.fmode(), - bitplane_dmacon, - self.denise.ddfstrt, - self.denise.ddfstop, - self.harddis_active(), - ) { - continue; - } - let line = if sprite_dma_enabled { - self.captured_sprite_line_at(sprite, vpos) + let line = if slot1_ran { + self.sprite_line_eval(sprite, vpos, SpriteSlotPhase::Slot2 { slot2_enabled }) + } else if slot2_enabled { + // The first slot did not run (SPREN low there, or the + // stream was re-seeded between the slots): the entry + // logic runs here, with the DATA side counted as a + // skipped slot. + self.sprite_line_eval( + sprite, + vpos, + SpriteSlotPhase::Pair { + slot1_enabled: false, + slot2_enabled: true, + }, + ) } else { self.captured_sprite_vertical_bar_line_at(sprite, vpos) }; @@ -973,6 +1035,36 @@ impl Bus { &mut self, sprite: usize, vpos: u32, + ) -> Option { + self.sprite_line_eval( + sprite, + vpos, + SpriteSlotPhase::Pair { + slot1_enabled: true, + slot2_enabled: true, + }, + ) + } + + /// First hardware sprite slot ($15+4N): runs the line's entry logic + /// (vstop comparator, descriptor chain, arming) and samples the DATA + /// word(s) at this slot's beam time into pending state. The line itself + /// is assembled and emitted by the second slot's call. + pub(super) fn sprite_slot1_fetch(&mut self, sprite: usize, vpos: u32) { + let _ = self.sprite_line_eval(sprite, vpos, SpriteSlotPhase::Slot1); + } + + /// One scanline of a sprite's DMA: the first slot ($15+4N) fetches POS + /// or DATA, the second ($17+4N) CTL or DATB. A mid-line SPREN edge can + /// enable one slot and not the other; a skipped slot leaves the word + /// counter behind (the stream shifts) and the display line assembles + /// from the stale latch on that side. A DATA fetch arms the sprite; + /// DATB alone does not. + fn sprite_line_eval( + &mut self, + sprite: usize, + vpos: u32, + phase: SpriteSlotPhase, ) -> Option { let ram_len = self.mem.chip_ram.len(); if ram_len == 0 { @@ -980,7 +1072,18 @@ impl Bus { } let ram_mask = self.chip_dma_mask; let beam_y = vpos as i32; + if let SpriteSlotPhase::Slot2 { slot2_enabled } = phase { + return self.sprite_slot2_complete(sprite, beam_y, ram_mask, slot2_enabled); + } let mut state = self.display_dma_sprite_state[sprite]; + if matches!(phase, SpriteSlotPhase::Slot1) { + // Mark the entry logic as run for this line so the second + // slot's call completes it instead of re-running descriptor + // work, and drop any pending words a broken-off line left. + state.entry_line_vpos = beam_y; + state.pending_data = None; + state.pending_line_vpos = unset_sprite_control_loaded_vpos(); + } let mut descriptor_can_match_current_vstart = state.control.is_some() || state.next_ptr.is_none(); let mut descriptor_loaded_after_stop_this_line = false; @@ -1002,7 +1105,12 @@ impl Bus { if let Some(control) = state.control { if beam_y >= control.vstop { - state.next_ptr = Some(control.next_ptr); + // The next descriptor is fetched from the pointer's + // actual position: slots skipped by SPREN edges leave + // it short of the precomputed stream end. + state.next_ptr = Some( + control.next_ptr.wrapping_sub(state.data_word_skew * 2) & ram_mask & !1, + ); state.control = None; state.data_dma_active = false; state.last_line = None; @@ -1025,34 +1133,108 @@ impl Bus { let quantum = sprite_fetch_quantum(self.agnus.fmode()); // SSCAN2 fetches sprite data only on every second display // line; the in-between line redisplays the same data. + if sprite_scan_doubled(self.agnus.fmode()) + && (beam_y - control.effective_data_vstart()) & 1 == 1 + { + let line_data = state.last_line; + self.display_dma_sprite_state[sprite] = state; + if matches!(phase, SpriteSlotPhase::Slot1) { + // The repeat line is emitted by the second + // slot's call. + return None; + } + return line_data.map(|line_data| CapturedSpriteLine { + sprite, + hstart: line_data.hstart, + hsub_70ns: line_data.hsub_70ns, + beam_y, + data: line_data.data, + datb: line_data.datb, + data_ext: line_data.data_ext, + datb_ext: line_data.datb_ext, + width_words: line_data.width_words, + attached: line_data.attached, + }); + } + // The stream address follows the hardware pointer: the + // line-derived position minus the words that skipped + // slots never fetched. let mut line = (beam_y - control.effective_data_vstart()) as u32; if sprite_scan_doubled(self.agnus.fmode()) { line /= 2; } - let line_bytes = 4 * quantum; - let data_ptr = control + let line_base = control .data_base - .wrapping_add(line.saturating_mul(line_bytes)) - & ram_mask - & !1; - let datb_ptr = data_ptr.wrapping_add(2 * quantum); - let mut data_ext = [0u16; 3]; - let mut datb_ext = [0u16; 3]; - for w in 1..quantum as usize { - data_ext[w - 1] = read_chip_word_wrapping( - &self.mem.chip_ram, - data_ptr.wrapping_add(2 * w as u32), - ); - datb_ext[w - 1] = read_chip_word_wrapping( - &self.mem.chip_ram, - datb_ptr.wrapping_add(2 * w as u32), - ); + .wrapping_add(line.saturating_mul(4 * quantum)); + let fetch_words = |state: &DisplaySpriteDmaState, word_off: u32| { + let ptr = line_base + .wrapping_add(word_off * 2) + .wrapping_sub(state.data_word_skew * 2) + & ram_mask + & !1; + let mut words = [0u16; 4]; + for (w, word) in words.iter_mut().enumerate().take(quantum as usize) { + *word = read_chip_word_wrapping( + &self.mem.chip_ram, + ptr.wrapping_add(2 * w as u32), + ); + } + words + }; + let (slot1_enabled, slot2_enabled) = match phase { + SpriteSlotPhase::Pair { + slot1_enabled, + slot2_enabled, + } => (slot1_enabled, slot2_enabled), + SpriteSlotPhase::Slot1 => { + // DATA is sampled at this slot's beam time; the + // second slot's call assembles the line. + let d = fetch_words(&state, 0); + state.pending_data = Some((d[0], [d[1], d[2], d[3]])); + state.pending_line_vpos = beam_y; + self.display_dma_sprite_state[sprite] = state; + return None; + } + SpriteSlotPhase::Slot2 { .. } => { + unreachable!("second-slot calls complete before the entry loop") + } + }; + let data = slot1_enabled.then(|| fetch_words(&state, 0)); + if !slot1_enabled { + state.data_word_skew += quantum; + } + let datb = slot2_enabled.then(|| fetch_words(&state, quantum)); + if !slot2_enabled { + state.data_word_skew += quantum; } + let stale = state.last_line; + let assembled = match (data, datb) { + (Some(d), Some(b)) => { + Some(((d[0], [d[1], d[2], d[3]]), (b[0], [b[1], b[2], b[3]]))) + } + // DATB missed its slot: the sprite displays the new + // DATA with the stale DATB latch. + (Some(d), None) => Some(( + (d[0], [d[1], d[2], d[3]]), + stale.map_or((0, [0; 3]), |l| (l.datb, l.datb_ext)), + )), + // DATA missed its slot: DATB alone does not arm the + // sprite; an already-armed sprite keeps displaying + // with the stale DATA. + (None, Some(b)) => { + stale.map(|l| ((l.data, l.data_ext), (b[0], [b[1], b[2], b[3]]))) + } + (None, None) => stale.map(|l| ((l.data, l.data_ext), (l.datb, l.datb_ext))), + }; + let Some(((data, data_ext), (datb, datb_ext))) = assembled else { + self.display_dma_sprite_state[sprite] = state; + return None; + }; let line_data = DisplaySpriteLineData { hstart: control.hstart, hsub_70ns: control.hsub_70ns, - data: read_chip_word_wrapping(&self.mem.chip_ram, data_ptr), - datb: read_chip_word_wrapping(&self.mem.chip_ram, datb_ptr), + data, + datb, data_ext, datb_ext, width_words: quantum as u8, @@ -1190,6 +1372,7 @@ impl Bus { state.control = Some(control); state.control_loaded_vpos = beam_y; + state.data_word_skew = 0; state.data_dma_active = false; state.last_line = None; if beam_y < control.vstart { @@ -1212,6 +1395,122 @@ impl Bus { } } + /// Second hardware sprite slot ($17+4N) after the first slot's call ran + /// the line entry: fetches DATB at this slot's beam time, assembles the + /// display line with the pending DATA words (or the stale latches), and + /// emits it. + fn sprite_slot2_complete( + &mut self, + sprite: usize, + beam_y: i32, + ram_mask: u32, + slot2_enabled: bool, + ) -> Option { + let mut state = self.display_dma_sprite_state[sprite]; + let pending = if state.pending_line_vpos == beam_y { + state.pending_data.take() + } else { + None + }; + state.pending_data = None; + let Some(control) = state.control else { + self.display_dma_sprite_state[sprite] = state; + return None; + }; + if state.terminated || !state.data_dma_active { + self.display_dma_sprite_state[sprite] = state; + return None; + } + let quantum = sprite_fetch_quantum(self.agnus.fmode()); + // SSCAN2 in-between lines redisplay the previous fetch. + if sprite_scan_doubled(self.agnus.fmode()) + && (beam_y - control.effective_data_vstart()) & 1 == 1 + { + let line_data = state.last_line; + self.display_dma_sprite_state[sprite] = state; + return line_data.map(|line_data| CapturedSpriteLine { + sprite, + hstart: line_data.hstart, + hsub_70ns: line_data.hsub_70ns, + beam_y, + data: line_data.data, + datb: line_data.datb, + data_ext: line_data.data_ext, + datb_ext: line_data.datb_ext, + width_words: line_data.width_words, + attached: line_data.attached, + }); + } + if pending.is_none() && state.pending_line_vpos != beam_y { + // The first slot ran the entry logic but no data fetch belongs + // to this line (e.g. a stop/start descriptor handover deferred + // the stream to the next line). + self.display_dma_sprite_state[sprite] = state; + return None; + } + let mut line = (beam_y - control.effective_data_vstart()) as u32; + if sprite_scan_doubled(self.agnus.fmode()) { + line /= 2; + } + let line_base = control + .data_base + .wrapping_add(line.saturating_mul(4 * quantum)); + let datb = if slot2_enabled { + let ptr = line_base + .wrapping_add(quantum * 2) + .wrapping_sub(state.data_word_skew * 2) + & ram_mask + & !1; + let mut words = [0u16; 4]; + for (w, word) in words.iter_mut().enumerate().take(quantum as usize) { + *word = read_chip_word_wrapping(&self.mem.chip_ram, ptr.wrapping_add(2 * w as u32)); + } + Some((words[0], [words[1], words[2], words[3]])) + } else { + state.data_word_skew += quantum; + None + }; + let stale = state.last_line; + let assembled = match (pending, datb) { + (Some(d), Some(b)) => Some((d, b)), + // DATB missed its slot: the sprite displays the new DATA with + // the stale DATB latch. + (Some(d), None) => Some((d, stale.map_or((0, [0; 3]), |l| (l.datb, l.datb_ext)))), + // DATA missed its slot: DATB alone does not arm the sprite; an + // already-armed sprite keeps displaying with the stale DATA. + (None, Some(b)) => stale.map(|l| ((l.data, l.data_ext), b)), + (None, None) => stale.map(|l| ((l.data, l.data_ext), (l.datb, l.datb_ext))), + }; + let Some(((data, data_ext), (datb, datb_ext))) = assembled else { + self.display_dma_sprite_state[sprite] = state; + return None; + }; + let line_data = DisplaySpriteLineData { + hstart: control.hstart, + hsub_70ns: control.hsub_70ns, + data, + datb, + data_ext, + datb_ext, + width_words: quantum as u8, + attached: control.attached, + }; + state.last_line = Some(line_data); + self.display_dma_sprite_state[sprite] = state; + Some(CapturedSpriteLine { + sprite, + hstart: line_data.hstart, + hsub_70ns: line_data.hsub_70ns, + beam_y, + data: line_data.data, + datb: line_data.datb, + data_ext: line_data.data_ext, + datb_ext: line_data.datb_ext, + width_words: line_data.width_words, + attached: line_data.attached, + }) + } + pub(super) fn captured_sprite_vertical_bar_line_at( &mut self, sprite: usize, @@ -1227,7 +1526,9 @@ impl Bus { let mut state = self.display_dma_sprite_state[sprite]; let control = state.control?; if beam_y >= control.vstop { - state.next_ptr = Some(control.next_ptr); + state.next_ptr = Some( + control.next_ptr.wrapping_sub(state.data_word_skew * 2) & self.chip_dma_mask & !1, + ); state.control = None; state.data_dma_active = false; state.last_line = None; diff --git a/src/bus/tests.rs b/src/bus/tests.rs index f3d32a8..d61be37 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -4723,9 +4723,13 @@ fn sprite_dma_capture_samples_line_words_at_beam_time() { bus.advance_chipset(1); write_chip_word(&mut bus, sprite_ptr + 4, 0xAAAA); write_chip_word(&mut bus, sprite_ptr + 6, 0xBBBB); + // Crosses the first slot ($15): DATA is sampled here. bus.advance_chipset(1); write_chip_word(&mut bus, sprite_ptr + 4, 0xCCCC); write_chip_word(&mut bus, sprite_ptr + 6, 0xDDDD); + // Crosses the second slot ($17): DATB is sampled here, after the + // rewrite, so the two words of one line see different memory states. + bus.advance_chipset(2); let lines = bus.frame_captured_sprite_lines(); assert!(bus.frame_sprite_dma_observed()); @@ -4734,7 +4738,7 @@ fn sprite_dma_capture_samples_line_words_at_beam_time() { assert_eq!(lines[0].beam_y, 0x2C); assert_eq!(lines[0].hstart, 0x0083); assert_eq!(lines[0].data, 0xAAAA); - assert_eq!(lines[0].datb, 0xBBBB); + assert_eq!(lines[0].datb, 0xDDDD); } #[test] @@ -4768,7 +4772,7 @@ fn inactive_sprite_pointer_write_before_pair_slot_seeds_next_descriptor_fetch() bus.agnus.hpos = 0; bus.capture_current_frame_display_start(); bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert!(bus.frame_sprite_dma_observed()); @@ -4817,7 +4821,7 @@ fn vertical_blank_sprite_pointer_write_reloads_descriptor_in_offscreen_replay() bus.agnus.vpos = RENDER_VISIBLE_START_VPOS; bus.capture_current_frame_display_start(); bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -4860,7 +4864,7 @@ fn post_vertical_blank_sprite_pointer_write_retargets_pending_descriptor() { bus.agnus.vpos = RENDER_VISIBLE_START_VPOS; bus.capture_current_frame_display_start(); bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -4890,10 +4894,10 @@ fn manual_sprite_control_write_fetches_data_from_sprpt() { bus.agnus.vpos = 0x2C; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.vpos = 0x2D; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert!(bus.frame_sprite_dma_observed()); @@ -4937,10 +4941,10 @@ fn pending_register_control_sprite_pointer_write_retargets_data_stream() { bus.agnus.vpos = 0x40; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.vpos = 0x41; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 2); @@ -4973,7 +4977,7 @@ fn pending_descriptor_sprite_pointer_write_retargets_data_stream() { bus.agnus.vpos = 0x2C; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let pending = bus.display_dma_sprite_state[0]; assert_eq!(pending.control.map(|control| control.vstart), Some(0x60)); assert!(!pending.data_dma_active); @@ -4985,10 +4989,10 @@ fn pending_descriptor_sprite_pointer_write_retargets_data_stream() { bus.agnus.vpos = 0x60; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.vpos = 0x61; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 2); @@ -5037,7 +5041,7 @@ fn armed_pointer_reload_before_vstart_fetches_descriptor_words() { bus.agnus.vpos = 0x40; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5065,7 +5069,7 @@ fn after_slot_armed_sprite_pointer_write_seeds_dma_data_stream() { let _ = bus.write_custom_word_from(0x122, data_ptr as u16, BeamWriteSource::Copper); bus.agnus.vpos = 0x41; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5094,7 +5098,7 @@ fn active_sprite_control_rewrite_preserves_descriptor_data_origin() { bus.agnus.vpos = 0x2C; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.vpos = 0x2D; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 8; @@ -5102,7 +5106,7 @@ fn active_sprite_control_rewrite_preserves_descriptor_data_origin() { assert!(!bus.write_custom_word_from(0x142, moved_ctl, BeamWriteSource::Copper)); bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert!(bus.frame_sprite_dma_observed()); @@ -5143,13 +5147,13 @@ fn sprite_pointer_write_after_pair_slot_seeds_next_descriptor_fetch() { let slot = SPRITE_DMA_SLOT1_HPOS[2]; bus.agnus.vpos = 0; bus.agnus.hpos = slot - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let _ = bus.write_custom_word_from(0x128, (new_ptr >> 16) as u16, BeamWriteSource::Copper); let _ = bus.write_custom_word_from(0x12A, new_ptr as u16, BeamWriteSource::Copper); bus.agnus.vpos = 0x2C; bus.agnus.hpos = slot - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert!(bus.frame_sprite_dma_observed()); @@ -5184,7 +5188,7 @@ fn sprite_dma_inverted_vstop_runs_to_frame_bottom() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert!(bus.frame_sprite_dma_observed()); @@ -5221,7 +5225,7 @@ fn fmode_wide_sprite_dma_captures_extension_words() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5265,7 +5269,7 @@ fn fmode_sscan2_sprite_dma_doubles_each_data_line() { for vpos in 0x2C..=0x32u32 { bus.agnus.vpos = vpos; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); } let lines = bus.frame_captured_sprite_lines(); @@ -5443,7 +5447,7 @@ fn sprite_dma_capture_preserves_sprite_started_before_visible_area() { bus.capture_current_frame_display_start(); bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5487,7 +5491,7 @@ fn pending_sprite_control_rewrite_preserves_descriptor_data_origin() { bus.agnus.vpos = vstart as u32; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5519,7 +5523,7 @@ fn active_sprite_pos_write_retimes_hstart_without_clearing_dma_stream() { bus.display_dma_sprpt[0] = sprite_ptr as u32; bus.agnus.vpos = 0x2C; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); // Rewriting SPRxPOS while DMA is already enabled changes the horizontal // comparator. It must not recompute the active descriptor from the @@ -5529,11 +5533,11 @@ fn active_sprite_pos_write_retimes_hstart_without_clearing_dma_stream() { bus.agnus.hpos = 0; assert!(!bus.write_custom_word_from(0x140, moved_pos, BeamWriteSource::Copper)); bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.vpos = 0x2E; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let words: Vec<(i32, i32, u16, u16)> = bus .frame_captured_sprite_lines() @@ -5574,6 +5578,10 @@ fn finished_sprite_channel_carries_dma_frontier_across_frame_boundary() { terminated: true, data_dma_active: false, last_line: None, + data_word_skew: 0, + pending_data: None, + pending_line_vpos: i32::MIN, + entry_line_vpos: i32::MIN, }; // Channel 1 is still mid-descriptor at frame end: keep the written @@ -5596,6 +5604,10 @@ fn finished_sprite_channel_carries_dma_frontier_across_frame_boundary() { terminated: false, data_dma_active: true, last_line: None, + data_word_skew: 0, + pending_data: None, + pending_line_vpos: i32::MIN, + entry_line_vpos: i32::MIN, }; // Channel 2 was never set up this field: fall back to the written pointer. @@ -5636,7 +5648,7 @@ fn sprite_dma_capture_treats_zero_pointer_as_chip_address() { bus.display_dma_sprpt[0] = 0; bus.display_dma_sprpt[1] = 0x20; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5706,6 +5718,10 @@ fn state_load_resets_transient_video_latches() { width_words: 1, attached: false, }), + data_word_skew: 0, + pending_data: None, + pending_line_vpos: i32::MIN, + entry_line_vpos: i32::MIN, }; bus.display_dma_sprite_state[3].terminated = true; @@ -5825,7 +5841,7 @@ fn sprite_dma_zero_height_descriptor_terminates_stream() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert!(lines.is_empty()); @@ -5849,7 +5865,7 @@ fn sprite_dma_capture_wraps_control_words_at_chip_ram_end() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5908,14 +5924,14 @@ fn sprite_dma_capture_latches_control_words_until_stop() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; - bus.advance_chipset(2); + bus.advance_chipset(4); let (rewritten_pos, rewritten_ctl) = sprite_control_words(0x2D, 0x2E, 0x0091); write_chip_word(&mut bus, sprite_ptr, rewritten_pos); write_chip_word(&mut bus, sprite_ptr + 2, rewritten_ctl); bus.agnus.vpos = 0x2D; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 2); @@ -5955,7 +5971,7 @@ fn sprite_dma_capture_samples_later_pairs_at_their_fetch_slot() { bus.advance_chipset(2); write_chip_word(&mut bus, sprite0_ptr + 4, 0xAAAA); write_chip_word(&mut bus, sprite6_ptr + 4, 0xBBBB); - let remaining = SPRITE_DMA_SLOT1_HPOS[6] + 1 - bus.agnus.hpos; + let remaining = SPRITE_DMA_SLOT1_HPOS[6] + 3 - bus.agnus.hpos; bus.advance_chipset(remaining); let lines = bus.frame_captured_sprite_lines(); @@ -6000,7 +6016,7 @@ fn sprite_pointer_write_at_pair_slot_seeds_next_line_descriptor_fetch() { bus.agnus.vpos = 0x40; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -6016,7 +6032,7 @@ fn empty_sprite_dma_slot_does_not_mark_frame_dma_observed() { bus.agnus.vpos = RENDER_VISIBLE_START_VPOS; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); assert!(bus.frame_captured_sprite_lines().is_empty()); assert!( @@ -6088,7 +6104,7 @@ fn sprite_dma_capture_keeps_sprite_seven_when_ddfstrt_matches_sprite_slot() { bus.display_dma_sprpt[6] = sprite6_ptr as u32; bus.display_dma_sprpt[7] = sprite7_ptr as u32; - bus.advance_chipset(SPRITE_DMA_SLOT1_HPOS[7] + 1 - bus.agnus.hpos); + bus.advance_chipset(SPRITE_DMA_SLOT1_HPOS[7] + 3 - bus.agnus.hpos); let lines = bus.frame_captured_sprite_lines(); assert!(lines.iter().any(|line| line.sprite == 6)); @@ -6115,14 +6131,14 @@ fn sprite_dma_capture_repeats_last_fetched_line_after_dma_disable_until_vstop() bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.dmacon = DMACON_DMAEN; bus.agnus.vpos = 0x2D; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.vpos = 0x2E; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); let first = lines @@ -6160,10 +6176,10 @@ fn sprite_dma_capture_does_not_start_descriptor_at_or_before_current_vpos() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprite_state[0].next_ptr = Some(sprite_ptr as u32); - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.vpos = 0x2D; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); assert!(bus .frame_captured_sprite_lines() @@ -6192,13 +6208,13 @@ fn sprite_dma_reuse_skips_descriptor_with_vstart_before_current_vpos() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.vpos = 0x2C; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); bus.agnus.vpos = 0x2D; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert!(lines @@ -6238,7 +6254,7 @@ fn sprite_dma_chained_descriptor_with_same_vstart_arms_after_control_fetch_line( for vpos in 0x2C..=0x31u32 { bus.agnus.vpos = vpos; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(2); + bus.advance_chipset(4); } let words: Vec<(i32, i32, u16, u16)> = bus diff --git a/src/savestate.rs b/src/savestate.rs index 9e92bab..f5d3fe7 100644 --- a/src/savestate.rs +++ b/src/savestate.rs @@ -76,7 +76,9 @@ const STATE_MAGIC: &[u8; 8] = b"CLSSTATE"; // replaces the value-range DDF window for FMODE=0 fetches // 16: CapturedBitplaneRow gained fetch_origin_cck (the sequencer run origin // for rows whose fetch diverges from the register-derived window) -pub const STATE_VERSION: u32 = 16; +// 17: DisplaySpriteDmaState gained the two-slot sprite fetch fields +// (data_words_fetched pointer progression, pending_data) +pub const STATE_VERSION: u32 = 17; /// Default state file name, timestamped like the screenshot/recorder names. pub fn auto_filename() -> std::path::PathBuf { From 1c8102915215778a706dfafe3d9e375fd7d0327d Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 4 Jul 2026 19:32:08 +0100 Subject: [PATCH 2/2] agnus: model sprite DMA at the register level Replace the descriptor-precompute sprite model with the register-level state machine the chip implements (vAmiga executeFirst/SecondSpriteCycle and updateSpriteDMA semantics, ground-truthed against the real-A500 sprenacpu2 photos, which match vAmiga's render exactly): - Each channel keeps Agnus-side SPRxPOS/SPRxCTL register copies and the vertical comparator values derived from them, updated identically by DMA control-word fetches and CPU/Copper pokes (POS supplies vstart's low bits, CTL the high bit plus the whole vstop, and disarms the display latches). Line-start comparators set/clear the per-channel DMA flip-flop; vstop clears it even when SPREN is off, which leaves a sprite dead until the next field when software disables DMA across its vstop line - the vAmigaTS sprena/sprdis mechanism. - The vstop line's two slots fetch the next POS/CTL control words, and the vertical-blank reset (PAL $19 / NTSC $14) forces every channel's vstop to that line, which is how each field's first control-word fetch happens. Terminators, inverted vstop pairs, deferred starts and equal start/stop descriptors all fall out of the comparators naturally. - SPRxPT advances only on words actually fetched: the word-skew model is gone and the frame-start frontier is simply the live pointer. An armed channel with DMA off keeps redisplaying its latches until a CTL fetch/poke disarms it, which renders the sprena/sprdis vertical-bar staircase the old model structurally could not produce. - Deleted with the descriptor model: register-stream seeding, SPRxPT retarget math, the same-slot descriptor-restart discard, and the save-state reconstruction hack - the register state is chip state and is now serialized and restored directly. - Sprite bus-slot arbitration uses the same rules (enabled channel or vstop-line control fetch claims the slots, vblank-gated), and VideoPipelineStats (host perf telemetry) is no longer serialized: call chunking legitimately differs across a save-state load. The sprena/sprdis vAmigaTS percentages stay ~30-45%: those sweeps step CPU DMACON writes at two-colour-clock resolution over a CPU-timed flickering background, so the residual is the known CPU-write-timing class, not sprite modelling. Structurally the renders now match the photos' bar staircase. Byte-identical on kick13boot, Gen-X, Hamazing, ITM, A1200 Kickstart, Zool and Roots II AGA; Second Nature and Roots II ECS show the known benign animation-phase drift class. STATE_VERSION 17. --- docs/internals/chipset.md | 92 ++- src/bus.rs | 168 ++--- src/bus/custom_regs.rs | 3 - src/bus/dma_slots.rs | 8 +- src/bus/frame_capture.rs | 1224 +++++++++---------------------------- src/bus/tests.rs | 309 ++++++---- 6 files changed, 616 insertions(+), 1188 deletions(-) diff --git a/docs/internals/chipset.md b/docs/internals/chipset.md index ba186ff..4890cd5 100644 --- a/docs/internals/chipset.md +++ b/docs/internals/chipset.md @@ -47,59 +47,47 @@ Alice adds the FMODE wide-fetch latch, which scales the bitplane and sprite fetch quanta (FMODE=0 stays byte-identical to the OCS/ECS slot timing). +Sprite DMA is modelled at the register level, the way the chips work: +there is no separate "descriptor" concept. Each channel keeps Agnus-side +copies of its SPRxPOS/SPRxCTL words and the vertical comparator values +derived from them, updated identically by DMA fetches and by CPU/Copper +pokes (POS supplies the vertical start's low bits, CTL the high bit, the +whole vertical stop, and disarms the display latches). At each line start +the comparators run: passing vstart sets the channel's DMA flip-flop, +passing vstop clears it -- even when SPREN is off, which is what leaves a +sprite dead until the next field when software disables DMA across its +vstop line. Writes landing on the matching line re-evaluate immediately. + A sprite line's two DMA slots ($15+4N and $17+4N) are evaluated at their -own colour clocks: the first slot runs the line's descriptor/arming logic -and samples the DATA word(s) at that slot's beam time, the second samples -DATB at its own beam time and assembles the display line, so chip RAM -rewritten between the two slots is seen by DATB but not DATA. DMACON's -SPREN is sampled by each slot individually, so a mid-line edge fetches -exactly one word of the pair; the skipped word's slot leaves SPRxPT one -fetch behind and the data stream shifts accordingly, while the display -line reuses the stale latch on that side (a missed DATA slot never arms -the sprite). - -Sprite DMA retains its latched POS/CTL descriptor independently from the -SPRxPT registers while a sprite data stream is active or waiting for VSTART. -Standard PAL/NTSC hard vertical blank inhibits sprite DMA at the top of the -field, so descriptor fetches are not replayed until PAL line $19 or NTSC line -$14. -If software rewrites SPRxPT on a later beam line while that descriptor is -still pending, the retained POS/CTL stays latched and the new SPRxPT value -retargets the descriptor's post-control data stream. A same-line rewrite -after the descriptor fetch is treated as a descriptor restart for the next -sprite DMA slot; otherwise freshly loaded descriptor words would be mistaken -for sprite data. -Software can also write SPRxPOS/SPRxCTL directly and let sprite DMA fetch -data from the current SPRxPT stream; in that case SPRxPT names the first -data word pair, not a memory descriptor. A save-state load clears -Copperline's transient Agnus descriptor latch, so this register-stream case -is reconstructed from Denise's retained armed state, SPRxPOS/SPRxCTL, and -an after-slot SPRxPT low-word write in the rendered display area. If a DMA -descriptor has already been retained and is still waiting for VSTART, later -SPRxPOS/SPRxCTL writes -update the live comparators but keep the descriptor's post-control data -origin. The frame-start replay path mirrors this by replaying off-screen -DMACON and SPRxPT writes in beam order before rendering the visible field. -Enabled sprite slots that fetch no sprite data are not treated as observed -sprite DMA; otherwise empty DMA slots would suppress valid register/manual -sprite replay for that frame. - -SPRxPT advances through sprite DMA and is not snapped back to the last value -the Copper/CPU wrote at the top of the next field. A channel that has read its -terminating descriptor leaves SPRxPT parked at the DMA frontier past the -consumed list, so the next field's replay is seeded from that frontier rather -than the stale descriptor address. Programs must reload SPRxPT every field -(normally from the Copper) to keep a sprite displayed; this is what prevents a -reused sprite descriptor buffer that software overwrites between fields from -being re-armed from its previous, now-overwritten address before the Copper's -reload lands. A channel still mid-descriptor at field end keeps the written -pointer so an active reused sprite is not skipped past its data. - -Sprite descriptors whose decoded VSTART equals VSTOP idle the current -sprite stream until software rearms it or the next field fetches again; -the following words are not scanned as another descriptor. This is distinct -from an inverted VSTOP smaller than VSTART, where the stop comparator has -already passed and DMA continues to the bottom of the field. +own colour clocks. On the channel's vstop line the slots fetch the next +control words (POS in the first slot, CTL in the second); on other lines +an enabled channel fetches DATA and DATB, so chip RAM rewritten between +the two slots is seen by DATB but not DATA. The vertical-blank reset +(PAL line $19, NTSC line $14 -- sprite DMA is inhibited above it) forces +every channel's vstop to that line, which is how each field's first +control-word fetch happens; software reloads SPRxPT each vblank to point +it at the sprite list. An "inverted" pair (vstop before vstart, or a +$0000/$0000 terminator) simply parks the comparators on values the beam +has passed or never shows: the terminator decodes to vstart=vstop=0 and +the channel stays silent until the next field's reset. + +DMACON's SPREN is sampled by each DMA slot individually, so a mid-line +edge fetches exactly one word of the pair. SPRxPT advances only on words +actually fetched: a skipped slot leaves the pointer behind and the stream +shifts accordingly. The display line assembles from the fetched words +plus the stale latch on a skipped side; a missed DATA slot never arms the +sprite, and an armed channel with DMA off keeps redisplaying its latches +at the current POS/CTL decode until a CTL fetch or poke disarms it (the +vAmigaTS sprena/sprdis families' vertical bars). + +Because SPRxPT only moves on fetches, a channel that consumed its list +leaves the pointer parked at the DMA frontier past it, and the next +field's replay seeds from that frontier rather than from the stale +last-written value; programs must reload SPRxPT every field (normally +from the Copper) to keep a sprite displayed. The frame-start replay path +replays off-screen DMACON, SPRxPT and SPRxPOS/CTL writes in beam order +before rendering the visible field. The whole per-channel register state +is chip state: save states serialize and restore it directly. A modelling note that catches people out: OCS lo-res with BPU=7 is an overprogrammed mode. Denise still decodes six BPLDAT latches, but Agnus diff --git a/src/bus.rs b/src/bus.rs index e0b114b..30fc0f6 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -307,34 +307,52 @@ pub struct HeldSpriteLine { pub vstop: i32, } +/// Agnus's per-channel sprite DMA state, modelled at the register level the +/// way the chip works: POS/CTL control words are fetched on the channel's +/// vstop line through its two DMA slots, the vertical comparators run off +/// these register copies at each line start (and immediately on CPU/Copper +/// pokes), the DMA flip-flop is cleared on the vstop line even when SPREN +/// is off, and SPRxPT advances only on words actually fetched. #[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] struct DisplaySpriteDmaState { - control: Option, - #[serde(skip, default = "unset_sprite_control_loaded_vpos")] - control_loaded_vpos: i32, - next_ptr: Option, - terminated: bool, - data_dma_active: bool, + /// Channel copies of the SPRxPOS/SPRxCTL register words (from DMA + /// control-word fetches or CPU/Copper writes); the horizontal position + /// and attach decode for emitted lines comes from these. + pos: u16, + ctl: u16, + /// Vertical comparator values. Mostly derived from pos/ctl, but held + /// separately because the vertical-blank reset writes vstop without + /// touching the CTL word. + vstrt: i32, + vstop: i32, + /// The per-sprite DMA flip-flop: set when the line counter passes + /// vstrt, cleared when it passes vstop (even with SPREN off) and in + /// the frame's last line. + dma_enabled: bool, + /// Line whose start-of-line comparator update has already run (the + /// update is applied lazily, catching up over skipped lines). + #[serde(default = "unset_sprite_line_marker")] + comparator_vpos: i32, + /// Display latches from the last assembled line: a skipped slot reuses + /// the stale side, and with DMA off an armed channel keeps displaying + /// this line until a CTL fetch or poke disarms it. last_line: Option, - /// Words the hardware pointer lags behind the line-derived stream - /// position: slots skipped by mid-line SPREN edges never fetch, so - /// every later fetch of the stream reads that many words earlier. - data_word_skew: u32, + /// Display line of the last DATA fetch: with SSCAN2 the hardware + /// fetches on every other line and redisplays in between. + last_data_fetch_vpos: i32, /// DATA word(s) fetched by the sprite's first DMA slot, sampled at that /// slot's beam time, awaiting the second slot to assemble the line. - /// None when the first slot was skipped (or fetched nothing). pending_data: Option<(u16, [u16; 3])>, - /// Line the first slot's evaluation ran for (entry logic + DATA fetch), - /// so the second slot completes rather than re-runs it. - #[serde(skip, default = "unset_sprite_control_loaded_vpos")] + /// Line the first slot's DATA fetch ran for. + #[serde(default = "unset_sprite_line_marker")] pending_line_vpos: i32, - /// Line the per-line entry logic (vstop comparator, descriptor chain) - /// already ran for, keeping it idempotent across the two slots. - #[serde(skip, default = "unset_sprite_control_loaded_vpos")] + /// Line the first slot's evaluation ran for, so the second slot + /// completes the line rather than re-running the entry logic. + #[serde(default = "unset_sprite_line_marker")] entry_line_vpos: i32, } -fn unset_sprite_control_loaded_vpos() -> i32 { +fn unset_sprite_line_marker() -> i32 { i32::MIN } @@ -344,16 +362,46 @@ impl Default for DisplaySpriteDmaState { // zero default would make a fresh state claim its per-line work // already ran when the beam is on line 0. Self { - control: None, - control_loaded_vpos: unset_sprite_control_loaded_vpos(), - next_ptr: None, - terminated: false, - data_dma_active: false, + pos: 0, + ctl: 0, + vstrt: 0, + vstop: 0, + dma_enabled: false, + comparator_vpos: unset_sprite_line_marker(), last_line: None, - data_word_skew: 0, + last_data_fetch_vpos: unset_sprite_line_marker(), pending_data: None, - pending_line_vpos: unset_sprite_control_loaded_vpos(), - entry_line_vpos: unset_sprite_control_loaded_vpos(), + pending_line_vpos: unset_sprite_line_marker(), + entry_line_vpos: unset_sprite_line_marker(), + } + } +} + +impl DisplaySpriteDmaState { + /// A POS word (fetch or poke) replaces the vertical start's low bits, + /// keeping the CTL-supplied high bit. + fn poke_pos(&mut self, value: u16) { + self.pos = value; + self.vstrt = (self.vstrt & 0x100) | i32::from(value >> 8); + } + + /// A CTL word (fetch or poke) supplies vstart's high bit and the whole + /// vstop, and disarms the channel's display latches. + fn poke_ctl(&mut self, value: u16) { + self.ctl = value; + self.vstrt = (self.vstrt & 0x0FF) | (i32::from(value & 0x0004) << 6); + self.vstop = (i32::from(value & 0x0002) << 7) | i32::from(value >> 8); + self.last_line = None; + } + + /// The comparators also fire immediately when a poke lands on the + /// matching line (vstop wins over vstrt when both match). + fn reevaluate_comparators_at(&mut self, beam_y: i32) { + if self.vstrt == beam_y { + self.dma_enabled = true; + } + if self.vstop == beam_y { + self.dma_enabled = false; } } } @@ -502,43 +550,6 @@ impl Default for BitplaneSlotPlanCache { } } -#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] -struct DisplaySpriteControl { - vstart: i32, - vstop: i32, - hstart: i32, - hsub_70ns: bool, - #[serde(skip, default = "unset_sprite_data_vstart")] - data_vstart: i32, - data_base: u32, - next_ptr: u32, - attached: bool, -} - -fn unset_sprite_data_vstart() -> i32 { - i32::MIN -} - -fn register_sprite_data_vstart() -> i32 { - i32::MIN + 1 -} - -impl DisplaySpriteControl { - fn effective_data_vstart(self) -> i32 { - if self.data_vstart == unset_sprite_data_vstart() - || self.data_vstart == register_sprite_data_vstart() - { - self.vstart - } else { - self.data_vstart - } - } - - fn data_origin_is_register_stream(self) -> bool { - self.data_vstart == register_sprite_data_vstart() - } -} - #[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] struct DisplaySpriteLineData { hstart: i32, @@ -664,6 +675,10 @@ pub struct Bus { pub input: InputState, pub poll_stats: PollStats, + /// Host performance telemetry (call/probe/nanosecond counters), not + /// machine state: call chunking legitimately differs across a + /// save-state load, so it is excluded from the serialized layout. + #[serde(skip)] video_pipeline_stats: VideoPipelineStats, /// Set by MMIO writes that should end the current CPU slice after @@ -908,14 +923,15 @@ pub struct Bus { /// frame boundary instead of re-seeding from `denise.sprpt` (the stale last /// write), so a sprite descriptor buffer that software rewrites every field /// is not re-armed from its previous, now-overwritten address before the - /// Copper reloads SPRxPT. Transient render state, rebuilt each frame (and - /// re-derived after a state load), so it is excluded from the serialized - /// layout. - #[serde(skip)] + /// Copper reloads SPRxPT. Captured at each frame start from the live + /// (fetch-advanced) pointers, and serialized: the live pointers move on + /// between the frame start and a mid-frame state save, so it cannot be + /// re-derived at load time. + #[serde(default)] sprite_dma_frame_start_ptr: [u32; 8], - // Derived from sprite DMA descriptor/control fetches. Kept in the bincode - // layout for compatibility, then reset after a state load so stale decoded - // latches are rebuilt from the restored pointer context. + // The register-level sprite DMA state (comparator copies, DMA + // flip-flops, display latches). Chip state: serialized and restored + // exactly by save states. display_dma_sprite_state: [DisplaySpriteDmaState; 8], display_dma_clipped_rows_advanced: bool, bitplane_dmacon_delay: Option, @@ -2541,7 +2557,6 @@ impl Bus { self.last_frame_sprite_display_enable_x_by_y = empty_sprite_display_enable_x_by_y(); self.current_frame_sprite_dma_observed = false; self.last_frame_sprite_dma_observed = false; - self.display_dma_sprite_state = [DisplaySpriteDmaState::default(); 8]; self.current_frame_bus_trace.clear(); self.last_frame_bus_trace = None; } @@ -2614,6 +2629,7 @@ impl Bus { self.last_frame_beam_bottom_palette = Palette::new(); self.last_frame_beam_bottom_palette_valid = false; self.reset_frame_capture_buffers(); + self.display_dma_sprite_state = [DisplaySpriteDmaState::default(); 8]; self.current_frame_display_snapshot_taken = false; self.ocs_same_line_diw_start_blocked_vpos = None; self.current_frame_render_blocked = false; @@ -2763,7 +2779,6 @@ impl Bus { self.ocs_same_line_diw_start_blocked_vpos = None; self.reset_frame_capture_buffers(); self.current_frame_render_blocked = self.agnus.vpos != 0 || self.agnus.hpos != 0; - self.sprite_dma_frame_start_ptr = self.display_dma_sprpt; } pub fn emulated_seconds(&self) -> f64 { @@ -4804,12 +4819,11 @@ impl Bus { let mut s = String::new(); for i in 0..8 { let st = &self.display_dma_sprite_state[i]; - let act = st.data_dma_active as u8; - let term = st.terminated as u8; - let (cvs, cve) = st.control.map(|c| (c.vstart, c.vstop)).unwrap_or((-1, -1)); + let ena = st.dma_enabled as u8; + let armed = st.last_line.is_some() as u8; s.push_str(&format!( - " s{i}[act={act} term={term} ctl_vs={cvs} ctl_ve={cve} nxt={:?}]", - st.next_ptr + " s{i}[ena={ena} armed={armed} vstrt={} vstop={} ptr={:06X}]", + st.vstrt, st.vstop, self.display_dma_sprpt[i] )); } log::info!( diff --git a/src/bus/custom_regs.rs b/src/bus/custom_regs.rs index ac07b29..d1dc2f8 100644 --- a/src/bus/custom_regs.rs +++ b/src/bus/custom_regs.rs @@ -1093,9 +1093,6 @@ impl Bus { ); } self.display_dma_sprpt[idx] = self.denise.sprpt[idx]; - if off & 2 != 0 { - self.apply_display_sprite_pointer_low_write(idx); - } } false } diff --git a/src/bus/dma_slots.rs b/src/bus/dma_slots.rs index 563fe01..e4ad07a 100644 --- a/src/bus/dma_slots.rs +++ b/src/bus/dma_slots.rs @@ -586,6 +586,9 @@ impl Bus { if self.agnus.dmacon & DMACON_SPREN == 0 { return false; } + if self.sprite_dma_inhibited_by_vertical_blank_at(self.agnus.vpos) { + return false; + } // Sprite DMA slots sit on ODD color clocks (same parity as refresh/ // disk/audio -- the HRM chart's fixed-DMA band), so they never block // the Copper's even-clock fetches. @@ -596,7 +599,10 @@ impl Bus { if sprite >= 8 { return false; } - self.display_dma_sprite_state[sprite].data_dma_active + // A channel uses its slots when it is fetching data this line, or on + // its vstop line where the slots fetch the next POS/CTL control word. + let state = &self.display_dma_sprite_state[sprite]; + state.dma_enabled || self.agnus.vpos as i32 == state.vstop } pub(super) fn record_bitplane_dmacon_write(&mut self, previous: u16) { diff --git a/src/bus/frame_capture.rs b/src/bus/frame_capture.rs index 1b3c940..7b501b7 100644 --- a/src/bus/frame_capture.rs +++ b/src/bus/frame_capture.rs @@ -8,23 +8,6 @@ use super::*; -/// Which of a sprite pair's two DMA slots a `sprite_line_eval` call models. -#[derive(Clone, Copy)] -enum SpriteSlotPhase { - /// Both words in one call: the pre-display replay path, and the second - /// slot's fallback when the first slot did not run (its SPREN sample - /// was low or the stream was re-seeded between the slots). - Pair { - slot1_enabled: bool, - slot2_enabled: bool, - }, - /// First hardware slot ($15+4N): entry logic plus the DATA fetch, - /// sampled at this slot's beam time. - Slot1, - /// Second hardware slot ($17+4N): DATB fetch and line assembly. - Slot2 { slot2_enabled: bool }, -} - impl Bus { pub(super) fn begin_new_beam_frame(&mut self) { self.diag_log_frame_start(); @@ -137,26 +120,18 @@ impl Bus { self.current_frame_visible_start_vpos = RENDER_MIN_OVERSCAN_START_VPOS; self.current_frame_render_base = self.capture_render_snapshot(); // Carry each sprite channel's DMA pointer across the frame boundary the - // way real Agnus does. A channel that finished the field (read its - // terminating descriptor, so `control` is cleared) leaves SPRxPT parked - // past the consumed list at its DMA frontier; it does NOT snap back to - // the last value the Copper/CPU wrote into `denise.sprpt`. Seed the next - // frame's replay from that frontier so a reused descriptor buffer that - // software rewrites every field is not re-armed from its previous, - // now-overwritten address before the Copper reloads SPRxPT. Channels - // still mid-descriptor at frame end keep the written pointer. - for sprite in 0..8 { - let state = &self.display_dma_sprite_state[sprite]; - self.sprite_dma_frame_start_ptr[sprite] = match (state.control, state.next_ptr) { - (None, Some(frontier)) => frontier, - _ => self.denise.sprpt[sprite], - }; - } + // way real Agnus does: SPRxPT advances only on fetched words, so the + // live pointer already sits at the channel's DMA frontier. It does NOT + // snap back to the last value the Copper/CPU wrote into `denise.sprpt`, + // so a reused control-word buffer that software rewrites every field is + // not re-read from its previous, now-overwritten address before the + // Copper reloads SPRxPT. + self.sprite_dma_frame_start_ptr = self.display_dma_sprpt; self.current_frame_collision_may_have_dual_playfield = self.current_frame_render_base.bplcon0 & 0x0400 != 0; self.display_dma_bplpt = self.denise.bplpt; self.display_dma_sprpt = self.denise.sprpt; - self.display_dma_sprite_state = [DisplaySpriteDmaState::default(); 8]; + self.reset_display_sprite_dma_states(); self.display_dma_clipped_rows_advanced = false; self.lazy_collision_vpos = self.current_frame_visible_start_vpos; self.lazy_collision_hpos = RENDER_COPPER_WAIT_HPOS_FB0; @@ -203,11 +178,29 @@ impl Bus { } } + /// Reset the per-frame transients of every sprite channel's DMA state. + /// The POS/CTL register copies and the vertical comparator values are + /// chip registers and carry across the field; the DMA flip-flop is + /// cleared in the frame's last line, and the display latches restart + /// with the field's replay. + pub(super) fn reset_display_sprite_dma_states(&mut self) { + for state in &mut self.display_dma_sprite_state { + *state = DisplaySpriteDmaState { + pos: state.pos, + ctl: state.ctl, + vstrt: state.vstrt, + vstop: state.vstop, + ..DisplaySpriteDmaState::default() + }; + } + } + /// After the offscreen sprite-DMA replay, snapshot any sprite that has /// fetched data but whose DMA is now disabled (SPREN cleared): it is being /// "held" and will be repainted by Copper SPRxPOS repositioning across the /// visible window. The renderer's manual-sprite path consumes these (it can - /// clip each repositioned segment); the bus bar path is suppressed for them. + /// clip each repositioned segment); the bus stale-latch path is suppressed + /// for them. pub(super) fn capture_held_sprites_for_visible_window(&mut self) { self.current_frame_held_sprites = [None; 8]; if self.agnus.dmacon & (DMACON_DMAEN | DMACON_SPREN) == (DMACON_DMAEN | DMACON_SPREN) { @@ -216,14 +209,13 @@ impl Bus { } for sprite in 0..8 { let state = self.display_dma_sprite_state[sprite]; - if !state.data_dma_active { - continue; - } let Some(line_data) = state.last_line else { continue; }; - let Some(control) = state.control else { - continue; + let vstop = if state.vstop <= state.vstrt { + self.agnus.current_frame_lines() as i32 + } else { + state.vstop }; self.current_frame_held_sprites[sprite] = Some(HeldSpriteLine { line: CapturedSpriteLine { @@ -238,163 +230,18 @@ impl Bus { width_words: line_data.width_words, attached: line_data.attached, }, - vstart: control.vstart, - vstop: control.vstop, + vstart: state.vstrt, + vstop, }); } } - pub(super) fn apply_display_sprite_pointer_low_write(&mut self, sprite: usize) { - self.apply_display_sprite_pointer_low_write_at(sprite, self.agnus.vpos, self.agnus.hpos); - } - - pub(super) fn apply_display_sprite_pointer_low_write_at( - &mut self, - sprite: usize, - vpos: u32, - hpos: u32, - ) { - self.apply_display_sprite_pointer_low_write_at_with_dmacon( - sprite, - vpos, - hpos, - self.agnus.dmacon, - ); - } - - pub(super) fn apply_display_sprite_pointer_low_write_at_with_dmacon( - &mut self, - sprite: usize, - vpos: u32, - hpos: u32, - dmacon: u16, - ) { - if sprite >= 8 { - return; - } - let state = self.display_dma_sprite_state[sprite]; - if let Some(control) = state.control { - let pending_descriptor_not_loaded = state.control_loaded_vpos >= vpos as i32 - || (state.control_loaded_vpos == unset_sprite_control_loaded_vpos() - && (vpos as i32) < control.vstart); - if !state.data_dma_active - && !control.data_origin_is_register_stream() - // The descriptor must have loaded earlier in this field before - // SPRxPT can retarget its data stream. Equal/later loads or an - // unknown save-state value before VSTART restart POS/CTL. - && pending_descriptor_not_loaded - { - self.display_dma_sprite_state[sprite] = DisplaySpriteDmaState::default(); - return; - } - } else if self - .armed_sprite_pointer_write_can_seed_register_data_stream(sprite, vpos, hpos, dmacon) - { - self.latch_display_sprite_register_data_stream_at(sprite, vpos, hpos); - return; - } - self.retarget_display_sprite_dma_pointer_at(sprite, vpos, hpos); - } - - pub(super) fn armed_sprite_pointer_write_can_seed_register_data_stream( - &self, - sprite: usize, - vpos: u32, - hpos: u32, - dmacon: u16, - ) -> bool { - if !self.denise.spr_armed[sprite] - || dmacon & (DMACON_DMAEN | DMACON_SPREN) != (DMACON_DMAEN | DMACON_SPREN) - { - return false; - } - - // The transient Agnus descriptor latch is not always available to this - // replay path (notably after save-state load), but Denise may still - // expose an armed sprite word plus retained POS/CTL comparators. Only - // use that as a data-stream fallback once the beam is in the rendered - // display area and the channel's descriptor fetch slot for this line - // has already passed. Frame-start/top-border SPRxPT reloads are normal - // descriptor setup and must not resurrect stale armed register data. - if !display_window_contains_vpos( - self.denise.diwstrt, - self.denise.diwstop, - self.effective_diwhigh(), - vpos, - ) { - return false; - } - - let beam_y = vpos as i32; - let vstart = - sprite_vstart_from_words(self.denise.sprpos[sprite], self.denise.sprctl[sprite]); - let raw_vstop = sprite_vstop_from_ctl(self.denise.sprctl[sprite]); - let vstop = if raw_vstop < vstart { - self.agnus.current_frame_lines() as i32 - } else { - raw_vstop - }; - vpos >= self.current_frame_visible_start_vpos - && beam_y >= vstart - && beam_y < vstop - && hpos >= SPRITE_DMA_SLOT1_HPOS[sprite] - } - - pub(super) fn latch_display_sprite_register_data_stream_at( - &mut self, - sprite: usize, - vpos: u32, - hpos: u32, - ) { - let frame_lines = self.agnus.current_frame_lines() as i32; - let beam_y = vpos as i32; - if beam_y >= frame_lines { - return; - } - let fetch_slot = SPRITE_DMA_SLOT1_HPOS[sprite]; - let stream_start = if hpos >= fetch_slot { - beam_y.saturating_add(1) - } else { - beam_y - }; - if stream_start >= frame_lines { - return; - } - - let quantum = sprite_fetch_quantum(self.agnus.fmode()); - let line_bytes = 4 * quantum; - let data_lines = (frame_lines - stream_start).max(0) as u32; - let data_base = self.display_dma_sprpt[sprite] & self.chip_dma_mask & !1; - let control = DisplaySpriteControl { - vstart: stream_start, - vstop: frame_lines, - hstart: sprite_hstart_from_words( - self.denise.sprpos[sprite], - self.denise.sprctl[sprite], - ), - hsub_70ns: bitplane_shres(self.denise.bplcon0) - && sprite_hsub_70ns_from_ctl(self.denise.sprctl[sprite]), - data_vstart: register_sprite_data_vstart(), - data_base, - next_ptr: data_base.wrapping_add(data_lines.saturating_mul(line_bytes)) - & self.chip_dma_mask - & !1, - attached: self.denise.sprctl[sprite] & 0x0080 != 0, - }; - self.display_dma_sprite_state[sprite] = DisplaySpriteDmaState { - control: Some(control), - control_loaded_vpos: stream_start, - next_ptr: Some(control.next_ptr), - terminated: false, - data_dma_active: false, - last_line: None, - data_word_skew: 0, - pending_data: None, - pending_line_vpos: i32::MIN, - entry_line_vpos: i32::MIN, - }; - } - + /// A CPU/Copper write to SPRxPOS or SPRxCTL pokes Agnus's channel + /// register copies the same way a DMA control-word fetch does: POS + /// retimes the horizontal/vertical-start bits without touching the data + /// stream, CTL sets both vertical comparators and disarms the display + /// latches, and the comparators fire immediately when the write lands + /// on a matching line. pub(super) fn latch_display_sprite_dma_control_from_registers( &mut self, sprite: usize, @@ -403,226 +250,37 @@ impl Bus { if sprite >= 8 { return; } - self.latch_display_sprite_dma_control_from_words_at( + self.poke_display_sprite_control_at( sprite, - self.denise.sprpos[sprite], - self.denise.sprctl[sprite], - self.agnus.vpos, - self.agnus.hpos, - self.agnus.dmacon, + match write { + SpriteControlRegisterWrite::Pos => self.denise.sprpos[sprite], + SpriteControlRegisterWrite::Ctl => self.denise.sprctl[sprite], + }, write, + self.agnus.vpos, ); } - pub(super) fn latch_display_sprite_dma_control_from_words_at( + pub(super) fn poke_display_sprite_control_at( &mut self, sprite: usize, - pos: u16, - ctl: u16, - vpos: u32, - hpos: u32, - dmacon: u16, + value: u16, write: SpriteControlRegisterWrite, - ) { - if sprite >= 8 { - return; - } - - let mut state = self.display_dma_sprite_state[sprite]; - let previous_control = state.control; - let beam_y = vpos as i32; - - if matches!(write, SpriteControlRegisterWrite::Pos) - && state.data_dma_active - && previous_control - .map(|previous| beam_y < previous.vstop) - .unwrap_or(false) - { - if let Some(mut control) = previous_control { - // SPRxPOS retimes the horizontal comparator, but it does not - // re-fetch POS/CTL or cancel an already-enabled sprite DMA - // stream. Keep the DMA descriptor's stop/data origin latched; - // the HSTART low bit still comes from the previously latched - // CTL word. - control.hstart = (((pos & 0x00FF) << 1) as i32) | (control.hstart & 1); - state.control = Some(control); - state.next_ptr = Some(control.next_ptr); - state.terminated = false; - state.data_dma_active = true; - self.display_dma_sprite_state[sprite] = state; - return; - } - } - - let vstart = sprite_vstart_from_words(pos, ctl); - let raw_vstop = sprite_vstop_from_ctl(ctl); - let vstop = if raw_vstop < vstart { - self.agnus.current_frame_lines() as i32 - } else { - raw_vstop - }; - let height = vstop - vstart; - if height <= 0 { - self.display_dma_sprite_state[sprite] = DisplaySpriteDmaState::default(); - return; - } - - let quantum = sprite_fetch_quantum(self.agnus.fmode()); - let line_bytes = 4 * quantum; - let data_lines = if sprite_scan_doubled(self.agnus.fmode()) { - (height as u32).div_ceil(2) - } else { - height as u32 - }; - let data_base = self.display_dma_sprpt[sprite] & self.chip_dma_mask & !1; - let mut control = DisplaySpriteControl { - vstart, - vstop, - hstart: sprite_hstart_from_words(pos, ctl), - hsub_70ns: bitplane_shres(self.denise.bplcon0) && sprite_hsub_70ns_from_ctl(ctl), - data_vstart: register_sprite_data_vstart(), - data_base, - next_ptr: data_base.wrapping_add(data_lines.saturating_mul(line_bytes)) - & self.chip_dma_mask - & !1, - attached: ctl & 0x0080 != 0, - }; - - let in_window = beam_y >= control.vstart && beam_y < control.vstop; - let sprite_dma_enabled = - dmacon & (DMACON_DMAEN | DMACON_SPREN) == (DMACON_DMAEN | DMACON_SPREN); - let reaches_current_fetch_slot = - beam_y == control.vstart && hpos <= SPRITE_DMA_SLOT1_HPOS[sprite] && sprite_dma_enabled; - let keep_held_line = - !sprite_dma_enabled && in_window && state.data_dma_active && state.last_line.is_some(); - let keep_active_dma_line = - sprite_dma_enabled && in_window && state.data_dma_active && state.last_line.is_some(); - let keep_pending_dma_origin = sprite_dma_enabled - && !state.data_dma_active - && state.last_line.is_none() - && previous_control - .map(|previous| beam_y < previous.vstop) - .unwrap_or(false); - - if keep_held_line || keep_active_dma_line { - if let Some(previous_control) = previous_control { - control.data_vstart = previous_control.effective_data_vstart(); - control.data_base = previous_control.data_base; - control.next_ptr = previous_control.next_ptr; - } - } else if keep_pending_dma_origin { - if let Some(previous_control) = previous_control { - // A pending descriptor has already consumed POS/CTL; direct - // control writes retime the comparators, not the data stream. - control.data_vstart = previous_control.data_vstart; - control.data_base = previous_control.data_base; - control.next_ptr = control - .data_base - .wrapping_add(data_lines.saturating_mul(line_bytes)) - & self.chip_dma_mask - & !1; - } - } - - state.control = Some(control); - state.control_loaded_vpos = beam_y; - state.next_ptr = Some(control.next_ptr); - state.terminated = false; - state.data_word_skew = 0; - state.pending_data = None; - state.pending_line_vpos = unset_sprite_control_loaded_vpos(); - state.entry_line_vpos = unset_sprite_control_loaded_vpos(); - state.data_dma_active = - in_window && (reaches_current_fetch_slot || keep_held_line || keep_active_dma_line); - if !keep_held_line && !keep_active_dma_line { - state.last_line = None; - } - self.display_dma_sprite_state[sprite] = state; - } - - pub(super) fn retarget_display_sprite_dma_pointer_at( - &mut self, - sprite: usize, vpos: u32, - hpos: u32, ) { if sprite >= 8 { return; } - - let mut state = self.display_dma_sprite_state[sprite]; - let Some(mut control) = state.control else { - self.display_dma_sprite_state[sprite] = DisplaySpriteDmaState::default(); - return; - }; - let beam_y = vpos as i32; - if beam_y >= control.vstop { - self.display_dma_sprite_state[sprite] = DisplaySpriteDmaState::default(); - return; + let state = &mut self.display_dma_sprite_state[sprite]; + match write { + SpriteControlRegisterWrite::Pos => state.poke_pos(value), + SpriteControlRegisterWrite::Ctl => state.poke_ctl(value), } - - let quantum = sprite_fetch_quantum(self.agnus.fmode()); - let line_bytes = 4 * quantum; - let mut line = if beam_y <= control.vstart { - 0 - } else { - (beam_y - control.vstart) as u32 - }; - if beam_y >= control.vstart && hpos > SPRITE_DMA_SLOT1_HPOS[sprite] { - line = line.saturating_add(1); - } - let line = if sprite_scan_doubled(self.agnus.fmode()) { - line.div_ceil(2) - } else { - line - }; - - let ptr = self.display_dma_sprpt[sprite] & self.chip_dma_mask & !1; - control.data_base = - ptr.wrapping_sub(line.saturating_mul(line_bytes)) & self.chip_dma_mask & !1; - control.data_vstart = if control.data_origin_is_register_stream() { - register_sprite_data_vstart() - } else { - control.vstart - }; - let height = (control.vstop - control.vstart).max(0) as u32; - let data_lines = if sprite_scan_doubled(self.agnus.fmode()) { - height.div_ceil(2) - } else { - height - }; - control.next_ptr = control - .data_base - .wrapping_add(data_lines.saturating_mul(line_bytes)) - & self.chip_dma_mask - & !1; - state.control = Some(control); - state.next_ptr = Some(control.next_ptr); - state.terminated = false; - state.data_word_skew = 0; - state.pending_data = None; - state.pending_line_vpos = unset_sprite_control_loaded_vpos(); - state.entry_line_vpos = unset_sprite_control_loaded_vpos(); - state.data_dma_active = beam_y >= control.vstart && beam_y < control.vstop; - state.last_line = None; - self.display_dma_sprite_state[sprite] = state; - - if diag_sprcap().is_some() { - log::info!( - "sprptr f={} v={} h={} s{} ptr={:06X} vstart={} vstop={} hstart={} line={} data_base={:06X} next={:06X}", - self.emulated_frames, - vpos, - hpos, - sprite, - ptr, - control.vstart, - control.vstop, - control.hstart, - line, - control.data_base, - control.next_ptr - ); + // TODO: near the end of a line the hardware comparator already sees + // the next line's counter (vAmiga applies pos.v+1 from cck $E1). + if !self.sprite_dma_inhibited_by_vertical_blank_at(vpos) { + self.display_dma_sprite_state[sprite].reevaluate_comparators_at(beam_y); } } @@ -746,14 +404,15 @@ impl Bus { // sprite lines along the way. Crucially, SPREN can be toggled within the // frame -- software may enable sprite DMA only briefly off-screen to load // reused sprites, then clear it before the visible window and reposition - // the held sprites per line. Replay this frame's DMACON and SPRxPT writes - // across the span and run the sprite fetch only on lines where SPREN was - // actually enabled, rather than sampling registers at the display start. + // the held sprites per line. Replay this frame's DMACON, SPRxPT and + // SPRxPOS/CTL writes across the span and run the sprite slots only on + // lines where SPREN was actually enabled, rather than sampling registers + // at the display start. let base = self.current_frame_render_base; // Seed from the previous field's carried SPRxPT frontier rather than the // last Copper/CPU write captured in `base.sprpt`. See - // `sprite_dma_frame_start_ptr` for why finished channels must not snap - // back to their stale descriptor address. + // `sprite_dma_frame_start_ptr` for why channels must not snap back to + // their stale control-word address. self.current_frame_sprite_lines .retain(|line| line.beam_y >= display_start as i32); for lines in &mut self.current_frame_sprite_lines_by_y { @@ -763,43 +422,51 @@ impl Bus { self.current_frame_sprite_display_enable_x_by_y = empty_sprite_display_enable_x_by_y(); self.current_frame_sprite_dma_observed = !self.current_frame_sprite_lines.is_empty(); self.display_dma_sprpt = self.sprite_dma_frame_start_ptr; - self.display_dma_sprite_state = [DisplaySpriteDmaState::default(); 8]; + self.reset_display_sprite_dma_states(); let mut dmacon = base.dmacon; let writes: Vec<(u32, u32, u16, u16)> = self .current_render_events() .iter() .filter(|w| { let off = w.offset & 0x01FE; - w.vpos < display_start && (off == 0x096 || (0x120..=0x13F).contains(&off)) + w.vpos < display_start + && (off == 0x096 + || (0x120..=0x13F).contains(&off) + || (0x140..=0x17F).contains(&off)) }) .map(|w| (w.vpos, w.hpos, w.offset & 0x01FE, w.value)) .collect(); let mut idx = 0; for vpos in 0..display_start { - for (sprite, &capture_hpos) in SPRITE_DMA_SLOT1_HPOS.iter().enumerate() { - while idx < writes.len() - && (writes[idx].0 < vpos - || (writes[idx].0 == vpos && writes[idx].1 < capture_hpos)) - { - let (event_vpos, event_hpos, offset, value) = writes[idx]; - self.apply_sprite_dma_replay_write( - offset, - value, - event_vpos, - event_hpos, - &mut dmacon, - ); - idx += 1; - } + for (sprite, &slot1_hpos) in SPRITE_DMA_SLOT1_HPOS.iter().enumerate() { + for slot_hpos in [slot1_hpos, slot1_hpos + 2] { + while idx < writes.len() + && (writes[idx].0 < vpos + || (writes[idx].0 == vpos && writes[idx].1 < slot_hpos)) + { + let (event_vpos, event_hpos, offset, value) = writes[idx]; + self.apply_sprite_dma_replay_write( + offset, + value, + event_vpos, + event_hpos, + &mut dmacon, + ); + idx += 1; + } - if dmacon & (DMACON_DMAEN | DMACON_SPREN) != (DMACON_DMAEN | DMACON_SPREN) { - continue; - } - if self.sprite_dma_inhibited_by_vertical_blank_at(vpos) { - continue; - } - { - if sprite_dma_disabled_by_bitplane_ddf( + // The start-of-line comparator update runs on every line, + // even where the vertical blank or DDF conflicts inhibit + // the DMA slots themselves. + if slot_hpos == slot1_hpos { + self.sprite_comparators_catch_up(sprite, vpos, dmacon); + } + if self.sprite_dma_inhibited_by_vertical_blank_at(vpos) { + continue; + } + let spren = + dmacon & (DMACON_DMAEN | DMACON_SPREN) == (DMACON_DMAEN | DMACON_SPREN); + let ddf_blocked = sprite_dma_disabled_by_bitplane_ddf( sprite, self.agnus.revision(), self.effective_bitplane_bplcon0(), @@ -808,10 +475,15 @@ impl Bus { self.denise.ddfstrt, self.denise.ddfstop, self.harddis_active(), - ) { - continue; + ); + if slot_hpos == slot1_hpos { + self.sprite_slot1(sprite, vpos, spren, ddf_blocked); + } else { + // Off-screen lines evolve the channel state only; + // the visible field's capture starts at the display + // start. + let _ = self.sprite_slot2(sprite, vpos, spren, ddf_blocked); } - let _ = self.captured_sprite_line_at(sprite, vpos); } } while idx < writes.len() && writes[idx].0 == vpos { @@ -833,7 +505,7 @@ impl Bus { offset: u16, value: u16, vpos: u32, - hpos: u32, + _hpos: u32, dmacon: &mut u16, ) { if offset == 0x096 { @@ -845,6 +517,26 @@ impl Bus { return; } + if (0x140..=0x17F).contains(&offset) { + let idx = ((offset - 0x140) / 8) as usize; + match (offset - 0x140) & 0x0006 { + 0x0 => self.poke_display_sprite_control_at( + idx, + value, + SpriteControlRegisterWrite::Pos, + vpos, + ), + 0x2 => self.poke_display_sprite_control_at( + idx, + value, + SpriteControlRegisterWrite::Ctl, + vpos, + ), + _ => {} + } + return; + } + let idx = ((offset - 0x120) / 4) as usize; if idx >= 8 { return; @@ -855,7 +547,6 @@ impl Bus { } else { let cur = self.display_dma_sprpt[idx]; self.display_dma_sprpt[idx] = (cur & 0x00FF_0000) | (value as u32 & 0xFFFE); - self.apply_display_sprite_pointer_low_write_at_with_dmacon(idx, vpos, hpos, *dmacon); } } @@ -876,13 +567,7 @@ impl Bus { if old_hpos > SPRITE_DMA_SLOT1_HPOS[7] + 2 || new_hpos <= SPRITE_DMA_SLOT1_HPOS[0] { return; } - if self.sprite_dma_inhibited_by_vertical_blank_at(vpos) { - return; - } - let sprite_vertical_bar_active = self - .display_dma_sprite_state - .iter() - .any(|state| state.data_dma_active && state.last_line.is_some()); + let vblank_inhibited = self.sprite_dma_inhibited_by_vertical_blank_at(vpos); let Some(fb_y) = visible_framebuffer_y( vpos, self.current_frame_visible_start_vpos, @@ -928,57 +613,40 @@ impl Bus { self.denise.ddfstop, self.harddis_active(), ); - if ddf_blocked { - continue; - } if (old_hpos..new_hpos).contains(&slot1_hpos) { - let slot1_enabled = - spren_at(self.effective_bitplane_dmacon_at(slot_cck(slot1_hpos))); - if slot1_enabled { - self.sprite_slot1_fetch(sprite, vpos); + let dmacon_now = self.effective_bitplane_dmacon_at(slot_cck(slot1_hpos)); + // The start-of-line comparator update runs on every line, + // even where vertical blank inhibits the DMA slots. + self.sprite_comparators_catch_up(sprite, vpos, dmacon_now); + if !vblank_inhibited { + self.sprite_slot1(sprite, vpos, spren_at(dmacon_now), ddf_blocked); } } if !(old_hpos..new_hpos).contains(&slot2_hpos) { continue; } - let slot2_enabled = spren_at(self.effective_bitplane_dmacon_at(slot_cck(slot2_hpos))); - let slot1_ran = self.display_dma_sprite_state[sprite].entry_line_vpos == vpos as i32; - if !slot1_ran && !slot2_enabled && !sprite_vertical_bar_active { + let dmacon_now = self.effective_bitplane_dmacon_at(slot_cck(slot2_hpos)); + self.sprite_comparators_catch_up(sprite, vpos, dmacon_now); + if vblank_inhibited { continue; } - if slot1_ran || slot2_enabled { + let slot2_enabled = spren_at(dmacon_now); + if slot2_enabled || self.display_dma_sprite_state[sprite].entry_line_vpos == vpos as i32 + { pair_slots += 1; } let mut captured_line = false; { - let line = if slot1_ran { - self.sprite_line_eval(sprite, vpos, SpriteSlotPhase::Slot2 { slot2_enabled }) - } else if slot2_enabled { - // The first slot did not run (SPREN low there, or the - // stream was re-seeded between the slots): the entry - // logic runs here, with the DATA side counted as a - // skipped slot. - self.sprite_line_eval( - sprite, - vpos, - SpriteSlotPhase::Pair { - slot1_enabled: false, - slot2_enabled: true, - }, - ) - } else { - self.captured_sprite_vertical_bar_line_at(sprite, vpos) - }; + let line = self.sprite_slot2(sprite, vpos, slot2_enabled, ddf_blocked); if let Some(line) = line { // COPPERLINE_DIAG_SPRCAP=BEAMY|all: log captured sprite // lines on that beam line (frame, channel, position, words, - // and the chip-RAM descriptor/data addresses they fetched). + // and the chip-RAM data addresses they fetched). if let Some(want) = diag_sprcap() { if diag_sprcap_matches(want, line.beam_y) { let st = &self.display_dma_sprite_state[sprite]; - let ctl = st.control; log::info!( - "sprcap f={} s{} y={} hstart={} hsub={} att={} w={} A={:04X} {:04X?} B={:04X} {:04X?} ctl=({},{},{},{:06X}) data_base={:06X} next={:06X?}", + "sprcap f={} s{} y={} hstart={} hsub={} att={} w={} A={:04X} {:04X?} B={:04X} {:04X?} vstrt={} vstop={} ena={} ptr={:06X}", self.emulated_frames, line.sprite, line.beam_y, @@ -990,12 +658,10 @@ impl Bus { line.data_ext, line.datb, line.datb_ext, - ctl.map(|c| c.vstart).unwrap_or(-1), - ctl.map(|c| c.vstop).unwrap_or(-1), - ctl.map(|c| c.effective_data_vstart()).unwrap_or(-1), - ctl.map(|c| c.next_ptr).unwrap_or(0), - st.control.map(|c| c.data_base).unwrap_or(0), - st.next_ptr + st.vstrt, + st.vstop, + u8::from(st.dma_enabled), + self.display_dma_sprpt[sprite], ); } } @@ -1031,381 +697,144 @@ impl Bus { } } + /// Run one full sprite line (both slots, DMA on) at a line the beam is + /// not sweeping: the tests' stand-in for the pre-display replay. + #[cfg(test)] pub(super) fn captured_sprite_line_at( &mut self, sprite: usize, vpos: u32, ) -> Option { - self.sprite_line_eval( - sprite, - vpos, - SpriteSlotPhase::Pair { - slot1_enabled: true, - slot2_enabled: true, - }, - ) - } - - /// First hardware sprite slot ($15+4N): runs the line's entry logic - /// (vstop comparator, descriptor chain, arming) and samples the DATA - /// word(s) at this slot's beam time into pending state. The line itself - /// is assembled and emitted by the second slot's call. - pub(super) fn sprite_slot1_fetch(&mut self, sprite: usize, vpos: u32) { - let _ = self.sprite_line_eval(sprite, vpos, SpriteSlotPhase::Slot1); - } - - /// One scanline of a sprite's DMA: the first slot ($15+4N) fetches POS - /// or DATA, the second ($17+4N) CTL or DATB. A mid-line SPREN edge can - /// enable one slot and not the other; a skipped slot leaves the word - /// counter behind (the stream shifts) and the display line assembles - /// from the stale latch on that side. A DATA fetch arms the sprite; - /// DATB alone does not. - fn sprite_line_eval( - &mut self, - sprite: usize, - vpos: u32, - phase: SpriteSlotPhase, - ) -> Option { - let ram_len = self.mem.chip_ram.len(); - if ram_len == 0 { + self.sprite_comparators_catch_up(sprite, vpos, self.agnus.dmacon); + if self.sprite_dma_inhibited_by_vertical_blank_at(vpos) { return None; } - let ram_mask = self.chip_dma_mask; + self.sprite_slot1(sprite, vpos, true, false); + self.sprite_slot2(sprite, vpos, true, false) + } + + /// Lazily run the start-of-line comparator update for every line since + /// the last evaluated one (vAmiga updateSpriteDMA): the vertical-blank + /// reset forces vstop to the first post-blank line -- which is what + /// schedules each field's first POS/CTL control-word fetch -- the DMA + /// flip-flop clears in the frame's last line, and outside the blank the + /// vstrt/vstop comparators set/clear it (clear winning on a tie). + pub(super) fn sprite_comparators_catch_up(&mut self, sprite: usize, vpos: u32, dmacon: u16) { let beam_y = vpos as i32; - if let SpriteSlotPhase::Slot2 { slot2_enabled } = phase { - return self.sprite_slot2_complete(sprite, beam_y, ram_mask, slot2_enabled); - } let mut state = self.display_dma_sprite_state[sprite]; - if matches!(phase, SpriteSlotPhase::Slot1) { - // Mark the entry logic as run for this line so the second - // slot's call completes it instead of re-running descriptor - // work, and drop any pending words a broken-off line left. - state.entry_line_vpos = beam_y; - state.pending_data = None; - state.pending_line_vpos = unset_sprite_control_loaded_vpos(); - } - let mut descriptor_can_match_current_vstart = - state.control.is_some() || state.next_ptr.is_none(); - let mut descriptor_loaded_after_stop_this_line = false; - - // Loop-detection scratch for the descriptor chain below. A plain - // Vec with a linear `contains` check is used instead of a HashSet: - // chains are almost always 1-2 descriptors long in practice (this - // runs per active sprite per DMA-pair capture point per scanline), - // so a linear scan avoids both the heap allocation pattern and the - // hashing overhead a HashSet would pay for such a tiny set, while - // keeping the exact same "any previously-visited pointer" cycle - // check (not just a fixed lookback window). - let mut visited_descriptor_ptrs: Vec = Vec::new(); - loop { - if state.terminated { - self.display_dma_sprite_state[sprite] = state; - return None; + if state.comparator_vpos >= beam_y { + return; + } + let reset_line = sprite_dma_first_active_vpos(self.agnus.video_standard()) as i32; + let last_line = self.agnus.current_frame_lines() as i32 - 1; + // TODO: SPREN is sampled once for the whole caught-up span; only the + // vertical-blank reset line consumes it, and the live/replay paths + // call this every line, so the approximation only matters when a + // DMACON edge lands inside a multi-line catch-up across the blank. + let spren = dmacon & (DMACON_DMAEN | DMACON_SPREN) == (DMACON_DMAEN | DMACON_SPREN); + for v in (state.comparator_vpos.max(-1) + 1)..=beam_y { + if v == reset_line && spren { + state.vstop = v; + continue; } - - if let Some(control) = state.control { - if beam_y >= control.vstop { - // The next descriptor is fetched from the pointer's - // actual position: slots skipped by SPREN edges leave - // it short of the precomputed stream end. - state.next_ptr = Some( - control.next_ptr.wrapping_sub(state.data_word_skew * 2) & ram_mask & !1, - ); - state.control = None; - state.data_dma_active = false; - state.last_line = None; - descriptor_can_match_current_vstart = false; - descriptor_loaded_after_stop_this_line = true; - } else if !state.data_dma_active { - if beam_y == control.vstart { - state.data_dma_active = true; - } else { - self.display_dma_sprite_state[sprite] = state; - return None; - } + if v == last_line { + state.dma_enabled = false; + continue; + } + if v > reset_line { + if v == state.vstrt { + state.dma_enabled = true; } - - if let Some(control) = state.control { - if !state.data_dma_active { - self.display_dma_sprite_state[sprite] = state; - return None; - } - let quantum = sprite_fetch_quantum(self.agnus.fmode()); - // SSCAN2 fetches sprite data only on every second display - // line; the in-between line redisplays the same data. - if sprite_scan_doubled(self.agnus.fmode()) - && (beam_y - control.effective_data_vstart()) & 1 == 1 - { - let line_data = state.last_line; - self.display_dma_sprite_state[sprite] = state; - if matches!(phase, SpriteSlotPhase::Slot1) { - // The repeat line is emitted by the second - // slot's call. - return None; - } - return line_data.map(|line_data| CapturedSpriteLine { - sprite, - hstart: line_data.hstart, - hsub_70ns: line_data.hsub_70ns, - beam_y, - data: line_data.data, - datb: line_data.datb, - data_ext: line_data.data_ext, - datb_ext: line_data.datb_ext, - width_words: line_data.width_words, - attached: line_data.attached, - }); - } - // The stream address follows the hardware pointer: the - // line-derived position minus the words that skipped - // slots never fetched. - let mut line = (beam_y - control.effective_data_vstart()) as u32; - if sprite_scan_doubled(self.agnus.fmode()) { - line /= 2; - } - let line_base = control - .data_base - .wrapping_add(line.saturating_mul(4 * quantum)); - let fetch_words = |state: &DisplaySpriteDmaState, word_off: u32| { - let ptr = line_base - .wrapping_add(word_off * 2) - .wrapping_sub(state.data_word_skew * 2) - & ram_mask - & !1; - let mut words = [0u16; 4]; - for (w, word) in words.iter_mut().enumerate().take(quantum as usize) { - *word = read_chip_word_wrapping( - &self.mem.chip_ram, - ptr.wrapping_add(2 * w as u32), - ); - } - words - }; - let (slot1_enabled, slot2_enabled) = match phase { - SpriteSlotPhase::Pair { - slot1_enabled, - slot2_enabled, - } => (slot1_enabled, slot2_enabled), - SpriteSlotPhase::Slot1 => { - // DATA is sampled at this slot's beam time; the - // second slot's call assembles the line. - let d = fetch_words(&state, 0); - state.pending_data = Some((d[0], [d[1], d[2], d[3]])); - state.pending_line_vpos = beam_y; - self.display_dma_sprite_state[sprite] = state; - return None; - } - SpriteSlotPhase::Slot2 { .. } => { - unreachable!("second-slot calls complete before the entry loop") - } - }; - let data = slot1_enabled.then(|| fetch_words(&state, 0)); - if !slot1_enabled { - state.data_word_skew += quantum; - } - let datb = slot2_enabled.then(|| fetch_words(&state, quantum)); - if !slot2_enabled { - state.data_word_skew += quantum; - } - let stale = state.last_line; - let assembled = match (data, datb) { - (Some(d), Some(b)) => { - Some(((d[0], [d[1], d[2], d[3]]), (b[0], [b[1], b[2], b[3]]))) - } - // DATB missed its slot: the sprite displays the new - // DATA with the stale DATB latch. - (Some(d), None) => Some(( - (d[0], [d[1], d[2], d[3]]), - stale.map_or((0, [0; 3]), |l| (l.datb, l.datb_ext)), - )), - // DATA missed its slot: DATB alone does not arm the - // sprite; an already-armed sprite keeps displaying - // with the stale DATA. - (None, Some(b)) => { - stale.map(|l| ((l.data, l.data_ext), (b[0], [b[1], b[2], b[3]]))) - } - (None, None) => stale.map(|l| ((l.data, l.data_ext), (l.datb, l.datb_ext))), - }; - let Some(((data, data_ext), (datb, datb_ext))) = assembled else { - self.display_dma_sprite_state[sprite] = state; - return None; - }; - let line_data = DisplaySpriteLineData { - hstart: control.hstart, - hsub_70ns: control.hsub_70ns, - data, - datb, - data_ext, - datb_ext, - width_words: quantum as u8, - attached: control.attached, - }; - state.last_line = Some(line_data); - self.display_dma_sprite_state[sprite] = state; - return Some(CapturedSpriteLine { - sprite, - hstart: line_data.hstart, - hsub_70ns: line_data.hsub_70ns, - beam_y, - data: line_data.data, - datb: line_data.datb, - data_ext: line_data.data_ext, - datb_ext: line_data.datb_ext, - width_words: line_data.width_words, - attached: line_data.attached, - }); + if v == state.vstop { + state.dma_enabled = false; } } + } + state.comparator_vpos = beam_y; + self.display_dma_sprite_state[sprite] = state; + } - let descriptor_ptr = - state.next_ptr.unwrap_or(self.display_dma_sprpt[sprite]) & ram_mask & !1; - if visited_descriptor_ptrs.contains(&descriptor_ptr) { - state.terminated = true; - state.data_dma_active = false; - state.last_line = None; - self.display_dma_sprite_state[sprite] = state; - return None; - } - visited_descriptor_ptrs.push(descriptor_ptr); - - // AGA wide fetches also widen the control-word slots: POS is the - // first word of the first fetch, CTL the first word of the second. - let quantum = sprite_fetch_quantum(self.agnus.fmode()); - let mut ptr = descriptor_ptr; - let pos = read_chip_word_wrapping(&self.mem.chip_ram, ptr); - let ctl = read_chip_word_wrapping(&self.mem.chip_ram, ptr.wrapping_add(2 * quantum)); - ptr = ptr.wrapping_add(4 * quantum) & ram_mask & !1; - if pos == 0 && ctl == 0 { - state.terminated = true; - state.data_dma_active = false; - state.last_line = None; - self.display_dma_sprite_state[sprite] = state; - return None; - } + /// Fetch one sprite DMA word group (1/2/4 words by FMODE) from the + /// channel's live pointer, advancing SPRxPT the way the chip does: + /// only words actually fetched move the pointer. + fn fetch_sprite_words(&mut self, sprite: usize) -> Option<[u16; 4]> { + if self.mem.chip_ram.is_empty() { + return None; + } + let quantum = sprite_fetch_quantum(self.agnus.fmode()); + let ptr = self.display_dma_sprpt[sprite] & self.chip_dma_mask & !1; + let mut words = [0u16; 4]; + for (w, word) in words.iter_mut().enumerate().take(quantum as usize) { + *word = read_chip_word_wrapping(&self.mem.chip_ram, ptr.wrapping_add(2 * w as u32)); + } + self.display_dma_sprpt[sprite] = ptr.wrapping_add(2 * quantum) & self.chip_dma_mask & !1; + Some(words) + } - let vstart = sprite_vstart_from_words(pos, ctl); - let raw_vstop = sprite_vstop_from_ctl(ctl); - // An inverted vertical pair (vstop < vstart) does not disable the - // sprite. Agnus arms it at vstart; its vstop comparator already - // passed for this field, so it does not fire again until the field - // wraps, and the sprite keeps fetching data to the bottom of the - // frame. Clamp the effective vstop to the frame bottom -- the - // per-field VBLANK reset re-fetches this descriptor, which covers - // the 0..vstop wrap tail on the next field. Treating vstop bool { + sprite_scan_doubled(self.agnus.fmode()) + && state.last_data_fetch_vpos != unset_sprite_line_marker() + && beam_y == state.last_data_fetch_vpos + 1 + } - // With SSCAN2 each fetched data line covers two display lines, - // so the descriptor consumes only ceil(height/2) data lines. - let data_lines = if sprite_scan_doubled(self.agnus.fmode()) { - (height as u32).div_ceil(2) - } else { - height as u32 - }; - let mut control = DisplaySpriteControl { - vstart, - vstop, - hstart: sprite_hstart_from_words(pos, ctl), - hsub_70ns: bitplane_shres(self.denise.bplcon0) && sprite_hsub_70ns_from_ctl(ctl), - data_vstart: vstart, - data_base: ptr, - next_ptr: ptr.wrapping_add(data_lines.saturating_mul(4 * quantum)) & ram_mask & !1, - attached: ctl & 0x0080 != 0, - }; - let defer_start_until_next_line = - descriptor_loaded_after_stop_this_line && beam_y == control.vstart; - if defer_start_until_next_line { - control.data_vstart = beam_y + 1; - let remaining_height = (control.vstop - control.data_vstart).max(0) as u32; - let remaining_data_lines = if sprite_scan_doubled(self.agnus.fmode()) { - remaining_height.div_ceil(2) - } else { - remaining_height - }; - control.next_ptr = control - .data_base - .wrapping_add(remaining_data_lines.saturating_mul(4 * quantum)) - & ram_mask - & !1; - } - if let Some(want) = diag_sprcap() { - if diag_sprcap_matches(want, beam_y) { - log::info!( - "sprdesc f={} s{} y={} ptr={:06X} pos={:04X} ctl={:04X} vstart={} vstop={} raw_vstop={} hstart={} att={} data_base={:06X} data_vstart={} next={:06X} can_match={} start_now={} defer_start={}", - self.emulated_frames, - sprite, - beam_y, - descriptor_ptr, - pos, - ctl, - control.vstart, - control.vstop, - raw_vstop, - control.hstart, - u8::from(control.attached), - control.data_base, - control.data_vstart, - control.next_ptr, - u8::from(descriptor_can_match_current_vstart), - u8::from(beam_y == control.vstart && descriptor_can_match_current_vstart), - u8::from(defer_start_until_next_line), - ); + /// First hardware sprite slot ($15+4N). On the channel's vstop line the + /// DMA flip-flop is cleared -- even when SPREN is off, which is what + /// leaves a sprite dead until the next field when software disables DMA + /// across its vstop line -- and, when the slot is enabled, the next + /// control word's POS part is fetched. On other lines an enabled + /// channel fetches the line's DATA word(s), sampled at this slot's beam + /// time; the second slot assembles and emits the line. + pub(super) fn sprite_slot1( + &mut self, + sprite: usize, + vpos: u32, + spren: bool, + ddf_blocked: bool, + ) { + let beam_y = vpos as i32; + let mut state = self.display_dma_sprite_state[sprite]; + state.entry_line_vpos = beam_y; + state.pending_data = None; + state.pending_line_vpos = unset_sprite_line_marker(); + if beam_y == state.vstop { + state.dma_enabled = false; + if spren && !ddf_blocked { + if let Some(words) = self.fetch_sprite_words(sprite) { + state.poke_pos(words[0]); + state.reevaluate_comparators_at(beam_y); } } - - state.control = Some(control); - state.control_loaded_vpos = beam_y; - state.data_word_skew = 0; - state.data_dma_active = false; - state.last_line = None; - if beam_y < control.vstart { - self.display_dma_sprite_state[sprite] = state; - return None; - } - if defer_start_until_next_line { - state.data_dma_active = true; - self.display_dma_sprite_state[sprite] = state; - return None; - } - if beam_y == control.vstart && descriptor_can_match_current_vstart { - state.data_dma_active = true; - continue; - } - if beam_y < control.vstop { - self.display_dma_sprite_state[sprite] = state; - return None; + } else if state.dma_enabled + && spren + && !ddf_blocked + && !self.sprite_scan_repeat_line(&state, beam_y) + { + if let Some(words) = self.fetch_sprite_words(sprite) { + state.pending_data = Some((words[0], [words[1], words[2], words[3]])); + state.pending_line_vpos = beam_y; + state.last_data_fetch_vpos = beam_y; } } + self.display_dma_sprite_state[sprite] = state; } - /// Second hardware sprite slot ($17+4N) after the first slot's call ran - /// the line entry: fetches DATB at this slot's beam time, assembles the - /// display line with the pending DATA words (or the stale latches), and - /// emits it. - fn sprite_slot2_complete( + /// Second hardware sprite slot ($17+4N): on the channel's vstop line it + /// fetches the control word's CTL part (loading the next vertical + /// comparators and disarming the display latches); on other lines it + /// fetches DATB at its own beam time and assembles/emits the display + /// line. A skipped slot reuses the stale latch on that side, a missed + /// DATA slot never arms the sprite, and an armed channel with DMA off + /// keeps displaying its latches until a CTL fetch or poke disarms it. + pub(super) fn sprite_slot2( &mut self, sprite: usize, - beam_y: i32, - ram_mask: u32, - slot2_enabled: bool, + vpos: u32, + spren: bool, + ddf_blocked: bool, ) -> Option { + let beam_y = vpos as i32; let mut state = self.display_dma_sprite_state[sprite]; let pending = if state.pending_line_vpos == beam_y { state.pending_data.take() @@ -1413,87 +842,65 @@ impl Bus { None }; state.pending_data = None; - let Some(control) = state.control else { - self.display_dma_sprite_state[sprite] = state; - return None; - }; - if state.terminated || !state.data_dma_active { + if beam_y == state.vstop { + state.dma_enabled = false; + if spren && !ddf_blocked { + if let Some(words) = self.fetch_sprite_words(sprite) { + state.poke_ctl(words[0]); + state.reevaluate_comparators_at(beam_y); + } + } self.display_dma_sprite_state[sprite] = state; return None; } - let quantum = sprite_fetch_quantum(self.agnus.fmode()); - // SSCAN2 in-between lines redisplay the previous fetch. - if sprite_scan_doubled(self.agnus.fmode()) - && (beam_y - control.effective_data_vstart()) & 1 == 1 + let mut datb_fetched = None; + if state.dma_enabled + && spren + && !ddf_blocked + && !self.sprite_scan_repeat_line(&state, beam_y) { - let line_data = state.last_line; - self.display_dma_sprite_state[sprite] = state; - return line_data.map(|line_data| CapturedSpriteLine { - sprite, - hstart: line_data.hstart, - hsub_70ns: line_data.hsub_70ns, - beam_y, - data: line_data.data, - datb: line_data.datb, - data_ext: line_data.data_ext, - datb_ext: line_data.datb_ext, - width_words: line_data.width_words, - attached: line_data.attached, - }); + if let Some(words) = self.fetch_sprite_words(sprite) { + datb_fetched = Some((words[0], [words[1], words[2], words[3]])); + } } - if pending.is_none() && state.pending_line_vpos != beam_y { - // The first slot ran the entry logic but no data fetch belongs - // to this line (e.g. a stop/start descriptor handover deferred - // the stream to the next line). + // Sprites captured as "held" at the visible start are repainted by + // the renderer's manual-sprite path (which clips each + // Copper-repositioned segment), so do not also emit stale-latch + // redisplay lines for them here. + if pending.is_none() + && datb_fetched.is_none() + && self.current_frame_held_sprites[sprite].is_some() + { self.display_dma_sprite_state[sprite] = state; return None; } - let mut line = (beam_y - control.effective_data_vstart()) as u32; - if sprite_scan_doubled(self.agnus.fmode()) { - line /= 2; - } - let line_base = control - .data_base - .wrapping_add(line.saturating_mul(4 * quantum)); - let datb = if slot2_enabled { - let ptr = line_base - .wrapping_add(quantum * 2) - .wrapping_sub(state.data_word_skew * 2) - & ram_mask - & !1; - let mut words = [0u16; 4]; - for (w, word) in words.iter_mut().enumerate().take(quantum as usize) { - *word = read_chip_word_wrapping(&self.mem.chip_ram, ptr.wrapping_add(2 * w as u32)); - } - Some((words[0], [words[1], words[2], words[3]])) - } else { - state.data_word_skew += quantum; - None - }; let stale = state.last_line; - let assembled = match (pending, datb) { - (Some(d), Some(b)) => Some((d, b)), + let assembled = match (pending, datb_fetched) { + (Some(data), Some(datb)) => Some((data, datb)), // DATB missed its slot: the sprite displays the new DATA with // the stale DATB latch. - (Some(d), None) => Some((d, stale.map_or((0, [0; 3]), |l| (l.datb, l.datb_ext)))), + (Some(data), None) => Some((data, stale.map_or((0, [0; 3]), |l| (l.datb, l.datb_ext)))), // DATA missed its slot: DATB alone does not arm the sprite; an // already-armed sprite keeps displaying with the stale DATA. - (None, Some(b)) => stale.map(|l| ((l.data, l.data_ext), b)), + (None, Some(datb)) => stale.map(|l| ((l.data, l.data_ext), datb)), + // No fetch at all (DMA off or an SSCAN2 repeat line): an armed + // channel redisplays its latches at the current POS/CTL decode. (None, None) => stale.map(|l| ((l.data, l.data_ext), (l.datb, l.datb_ext))), }; let Some(((data, data_ext), (datb, datb_ext))) = assembled else { self.display_dma_sprite_state[sprite] = state; return None; }; + let quantum = sprite_fetch_quantum(self.agnus.fmode()); let line_data = DisplaySpriteLineData { - hstart: control.hstart, - hsub_70ns: control.hsub_70ns, + hstart: sprite_hstart_from_words(state.pos, state.ctl), + hsub_70ns: bitplane_shres(self.denise.bplcon0) && sprite_hsub_70ns_from_ctl(state.ctl), data, datb, data_ext, datb_ext, width_words: quantum as u8, - attached: control.attached, + attached: state.ctl & 0x0080 != 0, }; state.last_line = Some(line_data); self.display_dma_sprite_state[sprite] = state; @@ -1511,57 +918,6 @@ impl Bus { }) } - pub(super) fn captured_sprite_vertical_bar_line_at( - &mut self, - sprite: usize, - vpos: u32, - ) -> Option { - // Sprites captured as "held" at the visible start are repainted by the - // renderer's manual-sprite path (which clips each Copper-repositioned - // segment), so do not also emit a full-width bar for them here. - if self.current_frame_held_sprites[sprite].is_some() { - return None; - } - let beam_y = vpos as i32; - let mut state = self.display_dma_sprite_state[sprite]; - let control = state.control?; - if beam_y >= control.vstop { - state.next_ptr = Some( - control.next_ptr.wrapping_sub(state.data_word_skew * 2) & self.chip_dma_mask & !1, - ); - state.control = None; - state.data_dma_active = false; - state.last_line = None; - self.display_dma_sprite_state[sprite] = state; - return None; - } - if beam_y < control.vstart || !state.data_dma_active { - self.display_dma_sprite_state[sprite] = state; - return None; - } - let line_data = state.last_line?; - self.display_dma_sprite_state[sprite] = state; - // Position the held strip at the sprite's *current* SPRxPOS/CTL, not - // the fetch-time hstart: with sprite DMA off the Copper (or CPU) can - // reposition a reused sprite by rewriting SPRxPOS, so the held data - // must follow it. For a sprite left where the DMA fetched it this is - // the same value. - let pos = self.denise.sprpos[sprite]; - let ctl = self.denise.sprctl[sprite]; - Some(CapturedSpriteLine { - sprite, - hstart: sprite_hstart_from_words(pos, ctl), - hsub_70ns: bitplane_shres(self.denise.bplcon0) && sprite_hsub_70ns_from_ctl(ctl), - beam_y, - data: line_data.data, - datb: line_data.datb, - data_ext: line_data.data_ext, - datb_ext: line_data.datb_ext, - width_words: line_data.width_words, - attached: line_data.attached, - }) - } - pub(super) fn capture_bitplane_dma_words_if_due( &mut self, vpos: u32, diff --git a/src/bus/tests.rs b/src/bus/tests.rs index d61be37..7a5e433 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -11,11 +11,11 @@ use super::{ live_manual_sprite_collision_sources, live_sprite_playfield_collision_bits_in_range, live_sprite_sprite_collision_bits, visible_start_vpos_for_diw, BeamChipRamWrite, BeamRegisterWrite, BeamWriteSource, Bus, CapturedBitplaneRow, CapturedSpriteLine, ChipBusOwner, - CpuBusAccessKind, DeviceClock, DisplaySpriteControl, DisplaySpriteDmaState, - DisplaySpriteLineData, FrameBusTrace, LiveCollisionControl, LiveCollisionLineReplay, - LiveSpriteCollisionSource, RenderRegisterSnapshot, BLITTER_SLOWDOWN_CPU_MISS_LIMIT, - BLTCON1_DOFF, BPLCON0_ECSENA, BPLCON3_BRDSPRT, BPLCON3_SPRES_HIRES, COPPER_BUS_LOCKOUT_HPOS, - DENISE_HPOS_LAG_CCK, DMACON_AUD_MASK, DMACON_BLTEN, DMACON_BLTPRI, DMACON_BPLEN, DMACON_SPREN, + CpuBusAccessKind, DeviceClock, DisplaySpriteDmaState, DisplaySpriteLineData, FrameBusTrace, + LiveCollisionControl, LiveCollisionLineReplay, LiveSpriteCollisionSource, + RenderRegisterSnapshot, BLITTER_SLOWDOWN_CPU_MISS_LIMIT, BLTCON1_DOFF, BPLCON0_ECSENA, + BPLCON3_BRDSPRT, BPLCON3_SPRES_HIRES, COPPER_BUS_LOCKOUT_HPOS, DENISE_HPOS_LAG_CCK, + DMACON_AUD_MASK, 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, RENDER_VISIBLE_START_VPOS, SPRITE_DMA_SLOT1_HPOS, @@ -411,6 +411,16 @@ fn sprite_control_words(vstart: u16, vstop: u16, hstart: u16) -> (u16, u16) { (pos, ctl) } +/// Cross the vertical-blank reset line (PAL $19) over the whole sprite slot +/// band: the reset forces every channel's vstop to that line, so its slots +/// fetch the POS/CTL control words from SPRxPT the way hardware loads them +/// at the start of each field. +fn sprite_fetch_control_words_at_reset_line(bus: &mut Bus) { + for sprite in 0..8 { + let _ = bus.captured_sprite_line_at(sprite, 0x19); + } +} + #[test] fn realtime_clock_produces_paula_cck_from_elapsed_wall_time() { let mut clock = DeviceClock::default(); @@ -4720,6 +4730,10 @@ fn sprite_dma_capture_samples_line_words_at_beam_time() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(1); write_chip_word(&mut bus, sprite_ptr + 4, 0xAAAA); write_chip_word(&mut bus, sprite_ptr + 6, 0xBBBB); @@ -4760,10 +4774,13 @@ fn inactive_sprite_pointer_write_before_pair_slot_seeds_next_descriptor_fetch() bus.agnus.dmacon = DMACON_DMAEN | DMACON_SPREN; bus.denise.sprpt[0] = old_ptr as u32; bus.display_dma_sprpt[0] = old_ptr as u32; + bus.sprite_dma_frame_start_ptr[0] = old_ptr as u32; bus.current_frame_render_base.dmacon = DMACON_DMAEN | DMACON_SPREN; bus.current_frame_render_base.sprpt[0] = old_ptr as u32; - bus.agnus.vpos = 0x24; + // The reload must land before the vertical-blank reset line: that + // line's slots consume SPRxPT for the field's control-word fetch. + bus.agnus.vpos = 0x10; bus.agnus.hpos = 0; let _ = bus.write_custom_word_from(0x120, (new_ptr >> 16) as u16, BeamWriteSource::Copper); let _ = bus.write_custom_word_from(0x122, new_ptr as u16, BeamWriteSource::Copper); @@ -4975,12 +4992,10 @@ fn pending_descriptor_sprite_pointer_write_retargets_data_stream() { bus.denise.sprpt[0] = old_ptr as u32; bus.display_dma_sprpt[0] = old_ptr as u32; - bus.agnus.vpos = 0x2C; - bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; - bus.advance_chipset(4); + sprite_fetch_control_words_at_reset_line(&mut bus); let pending = bus.display_dma_sprite_state[0]; - assert_eq!(pending.control.map(|control| control.vstart), Some(0x60)); - assert!(!pending.data_dma_active); + assert_eq!(pending.vstrt, 0x60); + assert!(!pending.dma_enabled); bus.agnus.vpos = 0x30; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0]; @@ -5016,29 +5031,16 @@ fn armed_pointer_reload_before_vstart_fetches_descriptor_words() { bus.agnus.dmacon = DMACON_DMAEN | DMACON_SPREN; bus.denise.diwstrt = (0x2C << 8) | 0x0081; bus.denise.diwstop = (0x80 << 8) | 0x00C1; - bus.denise.sprpos[0] = pos; - bus.denise.sprctl[0] = ctl; - bus.denise.spr_armed[0] = true; - bus.display_dma_sprite_state[0] = DisplaySpriteDmaState { - control: Some(DisplaySpriteControl { - vstart: 0x40, - vstop: 0x42, - hstart: 0x0101, - hsub_70ns: false, - data_vstart: 0x40, - data_base: 0x0100, - next_ptr: 0x0108, - attached: false, - }), - control_loaded_vpos: super::unset_sprite_control_loaded_vpos(), - ..DisplaySpriteDmaState::default() - }; - bus.agnus.vpos = 0x24; + // Reload SPRxPT during the vertical blank; the reset-line control-word + // fetch consumes it. + bus.agnus.vpos = 0x10; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0]; let _ = bus.write_custom_word_from(0x120, (sprite_ptr >> 16) as u16, BeamWriteSource::Copper); let _ = bus.write_custom_word_from(0x122, sprite_ptr as u16, BeamWriteSource::Copper); + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x40; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(4); @@ -5061,8 +5063,8 @@ fn after_slot_armed_sprite_pointer_write_seeds_dma_data_stream() { bus.agnus.dmacon = DMACON_DMAEN | DMACON_SPREN; bus.agnus.vpos = 0x40; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0]; - bus.denise.sprpos[0] = pos; - bus.denise.sprctl[0] = ctl; + let _ = bus.write_custom_word_from(0x140, pos, BeamWriteSource::Cpu); + let _ = bus.write_custom_word_from(0x142, ctl, BeamWriteSource::Cpu); bus.denise.spr_armed[0] = true; let _ = bus.write_custom_word_from(0x120, (data_ptr >> 16) as u16, BeamWriteSource::Copper); @@ -5096,6 +5098,10 @@ fn active_sprite_control_rewrite_preserves_descriptor_data_origin() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); bus.agnus.vpos = 0x2C; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(4); @@ -5151,6 +5157,9 @@ fn sprite_pointer_write_after_pair_slot_seeds_next_descriptor_fetch() { let _ = bus.write_custom_word_from(0x128, (new_ptr >> 16) as u16, BeamWriteSource::Copper); let _ = bus.write_custom_word_from(0x12A, new_ptr as u16, BeamWriteSource::Copper); + // The reset-line control fetch consumes the rewritten pointer. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; bus.agnus.hpos = slot - 1; bus.advance_chipset(4); @@ -5173,7 +5182,13 @@ fn sprite_pointer_write_after_pair_slot_seeds_next_descriptor_fetch() { /// Previously vstop> 16) as u16, BeamWriteSource::Copper); let _ = bus.write_custom_word_from(0x122, new_ptr as u16, BeamWriteSource::Copper); - - let state = bus.display_dma_sprite_state[0]; - assert!( - state.control.is_none(), - "same-slot pointer write should discard the descriptor fetched before the write" + assert_eq!( + bus.display_dma_sprpt[0], new_ptr as u32, + "the pointer write must seed the next control-word fetch" ); + // The vertical-blank reset line fetches POS/CTL from the new pointer. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x40; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(4); @@ -6069,6 +6078,10 @@ fn sprite_dma_capture_blocks_sprite_seven_when_ddfstrt_uses_early_fetch_slot() { bus.display_dma_sprpt[6] = sprite6_ptr as u32; bus.display_dma_sprpt[7] = sprite7_ptr as u32; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[6] - 1; bus.advance_chipset(SPRITE_DMA_SLOT1_HPOS[7] + 1 - bus.agnus.hpos); let lines = bus.frame_captured_sprite_lines(); @@ -6104,6 +6117,10 @@ fn sprite_dma_capture_keeps_sprite_seven_when_ddfstrt_matches_sprite_slot() { bus.display_dma_sprpt[6] = sprite6_ptr as u32; bus.display_dma_sprpt[7] = sprite7_ptr as u32; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[6] - 1; bus.advance_chipset(SPRITE_DMA_SLOT1_HPOS[7] + 3 - bus.agnus.hpos); let lines = bus.frame_captured_sprite_lines(); @@ -6131,6 +6148,10 @@ fn sprite_dma_capture_repeats_last_fetched_line_after_dma_disable_until_vstop() bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(4); bus.agnus.dmacon = DMACON_DMAEN; bus.agnus.vpos = 0x2D; @@ -6174,7 +6195,7 @@ fn sprite_dma_capture_does_not_start_descriptor_at_or_before_current_vpos() { bus.agnus.vpos = 0x2C; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.denise.sprpt[0] = sprite_ptr as u32; - bus.display_dma_sprite_state[0].next_ptr = Some(sprite_ptr as u32); + bus.display_dma_sprpt[0] = sprite_ptr as u32; bus.advance_chipset(4); bus.agnus.vpos = 0x2D; @@ -6208,6 +6229,10 @@ fn sprite_dma_reuse_skips_descriptor_with_vstart_before_current_vpos() { bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2B; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(4); bus.agnus.vpos = 0x2C; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; @@ -6251,6 +6276,8 @@ fn sprite_dma_chained_descriptor_with_same_vstart_arms_after_control_fetch_line( bus.denise.sprpt[0] = sprite_ptr as u32; bus.display_dma_sprpt[0] = sprite_ptr as u32; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); for vpos in 0x2C..=0x31u32 { bus.agnus.vpos = vpos; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; @@ -6298,6 +6325,10 @@ fn visible_sprite_pixels_accumulate_live_sprite_sprite_clxdat() { bus.display_dma_sprpt[2] = sprite2_ptr as u32; bus.current_frame_sprite_display_enable_x_by_y[0] = Some(0); + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(1); assert_eq!(bus.custom_read(0x00E, 2), 0x8000); @@ -6684,6 +6715,10 @@ fn bplcon3_spres_hires_narrows_live_sprite_sprite_clxdat() { bus.current_frame_sprite_display_enable_x_by_y[0] = Some(0); let remaining = 0x3A - bus.agnus.hpos; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(remaining); bus.custom_read(0x00E, 2) @@ -6724,6 +6759,10 @@ fn same_line_clxcon_odd_sprite_enable_does_not_retime_earlier_live_sprite_sprite bus.current_frame_render_base = bus.capture_render_snapshot(); let after_pair_capture = SPRITE_DMA_SLOT1_HPOS[1] + 1 - bus.agnus.hpos; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(after_pair_capture); if let Some(enable_hpos) = enable_hpos { @@ -7418,6 +7457,10 @@ fn captured_sprite_and_bitplane_rows_accumulate_live_sprite_playfield_clxdat() { bus.current_frame_sprite_display_enable_x_by_y[0] = Some(0); write_chip_word(&mut bus, 0x0100, 0x4000); + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(1); assert_eq!(bus.custom_read(0x00E, 2), 0x8000); let remaining = 0x40 - bus.agnus.hpos; @@ -7449,6 +7492,10 @@ fn explicit_bpl1dat_output_accumulates_live_sprite_playfield_clxdat() { bus.display_dma_sprpt[0] = sprite_ptr as u32; bus.current_frame_render_base = bus.capture_render_snapshot(); + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(2); assert_eq!(bus.custom_read(0x00E, 2), 0x8000); @@ -7514,6 +7561,10 @@ fn same_line_bplcon1_scroll_increase_latches_later_live_sprite_playfield_clxdat( write_chip_word(&mut bus, 0x0100, 0x0001); let before_scroll_write = 0x40 - bus.agnus.hpos; + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(before_scroll_write); assert_eq!(bus.custom_read(0x00E, 2), 0x8000); @@ -7556,6 +7607,10 @@ fn same_line_clxcon_odd_sprite_enable_does_not_retime_earlier_live_sprite_playfi write_chip_word(&mut bus, 0x0100, 0x8000); write_chip_word(&mut bus, 0x0102, 0); + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(1); assert_eq!(bus.custom_read(0x00E, 2), 0x8000); @@ -7606,6 +7661,10 @@ fn bplcon3_spres_hires_narrows_live_sprite_playfield_clxdat() { write_chip_word(&mut bus, 0x0100, 0x0004); write_chip_word(&mut bus, 0x0102, 0); + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(1); let remaining = 0x40 - bus.agnus.hpos; bus.advance_chipset(remaining); @@ -7653,6 +7712,10 @@ fn same_line_bplcon3_spres_write_does_not_retime_earlier_live_sprite_playfield_c write_chip_word(&mut bus, 0x0100, 0x0004); write_chip_word(&mut bus, 0x0102, 0); + // Control words load at the vertical-blank reset line. + sprite_fetch_control_words_at_reset_line(&mut bus); + bus.agnus.vpos = 0x2C; + bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(1); if let Some(spres_hpos) = spres_hpos { let before_spres = spres_hpos - bus.agnus.hpos; @@ -8277,11 +8340,14 @@ fn fixed_agnus_dma_slot_bands_drive_owner_selection() { bus.agnus.dmacon = DMACON_DMAEN | DMACON_SPREN; // A sprite's slot is reserved only while that sprite is actually - // fetching (data_dma_active); a parked sprite frees it. Sprite N owns - // the odd color clocks $15+4N and $17+4N (hardware slot chart). + // fetching (its DMA flip-flop is set, or the line is its vstop line + // fetching POS/CTL); a parked sprite frees it. Sprite N owns the odd + // color clocks $15+4N and $17+4N (hardware slot chart). + bus.agnus.vpos = 0x2C; + bus.display_dma_sprite_state[0].vstop = 0x60; bus.agnus.hpos = 0x015; assert_eq!(bus.scheduled_dma_owner(false), ChipBusOwner::Idle); - bus.display_dma_sprite_state[0].data_dma_active = true; + bus.display_dma_sprite_state[0].dma_enabled = true; assert_eq!(bus.scheduled_dma_owner(false), ChipBusOwner::Sprite); bus.agnus.hpos = 0x017; assert_eq!(bus.scheduled_dma_owner(false), ChipBusOwner::Sprite); @@ -8293,7 +8359,8 @@ fn fixed_agnus_dma_slot_bands_drive_owner_selection() { assert_eq!(bus.scheduled_dma_owner(false), ChipBusOwner::Idle); bus.agnus.hpos = 0x035; assert_eq!(bus.scheduled_dma_owner(false), ChipBusOwner::Idle); - bus.display_dma_sprite_state[0].data_dma_active = false; + bus.display_dma_sprite_state[0].dma_enabled = false; + bus.agnus.vpos = 0; bus.agnus.dmacon = DMACON_DMAEN | DMACON_BPLEN; bus.denise.bplcon0 = 0x1000;