diff --git a/docs/internals/chipset.md b/docs/internals/chipset.md index a5710d4..4890cd5 100644 --- a/docs/internals/chipset.md +++ b/docs/internals/chipset.md @@ -47,48 +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 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. +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. 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 7c111a3..30fc0f6 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -307,19 +307,103 @@ pub struct HeldSpriteLine { pub vstop: i32, } -#[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)] +/// 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, + /// 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. + pending_data: Option<(u16, [u16; 3])>, + /// Line the first slot's DATA fetch ran for. + #[serde(default = "unset_sprite_line_marker")] + pending_line_vpos: i32, + /// 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_line_marker() -> i32 { + i32::MIN } -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 { + pos: 0, + ctl: 0, + vstrt: 0, + vstop: 0, + dma_enabled: false, + comparator_vpos: unset_sprite_line_marker(), + last_line: None, + last_data_fetch_vpos: unset_sprite_line_marker(), + pending_data: None, + 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; + } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -466,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, @@ -628,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 @@ -872,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, @@ -2505,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; } @@ -2578,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; @@ -2727,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 { @@ -4768,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 684d890..7b501b7 100644 --- a/src/bus/frame_capture.rs +++ b/src/bus/frame_capture.rs @@ -120,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; @@ -186,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) { @@ -199,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 { @@ -221,159 +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, - }; - } - + /// 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, @@ -382,218 +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_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 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 state = &mut self.display_dma_sprite_state[sprite]; + match write { + SpriteControlRegisterWrite::Pos => state.poke_pos(value), + SpriteControlRegisterWrite::Ctl => state.poke_ctl(value), } - 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_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); } } @@ -717,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 { @@ -734,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(), @@ -779,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 { @@ -804,7 +505,7 @@ impl Bus { offset: u16, value: u16, vpos: u32, - hpos: u32, + _hpos: u32, dmacon: &mut u16, ) { if offset == 0x096 { @@ -816,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; @@ -826,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); } } @@ -844,16 +564,10 @@ 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) { - 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, @@ -869,54 +583,70 @@ 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 (old_hpos..new_hpos).contains(&slot1_hpos) { + 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 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 sprite_dma_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; { - 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) - } 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, @@ -928,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], ); } } @@ -969,288 +697,217 @@ 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 { - 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; let mut state = self.display_dma_sprite_state[sprite]; - 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 { - state.next_ptr = Some(control.next_ptr); - 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. - 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 - .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), - ); - } - 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_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_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; } - pub(super) fn captured_sprite_vertical_bar_line_at( + /// 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, vpos: u32, + spren: bool, + ddf_blocked: bool, ) -> 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); - state.control = None; - state.data_dma_active = false; - state.last_line = None; + let pending = if state.pending_line_vpos == beam_y { + state.pending_data.take() + } else { + None + }; + state.pending_data = None; + 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; } - if beam_y < control.vstart || !state.data_dma_active { + let mut datb_fetched = None; + if state.dma_enabled + && spren + && !ddf_blocked + && !self.sprite_scan_repeat_line(&state, beam_y) + { + if let Some(words) = self.fetch_sprite_words(sprite) { + datb_fetched = Some((words[0], [words[1], words[2], words[3]])); + } + } + // 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 line_data = state.last_line?; + let stale = state.last_line; + 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(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(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: 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: state.ctl & 0x0080 != 0, + }; + state.last_line = Some(line_data); 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), + hstart: line_data.hstart, + hsub_70ns: line_data.hsub_70ns, beam_y, data: line_data.data, datb: line_data.datb, diff --git a/src/bus/tests.rs b/src/bus/tests.rs index f3d32a8..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,12 +4730,20 @@ 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); + // 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 +4752,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] @@ -4756,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); @@ -4768,7 +4789,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 +4838,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 +4881,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 +4911,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 +4958,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); @@ -4971,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(2); + 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]; @@ -4985,10 +5004,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); @@ -5012,32 +5031,19 @@ 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(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5057,15 +5063,15 @@ 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); 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 +5100,11 @@ 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); + // 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.vpos = 0x2D; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 8; @@ -5102,7 +5112,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 +5153,16 @@ 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); + // 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(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert!(bus.frame_sprite_dma_observed()); @@ -5169,7 +5182,13 @@ fn sprite_pointer_write_after_pair_slot_seeds_next_descriptor_fetch() { /// Previously vstop = bus .frame_captured_sprite_lines() @@ -5562,44 +5602,23 @@ fn finished_sprite_channel_carries_dma_frontier_across_frame_boundary() { // SPRxPT), and from the written pointer for a channel still mid-field. let mut bus = empty_bus(); - // Channel 0 finished the field: its control comparator is cleared and the - // DMA frontier sits at the terminating descriptor. denise.sprpt still + // Channel 0 finished the field: SPRxPT advanced through its fetches and + // sits at the DMA frontier past the consumed list. denise.sprpt still // holds the now-overwritten descriptor address the Copper wrote earlier. let frontier0 = 0x0005_F3E4u32; bus.denise.sprpt[0] = 0x0005_F0BC; - bus.display_dma_sprite_state[0] = DisplaySpriteDmaState { - control: None, - control_loaded_vpos: super::unset_sprite_control_loaded_vpos(), - next_ptr: Some(frontier0), - terminated: true, - data_dma_active: false, - last_line: None, - }; + bus.display_dma_sprpt[0] = frontier0; - // Channel 1 is still mid-descriptor at frame end: keep the written - // pointer so an active reused sprite is not skipped past its data. - let written1 = 0x0006_0000u32; - bus.denise.sprpt[1] = written1; - bus.display_dma_sprite_state[1] = DisplaySpriteDmaState { - control: Some(DisplaySpriteControl { - vstart: 0x20, - vstop: 0x40, - hstart: 0x80, - hsub_70ns: false, - data_vstart: 0x20, - data_base: 0x0100, - next_ptr: 0x0200, - attached: false, - }), - control_loaded_vpos: 0x20, - next_ptr: Some(0x0200), - terminated: false, - data_dma_active: true, - last_line: None, - }; + // Channel 1 is mid-stream at frame end: its live pointer sits at the + // next data word. + let mid_stream1 = 0x0006_0104u32; + bus.denise.sprpt[1] = 0x0006_0000; + bus.display_dma_sprpt[1] = mid_stream1; - // Channel 2 was never set up this field: fall back to the written pointer. + // Channel 2 never fetched this field: the live pointer still equals the + // written value. bus.denise.sprpt[2] = 0x0007_0000; + bus.display_dma_sprpt[2] = 0x0007_0000; bus.begin_new_beam_frame(); @@ -5608,12 +5627,12 @@ fn finished_sprite_channel_carries_dma_frontier_across_frame_boundary() { "finished channel must carry the DMA frontier, not the stale written pointer" ); assert_eq!( - bus.sprite_dma_frame_start_ptr[1], written1, - "channel still mid-descriptor keeps the written SPRxPT" + bus.sprite_dma_frame_start_ptr[1], mid_stream1, + "mid-stream channel carries its live pointer" ); assert_eq!( bus.sprite_dma_frame_start_ptr[2], 0x0007_0000, - "channel with no descriptor falls back to the written SPRxPT" + "channel that never fetched keeps the written SPRxPT" ); } @@ -5636,7 +5655,11 @@ 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); + // 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); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5682,20 +5705,9 @@ fn state_load_resets_transient_video_latches() { attached: false, }); bus.display_dma_sprite_state[0] = DisplaySpriteDmaState { - control: Some(DisplaySpriteControl { - vstart: 0x20, - vstop: 0x40, - hstart: 0x80, - hsub_70ns: false, - data_vstart: 0x20, - data_base: 0x0100, - next_ptr: 0x0200, - attached: false, - }), - control_loaded_vpos: 0x20, - next_ptr: Some(0x0200), - terminated: false, - data_dma_active: true, + vstrt: 0x20, + vstop: 0x40, + dma_enabled: true, last_line: Some(DisplaySpriteLineData { hstart: 0x80, hsub_70ns: false, @@ -5706,8 +5718,8 @@ fn state_load_resets_transient_video_latches() { width_words: 1, attached: false, }), + ..DisplaySpriteDmaState::default() }; - bus.display_dma_sprite_state[3].terminated = true; bus.reset_transient_video_after_state_load(); @@ -5722,13 +5734,13 @@ fn state_load_resets_transient_video_latches() { assert!(bus.last_frame_bitplane_rows.iter().all(Option::is_none)); assert!(bus.current_frame_sprite_lines.is_empty()); assert!(bus.last_frame_sprite_lines.is_empty()); - for state in bus.display_dma_sprite_state { - assert!(state.control.is_none()); - assert!(state.next_ptr.is_none()); - assert!(!state.terminated); - assert!(!state.data_dma_active); - assert!(state.last_line.is_none()); - } + // The sprite DMA state is chip state (register copies, DMA flip-flops, + // display latches): a state load restores it rather than clearing it. + let restored = bus.display_dma_sprite_state[0]; + assert_eq!(restored.vstrt, 0x20); + assert_eq!(restored.vstop, 0x40); + assert!(restored.dma_enabled); + assert!(restored.last_line.is_some()); } #[test] @@ -5825,7 +5837,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 +5861,11 @@ 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); + // 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); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -5908,14 +5924,18 @@ 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); + // 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); 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); @@ -5952,10 +5972,14 @@ fn sprite_dma_capture_samples_later_pairs_at_their_fetch_slot() { bus.display_dma_sprpt[0] = sprite0_ptr as u32; bus.display_dma_sprpt[6] = sprite6_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(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(); @@ -5986,21 +6010,22 @@ fn sprite_pointer_write_at_pair_slot_seeds_next_line_descriptor_fetch() { bus.denise.sprpt[0] = old_ptr as u32; bus.display_dma_sprpt[0] = old_ptr as u32; - bus.agnus.vpos = 0x24; + bus.agnus.vpos = 0x10; bus.agnus.hpos = SPRITE_DMA_SLOT1_HPOS[0] - 1; bus.advance_chipset(1); 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); - - 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(2); + bus.advance_chipset(4); let lines = bus.frame_captured_sprite_lines(); assert_eq!(lines.len(), 1); @@ -6016,7 +6041,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!( @@ -6053,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(); @@ -6088,7 +6117,11 @@ 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); + // 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(); assert!(lines.iter().any(|line| line.sprite == 6)); @@ -6115,14 +6148,18 @@ 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); + // 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; 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 @@ -6158,12 +6195,12 @@ 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(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 +6229,17 @@ 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); + // 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; - 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 @@ -6235,10 +6276,12 @@ 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; - bus.advance_chipset(2); + bus.advance_chipset(4); } let words: Vec<(i32, i32, u16, u16)> = bus @@ -6282,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); @@ -6668,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) @@ -6708,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 { @@ -7402,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; @@ -7433,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); @@ -7498,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); @@ -7540,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); @@ -7590,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); @@ -7637,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; @@ -8261,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); @@ -8277,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; 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 {