From 913f6d1e8cbe073c4dd301f23b695747eb637e82 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Mon, 6 Jul 2026 08:36:11 +0100 Subject: [PATCH] copper: COPxLC writes retarget a dormant Copper's program counter Real Agnus reloads the Copper PC from COP1LC at every vertical-blank strobe whether or not Copper DMA is enabled, and while the Copper has not been active in the current field - DMA off at that strobe and no COPEN 0->1 edge since - a write to the location register the PC was last strobed from moves the PC directly instead of only loading the latch. Once the Copper has been active in the field, location writes are latch-only until the next strobe, as before. (vAmiga models the same rule via activeInThisFrame in pokeCOPxLC; the vAmigaTS Agnus/Copper/lc family photographs it: a VERTB handler that rewrites COP1LC and then enables Copper DMA runs the NEW list.) Copperline tracked neither the active-in-field state nor which list the PC was strobed from, so location writes never retargeted and the lc tests ran the stale list. Also refresh a pending vertical-blank strobe's address on COP1LC rewrites in the wrap-to-strobe window (the restart now uses the live COP1LC value instead of the wrap-time capture). vAmigaTS: lc1/lc1b 49.5->0.0% (exact), lc2/lc4 70.3->0.0%, lc2b 39.6->0.0%, lc3 30.7->0.0%; copvbl3 23.4->7.2%, Copper/Irq/irq3 32.7->19.5%; Agnus/Copper family summed 1060->687%. No collateral (the remaining copvbl/oldJump movement reproduces on main and is the known PR #125 IRQ-delivery trade). Demo screenshot set byte-identical. Save states: STATE_VERSION 19 (two new Bus fields). --- src/bus.rs | 16 ++++++++++++++++ src/bus/custom_regs.rs | 13 +++++++++++++ src/bus/dma_slots.rs | 36 ++++++++++++++++++++++++++++++++++++ src/bus/tests.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ src/savestate.rs | 2 +- 5 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/bus.rs b/src/bus.rs index 9ee7158..faece82 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -707,6 +707,18 @@ pub struct Bus { /// collapse into one pending VERTB bit. pub pending_vbi: u32, pending_copper_frame_start: Option, + /// Whether the Copper has been active at any point in the current field: + /// true from the vertical-blank COP1LC strobe if Copper DMA was enabled + /// there, or from any COPEN 0->1 write since. While false (a dormant + /// Copper), a COPxLC write for the Copper's current list retargets the + /// Copper PC directly instead of only loading the location latch -- + /// hardware behaviour the vAmigaTS Agnus/Copper/lc family photographs + /// (vAmiga models it as activeInThisFrame in pokeCOPxLC). + copper_active_in_frame: bool, + /// Which location register the Copper PC was last strobed from (1 or 2): + /// the vertical-blank restart selects COP1LC, COPJMPx selects its list. + /// The dormant-retarget rule above only applies to the matching list. + copper_current_list: u8, /// Paula interrupt bits that were pending when the current /// autovector was delivered. CPU INTREQ clears use this to tell @@ -1964,6 +1976,8 @@ impl Bus { diag_lowmem_blit: false, pending_vbi: 0, pending_copper_frame_start: None, + copper_active_in_frame: false, + copper_current_list: 1, delivered_irq_pending: 0, pending_copper_irq_beam: None, delivered_copper_irq_beam: None, @@ -2609,6 +2623,8 @@ impl Bus { self.slice_preempted = false; self.pending_vbi = 0; self.pending_copper_frame_start = None; + self.copper_active_in_frame = false; + self.copper_current_list = 1; self.delivered_irq_pending = 0; self.pending_copper_irq_beam = None; self.delivered_copper_irq_beam = None; diff --git a/src/bus/custom_regs.rs b/src/bus/custom_regs.rs index 49f44aa..11a678d 100644 --- a/src/bus/custom_regs.rs +++ b/src/bus/custom_regs.rs @@ -449,33 +449,46 @@ impl Bus { } 0x080 => { self.agnus.set_cop1lc_high(val); + self.copper_lc_written(1); false } 0x082 => { self.agnus.set_cop1lc_low(val); + self.copper_lc_written(1); false } 0x084 => { self.agnus.set_cop2lc_high(val); + self.copper_lc_written(2); false } 0x086 => { self.agnus.set_cop2lc_low(val); + self.copper_lc_written(2); false } 0x088 => { self.pending_copper_frame_start = None; + self.copper_current_list = 1; self.copper.jump(self.agnus.cop1lc); false } 0x08A => { self.pending_copper_frame_start = None; + self.copper_current_list = 2; self.copper.jump(self.agnus.cop2lc); false } 0x096 => { let previous = self.effective_bitplane_dmacon(); + let copen_before = self.agnus.dmacon & crate::chipset::copper::DMACON_COPEN != 0; self.agnus.write_dmacon(val); + if !copen_before && self.agnus.dmacon & crate::chipset::copper::DMACON_COPEN != 0 { + // COPEN switched on mid-field: the Copper counts as + // active this field, so COPxLC writes stop retargeting + // its PC (see copper_lc_written). + self.copper_active_in_frame = true; + } if crate::envcfg::flag("COPPERLINE_DIAG_AUDIO_NOTES") && (val & 0x000F != 0 || self.agnus.dmacon & 0x000F != previous & 0x000F) { diff --git a/src/bus/dma_slots.rs b/src/bus/dma_slots.rs index e4ad07a..c0e7742 100644 --- a/src/bus/dma_slots.rs +++ b/src/bus/dma_slots.rs @@ -994,6 +994,42 @@ impl Bus { } self.pending_copper_frame_start = None; self.copper.frame_start(cop1lc); + // The vertical-blank strobe selects COP1LC and records whether the + // Copper is live this field; a dormant Copper (DMA off here) has its + // PC retargeted by later COPxLC writes (copper_lc_written). + self.copper_current_list = 1; + self.copper_active_in_frame = self.copper_dma_enabled(); + } + + /// A COPxLC location register was rewritten. While the Copper has not + /// been active in the current field, a write to the location register it + /// was last strobed from retargets its program counter directly (real + /// Agnus behaviour, photographed by the vAmigaTS Copper/lc family); + /// otherwise the write only loads the latch for the next strobe. A + /// rewrite in the wrap-to-strobe window refreshes the pending strobe's + /// address so the restart uses the live COP1LC value. + pub(super) fn copper_lc_written(&mut self, list: u8) { + let lc = if list == 1 { + self.agnus.cop1lc + } else { + self.agnus.cop2lc + }; + if self.pending_copper_frame_start.is_some() { + if list == 1 { + self.pending_copper_frame_start = Some(lc); + } + return; + } + // A dormant Copper necessarily has its DMA off: any COPEN edge sets + // copper_active_in_frame. The explicit DMA check keeps directly + // constructed test states (DMACON preset without a register write) + // on the latch-only path a live Copper uses. + if !self.copper_active_in_frame + && !self.copper_dma_enabled() + && self.copper_current_list == list + { + self.copper.jump(lc); + } } pub(super) fn record_slice_bus_advance(&mut self, cck: u32, tick: AgnusTick) { diff --git a/src/bus/tests.rs b/src/bus/tests.rs index f298d3b..c126810 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -1363,6 +1363,43 @@ fn copper_dma_enable_gates_instruction_execution() { assert_eq!(bus.denise.bplcon0, 0x4200); } +#[test] +fn copper_lc_write_retargets_dormant_copper_pc() { + // Real Agnus: while the Copper has NOT been active in the current field + // (Copper DMA off at the vertical-blank strobe and no COPEN edge since), + // writing its current list's location register moves the Copper PC + // directly instead of only loading the latch. The vAmigaTS + // Agnus/Copper/lc tests photograph exactly this: a VERTB handler that + // rewrites COP1LC and then enables Copper DMA runs the NEW list. + let mut bus = empty_bus(); + let cop1 = 0x0100usize; + let redirected = 0x0200usize; + write_chip_word(&mut bus, cop1, 0x0180); + write_chip_word(&mut bus, cop1 + 2, 0x0111); + write_chip_word(&mut bus, cop1 + 4, 0xFFFF); + write_chip_word(&mut bus, cop1 + 6, 0xFFFE); + write_chip_word(&mut bus, redirected, 0x0180); + write_chip_word(&mut bus, redirected + 2, 0x0999); + write_chip_word(&mut bus, redirected + 4, 0xFFFF); + write_chip_word(&mut bus, redirected + 6, 0xFFFE); + + bus.agnus.dmacon = DMACON_DMAEN; // Copper DMA off: the Copper is dormant + bus.agnus.hpos = 0x20; + assert!(!bus.custom_write(0x080, 2, 0x0000)); + assert!(!bus.custom_write(0x082, 2, cop1 as u64)); + assert!(!bus.custom_write(0x088, 2, 0xFFFF)); // strobe: list 1, PC = cop1 + + // Dormant retarget: the rewrite moves the PC, no strobe needed. + assert!(!bus.custom_write(0x082, 2, redirected as u64)); + + assert!(!bus.custom_write(0x096, 2, (0x8000 | DMACON_COPEN) as u64)); + bus.advance_chipset(4); + assert_eq!( + bus.denise.palette[0], 0x0999, + "the redirected list must run without a COPJMP strobe" + ); +} + #[test] fn copper_dma_enable_gates_current_pc_until_copjmp_strobe() { let mut bus = empty_bus(); @@ -1379,6 +1416,11 @@ fn copper_dma_enable_gates_current_pc_until_copjmp_strobe() { bus.agnus.dmacon = DMACON_DMAEN; bus.copper.jump(stale as u32); + // The park at `stale` implies the Copper ran this field: COPxLC writes + // must only load the latch. (A DORMANT Copper - not active since the + // vertical blank - has its PC retargeted by the write instead; see + // copper_lc_written and the Copper/lc tests.) + bus.copper_active_in_frame = true; bus.agnus.hpos = 0x20; assert!(!bus.custom_write(0x080, 2, 0x0000)); assert!(!bus.custom_write(0x082, 2, cop1 as u64)); diff --git a/src/savestate.rs b/src/savestate.rs index ecb7303..2217872 100644 --- a/src/savestate.rs +++ b/src/savestate.rs @@ -78,7 +78,7 @@ const STATE_MAGIC: &[u8; 8] = b"CLSSTATE"; // for rows whose fetch diverges from the register-derived window) // 17: DisplaySpriteDmaState gained the two-slot sprite fetch fields // (data_words_fetched pointer progression, pending_data) -pub const STATE_VERSION: u32 = 18; +pub const STATE_VERSION: u32 = 19; /// Default state file name, timestamped like the screenshot/recorder names. pub fn auto_filename() -> std::path::PathBuf {