From 8bd676bc0fdeececa355eef4d90ea6f2bb64eaeb Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Mon, 13 Jul 2026 08:33:40 +0100 Subject: [PATCH 1/2] denise: sprite DMA fetches land in a hardware-true display-latch view Sprite DMA fetches write the same SPRxPOS/CTL/DATA/DATB registers a CPU/Copper write hits, but the emulated fetches only poked the Agnus-side FSM: the Denise display latches never saw them, so denise.spr_armed and the data latches stayed whatever the last manual write left, forever. Since the armed-latch redisplay landed (armed sprite latches keep serializing across DMA-idle frames), that stale state became visible: software that runs a DMA sprite scene, lets the null terminator's CTL fetch disarm every channel, turns SPREN off, and re-arms with the SPRxDATA=$0000 idiom expects invisible sprites (the latches hold the DMA-written zeros on real hardware), not a full-height redisplay of a manual pattern written thousands of frames earlier. Denise now keeps two views of the sprite registers: - sprpos/sprctl/sprdata/sprdatb/spr_armed stay the CPU/Copper write shadow. The render replay's manual-sprite model and the live collision path are calibrated against it and are bit-identical to before (seeding the replay from hardware-true registers instead regressed the vAmigaTS oldsprtimdat render, which a real-A500 photo pins). - spr_hw_pos/ctl/data/datb/armed are the registers as the hardware holds them: last writer wins across manual writes AND DMA fetches (a DATA fetch arms, the vstop control fetch - including the 0/0 terminator - disarms). The DMA-idle latched redisplay seeds from this view. Only the authoritative sprite-DMA pass for a line writes the hardware view through: pre-display lines are computed twice (a provisional live pass, then the pre-display replay at the display start re-runs them with the frame's final DMACON/SPRxPT event timeline and owns the result), and the provisional pass can fetch bogus data lines when SPREN is enabled near the frame wrap, before the vertical-blank reset's control fetch nulls stale comparators. STATE_VERSION 27 -> 28: Denise and RenderRegisterSnapshot gained the hardware-view fields. Regression example: Hamazing's kaleidoscope scene (~132s) painted the transition scene's sprite bars over the right 40% of the display; it now renders clean from boot, byte-identical to before at every other scene. The vAmigaTS Denise/Sprites family (214 renders) is byte-identical to the pre-fix build. --- docs/internals/video.md | 17 +++++ src/bus.rs | 10 +++ src/bus/custom_regs.rs | 2 +- src/bus/frame_capture.rs | 65 ++++++++++++++-- src/bus/tests.rs | 68 +++++++++++++++++ src/chipset/denise.rs | 63 +++++++++++++++ src/savestate.rs | 7 +- src/video/bitplane.rs | 30 +++++++- src/video/bitplane/sprite.rs | 40 +++++++--- src/video/bitplane/tests.rs | 143 +++++++++++++++++++++++++++++++++++ 10 files changed, 426 insertions(+), 19 deletions(-) diff --git a/docs/internals/video.md b/docs/internals/video.md index 4b1a9794..5ac5a60d 100644 --- a/docs/internals/video.md +++ b/docs/internals/video.md @@ -137,6 +137,23 @@ comparator. A vblank arm sequence with VSTART equal to VSTOP therefore displays full-height columns, which is how Gen-X draws the vertical edge-masking line sprites of its shutter transitions. +Because DMA fetches land in the same SPRxPOS/CTL/DATA/DATB registers a +CPU/Copper write hits, Denise keeps two views of them: the CPU/Copper write +shadow (`sprpos`/`sprctl`/`sprdata`/`sprdatb`/`spr_armed`), which the +manual replay above and the live collision path are calibrated against, and +the hardware-true view (`spr_hw_*`), which additionally receives every +sprite DMA fetch -- a DATA fetch arms it, the vstop control fetch +(including the 0/0 list terminator) disarms it. The DMA-idle latched +redisplay seeds from the hardware-true view: software relies on the +terminator's CTL to silence a channel for good, so a later bare +SPRxDATA arm must redisplay the DMA-written words, not the last manual +pattern (Hamazing's scene switch writes SPRxDATA=$0000 after a DMA sprite +scene and expects invisible sprites; the stale write-shadow pattern would +paint full-height bars). Only the authoritative sprite-DMA pass for a line +writes the hardware view through: pre-display lines are computed twice, and +the pre-display replay at the display start owns them (`sprprobe-latch` in +timing-test/ pins the whole sequence). + The mapping from beam coordinates to framebuffer x is anchored by constants that encode the hardware's fetch-to-display pipeline delays -- register writes, palette writes, and bitplane data each land at their own diff --git a/src/bus.rs b/src/bus.rs index 7e9bba0e..2eabb814 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -1274,11 +1274,21 @@ pub struct RenderRegisterSnapshot { pub bplpt: [u32; 8], pub bpldat: [u16; 8], pub sprpt: [u32; 8], + /// CPU/Copper write-shadow sprite registers (see the matching fields + /// on `Denise`): the render replay and live collisions are calibrated + /// against these. pub sprpos: [u16; 8], pub sprctl: [u16; 8], pub sprdata: [u16; 8], pub sprdatb: [u16; 8], pub spr_armed: [bool; 8], + /// Hardware-true sprite registers (manual writes AND sprite DMA + /// fetches): the armed-latch redisplay source for DMA-idle frames. + pub spr_hw_pos: [u16; 8], + pub spr_hw_ctl: [u16; 8], + pub spr_hw_data: [u16; 8], + pub spr_hw_datb: [u16; 8], + pub spr_hw_armed: [bool; 8], pub bpl1mod: i16, pub bpl2mod: i16, pub palette: Palette, diff --git a/src/bus/custom_regs.rs b/src/bus/custom_regs.rs index cd01a37c..849e9ebb 100644 --- a/src/bus/custom_regs.rs +++ b/src/bus/custom_regs.rs @@ -1141,7 +1141,7 @@ impl Bus { if idx < 8 { match reg { 0x0 => { - self.denise.sprpos[idx] = val; + self.denise.write_sprpos(idx, val); self.latch_display_sprite_dma_control_from_registers( idx, SpriteControlRegisterWrite::Pos, diff --git a/src/bus/frame_capture.rs b/src/bus/frame_capture.rs index d0d9f773..f41c78f7 100644 --- a/src/bus/frame_capture.rs +++ b/src/bus/frame_capture.rs @@ -477,12 +477,12 @@ impl Bus { self.harddis_active(), ); if slot_hpos == slot1_hpos { - self.sprite_slot1(sprite, vpos, spren, ddf_blocked); + self.sprite_slot1(sprite, vpos, spren, ddf_blocked, true); } 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.sprite_slot2(sprite, vpos, spren, ddf_blocked, true); } } } @@ -583,6 +583,14 @@ impl Bus { let mut fetched_lines = 0usize; let bitplane_bplcon0 = self.effective_bitplane_bplcon0(); let bitplane_dmacon = self.effective_bitplane_dmacon(); + // Lines above the display start are provisional here: the + // pre-display replay re-runs them at the display start with the + // frame's final DMACON/SPRxPT event timeline and owns their latch + // write-through (sprite_slot1 doc). TODO: a mid-frame DIWSTRT + // rewrite that moves the display start re-runs the overlap; the + // accurate model writes the latch once, at the single hardware + // fetch time of each slot. + let latch_write_through = vpos >= self.display_start_vpos_for_current_control(); 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 @@ -619,7 +627,13 @@ impl Bus { // 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); + self.sprite_slot1( + sprite, + vpos, + spren_at(dmacon_now), + ddf_blocked, + latch_write_through, + ); } } if !(old_hpos..new_hpos).contains(&slot2_hpos) { @@ -637,7 +651,13 @@ impl Bus { } let mut captured_line = false; { - let line = self.sprite_slot2(sprite, vpos, slot2_enabled, ddf_blocked); + let line = self.sprite_slot2( + sprite, + vpos, + slot2_enabled, + ddf_blocked, + latch_write_through, + ); if let Some(line) = line { // COPPERLINE_DIAG_SPRCAP=BEAMY|all: log captured sprite // lines on that beam line (frame, channel, position, words, @@ -709,8 +729,8 @@ impl Bus { if self.sprite_dma_inhibited_by_vertical_blank_at(vpos) { return None; } - self.sprite_slot1(sprite, vpos, true, false); - self.sprite_slot2(sprite, vpos, true, false) + self.sprite_slot1(sprite, vpos, true, false, true); + self.sprite_slot2(sprite, vpos, true, false, true) } /// Lazily run the start-of-line comparator update for every line since @@ -786,12 +806,19 @@ impl Bus { /// 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. + /// `latch_write_through` says whether this pass is the authoritative + /// sprite-DMA pass for the line: pre-display lines are computed twice + /// (a provisional live pass, then the pre-display replay at the display + /// start re-runs them with the frame's final DMACON/SPRxPT event + /// timeline and owns the result), and only the authoritative pass may + /// land its fetches in the Denise display latches. pub(super) fn sprite_slot1( &mut self, sprite: usize, vpos: u32, spren: bool, ddf_blocked: bool, + latch_write_through: bool, ) { let beam_y = vpos as i32; let mut state = self.display_dma_sprite_state[sprite]; @@ -804,6 +831,9 @@ impl Bus { if let Some(words) = self.fetch_sprite_words(sprite) { state.poke_pos(words[0]); state.reevaluate_comparators_at(beam_y); + if latch_write_through { + self.denise.dma_write_sprpos(sprite, words[0]); + } } } } else if state.dma_enabled @@ -815,6 +845,11 @@ impl Bus { 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; + // The DATA fetch arms the display latch exactly like a + // manual SPRxDATA write. + if latch_write_through { + self.denise.dma_write_sprdata(sprite, words[0]); + } } } self.display_dma_sprite_state[sprite] = state; @@ -833,6 +868,7 @@ impl Bus { vpos: u32, spren: bool, ddf_blocked: bool, + latch_write_through: bool, ) -> Option { let beam_y = vpos as i32; let mut state = self.display_dma_sprite_state[sprite]; @@ -848,6 +884,14 @@ impl Bus { if let Some(words) = self.fetch_sprite_words(sprite) { state.poke_ctl(words[0]); state.reevaluate_comparators_at(beam_y); + // The CTL fetch is a SPRxCTL write: it disarms the live + // display latch. Software relies on the null terminator's + // CTL to silence a channel for good (arming it later with + // SPRxDATA redisplays whatever the registers hold, so the + // latch has to track DMA truth, not the last manual write). + if latch_write_through { + self.denise.dma_write_sprctl(sprite, words[0]); + } } } self.display_dma_sprite_state[sprite] = state; @@ -861,6 +905,10 @@ impl Bus { { if let Some(words) = self.fetch_sprite_words(sprite) { datb_fetched = Some((words[0], [words[1], words[2], words[3]])); + // The DATB fetch lands in SPRxDATB like a manual write. + if latch_write_through { + self.denise.dma_write_sprdatb(sprite, words[0]); + } } } // Sprites captured as "held" at the visible start are repainted by @@ -1498,6 +1546,11 @@ impl Bus { sprdata: self.denise.sprdata, sprdatb: self.denise.sprdatb, spr_armed: self.denise.spr_armed, + spr_hw_pos: self.denise.spr_hw_pos, + spr_hw_ctl: self.denise.spr_hw_ctl, + spr_hw_data: self.denise.spr_hw_data, + spr_hw_datb: self.denise.spr_hw_datb, + spr_hw_armed: self.denise.spr_hw_armed, bpl1mod: self.denise.bpl1mod, bpl2mod: self.denise.bpl2mod, palette: self.denise.palette, diff --git a/src/bus/tests.rs b/src/bus/tests.rs index 5909b716..cb6819d5 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -5998,6 +5998,74 @@ fn sprite_dma_zero_height_descriptor_terminates_stream() { assert!(lines.is_empty()); } +#[test] +fn sprite_dma_fetches_land_in_hardware_latch_view() { + // A DMA DATA fetch arms the hardware-true display latch and overwrites + // its data words; a DMA CTL fetch (the vstop control words, here the + // 0/0 terminator) disarms it and leaves the fetched control words in + // the registers. The CPU/Copper write shadow never sees DMA, so the + // calibrated manual replay keeps its inputs. Software relies on the + // hardware view across a scene switch: Hamazing re-arms its sprites + // with SPRxDATA=$0000 after a DMA sprite scene and expects the + // DMA-written zeros (an invisible sprite), not a redisplay of the last + // manually written pattern as full-height stale bars. + let mut bus = empty_bus(); + + // A manual arm long before the DMA scene: both views hold the bar. + assert!(!bus.write_custom_word_from(0x140, 0x1080, BeamWriteSource::Cpu)); + assert!(!bus.write_custom_word_from(0x146, 0xAAAA, BeamWriteSource::Cpu)); + assert!(!bus.write_custom_word_from(0x144, 0xFFFF, BeamWriteSource::Cpu)); + assert!(bus.denise.spr_armed[0]); + assert!(bus.denise.spr_hw_armed[0]); + + // One DMA data line, then the 0/0 terminator. + let sprite_ptr = 0x0100usize; + let (pos, ctl) = sprite_control_words(0x2C, 0x2D, 0x0091); + write_chip_word(&mut bus, sprite_ptr, pos); + write_chip_word(&mut bus, sprite_ptr + 2, ctl); + write_chip_word(&mut bus, sprite_ptr + 4, 0x1234); + write_chip_word(&mut bus, sprite_ptr + 6, 0x0000); + write_chip_word(&mut bus, sprite_ptr + 8, 0); + write_chip_word(&mut bus, sprite_ptr + 10, 0); + + bus.agnus.dmacon = DMACON_DMAEN | DMACON_SPREN; + bus.denise.sprpt[0] = sprite_ptr as u32; + bus.display_dma_sprpt[0] = sprite_ptr as u32; + + sprite_fetch_control_words_at_reset_line(&mut bus); + let line = bus.captured_sprite_line_at(0, 0x2C); + assert!(line.is_some()); + assert!( + bus.denise.spr_hw_armed[0], + "the DATA fetch arms the hardware latch" + ); + assert_eq!(bus.denise.spr_hw_data[0], 0x1234); + assert_eq!(bus.denise.spr_hw_datb[0], 0x0000); + + // The vstop line consumes the terminator: POS/CTL land in the hardware + // view and the CTL write disarms it. + let line = bus.captured_sprite_line_at(0, 0x2D); + assert!(line.is_none()); + assert!( + !bus.denise.spr_hw_armed[0], + "the terminator CTL fetch disarms the hardware latch" + ); + assert_eq!(bus.denise.spr_hw_pos[0], 0); + assert_eq!(bus.denise.spr_hw_ctl[0], 0); + + // The write shadow never sees DMA: still the manual bar. + assert!(bus.denise.spr_armed[0]); + assert_eq!(bus.denise.sprdata[0], 0xFFFF); + assert_eq!(bus.denise.sprdatb[0], 0xAAAA); + + // The scene-switch arm-with-zero: the hardware view arms with A=0 over + // the DMA-written B=0 -- an invisible sprite, not the stale manual bar. + assert!(!bus.write_custom_word_from(0x144, 0x0000, BeamWriteSource::Cpu)); + assert!(bus.denise.spr_hw_armed[0]); + assert_eq!(bus.denise.spr_hw_data[0], 0x0000); + assert_eq!(bus.denise.spr_hw_datb[0], 0x0000); +} + #[test] fn sprite_dma_capture_wraps_control_words_at_chip_ram_end() { let mut bus = empty_bus(); diff --git a/src/chipset/denise.rs b/src/chipset/denise.rs index a65799c8..586a2805 100644 --- a/src/chipset/denise.rs +++ b/src/chipset/denise.rs @@ -275,11 +275,28 @@ pub struct Denise { pub bplpt: [u32; 8], pub bpldat: [u16; 8], pub sprpt: [u32; 8], + /// SPRxPOS/CTL/DATA/DATB as CPU/Copper writes left them, with `spr_armed` + /// tracking those writes only (DATA arms, CTL disarms). The render + /// replay's manual-sprite model is calibrated against this + /// write-shadow view; sprite DMA fetches do not land here. pub sprpos: [u16; 8], pub sprctl: [u16; 8], pub sprdata: [u16; 8], pub sprdatb: [u16; 8], pub spr_armed: [bool; 8], + /// The same registers as the hardware holds them: last-writer-wins + /// across CPU/Copper writes AND sprite DMA fetches (a DMA DATA fetch + /// arms the display latch, a DMA CTL fetch -- vstop control words or + /// the 0/0 list terminator -- disarms it). Feeds the armed-latch + /// redisplay in frames with sprite DMA idle: software relies on the + /// terminator's CTL to silence a channel for good, so a later bare + /// SPRxDATA write redisplays the DMA-written words, not the last + /// manual pattern (Hamazing scene-switch regression class). + pub spr_hw_pos: [u16; 8], + pub spr_hw_ctl: [u16; 8], + pub spr_hw_data: [u16; 8], + pub spr_hw_datb: [u16; 8], + pub spr_hw_armed: [bool; 8], pub bpl1mod: i16, pub bpl2mod: i16, pub diwstrt: u16, @@ -380,6 +397,11 @@ impl Denise { sprdata: [0; 8], sprdatb: [0; 8], spr_armed: [false; 8], + spr_hw_pos: [0; 8], + spr_hw_ctl: [0; 8], + spr_hw_data: [0; 8], + spr_hw_datb: [0; 8], + spr_hw_armed: [false; 8], bpl1mod: 0, bpl2mod: 0, diwstrt: 0, @@ -434,10 +456,19 @@ impl Denise { } } + pub fn write_sprpos(&mut self, idx: usize, val: u16) { + if idx < self.sprpos.len() { + self.sprpos[idx] = val; + self.spr_hw_pos[idx] = val; + } + } + pub fn write_sprctl(&mut self, idx: usize, val: u16) { if idx < self.sprctl.len() { self.sprctl[idx] = val; self.spr_armed[idx] = false; + self.spr_hw_ctl[idx] = val; + self.spr_hw_armed[idx] = false; } } @@ -445,12 +476,44 @@ impl Denise { if idx < self.sprdata.len() { self.sprdata[idx] = val; self.spr_armed[idx] = true; + self.spr_hw_data[idx] = val; + self.spr_hw_armed[idx] = true; } } pub fn write_sprdatb(&mut self, idx: usize, val: u16) { if idx < self.sprdatb.len() { self.sprdatb[idx] = val; + self.spr_hw_datb[idx] = val; + } + } + + /// A sprite DMA fetch landing in the registers: same effect as the + /// matching CPU/Copper write on the hardware-true view, but invisible + /// to the CPU/Copper write shadow the render replay is seeded from. + pub fn dma_write_sprpos(&mut self, idx: usize, val: u16) { + if idx < self.spr_hw_pos.len() { + self.spr_hw_pos[idx] = val; + } + } + + pub fn dma_write_sprctl(&mut self, idx: usize, val: u16) { + if idx < self.spr_hw_ctl.len() { + self.spr_hw_ctl[idx] = val; + self.spr_hw_armed[idx] = false; + } + } + + pub fn dma_write_sprdata(&mut self, idx: usize, val: u16) { + if idx < self.spr_hw_data.len() { + self.spr_hw_data[idx] = val; + self.spr_hw_armed[idx] = true; + } + } + + pub fn dma_write_sprdatb(&mut self, idx: usize, val: u16) { + if idx < self.spr_hw_datb.len() { + self.spr_hw_datb[idx] = val; } } diff --git a/src/savestate.rs b/src/savestate.rs index 01840efd..ede022ca 100644 --- a/src/savestate.rs +++ b/src/savestate.rs @@ -94,7 +94,12 @@ const STATE_MAGIC: &[u8; 8] = b"CLSSTATE"; // 27: LineBlitState gained the USEB line-program state (use_a/use_b flags, // the live B pointer bpt) and LineBlitPhase the two extra USEB pixel // cycles (LB fetch, LBus bare bus cycle) -pub const STATE_VERSION: u32 = 27; +// 28: Denise and RenderRegisterSnapshot gained the hardware-true sprite +// latch view (spr_hw_pos/ctl/data/datb/armed - CPU/Copper writes AND +// sprite DMA fetches, last writer wins; the existing spr* fields +// remain the CPU/Copper write shadow the render replay is calibrated +// against) +pub const STATE_VERSION: u32 = 28; /// Default state file name, timestamped like the screenshot/recorder names. pub fn auto_filename() -> std::path::PathBuf { diff --git a/src/video/bitplane.rs b/src/video/bitplane.rs index 7d3d3dfe..9c6cabc2 100644 --- a/src/video/bitplane.rs +++ b/src/video/bitplane.rs @@ -1874,11 +1874,22 @@ struct RenderState { bplpt: [u32; 8], bpldat: [u16; 8], sprpt: [u32; 8], + /// CPU/Copper write-shadow sprite registers: the replay's manual-sprite + /// model is calibrated against these (sprite DMA fetches never land + /// here). See the matching fields on `Denise`. sprpos: [u16; 8], sprctl: [u16; 8], sprdata: [u16; 8], sprdatb: [u16; 8], spr_armed: [bool; 8], + /// Hardware-true sprite registers (CPU/Copper writes AND sprite DMA + /// fetches, last writer wins). Seeds the armed-latch redisplay in + /// frames with sprite DMA idle. + spr_hw_pos: [u16; 8], + spr_hw_ctl: [u16; 8], + spr_hw_data: [u16; 8], + spr_hw_datb: [u16; 8], + spr_hw_armed: [bool; 8], bpl1mod: i16, bpl2mod: i16, palette: Palette, @@ -1911,6 +1922,11 @@ impl RenderState { sprdata: snapshot.sprdata, sprdatb: snapshot.sprdatb, spr_armed: snapshot.spr_armed, + spr_hw_pos: snapshot.spr_hw_pos, + spr_hw_ctl: snapshot.spr_hw_ctl, + spr_hw_data: snapshot.spr_hw_data, + spr_hw_datb: snapshot.spr_hw_datb, + spr_hw_armed: snapshot.spr_hw_armed, bpl1mod: snapshot.bpl1mod, bpl2mod: snapshot.bpl2mod, palette: snapshot.palette, @@ -2996,16 +3012,26 @@ fn apply_move(state: &mut RenderState, off: u16, val: u16) { let reg = (off - 0x140) & 0x0006; if idx < 8 { match reg { - 0x0 => state.sprpos[idx] = val, + 0x0 => { + state.sprpos[idx] = val; + state.spr_hw_pos[idx] = val; + } 0x2 => { state.sprctl[idx] = val; state.spr_armed[idx] = false; + state.spr_hw_ctl[idx] = val; + state.spr_hw_armed[idx] = false; } 0x4 => { state.sprdata[idx] = val; state.spr_armed[idx] = true; + state.spr_hw_data[idx] = val; + state.spr_hw_armed[idx] = true; + } + 0x6 => { + state.sprdatb[idx] = val; + state.spr_hw_datb[idx] = val; } - 0x6 => state.sprdatb[idx] = val, _ => {} } } diff --git a/src/video/bitplane/sprite.rs b/src/video/bitplane/sprite.rs index 28ff0be7..fc63f548 100644 --- a/src/video/bitplane/sprite.rs +++ b/src/video/bitplane/sprite.rs @@ -179,13 +179,34 @@ pub(super) struct BeamSpriteState { } impl BeamSpriteState { + /// `use_hardware_latch` picks the register view the replay starts from: + /// with sprite DMA idle this frame the armed latches follow Denise's own + /// rules across DMA history, so the hardware-true view (manual writes + /// AND DMA fetches) is the source; with captured DMA driving the frame + /// the replay owns only CPU/Copper writes and is calibrated against the + /// write-shadow view. pub(super) fn from_render_state( state: &RenderState, held: &[Option; 8], + use_hardware_latch: bool, ) -> Self { - let mut sprpos = state.sprpos; - let mut sprctl = state.sprctl; - let mut spr_armed = state.spr_armed; + let (mut sprpos, mut sprctl, sprdata, sprdatb, mut spr_armed) = if use_hardware_latch { + ( + state.spr_hw_pos, + state.spr_hw_ctl, + state.spr_hw_data, + state.spr_hw_datb, + state.spr_hw_armed, + ) + } else { + ( + state.sprpos, + state.sprctl, + state.sprdata, + state.sprdatb, + state.spr_armed, + ) + }; for (i, h) in held.iter().enumerate() { if let Some(held) = h { let (pos, ctl) = sprite_control_words_from_parts( @@ -203,8 +224,8 @@ impl BeamSpriteState { Self { sprpos, sprctl, - sprdata: state.sprdata, - sprdatb: state.sprdatb, + sprdata, + sprdatb, spr_armed, direct_data_armed: [false; 8], aga: matches!(state.agnus_revision, AgnusRevision::AgaAlice), @@ -385,7 +406,8 @@ pub(super) fn manual_sprite_lines_from_events_with_visible_line0( include_latched_sprite_state: bool, sprite_dma_observed: bool, ) -> Vec> { - let mut regs = BeamSpriteState::from_render_state(initial_state, held); + let mut regs = + BeamSpriteState::from_render_state(initial_state, held, include_latched_sprite_state); if sprite_dma_observed && !include_latched_sprite_state { regs.disarm_nonheld_latched_output(); } @@ -518,7 +540,7 @@ pub(super) fn manual_sprite_lines_from_captured_dma_reuse( vstart: beam_y, vstop: beam_y + 1, }); - let mut regs = BeamSpriteState::from_render_state(initial_state, &held); + let mut regs = BeamSpriteState::from_render_state(initial_state, &held, false); let mut next_beam = (visible_end, 0usize); let dma_hpos = SPRITE_DMA_PAIR_CAPTURE_HPOS[sprite / 2]; @@ -1403,10 +1425,10 @@ pub(super) fn collect_sprite_lines( } pub(super) fn register_latched_sprite_lines(sprite: usize, state: &RenderState) -> Vec { - if !state.spr_armed[sprite] { + if !state.spr_hw_armed[sprite] { return Vec::new(); } - let regs = BeamSpriteState::from_render_state(state, &[None; 8]); + let regs = BeamSpriteState::from_render_state(state, &[None; 8], true); let pos = regs.sprpos[sprite]; let ctl = regs.sprctl[sprite]; (sprite_vstart(pos, ctl)..sprite_vstop(ctl)) diff --git a/src/video/bitplane/tests.rs b/src/video/bitplane/tests.rs index f852c06f..26441723 100644 --- a/src/video/bitplane/tests.rs +++ b/src/video/bitplane/tests.rs @@ -572,6 +572,11 @@ fn blank_state() -> RenderState { sprdata: [0; 8], sprdatb: [0; 8], spr_armed: [false; 8], + spr_hw_pos: [0; 8], + spr_hw_ctl: [0; 8], + spr_hw_data: [0; 8], + spr_hw_datb: [0; 8], + spr_hw_armed: [false; 8], bpl1mod: 0, bpl2mod: 0, palette: Palette::from_ocs([0x0103; 32]), @@ -2206,9 +2211,13 @@ fn shres_sprite_control_bit4_adds_70ns_horizontal_offset() { }; state.palette.write_ocs(17, 0x0F00); state.sprpos[0] = pos; + state.spr_hw_pos[0] = pos; state.sprctl[0] = ctl | 0x0010; + state.spr_hw_ctl[0] = ctl | 0x0010; state.sprdata[0] = 0x8000; + state.spr_hw_data[0] = 0x8000; state.spr_armed[0] = true; + state.spr_hw_armed[0] = true; let ram = vec![0; 64]; let base_palettes = [state.palette; FB_HEIGHT]; let palette_segments = vec![Vec::new(); FB_HEIGHT]; @@ -2426,7 +2435,9 @@ fn manual_sprite_data_writes_affect_only_later_beam_lines() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.palette.write_ocs(17, 0x0F00); let write_line = PAL_VISIBLE_LINE0 + 4; @@ -2499,10 +2510,15 @@ fn armed_sprite_latch_keeps_serializing_in_frames_without_sprite_writes() { DIW_HSTART_FB0 as u16 + 40, ); initial_state.sprpos[7] = pos; + initial_state.spr_hw_pos[7] = pos; initial_state.sprctl[7] = ctl; + initial_state.spr_hw_ctl[7] = ctl; initial_state.sprdata[7] = 0xF000; + initial_state.spr_hw_data[7] = 0xF000; initial_state.sprdatb[7] = 0xF000; + initial_state.spr_hw_datb[7] = 0xF000; initial_state.spr_armed[7] = true; + initial_state.spr_hw_armed[7] = true; // Sprite DMA idle: the latch emits on every line of the window even // though this frame carries no sprite register writes. @@ -2545,9 +2561,13 @@ fn latched_sprite_vstart_equal_vstop_is_empty() { let beam_y = PAL_VISIBLE_LINE0; let (pos, ctl) = sprite_control_words(beam_y as u16, beam_y as u16, DIW_HSTART_FB0 as u16); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0xFFFF; + initial_state.spr_hw_data[0] = 0xFFFF; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; let manual_sprite_lines = manual_sprite_lines_from_events_with_visible_line0( &initial_state, @@ -2572,7 +2592,9 @@ fn direct_sprite_data_write_ignores_dma_vertical_window() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; let manual_sprite_lines = manual_sprite_lines_from_events_with_visible_line0( &initial_state, @@ -2605,7 +2627,9 @@ fn manual_sprite_data_write_replicates_words_across_fmode_wide_register() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; let events = [ cpu_event(PAL_VISIBLE_LINE0 as u32, 0, 0x146, 0x00FF), @@ -2635,7 +2659,9 @@ fn manual_sprite_width_stays_one_word_without_aga_lisa() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; let events = [cpu_event(PAL_VISIBLE_LINE0 as u32, 0, 0x144, 0x8001)]; let manual_sprite_lines = manual_sprite_lines_from_events(&initial_state, &events); @@ -2657,7 +2683,9 @@ fn manual_sprite_fmode_event_changes_width_for_later_lines() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; let fmode_line = PAL_VISIBLE_LINE0 + 4; let events = [ @@ -2687,9 +2715,13 @@ fn sprite_dma_capture_suppresses_latched_manual_sprite_spans() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0xFFFF; + initial_state.spr_hw_data[0] = 0xFFFF; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; let manual_sprite_lines = manual_sprite_lines_from_events_with_visible_line0( &initial_state, @@ -2709,9 +2741,13 @@ fn manual_sprite_replay_does_not_seed_from_frame_start_data_latch() { let mut initial_state = blank_state(); let (pos, ctl) = sprite_control_words(PAL_VISIBLE_LINE0 as u16, 0, DIW_HSTART_FB0 as u16); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0xFFFF; + initial_state.spr_hw_data[0] = 0xFFFF; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; let manual_sprite_lines = manual_sprite_lines_from_events_with_visible_line0( &initial_state, @@ -2737,9 +2773,13 @@ fn pre_dma_position_write_does_not_preserve_frame_start_latch_when_dma_observed( let post_dma_hstart = (DIW_HSTART_FB0 + 32) as u16; let (post_dma_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, post_dma_hstart); initial_state.sprpos[0] = old_pos; + initial_state.spr_hw_pos[0] = old_pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0x8000; + initial_state.spr_hw_data[0] = 0x8000; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; let manual_sprite_lines = manual_sprite_lines_from_events_with_visible_line0( &initial_state, @@ -2776,9 +2816,13 @@ fn post_dma_position_write_does_not_reuse_frame_start_latch_when_dma_observed() let reused_hstart = (DIW_HSTART_FB0 + 32) as u16; let (new_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, reused_hstart); initial_state.sprpos[0] = old_pos; + initial_state.spr_hw_pos[0] = old_pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0x8000; + initial_state.spr_hw_data[0] = 0x8000; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; let manual_sprite_lines = manual_sprite_lines_from_events_with_visible_line0( &initial_state, @@ -2803,10 +2847,15 @@ fn same_line_position_retime_does_not_reuse_frame_start_latch_when_dma_observed( let mut initial_state = blank_state(); let beam_y = 99; initial_state.sprpos[3] = 0x5020; + initial_state.spr_hw_pos[3] = 0x5020; initial_state.sprctl[3] = 0x0602; + initial_state.spr_hw_ctl[3] = 0x0602; initial_state.sprdata[3] = 0xE92D; + initial_state.spr_hw_data[3] = 0xE92D; initial_state.sprdatb[3] = 0x16FF; + initial_state.spr_hw_datb[3] = 0x16FF; initial_state.spr_armed[3] = true; + initial_state.spr_hw_armed[3] = true; let manual_sprite_lines = manual_sprite_lines_from_events_with_visible_line0( &initial_state, @@ -2828,6 +2877,7 @@ fn same_line_position_retime_does_not_reuse_frame_start_latch_when_dma_observed( fn pre_visible_data_write_seeds_latch_without_direct_output_guard() { let mut initial_state = blank_state(); initial_state.sprpos[3] = 0x5020; + initial_state.spr_hw_pos[3] = 0x5020; let manual_sprite_lines = manual_sprite_lines_from_events_with_visible_line0( &initial_state, @@ -2934,7 +2984,9 @@ fn manual_sprite_replay_starts_from_beam_timed_data_write() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; let manual_sprite_lines = manual_sprite_lines_from_events_with_visible_line0( &initial_state, @@ -2962,7 +3014,9 @@ fn early_line_position_write_does_not_reuse_previous_manual_sprite_data() { let beam_y = PAL_VISIBLE_LINE0; let (pos, ctl) = sprite_control_words(beam_y as u16, beam_y as u16 + 4, DIW_HSTART_FB0 as u16); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; let (next_pos, _) = sprite_control_words( (beam_y + 1) as u16, @@ -3050,7 +3104,9 @@ fn held_sprite_after_dma_disable_persists_past_descriptor_vstop() { let (pos, ctl) = sprite_control_words(live_vstart as u16, live_vstop as u16, live_hstart as u16); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; let mut held = [None; 8]; held[0] = Some(HeldSpriteLine { @@ -3151,9 +3207,13 @@ fn manual_sprite_position_write_before_hstart_uses_sprite_compare_domain() { let (old_pos, old_ctl) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, old_hstart); let (new_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, new_hstart); initial_state.sprpos[2] = old_pos; + initial_state.spr_hw_pos[2] = old_pos; initial_state.sprctl[2] = old_ctl; + initial_state.spr_hw_ctl[2] = old_ctl; initial_state.sprdata[2] = 0xFFFF; + initial_state.spr_hw_data[2] = 0xFFFF; initial_state.spr_armed[2] = true; + initial_state.spr_hw_armed[2] = true; // SPRxPOS writes update the Denise sprite comparator before the // general colour-output register domain reaches the same beam hpos. @@ -3210,9 +3270,13 @@ fn copper_sprite_position_write_repositions_four_cck_later_than_cpu() { let (old_pos, old_ctl) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, old_hstart); let (new_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, new_hstart); initial_state.sprpos[0] = old_pos; + initial_state.spr_hw_pos[0] = old_pos; initial_state.sprctl[0] = old_ctl; + initial_state.spr_hw_ctl[0] = old_ctl; initial_state.sprdata[0] = 0xFFFF; + initial_state.spr_hw_data[0] = 0xFFFF; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; let cpu_lines = manual_sprite_lines_from_events( &initial_state, @@ -3244,9 +3308,13 @@ fn manual_sprite_position_writes_use_denise_compare_lag() { let (first_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, first_hstart); let (second_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, second_hstart); initial_state.sprpos[0] = initial_pos; + initial_state.spr_hw_pos[0] = initial_pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0xFFFF; + initial_state.spr_hw_data[0] = 0xFFFF; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; let first_hpos = 64; let second_hpos = 72; @@ -3302,9 +3370,13 @@ fn manual_sprite_position_write_does_not_truncate_started_word() { let (first_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, first_hstart); let (second_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, second_hstart); initial_state.sprpos[0] = initial_pos; + initial_state.spr_hw_pos[0] = initial_pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0xFFFF; + initial_state.spr_hw_data[0] = 0xFFFF; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; let manual_sprite_lines = manual_sprite_lines_from_events( &initial_state, @@ -3356,9 +3428,13 @@ fn manual_sprite_position_write_on_compare_boundary_preserves_started_word() { let (first_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, first_hstart); let (second_pos, _) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, second_hstart); initial_state.sprpos[0] = initial_pos; + initial_state.spr_hw_pos[0] = initial_pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0xFFFF; + initial_state.spr_hw_data[0] = 0xFFFF; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; let boundary_hpos = u32::from(first_hstart / 2) + SPRITE_REGISTER_WRITE_PIPELINE_CCK; let manual_sprite_lines = manual_sprite_lines_from_events( @@ -3394,7 +3470,9 @@ fn manual_sprite_data_write_before_compare_uses_sprite_compare_domain() { let hstart = 240; let (pos, ctl) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, hstart); initial_state.sprpos[2] = pos; + initial_state.spr_hw_pos[2] = pos; initial_state.sprctl[2] = ctl; + initial_state.spr_hw_ctl[2] = ctl; initial_state.palette.write_ocs(25, 0x0F00); let event_hpos = 116; @@ -3433,9 +3511,13 @@ fn attached_manual_sprite_data_write_before_compare_uses_sprite_compare_domain() let hstart = 240; let (pos, ctl) = sprite_control_words(beam_y as u16, beam_y as u16 + 1, hstart); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprpos[1] = pos; + initial_state.spr_hw_pos[1] = pos; initial_state.sprctl[1] = ctl | 0x0080; + initial_state.spr_hw_ctl[1] = ctl | 0x0080; initial_state.palette.write_ocs(20, 0x0F00); let event_hpos = 116; @@ -3497,7 +3579,9 @@ fn manual_sprite_data_write_after_compare_waits_for_next_scanline() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.palette.write_ocs(17, 0x0F00); let after_compare_hpos = (DIW_HSTART_FB0 as u32 / 2) + SPRITE_REGISTER_WRITE_PIPELINE_CCK + 2; @@ -3563,7 +3647,9 @@ fn manual_sprite_datb_write_after_compare_waits_for_next_scanline() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.palette.write_ocs(17, 0x0F00); initial_state.palette.write_ocs(19, 0x00F0); @@ -3628,9 +3714,13 @@ fn manual_sprite_control_write_after_compare_preserves_loaded_word() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0xFFFF; + initial_state.spr_hw_data[0] = 0xFFFF; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; initial_state.palette.write_ocs(17, 0x0F00); let events = [ @@ -3700,9 +3790,13 @@ fn attached_manual_sprite_writes_draw_odd_bits_without_even_data_bits() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprpos[1] = pos; + initial_state.spr_hw_pos[1] = pos; initial_state.sprctl[1] = ctl | 0x0080; + initial_state.spr_hw_ctl[1] = ctl | 0x0080; initial_state.palette.write_ocs(20, 0x0F00); let events = [ @@ -3767,9 +3861,13 @@ fn attached_manual_sprite_data_after_compare_waits_for_next_scanline() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprpos[1] = pos; + initial_state.spr_hw_pos[1] = pos; initial_state.sprctl[1] = ctl | 0x0080; + initial_state.spr_hw_ctl[1] = ctl | 0x0080; initial_state.palette.write_ocs(20, 0x0F00); let after_compare_hpos = (DIW_HSTART_FB0 as u32 / 2) + SPRITE_REGISTER_WRITE_PIPELINE_CCK + 2; @@ -3838,11 +3936,17 @@ fn attached_manual_sprite_data_after_compare_preserves_loaded_even_pixels() { DIW_HSTART_FB0 as u16, ); initial_state.sprpos[0] = pos; + initial_state.spr_hw_pos[0] = pos; initial_state.sprctl[0] = ctl; + initial_state.spr_hw_ctl[0] = ctl; initial_state.sprdata[0] = 0xA000; + initial_state.spr_hw_data[0] = 0xA000; initial_state.spr_armed[0] = true; + initial_state.spr_hw_armed[0] = true; initial_state.sprpos[1] = pos; + initial_state.spr_hw_pos[1] = pos; initial_state.sprctl[1] = ctl | 0x0080; + initial_state.spr_hw_ctl[1] = ctl | 0x0080; initial_state.palette.write_ocs(17, 0x0F00); initial_state.palette.write_ocs(21, 0x00F0); @@ -4511,7 +4615,9 @@ fn dma_loaded_sprite_data_rearms_on_same_line_position_write() { state.palette.write_ocs(17, 0x0F00); state.palette.write_ocs(18, 0x00F0); state.sprdatb[0] = 0x8000; + state.spr_hw_datb[0] = 0x8000; state.spr_armed[0] = true; + state.spr_hw_armed[0] = true; let ram = vec![0; 64]; let base_palettes = [state.palette; FB_HEIGHT]; let palette_segments = vec![Vec::new(); FB_HEIGHT]; @@ -4614,7 +4720,9 @@ fn sprite_register_data_write_after_compare_preserves_dma_latch_on_same_beam_lin ); state.dmacon = DMACON_DMAEN | DMACON_SPREN; state.sprpos[0] = pos; + state.spr_hw_pos[0] = pos; state.sprctl[0] = ctl; + state.spr_hw_ctl[0] = ctl; state.palette.write_ocs(17, 0x0F00); let ram = vec![0; 64]; let base_palettes = [state.palette; FB_HEIGHT]; @@ -4974,15 +5082,25 @@ fn attached_manual_sprites_use_four_bit_color_indexes() { DIW_HSTART_FB0 as u16, ); state.sprpos[0] = pos; + state.spr_hw_pos[0] = pos; state.sprctl[0] = ctl; + state.spr_hw_ctl[0] = ctl; state.sprdata[0] = 0x8000; + state.spr_hw_data[0] = 0x8000; state.sprdatb[0] = 0; + state.spr_hw_datb[0] = 0; state.spr_armed[0] = true; + state.spr_hw_armed[0] = true; state.sprpos[1] = pos; + state.spr_hw_pos[1] = pos; state.sprctl[1] = ctl | 0x0080; + state.spr_hw_ctl[1] = ctl | 0x0080; state.sprdata[1] = 0x8000; + state.spr_hw_data[1] = 0x8000; state.sprdatb[1] = 0; + state.spr_hw_datb[1] = 0; state.spr_armed[1] = true; + state.spr_hw_armed[1] = true; state.palette.write_ocs(21, 0x00F0); let ram = vec![0; 64]; let base_palettes = [state.palette; FB_HEIGHT]; @@ -5026,15 +5144,25 @@ fn attached_manual_sprites_draw_odd_bits_without_even_data_bits() { DIW_HSTART_FB0 as u16, ); state.sprpos[0] = pos; + state.spr_hw_pos[0] = pos; state.sprctl[0] = ctl; + state.spr_hw_ctl[0] = ctl; state.sprdata[0] = 0; + state.spr_hw_data[0] = 0; state.sprdatb[0] = 0; + state.spr_hw_datb[0] = 0; state.spr_armed[0] = true; + state.spr_hw_armed[0] = true; state.sprpos[1] = pos; + state.spr_hw_pos[1] = pos; state.sprctl[1] = ctl | 0x0080; + state.spr_hw_ctl[1] = ctl | 0x0080; state.sprdata[1] = 0x8000; + state.spr_hw_data[1] = 0x8000; state.sprdatb[1] = 0; + state.spr_hw_datb[1] = 0; state.spr_armed[1] = true; + state.spr_hw_armed[1] = true; state.palette.write_ocs(20, 0x00F0); let ram = vec![0; 64]; let base_palettes = [state.palette; FB_HEIGHT]; @@ -5078,15 +5206,25 @@ fn attached_manual_sprite_pair_uses_even_control_attach_bit() { DIW_HSTART_FB0 as u16, ); state.sprpos[0] = pos; + state.spr_hw_pos[0] = pos; state.sprctl[0] = ctl | 0x0080; + state.spr_hw_ctl[0] = ctl | 0x0080; state.sprdata[0] = 0x8000; + state.spr_hw_data[0] = 0x8000; state.sprdatb[0] = 0; + state.spr_hw_datb[0] = 0; state.spr_armed[0] = true; + state.spr_hw_armed[0] = true; state.sprpos[1] = pos; + state.spr_hw_pos[1] = pos; state.sprctl[1] = ctl; + state.spr_hw_ctl[1] = ctl; state.sprdata[1] = 0x8000; + state.spr_hw_data[1] = 0x8000; state.sprdatb[1] = 0; + state.spr_hw_datb[1] = 0; state.spr_armed[1] = true; + state.spr_hw_armed[1] = true; state.palette.write_ocs(21, 0x00F0); let ram = vec![0; 64]; let base_palettes = [state.palette; FB_HEIGHT]; @@ -5130,10 +5268,15 @@ fn attached_manual_sprite_pair_decodes_odd_pixels_without_even_line() { DIW_HSTART_FB0 as u16, ); state.sprpos[1] = pos; + state.spr_hw_pos[1] = pos; state.sprctl[1] = ctl | 0x0080; + state.spr_hw_ctl[1] = ctl | 0x0080; state.sprdata[1] = 0x8000; + state.spr_hw_data[1] = 0x8000; state.sprdatb[1] = 0; + state.spr_hw_datb[1] = 0; state.spr_armed[1] = true; + state.spr_hw_armed[1] = true; state.palette.write_ocs(20, 0x00F0); let ram = vec![0; 64]; let base_palettes = [state.palette; FB_HEIGHT]; From 4ad5299c575f639d76ef4a233c06df759612a819 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Mon, 13 Jul 2026 08:34:00 +0100 Subject: [PATCH 2/2] timing-test: sprite display-latch DMA write-through probe sprprobe-latch renders the Hamazing scene-switch sequence as a static display: full-height bars armed manually with SPREN off, a DMA phase whose null-terminator CTL fetch disarms every channel and overwrites SPR0's data words, then SPREN off again with three manual re-arms. The steady frame must show exactly two bars: SPR2 (full manual re-arm after the DMA disarm) and SPR6 (DATA-only re-arm - its DATB latch still holds the phase-A word because control fetches never touch the data latches). SPR0's arm-with-zero must stay invisible (both data latches hold DMA-written zeros) and SPR3, left alone, must stay disarmed; a bar at either position is a latch write-through regression. DMACON is toggled on line $136, after the last display line, to keep the probe off the SPREN-enable frame-wrap edge where the provisional pre-display sprite pass diverges from the authoritative replay. Cross-checked against vAmiga (tools/vamiga-ref.sh): the steady render's bar positions, widths, and colour runs match pixel for pixel. Verified static (40s shot == 44s shot). Wired into tests/probe_golden.rs and blessed. --- tests/probe_golden.rs | 5 + timing-test/README.md | 15 +- timing-test/golden/sprprobe-latch.png | Bin 0 -> 124954 bytes timing-test/sprprobe-latch.asm | 198 ++++++++++++++++++++++++++ timing-test/sprprobe-latch.bin | Bin 0 -> 606 bytes 5 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 timing-test/golden/sprprobe-latch.png create mode 100644 timing-test/sprprobe-latch.asm create mode 100644 timing-test/sprprobe-latch.bin diff --git a/tests/probe_golden.rs b/tests/probe_golden.rs index 9dea0d1c..d736363d 100644 --- a/tests/probe_golden.rs +++ b/tests/probe_golden.rs @@ -357,4 +357,9 @@ probe_tests! { // enable/punch/disable/restart sequence (the issue #74 deferred // AUDxEN-disable regression class). golden_audprobe_en => probe("audprobe-en", "audprobe-en.bin", 40.0); + // Sprite DMA fetches land in the Denise display latches: the terminator + // CTL fetch disarms, DATA/DATB fetches overwrite, and a later bare + // SPRxDATA arm redisplays the DMA-written words (the Hamazing + // scene-switch stale-bar regression class). + golden_sprprobe_latch => probe("sprprobe-latch", "sprprobe-latch.bin", 40.0); } diff --git a/timing-test/README.md b/timing-test/README.md index 26e87330..34d14b8f 100644 --- a/timing-test/README.md +++ b/timing-test/README.md @@ -278,8 +278,8 @@ under `golden/`. The emulator core is deterministic, so any pixel change is a real behaviour change. Each probe is its own `#[test]`, so the harness runs the emulator boots in -parallel on the available cores (the full suite of 17 takes ~20 s on an -8-core host vs ~85 s sequentially). +parallel on the available cores (the full suite of 18 takes ~20 s on an +8-core host vs ~90 s sequentially). Covered: `timing-test` (all 32 timing rows as rendered hex), `ddfprobe` (DDF placement sweep), `ddfprobe-diw1` (DIW edge), `ddfprobe-toggle` @@ -299,12 +299,19 @@ pair -- the sprite register-FSM class), `regprobe-bytemirror` (CPU byte writes latch the mirrored word into COLOR00 -- both byte lanes must render the same magenta), `clxprobe` (the CLXDAT collision matrix rendered as bit cells for two CLXCON programs, including a plane enabled beyond the -BPU count), and `audprobe-en` (the AUD0 interrupt cadence as a +BPU count), `audprobe-en` (the AUD0 interrupt cadence as a one-row-per-line strip across a scripted AUDxEN enable / same-word disable-re-enable punch / disable / cold-restart sequence -- the issue #74 deferred AUDxEN-disable class, which the Paula HRM rework regressed once; the punch's gap-and-phase signature and the seamless handoff from DMA -cycle interrupts to the free-running IRQ-mode tick are both pinned). +cycle interrupts to the free-running IRQ-mode tick are both pinned), and +`sprprobe-latch` (sprite DMA fetches land in the Denise display latches: +bars armed manually with SPREN off, a DMA phase whose terminator CTL +fetch disarms every channel and overwrites SPR0's data words, then SPREN +off again with an arm-with-zero on SPR0 that must stay invisible, a full +manual re-arm on SPR2 and a DATA-only re-arm on SPR6 that must both +appear as full-height bars -- the Hamazing scene-switch stale-bar +regression class; vAmiga-verified). Excluded by design: `ddfprobe-cc5`/`-cc6` sit on deliberate race boundaries and would flip on any unrelated timing change, and `ddfprobe-cc7` replays a chip-RAM dump of a running demo that is not diff --git a/timing-test/golden/sprprobe-latch.png b/timing-test/golden/sprprobe-latch.png new file mode 100644 index 0000000000000000000000000000000000000000..29d2b164f9d6ff5408204d05b05edd5f0132da0b GIT binary patch literal 124954 zcmeI*PiWJ37{~E+vOgNEcnZk0Fb27`GB0keNN2~^32_^{j1}G3$qk`YS*;6X2%(H^ zC&3_;beCM{gf{~-<3W?7x37sa1#+Kj!V_K=nz^q1uO ze4fwy^E~1=>HjOwJyUvQ&yHQYLRI-(b5rBHC#!B> z+Vstf@16a5y4Zf_>#Jkmw;lYb@7QlY{`+0kPk;LO)OVfrpZt0# z8XM}maI2@cZ{WeVbj^tym$T7UAnx*7^I z3+?@_x$&yK9Wyrzf3@a@yEnDYOlD_)&#lXazfAm+=^V>sBZ<+})NuUzf#T`N9|zNS z+lF>`&t1z*Bu{nLJiYYiCAKfU*@~T&_eTV}?eqDC&#o>E#-~&9&2?kZeRFjuKfE}3 zwC+lHwt3sV@Zt0A^KajOjv`AZiKn;{UZzxkYZRx1>d`e1#qK3E^D57r0kgZ07s zSj`exikhOPs3~fSnxdwtDQb$EqNb=R>Hy39@nXCfFUE`UV!RkH-q=Eo4j8CWHo9o7+`zUIPnxdwtDQb$EqNb=R>hY?*9WyrzYeOleKBPXRKBPXR zKGxx1>d`e1#qJ{~cBt!dIUX__=m znkG$?rb*MJY0@-lnlw$CCQZ|d2(n0kNPtLyNPtLybuLddml9}snAC@@@=03)YzeR> zAl|VUX^h5(x(YmM9yO1eN6n+=QS+#I)I4e)HIJG{oykTLqp7Lk`1J!kY92L@nn%r} z=22TYZsoX@<5rGaId0{+mE&L{m+clYz;=WMDEdS0mLaYKoeorl=`uikhOPs3~fSnxdwtMFK`mpQ6t`ECD?E0|l!>$jzKJ5Ci>%*=OyFTpt zu&6q zRrAfohaIGfc96xUwgW?J;I(ElolNt39&D2>bOMn;p_|zuZZUC)G2#(w#DnM_u^4@! zJ-+H8?rTFl(|qb24dNvszH2XDp&yDQ7wRqIe}(gy>gxZ8ZOFy}9se UKIe1)_Z!Xn%&mji`qzB!7k2*46aWAK literal 0 HcmV?d00001