feat(cartridge): add 32 KiB SRAM save memory

Select it with --save-type sram (SaveDevice::Sram). The array is byte-wide,
starts erased, and repeats every 32 KiB through the 0x0e00xxxx save window.
A CPU byte store is staged and committed when its step completes, so failed
steps roll back and reads later in the step see the byte. Unlike Flash, SRAM
can be read from ROM code. Halfword/word access and DMA stay diagnostic until
the 8-bit bus behavior for those widths is verified.

SRAM shares the save-image API, host save files, and save states. Its device
code is new, so existing save states keep loading.

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 11:51:27 +07:00
commit 32eaffa0fe
15 changed files with 405 additions and 30 deletions

View file

@ -23,7 +23,7 @@ use gba_core::{
const MAX_STEPS: u64 = 100_000_000;
const MAX_FRAMES: u64 = 100_000;
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); speed: integer 1..=16 (default 1, audio muted above 1); steps: 1..=100000000, frames: 1..=100000; state file (window only): F5 saves and F9 loads a save state, default is the ROM path with a .state extension; do not combine with demo or help options";
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128|sram] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); speed: integer 1..=16 (default 1, audio muted above 1); steps: 1..=100000000, frames: 1..=100000; state file (window only): F5 saves and F9 loads a save state, default is the ROM path with a .state extension; do not combine with demo or help options";
#[derive(Debug, PartialEq, Eq)]
enum Mode {
@ -69,6 +69,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
save_device = match args.next().and_then(|value| value.to_str()) {
Some("flash64") => SaveDevice::Flash64,
Some("flash128") => SaveDevice::Flash128,
Some("sram") => SaveDevice::Sram,
_ => return Err(invalid()),
};
} else if arg == "--save-file" && save_file.is_none() {

View file

@ -18,7 +18,7 @@ pub struct Playback {
pub speed: u32,
}
/// Host files for one ROM window: the optional Flash save and the save-state slot.
/// Host files for one ROM window: the optional cartridge save and the save-state slot.
pub struct Persistence<'a> {
pub save_file: Option<&'a mut crate::cartridge::save_file::SaveFile>,
pub state_path: PathBuf,

View file

@ -91,7 +91,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); or gameboy-rust --test-suite PATH.json",
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128|sram] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); or gameboy-rust --test-suite PATH.json",
)),
}
}
@ -140,8 +140,8 @@ fn run() -> Result<(), Box<dyn Error>> {
--speed MULTIPLIER ROM-window speed: integer 1..=16 (default: 1); mutes audio above 1x\n\
--audio Enable macOS output at 1x (default: muted)\n\
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
--save-type flash64|flash128 Select nominal Macronix Flash emulation\n\
--save-file PATH Load raw Flash image; persist on clean exit or window diagnostic\n\
--save-type flash64|flash128|sram Select nominal Macronix Flash or 32 KiB SRAM\n\
--save-file PATH Load raw save image; persist on clean exit or window diagnostic\n\
--state-file PATH ROM window save state; F5 saves, F9 loads (default: ROM path + .state)\n\
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
--cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\

View file

@ -383,7 +383,7 @@ fn explicit_flash_selection_runs_ram_byte_reader_and_leaves_files_unchanged() {
fn invalid_save_type_options_fail_before_file_access() {
for extra in [
vec!["--save-type"],
vec!["--save-type", "sram"],
vec!["--save-type", "eeprom"],
vec!["--save-type", ""],
vec!["--save-type", "flash128", "--save-type", "flash64"],
] {

View file

@ -224,3 +224,33 @@ fn native_rom_window_save_reloads_in_another_process() {
.to_string_lossy()
.contains(".bak.")));
}
#[test]
fn sram_bytes_written_and_read_from_rom_code_persist_across_processes() {
let f = Fixture::new();
let mut code = Vec::new();
load(&mut code, 0, 0x0e00_0000);
code.extend([0xe3a0_105a, 0xe5c0_1000]); // MOV r1,#0x5a; STRB r1,[r0]
load(&mut code, 0, 0x0e00_8001); // Mirror of offset 1.
code.extend([0xe3a0_103c, 0xe5c0_1000, 0xeaff_fffe]); // MOV r1,#0x3c; STRB; loop
let write = f.rom("sram-writer.gba", &code);
let save = f.save();
let output = run(&write, &save, "sram");
assert!(output.status.success(), "{}", text(&output));
assert!(text(&output).contains("Save persisted on clean exit"));
let image = fs::read(&save).unwrap();
assert_eq!(image.len(), 32768);
assert_eq!(&image[..2], &[0x5a, 0x3c]);
assert!(image[2..].iter().all(|&v| v == 255));
// Unlike Flash, SRAM reads need no work-RAM code.
let mut code = Vec::new();
load(&mut code, 3, 0x0e00_0000);
code.extend([0xe5d3_5000, 0xe5d3_6001, 0xeaff_fffe]); // LDRB r5,[r3]; LDRB r6,[r3,#1]
let read = f.rom("sram-reader.gba", &code);
let output = run(&read, &save, "sram");
assert!(output.status.success(), "{}", text(&output));
assert!(text(&output).contains("r5=0x0000005a"), "{}", text(&output));
assert!(text(&output).contains("r6=0x0000003c"), "{}", text(&output));
assert!(text(&output).contains("Save unchanged"));
}

View file

@ -6,6 +6,7 @@ use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
mod calendar;
mod flash;
mod rtc;
mod sram;
pub use calendar::{RtcDateTime, RtcError};
use flash::Flash;
pub(crate) use flash::SaveMutation;
@ -13,6 +14,7 @@ pub use flash::{
SaveDevice, SaveError, FLASH_ERASE_CYCLES, FLASH_PROGRAM_CYCLES, SAVE_END, SAVE_START,
};
use rtc::Rtc;
use sram::Sram;
pub const GPIO_DATA: u32 = 0x0800_00c4;
pub const GPIO_DIRECTION: u32 = 0x0800_00c6;
@ -31,6 +33,7 @@ pub enum CartridgeHardware {
pub(crate) struct Cartridge {
gpio: Option<Gpio>,
flash: Option<Flash>,
sram: Option<Sram>,
}
impl Cartridge {
@ -40,14 +43,23 @@ impl Cartridge {
pub(crate) fn set_save_device(&mut self, device: SaveDevice) {
self.flash = Flash::new(device);
self.sram = (device == SaveDevice::Sram).then(Sram::default);
}
pub(crate) fn save_device(&self) -> SaveDevice {
if self.sram.is_some() {
return SaveDevice::Sram;
}
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)
(self.flash.is_some() || self.sram.is_some()) && (SAVE_START..=SAVE_END).contains(&address)
}
/// Flash reads are restricted to work-RAM code; SRAM has no such limit.
pub(crate) fn flash_mapped(&self, address: u32) -> bool {
self.flash.is_some() && self.save_mapped(address)
}
pub(crate) fn advance(&mut self, cycles: u32) {
@ -57,14 +69,20 @@ impl Cartridge {
}
pub(crate) fn save_unfinished(&self) -> bool {
self.flash.is_some_and(Flash::unfinished)
self.flash.is_some_and(Flash::unfinished) || self.sram.is_some_and(Sram::unfinished)
}
pub(crate) fn take_save_mutation(&mut self) -> Option<SaveMutation> {
if let Some(sram) = self.sram.as_mut() {
return sram.take_mutation();
}
self.flash.as_mut().and_then(Flash::take_mutation)
}
pub(crate) fn read_save8(&self, address: u32, image: &[u8]) -> Result<u8, MemoryError> {
if let Some(sram) = self.sram {
return Ok(sram.read(address, image));
}
self.flash.expect("mapped save device").read(address, image)
}
@ -125,9 +143,16 @@ impl Cartridge {
if bytes.len() != 1 {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash non-byte write",
operation: if self.sram.is_some() {
"SRAM non-byte write"
} else {
"Flash non-byte write"
},
});
}
if let Some(sram) = self.sram.as_mut() {
return sram.write(address, bytes[0]);
}
return self.flash.as_mut().unwrap().write(address, bytes[0], image);
}
let fail = |at, value, operation| MemoryError::UnsupportedIo {
@ -180,16 +205,14 @@ impl Gpio {
impl Cartridge {
/// Host configuration, not state: cartridge hardware then save device, one byte each.
pub(crate) fn setup_identity(&self) -> [u8; 2] {
let device = self
.flash
.as_ref()
.map_or(SaveDevice::None, |flash| flash.device());
let device = self.save_device();
[
u8::from(self.gpio.is_some()),
match device {
SaveDevice::None => 0,
SaveDevice::Flash64 => 1,
SaveDevice::Flash128 => 2,
SaveDevice::Sram => 3,
},
]
}
@ -197,7 +220,7 @@ impl Cartridge {
impl Snapshot for Cartridge {
fn save(&self, writer: &mut Writer) {
let Self { gpio, flash } = self;
let Self { gpio, flash, sram } = self;
writer.tag(b"CART");
writer.raw(&self.setup_identity());
if let Some(gpio) = gpio {
@ -206,10 +229,14 @@ impl Snapshot for Cartridge {
if let Some(flash) = flash {
flash.save(writer);
}
if let Some(sram) = sram {
sram.save(writer);
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { gpio, flash } = self;
let target_device = self.save_device();
let Self { gpio, flash, sram } = self;
reader.tag(b"CART")?;
let hardware = reader.u8()?;
@ -224,11 +251,9 @@ impl Snapshot for Cartridge {
0 => SaveDevice::None,
1 => SaveDevice::Flash64,
2 => SaveDevice::Flash128,
3 => SaveDevice::Sram,
_ => return Err(SnapshotError::Corrupt("save device")),
};
let target_device = flash
.as_ref()
.map_or(SaveDevice::None, |flash| flash.device());
if save_device != target_device {
return Err(SnapshotError::ConfigMismatch("save device"));
}
@ -239,6 +264,9 @@ impl Snapshot for Cartridge {
if let Some(flash) = flash {
flash.load(reader)?;
}
if let Some(sram) = sram {
sram.load(reader)?;
}
Ok(())
}
}

View file

@ -17,6 +17,8 @@ pub enum SaveDevice {
Flash64,
/// Macronix ID 09C2h, two 64 KiB banks.
Flash128,
/// 32 KiB battery-backed SRAM, byte addressed and mirrored through the save window.
Sram,
}
impl SaveDevice {
@ -25,6 +27,7 @@ impl SaveDevice {
Self::None => 0,
Self::Flash64 => 64 * 1024,
Self::Flash128 => 128 * 1024,
Self::Sram => 32 * 1024,
}
}
}
@ -84,7 +87,7 @@ enum Phase {
impl Flash {
pub(super) fn new(device: SaveDevice) -> Option<Self> {
(device != SaveDevice::None).then_some(Self {
matches!(device, SaveDevice::Flash64 | SaveDevice::Flash128).then_some(Self {
device,
phase: Phase::Ready,
identify: false,
@ -486,6 +489,7 @@ impl Snapshot for Flash {
SaveDevice::None => 0,
SaveDevice::Flash64 => 1,
SaveDevice::Flash128 => 2,
SaveDevice::Sram => unreachable!("SRAM has no Flash state"),
});
phase.save(writer);
writer.bool(*identify);

View file

@ -0,0 +1,128 @@
//! Battery-backed SRAM: a 32 KiB byte-wide array with no command protocol.
//! A CPU byte store is staged as a one-byte mutation and committed when its step completes.
//! Halfword and word accesses remain diagnostic until their 8-bit bus behavior is verified.
use super::{SaveDevice, SaveMutation, SAVE_START};
use crate::memory::MemoryError;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
/// The 32 KiB array repeats through the 64 KiB save window.
const MIRROR_MASK: u32 = 0x7fff;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Sram {
pending: Option<SaveMutation>,
}
impl Sram {
fn index(address: u32) -> usize {
((address - SAVE_START) & MIRROR_MASK) as usize
}
pub(super) fn read(self, address: u32, image: &[u8]) -> u8 {
let index = Self::index(address);
match self.pending {
Some(mutation) if (mutation.start..mutation.end).contains(&index) => mutation.value,
_ => image[index],
}
}
pub(super) fn write(&mut self, address: u32, value: u8) -> Result<(), MemoryError> {
if self.pending.is_some() {
// One CPU step performs at most one byte store; DMA cannot write bytes.
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "second SRAM write before commit",
});
}
let start = Self::index(address);
self.pending = Some(SaveMutation {
start,
end: start + 1,
value,
});
Ok(())
}
pub(super) fn unfinished(self) -> bool {
self.pending.is_some()
}
pub(super) fn take_mutation(&mut self) -> Option<SaveMutation> {
self.pending.take()
}
}
impl Snapshot for Sram {
fn save(&self, writer: &mut Writer) {
let Self { pending } = self;
writer.tag(b"SRAM");
writer.option(pending.as_ref(), |pending, writer| pending.save(writer));
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { pending } = self;
reader.tag(b"SRAM")?;
let decoded = reader.option(|reader| {
let mut mutation = SaveMutation {
start: 0,
end: 0,
value: 0,
};
mutation.load(reader)?;
Ok(mutation)
})?;
if decoded.is_some_and(|m| m.end != m.start + 1 || m.end > SaveDevice::Sram.capacity()) {
return Err(SnapshotError::Corrupt("SRAM mutation range"));
}
*pending = decoded;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_stage_one_mirrored_byte_and_reads_see_it_before_commit() {
let image = vec![0xff; SaveDevice::Sram.capacity()];
let mut sram = Sram::default();
sram.write(SAVE_START + 0x8001, 0x42).unwrap(); // Mirror of offset 1.
assert_eq!(sram.read(SAVE_START + 1, &image), 0x42);
assert_eq!(sram.read(SAVE_START + 0x8001, &image), 0x42);
assert_eq!(sram.read(SAVE_START, &image), 0xff);
assert!(sram.unfinished());
assert!(sram.write(SAVE_START, 0).is_err());
let mutation = sram.take_mutation().unwrap();
assert_eq!((mutation.start, mutation.end, mutation.value), (1, 2, 0x42));
assert!(!sram.unfinished());
}
#[test]
fn snapshot_round_trips_and_rejects_out_of_range_mutations() {
let mut sram = Sram::default();
sram.write(SAVE_START + 0x7fff, 7).unwrap();
let mut writer = Writer::new();
sram.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut restored = Sram::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, sram);
let mut writer = Writer::new();
Sram {
pending: Some(SaveMutation {
start: 0x8000,
end: 0x8001,
value: 0,
}),
}
.save(&mut writer);
let bytes = writer.finish().unwrap();
assert_eq!(
Sram::default().load(&mut Reader::new(&bytes)),
Err(SnapshotError::Corrupt("SRAM mutation range"))
);
}
}

View file

@ -864,7 +864,7 @@ 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)
if self.cartridge_state().flash_mapped(address)
&& self
.cpu_access
.is_some_and(|access| !matches!(access.pc >> 24, 0x02 | 0x03))
@ -984,6 +984,8 @@ impl Memory {
self.cartridge_step.set(Some(cartridge));
} else {
self.cartridge = cartridge;
// Outside a machine step, an SRAM store has no step boundary to commit at.
self.commit_save_mutation();
}
return Ok(());
}
@ -1143,7 +1145,11 @@ impl Memory {
if N != 1 {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash non-byte write",
operation: if self.cartridge_state().flash_mapped(address) {
"Flash non-byte write"
} else {
"SRAM non-byte write"
},
});
}
Ok(0)
@ -1246,10 +1252,15 @@ impl Memory {
}
fn validate_save_read_width(&self, address: u32) -> Result<(), MemoryError> {
if self.cartridge_state().save_mapped(address) {
let cartridge = self.cartridge_state();
if cartridge.save_mapped(address) {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash non-byte read",
operation: if cartridge.flash_mapped(address) {
"Flash non-byte read"
} else {
"SRAM non-byte read"
},
});
}
Ok(())

View file

@ -0,0 +1,159 @@
//! Original SRAM accesses and supplied save bytes; no game assets or save files.
use gba_core::{
cartridge::{SaveDevice, SaveError, SAVE_END, SAVE_START},
cpu::Cpu,
dma::DMA_BASE,
machine::{Machine, MachineError},
memory::{Memory, MemoryError, ROM_START},
snapshot::SnapshotError,
};
const SIZE: usize = 32 * 1024;
fn words(values: &[u32]) -> Vec<u8> {
values
.iter()
.flat_map(|value| value.to_le_bytes())
.collect()
}
fn memory(rom: &[u32]) -> Memory {
let mut bus = Memory::new(words(rom)).unwrap();
bus.set_save_device(SaveDevice::Sram);
bus
}
/// LDR r0,=address; then `instruction`; then loop.
fn program(address: u32, instruction: u32) -> Machine {
let mut bus = memory(&[0xe59f_0004, instruction, 0xeaff_fffe, address]);
let mut cpu = Cpu::new(ROM_START);
cpu.step(&mut bus).unwrap();
Machine::new(cpu, bus)
}
#[test]
fn selection_starts_erased_and_loads_only_exact_images() {
let mut bus = memory(&[0xeaff_fffe]);
assert_eq!(SaveDevice::Sram.capacity(), SIZE);
assert_eq!(bus.save_image().unwrap(), &[0xff; SIZE][..]);
assert!(!bus.save_modified());
assert_eq!(
bus.load_save_image(&[0; SIZE - 1]),
Err(SaveError::InvalidSize {
expected: SIZE,
actual: SIZE - 1
})
);
let image: Vec<u8> = (0..SIZE).map(|i| (i * 7) as u8).collect();
bus.load_save_image(&image).unwrap();
assert_eq!(bus.read8(SAVE_START + 3).unwrap(), 21);
assert!(!bus.save_modified());
}
#[test]
fn byte_writes_commit_immediately_and_mirror_every_32_kib() {
let mut bus = memory(&[0xeaff_fffe]);
bus.write8(SAVE_START + 0x8005, 0x42).unwrap();
assert_eq!(bus.read8(SAVE_START + 5).unwrap(), 0x42);
assert_eq!(bus.read8(SAVE_END).unwrap(), 0xff); // Mirror of offset 0x7fff.
assert_eq!(bus.save_image().unwrap()[5], 0x42);
assert!(bus.save_modified());
assert!(!bus.save_write_pending());
// Writing the stored value again does not mark an unchanged image as modified.
let mut bus = memory(&[0xeaff_fffe]);
bus.write8(SAVE_START, 0xff).unwrap();
assert!(!bus.save_modified());
assert_eq!(
bus.read8(SAVE_END + 1),
Err(MemoryError::Unmapped(SAVE_END + 1))
);
}
#[test]
fn halfword_and_word_accesses_remain_diagnostics() {
let mut bus = memory(&[0xeaff_fffe]);
for result in [
bus.read16(SAVE_START).map(|_| ()),
bus.read32(SAVE_START).map(|_| ()),
] {
assert_eq!(
result,
Err(MemoryError::UnsupportedCartridgeAccess {
address: SAVE_START,
operation: "SRAM non-byte read",
})
);
}
for result in [bus.write16(SAVE_START, 0), bus.write32(SAVE_START, 0)] {
assert_eq!(
result,
Err(MemoryError::UnsupportedCartridgeAccess {
address: SAVE_START,
operation: "SRAM non-byte write",
})
);
}
assert!(!bus.save_modified());
}
#[test]
fn cpu_byte_stores_and_loads_work_from_rom_code() {
let mut machine = program(SAVE_START + 9, 0xe5c0_0000); // STRB r0,[r0]
machine.step().unwrap();
assert_eq!(machine.memory().save_image().unwrap()[9], 0x09);
assert!(machine.memory().save_modified());
assert!(!machine.memory().save_write_pending());
let mut machine = program(SAVE_START + 9, 0xe5d0_1000); // LDRB r1,[r0]
machine.memory_mut().write8(SAVE_START + 9, 0x77).unwrap();
machine.step().unwrap();
assert_eq!(machine.cpu().registers()[1], 0x77);
}
#[test]
fn byte_swap_reads_the_old_value_then_stores_atomically() {
let mut machine = program(SAVE_START, 0xe140_1090); // SWPB r1,r0,[r0]
machine.memory_mut().write8(SAVE_START, 0x33).unwrap();
machine.step().unwrap();
assert_eq!(machine.cpu().registers()[1], 0x33);
assert_eq!(machine.memory().read8(SAVE_START).unwrap(), 0x00);
}
#[test]
fn failed_halfword_store_changes_nothing() {
let mut machine = program(SAVE_START, 0xe1c0_00b0); // STRH r0,[r0]
let before = machine.cpu().clone();
assert!(machine.step().is_err());
assert_eq!(machine.cpu(), &before);
assert!(!machine.memory().save_modified());
assert!(!machine.memory().save_write_pending());
}
#[test]
fn dma_into_sram_remains_a_diagnostic() {
let mut bus = memory(&[0xeaff_fffe]);
bus.write32(DMA_BASE + 0x24, 0x0300_0000).unwrap(); // DMA3 source
bus.write32(DMA_BASE + 0x28, SAVE_START).unwrap(); // DMA3 destination
bus.write32(DMA_BASE + 0x2c, 0x8000_0001).unwrap(); // One halfword, immediate
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
assert!(matches!(machine.step(), Err(MachineError::Dma(_))));
assert!(!machine.memory().save_modified());
}
#[test]
fn save_states_restore_sram_and_reject_other_save_devices() {
let mut machine = program(SAVE_START + 1, 0xe5c0_0000);
machine.step().unwrap();
let state = machine.save_state().unwrap();
machine.memory_mut().write8(SAVE_START + 1, 0x55).unwrap();
machine.load_state(&state).unwrap();
assert_eq!(machine.memory().read8(SAVE_START + 1).unwrap(), 0x01);
assert_eq!(machine.save_state().unwrap(), state);
let mut flash = program(SAVE_START + 1, 0xe5c0_0000);
flash.memory_mut().set_save_device(SaveDevice::Flash64);
assert_eq!(
flash.load_state(&state),
Err(SnapshotError::ConfigMismatch("cartridge setup"))
);
}

View file

@ -50,6 +50,8 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/cpu/instruction_tests.rs` | Register operations, transfers, branches, and edge cases |
| `crates/gba-core/src/cartridge.rs` | Explicit GPIO/RTC selection, ROM read overlay, pin directions, caller-clock routing, and bounded diagnostics |
| `crates/gba-core/src/cartridge/flash.rs` | Explicit save devices, Macronix IDs/banks, delayed program/erase, bounded DQ7 polling, and staged mutations |
| `crates/gba-core/src/cartridge/sram.rs` | 32 KiB byte-wide SRAM, mirrored offsets, one staged byte per step, and its save-state section |
| `crates/gba-core/tests/sram.rs` | SRAM selection, mirroring, byte stores/loads from ROM code, SWPB, width/DMA diagnostics, and save states |
| `crates/gba-core/tests/flash.rs` | Erased/supplied images, chip IDs, all bank addresses, invalid commands, reset limits, DMA rejection, and RTC independence |
| `crates/gba-core/tests/flash_write.rs` | Program values, sector/chip scopes, delays, polling, cancellation, bit transitions, and HALT/STOP |
| `crates/gba-core/src/memory/flash_write_tests.rs` | Store-phase start, completion overlays, dirty-state rollback, and speculative clock isolation |

View file

@ -35,7 +35,7 @@ Reads past supplied cartridge bytes remain explicit memory errors, not open-bus
All three Game Pak ROM windows use the same supplied bytes and existing wait-state rules.
Ordinary cartridge bytes remain read-only. CPU and DMA writes to the `0x08`–`0x0c` ROM windows pay their bus cost and are ignored, as on hardware.
Writes to the `0x0d` window remain `ReadOnly` diagnostics until EEPROM is modeled there. Host `Memory::write*` calls stay strict everywhere.
Optional `--save-type flash64` or `--save-type flash128` enables [nominal Flash programming, erase, and reads](saves.md).
Optional `--save-type flash64` or `--save-type flash128` enables [nominal Flash programming, erase, and reads](saves.md); `--save-type sram` selects [32 KiB SRAM](saves.md#sram).
Without `--save-file PATH`, changes remain volatile. With it, the frontend loads an exact-size image and persists changes on clean exit or when a ROM window ends on a diagnostic.
Missing images start erased. Existing images receive unique backups before replacement; incomplete Flash operations refuse persistence.
Read the [storage safeguards and limits](saves.md#desktop-persistence) before using a valuable save.

View file

@ -1,14 +1,15 @@
# Cartridge Flash and save files
# Cartridge Flash, SRAM, and save files
The core supports explicit Macronix Flash selection, identification, bank selection, byte programming, and sector/chip erase.
Operations complete after nominal delays and expose bounded data polling. They do not complete immediately on the command write.
The desktop can load and persist raw images through explicit `--save-file PATH` selection.
Original ROM tests verify save/restart/load across processes. Saving in an external game remains unverified.
SRAM, EEPROM, and other manufacturers' protocols remain separate work.
The core also supports explicit 32 KiB SRAM; see [SRAM](#sram).
EEPROM and other manufacturers' Flash protocols remain separate work.
## Selection and core API
Use `--save-type flash64` or `--save-type flash128` in terminal or window ROM modes.
Use `--save-type flash64`, `--save-type flash128`, or `--save-type sram` in terminal or window ROM modes.
Selection is independent of `--rtc`. Omitting the save type leaves save memory unmapped.
No filename, ROM signature, or first-write detection changes the selected device.
Without `--save-file`, the selected device starts erased (`0xff`) and all changes remain volatile.
@ -16,7 +17,7 @@ The ROM suite schema still has no save-selection or save-file field.
Core APIs on `Memory`:
- `set_save_device(SaveDevice)` selects `None`, `Flash64`, or `Flash128` and initializes an erased array.
- `set_save_device(SaveDevice)` selects `None`, `Flash64`, `Flash128`, or `Sram` and initializes an erased array.
GPIO/RTC state is preserved.
- `load_save_image(&[u8])` accepts exactly the selected capacity. It resets commands, pending operations, ID mode, bank, and modification tracking.
Invalid size or missing hardware changes nothing.
@ -29,6 +30,16 @@ Configuration, loading, and host inspection occur between machine steps.
The core has no file, clock, window, or host-audio dependency. Frontends supply image bytes and own durable storage.
A browser frontend can use the same API with browser storage instead of filesystem paths.
## SRAM
`SaveDevice::Sram` is a 32 KiB byte-wide array with no command protocol. It starts erased (`0xff`) like Flash.
The 32 KiB array repeats twice through the `0x0e000000..0x0e00ffff` save window; higher addresses stay unmapped.
CPU byte stores take effect when the instruction's step completes. Reads later in the same step already see the new byte.
Byte stores and loads may execute from ROM; the Flash work-RAM read restriction does not apply.
Halfword and word reads and writes, and DMA transfers, remain diagnostics until 8-bit bus behavior for those widths is verified.
SRAM shares the save window's WAITCNT wait states, save-image API, host persistence, and save states with Flash.
A save state records the selected device. Restoring it into a machine with another save device is rejected as a cartridge setup mismatch.
## Bus and commands
Only `0x0e000000` through `0x0e00ffff` are mapped. Accesses are byte-only.

View file

@ -82,6 +82,7 @@ Update this file when a feature lands or a limit is removed.
- Cartridge GPIO and Flash controller changes participate in CPU/DMA rollback and block-store preflight without modifying ROM bytes.
- Explicit Macronix Flash64/Flash128 identification, bank selection, delayed byte programming, sector/chip erase, and nominal DQ7 polling.
- Transactional Flash completion and modification tracking, with exact-size core image loading and inspection.
- Explicit 32 KiB byte-wide SRAM (`--save-type sram`), mirrored through the save window and readable from ROM code.
- Optional desktop `--save-file` loading and clean-exit persistence, with exclusive locks, atomic installation, and unique previous-image backups.
- Original ROM save/restart/load checks across processes, including native-window persistence on Darwin arm64.
- Whole-machine [save states](hardware/save-states.md): `Machine::save_state`/`load_state` with image and cartridge identity checks, a checksum, and verify-before-mutate loading; ROM windows bind F5 (save) and F9 (load) to a `--state-file` slot.
@ -155,7 +156,7 @@ Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, a
ROM windows offer opt-in Darwin audio output with `--audio` and explicit save persistence with `--save-file`.
The [Flash subset](hardware/saves.md) supports programming and erase with nominal delays and bounded DQ7 polling.
Flash timing, STOP clocking, and cancellation are not hardware-verified. Wider accesses and unverified busy addresses remain diagnostic.
SRAM, EEPROM, other manufacturers, autosave, crash recovery, and game-level save transactions remain unsupported.
EEPROM, other Flash manufacturers, SRAM halfword/word access, autosave, crash recovery, and game-level save transactions remain unsupported.
Persistence is tested on Darwin arm64 with original programs, not yet with external-game saves.
The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports calendar/control transfers and explicit clock updates, selected with `--rtc` in ROM modes.
RTC persistence, interrupts, invalid-date correction, physical subsecond timing, GPIO aliases, and byte writes remain unsupported or unverified.

View file

@ -69,7 +69,7 @@ Next implementation steps:
Use original regressions for any hardware correction; do not invent connected serial devices.
2. Validate an external game's completed save/restart/load cycle using a disposable `--save-file` path.
Refine nominal Flash timing and polling only with independent evidence and original regressions.
3. Add SRAM separately when the independent candidate requires it; verify widths, mirroring, and waits first.
3. Byte-wide 32 KiB SRAM is now available with `--save-type sram`. Halfword/word SRAM access stays diagnostic until verified.
4. Resume the second-game run and implement demonstrated audio requirements rather than accepting writes without behavior.
## Development loop