fix(memory): ignore CPU and DMA writes to the Game Pak ROM windows

Stores to 0x08-0x0c returned ReadOnly and stopped the machine. Hardware ignores
them, and a second game's boot code stores to a ROM mirror. Emulated writes now
pay their bus cost and change nothing. The 0x0d window stays a diagnostic until
EEPROM is modeled there, and host writes stay strict.

Tests that used a ROM store as their failing write now cross an ignored ROM
word into the 0x0d window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhfHfHEuzv2AUTd6erW1gU
This commit is contained in:
0xrsydn 2026-10-14 08:26:03 +07:00
commit 258fbff2fa
25 changed files with 86 additions and 74 deletions

View file

@ -403,9 +403,11 @@ fn rtc_selection_is_explicit_and_does_not_modify_the_rom_file() {
let fixture = Fixture::new();
let path = fixture.rom(&rtc_setup());
let original = fs::read(&path).unwrap();
// Without the device, the GPIO store is an ordinary ignored ROM write. The read-back
// then reaches past this short image, which remains a diagnostic.
let absent = run(&path, "100");
assert_eq!(absent.status.code(), Some(1));
assert!(stderr(&absent).contains("read-only memory at 0x080000c8"));
assert!(stderr(&absent).contains("unmapped memory at 0x080000c8"));
let selected = command()
.arg("--rtc")
.arg("--rom")

View file

@ -183,7 +183,7 @@ fn unmapped_bios_and_cartridge_destinations_remain_retryable_diagnostics() {
(0, false),
(0x4000, true),
(0x0100_0000, true),
(ROM_START, true),
(0x0d00_0000, true),
] {
let (mut cpu, mut memory, _) = setup(ROM_START, 0xe480_1004, false);
if !mapped_bios {
@ -201,7 +201,7 @@ fn unmapped_bios_and_cartridge_destinations_remain_retryable_diagnostics() {
let error = machine.step().unwrap_err();
assert_eq!(
error,
MachineError::Cpu(CpuError::Memory(if address == ROM_START {
MachineError::Cpu(CpuError::Memory(if address == 0x0d00_0000 {
MemoryError::ReadOnly(address)
} else {
MemoryError::Unmapped(address)

View file

@ -175,7 +175,7 @@ fn failed_dma_and_failed_resumed_instruction_preserve_last_completed_dma_history
machine.memory_mut(),
3,
if fail_destination { IWRAM } else { 0x0e00_0000 },
if fail_destination { 0x0800_0000 } else { IWRAM },
if fail_destination { 0x0d00_0000 } else { IWRAM },
1,
true,
);

View file

@ -141,7 +141,7 @@ fn empty_stores_advance_devices_only_after_a_successful_single_word_write() {
for instruction in [0xc200, 0xb400] {
for (address, error) in [
(ROM_START, MemoryError::ReadOnly(ROM_START)),
(0x0d00_0000, MemoryError::ReadOnly(0x0d00_0000)),
(0x0e00_0000, MemoryError::Unmapped(0x0e00_0000)),
] {
let (mut cpu, mut memory) = setup(instruction, CODE);

View file

@ -727,17 +727,28 @@ fn thumb_memory_errors_leave_state_unchanged() {
}
#[test]
fn failed_thumb_store_to_rom_does_not_modify_rom_or_cpu() {
fn thumb_store_to_rom_is_ignored_and_the_cartridge_window_stays_diagnostic() {
let mut memory = program(&[0x6001]);
let mut cpu = thumb_cpu();
cpu.registers[0] = ROM_START;
cpu.registers[1] = 0x1234_5678;
cpu.step(&mut memory).unwrap();
assert_eq!(cpu.pc(), ROM_START + 2);
assert_eq!(memory.read16(ROM_START).unwrap(), 0x6001);
// Mirrors past the supplied image ignore stores as well.
let mut cpu = thumb_cpu();
cpu.registers[0] = 0x09fe_2ffc;
cpu.step(&mut memory).unwrap();
assert_eq!(cpu.pc(), ROM_START + 2);
let mut cpu = thumb_cpu();
cpu.registers[0] = 0x0d00_0000;
let before = cpu.registers;
assert_eq!(
cpu.step(&mut memory),
Err(CpuError::Memory(MemoryError::ReadOnly(ROM_START)))
Err(CpuError::Memory(MemoryError::ReadOnly(0x0d00_0000)))
);
assert_eq!(cpu.registers, before);
assert_eq!(memory.read16(ROM_START).unwrap(), 0x6001);
}
#[test]
@ -748,9 +759,14 @@ fn late_thumb_stack_and_block_errors_do_not_partially_commit() {
memory.write32(0x07ff_fffc, 0x1234_5678).unwrap();
let mut cpu = thumb_cpu();
cpu.registers[15] = 0x0300_0000;
cpu.registers[0] = 0x07ff_fffc;
// Stores cross an ignored ROM word into the diagnostic cartridge window.
cpu.registers[0] = if instruction == 0xc003 {
0x0cff_fffc
} else {
0x07ff_fffc
};
cpu.registers[13] = if instruction == 0xb403 {
0x0800_0004
0x0d00_0004
} else {
0x07ff_fffc
};

View file

@ -302,7 +302,9 @@ fn block_diagnostics_do_not_commit_partial_register_or_ram_changes() {
memory.write32(0x07ff_fffc, 0xdead_beef).unwrap();
let mut cpu = Cpu::new(0x0300_0000);
cpu.registers[0] = 42;
cpu.registers[2] = 0x07ff_fffc; // Second transfer reaches absent/read-only ROM.
// The second load reaches absent ROM. The second store crosses an ignored
// ROM word into the diagnostic cartridge window.
cpu.registers[2] = if load { 0x07ff_fffc } else { 0x0cff_fffc };
cpu.flags = flags();
let before = cpu.registers;
assert_eq!(
@ -310,7 +312,7 @@ fn block_diagnostics_do_not_commit_partial_register_or_ram_changes() {
Err(CpuError::Memory(if load {
MemoryError::Unmapped(ROM_START)
} else {
MemoryError::ReadOnly(ROM_START)
MemoryError::ReadOnly(0x0d00_0000)
}))
);
assert_eq!(cpu.registers, before);
@ -320,7 +322,7 @@ fn block_diagnostics_do_not_commit_partial_register_or_ram_changes() {
}
#[test]
fn block_load_can_read_rom_and_store_reports_read_only_memory() {
fn block_load_can_read_rom_and_store_to_rom_is_ignored() {
for load in [false, true] {
let mut memory = program(&[block(0x0080_0000, load, true, 0, 0x6), 11, 22]);
let mut cpu = Cpu::new(ROM_START);
@ -330,13 +332,11 @@ fn block_load_can_read_rom_and_store_reports_read_only_memory() {
assert_eq!(&cpu.registers[1..3], &[11, 22]);
assert_eq!(cpu.registers[0], ROM_START + 12);
} else {
let before = cpu.registers;
assert_eq!(
cpu.step(&mut memory),
Err(CpuError::Memory(MemoryError::ReadOnly(ROM_START + 4)))
);
assert_eq!(cpu.registers, before);
cpu.registers[1] = 33;
cpu.step(&mut memory).unwrap();
assert_eq!(cpu.registers[0], ROM_START + 12);
assert_eq!(memory.read32(ROM_START + 4).unwrap(), 11);
assert_eq!(memory.read32(ROM_START + 8).unwrap(), 22);
}
}
}
@ -414,19 +414,16 @@ fn swap_snapshots_aliased_source_destination_and_base_registers() {
}
#[test]
fn swap_write_failure_preserves_destination_and_memory() {
fn swap_with_rom_loads_the_old_value_and_ignores_the_store() {
for byte in [false, true] {
let mut memory = program(&[0xe100_1092 | (u32::from(byte) << 22), 0x1234_5678]);
let mut cpu = Cpu::new(ROM_START);
cpu.registers[0] = ROM_START + 4;
cpu.registers[1] = 123;
cpu.registers[2] = 456;
let before = cpu.registers;
assert_eq!(
cpu.step(&mut memory),
Err(CpuError::Memory(MemoryError::ReadOnly(ROM_START + 4)))
);
assert_eq!(cpu.registers, before);
cpu.step(&mut memory).unwrap();
assert_eq!(cpu.registers[1], if byte { 0x78 } else { 0x1234_5678 });
assert_eq!(cpu.registers[2], 456);
assert_eq!(memory.read32(ROM_START + 4).unwrap(), 0x1234_5678);
}
}

View file

@ -987,7 +987,7 @@ impl Memory {
}
return Ok(());
}
if address < BIOS_SIZE as u32 {
if address < BIOS_SIZE as u32 || matches!(address >> 24, 0x08..=0x0d) {
// Only validated CPU/DMA writes reach here. Pay the bus cost but
// never modify the mapped ROM image or drive BIOS read history.
return Ok(());
@ -1123,8 +1123,11 @@ impl Memory {
Ok(0) // I/O does not use a RAM index.
}
0x08..=0x0d => {
// Game Pak ROM ignores emulated writes; host setup stays strict.
// The 0x0d window stays diagnostic until EEPROM is modeled there.
let ignored = access == WriteAccess::Emulated && address >> 24 != 0x0d;
for offset in 0..N as u32 {
if !self.cartridge_state().mapped(address + offset) {
if !ignored && !self.cartridge_state().mapped(address + offset) {
return Err(MemoryError::ReadOnly(address + offset));
}
}

View file

@ -185,7 +185,7 @@ fn failed_instruction_discards_its_iwram_fetch_and_failed_dma_preserves_committe
assert_eq!(memory.iwram_bus.committed_word(), Some(WORD));
let dma = DMA_BASE + 3 * DMA_STRIDE;
memory.write32(dma, IWRAM + 8).unwrap();
memory.write32(dma + 4, ROM_START).unwrap(); // Read-only destination.
memory.write32(dma + 4, 0x0d00_0000).unwrap(); // Diagnostic cartridge window.
memory.write32(dma + 8, 0x8400_0001).unwrap();
assert!(memory.step_dma().is_err());
assert_eq!(memory.iwram_bus.committed_word(), Some(WORD));

View file

@ -232,7 +232,7 @@ fn ram_dma_advances_the_queue_and_rom_dma_cancels_it_before_cpu_resume() {
#[test]
fn failed_dma_preserves_queue_and_clocks() {
for (source, destination) in [(0x0e00_0000, RAM), (RAM, ROM_START)] {
for (source, destination) in [(0x0e00_0000, RAM), (RAM, 0x0d00_0000)] {
let (mut cpu, mut memory) = prepared(false, &[NOP], &[]);
enable(&mut memory);
cpu.step_timed(&mut memory).unwrap();

View file

@ -249,7 +249,7 @@ fn dma_samples_timers_after_startup_and_source_and_writes_after_destination() {
#[test]
fn dma_failures_preserve_timers_and_if_and_clear_the_transaction() {
for (source, destination) in [(0x0e00_0000, RAM), (TIMER_BASE, ROM_START)] {
for (source, destination) in [(0x0e00_0000, RAM), (TIMER_BASE, 0x0d00_0000)] {
let mut memory = Memory::new(Vec::new()).unwrap();
start(&mut memory, 0, 0xffff, 0xc0);
let before = memory.io.timer_step();

View file

@ -437,8 +437,8 @@ fn protected_or_wrapping_source_ranges_return_without_copying() {
}
#[test]
fn copy_failure_keeps_earlier_writes_but_not_partial_instruction_changes() {
let (mut machine, _) = call(0x0b, false, [SOURCE, 0x07ff_fffc, 2 | (1 << 26)]);
fn copy_failure_follows_an_ignored_rom_word_without_partial_instruction_changes() {
let (mut machine, _) = call(0x0b, false, [SOURCE, 0x0cff_fffc, 2 | (1 << 26)]);
machine.memory_mut().write32(SOURCE, 0xaabb_ccdd).unwrap();
let mut failed = false;
for _ in 0..100 {
@ -448,12 +448,11 @@ fn copy_failure_keeps_earlier_writes_but_not_partial_instruction_changes() {
if let Err(error) = machine.step() {
assert_eq!(
error,
MachineError::Cpu(MemoryError::ReadOnly(0x0800_0000).into())
MachineError::Cpu(MemoryError::ReadOnly(0x0d00_0000).into())
);
assert_eq!(machine.cpu(), &before);
assert_eq!(machine.cycles(), cycles);
assert_eq!(machine.last_timing(), timing);
assert_eq!(machine.memory().read32(0x07ff_fffc).unwrap(), 0xaabb_ccdd);
failed = true;
break;
}

View file

@ -498,12 +498,11 @@ fn destination_failure_keeps_preceding_halfword_stores() {
angle: 0,
}])
};
let (mut m, _) = prepare(service, false, [DATA, 0x07ff_fffc, 1, 2], 0x1f, &data);
let (mut m, _) = prepare(service, false, [DATA, 0x0cff_fffc, 1, 2], 0x1f, &data);
assert_eq!(
fail(&mut m),
MachineError::Cpu(MemoryError::ReadOnly(ROM_START).into())
MachineError::Cpu(MemoryError::ReadOnly(0x0d00_0000).into())
);
assert_eq!(m.memory().read32(0x07ff_fffc).unwrap(), 256);
}
}

View file

@ -361,10 +361,10 @@ fn truncated_descriptors_and_sources_return_memory_diagnostics() {
}
#[test]
fn destination_failure_preserves_completed_word() {
fn destination_failure_follows_an_ignored_rom_word() {
let (mut m, _) = prepare(
false,
0x07ff_fffc,
0x0cff_fffc,
&[0x12, 0x34, 0x56, 0x78, 1, 2, 3, 4],
8,
8,
@ -372,9 +372,8 @@ fn destination_failure_preserves_completed_word() {
);
assert_eq!(
fail(&mut m),
MachineError::Cpu(MemoryError::ReadOnly(ROM_START).into())
MachineError::Cpu(MemoryError::ReadOnly(0x0d00_0000).into())
);
assert_eq!(m.memory().read32(0x07ff_fffc).unwrap(), 0x7856_3412);
}
#[test]

View file

@ -321,17 +321,13 @@ fn failed_source_reads_keep_completed_units_but_not_pending_halfwords() {
}
#[test]
fn destination_failure_preserves_preceding_completed_halfword() {
fn destination_failure_follows_an_ignored_rom_halfword() {
for service in [0x17, 0x18] {
let (data, _) = filtered(service, &[0x12, 0x34, 0x56, 0x78]);
let (mut m, _) = prepare(service, false, 0x07ff_fffe, &data);
let (mut m, _) = prepare(service, false, 0x0cff_fffe, &data);
assert_eq!(
fail(&mut m),
MachineError::Cpu(MemoryError::ReadOnly(ROM_START).into())
);
assert_eq!(
m.memory().read16(0x07ff_fffe).unwrap(),
if service == 0x17 { 0x3412 } else { 0x12 }
MachineError::Cpu(MemoryError::ReadOnly(0x0d00_0000).into())
);
}
}

View file

@ -412,14 +412,13 @@ fn input_word_refill_failure_does_not_commit_incomplete_output() {
}
#[test]
fn destination_failure_preserves_preceding_completed_word() {
fn destination_failure_follows_an_ignored_rom_word() {
let (data, _) = encode(8, &pair(), &[0x12; 8]);
let (mut m, _) = prepare(false, 0x07ff_fffc, &data);
let (mut m, _) = prepare(false, 0x0cff_fffc, &data);
assert_eq!(
fail(&mut m),
MachineError::Cpu(MemoryError::ReadOnly(ROM_START).into())
MachineError::Cpu(MemoryError::ReadOnly(0x0d00_0000).into())
);
assert_eq!(m.memory().read32(0x07ff_fffc).unwrap(), 0x1212_1212);
}
#[test]

View file

@ -431,10 +431,9 @@ fn destination_failure_preserves_preceding_completed_halfword() {
Token::Literal(0x56),
Token::Literal(0x78),
]);
let (mut machine, _) = prepare(0x12, false, 0x07ff_fffe, &compressed);
let (mut machine, _) = prepare(0x12, false, 0x0cff_fffe, &compressed);
assert_eq!(
fail(&mut machine),
MachineError::Cpu(MemoryError::ReadOnly(0x0800_0000).into())
MachineError::Cpu(MemoryError::ReadOnly(0x0d00_0000).into())
);
assert_eq!(machine.memory().read16(0x07ff_fffe).unwrap(), 0x3412);
}

View file

@ -317,14 +317,13 @@ fn incomplete_halfwords_are_not_committed_after_source_failure() {
}
#[test]
fn destination_failure_preserves_earlier_completed_writes() {
fn destination_failure_follows_an_ignored_rom_halfword() {
let (data, _) = stream(&[Block::Literal(vec![0x12, 0x34, 0x56, 0x78])]);
let (mut m, _) = prepare(0x15, false, 0x07ff_fffe, &data);
let (mut m, _) = prepare(0x15, false, 0x0cff_fffe, &data);
assert_eq!(
fail(&mut m),
MachineError::Cpu(MemoryError::ReadOnly(ROM_START).into())
MachineError::Cpu(MemoryError::ReadOnly(0x0d00_0000).into())
);
assert_eq!(m.memory().read16(0x07ff_fffe).unwrap(), 0x3412);
}
#[test]

View file

@ -686,10 +686,10 @@ fn invalid_memory_and_dma_register_destinations_are_diagnostics() {
),
(
SOURCE,
ROM_START,
0x0d00_0000,
DmaError::Memory {
channel: 3,
error: MemoryError::ReadOnly(ROM_START),
error: MemoryError::ReadOnly(0x0d00_0000),
},
),
(

View file

@ -145,12 +145,12 @@ fn zero_is_known_data_not_an_unknown_latch() {
fn failed_units_preserve_the_old_latch_without_new_data_or_irq() {
for (source, destination, control) in [
(0x0e00_0000, DEST, 0x4400), // Unmapped reads do not use a known latch.
(SOURCE, ROM_START, 0x4400),
(SOURCE, 0x0d00_0000, 0x4400),
(SOURCE, 0x0400_0058, 0x4400),
(SOURCE, DMA_BASE, 0x4400),
(SOURCE, 0x0400_0300, 0x4400),
(SOURCE, DEST, 0x4580), // Unsupported source mode.
(0, ROM_START, 0x4400), // A blocked source cannot bypass destination checks.
(SOURCE, DEST, 0x4580), // Unsupported source mode.
(0, 0x0d00_0000, 0x4400), // A blocked source cannot bypass destination checks.
] {
let mut machine = machine();
seed(&mut machine, 3, DATA);
@ -175,7 +175,7 @@ fn failed_units_preserve_the_old_latch_without_new_data_or_irq() {
fn failed_destination_does_not_seed_a_cold_channel() {
let mut machine = machine();
machine.memory_mut().write32(SOURCE, DATA).unwrap();
configure(machine.memory_mut(), 3, SOURCE, ROM_START, 1, 0x400);
configure(machine.memory_mut(), 3, SOURCE, 0x0d00_0000, 1, 0x400);
assert!(machine.step().is_err());
machine.memory_mut().write16(base(3) + 10, 0).unwrap();
configure(machine.memory_mut(), 3, 0, DEST, 1, 0x400);