feat(cartridge): implement transactional Flash programming and erase

This commit is contained in:
0xrsydn 2026-10-07 08:25:12 +07:00
commit 8ded9bc90e
7 changed files with 452 additions and 20 deletions

View file

@ -7,7 +7,10 @@ mod flash;
mod rtc;
pub use calendar::{RtcDateTime, RtcError};
use flash::Flash;
pub use flash::{SaveDevice, SaveError, SAVE_END, SAVE_START};
pub(crate) use flash::SaveMutation;
pub use flash::{
SaveDevice, SaveError, FLASH_ERASE_CYCLES, FLASH_PROGRAM_CYCLES, SAVE_END, SAVE_START,
};
use rtc::Rtc;
pub const GPIO_DATA: u32 = 0x0800_00c4;
@ -46,6 +49,20 @@ impl Cartridge {
self.flash.is_some() && (SAVE_START..=SAVE_END).contains(&address)
}
pub(crate) fn advance(&mut self, cycles: u32) {
if let Some(flash) = self.flash.as_mut() {
flash.advance(cycles);
}
}
pub(crate) fn save_unfinished(&self) -> bool {
self.flash.is_some_and(Flash::unfinished)
}
pub(crate) fn take_save_mutation(&mut self) -> Option<SaveMutation> {
self.flash.as_mut().and_then(Flash::take_mutation)
}
pub(crate) fn read_save8(&self, address: u32, image: &[u8]) -> Result<u8, MemoryError> {
self.flash.expect("mapped save device").read(address, image)
}
@ -94,7 +111,12 @@ impl Cartridge {
}
/// Apply one access to a copy first. A rejected second halfword changes nothing.
pub(crate) fn write(&mut self, address: u32, bytes: &[u8]) -> Result<(), MemoryError> {
pub(crate) fn write(
&mut self,
address: u32,
bytes: &[u8],
image: &[u8],
) -> Result<(), MemoryError> {
if !self.mapped(address) {
return Ok(()); // Non-cartridge accesses are validated by the bus.
}
@ -105,7 +127,7 @@ impl Cartridge {
operation: "Flash non-byte write",
});
}
return self.flash.as_mut().unwrap().write(address, bytes[0]);
return self.flash.as_mut().unwrap().write(address, bytes[0], image);
}
let fail = |at, value, operation| MemoryError::UnsupportedIo {
address: at,

View file

@ -1,18 +1,20 @@
//! Bounded Macronix Flash identification, array reads, and 128 KiB banking.
//! Program/erase commands remain diagnostic: this module cannot claim a completed save.
//! Bounded Macronix Flash commands with nominal delayed program/erase completion.
use crate::memory::MemoryError;
use std::{error::Error, fmt};
pub const SAVE_START: u32 = 0x0e00_0000;
pub const SAVE_END: u32 = 0x0e00_ffff;
/// Nominal simulation delays, not measured Macronix operation times.
pub const FLASH_PROGRAM_CYCLES: u32 = 650;
pub const FLASH_ERASE_CYCLES: u32 = 30000;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SaveDevice {
#[default]
None,
/// Macronix ID 1CC2h. Array reads and identification only.
/// Macronix ID 1CC2h, 64 KiB array.
Flash64,
/// Macronix ID 09C2h. Adds two 64 KiB banks; no programming or erase yet.
/// Macronix ID 09C2h, two 64 KiB banks.
Flash128,
}
@ -50,6 +52,21 @@ pub(super) struct Flash {
phase: Phase,
identify: bool,
bank: u8,
pending: Option<Pending>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SaveMutation {
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) value: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Pending {
mutation: SaveMutation,
remaining: u32,
poll: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -58,6 +75,10 @@ enum Phase {
Unlock,
Command,
Bank,
Program,
EraseUnlock,
EraseCommand,
EraseConfirm,
}
impl Flash {
@ -67,6 +88,7 @@ impl Flash {
phase: Phase::Ready,
identify: false,
bank: 0,
pending: None,
})
}
@ -76,6 +98,22 @@ impl Flash {
pub(super) fn read(self, address: u32, image: &[u8]) -> Result<u8, MemoryError> {
let offset = (address - SAVE_START) as usize;
let index = usize::from(self.bank) * 0x10000 + offset;
if let Some(pending) = self.pending {
if pending.remaining != 0 {
if address != pending.poll {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash busy read outside polling address",
});
}
// Bounded DQ7-only data polling. Toggle/error bits are not modeled.
return Ok((pending.mutation.value ^ 0x80) & 0x80);
}
if (pending.mutation.start..pending.mutation.end).contains(&index) {
return Ok(pending.mutation.value);
}
}
if self.identify {
return match offset {
0 => Ok(0xc2),
@ -90,10 +128,33 @@ impl Flash {
}),
};
}
Ok(image[usize::from(self.bank) * 0x10000 + offset])
Ok(image[index])
}
pub(super) fn write(&mut self, address: u32, value: u8) -> Result<(), MemoryError> {
pub(super) fn advance(&mut self, cycles: u32) {
if let Some(pending) = self.pending.as_mut() {
pending.remaining = pending.remaining.saturating_sub(cycles);
}
}
pub(super) fn unfinished(self) -> bool {
self.pending.is_some() || self.phase != Phase::Ready
}
pub(super) fn take_mutation(&mut self) -> Option<SaveMutation> {
if self.pending.is_some_and(|p| p.remaining == 0) {
self.pending.take().map(|p| p.mutation)
} else {
None
}
}
pub(super) fn write(
&mut self,
address: u32,
value: u8,
image: &[u8],
) -> Result<(), MemoryError> {
let offset = address - SAVE_START;
let error = |operation| MemoryError::UnsupportedIo {
address,
@ -101,9 +162,19 @@ impl Flash {
operation,
};
let mut next = *self;
if self.pending.is_some_and(|p| p.remaining != 0) {
if self.device == SaveDevice::Flash64 && offset == 0x5555 && value == 0xf0 {
next.pending = None;
next.phase = Phase::Ready;
*self = next;
return Ok(());
}
return Err(error("Flash write during busy operation"));
}
// 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
if self.phase != Phase::Program
&& offset == 0x5555
&& value == 0xf0
&& (self.device == SaveDevice::Flash64
|| (self.phase == Phase::Ready && !self.identify))
@ -129,8 +200,8 @@ impl Flash {
}
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")),
0xa0 => Phase::Program,
0x80 => Phase::EraseUnlock,
_ => return Err(error("Flash command")),
}
}
@ -139,6 +210,52 @@ impl Flash {
Phase::Ready
}
Phase::Bank => return Err(error("Flash bank address/value")),
Phase::Program => {
if self.pending.is_some() {
return Err(error("Flash completion awaiting commit"));
}
let index = usize::from(self.bank) * 0x10000 + offset as usize;
if value & !image[index] != 0 {
return Err(error(
"Flash programming requires erase for zero-to-one bits",
));
}
next.pending = Some(Pending {
mutation: SaveMutation {
start: index,
end: index + 1,
value,
},
remaining: FLASH_PROGRAM_CYCLES,
poll: address,
});
Phase::Ready
}
Phase::EraseUnlock if offset == 0x5555 && value == 0xaa => Phase::EraseCommand,
Phase::EraseCommand if offset == 0x2aaa && value == 0x55 => Phase::EraseConfirm,
Phase::EraseConfirm => {
if self.pending.is_some() {
return Err(error("Flash completion awaiting commit"));
}
let (start, end, poll) = match value {
0x10 if offset == 0x5555 => (0, self.device.capacity(), SAVE_START),
0x30 if offset & 0xfff == 0 => {
let start = usize::from(self.bank) * 0x10000 + offset as usize;
(start, start + 0x1000, address)
}
_ => return Err(error("Flash erase confirmation/address")),
};
next.pending = Some(Pending {
mutation: SaveMutation {
start,
end,
value: 0xff,
},
remaining: FLASH_ERASE_CYCLES,
poll,
});
Phase::Ready
}
_ => return Err(error("Flash unlock/reset sequence")),
};
}

View file

@ -12,6 +12,8 @@ mod fetch_tests;
#[cfg(test)]
mod flash_step_tests;
#[cfg(test)]
mod flash_write_tests;
#[cfg(test)]
mod irq_fetch_tests;
mod iwram_bus;
#[cfg(test)]
@ -138,6 +140,8 @@ pub struct Memory {
oam: Vec<u8>,
rom: Vec<u8>,
save_image: Vec<u8>,
save_modified: bool,
cartridge_elapsed: Cell<u32>,
cartridge: Cartridge,
cartridge_step: Cell<Option<Cartridge>>,
bios: Option<Vec<u8>>,
@ -170,6 +174,8 @@ impl Memory {
oam: vec![0; 1024],
rom,
save_image: Vec::new(),
save_modified: false,
cartridge_elapsed: Cell::new(0),
cartridge: Cartridge::default(),
cartridge_step: Cell::new(None),
bios: None,
@ -218,6 +224,7 @@ impl Memory {
"active cartridge transaction"
);
self.save_image = vec![0xff; device.capacity()];
self.save_modified = false;
self.cartridge.set_save_device(device);
}
@ -244,6 +251,7 @@ impl Memory {
});
}
self.save_image.copy_from_slice(bytes);
self.save_modified = false;
self.cartridge.set_save_device(device);
Ok(())
}
@ -272,6 +280,24 @@ impl Memory {
self.cartridge.advance_rtc_seconds(seconds)
}
/// True after a completed Flash operation changed stored bytes since setup/load.
pub fn save_modified(&self) -> bool {
self.save_modified
}
/// A command or operation is incomplete. Hosts should not persist on exit in this state.
pub fn save_write_pending(&self) -> bool {
self.cartridge.save_unfinished()
}
fn commit_save_mutation(&mut self) {
if let Some(effect) = self.cartridge.take_save_mutation() {
let target = &mut self.save_image[effect.start..effect.end];
self.save_modified |= target.iter().any(|&b| b != effect.value);
target.fill(effect.value);
}
}
fn cartridge_state(&self) -> Cartridge {
self.cartridge_step.get().unwrap_or(self.cartridge)
}
@ -345,14 +371,21 @@ impl Memory {
debug_assert!(self.timer_step.get().is_none());
self.timer_step.set(Some(self.io.timer_step()));
self.cartridge_step.set(Some(self.cartridge));
self.cartridge_elapsed.set(0);
}
pub(crate) fn discard_timer_step(&self) {
self.timer_step.set(None);
self.cartridge_step.set(None);
self.cartridge_elapsed.set(0);
}
fn advance_timer_step(&self, cycles: u32) {
if let Some(mut cartridge) = self.cartridge_step.get() {
cartridge.advance(cycles - self.cartridge_elapsed.get());
self.cartridge_elapsed.set(cycles);
self.cartridge_step.set(Some(cartridge));
}
if let Some(mut step) = self.timer_step.get() {
step.advance_to(cycles);
self.timer_step.set(Some(step));
@ -370,11 +403,15 @@ impl Memory {
.cartridge_step
.take()
.expect("active cartridge transaction");
self.commit_save_mutation();
self.cartridge_elapsed.set(0);
// Staged devices already reached this boundary. Advance remaining devices exactly once.
self.advance_running_cycles_with_timers(cycles, false);
}
fn advance_running_cycles(&mut self, cycles: u32) {
self.cartridge.advance(cycles);
self.commit_save_mutation();
self.advance_running_cycles_with_timers(cycles, true);
}
@ -930,8 +967,6 @@ impl Memory {
) -> Result<(), MemoryError> {
let index = self.write_index::<N>(address, access)?;
Self::validate_io_values(&mut self.audio_state(), address, &bytes)?;
let mut cartridge = self.cartridge_state();
cartridge.write(address, &bytes)?;
self.record_access(
address,
match N {
@ -941,6 +976,8 @@ impl Memory {
_ => unreachable!("unsupported bus width"),
},
);
let mut cartridge = self.cartridge_state();
cartridge.write(address, &bytes, &self.save_image)?;
if cartridge.mapped(address) {
if self.cartridge_step.get().is_some() {
self.cartridge_step.set(Some(cartridge));
@ -1150,6 +1187,8 @@ impl Memory {
let mut cartridge = self.cartridge_state();
let timing = self.cpu_timing.get();
let devices = self.timer_step.get();
let cartridge_step = self.cartridge_step.get();
let cartridge_elapsed = self.cartridge_elapsed.get();
// Preflight ordered data phases on temporary device/timing copies.
// A serial request can finish before a later control write reaches the bus.
let validation = (|| {
@ -1157,7 +1196,7 @@ impl Memory {
self.write_index::<4>(address, self.write_access())?;
let bytes = value.to_le_bytes();
Self::validate_io_values(&mut audio, address, &bytes)?;
cartridge.write(address, &bytes)?;
cartridge.write(address, &bytes, &self.save_image)?;
self.record_access(address, AccessWidth::Word);
if Serial::mapped(address) {
if let Some(mut step) = self.timer_step.get() {
@ -1174,6 +1213,8 @@ impl Memory {
})();
self.cpu_timing.set(timing);
self.timer_step.set(devices);
self.cartridge_step.set(cartridge_step);
self.cartridge_elapsed.set(cartridge_elapsed);
validation?;
for &(address, value) in writes {
// The map cannot change between validation and these writes.

View file

@ -73,7 +73,7 @@ fn work_ram_byte_loads_observe_selected_id_with_save_bus_costs() {
}
#[test]
fn rejected_programming_and_rom_execution_reads_roll_back_cpu_and_command_state() {
fn rejected_commands_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(
@ -84,7 +84,7 @@ fn rejected_programming_and_rom_execution_reads_roll_back_cpu_and_command_state(
(false, true) => 0xe5d10000,
(true, true) => 0x7808,
}],
&[(0, 0xa0), (1, if read { SAVE_START } else { HI })],
&[(0, 0x42), (1, if read { SAVE_START } else { HI })],
);
unlocked(&mut memory);
let before = memory.cartridge;
@ -94,7 +94,7 @@ fn rejected_programming_and_rom_execution_reads_roll_back_cpu_and_command_state(
assert!(error.to_string().contains(if read {
"outside work RAM"
} else {
"programming"
"Flash command"
}));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cpu(), &cpu);

View file

@ -0,0 +1,95 @@
//! Original completion-at-bus-phase and mutation rollback checks.
use super::{timing_event_tests::prepared, *};
use crate::{
cartridge::{SaveDevice, FLASH_PROGRAM_CYCLES as PROGRAM, SAVE_START as BASE},
io::SOUNDBIAS,
machine::Machine,
};
fn setup(m: &mut Memory) {
m.set_save_device(SaveDevice::Flash128);
m.write8(BASE + 0x5555, 0xaa).unwrap();
m.write8(BASE + 0x2aaa, 0x55).unwrap();
m.write8(BASE + 0x5555, 0xa0).unwrap();
}
#[test]
fn arm_and_thumb_program_start_excludes_preceding_bus_cycles() {
for thumb in [false, true] {
let (cpu, mut m) = prepared(
thumb,
&[if thumb { 0x7008 } else { 0xe5c10000 }],
&[(0, 0x12), (1, BASE)],
);
setup(&mut m);
let mut machine = Machine::new(cpu, m);
machine.step().unwrap();
assert_eq!(machine.memory().save_image().unwrap()[0], 255);
machine.memory_mut().advance_cycles(PROGRAM - 1);
assert_eq!(machine.memory().save_image().unwrap()[0], 255);
machine.memory_mut().advance_cycles(1);
assert_eq!(machine.memory().save_image().unwrap()[0], 0x12);
assert!(machine.memory().save_modified());
}
}
#[test]
fn failed_block_load_restores_completion_and_dirty_state() {
let (cpu, mut m) = prepared(false, &[0xe8910005], &[(1, SOUNDBIAS)]);
setup(&mut m);
m.write8(BASE, 0x12).unwrap();
m.advance_cycles(PROGRAM - 7);
let controller = m.cartridge;
let mut machine = Machine::new(cpu, m);
for _ in 0..2 {
assert!(machine.step().is_err());
assert_eq!(machine.memory().cartridge, controller);
assert_eq!(machine.memory().save_image().unwrap()[0], 255);
assert!(!machine.memory().save_modified());
assert_eq!(machine.cycles(), u64::from(PROGRAM - 7));
}
machine.memory_mut().advance_cycles(7);
assert_eq!(machine.memory().save_image().unwrap()[0], 0x12);
}
#[test]
fn read_at_completion_uses_staged_overlay_and_read_before_completion_sees_busy() {
for elapsed in [PROGRAM - 7, PROGRAM - 6] {
let (mut cpu, mut m) = prepared(false, &[0xe12fff12], &[(1, BASE), (2, 0x03001000)]);
for i in 0..8 {
m.write32(
0x03001000 + i * 4,
if i == 0 { 0xe5d10000 } else { 0xe1a00000 },
)
.unwrap();
}
cpu.step(&mut m).unwrap();
setup(&mut m);
m.write8(BASE, 0).unwrap();
m.advance_cycles(elapsed);
let mut machine = Machine::new(cpu, m);
machine.step().unwrap();
assert_eq!(
machine.cpu().registers()[0],
if elapsed == PROGRAM - 6 { 0 } else { 0x80 }
);
assert_eq!(machine.memory().save_image().unwrap()[0], 0);
}
}
#[test]
fn preflight_and_discard_cannot_apply_save_mutations() {
let (_, mut m) = prepared(false, &[0xe1a00000], &[]);
setup(&mut m);
m.write8(BASE, 0x12).unwrap();
m.advance_cycles(PROGRAM - 1);
let before = m.cartridge;
m.begin_timer_step();
m.advance_timer_step(1);
assert_eq!(m.read8(BASE).unwrap(), 0x12);
assert_eq!(m.save_image().unwrap()[0], 255);
let shadow = m.cartridge_step.get();
let elapsed = m.cartridge_elapsed.get();
assert!(m.write_words(&[(0x02000000, 42), (BASE, 0)]).is_err());
assert_eq!(m.cartridge_step.get(), shadow);
assert_eq!(m.cartridge_elapsed.get(), elapsed);
assert_eq!(m.read32(0x02000000).unwrap(), 0);
m.discard_timer_step();
assert_eq!(m.cartridge, before);
assert!(!m.save_modified());
}