feat(core): add whole-machine save states with F5/F9 window slots

Machine::save_state and load_state encode the CPU, every RAM, and every device
through Snapshot impls into a versioned, checksummed format. Rejected loads leave
the machine unchanged. The ROM window saves to and loads from one state file
with F5 and F9.
This commit is contained in:
0xrsydn 2026-10-08 21:39:16 +07:00
commit fd73788735
55 changed files with 5157 additions and 20 deletions

View file

@ -2,6 +2,7 @@
pub(crate) mod rtc_clock;
pub(crate) mod save_file;
pub(crate) mod state_file;
pub mod suite;
pub mod window;
@ -22,7 +23,7 @@ use gba_core::{
const MAX_STEPS: u64 = 100_000_000;
const MAX_FRAMES: u64 = 100_000;
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER]); speed: integer 1..=16 (default 1, audio muted above 1); 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] [--speed MULTIPLIER] [--state-file PATH]); speed: integer 1..=16 (default 1, audio muted above 1); steps: 1..=100000000, frames: 1..=100000; state file (window only): F5 saves and F9 loads a save state, default is the ROM path with a .state extension; do not combine with demo or help options";
#[derive(Debug, PartialEq, Eq)]
enum Mode {
@ -39,6 +40,7 @@ pub struct Options {
audio: bool,
speed: u32,
save_file: Option<PathBuf>,
state_file: Option<PathBuf>,
}
pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
@ -52,6 +54,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
let mut hardware = CartridgeHardware::None;
let mut save_device = SaveDevice::None;
let mut save_file = None;
let mut state_file = None;
let mut args = args.iter();
while let Some(arg) = args.next() {
if arg == "--rom" && path.is_none() {
@ -72,6 +75,10 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
save_file = Some(PathBuf::from(
args.next().filter(|v| !v.is_empty()).ok_or_else(invalid)?,
));
} else if arg == "--state-file" && state_file.is_none() {
state_file = Some(PathBuf::from(
args.next().filter(|v| !v.is_empty()).ok_or_else(invalid)?,
));
} else if arg == "--speed" && speed.is_none() {
let value = args
.next()
@ -118,7 +125,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
if save_file.is_some() && save_device == SaveDevice::None {
return Err(invalid());
}
if (audio || speed.is_some()) && !window {
if (audio || speed.is_some() || state_file.is_some()) && !window {
return Err(invalid());
}
let mode = match (window, steps, frames) {
@ -132,6 +139,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
hardware,
save_device,
save_file,
state_file,
audio,
speed: speed.unwrap_or(1),
})
@ -340,7 +348,12 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
audio: options.audio,
speed: options.speed,
},
save_file.as_mut(),
crate::desktop::RomPersistence {
save_file: save_file.as_mut(),
state_path: options
.state_file
.unwrap_or_else(|| options.path.with_extension("state")),
},
writer,
)
}

View file

@ -0,0 +1,101 @@
//! Save-state files for the ROM window. The core never touches the file system.
//!
//! A state is replaced atomically: bytes go to a unique sibling file that is
//! synced and renamed over the destination, so an interrupted write leaves the
//! previous state intact.
use std::{
fs::{self, File, OpenOptions},
io::{self, Read, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
/// A state is under a megabyte. Refuse anything far larger before reading it.
const MAX_STATE_BYTES: u64 = 16 * 1024 * 1024;
fn temporary_sibling(path: &Path) -> io::Result<PathBuf> {
static NEXT: AtomicU64 = AtomicU64::new(0);
let name = path.file_name().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "state path has no file name")
})?;
let mut name = name.to_os_string();
name.push(format!(
".tmp.{}.{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
Ok(path.with_file_name(name))
}
pub(crate) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
let temporary = temporary_sibling(path)?;
let result = (|| {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&temporary)?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
fs::rename(&temporary, path)
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
pub(crate) fn read(path: &Path) -> io::Result<Vec<u8>> {
let file = File::open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(io::Error::other("state path must be a regular file"));
}
if metadata.len() > MAX_STATE_BYTES {
return Err(io::Error::other("state file is too large"));
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take(MAX_STATE_BYTES + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_STATE_BYTES {
return Err(io::Error::other("state file grew while reading"));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let directory =
std::env::temp_dir().join(format!("gba-state-file-{name}-{}", std::process::id()));
fs::create_dir_all(&directory).unwrap();
directory.join("slot.state")
}
#[test]
fn write_replaces_the_previous_state_and_leaves_no_temporary_files() {
let path = scratch("replace");
write(&path, b"first").unwrap();
write(&path, b"second state").unwrap();
assert_eq!(read(&path).unwrap(), b"second state");
let leftovers = fs::read_dir(path.parent().unwrap()).unwrap().count();
assert_eq!(leftovers, 1);
fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
#[test]
fn missing_and_oversized_files_are_errors() {
let path = scratch("errors");
assert_eq!(read(&path).unwrap_err().kind(), io::ErrorKind::NotFound);
let file = File::create(&path).unwrap();
file.set_len(MAX_STATE_BYTES + 1).unwrap();
assert!(read(&path).is_err());
fs::remove_dir_all(path.parent().unwrap()).unwrap();
}
}

View file

@ -30,6 +30,7 @@ fn options_require_both_flags_in_either_order() {
hardware: CartridgeHardware::None,
save_device: SaveDevice::None,
save_file: None,
state_file: None,
audio: false,
speed: 1,
}
@ -136,6 +137,41 @@ fn speed_rejects_invalid_values_duplicate_flags_and_nonwindow_modes() {
}
}
#[test]
fn state_file_is_a_nonempty_window_only_path() {
let accepted = parse_args(&args(&[
"--state-file",
"slot.state",
"--rom",
"test.gba",
"--window",
]))
.unwrap();
assert_eq!(accepted.state_file, Some(PathBuf::from("slot.state")));
assert_eq!(
parse_args(&args(&["--rom", "test.gba", "--window"]))
.unwrap()
.state_file,
None
);
for arguments in [
vec!["--rom", "test.gba", "--steps", "100", "--state-file", "a"],
vec!["--rom", "test.gba", "--window", "--state-file"],
vec!["--rom", "test.gba", "--window", "--state-file", ""],
vec![
"--rom",
"test.gba",
"--window",
"--state-file",
"a",
"--state-file",
"b",
],
] {
assert!(parse_args(&args(&arguments)).is_err(), "{arguments:?}");
}
}
#[test]
fn audio_is_opt_in_window_only_and_cannot_be_duplicated() {
assert!(

View file

@ -6,7 +6,10 @@ use std::{
io::{self, Write},
};
use gba_core::{bios, input::Buttons, machine::Machine, memory::MemoryError, video::Framebuffer};
use gba_core::{
bios, input::Buttons, machine::Machine, memory::MemoryError, snapshot::SnapshotError,
video::Framebuffer,
};
use super::{write_state, Stats};
@ -62,6 +65,22 @@ impl Session {
self.frames
}
/// Encode the machine at the current step boundary. Host counters are not part of it.
pub fn save_state(&self) -> Result<Vec<u8>, SnapshotError> {
self.machine.save_state()
}
/// Restore a state and show its last captured frame at once. Any error other than
/// `SnapshotError::RestoreFailed` leaves the running machine untouched.
pub fn load_state(&mut self, bytes: &[u8]) -> Result<(), SnapshotError> {
self.machine.load_state(bytes)?;
self.vblank = self.machine.memory().display_position().vblanks;
self.frame_steps = 0;
// A captured frame that failed to render is reported at the next VBlank instead.
let _ = self.machine.memory().present_frame(&mut self.frame);
Ok(())
}
/// Sample input, then advance at most one bounded slice or one VBlank event.
/// STOP preserves clocks and the last image while allowing later input to wake.
/// Diagnostics preserve the last presented image and earlier successful steps.

View file

@ -301,3 +301,26 @@ fn report_contains_capture_count_and_propagates_output_errors() {
io::ErrorKind::BrokenPipe
);
}
#[test]
fn loading_a_state_restores_the_machine_and_shows_its_frame_at_once() {
let mut session = original();
let pressed = Buttons::default().with(Button::A, true);
next_frame(&mut session, pressed);
next_frame(&mut session, pressed);
assert_color(&session, 0x001f);
let state = session.save_state().unwrap();
next_frame(&mut session, Buttons::default());
assert_color(&session, 0x7c00);
session.load_state(&state).unwrap();
assert_color(&session, 0x001f);
assert_eq!(session.save_state().unwrap(), state);
next_frame(&mut session, Buttons::default());
assert_color(&session, 0x7c00);
// A rejected state is an error and leaves the running session untouched.
let before = session.save_state().unwrap();
assert!(session.load_state(b"not a save state at all").is_err());
assert_eq!(session.save_state().unwrap(), before);
}

View file

@ -13,7 +13,7 @@ pub use bitmap::run as run_bitmap;
pub use effects::run as run_effects;
pub use mosaic::run as run_mosaic;
pub use raster::run as run_raster;
pub use rom::{run as run_rom, Playback as RomPlayback};
pub use rom::{run as run_rom, Persistence as RomPersistence, Playback as RomPlayback};
use std::{
error::Error,

View file

@ -1,9 +1,16 @@
//! Main-thread ROM presentation, physical keyboard input, and optional host audio.
use std::io::Write;
use std::{
io::Write,
path::{Path, PathBuf},
};
use super::*;
use crate::cartridge::window::{Session, Update};
use crate::cartridge::{
state_file,
window::{Session, Update},
};
use gba_core::snapshot::SnapshotError;
/// Host-only playback policy. The CLI validates integer speeds from 1 through 16.
pub struct Playback {
@ -11,6 +18,12 @@ pub struct Playback {
pub speed: u32,
}
/// Host files for one ROM window: the optional Flash save and the save-state slot.
pub struct Persistence<'a> {
pub save_file: Option<&'a mut crate::cartridge::save_file::SaveFile>,
pub state_path: PathBuf,
}
impl Playback {
fn audio_enabled(&self) -> bool {
self.audio && self.speed == 1
@ -23,6 +36,49 @@ impl Playback {
}
}
/// Write the current state to `path`. A failed save is reported and play continues.
fn save_state_slot(session: &Session, path: &Path, writer: &mut impl Write) -> io::Result<()> {
let saved = session
.save_state()
.map_err(|error| error.to_string())
.and_then(|bytes| {
state_file::write(path, &bytes)
.map(|()| bytes.len())
.map_err(|error| error.to_string())
});
match saved {
Ok(length) => writeln!(writer, "State saved: {path:?} ({length} bytes)"),
Err(error) => writeln!(writer, "State not saved: {error}"),
}
}
/// Restore `path`. Returns whether the state was loaded. Rejected states leave the
/// machine running unchanged; only a failure after restoration began is fatal.
fn load_state_slot(
session: &mut Session,
path: &Path,
writer: &mut impl Write,
) -> Result<bool, Box<dyn Error>> {
let bytes = match state_file::read(path) {
Ok(bytes) => bytes,
Err(error) => {
writeln!(writer, "State not loaded: {path:?}: {error}")?;
return Ok(false);
}
};
match session.load_state(&bytes) {
Ok(()) => {
writeln!(writer, "State loaded: {path:?}")?;
Ok(true)
}
Err(error @ SnapshotError::RestoreFailed(_)) => Err(Box::new(error)),
Err(error) => {
writeln!(writer, "State not loaded: {error}")?;
Ok(false)
}
}
}
fn next_frame_start(previous: Instant, now: Instant, period: Duration, audio: bool) -> Instant {
let target = previous + period;
if audio && now.saturating_duration_since(target) <= period {
@ -38,9 +94,13 @@ pub fn run(
hardware: gba_core::cartridge::CartridgeHardware,
save_device: gba_core::cartridge::SaveDevice,
playback: Playback,
save_file: Option<&mut crate::cartridge::save_file::SaveFile>,
persistence: Persistence<'_>,
writer: &mut impl Write,
) -> Result<(), Box<dyn Error>> {
let Persistence {
save_file,
state_path,
} = persistence;
let audio_enabled = playback.audio_enabled();
let mut session = Session::new(bytes)?;
session.set_cartridge_hardware(hardware);
@ -57,11 +117,11 @@ pub fn run(
};
writeln!(
writer,
"ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits."
"ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; F5=save state; F9=load state; Escape exits."
)?;
writeln!(
writer,
"Input releases and audio mutes on focus loss. Save files require --save-file and clean exit. Startup frames are not suppressed."
"Input releases and audio mutes on focus loss. Save files require --save-file and clean exit; state file: {state_path:?}. Startup frames are not suppressed."
)?;
writeln!(
writer,
@ -114,6 +174,19 @@ pub fn run(
let reported_active = window.is_active();
let focused = is_focused(reported_active);
let buttons = read_buttons(reported_active, |key| window.is_key_down(key));
if focused && window.is_key_pressed(Key::F5, KeyRepeat::No) {
save_state_slot(&session, &state_path, writer)?;
writer.flush()?;
}
if focused
&& window.is_key_pressed(Key::F9, KeyRepeat::No)
&& load_state_slot(&mut session, &state_path, writer)?
{
writer.flush()?;
if let Some(output) = audio.as_ref() {
output.clear()?;
}
}
let update = session.update(buttons)?;
let stopped = update == Update::Stopped;
if let Some(output) = audio.as_ref() {

View file

@ -58,6 +58,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
"--speed",
"--save-type",
"--save-file",
"--state-file",
]
.iter()
.any(|flag| arg == flag)
@ -90,7 +91,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER]); 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] [--speed MULTIPLIER] [--state-file PATH]); or gameboy-rust --test-suite PATH.json",
)),
}
}
@ -141,6 +142,7 @@ fn run() -> Result<(), Box<dyn Error>> {
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
--save-type flash64|flash128 Select nominal Macronix Flash emulation\n\
--save-file PATH Load raw Flash image; persist only on clean exit\n\
--state-file PATH ROM window save state; F5 saves, F9 loads (default: ROM path + .state)\n\
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
--cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\
--timer-demo Run the timer IRQ demo with nominal cycle costs\n\
@ -175,7 +177,7 @@ fn run() -> Result<(), Box<dyn Error>> {
Bitmap pages: Arrows pan; Q rotates; W zooms; Z forces page1; Enter resets; Escape exits.\n\
All demos use original test content. ROM mode uses our limited BIOS replacement.\n\
Terminal ROM mode has no window, input, or audio. ROM windows offer --audio on macOS. 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; F5=save state; F9=load state; Escape exits.\n\
Commercial games are not supported."
);
Ok(())