From ae0f1102e79f2f032e98e75456c4108617725f2c Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 15 Jul 2026 18:43:56 +0100 Subject: [PATCH 1/2] cpu: mirror chip RAM across the chip window by Agnus address reach The motherboard decode (Gary and equivalents) routes the whole $000000-$1FFFFF window to Agnus, which only decodes as many address bits as its DRAM reach, so the fitted chip RAM image repeats across the window: an OCS 8370/8371 mirrors its 512K image at $080000/$100000/$180000, the 1M 8372A (A20 unused) repeats its image at $100000, and the 2M 8375/Alice decode the full window. Within one image, addresses past the fitted RAM select no DRAM bank and stay open bus, e.g. $080000-$0FFFFF on an 8372A with 512K fitted. Copperline previously decoded chip RAM only within its fitted size and left the rest of the window unmapped. Action Replay freeze-disk loaders park the supervisor stack at $100000 on a 512K OCS machine and rely on the pushes landing at the top of chip RAM; without the mirror the pushes vanished, the return popped 0, and the exception cascade rebooted into a garbage guru (issue #196, Brian the Lion AR freeze on 512K chip + 512K slow). With the mirror the freeze restores and the game resumes, matching real OCS hardware and vAmiga. Writes through an image repeat report the canonical chip address to watchpoints and UI highlights. Kickstart's chip sizing detects the wrap and still reports the fitted size (verified with AmigaTestKit on KS 1.2/1.3 x OCS/8372A with 512K and 1M chip). The debugger memory find now walks the alias hits, so its test asserts the image-repeat addresses. Fixes #196 --- docs/internals/chipset.md | 12 ++++++ src/cpu.rs | 89 ++++++++++++++++++++++++++++++++++++--- src/memory.rs | 5 +++ src/video/window/tests.rs | 18 +++++--- 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/docs/internals/chipset.md b/docs/internals/chipset.md index 531a2261..56671552 100644 --- a/docs/internals/chipset.md +++ b/docs/internals/chipset.md @@ -47,6 +47,18 @@ 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). +The CPU sees the reach too: the motherboard decode (Gary and equivalents) +routes the whole $000000-$1FFFFF window to Agnus, which decodes only as +many address bits as its DRAM reach, so the fitted chip RAM image repeats +across the window. A 512K OCS machine mirrors chip RAM at +$080000/$100000/$180000, and the 8372A (A20 unused) repeats its 1M image +at $100000, while addresses past the fitted RAM inside one image select +no DRAM bank and stay open bus ($080000-$0FFFFF on an 8372A with 512K +fitted). Kickstart's chip sizing detects the wrap and still reports the +fitted size. Action Replay freeze-disk loaders are a regression example: +they park the supervisor stack at $100000 on a 512K OCS machine and rely +on the pushes landing at the top of chip RAM. + 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 diff --git a/src/cpu.rs b/src/cpu.rs index c334e7d5..c037e133 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -6,7 +6,8 @@ use crate::bus::{Bus, CpuBusAccessKind}; use crate::chipset::paula::{pending_ipl, INT_MASTER}; use crate::config::CpuModel; use crate::memory::{ - AUTOCONFIG_BASE, AUTOCONFIG_SIZE, CHIP_RAM_BASE, ROM_BASE, SLOW_RAM_BASE, WCS_BASE, + AUTOCONFIG_BASE, AUTOCONFIG_SIZE, CHIP_RAM_BASE, CHIP_WINDOW_SIZE, ROM_BASE, SLOW_RAM_BASE, + WCS_BASE, }; use anyhow::{anyhow, Result}; use log::{debug, trace}; @@ -2223,7 +2224,7 @@ impl CpuBus { if let Some(off) = self.overlay_rom_offset(addr, size) { return Some(PlainMemRegion::OverlayRom(off)); } - if let Some(off) = region_offset(self.bus.mem.chip_ram.len(), CHIP_RAM_BASE, addr, size) { + if let Some(off) = self.chip_window_offset(addr, size) { return Some(PlainMemRegion::ChipRam(off)); } if let Some((board, off)) = self.bus.mem.zorro.region_at(addr, size) { @@ -2249,6 +2250,26 @@ impl CpuBus { None } + /// CPU-side chip RAM decode. The motherboard address decode (Gary and + /// equivalents) routes the whole $000000-$1FFFFF window to Agnus, and + /// Agnus only decodes as many address bits as its DRAM reach + /// (dma_addr_capability_mask): an OCS 8370/8371 ignores A19/A20, so its + /// 512 KiB image repeats at $080000/$100000/$180000; the 1 MiB 8372A + /// ignores A20, repeating its 1 MiB image at $100000; the 2 MiB + /// 8375/Alice decode the full window with no repeat. Within one image, + /// addresses past the fitted RAM select no DRAM bank and stay open bus + /// (e.g. $080000-$0FFFFF on an 8372A with 512 KiB fitted). Kickstart's + /// chip sizing copes: it detects the wrap by checking whether a probe + /// write aliased the bottom of RAM. + fn chip_window_offset(&self, addr: u32, size: usize) -> Option { + let len = self.bus.mem.chip_ram.len(); + if len == 0 || u64::from(addr) >= CHIP_WINDOW_SIZE { + return None; + } + let off = (addr & self.bus.agnus.revision().dma_addr_capability_mask()) as usize; + (off.checked_add(size)? <= len).then_some(off) + } + /// Canonical custom-register page offset for a CPU access that the /// motherboard address decode routes to the custom chips: the canonical /// $DFF000 page on every machine, plus the mirrors Gary's coarse decode @@ -2464,7 +2485,7 @@ impl CpuBus { if self.overlay_rom_offset(addr, 1).is_some() { return false; } - region_offset(self.bus.mem.chip_ram.len(), CHIP_RAM_BASE, addr, 1).is_some() + self.chip_window_offset(addr, 1).is_some() || self.bus.mem.zorro.region_at(addr, 1).is_some() || region_offset(self.bus.mem.slow_ram.len(), SLOW_RAM_BASE, addr, 1).is_some() || region_offset(self.bus.mem.rom.len(), ROM_BASE, addr, 1).is_some() @@ -2719,9 +2740,12 @@ impl CpuBus { self.bus .grant_cpu_bus_access_at(Some(addr), size, CpuBusAccessKind::Write); self.bus.record_cpu_chip_ram_write(off, size, value); - self.bus.ui_note_cpu_ram_write(addr, size); + // Note the canonical chip address (off), not the CPU's bus + // address: a write through an Agnus image repeat must hit + // watchpoints and UI highlights on the RAM cell it lands in. + self.bus.ui_note_cpu_ram_write(off as u32, size); write_be(&mut self.bus.mem.chip_ram, off, size, value); - self.dbg_note_memw(addr, size); + self.dbg_note_memw(off as u32, size); return; } Some(PlainMemRegion::ZorroRam(board, off)) => { @@ -3444,6 +3468,61 @@ mod tests { Ok(()) } + // The motherboard decode routes the whole $000000-$1FFFFF window to + // Agnus, which decodes only as many address bits as its DRAM reach, so + // the fitted chip RAM image repeats across the window (every 512 KiB on + // OCS). Regression example: Action Replay freeze-disk loaders point the + // supervisor stack at $100000 on a 512 KiB machine and rely on the + // pushes landing at the top of chip RAM. + #[test] + fn cpu_chip_window_image_aliases_fitted_ram() -> Result<()> { + // move.l #$DEADBEEF,$180010.l ; move.w $100012.l,d1 + let mut machine = machine_with_program( + 0x0500, + &[ + 0x23FC, 0xDEAD, 0xBEEF, 0x0018, 0x0010, // move.l #imm,$180010.l + 0x3239, 0x0010, 0x0012, // move.w $100012.l,d1 + ], + )?; + machine.step_slice(2)?; + // On the fixture's OCS Agnus both $180010 and $100012 are images of + // chip $10/$12. + assert_eq!(read_chip_long(machine.bus(), 0x10), 0xDEADBEEF); + assert_eq!(machine.d(1) & 0xFFFF, 0xBEEF); + Ok(()) + } + + #[test] + fn chip_window_images_follow_agnus_reach() -> Result<()> { + use crate::chipset::agnus::AgnusRevision; + let mut machine = machine_with_program(0x0500, &[0x4E71])?; + machine.bus_mut().mem.chip_ram[0x60000] = 0xA5; + + // OCS (512 KiB reach, A19/A20 unused): the image repeats every + // 512 KiB, and a write through an image lands in the RAM cell. + assert_eq!(machine.debug_read_memory(0x0E0000, 1)[0], 0xA5); + assert_eq!(machine.debug_read_memory(0x160000, 1)[0], 0xA5); + assert_eq!(machine.debug_write_memory(0x1E0004, &[0x77]), 1); + assert_eq!(machine.bus().mem.chip_ram[0x60004], 0x77); + + // 8372A (1 MiB reach, A20 unused): $0E0000 selects the unfitted + // second DRAM bank (open bus) while $160000 is the A20 image of + // $060000. + machine + .bus_mut() + .set_agnus_revision(AgnusRevision::Ecs8372Rev4); + assert_eq!(machine.debug_read_memory(0x0E0000, 1)[0], 0xFF); + assert_eq!(machine.debug_write_memory(0x0E0000, &[0x11]), 0); + assert_eq!(machine.debug_read_memory(0x160000, 1)[0], 0xA5); + + // 8375/Alice (2 MiB reach): the full window is decoded, so nothing + // past the fitted RAM answers. + machine.bus_mut().set_agnus_revision(AgnusRevision::Ecs8375); + assert_eq!(machine.debug_read_memory(0x0E0000, 1)[0], 0xFF); + assert_eq!(machine.debug_read_memory(0x160000, 1)[0], 0xFF); + Ok(()) + } + fn assert_single_instruction_timing( label: &str, slice: CpuStepSlice, diff --git a/src/memory.rs b/src/memory.rs index e8ed5fae..c06cafbb 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -12,6 +12,11 @@ pub const ROM_SIZE: usize = 512 * 1024; pub const ROM_SIZE_256K: usize = 256 * 1024; pub const ROM_BASE: u64 = 0x00F8_0000; pub const CHIP_RAM_BASE: u64 = 0x0000_0000; +/// Size of the chip-RAM select window the motherboard address decode (Gary +/// and equivalents) routes to Agnus: $000000-$1FFFFF. Agnus decodes fewer +/// address bits than the window on the smaller parts, so the fitted RAM +/// image repeats inside it (see CpuBus::chip_window_offset). +pub const CHIP_WINDOW_SIZE: u64 = 0x0020_0000; /// Conventional base of the first Zorro II RAM board (the start of the /// Zorro II expansion space, where the ROM assigns it). Test fixtures /// pre-configure their fast RAM boards here. diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index 5461a5cf..162359ef 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -3192,13 +3192,17 @@ fn memory_tab_find_scroll_and_bitmap_toggle() { assert_eq!(panel.mem_last_find, Some(0x60000)); assert_eq!(panel.mem_addr, 0x60000); } - // Find again continues past the hit; with a single match the page - // wraps back around to the same place. - app.activate_ui_control(UiControl::DebugMemFind); - assert_eq!( - app.debugger_panel.as_ref().unwrap().mem_last_find, - Some(0x60000) - ); + // Find again continues past the hit. The pattern is CPU-visible again + // at each Agnus image repeat of the 512 KiB chip RAM (OCS Agnus decodes + // only A1-A18, so the image recurs every $80000 across the $000000- + // $1FFFFF chip window), then the search wraps back to the original. + for expected in [0xE0000, 0x160000, 0x1E0000, 0x60000] { + app.activate_ui_control(UiControl::DebugMemFind); + assert_eq!( + app.debugger_panel.as_ref().unwrap().mem_last_find, + Some(expected) + ); + } // Scrolling moves by 16-byte hex rows. app.debugger_mem_scroll(2); From 7c902ebd1a56e7bb4b3a94b639cbbc15866dcb40 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 15 Jul 2026 18:56:03 +0100 Subject: [PATCH 2/2] cpu: note both aliases of a mirrored chip write for watch attribution A chip write through an Agnus image repeat lands in one RAM cell that is CPU-visible at several addresses, and watchpoints store the address the user set them on. Note the CPU bus address and the canonical chip address (when they differ) so watches and UI highlights fire whichever alias they were set on. --- src/cpu.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/cpu.rs b/src/cpu.rs index c037e133..ca6c8b3b 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -2740,12 +2740,17 @@ impl CpuBus { self.bus .grant_cpu_bus_access_at(Some(addr), size, CpuBusAccessKind::Write); self.bus.record_cpu_chip_ram_write(off, size, value); - // Note the canonical chip address (off), not the CPU's bus - // address: a write through an Agnus image repeat must hit - // watchpoints and UI highlights on the RAM cell it lands in. - self.bus.ui_note_cpu_ram_write(off as u32, size); + self.bus.ui_note_cpu_ram_write(addr, size); write_be(&mut self.bus.mem.chip_ram, off, size, value); - self.dbg_note_memw(off as u32, size); + self.dbg_note_memw(addr, size); + if off as u32 != addr { + // The write landed through an Agnus image repeat: note + // the canonical chip address as well as the CPU's bus + // address, so watchpoints and UI highlights fire + // whichever alias of the RAM cell they were set on. + self.bus.ui_note_cpu_ram_write(off as u32, size); + self.dbg_note_memw(off as u32, size); + } return; } Some(PlainMemRegion::ZorroRam(board, off)) => { @@ -3484,11 +3489,16 @@ mod tests { 0x3239, 0x0010, 0x0012, // move.w $100012.l,d1 ], )?; + // Watch both aliases of the target cell: the mirror write must fire + // the watch whichever alias it was set on. + machine.bus_mut().set_ui_mem_watches(&[0x10, 0x180010]); machine.step_slice(2)?; // On the fixture's OCS Agnus both $180010 and $100012 are images of // chip $10/$12. assert_eq!(read_chip_long(machine.bus(), 0x10), 0xDEADBEEF); assert_eq!(machine.d(1) & 0xFFFF, 0xBEEF); + assert!(machine.bus_mut().ui_take_mem_writer(0x10).is_some()); + assert!(machine.bus_mut().ui_take_mem_writer(0x180010).is_some()); Ok(()) }