feat(cartridge): add explicit Flash identification and banked reads

This commit is contained in:
0xrsydn 2026-10-07 06:13:39 +07:00
commit 8b3b7ae62d
23 changed files with 1042 additions and 52 deletions

View file

@ -3,8 +3,11 @@
use crate::memory::MemoryError;
mod calendar;
mod flash;
mod rtc;
pub use calendar::{RtcDateTime, RtcError};
use flash::Flash;
pub use flash::{SaveDevice, SaveError, SAVE_END, SAVE_START};
use rtc::Rtc;
pub const GPIO_DATA: u32 = 0x0800_00c4;
@ -23,13 +26,28 @@ pub enum CartridgeHardware {
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Cartridge {
gpio: Option<Gpio>,
flash: Option<Flash>,
}
impl Cartridge {
pub(crate) fn new(hardware: CartridgeHardware) -> Self {
Self {
gpio: (hardware == CartridgeHardware::Rtc).then(Gpio::default),
}
pub(crate) fn set_hardware(&mut self, hardware: CartridgeHardware) {
self.gpio = (hardware == CartridgeHardware::Rtc).then(Gpio::default);
}
pub(crate) fn set_save_device(&mut self, device: SaveDevice) {
self.flash = Flash::new(device);
}
pub(crate) fn save_device(&self) -> SaveDevice {
self.flash.map_or(SaveDevice::None, Flash::device)
}
pub(crate) fn save_mapped(&self, address: u32) -> bool {
self.flash.is_some() && (SAVE_START..=SAVE_END).contains(&address)
}
pub(crate) fn read_save8(&self, address: u32, image: &[u8]) -> Result<u8, MemoryError> {
self.flash.expect("mapped save device").read(address, image)
}
pub(crate) fn rtc_datetime(&self) -> Option<RtcDateTime> {
@ -56,13 +74,14 @@ impl Cartridge {
}
pub(crate) fn mapped(&self, address: u32) -> bool {
self.gpio.is_some() && (GPIO_DATA..GPIO_CONTROL + 2).contains(&address)
self.save_mapped(address)
|| (self.gpio.is_some() && (GPIO_DATA..GPIO_CONTROL + 2).contains(&address))
}
/// Disabled reads expose the supplied ROM bytes, not a zero-filled replacement.
pub(crate) fn read8(&self, address: u32) -> Option<u8> {
let gpio = self.gpio?;
if !self.mapped(address) || !gpio.readable {
if !(GPIO_DATA..GPIO_CONTROL + 2).contains(&address) || !gpio.readable {
return None;
}
let value = match address & !1 {
@ -79,6 +98,15 @@ impl Cartridge {
if !self.mapped(address) {
return Ok(()); // Non-cartridge accesses are validated by the bus.
}
if self.save_mapped(address) {
if bytes.len() != 1 {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash non-byte write",
});
}
return self.flash.as_mut().unwrap().write(address, bytes[0]);
}
let fail = |at, value, operation| MemoryError::UnsupportedIo {
address: at,
value,

View file

@ -0,0 +1,148 @@
//! Bounded Macronix Flash identification, array reads, and 128 KiB banking.
//! Program/erase commands remain diagnostic: this module cannot claim a completed save.
use crate::memory::MemoryError;
use std::{error::Error, fmt};
pub const SAVE_START: u32 = 0x0e00_0000;
pub const SAVE_END: u32 = 0x0e00_ffff;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SaveDevice {
#[default]
None,
/// Macronix ID 1CC2h. Array reads and identification only.
Flash64,
/// Macronix ID 09C2h. Adds two 64 KiB banks; no programming or erase yet.
Flash128,
}
impl SaveDevice {
pub fn capacity(self) -> usize {
match self {
Self::None => 0,
Self::Flash64 => 64 * 1024,
Self::Flash128 => 128 * 1024,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveError {
NoDevice,
InvalidSize { expected: usize, actual: usize },
}
impl fmt::Display for SaveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoDevice => f.write_str("no cartridge save device selected"),
Self::InvalidSize { expected, actual } => write!(
f,
"save image size {actual}; expected exactly {expected} bytes"
),
}
}
}
impl Error for SaveError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Flash {
device: SaveDevice,
phase: Phase,
identify: bool,
bank: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Ready,
Unlock,
Command,
Bank,
}
impl Flash {
pub(super) fn new(device: SaveDevice) -> Option<Self> {
(device != SaveDevice::None).then_some(Self {
device,
phase: Phase::Ready,
identify: false,
bank: 0,
})
}
pub(super) fn device(self) -> SaveDevice {
self.device
}
pub(super) fn read(self, address: u32, image: &[u8]) -> Result<u8, MemoryError> {
let offset = (address - SAVE_START) as usize;
if self.identify {
return match offset {
0 => Ok(0xc2),
1 => Ok(if self.device == SaveDevice::Flash64 {
0x1c
} else {
9
}),
_ => Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash ID read outside manufacturer/device bytes",
}),
};
}
Ok(image[usize::from(self.bank) * 0x10000 + offset])
}
pub(super) fn write(&mut self, address: u32, value: u8) -> Result<(), MemoryError> {
let offset = address - SAVE_START;
let error = |operation| MemoryError::UnsupportedIo {
address,
value,
operation,
};
let mut next = *self;
// GBATEK documents direct reset for the 64 KiB chip. For 128 KiB, accept
// only a redundant reset while already idle in array mode. No busy/ID exit is inferred.
if offset == 0x5555
&& value == 0xf0
&& (self.device == SaveDevice::Flash64
|| (self.phase == Phase::Ready && !self.identify))
{
next.identify = false;
next.phase = Phase::Ready;
} else {
next.phase = match self.phase {
Phase::Ready if offset == 0x5555 && value == 0xaa => Phase::Unlock,
Phase::Unlock if offset == 0x2aaa && value == 0x55 => Phase::Command,
Phase::Command if offset == 0x5555 => {
if self.identify && value != 0xf0 {
return Err(error("Flash command while in ID mode"));
}
match value {
0x90 => {
next.identify = true;
Phase::Ready
}
0xf0 => {
next.identify = false;
Phase::Ready
}
0xb0 if self.device == SaveDevice::Flash128 => Phase::Bank,
0xb0 => return Err(error("Flash bank selection on 64 KiB device")),
0xa0 => return Err(error("Flash byte programming")),
0x80 => return Err(error("Flash erase setup")),
_ => return Err(error("Flash command")),
}
}
Phase::Bank if offset == 0 && value < 2 => {
next.bank = value;
Phase::Ready
}
Phase::Bank => return Err(error("Flash bank address/value")),
_ => return Err(error("Flash unlock/reset sequence")),
};
}
*self = next;
Ok(())
}
}

View file

@ -8,6 +8,8 @@ mod fetch;
#[cfg(test)]
mod fetch_tests;
#[cfg(test)]
mod flash_step_tests;
#[cfg(test)]
mod irq_fetch_tests;
mod iwram_bus;
#[cfg(test)]
@ -27,7 +29,7 @@ use iwram_bus::IwramBus;
use crate::{
audio::{Audio, StereoLevel},
cartridge::{Cartridge, CartridgeHardware, RtcDateTime, RtcError},
cartridge::{Cartridge, CartridgeHardware, RtcDateTime, RtcError, SaveDevice, SaveError},
cpu::InstructionSet,
display::{DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES},
dma::{DmaError, DMA_BASE, DMA_END},
@ -53,6 +55,10 @@ pub enum MemoryError {
Unaligned(u32),
RomTooLarge(usize),
InvalidBiosSize(usize),
UnsupportedCartridgeAccess {
address: u32,
operation: &'static str,
},
UnsupportedIo {
address: u32,
value: u8,
@ -63,6 +69,9 @@ pub enum MemoryError {
impl fmt::Display for MemoryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsupportedCartridgeAccess { address, operation } => {
write!(f, "unsupported {operation} at {address:#010x}")
}
Self::UnsupportedIo {
address,
value,
@ -122,6 +131,7 @@ pub struct Memory {
video_ram: Vec<u8>,
oam: Vec<u8>,
rom: Vec<u8>,
save_image: Vec<u8>,
cartridge: Cartridge,
cartridge_step: Cell<Option<Cartridge>>,
bios: Option<Vec<u8>>,
@ -153,6 +163,7 @@ impl Memory {
video_ram: vec![0; VRAM_SIZE],
oam: vec![0; 1024],
rom,
save_image: Vec::new(),
cartridge: Cartridge::default(),
cartridge_step: Cell::new(None),
bios: None,
@ -183,14 +194,52 @@ impl Memory {
Ok(memory)
}
/// Replace optional cartridge hardware with its initial state. Call between machine steps.
/// This never modifies the supplied ROM bytes or enables a host clock.
/// Replace GPIO/RTC hardware with its initial state. Call between machine steps.
/// Save hardware/data are preserved. This never modifies ROM bytes or enables a host clock.
pub fn set_cartridge_hardware(&mut self, hardware: CartridgeHardware) {
assert!(
self.cartridge_step.get().is_none(),
"active cartridge transaction"
);
self.cartridge = Cartridge::new(hardware);
self.cartridge.set_hardware(hardware);
}
/// Replace save hardware and its array with erased bytes. GPIO/RTC state is unchanged.
/// Call between machine steps; no file is loaded or written by the core.
pub fn set_save_device(&mut self, device: SaveDevice) {
assert!(
self.cartridge_step.get().is_none(),
"active cartridge transaction"
);
self.save_image = vec![0xff; device.capacity()];
self.cartridge.set_save_device(device);
}
/// Inspect the complete save array, independently of ID mode and selected bank.
pub fn save_image(&self) -> Option<&[u8]> {
(self.cartridge.save_device() != SaveDevice::None).then_some(self.save_image.as_slice())
}
/// Replace the entire array and reset Flash command/bank state. RTC state is unchanged.
/// A wrong length changes nothing. This setup API does not emulate programming.
pub fn load_save_image(&mut self, bytes: &[u8]) -> Result<(), SaveError> {
assert!(
self.cartridge_step.get().is_none(),
"active cartridge transaction"
);
let device = self.cartridge.save_device();
if device == SaveDevice::None {
return Err(SaveError::NoDevice);
}
if bytes.len() != device.capacity() {
return Err(SaveError::InvalidSize {
expected: device.capacity(),
actual: bytes.len(),
});
}
self.save_image.copy_from_slice(bytes);
self.cartridge.set_save_device(device);
Ok(())
}
/// Inspect the live calendar, not an in-progress serial read snapshot.
@ -749,6 +798,16 @@ impl Memory {
// Multi-byte reads use this helper to avoid charging each byte as a bus access.
fn read_byte(&self, address: u32) -> Result<u8, MemoryError> {
if self.cartridge_state().save_mapped(address)
&& self
.cpu_access
.is_some_and(|access| !matches!(access.pc >> 24, 0x02 | 0x03))
{
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash CPU read outside work RAM",
});
}
if address >> 24 == 0x04 {
if let Some(step) = self.timer_step.get() {
if Serial::mapped(address) {
@ -784,7 +843,11 @@ impl Memory {
// Strict lookup for host access, instruction fetches, and prefetch snapshots.
fn read_mapped_byte(&self, address: u32) -> Result<u8, MemoryError> {
if let Some(value) = self.cartridge_state().read8(address) {
let cartridge = self.cartridge_state();
if cartridge.save_mapped(address) {
return cartridge.read_save8(address, &self.save_image);
}
if let Some(value) = cartridge.read8(address) {
return Ok(value);
}
match address >> 24 {
@ -1001,6 +1064,15 @@ impl Memory {
}
Ok(0)
}
0x0e if self.cartridge_state().save_mapped(address) => {
if N != 1 {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash non-byte write",
});
}
Ok(0)
}
_ => Err(MemoryError::Unmapped(address)),
}
}
@ -1086,6 +1158,7 @@ impl Memory {
if address & 1 != 0 {
return Err(MemoryError::Unaligned(address));
}
self.validate_save_read_width(address)?;
self.record_access(address, AccessWidth::Halfword);
let value = u16::from_le_bytes([self.read_byte(address)?, self.read_byte(address + 1)?]);
self.iwram_bus
@ -1093,11 +1166,22 @@ impl Memory {
Ok(value)
}
fn validate_save_read_width(&self, address: u32) -> Result<(), MemoryError> {
if self.cartridge_state().save_mapped(address) {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash non-byte read",
});
}
Ok(())
}
/// The bus requires alignment; the CPU applies ARM7TDMI load rotation.
pub fn read32(&self, address: u32) -> Result<u32, MemoryError> {
if address & 3 != 0 {
return Err(MemoryError::Unaligned(address));
}
self.validate_save_read_width(address)?;
self.record_access(address, AccessWidth::Word);
let value = u32::from_le_bytes([
self.read_byte(address)?,

View file

@ -62,6 +62,12 @@ impl Memory {
if address & (width as u32 - 1) != 0 {
return Err(MemoryError::Unaligned(address));
}
if self.cartridge_state().save_mapped(address) {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash instruction fetch",
});
}
let mut bytes = [0; 4];
for (offset, byte) in bytes[..width].iter_mut().enumerate() {
*byte = self.read_mapped_byte(address.wrapping_add(offset as u32))?;

View file

@ -0,0 +1,148 @@
//! Original Flash CPU bus phases, failed writes, fetch restrictions, and preflight checks.
use super::{timing_event_tests::prepared, *};
use crate::{
cartridge::{SaveDevice, SAVE_START},
cpu::Cpu,
io::{IF, WAITCNT},
machine::Machine,
};
const HI: u32 = SAVE_START + 0x5555;
const LO: u32 = SAVE_START + 0x2aaa;
fn unlocked(memory: &mut Memory) {
memory.set_save_device(SaveDevice::Flash128);
memory.write8(HI, 0xaa).unwrap();
memory.write8(LO, 0x55).unwrap();
}
fn ram_reader(thumb: bool, base: u32) -> (Cpu, Memory) {
let (mut cpu, mut memory) = prepared(
false,
&[0xe12fff12],
&[(1, SAVE_START), (2, base | u32::from(thumb))],
);
for at in (0..32).step_by(4) {
memory
.write32(base + at, if thumb { 0x46c046c0 } else { 0xe1a00000 })
.unwrap();
}
if thumb {
memory.write16(base, 0x7808).unwrap();
} else {
memory.write32(base, 0xe5d10000).unwrap();
}
cpu.step(&mut memory).unwrap(); // BX from setup ROM into original RAM reader.
(cpu, memory)
}
#[test]
fn arm_and_thumb_flash_commands_commit_once_at_all_save_wait_settings() {
for thumb in [false, true] {
for (wait, cycles) in [5, 4, 3, 9].into_iter().enumerate() {
let (cpu, mut memory) = prepared(
thumb,
&[if thumb { 0x7008 } else { 0xe5c10000 }],
&[(0, 0x90), (1, HI)],
);
unlocked(&mut memory);
memory.write16(WAITCNT, wait as u16).unwrap();
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.memory().read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(machine.last_timing().data_cycles, cycles);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
}
}
}
#[test]
fn work_ram_byte_loads_observe_selected_id_with_save_bus_costs() {
for thumb in [false, true] {
for base in [0x02001000, 0x03001000] {
for (wait, cycles) in [5, 4, 3, 9].into_iter().enumerate() {
let (cpu, mut memory) = ram_reader(thumb, base);
unlocked(&mut memory);
memory.write8(HI, 0x90).unwrap();
memory.write16(WAITCNT, wait as u16).unwrap();
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.cpu().registers()[0], 0xc2);
assert_eq!(machine.last_timing().data_cycles, cycles);
}
}
}
}
#[test]
fn rejected_programming_and_rom_execution_reads_roll_back_cpu_and_command_state() {
for thumb in [false, true] {
for read in [false, true] {
let (cpu, mut memory) = prepared(
thumb,
&[match (thumb, read) {
(false, false) => 0xe5c10000,
(true, false) => 0x7008,
(false, true) => 0xe5d10000,
(true, true) => 0x7808,
}],
&[(0, 0xa0), (1, if read { SAVE_START } else { HI })],
);
unlocked(&mut memory);
let before = memory.cartridge;
let mut machine = Machine::new(cpu, memory);
let cpu = machine.cpu().clone();
let error = machine.step().unwrap_err();
assert!(error.to_string().contains(if read {
"outside work RAM"
} else {
"programming"
}));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.memory().cartridge, before);
assert_eq!(machine.cycles(), 0);
machine.memory_mut().write8(HI, 0x90).unwrap(); // Failure retained the accepted unlock prefix.
assert_eq!(machine.memory().read8(SAVE_START).unwrap(), 0xc2);
}
}
}
#[test]
fn staged_id_reads_and_discard_do_not_mutate_array_or_committed_controller() {
let (_, mut memory) = prepared(false, &[0xe1a00000], &[]);
unlocked(&mut memory);
let before = memory.cartridge;
memory.begin_timer_step();
memory.write8(HI, 0x90).unwrap();
assert_eq!(memory.read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(memory.cartridge, before);
memory.discard_timer_step();
assert_eq!(memory.read8(SAVE_START).unwrap(), 255);
memory.write8(HI, 0x90).unwrap();
memory.begin_timer_step();
memory.write8(HI, 0xaa).unwrap();
memory.write8(LO, 0x55).unwrap();
memory.write8(HI, 0xf0).unwrap();
assert_eq!(memory.read8(SAVE_START).unwrap(), 255);
memory.discard_timer_step();
assert_eq!(memory.read8(SAVE_START).unwrap(), 0xc2);
assert!(memory.save_image().unwrap().iter().all(|&b| b == 255));
}
#[test]
fn instruction_fetch_and_block_word_access_cannot_bypass_byte_only_device() {
let (_, mut memory) = prepared(false, &[0xe1a00000], &[]);
unlocked(&mut memory);
for state in [InstructionSet::Arm, InstructionSet::Thumb] {
let error = memory
.fetch_instruction(SAVE_START, state)
.instruction
.unwrap_err();
assert!(error.to_string().contains("Flash instruction fetch"));
}
let before = memory.cartridge;
assert!(memory
.write_words(&[(0x02000000, 0x12345678), (SAVE_START, 0)])
.is_err());
assert_eq!(memory.read32(0x02000000).unwrap(), 0);
assert_eq!(memory.cartridge, before);
}

View file

@ -87,6 +87,8 @@ pub fn bus_cycles(waitcnt: u16, address: u32, width: AccessWidth, kind: AccessKi
0
}
}
// The supported save bus permits byte transfers only; N/S use the same SRAM waits.
0x0e | 0x0f => 1 + [4, 3, 2, 8][usize::from(waitcnt & 3)],
// BIOS, IWRAM, I/O, and OAM use a 32-bit bus. Other areas remain unmapped.
_ => 1,
}

View file

@ -10,6 +10,30 @@ fn queue(waitcnt: u16) -> Prefetch {
queue
}
#[test]
fn save_wait_changes_preserve_the_queue_until_a_flash_data_access_cancels_it() {
for (wait, raw) in [5, 4, 3, 9].into_iter().enumerate() {
let mut q = queue(ENABLE);
assert_eq!(
q.code(PC, AccessWidth::Halfword, AccessKind::NonSequential),
5
);
q.advance(2); // One cycle remains in the nominal three-cycle ROM halfword.
let stream = q.stream;
q.configure(ENABLE | wait as u16);
assert_eq!(q.stream, stream);
assert_eq!(
q.data(0x0e005555, AccessWidth::Byte, AccessKind::NonSequential),
raw + 1
);
assert_eq!(q.stream, None);
assert_eq!(
q.code(PC + 2, AccessWidth::Halfword, AccessKind::NonSequential),
5
);
}
}
#[test]
fn ready_and_in_progress_fetches_follow_all_rom_wait_settings_and_widths() {
for window in 0..3 {

View file

@ -0,0 +1,276 @@
//! Original Flash command sequences and supplied save bytes; no game assets or save files.
use gba_core::{
cartridge::{CartridgeHardware, SaveDevice, SaveError, SAVE_END, SAVE_START},
cpu::Cpu,
dma::DMA_BASE,
io::{HALTCNT, IE, IF, IME},
machine::Machine,
memory::{Memory, MemoryError, ROM_START},
};
const HI: u32 = SAVE_START + 0x5555;
const LO: u32 = SAVE_START + 0x2aaa;
fn memory(device: SaveDevice) -> Memory {
let mut bus = Memory::new(0xeafffffeu32.to_le_bytes().to_vec()).unwrap();
bus.set_save_device(device);
bus
}
fn command(bus: &mut Memory, byte: u8) -> Result<(), MemoryError> {
bus.write8(HI, 0xaa)?;
bus.write8(LO, 0x55)?;
bus.write8(HI, byte)
}
fn bank(bus: &mut Memory, number: u8) {
command(bus, 0xb0).unwrap();
bus.write8(SAVE_START, number).unwrap();
}
fn image(device: SaveDevice) -> Vec<u8> {
(0..device.capacity())
.map(|i| ((i >> 16) * 73 + i * 19 + (i >> 8)) as u8)
.collect()
}
#[test]
fn selection_erased_state_and_exact_image_loading_are_explicit() {
let mut bus = memory(SaveDevice::None);
assert_eq!(bus.save_image(), None);
assert_eq!(
bus.read8(SAVE_START),
Err(MemoryError::Unmapped(SAVE_START))
);
assert_eq!(bus.write8(HI, 0xaa), Err(MemoryError::Unmapped(HI)));
assert_eq!(bus.load_save_image(&[]), Err(SaveError::NoDevice));
for device in [SaveDevice::Flash64, SaveDevice::Flash128] {
bus.set_save_device(device);
assert_eq!(bus.save_image().unwrap(), vec![255; device.capacity()]);
assert_eq!(bus.read8(SAVE_START).unwrap(), 255);
assert_eq!(bus.read8(SAVE_END).unwrap(), 255);
let bytes = image(device);
bus.load_save_image(&bytes).unwrap();
command(&mut bus, 0x90).unwrap();
for len in [0, device.capacity() - 1, device.capacity() + 1] {
assert_eq!(
bus.load_save_image(&vec![0; len]),
Err(SaveError::InvalidSize {
expected: device.capacity(),
actual: len
})
);
assert_eq!(bus.save_image().unwrap(), bytes);
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2); // Invalid load did not reset ID state.
}
bus.load_save_image(&bytes).unwrap(); // Valid host load resets command/bank state.
assert_eq!(bus.read8(SAVE_START).unwrap(), bytes[0]);
assert_eq!(bus.read32(ROM_START).unwrap(), 0xeafffffe);
}
}
#[test]
fn manufacturer_and_device_id_are_separate_bytes_and_exit_preserves_data() {
for (device, id) in [(SaveDevice::Flash64, 0x1c), (SaveDevice::Flash128, 9)] {
let mut bus = memory(device);
let data = image(device);
bus.load_save_image(&data).unwrap();
command(&mut bus, 0x90).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(bus.read8(SAVE_START + 1).unwrap(), id);
assert!(bus
.read8(SAVE_START + 2)
.unwrap_err()
.to_string()
.contains("ID read outside"));
command(&mut bus, 0xf0).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), data[0]);
assert_eq!(bus.read8(SAVE_START + 1).unwrap(), data[1]);
assert_eq!(bus.save_image().unwrap(), data);
assert_eq!(bus.read16(IF).unwrap(), 0);
}
}
#[test]
fn bank_selection_covers_every_byte_and_survives_id_mode() {
let mut bus = memory(SaveDevice::Flash128);
let data = image(SaveDevice::Flash128);
bus.load_save_image(&data).unwrap();
for selected in [1, 0, 1] {
bank(&mut bus, selected);
for offset in 0..=0xffff {
assert_eq!(
bus.read8(SAVE_START + offset).unwrap(),
data[selected as usize * 0x10000 + offset as usize]
);
}
command(&mut bus, 0x90).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(bus.read8(SAVE_START + 1).unwrap(), 9);
command(&mut bus, 0xf0).unwrap();
assert_eq!(
bus.read8(SAVE_START).unwrap(),
data[selected as usize * 0x10000]
);
}
let mut small = memory(SaveDevice::Flash64);
assert!(command(&mut small, 0xb0)
.unwrap_err()
.to_string()
.contains("64 KiB"));
small.write8(HI, 0xf0).unwrap();
assert_eq!(small.save_image().unwrap().len(), 0x10000);
}
#[test]
fn unsupported_commands_and_bad_bank_values_are_retryable_without_data_changes() {
for device in [SaveDevice::Flash64, SaveDevice::Flash128] {
for (byte, message) in [
(0xa0, "programming"),
(0x80, "erase setup"),
(0x10, "Flash command"),
] {
let mut bus = memory(device);
let data = image(device);
bus.load_save_image(&data).unwrap();
let error = command(&mut bus, byte).unwrap_err();
assert!(error.to_string().contains(message));
assert_eq!(bus.write8(HI, byte), Err(error));
assert_eq!(bus.save_image().unwrap(), data);
bus.write8(HI, 0xf0).unwrap(); // Finish the already accepted unlock sequence.
assert_eq!(bus.read8(SAVE_START).unwrap(), data[0]);
assert_eq!(bus.read16(IF).unwrap(), 0);
}
}
let mut bus = memory(SaveDevice::Flash128);
let data = image(SaveDevice::Flash128);
bus.load_save_image(&data).unwrap();
command(&mut bus, 0xb0).unwrap();
for (address, value) in [(SAVE_START, 2), (SAVE_START, 255), (SAVE_START + 1, 1)] {
let error = bus.write8(address, value).unwrap_err();
assert_eq!(bus.write8(address, value), Err(error));
assert_eq!(bus.read8(SAVE_START).unwrap(), data[0]);
}
bus.write8(SAVE_START, 1).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), data[0x10000]);
}
#[test]
fn widths_aliases_and_invalid_unlocks_remain_diagnostic() {
let mut bus = memory(SaveDevice::Flash128);
for address in [SAVE_START - 1, SAVE_END + 1, 0x0f000000, 0x0eff5555] {
assert_eq!(bus.read8(address), Err(MemoryError::Unmapped(address)));
}
assert!(bus
.read16(SAVE_START)
.unwrap_err()
.to_string()
.contains("non-byte read"));
assert!(bus
.read32(SAVE_START)
.unwrap_err()
.to_string()
.contains("non-byte read"));
assert!(bus
.write16(HI & !1, 0xaa)
.unwrap_err()
.to_string()
.contains("non-byte write"));
assert!(bus
.write32(SAVE_START, 0)
.unwrap_err()
.to_string()
.contains("non-byte write"));
for (address, value) in [(HI, 0x55), (LO, 0xaa)] {
let error = bus.write8(address, value).unwrap_err();
assert_eq!(bus.write8(address, value), Err(error));
}
bus.write8(HI, 0xaa).unwrap();
assert!(bus.write8(LO + 1, 0x55).is_err());
bus.write8(LO, 0x55).unwrap();
assert!(bus.write8(HI + 1, 0x90).is_err());
bus.write8(HI, 0x90).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
}
#[test]
fn redundant_128_reset_preserves_array_bank_but_does_not_exit_id_or_partial_unlock() {
let mut bus = memory(SaveDevice::Flash128);
let bytes = image(SaveDevice::Flash128);
bus.load_save_image(&bytes).unwrap();
bank(&mut bus, 1);
bus.write8(HI, 0xf0).unwrap(); // Already idle in array mode.
assert_eq!(bus.read8(SAVE_START).unwrap(), bytes[0x10000]);
command(&mut bus, 0x90).unwrap();
assert!(bus.write8(HI, 0xf0).is_err()); // Direct ID exit for this device is not established.
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
command(&mut bus, 0xf0).unwrap();
bus.write8(HI, 0xf0).unwrap(); // Redundant reset after documented unlocked ID exit.
assert_eq!(bus.read8(SAVE_START).unwrap(), bytes[0x10000]);
bus.write8(HI, 0xaa).unwrap();
assert!(bus.write8(HI, 0xf0).is_err());
bus.write8(LO, 0x55).unwrap();
bus.write8(HI, 0x90).unwrap();
assert_eq!(bus.save_image().unwrap(), bytes);
}
#[test]
fn macronix_64_direct_reset_and_gpio_selection_are_independent() {
let mut bus = memory(SaveDevice::Flash64);
bus.set_cartridge_hardware(CartridgeHardware::Rtc);
bus.advance_rtc_seconds(42).unwrap();
let rtc = bus.rtc_datetime();
command(&mut bus, 0x90).unwrap();
bus.write8(HI, 0xf0).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), 255);
bus.write8(HI, 0xaa).unwrap();
bus.write8(HI, 0xf0).unwrap(); // Abort partial unlock for this documented device.
command(&mut bus, 0x90).unwrap();
bus.set_cartridge_hardware(CartridgeHardware::None);
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
bus.set_cartridge_hardware(CartridgeHardware::Rtc);
bus.set_rtc_datetime(rtc.unwrap()).unwrap();
bus.set_save_device(SaveDevice::Flash128);
assert_eq!(bus.rtc_datetime(), rtc);
bus.set_save_device(SaveDevice::None);
assert_eq!(bus.save_image(), None);
assert_eq!(bus.rtc_datetime(), rtc);
}
#[test]
fn halt_stop_and_dma_do_not_create_a_flash_completion_or_change_data() {
for stop in [false, true] {
let mut bus = memory(SaveDevice::Flash128);
command(&mut bus, 0x90).unwrap();
bus.write16(IE, 0x2000).unwrap();
bus.write16(IME, 1).unwrap();
bus.write8(HALTCNT, if stop { 128 } else { 0 }).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
for _ in 0..20 {
machine.step().unwrap();
}
assert_eq!(machine.memory().read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
assert_eq!(machine.halted(), !stop);
assert_eq!(machine.stopped(), stop);
}
for source in [false, true] {
let mut bus = memory(SaveDevice::Flash128);
let dma = DMA_BASE + 36;
bus.write32(0x02000000, 0xaa).unwrap();
bus.write32(dma, if source { SAVE_START } else { 0x02000000 })
.unwrap();
bus.write32(dma + 4, if source { 0x02000010 } else { SAVE_START })
.unwrap();
bus.write32(dma + 8, 0xc0000001).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
let error = machine.step().unwrap_err();
assert!(error.to_string().contains("Flash non-byte"));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cycles(), 0);
assert_eq!(machine.memory().read32(0x02000010).unwrap(), 0);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
assert!(machine
.memory()
.save_image()
.unwrap()
.iter()
.all(|&x| x == 255));
}
}

View file

@ -27,6 +27,20 @@ fn waitcnt_masks_unused_bits_and_supports_all_bus_widths() {
assert_eq!(memory.waitcnt(), 0x4317);
}
#[test]
fn save_byte_access_costs_follow_waitcnt_and_ignore_sequential_kind() {
for (wait, cycles) in [5, 4, 3, 9].into_iter().enumerate() {
for kind in [AccessKind::NonSequential, AccessKind::Sequential] {
for address in [0x0e000000, 0x0e005555, 0x0e00ffff] {
assert_eq!(
bus_cycles(wait as u16, address, AccessWidth::Byte, kind),
cycles
);
}
}
}
}
#[test]
fn every_rom_wait_setting_has_the_documented_halfword_and_word_costs() {
for window in 0..3 {
@ -112,8 +126,8 @@ fn internal_regions_and_ewram_use_fixed_width_specific_costs() {
}
#[test]
fn stored_prefetch_phi_and_sram_settings_do_not_change_supported_bus_costs() {
// This stateless helper excludes opcode prefetch. PHI output and SRAM remain unmapped.
fn stored_prefetch_phi_and_save_wait_settings_do_not_change_rom_bus_costs() {
// This stateless helper excludes opcode prefetch. Save waits do not affect ROM cycles.
for width in [AccessWidth::Halfword, AccessWidth::Word] {
for address in [ROM_START, ROM_START + 4, 0x0a00_0004, 0x0c00_0004] {
for kind in [AccessKind::NonSequential, AccessKind::Sequential] {