From 843b4ee64d501a3746c0184e5ff65fa7abf90654 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 4 Jul 2026 18:32:57 +0100 Subject: [PATCH] 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 {