From e10ee55f8ae9d6a994e5ac048f2f675b18ff087d Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 25 Jul 2026 15:06:33 +0100 Subject: [PATCH 1/4] amigaos: Follow exec structures into the 32-bit RAM banks plausible_ptr() learned the motherboard, CPU-slot, and Zorro III windows in PR #279, but the AmigaDOS walkers around it still carried their own hard 16 MiB ceilings, so nothing downstream benefited. process_seglist(), walk_seglist(), read_bstr(), and process_command_name() each bounded a BPTR at $00400000 -- a $01000000 address once shifted -- and cpu.rs's task-catch peeks rejected an ExecBase or task node at or above $01000000. Those bounds hold on a small-box machine, where every RAM bank fits below $01000000. On an A3000/A4000 or an accelerated machine they do not: exec allocates out of the Ramsey bank at $04000000-$07FFFFFF, the CPU-slot bank at $08000000-$0FFFFFFF, or a Zorro III board, and OS 3.2+ SetPatch deliberately moves ExecBase into fast RAM. A program loaded there had its seglist rejected at the first BPTR, so the debugger's module tracker and gdb's qXfer:libraries:read reported nothing, and the task catch never armed. Add plausible_bptr(), which shifts and then applies the same window test, and route all four walkers plus the task-catch peeks through the shared predicates. ln_Name keeps no address bound at all: the ROM devices' node names are Kickstart strings, and node_name() already documents that case. --- src/amigaos.rs | 100 ++++++++++++++++++++++++++++++++++++++++++------- src/cpu.rs | 9 +++-- 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/amigaos.rs b/src/amigaos.rs index 3c308d8..58636fd 100644 --- a/src/amigaos.rs +++ b/src/amigaos.rs @@ -99,9 +99,10 @@ pub fn task_state_name(state: u8) -> &'static str { } /// Heuristic: even and in an address range where exec structures can -/// live -- chip+Z2 RAM, slow RAM, or Zorro III space (OS 3.2+ SetPatch -/// moves ExecBase to fast RAM, which may be a Z3 board). -fn plausible_ptr(addr: u32) -> bool { +/// live -- chip+Z2 RAM, slow RAM, the big-box motherboard and CPU-slot +/// fast-RAM windows, or Zorro III space (OS 3.2+ SetPatch moves ExecBase +/// to fast RAM, which on a big-box machine is well above the 24-bit space). +pub(crate) fn plausible_ptr(addr: u32) -> bool { addr & 1 == 0 && ((0x100..0x00A0_0000).contains(&addr) // chip + Z2 fast || (0x00C0_0000..0x00D8_0000).contains(&addr) // slow @@ -110,6 +111,14 @@ fn plausible_ptr(addr: u32) -> bool { || (0x1000_0000..0x8000_0000).contains(&addr)) // Zorro III } +/// The same test for a BPTR (an address shifted right by two, as AmigaDOS +/// stores pointers). The pointed-at address must be longword aligned, so +/// the shift is exact; BPTRs past a quarter of the address space cannot +/// name a real address at all and are rejected before the shift. +fn plausible_bptr(bptr: u32) -> bool { + bptr < 0x4000_0000 && plausible_ptr(bptr << 2) +} + /// Read-only view of guest memory, built from the debugger's peek /// primitives. `peek8`/`peek32` must be side-effect-free. pub struct OsMemory<'a> { @@ -240,14 +249,14 @@ impl OsMemory<'_> { return None; } let cli = (self.peek32)(task.wrapping_add(PR_CLI)); - if cli != 0 && cli < 0x0040_0000 { + if plausible_bptr(cli) { let module = (self.peek32)((cli << 2).wrapping_add(CLI_MODULE)); if module != 0 { return Some(module); } } let array = (self.peek32)(task.wrapping_add(PR_SEGLIST)); - if array == 0 || array >= 0x0040_0000 { + if !plausible_bptr(array) { return None; } let count = (self.peek32)(array << 2); @@ -263,11 +272,8 @@ impl OsMemory<'_> { /// size longword; the payload follows the next pointer. pub fn walk_seglist(&self, mut bptr: u32) -> Vec { let mut out = Vec::new(); - while bptr != 0 && bptr < 0x0040_0000 && out.len() < 64 { + while plausible_bptr(bptr) && out.len() < 64 { let addr = bptr << 2; - if !(4..0x0100_0000).contains(&addr) { - break; - } let size = (self.peek32)(addr.wrapping_sub(4)); let next = (self.peek32)(addr); out.push(Segment { @@ -295,7 +301,7 @@ impl OsMemory<'_> { /// Read a BSTR (BPTR to a length-prefixed string): printable ASCII, /// bounded by `cap`, empty for implausible pointers. pub fn read_bstr(&self, bptr: u32, cap: usize) -> String { - if bptr == 0 || bptr >= 0x0040_0000 { + if !plausible_bptr(bptr) { return String::new(); } let addr = bptr << 2; @@ -317,7 +323,7 @@ impl OsMemory<'_> { pub fn process_command_name(&self, task: u32) -> String { if (self.peek8)(task.wrapping_add(LN_TYPE)) == NT_PROCESS { let cli = (self.peek32)(task.wrapping_add(PR_CLI)); - if cli != 0 && cli < 0x0040_0000 { + if plausible_bptr(cli) { let name_bptr = (self.peek32)((cli << 2).wrapping_add(CLI_COMMAND_NAME)); // A full AmigaDOS path fits in a 255-byte BSTR. let name = self.read_bstr(name_bptr, 255); @@ -356,8 +362,10 @@ pub fn command_basename(path: &str) -> &str { } /// A loaded hunk must sit in RAM exec could have allocated and carry a -/// believable size (the loader's size longword is bounded by the 16MB -/// chip/Z2 space a seglist can live in). +/// believable size. The 16 MiB size ceiling is a sanity bound on the +/// loader's size longword, not an address-space limit: the hunk itself may +/// sit anywhere `plausible_ptr` accepts, including the 32-bit fast-RAM +/// windows of a big-box machine. fn segment_plausible(seg: &Segment) -> bool { plausible_ptr(seg.start) && seg.size < 0x0100_0000 } @@ -890,4 +898,70 @@ mod tests { let err = os(&peek8, &peek32).exec_base().unwrap_err(); assert!(err.contains("ChkBase"), "{err}"); } + + /// Exec structures live wherever exec allocated them. On a big-box + /// machine that is the Ramsey motherboard bank ($04000000-$07FFFFFF), + /// the CPU-slot accelerator bank ($08000000-$0FFFFFFF), or a Zorro III + /// board -- all beyond the 24-bit space the small-box map fits in. + #[test] + fn plausible_pointers_cover_the_32_bit_ram_windows() { + for addr in [ + 0x0000_1000, // chip + 0x0020_0000, // Zorro II fast + 0x00C0_0000, // slow + 0x0400_0000, // motherboard (Ramsey), bottom of the window + 0x07FF_FFFE, // motherboard, top of the window + 0x0800_0000, // CPU slot, bottom + 0x0FFF_FFFE, // CPU slot, top + 0x1000_0000, // Zorro III, bottom + 0x4000_0000, // Zorro III + ] { + assert!(plausible_ptr(addr), "${addr:08X} should be plausible"); + assert!( + plausible_bptr(addr >> 2), + "BPTR ${:08X} should be plausible", + addr >> 2 + ); + } + // Still rejected: odd addresses, the zero page, the custom-register + // and ROM spaces, and anything past the Zorro III window. + for addr in [ + 0x0000_0001, + 0x0000_0080, + 0x00DF_F000, + 0x00F8_0000, + 0x8000_0000u32, + ] { + assert!(!plausible_ptr(addr), "${addr:08X} should be rejected"); + } + } + + /// A seglist walked from a hunk in motherboard RAM: the BPTR bound + /// must follow the same address windows, not a 16 MiB ceiling. + #[test] + fn walks_a_seglist_loaded_above_the_24_bit_space() { + let hunk = 0x0450_0000u32; + let mut mem = FakeMem::new(); + mem.put32(hunk - 4, 0x100); // loader size longword + mem.put32(hunk, 0); // end of list + let peek8 = |a: u32| mem.peek8(a); + let peek32 = |a: u32| mem.peek32(a); + let segs = os(&peek8, &peek32).walk_seglist(hunk >> 2); + assert_eq!(segs.len(), 1); + assert_eq!(segs[0].start, hunk + 4); + assert!(segment_plausible(&segs[0])); + } + + /// The same for a BSTR: cli_CommandName points into whatever RAM the + /// shell was loaded into. + #[test] + fn reads_a_bstr_above_the_24_bit_space() { + let bstr = 0x0900_0000u32; + let mut mem = FakeMem::new(); + mem.put8(bstr, 3); + mem.put_str(bstr + 1, "Dir"); + let peek8 = |a: u32| mem.peek8(a); + let peek32 = |a: u32| mem.peek32(a); + assert_eq!(os(&peek8, &peek32).read_bstr(bstr >> 2, 255), "Dir"); + } } diff --git a/src/cpu.rs b/src/cpu.rs index c12d2a8..6c9f5c4 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -1674,11 +1674,11 @@ impl M68kMachine { | u32::from(self.bus.bus.peek_word_any(addr.wrapping_add(2))) }; let execbase = peek32(4); - if execbase == 0 || execbase & 1 != 0 || execbase >= 0x0100_0000 { + if !crate::amigaos::plausible_ptr(execbase) { return None; } let task = peek32(execbase.wrapping_add(0x114)); - (task != 0 && task & 1 == 0 && task < 0x0100_0000).then_some(task) + crate::amigaos::plausible_ptr(task).then_some(task) } /// The name a task node points at, uppercased for matching. @@ -1688,7 +1688,10 @@ impl M68kMachine { | u32::from(self.bus.bus.peek_word_any(addr.wrapping_add(2))) }; let name_ptr = peek32(task.wrapping_add(10)); - if name_ptr == 0 || name_ptr >= 0x0100_0000 { + // Names may live in ROM or at odd addresses (the ROM devices' node + // names are Kickstart strings), so no plausible_ptr bound here; + // unmapped pointers read as zero bytes and end the string at once. + if name_ptr == 0 { return String::new(); } let mut name = String::new(); From 8441d831f847c231d0de3e1877a5e76629d40999 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 25 Jul 2026 15:06:46 +0100 Subject: [PATCH 2/4] debugger: Sweep the decoded memory map in Find, not a fixed 24-bit span The Memory tab's Find button and the console FIND command both walked a hard-coded $01000000-byte space in 4 KiB chunks and masked the hit back to 24 bits. On a machine whose RAM sits above that -- the Ramsey motherboard bank at $04000000, the CPU-slot bank at $08000000, a Zorro III board -- the search could never reach it, and a hit reported from anywhere above 16 MiB aliased down to the wrong address. Add Bus::searchable_regions(): the writable RAM banks plus the Kickstart and extended-ROM windows, in ascending address order. Chip RAM is offered as the whole $000000-$1FFFFF select window rather than the fitted bank, because Agnus decodes fewer address bits than the window on the smaller parts and the bank repeats inside it -- a search of CPU-visible memory sees every image, which is the existing behaviour the regression test pins. writable_ram_regions() keeps reporting the fitted bank so the memory hunt gets no duplicate candidates. search_cpu_memory() now takes that region list and sweeps it twice (from the start address to the top of the map, then the bottom back up), so the wrap is exactly once over what the machine actually decodes. Skipping the undecoded gaps also drops the reads a search costs on a big-box configuration. The Save-region spec had the same 24-bit mask on its start address, so a dump requested above 16 MiB silently wrote a different region; the address is now taken as written and only the length stays capped. --- docs/debugger/window.md | 5 ++- src/bus.rs | 31 ++++++++++++++++ src/bus/tests.rs | 35 +++++++++++++++++++ src/video/ui.rs | 8 +++-- src/video/window.rs | 25 ++----------- src/video/window/console.rs | 70 ++++++++++++++++++++++++++++--------- src/video/window/tests.rs | 47 +++++++++++++++++++++++-- 7 files changed, 176 insertions(+), 45 deletions(-) diff --git a/docs/debugger/window.md b/docs/debugger/window.md index 58fddf0..386c200 100644 --- a/docs/debugger/window.md +++ b/docs/debugger/window.md @@ -111,7 +111,10 @@ PageUp/PageDown by whole pages. Four buttons sit above the dump: - **Find** searches CPU-visible memory for the `$` box's hex byte pattern (`4E75`, or spaced pairs like `C0 FF EE`), starting past the previous - hit and wrapping the 24-bit space once. The view jumps to the match. + hit and wrapping the decoded memory map once. The sweep covers every RAM + bank the machine decodes -- chip, slow, Zorro II, and on a 32-bit CPU the + motherboard (`$04000000`), CPU-slot (`$08000000`), and Zorro III banks -- + plus the Kickstart and extended-ROM windows. The view jumps to the match. - **Save...** writes the `$` box's `ADDR LEN` (hex) region to a file via a save dialog -- the GUI counterpart of `COPPERLINE_DBG_RAMDUMP`. - **Writer?** reports the last instruction that wrote the word at the diff --git a/src/bus.rs b/src/bus.rs index 227e7ec..d6e044c 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -5477,6 +5477,37 @@ impl Bus { regions } + /// The regions a debugger pattern search should sweep: every writable + /// RAM bank plus the Kickstart and extended-ROM windows, in ascending + /// address order. Sweeping the decoded map rather than a fixed + /// address span is what lets a search reach RAM above the 24-bit + /// space (motherboard, CPU-slot, and Zorro III banks) without walking + /// the gigabytes of undecoded address space between them. + /// + /// Chip RAM is offered as the whole $000000-$1FFFFF select window + /// rather than the fitted bank: Agnus decodes fewer address bits than + /// the window on the smaller parts, so the bank repeats inside it and + /// a search of CPU-visible memory sees every image. + pub fn searchable_regions(&self) -> Vec<(u32, u32)> { + let mut regions = self.writable_ram_regions(); + for (base, len) in regions.iter_mut() { + if *base == crate::memory::CHIP_RAM_BASE as u32 { + *len = crate::memory::CHIP_WINDOW_SIZE as u32; + } + } + if !self.mem.rom.is_empty() { + regions.push((crate::memory::ROM_BASE as u32, self.mem.rom.len() as u32)); + } + if !self.mem.extended_rom.is_empty() { + regions.push(( + self.mem.extended_rom_base as u32, + self.mem.extended_rom.len() as u32, + )); + } + regions.sort_by_key(|(base, _)| *base); + regions + } + /// Read a 16-bit big-endian word from whichever RAM/ROM region maps /// `addr` (chip, fast, slow, motherboard, accelerator, or ROM), for the /// debugger's memory dumps. Returns 0 for unmapped addresses. diff --git a/src/bus/tests.rs b/src/bus/tests.rs index f3a37dd..7b189b1 100644 --- a/src/bus/tests.rs +++ b/src/bus/tests.rs @@ -10897,3 +10897,38 @@ fn motherboard_ram_reaches_the_debugger_helpers() { .writable_ram_regions() .contains(&(base, 1024 * 1024_u32))); } + +/// A debugger pattern search sweeps the decoded map, so it must offer the +/// RAM banks that sit past the 24-bit space -- the Ramsey motherboard +/// bank, the CPU-slot accelerator bank, and Zorro III boards -- alongside +/// the chip window and the ROMs. Chip RAM is offered as the whole +/// $000000-$1FFFFF select window so the Agnus image repeats are searched. +#[test] +fn searchable_regions_cover_the_32_bit_ram_banks() { + let mut bus = empty_bus(); + bus.mem.fit_mb_ram(4 * 1024 * 1024); + bus.mem.fit_accel_ram(8 * 1024 * 1024); + bus.mem + .zorro + .add_board_configured_at( + crate::zorro::BoardSpec::z3_ram(16 * 1024 * 1024), + 0x1000_0000, + ) + .expect("Z3 RAM board"); + + let regions = bus.searchable_regions(); + assert!(regions.contains(&(0, crate::memory::CHIP_WINDOW_SIZE as u32))); + assert!(regions.contains(&(0x07C0_0000, 4 * 1024 * 1024))); + assert!(regions.contains(&(crate::memory::ACCEL_RAM_BASE as u32, 8 * 1024 * 1024))); + assert!( + regions + .iter() + .any(|(base, len)| *base >= 0x1000_0000 && *len == 16 * 1024 * 1024), + "Zorro III RAM board missing: {regions:08X?}" + ); + assert!(regions.contains(&(crate::memory::ROM_BASE as u32, 512 * 1024))); + assert!( + regions.windows(2).all(|w| w[0].0 <= w[1].0), + "regions must be in ascending address order: {regions:08X?}" + ); +} diff --git a/src/video/ui.rs b/src/video/ui.rs index 30c3575..312d093 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -403,8 +403,10 @@ impl DebuggerPanel { .collect() } - /// Region spec for Save region: "ADDR LEN", both hex, length bounded - /// to the 24-bit bus. + /// Region spec for Save region: "ADDR LEN", both hex. The address is + /// taken as written -- a dump can start anywhere the CPU decodes, + /// including the motherboard, CPU-slot, and Zorro III RAM above the + /// 24-bit space -- and only the length is capped, at 16 MiB per dump. pub fn region_spec(&self) -> Option<(u32, u32)> { let mut tokens = self.entry.split_whitespace(); let addr = parse_hex_u32(tokens.next()?)?; @@ -412,7 +414,7 @@ impl DebuggerPanel { if tokens.next().is_some() || len == 0 || len > 0x0100_0000 { return None; } - Some((addr & 0x00FF_FFFF, len)) + Some((addr, len)) } pub fn push_entry_char(&mut self, ch: char) { diff --git a/src/video/window.rs b/src/video/window.rs index 302d670..8fc8098 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -5807,7 +5807,7 @@ impl App { /// Find the entry's hex byte pattern in CPU-visible memory, starting /// past the previous hit (or the current page) and wrapping around - /// the 24-bit space once. + /// the decoded memory map once. fn debugger_mem_find(&mut self) { let Some(panel) = self.debugger_panel.as_ref() else { return; @@ -5821,27 +5821,8 @@ impl App { .map(|addr| addr.wrapping_add(1)) .unwrap_or(panel.mem_addr) & self.emu.machine.ui_addr_mask(); - const SPACE: u64 = 0x0100_0000; - const CHUNK: usize = 4096; - let mut offset = 0u64; - let mut found = None; - while offset < SPACE { - let base = ((u64::from(start) + offset) % SPACE) as u32; - // Overlap chunks by the pattern length so matches spanning a - // chunk boundary are seen. - let bytes = self - .emu - .machine - .debug_read_memory(base, CHUNK + pattern.len() - 1); - if let Some(hit) = bytes - .windows(pattern.len()) - .position(|window| window == pattern) - { - found = Some(base.wrapping_add(hit as u32) & 0x00FF_FFFF); - break; - } - offset += CHUNK as u64; - } + let regions = self.emu.bus().searchable_regions(); + let found = console::search_cpu_memory(&self.emu.machine, ®ions, &pattern, start); match found { Some(addr) => { if let Some(panel) = self.debugger_panel.as_mut() { diff --git a/src/video/window/console.rs b/src/video/window/console.rs index 7531ce5..f5e8103 100644 --- a/src/video/window/console.rs +++ b/src/video/window/console.rs @@ -99,29 +99,61 @@ fn reg_index(token: &str) -> Option { } } -/// Search CPU-visible memory for `pattern`, starting at `start` and -/// wrapping the 24-bit space once. Shared by the console FIND command -/// and the Memory tab's Find button. -pub(super) fn search_cpu_memory( +/// Search one `[from, end)` span of CPU-visible memory for `pattern`. +fn search_span( machine: &crate::cpu::M68kMachine, pattern: &[u8], - start: u32, + from: u32, + end: u64, ) -> Option { - const SPACE: u64 = 0x0100_0000; const CHUNK: usize = 4096; - let mut offset = 0u64; - while offset < SPACE { - let base = ((u64::from(start) + offset) % SPACE) as u32; + let mut addr = u64::from(from); + while addr < end { // Overlap chunks by the pattern length so matches spanning a // chunk boundary are seen. - let bytes = machine.debug_read_memory(base, CHUNK + pattern.len() - 1); + let span = (end - addr) as usize; + let bytes = machine.debug_read_memory(addr as u32, span.min(CHUNK) + pattern.len() - 1); if let Some(hit) = bytes .windows(pattern.len()) .position(|window| window == pattern) { - return Some(base.wrapping_add(hit as u32) & 0x00FF_FFFF); + return Some((addr as u32).wrapping_add(hit as u32)); + } + addr += CHUNK as u64; + } + None +} + +/// Search CPU-visible memory for `pattern`, starting at `start` and +/// wrapping the decoded map once. `regions` is the machine's +/// [`crate::bus::Bus::searchable_regions`], so RAM above the 24-bit space +/// (motherboard, CPU-slot, and Zorro III banks) is covered and the +/// undecoded gaps between the banks are skipped. Shared by the console +/// FIND command and the Memory tab's Find button. +pub(super) fn search_cpu_memory( + machine: &crate::cpu::M68kMachine, + regions: &[(u32, u32)], + pattern: &[u8], + start: u32, +) -> Option { + // Two sweeps: the map from `start` up to its top, then its bottom back + // up to `start`, so the search wraps the whole map exactly once. + for (base, len) in regions { + let end = u64::from(*base) + u64::from(*len); + let from = u64::from(start).max(u64::from(*base)); + if from < end { + if let Some(hit) = search_span(machine, pattern, from as u32, end) { + return Some(hit); + } + } + } + for (base, len) in regions { + let end = (u64::from(*base) + u64::from(*len)).min(u64::from(start)); + if u64::from(*base) < end { + if let Some(hit) = search_span(machine, pattern, *base, end) { + return Some(hit); + } } - offset += CHUNK as u64; } None } @@ -865,7 +897,8 @@ impl App { let Some(pattern) = parse_hex_pattern(pattern_tokens) else { return ConsoleOutcome::error("FIND takes hex byte pairs (e.g. 4E75)"); }; - match search_cpu_memory(&self.emu.machine, &pattern, start) { + let regions = self.emu.bus().searchable_regions(); + match search_cpu_memory(&self.emu.machine, ®ions, &pattern, start) { Some(addr) => ConsoleOutcome::one(format!("found at ${addr:06X}")), None => ConsoleOutcome::one("pattern not found"), } @@ -1312,8 +1345,8 @@ impl App { } /// Heuristic 68k call-stack walk: scan up the stack for longwords - /// that look like return addresses (even, in the 24-bit space, and - /// immediately preceded by a JSR or BSR encoding). Heuristic by + /// that look like return addresses (even, on the CPU's address bus, + /// and immediately preceded by a JSR or BSR encoding). Heuristic by /// nature -- data words that happen to follow call opcodes can slip /// in -- but each frame shows its stack slot so it can be judged. fn console_stack_lines(&self) -> Vec { @@ -1324,8 +1357,13 @@ impl App { let peek16 = |addr: u32| bus.peek_word_any(addr); let peek32 = |addr: u32| (u32::from(peek16(addr)) << 16) | u32::from(peek16(addr.wrapping_add(2))); + // A0-A23 on the 24-bit models, the full 32 bits on 020+, so code + // running from motherboard, CPU-slot, or Zorro III RAM is walked + // rather than rejected. Unmapped words peek as 0, which matches no + // JSR/BSR encoding, so the opcode test still does the filtering. + let addr_mask = machine.ui_addr_mask(); let looks_like_return = |addr: u32| -> bool { - if addr == 0 || addr & 1 != 0 || addr >= 0x0100_0000 { + if addr == 0 || addr & 1 != 0 || addr & addr_mask != addr { return false; } // JSR (An)/-(An)+modes and BSR.B end 2 bytes before the diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index 39952cb..31fb762 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -1902,8 +1902,18 @@ fn test_app() -> super::App { } fn test_app_with_audio(audio: Box) -> super::App { + test_app_with_audio_and_cpu(audio, crate::config::CpuModel::M68000, 0) +} + +/// The same fixture on a chosen CPU model, optionally with `accel_ram_bytes` +/// of CPU-slot fast RAM fitted at $08000000 (a 32-bit model only reaches it). +fn test_app_with_audio_and_cpu( + audio: Box, + cpu: crate::config::CpuModel, + accel_ram_bytes: usize, +) -> super::App { use crate::chipset::paula::Paula; - use crate::config::{CpuModel, PacingBudget}; + use crate::config::PacingBudget; use crate::emulator::Emulator; use crate::floppy::FloppyController; use crate::memory::{Memory, ROM_BASE, ROM_SIZE}; @@ -1921,7 +1931,7 @@ fn test_app_with_audio(audio: Box) -> super::App { chip_ram: vec![0u8; 512 * 1024], slow_ram: Vec::new(), mb_ram: Vec::new(), - accel_ram: Vec::new(), + accel_ram: vec![0u8; accel_ram_bytes], rom, overlay: true, zorro: crate::zorro::ZorroChain::default(), @@ -1937,7 +1947,7 @@ fn test_app_with_audio(audio: Box) -> super::App { ); let emu = Emulator::new( bus, - CpuModel::M68000, + cpu, false, Default::default(), PacingBudget::Cycles, @@ -3632,6 +3642,37 @@ fn memory_tab_find_scroll_and_bitmap_toggle() { assert!(!app.debugger_panel.as_ref().unwrap().mem_view_bits); } +/// Find sweeps the decoded memory map, so on a 32-bit CPU it reaches the +/// RAM banks past the 24-bit space -- here the CPU-slot accelerator bank at +/// $08000000. A search of a fixed 16 MiB span could never see them. +#[test] +fn memory_tab_find_reaches_ram_above_the_24_bit_space() { + let mut app = test_app_with_audio_and_cpu( + Box::new(NullSink), + crate::config::CpuModel::M68030, + 1024 * 1024, + ); + app.open_debugger(); + if let Some(panel) = app.debugger_panel.as_mut() { + panel.tab = super::ui::DebugTab::Memory; + panel.mem_addr = 0; + } + app.emu.bus_mut().mem.accel_ram[0x4_0000..0x4_0004].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); + if let Some(panel) = app.debugger_panel.as_mut() { + panel.entry = "DEADBEEF".to_string(); + } + app.activate_ui_control(UiControl::DebugMemFind); + let panel = app.debugger_panel.as_ref().unwrap(); + assert_eq!( + panel.mem_last_find, + Some(crate::memory::ACCEL_RAM_BASE as u32 + 0x4_0000) + ); + assert_eq!( + panel.mem_addr, + crate::memory::ACCEL_RAM_BASE as u32 + 0x4_0000 + ); +} + #[test] fn video_tab_layer_toggles_flip_bus_masks() { let mut app = test_app(); From 6b63c05e5a7ef22b110d713187d7c895e95a79d1 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 25 Jul 2026 15:06:54 +0100 Subject: [PATCH 3/4] debugger: Compare PC targets through the CPU's own address mask debug_run_to_pc(), the reverse-debug replay scan tt_scan_stop(), and the CCP run-to-PC target all masked the PC with a literal $00FF_FFFF. The machine already carries the right width -- ui_addr_mask() is A0-A23 on the 68000, 68010, and 68EC020 and the full 32 bits on the 020/030/040/060 -- so the literal both aliased distinct 32-bit PCs onto each other and stopped a breakpoint set in RAM above 16 MiB from ever being recognised as the target. Route all three through ui_addr_mask(). On the 24-bit models the behaviour is unchanged, since the mask is the same constant there. --- src/control/headless.rs | 5 +++-- src/emulator.rs | 17 ++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/control/headless.rs b/src/control/headless.rs index 846663f..f4979db 100644 --- a/src/control/headless.rs +++ b/src/control/headless.rs @@ -616,7 +616,8 @@ impl Session { // Target already met (or met exactly at the last quantum)? match target { Some(RunTarget::Pc(pc)) - if self.emu.machine.pc() & 0x00FF_FFFF == pc & 0x00FF_FFFF => + if self.emu.machine.pc() & self.emu.machine.ui_addr_mask() + == pc & self.emu.machine.ui_addr_mask() => { return finish("target", format!("pc ${pc:06X}"), extra_ids, false); } @@ -638,7 +639,7 @@ impl Session { // debug_step_for_gdb keeps reverse-debug captures // happening at frame crossings. The hit itself is // seen by the checks at the top of the loop. - let mask = 0x00FF_FFFF; + let mask = self.emu.machine.ui_addr_mask(); for _ in 0..PC_POLL_CHUNK { self.emu.debug_step_for_gdb(&mut cpu_idle)?; if self.emu.machine.pc() & mask == pc & mask diff --git a/src/emulator.rs b/src/emulator.rs index 019f2a9..19a27c6 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -978,8 +978,11 @@ impl Emulator { end_pos: u64, full: bool, ) -> Result> { - const PC_MASK: u32 = 0x00FF_FFFF; - let is_bp = |pc: u32| breakpoints.contains(&(pc & PC_MASK)); + // A0-A23 on the 24-bit models, the full 32 bits on 020+, so a + // breakpoint in RAM above the 24-bit space is not aliased onto a + // different PC during replay. + let pc_mask = self.machine.ui_addr_mask(); + let is_bp = |pc: u32| breakpoints.contains(&(pc & pc_mask)); // Drain any stop left over from an earlier replay segment. let _ = self.machine.take_ui_debug_stop(); self.tt_apply_due_input(self.retired_instructions); @@ -987,7 +990,7 @@ impl Emulator { if self.retired_instructions < end_pos && is_bp(self.machine.pc()) { best = Some(( self.retired_instructions, - format!("Breakpoint at ${:06X}", self.machine.pc() & PC_MASK), + format!("Breakpoint at ${:06X}", self.machine.pc() & pc_mask), )); } let mut cpu_idle = false; @@ -1006,7 +1009,7 @@ impl Emulator { if self.retired_instructions < end_pos && is_bp(self.machine.pc()) { best = Some(( self.retired_instructions, - format!("Breakpoint at ${:06X}", self.machine.pc() & PC_MASK), + format!("Breakpoint at ${:06X}", self.machine.pc() & pc_mask), )); } if self.retired_instructions == before && !cpu_idle { @@ -1294,11 +1297,11 @@ impl Emulator { /// Run until the CPU reaches `target_pc` (masked to the bus width), up /// to `max_instructions`. Returns true when the target was hit. pub fn debug_run_to_pc(&mut self, target_pc: u32, max_instructions: usize) -> Result { - const PC_MASK: u32 = 0x00FF_FFFF; - let target = target_pc & PC_MASK; + let pc_mask = self.machine.ui_addr_mask(); + let target = target_pc & pc_mask; for _ in 0..max_instructions { self.debug_step_one_with_idle()?; - if self.machine.pc() & PC_MASK == target { + if self.machine.pc() & pc_mask == target { return Ok(true); } // A breakpoint/watch hit on the way to the target ends the From f5ba9e3f6c1b36c196adc85c903487d6b472f4c9 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Sat, 25 Jul 2026 15:17:06 +0100 Subject: [PATCH 4/4] debugger: Mask Find and Save-region addresses at the point of use Review follow-ups on the address-window sweep. The console FIND command passed its optional START token through unmasked while the Memory tab's Find button masked its start with ui_addr_mask(). A START past a 24-bit bus then named no region at all, so the first sweep matched nothing and the search silently restarted from the bottom of the map, reporting an earlier copy of the pattern than the one asked for. Take START through the address bus, as the GUI path does. region_spec() no longer masks, which is what lets a dump start above the 24-bit space on an 020+, but the save path reads through debug_read_memory(), which masks to the bus width. On a 24-bit model the file name and the OSD therefore named an address the bytes had not come from. Mask in debugger_mem_save_region() instead, alongside the identical ui_addr_mask() the neighbouring Writer? path already applies -- display and content agree again on 24-bit models, and a 32-bit dump address still passes through. The per-span reads deliberately run one pattern short of a chunk past the span end. Document why: that tail is what carries a match across a chunk boundary, and at a span end it is what carries a match across two banks that abut in the map -- a full motherboard bank ends at $08000000, exactly where the CPU-slot bank begins. The window count is the chunk length, so a reported hit always starts inside the span it was searched from. Pinned with a regression test for a pattern straddling that seam. --- src/video/window.rs | 5 ++++ src/video/window/console.rs | 17 +++++++++-- src/video/window/tests.rs | 58 ++++++++++++++++++++++++++++++------- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/video/window.rs b/src/video/window.rs index 8fc8098..27939a7 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -5848,6 +5848,11 @@ impl App { self.show_osd("Save: type \"ADDR LEN\" (hex) first"); return; }; + // Through the machine's address bus, like every other debugger + // surface: the file name and the OSD then name the address the + // bytes actually came from on a 24-bit model, and a 32-bit dump + // above the 24-bit space passes through untouched on 020+. + let addr = addr & self.emu.machine.ui_addr_mask(); self.suspend_live_audio_for_host_io(); let picked = rfd::FileDialog::new() .set_title("Save memory region") diff --git a/src/video/window/console.rs b/src/video/window/console.rs index f5e8103..637263a 100644 --- a/src/video/window/console.rs +++ b/src/video/window/console.rs @@ -100,6 +100,16 @@ fn reg_index(token: &str) -> Option { } /// Search one `[from, end)` span of CPU-visible memory for `pattern`. +/// +/// Every read runs `pattern.len() - 1` bytes past its chunk, including the +/// last chunk of the span. That tail is deliberate twice over: it is what +/// lets a match straddle a chunk boundary, and at the end of a span it is +/// what lets a match straddle two banks that abut in the map (a full +/// motherboard bank ends at $08000000, exactly where the CPU-slot bank +/// begins). The tail only ever supplies trailing bytes -- the window count +/// is the chunk length, so a reported hit always starts inside +/// `[from, end)` -- and the bytes it reads are what the CPU would see +/// there, unmapped space included. fn search_span( machine: &crate::cpu::M68kMachine, pattern: &[u8], @@ -109,8 +119,6 @@ fn search_span( const CHUNK: usize = 4096; let mut addr = u64::from(from); while addr < end { - // Overlap chunks by the pattern length so matches spanning a - // chunk boundary are seen. let span = (end - addr) as usize; let bytes = machine.debug_read_memory(addr as u32, span.min(CHUNK) + pattern.len() - 1); if let Some(hit) = bytes @@ -897,6 +905,11 @@ impl App { let Some(pattern) = parse_hex_pattern(pattern_tokens) else { return ConsoleOutcome::error("FIND takes hex byte pairs (e.g. 4E75)"); }; + // Through the machine's address bus, as the Memory tab's + // Find does: the sweep must start where the reads will + // land, or a START past a 24-bit bus would skip the + // whole map and silently restart from the bottom. + let start = start & self.emu.machine.ui_addr_mask(); let regions = self.emu.bus().searchable_regions(); match search_cpu_memory(&self.emu.machine, ®ions, &pattern, start) { Some(addr) => ConsoleOutcome::one(format!("found at ${addr:06X}")), diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index 31fb762..002d9e1 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -1902,15 +1902,15 @@ fn test_app() -> super::App { } fn test_app_with_audio(audio: Box) -> super::App { - test_app_with_audio_and_cpu(audio, crate::config::CpuModel::M68000, 0) + test_app_with_audio_and_cpu(audio, crate::config::CpuModel::M68000) } -/// The same fixture on a chosen CPU model, optionally with `accel_ram_bytes` -/// of CPU-slot fast RAM fitted at $08000000 (a 32-bit model only reaches it). +/// The same fixture on a chosen CPU model. Fast-RAM banks past the 24-bit +/// space are fitted by the caller through `bus_mut().mem`, since only a +/// 32-bit model reaches them. fn test_app_with_audio_and_cpu( audio: Box, cpu: crate::config::CpuModel, - accel_ram_bytes: usize, ) -> super::App { use crate::chipset::paula::Paula; use crate::config::PacingBudget; @@ -1931,7 +1931,7 @@ fn test_app_with_audio_and_cpu( chip_ram: vec![0u8; 512 * 1024], slow_ram: Vec::new(), mb_ram: Vec::new(), - accel_ram: vec![0u8; accel_ram_bytes], + accel_ram: Vec::new(), rom, overlay: true, zorro: crate::zorro::ZorroChain::default(), @@ -3462,6 +3462,16 @@ fn console_modify_search_and_transport_commands() { let out = console_run(&mut app, "FIND BEEF 50000"); assert!(out[0].contains("found at $060000"), "{out:?}"); + // A START is taken through the machine's address bus, so on this + // 24-bit fixture $2050000 sweeps from $050000 and reports the hit + // above it. Unmasked it would name no region at all and restart from + // the bottom of the map, reporting the earlier copy instead. + console_run(&mut app, "POKE 40000 BEEF"); + let out = console_run(&mut app, "FIND BEEF 2050000"); + assert!(out[0].contains("found at $060000"), "{out:?}"); + let out = console_run(&mut app, "FIND BEEF 0"); + assert!(out[0].contains("found at $040000"), "{out:?}"); + // Run to an exact beam slot; the one-shot trap reports its position. let out = console_run(&mut app, "TOSLOT 50 30"); assert!( @@ -3647,11 +3657,8 @@ fn memory_tab_find_scroll_and_bitmap_toggle() { /// $08000000. A search of a fixed 16 MiB span could never see them. #[test] fn memory_tab_find_reaches_ram_above_the_24_bit_space() { - let mut app = test_app_with_audio_and_cpu( - Box::new(NullSink), - crate::config::CpuModel::M68030, - 1024 * 1024, - ); + let mut app = test_app_with_audio_and_cpu(Box::new(NullSink), crate::config::CpuModel::M68030); + app.emu.bus_mut().mem.fit_accel_ram(1024 * 1024); app.open_debugger(); if let Some(panel) = app.debugger_panel.as_mut() { panel.tab = super::ui::DebugTab::Memory; @@ -3673,6 +3680,37 @@ fn memory_tab_find_reaches_ram_above_the_24_bit_space() { ); } +/// A full motherboard bank ends at $08000000, exactly where the CPU-slot +/// bank begins, so the two abut in the decoded map. The per-span reads run +/// one pattern short of a chunk past their span end precisely so a match +/// straddling that seam is still found, anchored in the bank it starts in. +#[test] +fn memory_tab_find_spans_two_abutting_ram_banks() { + let mut app = test_app_with_audio_and_cpu(Box::new(NullSink), crate::config::CpuModel::M68030); + { + let mem = &mut app.emu.bus_mut().mem; + mem.fit_mb_ram(4 * 1024 * 1024); + mem.fit_accel_ram(1024 * 1024); + assert_eq!(mem.mb_ram_base(), 0x07C0_0000); + // DE AD in the last two bytes of the motherboard bank, BE EF in + // the first two of the CPU-slot bank. + let top = mem.mb_ram.len(); + mem.mb_ram[top - 2..].copy_from_slice(&[0xDE, 0xAD]); + mem.accel_ram[..2].copy_from_slice(&[0xBE, 0xEF]); + } + app.open_debugger(); + if let Some(panel) = app.debugger_panel.as_mut() { + panel.tab = super::ui::DebugTab::Memory; + panel.mem_addr = 0; + panel.entry = "DEADBEEF".to_string(); + } + app.activate_ui_control(UiControl::DebugMemFind); + assert_eq!( + app.debugger_panel.as_ref().unwrap().mem_last_find, + Some(0x07FF_FFFE) + ); +} + #[test] fn video_tab_layer_toggles_flip_bus_masks() { let mut app = test_app();