fix(memory): ignore emulated writes to mapped BIOS

This commit is contained in:
0xrsydn 2026-10-07 03:31:33 +07:00
commit a0516de748
16 changed files with 462 additions and 34 deletions

View file

@ -357,7 +357,8 @@ fn dma_and_irq_each_consume_a_budget_step_and_dma_errors_fail() {
assert_eq!(stats.irq_entries, 1);
assert_eq!(stats.instructions, 0);
let bus = machine.memory_mut();
bus.write32(DMA_BASE + 4, ROM_START).unwrap();
// DMA0 masks cartridge addresses to BIOS; use an actually unmapped destination.
bus.write32(DMA_BASE + 4, 0x0000_4000).unwrap();
bus.write32(DMA_BASE + 8, 0x8000_0001).unwrap();
let (stats, outcome) = run_to_checkpoint(&mut machine, &config);
assert_eq!(stats.steps, 0);

View file

@ -322,7 +322,8 @@ fn dma_errors_return_a_diagnostic_without_consuming_a_step() {
let mut machine = boot(&[0xeaff_fffe]);
let bus = machine.memory_mut();
bus.write32(DMA_BASE, 0x0200_0000).unwrap();
bus.write32(DMA_BASE + 4, ROM_START).unwrap();
// DMA0 masks cartridge addresses to BIOS; use an actually unmapped destination.
bus.write32(DMA_BASE + 4, 0x0000_4000).unwrap();
bus.write32(DMA_BASE + 8, 0x8000_0001).unwrap();
let report = run_bounded(&mut machine, 100);
assert!(matches!(

View file

@ -266,7 +266,8 @@ fn dma_and_irq_consume_slice_budget_and_dma_failure_preserves_progress() {
.unwrap();
assert_eq!(session.stats.irq_entries, 1);
let bus = session.machine.memory_mut();
bus.write32(DMA_BASE + 4, ROM_START).unwrap();
// DMA0 masks cartridge addresses to BIOS; use an actually unmapped destination.
bus.write32(DMA_BASE + 4, 0x0000_4000).unwrap();
bus.write32(DMA_BASE + 8, 0x8000_0001).unwrap();
let cycles = session.machine.cycles();
assert!(session.update(Buttons::default()).is_err());

View file

@ -32,6 +32,8 @@ mod arm_pipeline_tests;
#[cfg(test)]
mod bios_access_tests;
#[cfg(test)]
mod bios_write_tests;
#[cfg(test)]
mod compare_psr_tests;
#[cfg(test)]
mod dma_resume_tests;

View file

@ -0,0 +1,276 @@
//! Original BIOS-bus write probes. Images stay immutable; bus transfers still complete.
use super::*;
use crate::{
dma::{DmaError, DMA_BASE},
io::{IF, TIMER_BASE},
machine::{Machine, MachineError, StepKind},
memory::{BIOS_SIZE, ROM_START},
};
const DATA: u32 = 0x9234_abcd;
const RAM: u32 = 0x0200_0100;
fn setup(pc: u32, instruction: u32, thumb: bool) -> (Cpu, Memory, Vec<u8>) {
let mut image = vec![0x55; BIOS_SIZE];
let rom = [instruction, 0xe1a0_0000, 0xe1a0_0000]
.into_iter()
.flat_map(u32::to_le_bytes)
.collect();
if pc < BIOS_SIZE as u32 {
image[pc as usize..pc as usize + 4].copy_from_slice(&instruction.to_le_bytes());
}
let mut memory = Memory::with_bios(rom, image.clone()).unwrap();
if pc == RAM {
memory.write32(pc, instruction).unwrap();
memory.write32(pc + 4, 0xe1a0_0000).unwrap();
memory.write32(pc + 8, 0xe1a0_0000).unwrap();
}
let mut cpu = Cpu::new(pc);
if thumb {
cpu.instruction_set = InstructionSet::Thumb;
}
cpu.registers[1] = DATA;
(cpu, memory, image)
}
fn unchanged(memory: &Memory, image: &[u8]) {
for (address, expected) in image.iter().enumerate() {
assert_eq!(memory.read8(address as u32).unwrap(), *expected);
}
}
#[test]
fn bios_stores_ignore_data_in_both_states_all_modes_widths_and_address_lanes() {
for (arm, thumb) in [
(0xe5c0_1000, 0x7001),
(0xe1c0_10b0, 0x8001),
(0xe580_1000, 0x6001),
] {
for is_thumb in [false, true] {
for pc in [0x100, RAM, ROM_START] {
for mode in [
Mode::User,
Mode::System,
Mode::Supervisor,
Mode::Irq,
Mode::Fiq,
Mode::Abort,
Mode::Undefined,
] {
for address in [0, 1, 2, 3, 0x3ffc, 0x3ffd, 0x3ffe, 0x3fff] {
let (mut cpu, mut memory, image) =
setup(pc, if is_thumb { thumb } else { arm }, is_thumb);
cpu.apply_status(
0xb000_00c0 | (u32::from(is_thumb) << 5) | mode as u32,
mode,
);
cpu.registers[0] = address;
let mut expected = cpu.clone();
expected.registers[15] += if is_thumb { 2 } else { 4 };
let timing = cpu.step_timed(&mut memory).unwrap();
assert_cpu_arch_eq!(cpu, expected);
assert_eq!(timing.data_cycles, 1);
assert_eq!(memory.cycles(), 0); // CPU-only stepping never clocks devices.
unchanged(&memory, &image);
assert_eq!(memory.write8(0, 1), Err(MemoryError::ReadOnly(0)));
}
}
}
}
}
}
#[test]
fn ignored_block_stores_write_back_but_a_later_unmapped_word_rolls_back_the_instruction() {
for thumb in [false, true] {
for base in [0x3ff8, 0x3ffc] {
let (mut cpu, mut memory, image) =
setup(ROM_START, if thumb { 0xc006 } else { 0xe8a0_0006 }, thumb);
cpu.registers[0] = base;
cpu.registers[2] = !DATA;
let before = cpu.clone();
let result = cpu.step_timed(&mut memory);
if base == 0x3ff8 {
assert_eq!(result.unwrap().data_cycles, 2);
assert_eq!(cpu.registers[0], base + 8);
assert_eq!(cpu.pc(), ROM_START + if thumb { 2 } else { 4 });
} else {
assert_eq!(result, Err(CpuError::Memory(MemoryError::Unmapped(0x4000))));
assert_eq!(cpu, before);
assert_eq!(cpu.step_timed(&mut memory), result);
}
unchanged(&memory, &image);
}
}
}
#[test]
fn swap_keeps_protected_read_lanes_and_does_not_latch_the_ignored_write_value() {
for byte in [false, true] {
for lane in 0..4 {
let instruction = if byte { 0xe140_2091_u32 } else { 0xe100_2091 }; // SWPB/SWP r2,r1,[r0]
let rom = [instruction, 0xe590_3000, 0xe1a0_0000, 0xe1a0_0000]
.into_iter()
.flat_map(u32::to_le_bytes)
.collect();
// Enter BIOS using a synthetic image to establish known protected read history.
let mut image = vec![0x55; BIOS_SIZE];
image[0x100..0x104].copy_from_slice(&0xe12f_ff14_u32.to_le_bytes()); // BX r4
image[0x108..0x10c].copy_from_slice(&0x8765_4321_u32.to_le_bytes());
let mut memory = Memory::with_bios(rom, image.clone()).unwrap();
let mut cpu = Cpu::new(0x100);
cpu.registers[4] = ROM_START;
cpu.step(&mut memory).unwrap();
cpu.registers[0] = lane;
cpu.registers[1] = DATA;
assert_eq!(cpu.step_timed(&mut memory).unwrap().data_cycles, 2);
let rotated = 0x8765_4321_u32.rotate_right(lane * 8);
assert_eq!(cpu.registers[2], if byte { rotated & 255 } else { rotated });
cpu.step(&mut memory).unwrap(); // Protected LDR r3,[r0] retains the old BIOS fetch.
assert_eq!(cpu.registers[3], rotated);
unchanged(&memory, &image);
}
}
}
#[test]
fn swap_with_unknown_bios_read_history_still_fails_before_the_write() {
let (mut cpu, mut memory, image) = setup(ROM_START, 0xe100_2091, false);
cpu.registers[0] = 0;
let before = cpu.clone();
assert_eq!(
cpu.step(&mut memory),
Err(CpuError::Memory(MemoryError::Unmapped(0)))
);
assert_eq!(cpu, before);
unchanged(&memory, &image);
}
#[test]
fn ignored_cpu_stores_still_advance_timers_and_preserve_strict_host_diagnostics() {
let (mut cpu, mut memory, image) = setup(ROM_START, 0xe480_1004, false); // STR r1,[r0],#4
cpu.registers[0] = 0x200;
memory.write32(TIMER_BASE, 0x0080_0000).unwrap();
let mut machine = Machine::new(cpu, memory);
assert_eq!(machine.step().unwrap(), StepKind::Instruction);
assert_eq!(machine.cpu().registers()[0], 0x204);
assert_eq!(machine.last_timing().data_cycles, 1);
assert_eq!(u64::from(machine.last_timing().total()), machine.cycles());
assert_eq!(
u64::from(machine.memory().read16(TIMER_BASE).unwrap()),
machine.cycles()
);
unchanged(machine.memory(), &image);
for address in [0, 0x3ffc] {
assert_eq!(
machine.memory_mut().write8(address, 0),
Err(MemoryError::ReadOnly(address))
);
assert_eq!(
machine.memory_mut().write16(address, 0),
Err(MemoryError::ReadOnly(address))
);
assert_eq!(
machine.memory_mut().write32(address, 0),
Err(MemoryError::ReadOnly(address))
);
}
}
#[test]
fn unmapped_bios_and_cartridge_destinations_remain_retryable_diagnostics() {
for (address, mapped_bios) in [
(0, false),
(0x4000, true),
(0x0100_0000, true),
(ROM_START, true),
] {
let (mut cpu, mut memory, _) = setup(ROM_START, 0xe480_1004, false);
if !mapped_bios {
memory = Memory::new(
[0xe480_1004_u32, 0xe1a0_0000, 0xe1a0_0000]
.into_iter()
.flat_map(u32::to_le_bytes)
.collect(),
)
.unwrap();
}
cpu.registers[0] = address;
let before = cpu.clone();
let mut machine = Machine::new(cpu, memory);
let error = machine.step().unwrap_err();
assert_eq!(
error,
MachineError::Cpu(CpuError::Memory(if address == ROM_START {
MemoryError::ReadOnly(address)
} else {
MemoryError::Unmapped(address)
}))
);
assert_eq!(machine.cpu(), &before);
assert_eq!(machine.cycles(), 0);
assert_eq!(machine.step(), Err(error));
}
}
#[test]
fn dma_ignored_writes_pay_cycles_complete_and_raise_the_normal_completion_irq() {
// DMA0's destination mask turns ROM_START into BIOS address zero.
for (word, destination) in [(false, 0), (true, 0), (false, ROM_START), (true, ROM_START)] {
let (cpu, mut memory, image) = setup(ROM_START, 0xeaff_fffe, false);
memory.write32(RAM, DATA).unwrap();
memory.write32(DMA_BASE, RAM).unwrap();
memory.write32(DMA_BASE + 4, destination).unwrap();
memory
.write32(DMA_BASE + 8, if word { 0xc400_0001 } else { 0xc000_0001 })
.unwrap();
memory.write32(TIMER_BASE, 0x0080_0000).unwrap();
let before = cpu.clone();
let mut machine = Machine::new(cpu, memory);
assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 0 });
assert_eq!(machine.cpu(), &before);
assert_eq!(machine.cycles(), if word { 9 } else { 6 }); // Startup + EWRAM read + BIOS write.
assert_eq!(
u64::from(machine.memory().read16(TIMER_BASE).unwrap()),
machine.cycles()
);
assert_eq!(machine.memory().read16(IF).unwrap(), 0x100);
assert_eq!(machine.memory().read16(DMA_BASE + 10).unwrap() & 0x8000, 0);
unchanged(machine.memory(), &image);
// A blocked source now reuses this completed unit's data, not BIOS bytes.
machine.memory_mut().write32(DMA_BASE, 0).unwrap();
machine.memory_mut().write32(DMA_BASE + 4, RAM + 8).unwrap();
machine
.memory_mut()
.write32(DMA_BASE + 8, 0x8400_0001)
.unwrap();
machine.step().unwrap();
assert_eq!(
machine.memory().read32(RAM + 8).unwrap(),
if word { DATA } else { 0xabcd_abcd }
);
}
}
#[test]
fn dma_bios_boundary_keeps_completed_units_and_retries_the_unmapped_destination() {
let (cpu, mut memory, image) = setup(ROM_START, 0xeaff_fffe, false);
memory.write32(RAM, DATA).unwrap();
memory.write32(DMA_BASE, RAM).unwrap();
memory.write32(DMA_BASE + 4, 0x3ffc).unwrap();
memory.write32(DMA_BASE + 8, 0xc400_0002).unwrap();
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
let cycles = machine.cycles();
let timing = machine.last_timing();
let error = MachineError::Dma(DmaError::Memory {
channel: 0,
error: MemoryError::Unmapped(0x4000),
});
assert_eq!(machine.step(), Err(error.clone()));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cycles(), cycles);
assert_eq!(machine.last_timing(), timing);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
unchanged(machine.memory(), &image);
}

View file

@ -86,6 +86,13 @@ fn vram_index(address: u32) -> usize {
}
}
/// Host setup rejects read-only destinations; CPU and DMA use bus semantics.
#[derive(Clone, Copy, PartialEq, Eq)]
enum WriteAccess {
Host,
Emulated,
}
#[derive(Clone, Copy)]
struct CpuAccess {
pc: u32,
@ -373,8 +380,10 @@ impl Memory {
let map_error = |error| DmaError::Memory { channel, error };
// Validate before reading or committing channel data. Failed units are atomic.
match transfer.width {
AccessWidth::Halfword => self.write_index::<2>(transfer.destination),
AccessWidth::Word => self.write_index::<4>(transfer.destination),
AccessWidth::Halfword => {
self.write_index::<2>(transfer.destination, WriteAccess::Emulated)
}
AccessWidth::Word => self.write_index::<4>(transfer.destination, WriteAccess::Emulated),
AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
}
.map_err(map_error)?;
@ -419,8 +428,16 @@ impl Memory {
};
self.advance_timer_step(timing.total());
match transfer.width {
AccessWidth::Halfword => self.write16(transfer.destination, value as u16),
AccessWidth::Word => self.write32(transfer.destination, value),
AccessWidth::Halfword => self.write_aligned(
transfer.destination,
(value as u16).to_le_bytes(),
WriteAccess::Emulated,
),
AccessWidth::Word => self.write_aligned(
transfer.destination,
value.to_le_bytes(),
WriteAccess::Emulated,
),
AccessWidth::Byte => unreachable!("DMA is never byte-wide"),
}?;
Ok::<_, MemoryError>((data_latch, value))
@ -722,15 +739,23 @@ impl Memory {
}
pub fn write8(&mut self, address: u32, value: u8) -> Result<(), MemoryError> {
self.write_aligned(address, [value])
self.write_aligned(address, [value], self.write_access())
}
pub fn write16(&mut self, address: u32, value: u16) -> Result<(), MemoryError> {
self.write_aligned(address, value.to_le_bytes())
self.write_aligned(address, value.to_le_bytes(), self.write_access())
}
pub fn write32(&mut self, address: u32, value: u32) -> Result<(), MemoryError> {
self.write_aligned(address, value.to_le_bytes())
self.write_aligned(address, value.to_le_bytes(), self.write_access())
}
fn write_access(&self) -> WriteAccess {
if self.cpu_access.is_some() {
WriteAccess::Emulated
} else {
WriteAccess::Host
}
}
// Validate the complete access before any RAM or I/O side effects.
@ -739,8 +764,9 @@ impl Memory {
&mut self,
address: u32,
bytes: [u8; N],
access: WriteAccess,
) -> Result<(), MemoryError> {
let index = self.write_index::<N>(address)?;
let index = self.write_index::<N>(address, access)?;
Self::validate_io_values(&mut self.audio_state(), address, &bytes)?;
self.record_access(
address,
@ -751,6 +777,11 @@ impl Memory {
_ => unreachable!("unsupported bus width"),
},
);
if address < BIOS_SIZE as u32 {
// 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(());
}
if address >> 24 == 0x03 {
let (width, value) = match N {
1 => (AccessWidth::Byte, u32::from(bytes[0])),
@ -840,13 +871,21 @@ impl Memory {
Ok(())
}
fn write_index<const N: usize>(&self, address: u32) -> Result<usize, MemoryError> {
fn write_index<const N: usize>(
&self,
address: u32,
access: WriteAccess,
) -> Result<usize, MemoryError> {
if address & (N as u32 - 1) != 0 {
return Err(MemoryError::Unaligned(address));
}
match address >> 24 {
0x00 if self.bios.is_some() && address < BIOS_SIZE as u32 => {
Err(MemoryError::ReadOnly(address))
if access == WriteAccess::Emulated {
Ok(0) // Mapped BIOS ignores emulated writes; host setup stays strict.
} else {
Err(MemoryError::ReadOnly(address))
}
}
0x02 => Ok((address & 0x3ffff) as usize),
0x03 => Ok((address & 0x7fff) as usize),
@ -897,7 +936,7 @@ impl Memory {
pub(crate) fn write_words(&mut self, writes: &[(u32, u32)]) -> Result<(), MemoryError> {
let mut audio = self.audio_state();
for &(address, value) in writes {
self.write_index::<4>(address)?;
self.write_index::<4>(address, self.write_access())?;
Self::validate_io_values(&mut audio, address, &value.to_le_bytes())?;
}
for &(address, value) in writes {

View file

@ -22,6 +22,8 @@ mod arithmetic;
mod bit_unpack;
#[path = "bios/boot_video.rs"]
mod boot_video;
#[path = "bios/copy_bus.rs"]
mod copy_bus;
#[path = "bios/differential.rs"]
mod differential;
#[path = "bios/huffman.rs"]

View file

@ -0,0 +1,51 @@
//! Copy services execute real bus accesses, including ignored BIOS destinations.
use super::*;
#[test]
fn copy_and_fill_to_bios_return_without_modifying_firmware() {
for thumb in [false, true] {
for (service, width) in [(0x0b, 0), (0x0b, 1 << 26), (0x0c, 0)] {
for fill in [0, 1 << 24] {
let (mut machine, return_pc) = call(service, thumb, [SOURCE, 0, 28 | width | fill]);
for index in 0..32 {
machine
.memory_mut()
.write32(SOURCE + index * 4, 0x1234_abcd ^ index)
.unwrap();
}
let before = machine.cpu().clone();
let cycles = machine.cycles();
finish(&mut machine, return_pc);
assert_eq!(&machine.cpu().registers()[..15], &before.registers()[..15]);
assert_eq!(machine.cpu().cpsr(), before.cpsr());
assert!(machine.cycles() > cycles);
for (address, expected) in bios::image().into_iter().enumerate() {
assert_eq!(machine.memory().read8(address as u32).unwrap(), expected);
}
assert_eq!(
machine.memory_mut().write16(0, 0),
Err(MemoryError::ReadOnly(0))
);
}
}
}
}
#[test]
fn ignored_bios_destination_does_not_bypass_invalid_source_reads() {
for service in [0x0b, 0x0c] {
let (mut machine, _) = call(service, true, [0x0e00_0000, 0, 1]);
let error = (0..200)
.find_map(|_| machine.step().err())
.expect("source read must still fail");
assert_eq!(
error,
MachineError::Cpu(CpuError::Memory(MemoryError::Unmapped(0x0e00_0000)))
);
let cpu = machine.cpu().clone();
let cycles = machine.cycles();
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.cycles(), cycles);
}
}

View file

@ -436,14 +436,14 @@ fn dma_reset_disables_channels_and_clears_write_only_address_and_count_latches()
);
m.memory_mut().write16(base + 10, 0).unwrap();
m.memory_mut().write32(base, SOURCE).unwrap();
m.memory_mut().write32(SOURCE, MARKER).unwrap();
m.memory_mut().write32(DEST, !MARKER).unwrap();
let vector = m.memory().read32(0).unwrap();
m.memory_mut().write16(base + 10, 0x8000).unwrap();
assert_eq!(
m.step(),
Err(MachineError::Dma(DmaError::Memory {
channel,
error: MemoryError::ReadOnly(0)
}))
);
assert_eq!(m.step().unwrap(), StepKind::Dma { channel });
// The reset destination is BIOS: discard the transfer, not into stale DEST.
assert_eq!(m.memory().read32(0).unwrap(), vector);
assert_eq!(m.memory().read32(DEST).unwrap(), !MARKER);
m.memory_mut().write16(base + 10, 0).unwrap();
m.memory_mut().write32(base + 4, DEST).unwrap();
m.memory_mut().write32(SOURCE, MARKER).unwrap();