feat(desktop): add safe explicit Flash save-file persistence
This commit is contained in:
parent
8ded9bc90e
commit
86f29018fd
17 changed files with 1032 additions and 133 deletions
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
275
crates/desktop/src/cartridge/save_file.rs
Normal file
275
crates/desktop/src/cartridge/save_file.rs
Normal 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(¤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<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 }))
|
||||
}
|
||||
}
|
||||
201
crates/desktop/src/cartridge/save_file/tests.rs
Normal file
201
crates/desktop/src/cartridge/save_file/tests.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
226
crates/desktop/tests/save_cli.rs
Normal file
226
crates/desktop/tests/save_cli.rs
Normal 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.")));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue