From 86f29018fd1cf238ebb600b8a636119e38fd6953 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 7 Oct 2026 08:25:12 +0700 Subject: [PATCH] feat(desktop): add safe explicit Flash save-file persistence --- .gitignore | 3 + crates/desktop/src/cartridge.rs | 55 +++- crates/desktop/src/cartridge/save_file.rs | 275 ++++++++++++++++++ .../desktop/src/cartridge/save_file/tests.rs | 201 +++++++++++++ crates/desktop/src/cartridge/tests.rs | 34 +++ crates/desktop/src/desktop/rom.rs | 15 +- crates/desktop/src/main.rs | 8 +- crates/desktop/tests/rom_cli.rs | 6 +- crates/desktop/tests/save_cli.rs | 226 ++++++++++++++ docs/architecture.md | 6 +- docs/development.md | 18 ++ docs/hardware/cartridge.md | 10 +- docs/hardware/saves.md | 233 +++++++++------ docs/research/emerald-reset.md | 9 +- docs/status.md | 18 +- docs/testing.md | 18 +- docs/usability-roadmap.md | 12 +- 17 files changed, 1023 insertions(+), 124 deletions(-) create mode 100644 crates/desktop/src/cartridge/save_file.rs create mode 100644 crates/desktop/src/cartridge/save_file/tests.rs create mode 100644 crates/desktop/tests/save_cli.rs diff --git a/.gitignore b/.gitignore index b952f7f..23e280d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,8 @@ *.gba *.nds *.sav +*.sav.lock +*.sav.tmp.* +*.sav.bak.* __pycache__/ *.pyc diff --git a/crates/desktop/src/cartridge.rs b/crates/desktop/src/cartridge.rs index c56ba83..f5e6e01 100644 --- a/crates/desktop/src/cartridge.rs +++ b/crates/desktop/src/cartridge.rs @@ -1,6 +1,7 @@ //! Host-only raw ROM loading, execution modes, and diagnostics. pub(crate) mod rtc_clock; +pub(crate) mod save_file; pub mod suite; pub mod window; @@ -21,7 +22,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] (--steps COUNT | --window [--frames COUNT] [--audio]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options"; +const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options"; #[derive(Debug, PartialEq, Eq)] enum Mode { @@ -36,6 +37,7 @@ pub struct Options { hardware: CartridgeHardware, save_device: SaveDevice, audio: bool, + save_file: Option, } pub fn parse_args(args: &[OsString]) -> io::Result { @@ -47,6 +49,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result { let mut audio = false; let mut hardware = CartridgeHardware::None; let mut save_device = SaveDevice::None; + let mut save_file = None; let mut args = args.iter(); while let Some(arg) = args.next() { if arg == "--rom" && path.is_none() { @@ -63,6 +66,10 @@ pub fn parse_args(args: &[OsString]) -> io::Result { Some("flash128") => SaveDevice::Flash128, _ => return Err(invalid()), }; + } else if arg == "--save-file" && save_file.is_none() { + save_file = Some(PathBuf::from( + args.next().filter(|v| !v.is_empty()).ok_or_else(invalid)?, + )); } else if arg == "--audio" && !audio { audio = true; } else if arg == "--window" && !window { @@ -93,6 +100,9 @@ pub fn parse_args(args: &[OsString]) -> io::Result { return Err(invalid()); } } + if save_file.is_some() && save_device == SaveDevice::None { + return Err(invalid()); + } if audio && !window { return Err(invalid()); } @@ -106,6 +116,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result { mode, hardware, save_device, + save_file, audio, }) } @@ -282,7 +293,24 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box steps, @@ -293,6 +321,7 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box Result<(), Box Result<(), Box io::Result<()> { + if let Some(saved) = save.persist(memory)? { + writeln!(writer, "Save persisted on clean exit.")?; + if let Some(backup) = saved.backup { + writeln!(writer, "Previous save backup: {backup:?}")?; + } + } else { + writeln!(writer, "Save unchanged; no file written.")?; + } Ok(()) } diff --git a/crates/desktop/src/cartridge/save_file.rs b/crates/desktop/src/cartridge/save_file.rs new file mode 100644 index 0000000..7458065 --- /dev/null +++ b/crates/desktop/src/cartridge/save_file.rs @@ -0,0 +1,275 @@ +//! Explicit raw-save storage. Never runs in the core or writes on an emulation error. +#[cfg(all(test, unix))] +mod tests; +use gba_core::{cartridge::SaveDevice, memory::Memory}; +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +fn sibling(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.file_name().unwrap().to_os_string(); + name.push(suffix); + path.with_file_name(name) +} +fn create_new(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path) +} +fn same_file(a: &fs::Metadata, b: &fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + a.dev() == b.dev() && a.ino() == b.ino() + } + #[cfg(not(unix))] + { + let _ = (a, b); + false + } +} +fn read_image(path: &Path, size: usize) -> io::Result>> { + let metadata = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + if !metadata.file_type().is_file() { + return Err(io::Error::other( + "save path must be a regular file, not a symlink or directory", + )); + } + if metadata.len() != size as u64 { + return Err(io::Error::other(format!( + "save file must contain exactly {size} bytes" + ))); + } + if metadata.permissions().readonly() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "save file is read-only", + )); + } + let file = File::open(path)?; + #[cfg(unix)] + if !same_file(&metadata, &file.metadata()?) { + return Err(io::Error::other("save file changed while opening")); + } + let mut data = Vec::new(); + file.take(size as u64 + 1).read_to_end(&mut data)?; + if data.len() != size { + return Err(io::Error::other("save size changed while reading")); + } + Ok(Some(data)) +} +struct Lease { + path: PathBuf, + file: File, +} +impl Drop for Lease { + fn drop(&mut self) { + // Never remove a lock that another actor replaced after we created ours. + if let (Ok(current), Ok(owned)) = (fs::symlink_metadata(&self.path), self.file.metadata()) { + if current.is_file() && same_file(¤t, &owned) { + let _ = fs::remove_file(&self.path); + } + } + } +} +struct Temporary { + path: PathBuf, + retain: bool, +} +impl Drop for Temporary { + fn drop(&mut self) { + if !self.retain { + let _ = fs::remove_file(&self.path); + } + } +} +fn unique_file(path: &Path, kind: &str) -> io::Result<(Temporary, File)> { + static NEXT: AtomicU64 = AtomicU64::new(0); + for _ in 0..128 { + let name = sibling( + path, + &format!( + ".{kind}.{}.{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ), + ); + match create_new(&name) { + Ok(file) => { + return Ok(( + Temporary { + path: name, + retain: false, + }, + file, + )) + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(io::Error::other( + "could not reserve a unique save sidecar file", + )) +} + +#[derive(Debug)] +pub(crate) struct Persisted { + pub(crate) backup: Option, +} + +pub(crate) struct SaveFile { + path: PathBuf, + original: Option>, + size: usize, + _lease: Lease, +} +impl SaveFile { + pub(crate) fn open(path: &Path, rom: &Path, device: SaveDevice) -> io::Result { + #[cfg(not(unix))] + { + let _ = (path, rom, device); + return Err(io::Error::other( + "save-file persistence currently requires a Unix host", + )); + } + #[cfg(unix)] + { + if device == SaveDevice::None { + return Err(io::Error::other("--save-file requires --save-type")); + } + let filename = path + .file_name() + .ok_or_else(|| io::Error::other("save file needs a filename"))?; + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let path = parent.canonicalize()?.join(filename); + let rom = rom.canonicalize()?; + let rom_metadata = fs::metadata(&rom)?; + if path == rom + || fs::metadata(&path) + .ok() + .is_some_and(|m| same_file(&m, &rom_metadata)) + { + return Err(io::Error::other("save file must not alias the ROM")); + } + let lock_path = sibling(&path, ".lock"); + let file = create_new(&lock_path).map_err(|e| { + io::Error::new( + e.kind(), + format!("cannot lock save file (another process or stale lock): {e}"), + ) + })?; + let mut lease = Lease { + path: lock_path, + file, + }; + writeln!(lease.file, "gameboy-rust pid={}", std::process::id())?; + lease.file.sync_all()?; + let original = read_image(&path, device.capacity())?; + Ok(Self { + path, + original, + size: device.capacity(), + _lease: lease, + }) + } + } + pub(crate) fn initialize(&self, memory: &mut Memory) -> io::Result<()> { + if let Some(image) = &self.original { + memory.load_save_image(image).map_err(io::Error::other)?; + } + Ok(()) + } + pub(crate) fn loaded(&self) -> bool { + self.original.is_some() + } + pub(crate) fn persist(&mut self, memory: &Memory) -> io::Result> { + if memory.save_write_pending() { + return Err(io::Error::other( + "save not written: Flash command/operation is incomplete", + )); + } + if !memory.save_modified() { + return Ok(None); + } + let image = memory + .save_image() + .ok_or_else(|| io::Error::other("no save image"))?; + if image.len() != self.size { + return Err(io::Error::other("save device changed during execution")); + } + if self.original.as_deref() == Some(image) { + return Ok(None); + } + if read_image(&self.path, self.size)? != self.original { + return Err(io::Error::other( + "save file changed externally; refusing overwrite", + )); + } + let (temp, mut file) = unique_file(&self.path, "tmp")?; + file.write_all(image)?; + file.sync_all()?; + drop(file); + let backup = if let Some(original) = &self.original { + let (mut backup, mut file) = unique_file(&self.path, "bak")?; + file.write_all(original)?; + file.sync_all()?; + drop(file); + backup.retain = true; + Some(backup.path.clone()) + } else { + None + }; + // Recheck immediately before installation. The lock coordinates this frontend, + // not unrelated programs; this is not an atomic compare-and-swap filesystem API. + if read_image(&self.path, self.size)? != self.original { + return Err(io::Error::other( + "save file changed before replacement; refusing overwrite", + )); + } + if self.original.is_some() { + fs::rename(&temp.path, &self.path)?; + } else { + fs::hard_link(&temp.path, &self.path)?; + } // No-clobber first creation, even if a new file raced us. + self.original = Some(image.to_vec()); + fs::remove_file(&temp.path) + .or_else(|e| { + if e.kind() == io::ErrorKind::NotFound { + Ok(()) + } else { + Err(e) + } + }) + .map_err(|e| { + io::Error::new( + e.kind(), + format!("save installed, but temporary cleanup failed: {e}"), + ) + })?; + File::open(self.path.parent().unwrap()) + .and_then(|directory| directory.sync_all()) + .map_err(|e| { + io::Error::new( + e.kind(), + format!("save installed, but directory sync failed: {e}"), + ) + })?; + Ok(Some(Persisted { backup })) + } +} diff --git a/crates/desktop/src/cartridge/save_file/tests.rs b/crates/desktop/src/cartridge/save_file/tests.rs new file mode 100644 index 0000000..7a25205 --- /dev/null +++ b/crates/desktop/src/cartridge/save_file/tests.rs @@ -0,0 +1,201 @@ +//! File tests use generated bytes in private temporary directories, never user saves. +use super::*; +use gba_core::cartridge::{FLASH_PROGRAM_CYCLES, SAVE_START}; + +struct Fixture { + dir: PathBuf, + rom: PathBuf, + save: PathBuf, +} +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "gba-save-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&dir).unwrap(); + let rom = dir.join("original.gba"); + fs::write(&rom, [0xfe, 0xff, 0xff, 0xea]).unwrap(); + let save = dir.join("original.sav"); + Self { dir, rom, save } + } + fn open(&self) -> io::Result { + SaveFile::open(&self.save, &self.rom, SaveDevice::Flash128) + } + fn image(&self, value: u8) -> Vec { + let bytes = vec![value; SaveDevice::Flash128.capacity()]; + fs::write(&self.save, &bytes).unwrap(); + bytes + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } +} +fn memory() -> Memory { + let mut m = Memory::new(vec![]).unwrap(); + m.set_save_device(SaveDevice::Flash128); + m +} +fn start_program(m: &mut Memory, value: u8) { + for (offset, byte) in [(0x5555, 0xaa), (0x2aaa, 0x55), (0x5555, 0xa0), (0, value)] { + m.write8(SAVE_START + offset, byte).unwrap(); + } +} +fn program(m: &mut Memory, value: u8) { + start_program(m, value); + m.advance_cycles(FLASH_PROGRAM_CYCLES); +} + +#[test] +fn fresh_image_is_volatile_until_modified_then_reloads_with_unique_backups() { + let f = Fixture::new(); + let mut m = memory(); + let mut save = f.open().unwrap(); + assert!(!save.loaded()); + save.initialize(&mut m).unwrap(); + assert!(save.persist(&m).unwrap().is_none()); + assert!(!f.save.exists()); + program(&mut m, 0xa5); + assert!(save.persist(&m).unwrap().unwrap().backup.is_none()); + assert_eq!(fs::read(&f.save).unwrap(), m.save_image().unwrap()); + assert!(save.persist(&m).unwrap().is_none()); + drop(save); + assert!(!sibling(&f.save, ".lock").exists()); + let mut save = f.open().unwrap(); + assert!(save.loaded()); + let mut m = memory(); + save.initialize(&mut m).unwrap(); + assert!(!m.save_modified()); + assert_eq!(m.read8(SAVE_START).unwrap(), 0xa5); + program(&mut m, 0xa0); + let first = save.persist(&m).unwrap().unwrap().backup.unwrap(); + let original = fs::read(&first).unwrap(); + assert_eq!(original[0], 0xa5); + program(&mut m, 0x80); + let second = save.persist(&m).unwrap().unwrap().backup.unwrap(); + assert_ne!(first, second); + assert_eq!(fs::read(&first).unwrap(), original); + assert_eq!(fs::read(second).unwrap()[0], 0xa0); + assert_eq!(fs::read(&f.save).unwrap()[0], 0x80); + assert!(!fs::read_dir(&f.dir).unwrap().any(|p| p + .unwrap() + .file_name() + .to_string_lossy() + .contains(".tmp."))); + assert_eq!(fs::read(&f.rom).unwrap(), [0xfe, 0xff, 0xff, 0xea]); +} + +#[test] +fn incomplete_commands_and_busy_operations_preserve_the_existing_image() { + let f = Fixture::new(); + let original = f.image(255); + let mut save = f.open().unwrap(); + let mut m = memory(); + save.initialize(&mut m).unwrap(); + m.write8(SAVE_START + 0x5555, 0xaa).unwrap(); + assert!(save.persist(&m).is_err()); + assert_eq!(fs::read(&f.save).unwrap(), original); + m.load_save_image(&original).unwrap(); + start_program(&mut m, 0x12); + assert!(save.persist(&m).is_err()); + assert_eq!(fs::read(&f.save).unwrap(), original); + m.advance_cycles(FLASH_PROGRAM_CYCLES); + assert!(save.persist(&m).unwrap().is_some()); +} + +#[test] +fn lock_excludes_other_sessions_and_does_not_delete_replaced_locks() { + let f = Fixture::new(); + let first = f.open().unwrap(); + assert!(f.open().is_err()); + let lock = sibling(&f.save, ".lock"); + fs::rename(&lock, f.dir.join("owned-lock")).unwrap(); + fs::write(&lock, b"another owner").unwrap(); + drop(first); + assert_eq!(fs::read(&lock).unwrap(), b"another owner"); + assert!(f.open().is_err()); + fs::remove_file(&lock).unwrap(); + drop(f.open().unwrap()); + assert!(!lock.exists()); +} + +#[test] +fn size_and_device_errors_leave_no_lock_and_no_changed_bytes() { + let f = Fixture::new(); + for length in [0, 1, 65536, 131071, 131073] { + let bytes = vec![0x5a; length]; + fs::write(&f.save, &bytes).unwrap(); + assert!(f.open().is_err()); + assert_eq!(fs::read(&f.save).unwrap(), bytes); + assert!(!sibling(&f.save, ".lock").exists()); + } + assert!(SaveFile::open(&f.save, &f.rom, SaveDevice::None).is_err()); + let bytes = vec![255; 65536]; + fs::write(&f.save, &bytes).unwrap(); + let save = SaveFile::open(&f.save, &f.rom, SaveDevice::Flash64).unwrap(); + let mut m = Memory::new(vec![]).unwrap(); + m.set_save_device(SaveDevice::Flash64); + save.initialize(&mut m).unwrap(); + assert_eq!(m.save_image().unwrap(), bytes); +} + +#[test] +fn external_changes_and_new_files_are_never_overwritten() { + for existing in [false, true] { + let f = Fixture::new(); + if existing { + f.image(255); + } + let mut save = f.open().unwrap(); + let mut m = memory(); + save.initialize(&mut m).unwrap(); + program(&mut m, 0x12); + let external = f.image(0x5a); + assert!(save + .persist(&m) + .unwrap_err() + .to_string() + .contains("changed externally")); + assert_eq!(fs::read(&f.save).unwrap(), external); + assert_eq!(fs::read_dir(&f.dir).unwrap().count(), 3); // ROM, image, owned lock only. + } +} + +#[cfg(unix)] +#[test] +fn reject_rom_aliases_symlinks_directories_and_readonly_images() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let f = Fixture::new(); + assert!(SaveFile::open(&f.rom, &f.rom, SaveDevice::Flash128).is_err()); + fs::hard_link(&f.rom, &f.save).unwrap(); + assert!(f.open().is_err()); + fs::remove_file(&f.save).unwrap(); + let target = f.dir.join("target"); + fs::write(&target, vec![255; 131072]).unwrap(); + symlink(&target, &f.save).unwrap(); + assert!(f.open().is_err()); + fs::remove_file(&f.save).unwrap(); + symlink(f.dir.join("absent"), &f.save).unwrap(); + assert!(f.open().is_err()); + fs::remove_file(&f.save).unwrap(); + fs::create_dir(&f.save).unwrap(); + assert!(f.open().is_err()); + fs::remove_dir(&f.save).unwrap(); + let image = f.image(255); + fs::set_permissions(&f.save, fs::Permissions::from_mode(0o400)).unwrap(); + assert!(f.open().is_err()); + assert_eq!(fs::read(&f.save).unwrap(), image); + fs::set_permissions(&f.save, fs::Permissions::from_mode(0o600)).unwrap(); + let mut save = f.open().unwrap(); + let mut m = memory(); + save.initialize(&mut m).unwrap(); + program(&mut m, 0x12); + fs::set_permissions(&f.save, fs::Permissions::from_mode(0o400)).unwrap(); + assert!(save.persist(&m).is_err()); + assert_eq!(fs::read(&f.save).unwrap(), image); + fs::set_permissions(&f.save, fs::Permissions::from_mode(0o600)).unwrap(); +} diff --git a/crates/desktop/src/cartridge/tests.rs b/crates/desktop/src/cartridge/tests.rs index f1f3612..f9990ea 100644 --- a/crates/desktop/src/cartridge/tests.rs +++ b/crates/desktop/src/cartridge/tests.rs @@ -29,6 +29,7 @@ fn options_require_both_flags_in_either_order() { mode: Mode::Terminal { steps: count }, hardware: CartridgeHardware::None, save_device: SaveDevice::None, + save_file: None, audio: false, } ); @@ -36,6 +37,39 @@ fn options_require_both_flags_in_either_order() { } } +#[test] +fn save_file_requires_explicit_device_and_one_nonempty_path() { + let base = args(&["--rom", "test.gba", "--steps", "100"]); + for tail in [ + vec!["--save-file", "test.sav"], + vec!["--save-type", "flash128", "--save-file", ""], + vec!["--save-type", "flash128", "--save-file"], + vec![ + "--save-type", + "flash128", + "--save-file", + "a", + "--save-file", + "b", + ], + ] { + let mut arguments = base.clone(); + arguments.extend(args(&tail)); + assert!(parse_args(&arguments).is_err()); + } + let mut arguments = base; + arguments.extend(args(&[ + "--save-file", + "test.sav", + "--save-type", + "flash128", + ])); + assert_eq!( + parse_args(&arguments).unwrap().save_file, + Some("test.sav".into()) + ); +} + #[test] fn audio_is_opt_in_window_only_and_cannot_be_duplicated() { assert!( diff --git a/crates/desktop/src/desktop/rom.rs b/crates/desktop/src/desktop/rom.rs index 647cd73..2167e9b 100644 --- a/crates/desktop/src/desktop/rom.rs +++ b/crates/desktop/src/desktop/rom.rs @@ -20,11 +20,15 @@ pub fn run( hardware: gba_core::cartridge::CartridgeHardware, save_device: gba_core::cartridge::SaveDevice, audio_enabled: bool, + save_file: Option<&mut crate::cartridge::save_file::SaveFile>, writer: &mut impl Write, ) -> Result<(), Box> { let mut session = Session::new(bytes)?; session.set_cartridge_hardware(hardware); session.memory_mut().set_save_device(save_device); + if let Some(save) = save_file.as_ref() { + save.initialize(session.memory_mut())?; + } let mut rtc_clock = if hardware == gba_core::cartridge::CartridgeHardware::Rtc { Some(crate::cartridge::rtc_clock::RtcHostClock::new( session.memory_mut(), @@ -38,7 +42,7 @@ pub fn run( )?; writeln!( writer, - "Input releases and audio mutes on focus loss. No saves. Startup frames are not suppressed." + "Input releases and audio mutes on focus loss. Save files require --save-file and clean exit. Startup frames are not suppressed." )?; if let Some(limit) = frame_limit { writeln!( @@ -50,7 +54,7 @@ pub fn run( let mut audio = None; let mut samples = [gba_core::audio::StereoLevel::default(); 1024]; let result = (|| -> Result<&str, Box> { - let title = "GBA Rust | ROM | Esc: exit | No saves"; + let title = "GBA Rust | ROM | Esc: exit"; let mut window = create_window(title)?; window.set_target_fps(0); if audio_enabled { @@ -144,7 +148,12 @@ pub fn run( if let Some(output) = audio.as_ref() { output.report(writer)?; } - result.map(|_| ()) + result?; + writer.flush()?; + if let Some(save) = save_file { + crate::cartridge::persist_save(save, session.memory_mut(), writer)?; + } + Ok(()) } #[cfg(test)] diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index dc496b0..e82a5e2 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -56,6 +56,7 @@ fn parse_args(args: &[OsString]) -> Result { "--rtc", "--audio", "--save-type", + "--save-file", ] .iter() .any(|flag| arg == flag) @@ -88,7 +89,7 @@ fn parse_args(args: &[OsString]) -> Result { [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] (--steps COUNT | --window [--frames COUNT] [--audio]); 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] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio]); or gameboy-rust --test-suite PATH.json", )), } } @@ -136,7 +137,8 @@ fn run() -> Result<(), Box> { --frames COUNT Exit ROM window after 1..=100000 captured frames\n\ --audio Enable macOS output for ROM windows (default: muted)\n\ --rtc Select cartridge RTC (UTC startup, host elapsed time)\n\ - --save-type flash64|flash128 Select Flash identification/read subset (no saving yet)\n\ + --save-type flash64|flash128 Select nominal Macronix Flash emulation\n\ + --save-file PATH Load raw Flash image; persist only on clean exit\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\ --timer-demo Run the timer IRQ demo with nominal cycle costs\n\ @@ -170,7 +172,7 @@ fn run() -> Result<(), Box> { Affine backgrounds: Arrows pan; Q rotates; W zooms; Z disables wrapping; Enter resets; Escape exits.\n\ Bitmap pages: Arrows pan; Q rotates; W zooms; Z forces page1; Enter resets; Escape exits.\n\ All demos use original test content. ROM mode uses our limited BIOS replacement.\n\ - Terminal ROM mode has no window, input, or audio. ROM windows offer --audio on macOS. No saves.\n\ + Terminal ROM mode has no window, input, or audio. ROM windows offer --audio on macOS. Saves require --save-type and --save-file.\n\ ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits.\n\ Commercial games are not supported." ); diff --git a/crates/desktop/tests/rom_cli.rs b/crates/desktop/tests/rom_cli.rs index 55de8e2..9e93426 100644 --- a/crates/desktop/tests/rom_cli.rs +++ b/crates/desktop/tests/rom_cli.rs @@ -278,7 +278,7 @@ fn explicit_flash_selection_runs_ram_byte_reader_and_leaves_files_unchanged() { .unwrap(); assert!(output.status.success(), "{}", stderr(&output)); let text = stdout(&output); - assert!(text.contains("identification/bank reads only")); + assert!(text.contains("nominal program/erase timing and DQ7 polling")); assert!(text.contains("r5=0x000000c2"), "{text}"); assert!(text.contains(id), "{text}"); assert_eq!(fs::read(&path).unwrap(), bytes); @@ -427,7 +427,9 @@ fn help_describes_terminal_rom_mode_and_existing_cpu_demo_still_runs() { assert!(output.status.success()); assert!(stdout(&output).contains("--rom PATH --steps COUNT")); assert!(stdout(&output).contains("--rom PATH --window")); - assert!(stdout(&output).contains("ROM windows offer --audio on macOS. No saves.")); + assert!(stdout(&output).contains( + "ROM windows offer --audio on macOS. Saves require --save-type and --save-file." + )); let output = command().arg("--cpu-demo").output().unwrap(); assert!(output.status.success()); assert!(stdout(&output).contains("Exception demo")); diff --git a/crates/desktop/tests/save_cli.rs b/crates/desktop/tests/save_cli.rs new file mode 100644 index 0000000..4328eea --- /dev/null +++ b/crates/desktop/tests/save_cli.rs @@ -0,0 +1,226 @@ +//! Save/restart process tests use original ARM instructions and temporary raw images. +#![cfg(unix)] +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Output}, + sync::atomic::{AtomicU64, Ordering}, +}; +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "gba-save-cli-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn rom(&self, name: &str, code: &[u32]) -> PathBuf { + let path = self.0.join(name); + fs::write( + &path, + code.iter() + .flat_map(|word| word.to_le_bytes()) + .collect::>(), + ) + .unwrap(); + path + } + fn save(&self) -> PathBuf { + self.0.join("original.sav") + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn command(rom: &Path, save: &Path, device: &str) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_gameboy-rust")); + c.arg("--rom") + .arg(rom) + .args(["--save-type", device, "--save-file"]) + .arg(save); + c +} +fn text(output: &Output) -> String { + format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} +fn load(code: &mut Vec, register: u32, value: u32) { + code.extend([0xe59f0000 | register << 12, 0xea000000, value]); +} +fn writer(value: u8, fail: bool) -> Vec { + let mut code = Vec::new(); + for (address, byte) in [ + (0x0e005555, 0xaa), + (0x0e002aaa, 0x55), + (0x0e005555, 0xa0), + (0x0e000000, value), + ] { + load(&mut code, 0, address); + code.extend([0xe3a01000 | u32::from(byte), 0xe5c01000]); + } + code.extend([0xe1a00000; 700]); // Allow pending program to complete before failure or exit. + code.push(if fail { 0xee000000 } else { 0xeafffffe }); + code +} +fn reader() -> Vec { + let mut code = Vec::new(); + load(&mut code, 3, 0x0e000000); + load(&mut code, 4, 0x03000000); + for (offset, instruction) in [(0, 0xe5d35000), (4, 0xeafffffe)] { + load(&mut code, 0, instruction); + code.push(0xe5840000 | offset); + } + code.push(0xe12fff14); + code +} +fn run(rom: &Path, save: &Path, device: &str) -> Output { + command(rom, save, device) + .args(["--steps", "2000"]) + .output() + .unwrap() +} + +#[test] +fn separate_processes_program_reload_and_retain_previous_images() { + for (device, size) in [("flash64", 65536), ("flash128", 131072)] { + let f = Fixture::new(); + let write = f.rom("writer.gba", &writer(0xa5, false)); + let bytes = fs::read(&write).unwrap(); + let mut permissions = fs::metadata(&write).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(&write, permissions).unwrap(); + let save = f.save(); + let output = run(&write, &save, device); + assert!(output.status.success(), "{}", text(&output)); + assert!(text(&output).contains("Save persisted on clean exit")); + let original = fs::read(&save).unwrap(); + assert_eq!(original.len(), size); + assert_eq!(original[0], 0xa5); + assert!(original[1..].iter().all(|&v| v == 255)); + let read = f.rom("reader.gba", &reader()); + let output = run(&read, &save, device); + assert!(output.status.success(), "{}", text(&output)); + assert!(text(&output).contains("r5=0x000000a5")); + assert!(text(&output).contains("Save unchanged")); + let write2 = f.rom("writer2.gba", &writer(0xa0, false)); + let output = run(&write2, &save, device); + assert!(output.status.success(), "{}", text(&output)); + assert!(text(&output).contains("Previous save backup")); + let backups: Vec<_> = fs::read_dir(&f.0) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.file_name().unwrap().to_string_lossy().contains(".bak.")) + .collect(); + assert_eq!(backups.len(), 1); + assert_eq!(fs::read(&backups[0]).unwrap(), original); + assert_eq!(fs::read(&save).unwrap()[0], 0xa0); + assert_eq!(fs::read(&write).unwrap(), bytes); + assert!(!f.0.join("original.sav.lock").exists()); + } +} + +#[test] +fn diagnostic_after_completed_program_never_persists_or_creates_backups() { + for existing in [false, true] { + let f = Fixture::new(); + let rom = f.rom("failure.gba", &writer(0x12, true)); + let save = f.save(); + let original = vec![255; 131072]; + if existing { + fs::write(&save, &original).unwrap(); + } + let output = run(&rom, &save, "flash128"); + assert!(!output.status.success()); + assert!(text(&output).contains("unsupported instruction")); + if existing { + assert_eq!(fs::read(&save).unwrap(), original); + } else { + assert!(!save.exists()); + } + assert_eq!( + fs::read_dir(&f.0).unwrap().count(), + if existing { 2 } else { 1 } + ); + } +} + +#[test] +fn clean_terminal_stop_with_an_incomplete_command_refuses_persistence() { + let f = Fixture::new(); + let mut code = Vec::new(); + load(&mut code, 0, 0x0e005555); + code.extend([0xe3a010aa, 0xe5c01000, 0xef030000, 0xeafffffe]); + let rom = f.rom("incomplete.gba", &code); + let output = run(&rom, &f.save(), "flash128"); + assert!(!output.status.success()); + assert!( + text(&output).contains("Flash command/operation is incomplete"), + "{}", + text(&output) + ); + assert!(!f.save().exists()); +} + +#[test] +fn existing_lock_blocks_execution_without_changing_files() { + let f = Fixture::new(); + let rom = f.rom("writer.gba", &writer(0xa5, false)); + let lock = f.0.join("original.sav.lock"); + fs::write(&lock, b"other process").unwrap(); + let output = run(&rom, &f.save(), "flash128"); + assert!(!output.status.success()); + assert!(text(&output).contains("cannot lock save file")); + assert_eq!(fs::read(lock).unwrap(), b"other process"); + assert!(!f.save().exists()); +} + +#[test] +#[ignore = "requires a logged-in desktop session"] +fn native_rom_window_save_reloads_in_another_process() { + let f = Fixture::new(); + let write = f.rom("writer.gba", &writer(0xa5, false)); + let output = command(&write, &f.save(), "flash128") + .args(["--window", "--frames", "3"]) + .output() + .unwrap(); + assert!(output.status.success(), "{}", text(&output)); + assert!(text(&output).contains("Save persisted on clean exit")); + let read = f.rom("reader.gba", &reader()); + let output = run(&read, &f.save(), "flash128"); + assert!(output.status.success(), "{}", text(&output)); + assert!(text(&output).contains("r5=0x000000a5")); + let baseline = fs::read(f.save()).unwrap(); + let fail = f.rom("failure.gba", &writer(0xa0, true)); + let output = command(&fail, &f.save(), "flash128") + .args(["--window", "--frames", "3"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(text(&output).contains("unsupported instruction")); + assert_eq!(fs::read(f.save()).unwrap(), baseline); + let mut code = Vec::new(); + load(&mut code, 0, 0x0e005555); + code.extend([0xe3a010aa, 0xe5c01000, 0xeafffffe]); + let incomplete = f.rom("incomplete.gba", &code); + let output = command(&incomplete, &f.save(), "flash128") + .args(["--window", "--frames", "3"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(text(&output).contains("Flash command/operation is incomplete")); + assert_eq!(fs::read(f.save()).unwrap(), baseline); + assert!(!fs::read_dir(&f.0).unwrap().any(|p| p + .unwrap() + .file_name() + .to_string_lossy() + .contains(".bak."))); +} diff --git a/docs/architecture.md b/docs/architecture.md index 31d44b2..97ab327 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,8 +49,10 @@ The repository is a Cargo workspace with three crates: | `crates/gba-core/src/cpu/tests.rs` | Condition truth tables and immediate arithmetic tests | | `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 identification/read controller, bank selection, and unsupported-write 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/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 | | `crates/gba-core/src/memory/flash_step_tests.rs` | ARM/Thumb Flash writes and RAM reads, wait costs, staged IDs, fetch restrictions, and failed-step/preflight isolation | | `crates/gba-core/src/cartridge/rtc.rs` | RTC framing, control/calendar commands, read snapshots, complete writes, and reset | | `crates/gba-core/src/cartridge/calendar.rs` | Validated decimal calendar, BCD representation, checked epoch conversion, and bounded elapsed-time arithmetic | @@ -169,6 +171,8 @@ The repository is a Cargo workspace with three crates: | `crates/desktop/src/cartridge/suite/gameplay_tests.rs` | VBlank completion, input snapshots, captured pixels, and rendering failures | | `crates/desktop/tests/rom_suite_cli.rs` | File-backed suite execution, case isolation, output schema, determinism, and exit status | | `crates/desktop/examples/write_test_suite.rs` | Original ARM/Thumb/BIOS fixture generator with a local JSON suite | +| `crates/desktop/src/cartridge/save_file.rs`, `crates/desktop/src/cartridge/save_file/tests.rs` | Explicit raw-save loading, locks, baseline checks, synced installation, backups, and file-safety tests | +| `crates/desktop/tests/save_cli.rs` | Original program/save/restart/load tests, diagnostic isolation, and native-window persistence | | `crates/desktop/src/cartridge/rtc_clock.rs` | Host-only UTC seed and monotonic elapsed-time adapter, with injected epoch/duration tests | | `crates/desktop/src/cartridge/window.rs` | ROM-window session, bounded slices, capture, STOP/input handling, and progress limits | | `crates/desktop/src/cartridge/window/tests.rs` | Original ROM pixels, keyboard mapping, STOP wake, HALT, frame budgets, and diagnostics | diff --git a/docs/development.md b/docs/development.md index c0cb6a3..8a1c31c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,6 +75,24 @@ The window runs on the main thread, as required by macOS AppKit. The dependency is pinned to that exact version. Review this workaround before upgrading minifb. Intel macOS and Linux desktop builds are not verified. +## Portability boundary + +The Rust `gba-core` owns CPU, memory, video, audio generation, input state, and cartridge protocols. +It has no external dependencies or filesystem, window, host-clock, or audio-device code. +The same core can serve another frontend; porting does not require rewriting emulated hardware. +This separation is a design boundary, not a completed Linux or WebAssembly port. + +- Linux can reuse the desktop structure and Unix raw-save adapter. Build dependencies and native behavior still need validation. + Host audio currently returns an explicit unsupported-platform error outside macOS; add and test a Linux backend separately. +- A browser needs WebAssembly bindings, canvas presentation, browser input, Web Audio, and browser scheduling. + Its frontend must load ROM/image bytes and persist save images through browser storage or explicit import/export. + It cannot reuse desktop `.sav` filesystem operations or CoreAudio. +- Core save loading/inspection is byte-based. Flash commands never access host files. + Desktop [save persistence](hardware/saves.md#desktop-persistence) is tested on Darwin arm64 only. + +Cross-compilation, performance, lifecycle handling, and save durability need tests on each target. +The core currently uses Rust's standard library; it is dependency-free, not a `no_std` crate. + Without direnv, use: ```sh diff --git a/docs/hardware/cartridge.md b/docs/hardware/cartridge.md index 80248c2..431877f 100644 --- a/docs/hardware/cartridge.md +++ b/docs/hardware/cartridge.md @@ -34,8 +34,10 @@ Headerless original programs are accepted. Reads past supplied cartridge bytes remain explicit memory errors, not open-bus values. All three Game Pak ROM windows use the same supplied bytes and existing wait-state rules. Ordinary cartridge bytes remain read-only. -Optional `--save-type flash64` or `--save-type flash128` enables [Flash identification and array reads](saves.md). -The desktop starts an erased device and does not load or write save files. Programming and erase remain diagnostic. +Optional `--save-type flash64` or `--save-type flash128` enables [nominal Flash programming, erase, and reads](saves.md). +Without `--save-file PATH`, changes remain volatile. With it, the frontend loads an exact-size image and persists changes on clean exit. +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. Optional `--rtc` selection enables the bounded [Game Pak GPIO and RTC calendar interface](cartridge-gpio.md). It supports date/time reads, complete writes, reset, and explicit elapsed-time updates. RTC interrupts and persistence remain unsupported. The desktop seeds UTC once, then supplies monotonic host elapsed seconds independently of GBA cycles and STOP. @@ -70,7 +72,7 @@ The report includes: Exit status zero means the step limit was reached or STOP was entered. It does not mean the program completed or passed a hardware test. -Invalid arguments, loading failures, output failures, and emulation diagnostics produce a nonzero exit status. +Invalid arguments, loading failures, output failures, save-persistence failures, and emulation diagnostics produce a nonzero exit status. For emulation diagnostics, stdout contains the final machine state and stderr contains the core error. Terminal mode has no window, keyboard input, or real-time pacing. @@ -138,6 +140,6 @@ No game or firmware content is included. ROM windows can enable Darwin output with `--audio`; they are muted by default. The option requires a usable default audio device. Device/configuration errors fail explicitly; omit the flag to run muted. Focus loss and STOP clear queued audio. See [audio output and its limits](audio.md#darwin-output-adapter). -Terminal mode has no audio. Both modes lack save writing/persistence and per-step tracing. +Terminal mode has no audio. Both modes support explicit save files but lack per-step tracing. Commercial-game compatibility remains unverified and unsupported. Only load files that you may lawfully use. Do not commit game ROMs or firmware. diff --git a/docs/hardware/saves.md b/docs/hardware/saves.md index c4d557c..67413bd 100644 --- a/docs/hardware/saves.md +++ b/docs/hardware/saves.md @@ -1,122 +1,175 @@ -# Cartridge Flash identification and read subset +# Cartridge Flash and save files -The core supports explicit Macronix Flash selection, chip identification, array reads, and 128 KiB bank selection. -Programming, erase, busy polling, and host save-file persistence remain unimplemented. -This is not working game-save support. Unsupported writes fail instead of reporting a completed save. +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. -## Selection and initial data +## Selection and core API -The desktop accepts either `--save-type flash64` or `--save-type flash128` in ROM terminal/window modes. -The option is independent of `--rtc`. Omitting it leaves save memory unmapped. -Unknown values and duplicate options fail before opening the ROM. - -```sh -direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --steps 3000000 -direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --window --frames 60 -``` - -Selection is explicit, not inferred from a filename, ROM signature, or the first write. -A 64 KiB device cannot silently become a 128 KiB device. -The desktop starts each selected device with erased `0xff` bytes and reports the read-only subset's limits. -It does not look for, read, create, or overwrite a save file. Existing game saves remain untouched. -The ROM suite schema still uses no save device by default and has no save-selection field yet. +Use `--save-type flash64` or `--save-type flash128` 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. +The ROM suite schema still has no save-selection or save-file field. Core APIs on `Memory`: -- `set_save_device(SaveDevice)` replaces save hardware and initializes its array to erased bytes. - `None`, `Flash64`, and `Flash128` are available. The call preserves GPIO/RTC state. -- `load_save_image(&[u8])` supplies an exact-capacity image and resets command state, ID mode, and bank selection. - A wrong length or missing device changes nothing. This is host setup, not an emulated program operation. -- `save_image()` inspects the complete array, independently of ID mode or selected bank. - It returns `None` when no save device is selected. +- `set_save_device(SaveDevice)` selects `None`, `Flash64`, or `Flash128` 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. +- `save_image()` inspects the full committed array, independent of bank and ID mode. No selected device returns `None`. +- `save_modified()` reports whether a completed operation changed bytes since setup or loading. + It remains set after host persistence; hosts compare against their last persisted image to avoid redundant writes. +- `save_write_pending()` reports an incomplete command or operation. Hosts must not persist an exit snapshot in this state. -Configuration and image replacement occur between machine steps. No filesystem operations belong to the core. -Selecting GPIO/RTC hardware does not replace an attached save device or its bytes. -This feature can inspect caller-supplied images but cannot modify their data through Flash commands. +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. -## Bus behavior +## Bus and commands -Only `0x0e000000` through `0x0e00ffff` are mapped when a device is selected. -The Flash interface uses byte accesses. Halfword/word transfers, DMA, and instruction fetches remain diagnostic. -Unverified aliases outside this window remain unmapped rather than returning mirrored data. -Ordinary ROM writes remain read-only. - -CPU Flash reads must execute from external or internal work RAM, as required by GBATEK's access description. -A read from ROM/BIOS code reports a diagnostic rather than inventing a value for an unsupported bus sequence. -Host byte inspection has no CPU execution restriction. CPU command writes may execute from ROM. +Only `0x0e000000` through `0x0e00ffff` are mapped. Accesses are byte-only. +Halfword/word transfers, DMA, instruction fetches, and unverified aliases remain diagnostic. +CPU Flash reads must execute from external or internal work RAM, following GBATEK's access description. +Reads from ROM/BIOS code remain diagnostic. Host inspection has no execution restriction; CPU writes can execute from ROM. +Ordinary ROM bytes remain read-only. WAITCNT bits 0–1 select save wait states 4, 3, 2, or 8. -The nominal byte-access costs are therefore 5, 4, 3, or 9 cycles, for both sequential and non-sequential requests. -Save waits do not alter ROM wait fields. Flash data accesses cancel Game Pak opcode prefetch through the existing nominal model. -Changing only save wait bits preserves the queue until a cartridge access cancels it. -These costs are source-backed estimates, not physical timing measurements. +Nominal byte-access costs are therefore 5, 4, 3, or 9 cycles for sequential and non-sequential accesses. +Flash data accesses cancel Game Pak opcode prefetch. Changing save wait bits alone preserves the queue. +These costs are source-backed estimates, not physical measurements. -## Commands - -Use these byte addresses and values for the unlock prefix: +The unlock prefix consists of these byte writes: 1. Write `0xaa` to `0x0e005555`. 2. Write `0x55` to `0x0e002aaa`. -3. Write a command byte to `0x0e005555`. +3. Write the command to `0x0e005555`. -Supported commands after the prefix: - -| Command | Behavior | +| Command | Behavior after the unlock prefix | | --- | --- | -| `0x90` | Enter chip-identification mode | -| `0xf0` | Exit identification mode and return to array reads | -| `0xb0` | On Flash128 only, accept a bank byte next at `0x0e000000` | +| `0x90` | Enter chip identification | +| `0xf0` | Exit identification and return to array reads | +| `0xb0` | Flash128 only: next write selects bank 0 or 1 at `0x0e000000` | +| `0xa0` | Next byte write supplies the target address and program data | +| `0x80` | Accept another `0xaa`/`0x55` unlock pair, then an erase confirmation | -In ID mode, byte zero is the manufacturer and byte one is the device: +Erase confirmation is `0x10` at `0x0e005555` for the entire chip, or `0x30` at a 4 KiB sector base. +Sector erase affects only the selected bank's sector. Chip erase affects both banks on Flash128. +Byte programming can clear bits or retain their value. Requests that set a stored zero bit are diagnostic; erase first. +Invalid commands, prefixes, bank values, and sector addresses retain the preceding state for diagnostic retry. +This policy does not reproduce hardware behavior for every malformed sequence. -| Selection | Manufacturer | Device | Combined ID | +| Device | Capacity | Manufacturer byte | Device byte | | --- | --- | --- | --- | -| Flash64 | `0xc2` | `0x1c` | `0x1cc2` | -| Flash128 | `0xc2` | `0x09` | `0x09c2` | +| Flash64 | 64 KiB | `0xc2` | `0x1c` | +| Flash128 | 128 KiB | `0xc2` | `0x09` | -Other ID-mode addresses remain diagnostic because their values are not established by this subset. -Array mode reads bytes from the selected 64 KiB bank. Flash64 always uses bank zero. -Flash128 accepts bank values zero and one only. Bank changes do not alter stored data. -Entering and leaving ID mode preserves the selected bank. -Reads during an incomplete unlock retain the previous read mode and bank. +Identification exposes these bytes at offsets zero and one. Other ID addresses remain diagnostic. +Bank selection survives ID entry/exit; incomplete unlock reads retain the previous mode and bank. -For the documented 64 KiB Macronix device, a direct `0xf0` write at `0x0e005555` resets command/ID state. -For Flash128, the same write is accepted only as a redundant reset when already idle in array mode. -This bounded no-op matches the inspected emulators' idle behavior. It does not establish direct ID exit or busy-command cancellation. -Flash128 still requires the full unlock prefix to exit ID mode. Direct reset during an incomplete unlock remains diagnostic. -No matching manufacturer datasheet or physical measurement was used to claim broader 128 KiB reset behavior. +For Flash64, direct `0xf0` at `0x0e005555` resets commands/ID state or cancels a busy operation. +Cancellation preserves the old array. Partial physical programming on cancellation is not modeled. +After `0xa0`, `0xf0` at that address is program data, not reset. +For Flash128, direct reset is accepted only as a redundant no-op while idle in array mode. +Flash128 requires an unlocked ID exit; direct busy/partial-command cancellation remains diagnostic. -`0xa0` byte-program setup and `0x80` erase setup remain diagnostic at the command write. -No operation starts, data changes, busy state, completion flag, or interrupt is fabricated. -Unknown commands, invalid prefixes, and invalid bank writes are also diagnostic. -Rejected accesses retain the previous command state and bytes, so retrying the same access gives the same result. -This atomic diagnostic policy does not reproduce hardware handling of every malformed sequence. +## Nominal completion and transactions -## Transactions and validation +Byte programming takes 650 nominal cycles. Sector and chip erase each take 30,000 nominal cycles. +These are simulation constants, not measured Macronix timings. Chip erase duration is deliberately not capacity-dependent. +GBATEK leaves average Macronix operation times unknown. The constants match the pinned mGBA timing approximation below. +No matching manufacturer datasheet or physical timing measurement establishes these values. -The small Flash controller state joins the existing cartridge machine-step transaction. -The full save image stays outside the copied controller state; this read-only subset never mutates it during emulation. -Failed CPU or DMA steps discard speculative controller changes and timing. -Block-store preflight rejects a wide Flash destination before committing earlier RAM or device writes. -HALT/STOP and elapsed cycles cannot create Flash completion events. There is no pending program or erase operation to finish. +While busy, reads are accepted only at the operation's polling address: -Original tests cover both device IDs, every byte in both banks, exact image lengths, erased state, invalid sequences, and unchanged ROM/save data. -They distinguish redundant idle reset from unsupported Flash128 ID/partial-command reset. -ARM/Thumb probes verify ROM command writes, RAM byte reads, wait settings, restrictions, and retryable failed steps. -Additional tests cover staged ID reads, block rollback, DMA rejection, GPIO/RTC independence, and prefetch cancellation. -Desktop tests execute an original IWRAM byte reader with explicit device selection in terminal and native-window modes. -No game code, save file, or copyrighted assets are included in these tests. +- Program: the target byte. +- Sector erase: the selected sector base. +- Chip erase: `0x0e000000`. -## Remaining work +Busy reads return the complement of the final data's bit 7 (DQ7), with other bits zero. +Toggle/error bits, protection, wear, queued sector erase, and suspend/resume are not modeled. +Other busy reads and writes remain diagnostic, except the documented Flash64 cancellation subset. +After completion, ordinary array reads resume. No completion interrupt is generated. -Implement and validate program/erase commands, busy polling, cancellation, and byte-program bit transitions before claiming save functionality. -Then add explicit host save loading and safe persistence, with exact-size validation and restart tests. -Do not overwrite existing files or add immediate-success responses merely to pass a game check. +A program starts at the successful byte-store bus phase, after preceding fetch/data clocks. +The small controller and remaining delay participate in the machine-step transaction. +Completed reads in a step see a staged overlay. The full array changes only when the step commits successfully. +Failed CPU/DMA steps and speculative block-store validation discard staged progress, data changes, and modification flags. +There is no full-image copy per instruction. + +`Machine` execution and `Memory::advance_cycles` advance completion. CPU-only stepping does not clock devices automatically. +HALT advances pending operations. STOP freezes them under the current nominal device-clock policy. +This does not reproduce a physical Flash chip's independent oscillator during STOP. + +## Desktop persistence + +Persistence is explicit. A typical local command is: + +```sh +direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --save-file roms/pokemon-emerald.sav --window --audio +``` + +Omit `--audio` for muted execution. This command is not a game-save compatibility claim. +Use a copy of any valuable existing save while compatibility remains unverified. + +The save path's parent directory must exist. File extensions do not select the format. +Existing raw images must contain exactly 65,536 or 131,072 bytes, matching the selected hardware. +Loading occurs before the first machine step. A missing file starts an erased array without creating a file immediately. +Directories, symlinks, read-only files, and paths that alias the ROM are rejected. +The ROM is never opened for writing. + +Complete the game's own save operation before closing the window normally or pressing Escape. +The frontend persists changed bytes only on a clean exit: + +- Interactive window closure or Escape. +- Reaching a requested window frame limit. +- Reaching a terminal step limit or entering terminal STOP. + +An incomplete Flash command/operation refuses persistence and returns an error, even if no bytes changed yet. +CPU, DMA, rendering, window, or reported audio errors do not persist the session image. +There is no autosave, crash recovery, save-state support, or RTC persistence. +The emulator cannot detect a game's multi-command save transaction. Exiting between completed commands can still save incomplete game data. +A crash or forced termination loses changes since startup and can leave a stale lock. + +The host storage sequence is: + +1. Reserve a sibling `.lock` file exclusively for the entire session. +2. Before persistence, compare the current destination bytes with the loaded baseline. Refuse ordinary external changes. +3. Write and sync a uniquely named same-directory `.tmp..` file. +4. Before replacement, write and sync the prior bytes to a unique `.bak..` backup. +5. Recheck the destination, then install the image. Existing files use atomic rename; new files use a no-clobber hard link. +6. Remove the temporary name and sync the parent directory. Release the owned lock on normal scope exit. + +Unchanged images create no save or backup. Backups are never overwritten or automatically deleted by this frontend. +Backup files are ordinary files, not OS-enforced immutable files. Review and manage them manually when needed. +A directory-sync failure after installation reports that the save was installed but durability is uncertain. +Storage exhaustion or permission errors are explicit failures; previous backups are retained. + +The lock coordinates cooperating emulator processes. It is not protection against hostile or noncooperating filesystem changes. +The final comparison and rename are not an atomic compare-and-swap operation. +After a crash, confirm that no emulator owns the save, inspect the save/backup files, then remove the stale `.lock` manually. +The frontend never takes over an existing lock automatically. +Persistence currently requires a Unix host. Darwin arm64 is validated; Linux and browser storage remain unvalidated. + +## Validation and remaining work + +Original tests cover all program byte values in both banks, erase scope, exact delays, polling, cancellation, and bit transitions. +ARM/Thumb tests cover store timing, completion reads, failed-step rollback, block preflight, HALT, and STOP. +File tests cover size checks, unchanged images, locks, external changes, aliases, read-only paths, and retained backups. +Original ROM process tests program, restart, reload through an IWRAM byte reader, and verify no persistence after diagnostics. +A native-window test persists on frame-limit exit and reloads in another process. +No game code, user save, firmware, or copyrighted assets are included in the tests. + +Next, validate an external game's completed save and load using a disposable save path. +Add independent hardware coverage for timing, status bits, busy addresses, and reset behavior before broadening the model. +Implement SRAM and EEPROM separately when a demonstrated compatibility requirement needs them. ## References -- [GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm): capacities, IDs, unlock sequences, bank selection, 64 KiB Macronix reset, byte-only access, and RAM-execution requirements. -- [GBATEK](https://mgba-emu.github.io/gbatek/), WAITCNT section: save wait fields and one-cycle-plus-wait timing. -- [mGBA savedata at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/savedata.c): comparison for controller phases, bank addressing, ID overlays, and idle reset handling. Its default chip IDs differ; its write/erase timing and busy approximation are not copied. -- [ares Flash at 9408cb43](https://github.com/ares-emulator/ares/blob/9408cb43d4948fc3ea6e152a307a34348df3fe04/ares/gba/cartridge/flash.cpp): comparison for IDs, banking, unlocked ID exit, and unchanged idle state after an unrecognized direct reset. Its immediate program/erase behavior and permissive unlock handling are not adopted. +- [GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm): capacities, IDs, command sequences, banking, program/erase polling, Macronix reset, byte-only access, and RAM execution. +- [GBATEK](https://mgba-emu.github.io/gbatek/), WAITCNT section: save waits and one-cycle-plus-wait timing. +- [mGBA savedata at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/savedata.c): comparison for controller phases, bank addressing, ID overlays, idle reset, and nominal 650/30,000-cycle delays. Its DQ7 approximation is not hardware evidence. Our array mutation is deferred to transactional completion rather than applied immediately. +- [ares Flash at 9408cb43](https://github.com/ares-emulator/ares/blob/9408cb43d4948fc3ea6e152a307a34348df3fe04/ares/gba/cartridge/flash.cpp): comparison for IDs, banking, unlocked ID exit, and idle reset. Immediate program/erase and permissive unlock handling are not adopted. diff --git a/docs/research/emerald-reset.md b/docs/research/emerald-reset.md index 9883ff5..bb6525a 100644 --- a/docs/research/emerald-reset.md +++ b/docs/research/emerald-reset.md @@ -73,8 +73,8 @@ Core tests inject deterministic time. Desktop `--rtc` uses UTC startup and monot Select it explicitly; ordinary ROM bytes and other ROMs' default hardware remain unchanged. RTC support then reached a Flash unlock write (`0xaa` at `0x0e005555`, PC `0x082e1892`). -The explicit [Flash read subset](../hardware/saves.md) now handles identification, array reads, and bank selection. -Select `--save-type flash128` for this bounded run; the device begins erased and no save file is opened. +The initial [Flash subset](../hardware/saves.md) added identification, array reads, and bank selection. +This bounded run selected `--save-type flash128` without a save file; the device began erased. A later redundant `0xf0` write occurred after returning to idle array mode. The subset accepts that no-op without claiming direct Flash128 ID exit or busy-command cancellation. @@ -117,7 +117,8 @@ The pinned Pong debug/release reports match. A subsequent [scheduled-input probe](emerald-wave.md) exposed a wave-channel trigger with its gate disabled. The general single-bank wave model now passes that point and completes an extended Start/A input schedule. Visible menu semantics and gameplay still require verification; frame-limit success alone cannot establish either. -Flash programming, erase, busy behavior, and safe persistence remain required before claiming save compatibility. +Nominal Flash programming, erase, DQ7 polling, and explicit host persistence were added after these game probes. +Original ROM save/restart/load tests pass, but Emerald's completed save and reload still need separate validation. RTC persistence and cartridge IRQ behavior remain separate missing features. Ordinary ROM writes remain read-only. Without `--rtc`, the earlier GPIO diagnostic is still expected. This result does not establish gameplay, linked transfers, or cartridge/serial GPIO interrupt support. @@ -128,7 +129,7 @@ All supported channels use the instantaneous digital-level inspection interface. Wave channel 3 now supports single-bank playback. Two-bank playback remains missing. Fixed-rate capture and optional Darwin `--audio` output were added later; their counters do not establish correct game music. Do not treat digital-level tests as audible-game validation or suppress remaining diagnostics without implementing their behavior. -Cartridge saves remain a separate required feature. SRAM alone will not support Emerald's Flash save protocol. +External-game save compatibility remains unverified. SRAM alone will not support Emerald's Flash save protocol. Hardware and emulator references are recorded in the linked subsystem documents. [GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm) identifies the `0xaa`/`0x5555` unlock and chip-identification sequence. diff --git a/docs/status.md b/docs/status.md index d3910a9..b660cd1 100644 --- a/docs/status.md +++ b/docs/status.md @@ -80,8 +80,10 @@ Update this file when a feature lands or a limit is removed. - Validated RTC date/time, BCD transfers, read snapshots, complete writes/reset, and caller-supplied battery-clock advancement. - Desktop `--rtc` seeds UTC once and supplies host elapsed seconds independently of CPU cycles and STOP. - Cartridge GPIO and Flash controller changes participate in CPU/DMA rollback and block-store preflight without modifying ROM bytes. -- Explicit Macronix Flash64/Flash128 identification, read-only arrays, and 128 KiB bank selection. -- Exact-size core save-image initialization and inspection; desktop save selection starts erased and performs no save-file access. +- 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. +- 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. - Raw ROM-file loading with original BIOS boot, terminal diagnostics, and final CPU/step reports. - Firmware boot initializes BG2/BG3 identity scales for bitmap and affine programs that do not program their own matrices. - RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request. @@ -147,9 +149,11 @@ Internal affine origins, vertical window flags, and vertical mosaic counters tra Horizontal window comparators retain four-cycle event history. Sprite rows prepare one line ahead with a whole-row cycle40 sample and nominal per-row work limits. Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, and per-pixel color composition remain unimplemented. -ROM windows offer opt-in Darwin audio output with `--audio`. Save writing/persistence remains unavailable. -The [Flash subset](hardware/saves.md) supports explicit device selection, identification, array reads, and banking. -Flash programming, erase, busy polling, wider accesses, unverified aliases, and other save-device protocols remain diagnostic or unimplemented. +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. +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. The core uses deterministic caller time. Desktop RTC runs use host UTC/elapsed time and are not fully deterministic. @@ -171,7 +175,7 @@ The presentation framebuffer is separate from emulated video RAM. All named graphics demos connect desktop input and rendering to the emulated CPU. The default display test remains host-generated. ROM files can run in bounded terminal mode or a window with the original BIOS replacement. ROM windows have no demo-specific startup checks. Input is sampled between bounded execution slices. -Audio is muted by default; `--audio` opens the macOS output device for ROM windows only. Saves and external BIOS loading remain unavailable. +Audio is muted by default; `--audio` opens the macOS output device for ROM windows only. Save files require explicit selection; external BIOS loading remains unavailable. The host output has bounded buffers, linear rate conversion, DC removal, and underrun/drop counters. Slow hosts can stutter. ROM loading and nonzero audio callbacks do not establish commercial-game compatibility or correct music. A local Emerald run now completes its all-device reset and master sound enable. @@ -186,7 +190,7 @@ That run used no scripted input or pixel assertions. A later [fixed-time input probe](research/emerald-wave.md) found a wave trigger with its gate disabled. Wave support now passes that point and reaches 1,320 frames with scheduled Start/A presses and releases. Output differs from a no-input control, but readable menus and gameplay remain unverified. -Flash writing and persistence remain unimplemented. +Flash writing and host persistence now pass original save/restart/load tests. Emerald saving and reloading remain unverified. Without `--rtc`, cartridge GPIO writes still return read-only diagnostics. No title-screen, gameplay, or audible-game compatibility is established. See [the startup result](research/emerald-reset.md) for the bounded run and next investigation. diff --git a/docs/testing.md b/docs/testing.md index 0fe12c4..052cc7a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -74,10 +74,24 @@ Desktop tests execute original ARM calendar commands in terminal and native-wind Host-adapter tests inject UTC epoch values and elapsed durations without sleeping. Guest time changes survive subsequent elapsed updates. Physical pin timing, invalid-date hardware correction, GPIO aliases, and battery persistence remain unverified or unimplemented. Flash regressions use original command sequences and exact-size generated arrays, not game saves. -They check device IDs, every byte in both 128 KiB banks, erased state, image validation, reset limits, and unsupported program/erase commands. +They check device IDs, bank addressing, erased state, image validation, and bounded reset behavior. +Program/erase tests cover all data bytes, sector/chip scope, nominal delays, DQ7 polling, bit transitions, HALT, and STOP. +Ordered ARM/Thumb probes verify completion reads, store-phase start, speculative timing, and array/modification rollback. ARM/Thumb probes verify RAM byte reads, ROM command writes, all save wait settings, staged ID state, and failed-step rollback. Wide accesses, DMA, instruction fetches, and CPU reads outside work RAM retain explicit diagnostics. -An original desktop ROM installs its Flash byte reader in IWRAM; terminal/native tests check explicit selection and unchanged files. +An original desktop ROM installs its Flash byte reader in IWRAM; terminal/native tests check explicit selection and unchanged ROMs. +Separate processes program, persist, restart, and read the image through that reader. +Host tests reject incomplete operations, diagnostic exits, wrong sizes, aliases, read-only paths, lock conflicts, and external changes. +They also verify unique retained backups, unchanged-image no-ops, and cleanup. All images are generated in temporary directories. + +The save-window check requires a logged-in desktop session, but no physical input or audio device: + +```sh +direnv exec . cargo test --locked --release -p gameboy-rust-desktop --test save_cli native_rom_window_save -- --ignored --test-threads=1 +``` + +That check validates frame-limit persistence and a separate terminal reload on Darwin arm64. +It does not validate external-game saves or crash/power-loss durability. They verify explicit activation errors and atomic byte/word/block-store/DMA failure behavior. Wave tests cover every frequency and bank, independent nibble rotation, full rotations, bounded maximum batches, and fractional gains. They also check inactive triggers, gates, length, status, master/BIOS reset, HALT/STOP, live-write limits, and CPU/DMA rollback. diff --git a/docs/usability-roadmap.md b/docs/usability-roadmap.md index 6e46980..0c82f07 100644 --- a/docs/usability-roadmap.md +++ b/docs/usability-roadmap.md @@ -52,7 +52,8 @@ Fixed-rate stereo capture and opt-in Darwin `--audio` output now work with origi A local Emerald run submits nonzero device frames but records underruns; listening quality and correct music remain unverified. Two-bank wave playback, hardware PWM sampling, and improved reconstruction/pacing remain missing. See [the runtime result](research/emerald-reset.md). Next, verify visible menu progress rather than treating frame counts as playability. -The separate save requirement includes Flash programming/erase and safe host persistence. Identification/read support is not successful saving. +Nominal Flash programming/erase and explicit clean-exit persistence now pass original save/restart/load tests across processes. +A native-window save also reloads through a separate process on Darwin arm64. External-game saving remains unverified. SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols. [Paperdomo101/2048-GBA at ae36800d](https://github.com/Paperdomo101/2048-GBA/tree/ae36800dfa1314f96c4c8785b5c2d077fb731aff) is the next independent candidate. @@ -65,11 +66,10 @@ Next implementation steps: 1. Inspect visible startup/menu progress and exercise input in the bounded local game run. Record the first demonstrated failure. Use original regressions for any hardware correction; do not invent connected serial devices. -2. Extend explicit Flash selection with verified programming, erase, and busy behavior. Keep absent devices and unsupported commands diagnostic. - Add SRAM separately when the independent candidate requires it; verify its widths, mirroring, and wait behavior first. -3. Add bounded save loading and safe persistence in the desktop crate. Do not put file operations in the core. -4. Test a save/restart/load cycle across processes with original ROM instructions before external-game validation. -5. Resume the second-game run and implement demonstrated audio requirements rather than accepting writes without behavior. +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. +4. Resume the second-game run and implement demonstrated audio requirements rather than accepting writes without behavior. ## Development loop