feat(desktop): add safe explicit Flash save-file persistence

This commit is contained in:
0xrsydn 2026-10-07 08:25:12 +07:00
commit 86f29018fd
17 changed files with 1032 additions and 133 deletions

View file

@ -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<PathBuf>,
}
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
@ -47,6 +49,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
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<Options> {
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<Options> {
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<Options> {
mode,
hardware,
save_device,
save_file,
audio,
})
}
@ -282,7 +293,24 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
writeln!(writer, "RTC selected: UTC at startup, then host elapsed time; no persistence or RTC interrupts.")?;
}
if options.save_device != SaveDevice::None {
writeln!(writer, "Flash selected: {} KiB Macronix, erased array; identification/bank reads only. No programming, erase, or save files.", options.save_device.capacity() / 1024)?;
writeln!(
writer,
"Flash selected: {} KiB Macronix; nominal program/erase timing and DQ7 polling.",
options.save_device.capacity() / 1024
)?;
}
let mut save_file = options
.save_file
.as_ref()
.map(|path| save_file::SaveFile::open(path, &options.path, options.save_device))
.transpose()?;
if let Some(save) = save_file.as_ref() {
writeln!(writer, "Save file: {}; writes only on clean exit, with unique backups when replacing existing data.", if save.loaded() { "loaded" } else { "new erased image" })?;
} else if options.save_device != SaveDevice::None {
writeln!(
writer,
"Save storage is volatile; use --save-file PATH for persistence."
)?;
}
let steps = match options.mode {
Mode::Terminal { steps } => steps,
@ -293,6 +321,7 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
options.hardware,
options.save_device,
options.audio,
save_file.as_mut(),
writer,
)
}
@ -303,6 +332,9 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
.memory_mut()
.set_cartridge_hardware(options.hardware);
machine.memory_mut().set_save_device(options.save_device);
if let Some(save) = save_file.as_ref() {
save.initialize(machine.memory_mut())?;
}
let mut clock = if options.hardware == CartridgeHardware::Rtc {
Some(rtc_clock::RtcHostClock::new(machine.memory_mut())?)
} else {
@ -314,6 +346,25 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
if let Reason::Diagnostic(error) = report.reason {
return Err(Box::new(error));
}
if let Some(save) = save_file.as_mut() {
persist_save(save, machine.memory(), writer)?;
}
Ok(())
}
pub(crate) fn persist_save(
save: &mut save_file::SaveFile,
memory: &gba_core::memory::Memory,
writer: &mut impl Write,
) -> 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(())
}

View file

@ -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<File> {
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<Option<Vec<u8>>> {
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(&current, &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<PathBuf>,
}
pub(crate) struct SaveFile {
path: PathBuf,
original: Option<Vec<u8>>,
size: usize,
_lease: Lease,
}
impl SaveFile {
pub(crate) fn open(path: &Path, rom: &Path, device: SaveDevice) -> io::Result<Self> {
#[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<Option<Persisted>> {
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 }))
}
}

View file

@ -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> {
SaveFile::open(&self.save, &self.rom, SaveDevice::Flash128)
}
fn image(&self, value: u8) -> Vec<u8> {
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();
}

View file

@ -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!(

View file

@ -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<dyn Error>> {
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<dyn Error>> {
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)]

View file

@ -56,6 +56,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
"--rtc",
"--audio",
"--save-type",
"--save-file",
]
.iter()
.any(|flag| arg == flag)
@ -88,7 +89,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] (--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<dyn Error>> {
--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<dyn Error>> {
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."
);

View file

@ -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"));

View file

@ -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::<Vec<_>>(),
)
.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<u32>, register: u32, value: u32) {
code.extend([0xe59f0000 | register << 12, 0xea000000, value]);
}
fn writer(value: u8, fail: bool) -> Vec<u32> {
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<u32> {
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.")));
}