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 {