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
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,5 +5,8 @@
|
||||||
*.gba
|
*.gba
|
||||||
*.nds
|
*.nds
|
||||||
*.sav
|
*.sav
|
||||||
|
*.sav.lock
|
||||||
|
*.sav.tmp.*
|
||||||
|
*.sav.bak.*
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
//! Host-only raw ROM loading, execution modes, and diagnostics.
|
//! Host-only raw ROM loading, execution modes, and diagnostics.
|
||||||
|
|
||||||
pub(crate) mod rtc_clock;
|
pub(crate) mod rtc_clock;
|
||||||
|
pub(crate) mod save_file;
|
||||||
pub mod suite;
|
pub mod suite;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@ use gba_core::{
|
||||||
|
|
||||||
const MAX_STEPS: u64 = 100_000_000;
|
const MAX_STEPS: u64 = 100_000_000;
|
||||||
const MAX_FRAMES: u64 = 100_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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum Mode {
|
enum Mode {
|
||||||
|
|
@ -36,6 +37,7 @@ pub struct Options {
|
||||||
hardware: CartridgeHardware,
|
hardware: CartridgeHardware,
|
||||||
save_device: SaveDevice,
|
save_device: SaveDevice,
|
||||||
audio: bool,
|
audio: bool,
|
||||||
|
save_file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
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 audio = false;
|
||||||
let mut hardware = CartridgeHardware::None;
|
let mut hardware = CartridgeHardware::None;
|
||||||
let mut save_device = SaveDevice::None;
|
let mut save_device = SaveDevice::None;
|
||||||
|
let mut save_file = None;
|
||||||
let mut args = args.iter();
|
let mut args = args.iter();
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
if arg == "--rom" && path.is_none() {
|
if arg == "--rom" && path.is_none() {
|
||||||
|
|
@ -63,6 +66,10 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
||||||
Some("flash128") => SaveDevice::Flash128,
|
Some("flash128") => SaveDevice::Flash128,
|
||||||
_ => return Err(invalid()),
|
_ => 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 {
|
} else if arg == "--audio" && !audio {
|
||||||
audio = true;
|
audio = true;
|
||||||
} else if arg == "--window" && !window {
|
} else if arg == "--window" && !window {
|
||||||
|
|
@ -93,6 +100,9 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
||||||
return Err(invalid());
|
return Err(invalid());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if save_file.is_some() && save_device == SaveDevice::None {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
if audio && !window {
|
if audio && !window {
|
||||||
return Err(invalid());
|
return Err(invalid());
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +116,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
||||||
mode,
|
mode,
|
||||||
hardware,
|
hardware,
|
||||||
save_device,
|
save_device,
|
||||||
|
save_file,
|
||||||
audio,
|
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.")?;
|
writeln!(writer, "RTC selected: UTC at startup, then host elapsed time; no persistence or RTC interrupts.")?;
|
||||||
}
|
}
|
||||||
if options.save_device != SaveDevice::None {
|
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 {
|
let steps = match options.mode {
|
||||||
Mode::Terminal { steps } => steps,
|
Mode::Terminal { steps } => steps,
|
||||||
|
|
@ -293,6 +321,7 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
|
||||||
options.hardware,
|
options.hardware,
|
||||||
options.save_device,
|
options.save_device,
|
||||||
options.audio,
|
options.audio,
|
||||||
|
save_file.as_mut(),
|
||||||
writer,
|
writer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -303,6 +332,9 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
|
||||||
.memory_mut()
|
.memory_mut()
|
||||||
.set_cartridge_hardware(options.hardware);
|
.set_cartridge_hardware(options.hardware);
|
||||||
machine.memory_mut().set_save_device(options.save_device);
|
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 {
|
let mut clock = if options.hardware == CartridgeHardware::Rtc {
|
||||||
Some(rtc_clock::RtcHostClock::new(machine.memory_mut())?)
|
Some(rtc_clock::RtcHostClock::new(machine.memory_mut())?)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -314,6 +346,25 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
|
||||||
if let Reason::Diagnostic(error) = report.reason {
|
if let Reason::Diagnostic(error) = report.reason {
|
||||||
return Err(Box::new(error));
|
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(())
|
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 },
|
mode: Mode::Terminal { steps: count },
|
||||||
hardware: CartridgeHardware::None,
|
hardware: CartridgeHardware::None,
|
||||||
save_device: SaveDevice::None,
|
save_device: SaveDevice::None,
|
||||||
|
save_file: None,
|
||||||
audio: false,
|
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]
|
#[test]
|
||||||
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
|
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,15 @@ pub fn run(
|
||||||
hardware: gba_core::cartridge::CartridgeHardware,
|
hardware: gba_core::cartridge::CartridgeHardware,
|
||||||
save_device: gba_core::cartridge::SaveDevice,
|
save_device: gba_core::cartridge::SaveDevice,
|
||||||
audio_enabled: bool,
|
audio_enabled: bool,
|
||||||
|
save_file: Option<&mut crate::cartridge::save_file::SaveFile>,
|
||||||
writer: &mut impl Write,
|
writer: &mut impl Write,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
let mut session = Session::new(bytes)?;
|
let mut session = Session::new(bytes)?;
|
||||||
session.set_cartridge_hardware(hardware);
|
session.set_cartridge_hardware(hardware);
|
||||||
session.memory_mut().set_save_device(save_device);
|
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 {
|
let mut rtc_clock = if hardware == gba_core::cartridge::CartridgeHardware::Rtc {
|
||||||
Some(crate::cartridge::rtc_clock::RtcHostClock::new(
|
Some(crate::cartridge::rtc_clock::RtcHostClock::new(
|
||||||
session.memory_mut(),
|
session.memory_mut(),
|
||||||
|
|
@ -38,7 +42,7 @@ pub fn run(
|
||||||
)?;
|
)?;
|
||||||
writeln!(
|
writeln!(
|
||||||
writer,
|
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 {
|
if let Some(limit) = frame_limit {
|
||||||
writeln!(
|
writeln!(
|
||||||
|
|
@ -50,7 +54,7 @@ pub fn run(
|
||||||
let mut audio = None;
|
let mut audio = None;
|
||||||
let mut samples = [gba_core::audio::StereoLevel::default(); 1024];
|
let mut samples = [gba_core::audio::StereoLevel::default(); 1024];
|
||||||
let result = (|| -> Result<&str, Box<dyn Error>> {
|
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)?;
|
let mut window = create_window(title)?;
|
||||||
window.set_target_fps(0);
|
window.set_target_fps(0);
|
||||||
if audio_enabled {
|
if audio_enabled {
|
||||||
|
|
@ -144,7 +148,12 @@ pub fn run(
|
||||||
if let Some(output) = audio.as_ref() {
|
if let Some(output) = audio.as_ref() {
|
||||||
output.report(writer)?;
|
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)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
|
||||||
"--rtc",
|
"--rtc",
|
||||||
"--audio",
|
"--audio",
|
||||||
"--save-type",
|
"--save-type",
|
||||||
|
"--save-file",
|
||||||
]
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|flag| arg == flag)
|
.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),
|
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
|
||||||
_ => Err(io::Error::new(
|
_ => Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
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\
|
--frames COUNT Exit ROM window after 1..=100000 captured frames\n\
|
||||||
--audio Enable macOS output for ROM windows (default: muted)\n\
|
--audio Enable macOS output for ROM windows (default: muted)\n\
|
||||||
--rtc Select cartridge RTC (UTC startup, host elapsed time)\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\
|
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
|
||||||
--cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\
|
--cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\
|
||||||
--timer-demo Run the timer IRQ demo with nominal cycle costs\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\
|
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\
|
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\
|
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\
|
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."
|
Commercial games are not supported."
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -278,7 +278,7 @@ fn explicit_flash_selection_runs_ram_byte_reader_and_leaves_files_unchanged() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(output.status.success(), "{}", stderr(&output));
|
assert!(output.status.success(), "{}", stderr(&output));
|
||||||
let text = stdout(&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("r5=0x000000c2"), "{text}");
|
||||||
assert!(text.contains(id), "{text}");
|
assert!(text.contains(id), "{text}");
|
||||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
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!(output.status.success());
|
||||||
assert!(stdout(&output).contains("--rom PATH --steps COUNT"));
|
assert!(stdout(&output).contains("--rom PATH --steps COUNT"));
|
||||||
assert!(stdout(&output).contains("--rom PATH --window"));
|
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();
|
let output = command().arg("--cpu-demo").output().unwrap();
|
||||||
assert!(output.status.success());
|
assert!(output.status.success());
|
||||||
assert!(stdout(&output).contains("Exception demo"));
|
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.")));
|
||||||
|
}
|
||||||
|
|
@ -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/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/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.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.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/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/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 |
|
| `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/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/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/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/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.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 |
|
| `crates/desktop/src/cartridge/window/tests.rs` | Original ROM pixels, keyboard mapping, STOP wake, HALT, frame budgets, and diagnostics |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
The dependency is pinned to that exact version. Review this workaround before upgrading minifb.
|
||||||
Intel macOS and Linux desktop builds are not verified.
|
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:
|
Without direnv, use:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,10 @@ Headerless original programs are accepted.
|
||||||
Reads past supplied cartridge bytes remain explicit memory errors, not open-bus values.
|
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.
|
All three Game Pak ROM windows use the same supplied bytes and existing wait-state rules.
|
||||||
Ordinary cartridge bytes remain read-only.
|
Ordinary cartridge bytes remain read-only.
|
||||||
Optional `--save-type flash64` or `--save-type flash128` enables [Flash identification and array reads](saves.md).
|
Optional `--save-type flash64` or `--save-type flash128` enables [nominal Flash programming, erase, and reads](saves.md).
|
||||||
The desktop starts an erased device and does not load or write save files. Programming and erase remain diagnostic.
|
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).
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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).
|
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.
|
Commercial-game compatibility remains unverified and unsupported.
|
||||||
Only load files that you may lawfully use. Do not commit game ROMs or firmware.
|
Only load files that you may lawfully use. Do not commit game ROMs or firmware.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
The core supports explicit Macronix Flash selection, identification, bank selection, byte programming, and sector/chip erase.
|
||||||
Programming, erase, busy polling, and host save-file persistence remain unimplemented.
|
Operations complete after nominal delays and expose bounded data polling. They do not complete immediately on the command write.
|
||||||
This is not working game-save support. Unsupported writes fail instead of reporting a completed save.
|
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.
|
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.
|
Use `--save-type flash64` or `--save-type flash128` in terminal or window ROM modes.
|
||||||
The option is independent of `--rtc`. Omitting it leaves save memory unmapped.
|
Selection is independent of `--rtc`. Omitting the save type leaves save memory unmapped.
|
||||||
Unknown values and duplicate options fail before opening the ROM.
|
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.
|
||||||
```sh
|
The ROM suite schema still has no save-selection or save-file field.
|
||||||
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.
|
|
||||||
|
|
||||||
Core APIs on `Memory`:
|
Core APIs on `Memory`:
|
||||||
|
|
||||||
- `set_save_device(SaveDevice)` replaces save hardware and initializes its array to erased bytes.
|
- `set_save_device(SaveDevice)` selects `None`, `Flash64`, or `Flash128` and initializes an erased array.
|
||||||
`None`, `Flash64`, and `Flash128` are available. The call preserves GPIO/RTC state.
|
GPIO/RTC state is preserved.
|
||||||
- `load_save_image(&[u8])` supplies an exact-capacity image and resets command state, ID mode, and bank selection.
|
- `load_save_image(&[u8])` accepts exactly the selected capacity. It resets commands, pending operations, ID mode, bank, and modification tracking.
|
||||||
A wrong length or missing device changes nothing. This is host setup, not an emulated program operation.
|
Invalid size or missing hardware changes nothing.
|
||||||
- `save_image()` inspects the complete array, independently of ID mode or selected bank.
|
- `save_image()` inspects the full committed array, independent of bank and ID mode. No selected device returns `None`.
|
||||||
It returns `None` when no save device is selected.
|
- `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.
|
Configuration, loading, and host inspection occur between machine steps.
|
||||||
Selecting GPIO/RTC hardware does not replace an attached save device or its bytes.
|
The core has no file, clock, window, or host-audio dependency. Frontends supply image bytes and own durable storage.
|
||||||
This feature can inspect caller-supplied images but cannot modify their data through Flash commands.
|
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.
|
Only `0x0e000000` through `0x0e00ffff` are mapped. Accesses are byte-only.
|
||||||
The Flash interface uses byte accesses. Halfword/word transfers, DMA, and instruction fetches remain diagnostic.
|
Halfword/word transfers, DMA, instruction fetches, and unverified aliases remain diagnostic.
|
||||||
Unverified aliases outside this window remain unmapped rather than returning mirrored data.
|
CPU Flash reads must execute from external or internal work RAM, following GBATEK's access description.
|
||||||
Ordinary ROM writes remain read-only.
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
WAITCNT bits 0–1 select save wait states 4, 3, 2, or 8.
|
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.
|
Nominal byte-access costs are therefore 5, 4, 3, or 9 cycles for sequential and non-sequential accesses.
|
||||||
Save waits do not alter ROM wait fields. Flash data accesses cancel Game Pak opcode prefetch through the existing nominal model.
|
Flash data accesses cancel Game Pak opcode prefetch. Changing save wait bits alone preserves the queue.
|
||||||
Changing only save wait bits preserves the queue until a cartridge access cancels it.
|
These costs are source-backed estimates, not physical measurements.
|
||||||
These costs are source-backed estimates, not physical timing measurements.
|
|
||||||
|
|
||||||
## Commands
|
The unlock prefix consists of these byte writes:
|
||||||
|
|
||||||
Use these byte addresses and values for the unlock prefix:
|
|
||||||
|
|
||||||
1. Write `0xaa` to `0x0e005555`.
|
1. Write `0xaa` to `0x0e005555`.
|
||||||
2. Write `0x55` to `0x0e002aaa`.
|
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 after the unlock prefix |
|
||||||
|
|
||||||
| Command | Behavior |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `0x90` | Enter chip-identification mode |
|
| `0x90` | Enter chip identification |
|
||||||
| `0xf0` | Exit identification mode and return to array reads |
|
| `0xf0` | Exit identification and return to array reads |
|
||||||
| `0xb0` | On Flash128 only, accept a bank byte next at `0x0e000000` |
|
| `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` |
|
| Flash64 | 64 KiB | `0xc2` | `0x1c` |
|
||||||
| Flash128 | `0xc2` | `0x09` | `0x09c2` |
|
| Flash128 | 128 KiB | `0xc2` | `0x09` |
|
||||||
|
|
||||||
Other ID-mode addresses remain diagnostic because their values are not established by this subset.
|
Identification exposes these bytes at offsets zero and one. Other ID addresses remain diagnostic.
|
||||||
Array mode reads bytes from the selected 64 KiB bank. Flash64 always uses bank zero.
|
Bank selection survives ID entry/exit; incomplete unlock reads retain the previous mode and bank.
|
||||||
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.
|
|
||||||
|
|
||||||
For the documented 64 KiB Macronix device, a direct `0xf0` write at `0x0e005555` resets command/ID state.
|
For Flash64, direct `0xf0` at `0x0e005555` resets commands/ID state or cancels a busy operation.
|
||||||
For Flash128, the same write is accepted only as a redundant reset when already idle in array mode.
|
Cancellation preserves the old array. Partial physical programming on cancellation is not modeled.
|
||||||
This bounded no-op matches the inspected emulators' idle behavior. It does not establish direct ID exit or busy-command cancellation.
|
After `0xa0`, `0xf0` at that address is program data, not reset.
|
||||||
Flash128 still requires the full unlock prefix to exit ID mode. Direct reset during an incomplete unlock remains diagnostic.
|
For Flash128, direct reset is accepted only as a redundant no-op while idle in array mode.
|
||||||
No matching manufacturer datasheet or physical measurement was used to claim broader 128 KiB reset behavior.
|
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.
|
## Nominal completion and transactions
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
While busy, reads are accepted only at the operation's polling address:
|
||||||
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.
|
|
||||||
|
|
||||||
Original tests cover both device IDs, every byte in both banks, exact image lengths, erased state, invalid sequences, and unchanged ROM/save data.
|
- Program: the target byte.
|
||||||
They distinguish redundant idle reset from unsupported Flash128 ID/partial-command reset.
|
- Sector erase: the selected sector base.
|
||||||
ARM/Thumb probes verify ROM command writes, RAM byte reads, wait settings, restrictions, and retryable failed steps.
|
- Chip erase: `0x0e000000`.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
A program starts at the successful byte-store bus phase, after preceding fetch/data clocks.
|
||||||
Then add explicit host save loading and safe persistence, with exact-size validation and restart tests.
|
The small controller and remaining delay participate in the machine-step transaction.
|
||||||
Do not overwrite existing files or add immediate-success responses merely to pass a game check.
|
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.<pid>.<counter>` file.
|
||||||
|
4. Before replacement, write and sync the prior bytes to a unique `.bak.<pid>.<counter>` 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
|
## 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 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 wait fields and one-cycle-plus-wait timing.
|
- [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, and idle reset handling. Its default chip IDs differ; its write/erase timing and busy approximation are not copied.
|
- [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 unchanged idle state after an unrecognized direct reset. Its immediate program/erase behavior and permissive unlock handling are not adopted.
|
- [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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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`).
|
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.
|
The initial [Flash subset](../hardware/saves.md) added identification, array reads, and bank selection.
|
||||||
Select `--save-type flash128` for this bounded run; the device begins erased and no save file is opened.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
[GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm) identifies the `0xaa`/`0x5555` unlock and chip-identification sequence.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- Explicit Macronix Flash64/Flash128 identification, bank selection, delayed byte programming, sector/chip erase, and nominal DQ7 polling.
|
||||||
- Exact-size core save-image initialization and inspection; desktop save selection starts erased and performs no save-file access.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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.
|
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.
|
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.
|
ROM windows offer opt-in Darwin audio output with `--audio` and explicit save persistence with `--save-file`.
|
||||||
The [Flash subset](hardware/saves.md) supports explicit device selection, identification, array reads, and banking.
|
The [Flash subset](hardware/saves.md) supports programming and erase with nominal delays and bounded DQ7 polling.
|
||||||
Flash programming, erase, busy polling, wider accesses, unverified aliases, and other save-device protocols remain diagnostic or unimplemented.
|
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.
|
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.
|
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.
|
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.
|
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 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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
Without `--rtc`, cartridge GPIO writes still return read-only diagnostics.
|
||||||
No title-screen, gameplay, or audible-game compatibility is established.
|
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.
|
See [the startup result](research/emerald-reset.md) for the bounded run and next investigation.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
They also check inactive triggers, gates, length, status, master/BIOS reset, HALT/STOP, live-write limits, and CPU/DMA rollback.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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.
|
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.
|
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.
|
[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.
|
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.
|
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.
|
2. Validate an external game's completed save/restart/load cycle using a disposable `--save-file` path.
|
||||||
Add SRAM separately when the independent candidate requires it; verify its widths, mirroring, and wait behavior first.
|
Refine nominal Flash timing and polling only with independent evidence and original regressions.
|
||||||
3. Add bounded save loading and safe persistence in the desktop crate. Do not put file operations in the core.
|
3. Add SRAM separately when the independent candidate requires it; verify widths, mirroring, and waits first.
|
||||||
4. Test a save/restart/load cycle across processes with original ROM instructions before external-game validation.
|
4. Resume the second-game run and implement demonstrated audio requirements rather than accepting writes without behavior.
|
||||||
5. Resume the second-game run and implement demonstrated audio requirements rather than accepting writes without behavior.
|
|
||||||
|
|
||||||
## Development loop
|
## Development loop
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue