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

@ -39,6 +39,7 @@ Window smoke tests (`--*-smoke-test`) need a logged-in desktop session. Details
| ROM test suites, assertions, and JSON reports | [docs/rom-tests.md](docs/rom-tests.md) |
| Jev-assisted diagnostic review and `/jev-gb-debug` | [docs/jev-debugging.md](docs/jev-debugging.md) |
| ROM files, window execution, and terminal diagnostics | [docs/hardware/cartridge.md](docs/hardware/cartridge.md) |
| Save states (`Machine::save_state`, `Snapshot` impls, F5/F9) | [docs/hardware/save-states.md](docs/hardware/save-states.md) |
| Pinned public BIOS read-protection test and firmware limits | [docs/public-bios-tests.md](docs/public-bios-tests.md) |
| BIOS services (SWI) | [docs/hardware/bios.md](docs/hardware/bios.md) |
| Timers, IE/IF/IME, HALT/STOP | [docs/hardware/timers-irq.md](docs/hardware/timers-irq.md) |

View file

@ -52,6 +52,7 @@ Use `--test-suite suite.json` for [repeatable headless ROM assertions](docs/rom-
| [docs/architecture.md](docs/architecture.md) | Crate boundaries and a file-by-file map |
| [docs/hardware/cpu.md](docs/hardware/cpu.md) | ARM/Thumb execution rules, modes, exceptions, and timing |
| [docs/hardware/cartridge.md](docs/hardware/cartridge.md) | ROM loading, window/input controls, original ROM generator, and diagnostics |
| [docs/hardware/save-states.md](docs/hardware/save-states.md) | Whole-machine save states, F5/F9 hotkeys, format, and limits |
| [docs/hardware/bios.md](docs/hardware/bios.md) | The original BIOS replacement and each supported service |
| [docs/hardware/timers-irq.md](docs/hardware/timers-irq.md) | Timers, interrupt registers, HALT/STOP, and power control |
| [docs/hardware/dma.md](docs/hardware/dma.md) | DMA channels, triggers, and timing |

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(())

View file

@ -6,6 +6,7 @@ mod modulation;
mod noise;
mod pulse;
mod wave;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use noise::Noise;
use pulse::Pulse;
use wave::Wave;
@ -326,3 +327,279 @@ impl Audio {
}
}
}
impl Snapshot for StereoLevel {
fn save(&self, writer: &mut Writer) {
let Self { left, right } = self;
writer.tag(b"STLV");
writer.i16(*left);
writer.i16(*right);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"STLV")?;
let Self { left, right } = self;
*left = reader.i16()?;
*right = reader.i16()?;
Ok(())
}
}
impl Snapshot for Fifo {
fn save(&self, writer: &mut Writer) {
let Self {
words,
read,
len,
playback,
remaining,
sample,
} = self;
writer.tag(b"FIF0");
for word in words {
writer.u32(*word);
}
writer.u32(*read as u32);
writer.u32(*len as u32);
writer.u32(*playback);
writer.u8(*remaining);
writer.u8(*sample as u8);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"FIF0")?;
let Self {
words,
read,
len,
playback,
remaining,
sample,
} = self;
for word in words {
*word = reader.u32()?;
}
let read_value = reader.u32()?;
let len_value = reader.u32()?;
*playback = reader.u32()?;
*remaining = reader.u8()?;
*sample = reader.u8()? as i8;
if read_value >= 7 || len_value > 7 || *remaining > 4 {
return Err(SnapshotError::Corrupt("FIFO range"));
}
*read = read_value as usize;
*len = len_value as usize;
Ok(())
}
}
impl Snapshot for Audio {
fn save(&self, writer: &mut Writer) {
let Self {
enabled,
psg,
pulses,
noise,
wave_channel,
sequencer_phase,
next_step,
control,
bias,
wave,
fifo,
requests,
} = self;
writer.tag(b"AUD0");
writer.bool(*enabled);
for register in psg {
writer.u16(*register);
}
for pulse in pulses {
pulse.save(writer);
}
noise.save(writer);
wave_channel.save(writer);
writer.u16(*sequencer_phase);
writer.u8(*next_step);
writer.u16(*control);
writer.u16(*bias);
writer.raw(&wave[0]);
writer.raw(&wave[1]);
for fifo in fifo {
fifo.save(writer);
}
for request in requests {
writer.bool(*request);
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"AUD0")?;
let Self {
enabled,
psg,
pulses,
noise,
wave_channel,
sequencer_phase,
next_step,
control,
bias,
wave,
fifo,
requests,
} = self;
*enabled = reader.bool()?;
for register in psg {
*register = reader.u16()?;
}
for pulse in pulses {
pulse.load(reader)?;
}
noise.load(reader)?;
wave_channel.load(reader)?;
*sequencer_phase = reader.u16()?;
*next_step = reader.u8()?;
*control = reader.u16()?;
*bias = reader.u16()?;
reader.raw_into(&mut wave[0])?;
reader.raw_into(&mut wave[1])?;
for fifo in fifo {
fifo.load(reader)?;
}
for request in requests {
*request = reader.bool()?;
}
if u32::from(*sequencer_phase) >= SEQUENCER_PERIOD
|| *next_step > 7
|| *control & !0x770f != 0
|| *bias & !0xc3fe != 0
{
return Err(SnapshotError::Corrupt("audio range"));
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encode<T: Snapshot>(value: &T) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
fn check_prefixes<T: Snapshot + Default>(bytes: &[u8], mut target: T) {
for end in 0..bytes.len() {
assert!(target.load(&mut Reader::new(&bytes[..end])).is_err());
}
}
#[test]
fn stereo_level_round_trip_and_prefixes() {
let source = StereoLevel {
left: -371,
right: 248,
};
let bytes = encode(&source);
let mut loaded = StereoLevel::default();
loaded.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(loaded, source);
check_prefixes(&bytes, StereoLevel::default());
}
#[test]
fn fifo_round_trip_prefixes_and_invalid_index() {
let source = Fifo {
words: [0x1234_5678, 0x9abc_def0, 3, 4, 5, 6, 7],
read: 5,
len: 6,
playback: 0xabcd_9876,
remaining: 3,
sample: -47,
};
let bytes = encode(&source);
let mut loaded = Fifo::default();
loaded.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(loaded, source);
check_prefixes(&bytes, Fifo::default());
let mut corrupt = bytes;
corrupt[32..36].copy_from_slice(&7u32.to_le_bytes());
assert_eq!(
loaded.load(&mut Reader::new(&corrupt)),
Err(SnapshotError::Corrupt("FIFO range"))
);
}
#[test]
fn audio_round_trip_prefixes_and_invalid_boolean() {
let mut source = Audio {
enabled: true,
psg: [0x1234; 17],
..Audio::default()
};
source.pulses[0].write(0, 0x09, 0);
source.pulses[0].write(2, 0x9d, 0);
source.pulses[0].write(3, 0xa3, 0);
source.pulses[0].write(4, 0x7b, 0);
source.pulses[0].write(5, 0x84, 0);
source.pulses[0].advance(23);
source.pulses[0].clock(2);
source.pulses[1].write(2, 0x5b, 0);
source.pulses[1].write(3, 0x71, 0);
source.pulses[1].write(4, 0x42, 0);
source.pulses[1].write(5, 0x83, 0);
source.pulses[1].advance(11);
source.noise.write(0, 5, 0);
source.noise.write(1, 0xa3, 0);
source.noise.write(4, 0x39, 0);
source.noise.write(5, 0x80, 0);
source.noise.advance(3_601);
source.wave_channel.write(0, 0xa0, 0);
source.wave_channel.write(2, 55, 0);
source.wave_channel.write(3, 0xe0, 0);
source.wave_channel.write(4, 0x7b, 0);
source.wave_channel.write(5, 0xc1, 0);
source.wave_channel.advance(13_352, &mut source.wave);
source.sequencer_phase = 12_345;
source.next_step = 5;
source.control = 0x7105;
source.bias = 0x83ac;
source.wave = [[0x12; 16], [0xa5; 16]];
source.fifo = [
Fifo {
words: [0x1122_3344; 7],
read: 3,
len: 5,
playback: 0xaabb_ccdd,
remaining: 2,
sample: -91,
},
Fifo {
words: [0x5566_7788; 7],
read: 6,
len: 7,
playback: 0x1234_5678,
remaining: 4,
sample: 102,
},
];
source.requests = [true, true];
let bytes = encode(&source);
let mut loaded = Audio::default();
loaded.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(loaded, source);
check_prefixes(&bytes, Audio::default());
let mut corrupt = bytes;
corrupt[4] = 2;
assert_eq!(
loaded.load(&mut Reader::new(&corrupt)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}

View file

@ -1,5 +1,6 @@
//! Optional fixed-rate digital capture, separate from hardware channel state.
use super::StereoLevel;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use std::collections::VecDeque;
/// Nominal stereo capture rate. This is not the GBA PWM/resolution model.
@ -101,3 +102,107 @@ impl AudioCapture {
count
}
}
impl Snapshot for AudioCapture {
/// The sample-clock phase continues the emulated timeline, so it is state.
/// The queue and drop count are host-facing output and are not.
fn save(&self, writer: &mut Writer) {
let Self {
clock,
queue: _queue, // Host-facing output, not hardware state.
dropped: _dropped, // Host-facing counter, not hardware state.
} = self;
writer.tag(b"ACAP");
writer.option(clock.0.as_ref(), |phase, writer| writer.u16(*phase));
}
/// Whether capture runs is host configuration and is kept. A running capture
/// resumes the saved phase, or starts at phase zero if none was running.
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
clock,
queue,
dropped,
} = self;
reader.tag(b"ACAP")?;
let saved = reader.option(Reader::u16)?;
if saved.is_some_and(|phase| u32::from(phase) >= PERIOD) {
return Err(SnapshotError::Corrupt("audio capture phase"));
}
if clock.0.is_some() {
clock.0 = Some(saved.unwrap_or(0));
}
queue.clear();
*dropped = 0;
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encoded(capture: &AudioCapture) -> Vec<u8> {
let mut writer = Writer::new();
capture.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn snapshot_restores_phase_and_clears_host_output() {
let mut source = AudioCapture::default();
source.set_enabled(true);
source.clock.0 = Some(317);
source.push(StereoLevel {
left: 12,
right: -9,
});
source.dropped = 7;
let bytes = encoded(&source);
let mut loaded = AudioCapture::default();
loaded.set_enabled(true);
loaded.push(StereoLevel { left: 1, right: 2 });
loaded.dropped = 4;
loaded.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(loaded.clock.0, Some(317));
assert!(loaded.queue.is_empty());
assert_eq!(loaded.dropped, 0);
for end in 0..bytes.len() {
assert!(loaded.load(&mut Reader::new(&bytes[..end])).is_err());
}
let mut corrupt = bytes.clone();
corrupt[0] ^= 1;
assert_eq!(
loaded.load(&mut Reader::new(&corrupt)),
Err(SnapshotError::Corrupt("section tag"))
);
let mut out_of_range = bytes;
out_of_range[5..7].copy_from_slice(&512u16.to_le_bytes());
assert_eq!(
loaded.load(&mut Reader::new(&out_of_range)),
Err(SnapshotError::Corrupt("audio capture phase"))
);
}
#[test]
fn capture_configuration_is_kept_not_restored() {
let mut running = AudioCapture::default();
running.set_enabled(true);
running.clock.0 = Some(100);
let running_bytes = encoded(&running);
let idle_bytes = encoded(&AudioCapture::default());
// A state saved while capture ran does not enable capture on an idle target.
let mut idle = AudioCapture::default();
idle.load(&mut Reader::new(&running_bytes)).unwrap();
assert_eq!(idle.clock.0, None);
// A state saved while idle starts a running capture at phase zero.
let mut target = AudioCapture::default();
target.set_enabled(true);
target.clock.0 = Some(200);
target.load(&mut Reader::new(&idle_bytes)).unwrap();
assert_eq!(target.clock.0, Some(0));
}
}

View file

@ -1,5 +1,7 @@
//! Shared nominal PSG length, envelope, activity, and logical DAC gate.
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Modulation {
pub(super) active: bool,
@ -96,3 +98,84 @@ impl Modulation {
}
}
}
impl Snapshot for Modulation {
fn save(&self, writer: &mut Writer) {
let Self {
active,
envelope,
length_enabled,
length,
volume,
envelope_ticks,
envelope_running,
} = self;
writer.tag(b"MOD0");
writer.bool(*active);
writer.u8(*envelope);
writer.bool(*length_enabled);
writer.u8(*length);
writer.u8(*volume);
writer.u8(*envelope_ticks);
writer.bool(*envelope_running);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"MOD0")?;
let Self {
active,
envelope,
length_enabled,
length,
volume,
envelope_ticks,
envelope_running,
} = self;
*active = reader.bool()?;
*envelope = reader.u8()?;
*length_enabled = reader.bool()?;
*length = reader.u8()?;
*volume = reader.u8()?;
*envelope_ticks = reader.u8()?;
*envelope_running = reader.bool()?;
if *length > 64 || *volume > 15 || *envelope_ticks > 8 {
return Err(SnapshotError::Corrupt("modulation range"));
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
#[test]
fn snapshot_round_trip_prefixes_and_invalid_boolean() {
let source = Modulation {
active: true,
envelope: 0xf3,
length_enabled: true,
length: 29,
volume: 7,
envelope_ticks: 4,
envelope_running: true,
};
let mut writer = Writer::new();
source.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut loaded = Modulation::default();
loaded.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(loaded, source);
for end in 0..bytes.len() {
assert!(loaded.load(&mut Reader::new(&bytes[..end])).is_err());
}
let mut corrupt = bytes;
corrupt[4] = 2;
assert_eq!(
loaded.load(&mut Reader::new(&corrupt)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}

View file

@ -1,5 +1,6 @@
//! Nominal GBA channel 4 polynomial counter. No random numbers or host clocks.
use super::modulation::Modulation;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Noise {
@ -80,6 +81,78 @@ impl Noise {
}
}
impl Snapshot for Noise {
fn save(&self, writer: &mut Writer) {
let Self {
modulation,
polynomial,
lfsr,
remaining,
high,
} = self;
writer.tag(b"NOI0");
modulation.save(writer);
writer.u8(*polynomial);
writer.u16(*lfsr);
writer.u32(*remaining);
writer.bool(*high);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"NOI0")?;
let Self {
modulation,
polynomial,
lfsr,
remaining,
high,
} = self;
modulation.load(reader)?;
*polynomial = reader.u8()?;
*lfsr = reader.u16()?;
*remaining = reader.u32()?;
*high = reader.bool()?;
if *lfsr > 0x7fff || *remaining > 14_680_064 {
return Err(SnapshotError::Corrupt("noise range"));
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
#[test]
fn snapshot_round_trip_prefixes_and_invalid_boolean() {
let mut source = Noise::default();
source.write(0, 5, 0);
source.write(1, 0xa3, 0);
source.write(4, 0x39, 0);
source.write(5, 0x80, 0);
let cycles = 7 * source.period() + 17;
source.advance(cycles);
source.clock(7);
let mut writer = Writer::new();
source.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut loaded = Noise::default();
loaded.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(loaded, source);
for end in 0..bytes.len() {
assert!(loaded.load(&mut Reader::new(&bytes[..end])).is_err());
}
let mut corrupt = bytes;
corrupt[8] = 2;
assert_eq!(
loaded.load(&mut Reader::new(&corrupt)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}
const fn shift(state: u16, short: bool) -> u16 {
(state >> 1)
^ if state & 1 != 0 {

View file

@ -1,6 +1,7 @@
//! Shared nominal pulse oscillator and sweep unit. No host-clock dependency.
//! Channel 2 never receives sweep-register writes, so its sweep remains disabled.
use super::modulation::Modulation;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Pulse {
@ -134,5 +135,104 @@ impl Pulse {
}
}
impl Snapshot for Pulse {
fn save(&self, writer: &mut Writer) {
let Self {
modulation,
sweep,
duty,
frequency,
phase,
remaining,
sweep_shadow,
sweep_ticks,
sweep_running,
subtracted,
} = self;
writer.tag(b"PUL0");
modulation.save(writer);
writer.u8(*sweep);
writer.u8(*duty);
writer.u16(*frequency);
writer.u8(*phase);
writer.u32(*remaining);
writer.u16(*sweep_shadow);
writer.u8(*sweep_ticks);
writer.bool(*sweep_running);
writer.bool(*subtracted);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"PUL0")?;
let Self {
modulation,
sweep,
duty,
frequency,
phase,
remaining,
sweep_shadow,
sweep_ticks,
sweep_running,
subtracted,
} = self;
modulation.load(reader)?;
*sweep = reader.u8()?;
*duty = reader.u8()?;
*frequency = reader.u16()?;
*phase = reader.u8()?;
*remaining = reader.u32()?;
*sweep_shadow = reader.u16()?;
*sweep_ticks = reader.u8()?;
*sweep_running = reader.bool()?;
*subtracted = reader.bool()?;
if *sweep > 0x7f
|| *duty > 3
|| *frequency > 2047
|| *phase > 7
|| *remaining > 32768
|| *sweep_shadow > 2047
|| *sweep_ticks > 8
{
return Err(SnapshotError::Corrupt("pulse range"));
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
#[test]
fn snapshot_round_trip_prefixes_and_invalid_boolean() {
let mut source = Pulse::default();
source.write(0, 0x09, 0);
source.write(2, 0x9d, 0);
source.write(3, 0xa3, 0);
source.write(4, 0x7b, 0);
source.write(5, 0x84, 0);
source.advance(23);
source.clock(2);
let mut writer = Writer::new();
source.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut loaded = Pulse::default();
loaded.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(loaded, source);
for end in 0..bytes.len() {
assert!(loaded.load(&mut Reader::new(&bytes[..end])).is_err());
}
let mut corrupt = bytes;
corrupt[8] = 2;
assert_eq!(
loaded.load(&mut Reader::new(&corrupt)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}
#[cfg(test)]
mod tests;

View file

@ -1,4 +1,6 @@
//! Nominal single-bank wave playback. RAM rotates in high-nibble-first order.
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) struct Wave {
control: u8,
@ -129,5 +131,95 @@ impl Wave {
}
}
impl Snapshot for Wave {
fn save(&self, writer: &mut Writer) {
let Self {
control,
volume,
frequency,
length,
length_enabled,
active,
remaining,
sample,
} = self;
writer.tag(b"WAV0");
writer.u8(*control);
writer.u8(*volume);
writer.u16(*frequency);
writer.u16(*length);
writer.bool(*length_enabled);
writer.bool(*active);
writer.u32(*remaining);
writer.option(sample.as_ref(), |sample, writer| writer.u8(*sample));
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"WAV0")?;
let Self {
control,
volume,
frequency,
length,
length_enabled,
active,
remaining,
sample,
} = self;
*control = reader.u8()?;
*volume = reader.u8()?;
*frequency = reader.u16()?;
*length = reader.u16()?;
*length_enabled = reader.bool()?;
*active = reader.bool()?;
*remaining = reader.u32()?;
*sample = reader.option(|reader| reader.u8())?;
if *control & !0xe0 != 0
|| *volume & !0xe0 != 0
|| *frequency > 2047
|| *length > 256
|| *remaining > 16384
|| matches!(*sample, Some(value) if value > 15)
{
return Err(SnapshotError::Corrupt("wave range"));
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
#[test]
fn snapshot_round_trip_prefixes_and_invalid_option_tag() {
let mut source = Wave::default();
source.write(0, 0xa0, 0);
source.write(2, 55, 0);
source.write(3, 0xe0, 0);
source.write(4, 0x7b, 0);
source.write(5, 0xc1, 0);
let mut banks = [[0x12; 16]; 2];
source.advance(source.remaining, &mut banks);
let mut writer = Writer::new();
source.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut loaded = Wave::default();
loaded.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(loaded, source);
for end in 0..bytes.len() {
assert!(loaded.load(&mut Reader::new(&bytes[..end])).is_err());
}
let mut corrupt = bytes;
corrupt[16] = 2;
assert_eq!(
loaded.load(&mut Reader::new(&corrupt)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}
#[cfg(test)]
mod tests;

View file

@ -1,6 +1,7 @@
//! Optional Game Pak hardware. No host clock, filesystem, or ROM-byte mutation.
use crate::memory::MemoryError;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
mod calendar;
mod flash;
@ -175,3 +176,199 @@ impl Gpio {
(self.latch & self.direction) | (u8::from(self.rtc.output) << 1 & !self.direction)
}
}
impl Cartridge {
/// Host configuration, not state: cartridge hardware then save device, one byte each.
pub(crate) fn setup_identity(&self) -> [u8; 2] {
let device = self
.flash
.as_ref()
.map_or(SaveDevice::None, |flash| flash.device());
[
u8::from(self.gpio.is_some()),
match device {
SaveDevice::None => 0,
SaveDevice::Flash64 => 1,
SaveDevice::Flash128 => 2,
},
]
}
}
impl Snapshot for Cartridge {
fn save(&self, writer: &mut Writer) {
let Self { gpio, flash } = self;
writer.tag(b"CART");
writer.raw(&self.setup_identity());
if let Some(gpio) = gpio {
gpio.save(writer);
}
if let Some(flash) = flash {
flash.save(writer);
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { gpio, flash } = self;
reader.tag(b"CART")?;
let hardware = reader.u8()?;
if hardware > 1 {
return Err(SnapshotError::Corrupt("cartridge hardware"));
}
if hardware != u8::from(gpio.is_some()) {
return Err(SnapshotError::ConfigMismatch("cartridge hardware"));
}
let save_device = match reader.u8()? {
0 => SaveDevice::None,
1 => SaveDevice::Flash64,
2 => SaveDevice::Flash128,
_ => return Err(SnapshotError::Corrupt("save device")),
};
let target_device = flash
.as_ref()
.map_or(SaveDevice::None, |flash| flash.device());
if save_device != target_device {
return Err(SnapshotError::ConfigMismatch("save device"));
}
if let Some(gpio) = gpio {
gpio.load(reader)?;
}
if let Some(flash) = flash {
flash.load(reader)?;
}
Ok(())
}
}
impl Snapshot for Gpio {
fn save(&self, writer: &mut Writer) {
let Self {
latch,
direction,
readable,
rtc,
} = self;
writer.tag(b"GPIO");
writer.u8(*latch);
writer.u8(*direction);
writer.bool(*readable);
rtc.save(writer);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
latch,
direction,
readable,
rtc,
} = self;
reader.tag(b"GPIO")?;
let saved_latch = reader.u8()?;
let saved_direction = reader.u8()?;
if saved_latch & !0x0f != 0 || saved_direction & !0x0f != 0 {
return Err(SnapshotError::Corrupt("GPIO pins"));
}
let saved_readable = reader.bool()?;
rtc.load(reader)?;
*latch = saved_latch;
*direction = saved_direction;
*readable = saved_readable;
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encoded<T: Snapshot>(value: &T) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
fn configured_cartridge(hardware: CartridgeHardware, device: SaveDevice) -> Cartridge {
let mut cartridge = Cartridge::default();
cartridge.set_hardware(hardware);
cartridge.set_save_device(device);
cartridge
}
#[test]
fn cartridge_snapshot_round_trips_and_checks_configuration() {
let mut original = configured_cartridge(CartridgeHardware::Rtc, SaveDevice::Flash128);
let gpio = original.gpio.as_mut().unwrap();
gpio.latch = 0x0b;
gpio.direction = 0x05;
gpio.readable = true;
let bytes = encoded(&original);
let mut restored = configured_cartridge(CartridgeHardware::Rtc, SaveDevice::Flash128);
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, original);
for end in 0..bytes.len() {
let mut target = configured_cartridge(CartridgeHardware::Rtc, SaveDevice::Flash128);
assert!(target.load(&mut Reader::new(&bytes[..end])).is_err());
}
let mut no_rtc = configured_cartridge(CartridgeHardware::None, SaveDevice::Flash128);
assert_eq!(
no_rtc.load(&mut Reader::new(&bytes)),
Err(SnapshotError::ConfigMismatch("cartridge hardware"))
);
let mut other_save = configured_cartridge(CartridgeHardware::Rtc, SaveDevice::Flash64);
assert_eq!(
other_save.load(&mut Reader::new(&bytes)),
Err(SnapshotError::ConfigMismatch("save device"))
);
let mut invalid_identity = bytes.clone();
invalid_identity[4] = 0xff;
assert_eq!(
configured_cartridge(CartridgeHardware::Rtc, SaveDevice::Flash128)
.load(&mut Reader::new(&invalid_identity)),
Err(SnapshotError::Corrupt("cartridge hardware"))
);
let empty = Cartridge::default();
let empty_bytes = encoded(&empty);
let mut empty_restored = Cartridge::default();
empty_restored.load(&mut Reader::new(&empty_bytes)).unwrap();
assert_eq!(empty_restored, empty);
}
#[test]
fn gpio_snapshot_round_trips_every_gpio_latch() {
let mut gpio = Gpio {
latch: 0x0d,
direction: 0x06,
readable: true,
..Gpio::default()
};
gpio.rtc.update(5, 2).unwrap();
gpio.rtc.update(6, 2).unwrap();
gpio.rtc.update(7, 2).unwrap();
let bytes = encoded(&gpio);
let mut restored = Gpio::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, gpio);
for end in 0..bytes.len() {
assert!(Gpio::default()
.load(&mut Reader::new(&bytes[..end]))
.is_err());
}
let mut corrupt = bytes;
corrupt[4] = 0x10;
assert_eq!(
Gpio::default().load(&mut Reader::new(&corrupt)),
Err(SnapshotError::Corrupt("GPIO pins"))
);
}
}

View file

@ -1,4 +1,5 @@
//! Validated 2000–2099 RTC calendar arithmetic. No clock sampling or timezone conversion.
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use std::{error::Error, fmt};
const DAYS_PER_CENTURY: u64 = 36_525;
@ -153,5 +154,23 @@ impl RtcDateTime {
}
}
impl Snapshot for RtcDateTime {
fn save(&self, writer: &mut Writer) {
let Self(parts) = self;
writer.tag(b"DATE");
writer.raw(parts);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self(current) = self;
reader.tag(b"DATE")?;
let mut parts = [0; 7];
reader.raw_into(&mut parts)?;
let validated = Self::new(parts).map_err(|_| SnapshotError::Corrupt("RTC date/time"))?;
*current = validated.0;
Ok(())
}
}
#[cfg(test)]
mod tests;

View file

@ -139,3 +139,29 @@ fn large_and_split_advances_do_not_overflow_or_lose_time() {
assert_eq!(batched, small);
}
}
#[test]
fn snapshot_round_trips_and_validates_rtc_date_time() {
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
let value = RtcDateTime::new([24, 2, 29, 4, 23, 58, 59]).unwrap();
let mut writer = Writer::new();
value.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut restored = RtcDateTime::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, value);
for end in 0..bytes.len() {
assert!(RtcDateTime::default()
.load(&mut Reader::new(&bytes[..end]))
.is_err());
}
let mut corrupt = bytes;
corrupt[4] = 0xff;
assert_eq!(
RtcDateTime::default().load(&mut Reader::new(&corrupt)),
Err(SnapshotError::Corrupt("RTC date/time"))
);
}

View file

@ -1,5 +1,6 @@
//! Bounded Macronix Flash commands with nominal delayed program/erase completion.
use crate::memory::MemoryError;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use std::{error::Error, fmt};
pub const SAVE_START: u32 = 0x0e00_0000;
@ -263,3 +264,408 @@ impl Flash {
Ok(())
}
}
impl SaveMutation {
fn read(reader: &mut Reader<'_>) -> Result<Self, SnapshotError> {
reader.tag(b"SMUT")?;
let start = usize::try_from(reader.u64()?)
.map_err(|_| SnapshotError::Corrupt("Flash mutation range"))?;
let end = usize::try_from(reader.u64()?)
.map_err(|_| SnapshotError::Corrupt("Flash mutation range"))?;
let value = reader.u8()?;
let mutation = Self { start, end, value };
if !mutation.valid_for(SaveDevice::Flash128.capacity()) {
return Err(SnapshotError::Corrupt("Flash mutation range"));
}
Ok(mutation)
}
fn valid_for(self, capacity: usize) -> bool {
let Self { start, end, value } = self;
let Some(length) = end.checked_sub(start) else {
return false;
};
if start >= end || end > capacity {
return false;
}
match length {
1 => true,
0x1000 => value == 0xff && start & 0xfff == 0,
0x1_0000 | 0x2_0000 => value == 0xff && start == 0,
_ => false,
}
}
}
impl Snapshot for SaveMutation {
fn save(&self, writer: &mut Writer) {
let Self { start, end, value } = self;
writer.tag(b"SMUT");
writer.u64(*start as u64);
writer.u64(*end as u64);
writer.u8(*value);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { start, end, value } = self;
let decoded = Self::read(reader)?;
*start = decoded.start;
*end = decoded.end;
*value = decoded.value;
Ok(())
}
}
#[cfg(test)]
mod mutation_snapshot_tests {
use super::*;
#[test]
fn save_mutation_snapshot_round_trips_and_rejects_prefixes() {
let mutation = SaveMutation {
start: 0x1000,
end: 0x2000,
value: 0xff,
};
let mut writer = Writer::new();
mutation.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut restored = SaveMutation {
start: 0,
end: 1,
value: 0,
};
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, mutation);
for end in 0..bytes.len() {
let mut target = SaveMutation {
start: 0,
end: 1,
value: 0,
};
assert!(target.load(&mut Reader::new(&bytes[..end])).is_err());
}
}
}
impl Snapshot for Phase {
fn save(&self, writer: &mut Writer) {
writer.tag(b"FLPH");
writer.u8(match self {
Self::Ready => 0,
Self::Unlock => 1,
Self::Command => 2,
Self::Bank => 3,
Self::Program => 4,
Self::EraseUnlock => 5,
Self::EraseCommand => 6,
Self::EraseConfirm => 7,
});
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"FLPH")?;
let decoded = match reader.u8()? {
0 => Self::Ready,
1 => Self::Unlock,
2 => Self::Command,
3 => Self::Bank,
4 => Self::Program,
5 => Self::EraseUnlock,
6 => Self::EraseCommand,
7 => Self::EraseConfirm,
_ => return Err(SnapshotError::Corrupt("Flash phase")),
};
let _current = match self {
Self::Ready => 0,
Self::Unlock => 1,
Self::Command => 2,
Self::Bank => 3,
Self::Program => 4,
Self::EraseUnlock => 5,
Self::EraseCommand => 6,
Self::EraseConfirm => 7,
};
*self = decoded;
Ok(())
}
}
impl Pending {
fn read(reader: &mut Reader<'_>) -> Result<Self, SnapshotError> {
reader.tag(b"PEND")?;
let mutation = SaveMutation::read(reader)?;
let remaining = reader.u32()?;
let poll = reader.u32()?;
let pending = Self {
mutation,
remaining,
poll,
};
if !pending.valid_for(SaveDevice::Flash128.capacity()) {
return Err(SnapshotError::Corrupt("Flash pending operation"));
}
Ok(pending)
}
fn valid_for(self, capacity: usize) -> bool {
let Self {
mutation,
remaining,
poll,
} = self;
let SaveMutation { start, end, value } = mutation;
let Some(length) = end.checked_sub(start) else {
return false;
};
let Some(poll_offset) = poll
.checked_sub(SAVE_START)
.and_then(|offset| usize::try_from(offset).ok())
else {
return false;
};
if !mutation.valid_for(capacity) || poll_offset > 0xffff || remaining == 0 {
return false;
}
let bank_offset = start % 0x1_0000;
match length {
1 => remaining <= FLASH_PROGRAM_CYCLES && poll_offset == bank_offset,
0x1000 => {
remaining <= FLASH_ERASE_CYCLES
&& value == 0xff
&& bank_offset & 0xfff == 0
&& poll_offset == bank_offset
}
0x1_0000 | 0x2_0000 => {
remaining <= FLASH_ERASE_CYCLES && value == 0xff && start == 0 && poll == SAVE_START
}
_ => false,
}
}
}
impl Snapshot for Pending {
fn save(&self, writer: &mut Writer) {
let Self {
mutation,
remaining,
poll,
} = self;
writer.tag(b"PEND");
mutation.save(writer);
writer.u32(*remaining);
writer.u32(*poll);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
mutation,
remaining,
poll,
} = self;
let decoded = Self::read(reader)?;
*mutation = decoded.mutation;
*remaining = decoded.remaining;
*poll = decoded.poll;
Ok(())
}
}
impl Snapshot for Flash {
fn save(&self, writer: &mut Writer) {
let Self {
device,
phase,
identify,
bank,
pending,
} = self;
writer.tag(b"FLAS");
writer.u8(match device {
SaveDevice::None => 0,
SaveDevice::Flash64 => 1,
SaveDevice::Flash128 => 2,
});
phase.save(writer);
writer.bool(*identify);
writer.u8(*bank);
writer.option(pending.as_ref(), |pending, writer| pending.save(writer));
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
device,
phase,
identify,
bank,
pending,
} = self;
reader.tag(b"FLAS")?;
let saved_device = match reader.u8()? {
0 => SaveDevice::None,
1 => SaveDevice::Flash64,
2 => SaveDevice::Flash128,
_ => return Err(SnapshotError::Corrupt("save device")),
};
if saved_device == SaveDevice::None {
return Err(SnapshotError::Corrupt("save device"));
}
if saved_device != *device {
return Err(SnapshotError::ConfigMismatch("save device"));
}
let mut saved_phase = Phase::Ready;
saved_phase.load(reader)?;
let saved_identify = reader.bool()?;
let saved_bank = reader.u8()?;
let saved_pending = reader.option(Pending::read)?;
if saved_bank > 1 || (saved_device == SaveDevice::Flash64 && saved_bank != 0) {
return Err(SnapshotError::Corrupt("Flash bank"));
}
if saved_device == SaveDevice::Flash64 && saved_phase == Phase::Bank {
return Err(SnapshotError::Corrupt("Flash bank phase"));
}
if let Some(saved_pending) = saved_pending {
let pending_length = saved_pending.mutation.end - saved_pending.mutation.start;
let mutation_start = saved_pending.mutation.start;
if !saved_pending.valid_for(saved_device.capacity())
|| saved_phase != Phase::Ready
|| saved_identify
|| ((pending_length == 1 || pending_length == 0x1000)
&& mutation_start / 0x1_0000 != usize::from(saved_bank))
|| (matches!(pending_length, 0x1_0000 | 0x2_0000)
&& pending_length != saved_device.capacity())
{
return Err(SnapshotError::Corrupt("Flash pending state"));
}
}
*phase = saved_phase;
*identify = saved_identify;
*bank = saved_bank;
*pending = saved_pending;
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encoded<T: Snapshot>(value: &T) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
fn assert_prefixes<T: Snapshot + Copy>(value: T) {
let bytes = encoded(&value);
for end in 0..bytes.len() {
let mut restored = value;
assert!(restored.load(&mut Reader::new(&bytes[..end])).is_err());
}
}
fn erase_pending(start: usize, end: usize, remaining: u32, poll: u32) -> Pending {
Pending {
mutation: SaveMutation {
start,
end,
value: 0xff,
},
remaining,
poll,
}
}
#[test]
fn flash_snapshot_round_trips_controller_and_pending_operation() {
let flash = Flash {
device: SaveDevice::Flash128,
phase: Phase::Ready,
identify: false,
bank: 1,
pending: Some(erase_pending(
0x1_1000,
0x1_2000,
12_345,
SAVE_START + 0x1000,
)),
};
let bytes = encoded(&flash);
let mut restored = Flash::new(SaveDevice::Flash128).unwrap();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, flash);
assert_prefixes(flash);
let command_state = Flash {
device: SaveDevice::Flash128,
phase: Phase::Command,
identify: true,
bank: 1,
pending: None,
};
let command_bytes = encoded(&command_state);
let mut restored = Flash::new(SaveDevice::Flash128).unwrap();
restored.load(&mut Reader::new(&command_bytes)).unwrap();
assert_eq!(restored, command_state);
let mut other_device = Flash::new(SaveDevice::Flash64).unwrap();
assert_eq!(
other_device.load(&mut Reader::new(&bytes)),
Err(SnapshotError::ConfigMismatch("save device"))
);
}
#[test]
fn flash_pending_phase_and_bank_encodings_are_checked() {
let phases = [
Phase::Ready,
Phase::Unlock,
Phase::Command,
Phase::Bank,
Phase::Program,
Phase::EraseUnlock,
Phase::EraseCommand,
Phase::EraseConfirm,
];
for phase in phases {
let bytes = encoded(&phase);
let mut restored = Phase::Ready;
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, phase);
assert_prefixes(phase);
}
let pending = erase_pending(0x1000, 0x2000, 10_000, SAVE_START + 0x1000);
let bytes = encoded(&pending);
let mut restored = erase_pending(0, 1, 1, SAVE_START);
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, pending);
assert_prefixes(pending);
let mut invalid_phase = encoded(&Phase::Ready);
invalid_phase[4] = 0xff;
assert_eq!(
Phase::Ready.load(&mut Reader::new(&invalid_phase)),
Err(SnapshotError::Corrupt("Flash phase"))
);
let mut invalid_pending = encoded(&pending);
invalid_pending[8..16].copy_from_slice(&u64::MAX.to_le_bytes());
assert!(matches!(
erase_pending(0, 1, 1, SAVE_START).load(&mut Reader::new(&invalid_pending)),
Err(SnapshotError::Corrupt(_))
));
let mut invalid_bank = encoded(&Flash::new(SaveDevice::Flash128).unwrap());
invalid_bank[11] = 2;
assert_eq!(
Flash::new(SaveDevice::Flash128)
.unwrap()
.load(&mut Reader::new(&invalid_bank)),
Err(SnapshotError::Corrupt("Flash bank"))
);
}
}

View file

@ -1,5 +1,6 @@
//! Edge-driven RTC command framing with latched reads and complete-payload writes.
use super::RtcDateTime;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Rtc {
@ -237,3 +238,277 @@ impl Rtc {
}
}
}
impl Snapshot for Register {
fn save(&self, writer: &mut Writer) {
writer.tag(b"RTCR");
writer.u8(match self {
Self::Control => 0,
Self::DateTime => 1,
Self::Time => 2,
});
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"RTCR")?;
let decoded = match reader.u8()? {
0 => Self::Control,
1 => Self::DateTime,
2 => Self::Time,
_ => return Err(SnapshotError::Corrupt("RTC register")),
};
let _current = match self {
Self::Control => 0,
Self::DateTime => 1,
Self::Time => 2,
};
*self = decoded;
Ok(())
}
}
impl Snapshot for Phase {
fn save(&self, writer: &mut Writer) {
writer.tag(b"RTPH");
match self {
Self::Command { byte, bits } => {
writer.u8(0);
writer.u8(*byte);
writer.u8(*bits);
}
Self::Write {
register,
data,
offset,
byte,
bits,
} => {
writer.u8(1);
register.save(writer);
writer.raw(data);
writer.u8(*offset);
writer.u8(*byte);
writer.u8(*bits);
}
Self::Read { data, len, bit } => {
writer.u8(2);
writer.raw(data);
writer.u8(*len);
writer.u8(*bit);
}
Self::Done => writer.u8(3),
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
reader.tag(b"RTPH")?;
let decoded = match reader.u8()? {
0 => {
let byte = reader.u8()?;
let bits = reader.u8()?;
if bits >= 8 {
return Err(SnapshotError::Corrupt("RTC command bit index"));
}
Self::Command { byte, bits }
}
1 => {
let mut data = [0; 7];
let mut register = Register::Control;
register.load(reader)?;
reader.raw_into(&mut data)?;
let offset = reader.u8()?;
let byte = reader.u8()?;
let bits = reader.u8()?;
if offset >= register.len() || bits >= 8 {
return Err(SnapshotError::Corrupt("RTC write index"));
}
Self::Write {
register,
data,
offset,
byte,
bits,
}
}
2 => {
let mut data = [0; 7];
reader.raw_into(&mut data)?;
let len = reader.u8()?;
let bit = reader.u8()?;
if !matches!(len, 1 | 3 | 7) || bit >= len * 8 {
return Err(SnapshotError::Corrupt("RTC read index"));
}
Self::Read { data, len, bit }
}
3 => Self::Done,
_ => return Err(SnapshotError::Corrupt("RTC phase")),
};
let _current = match self {
Self::Command { byte: _, bits: _ } => 0,
Self::Write {
register: _,
data: _,
offset: _,
byte: _,
bits: _,
} => 1,
Self::Read {
data: _,
len: _,
bit: _,
} => 2,
Self::Done => 3,
};
*self = decoded;
Ok(())
}
}
impl Snapshot for Rtc {
fn save(&self, writer: &mut Writer) {
let Self {
control,
calendar,
selected,
clock,
sample,
output,
phase,
} = self;
writer.tag(b"RTC0");
writer.u8(*control);
calendar.save(writer);
writer.bool(*selected);
writer.bool(*clock);
writer.bool(*sample);
writer.bool(*output);
phase.save(writer);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
control,
calendar,
selected,
clock,
sample,
output,
phase,
} = self;
reader.tag(b"RTC0")?;
let saved_control = reader.u8()?;
if saved_control & !0x40 != 0 {
return Err(SnapshotError::Corrupt("RTC control"));
}
calendar.load(reader)?;
let saved_selected = reader.bool()?;
let saved_clock = reader.bool()?;
let saved_sample = reader.bool()?;
let saved_output = reader.bool()?;
phase.load(reader)?;
*control = saved_control;
*selected = saved_selected;
*clock = saved_clock;
*sample = saved_sample;
*output = saved_output;
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encoded<T: Snapshot>(value: &T) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
fn assert_prefixes<T: Snapshot + Copy>(value: T) {
let bytes = encoded(&value);
for end in 0..bytes.len() {
let mut restored = value;
assert!(restored.load(&mut Reader::new(&bytes[..end])).is_err());
}
}
#[test]
fn rtc_snapshot_round_trips_all_latches_and_calendar() {
let rtc = Rtc {
calendar: RtcDateTime::new([4, 3, 8, 2, 12, 34, 56]).unwrap(),
selected: true,
phase: Phase::Read {
data: [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde],
len: 7,
bit: 19,
},
..Rtc::default()
};
let bytes = encoded(&rtc);
let mut restored = Rtc::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, rtc);
assert_prefixes(rtc);
let mut invalid = bytes;
invalid[4] = 0x41;
assert_eq!(
Rtc::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("RTC control"))
);
}
#[test]
fn rtc_register_and_phase_encodings_are_exhaustive_and_checked() {
for register in [Register::Control, Register::DateTime, Register::Time] {
let bytes = encoded(&register);
let mut restored = Register::Control;
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, register);
assert_prefixes(register);
}
let phases = [
Phase::Command {
byte: 0x06,
bits: 4,
},
Phase::Write {
register: Register::Time,
data: [1, 2, 3, 4, 5, 6, 7],
offset: 2,
byte: 0x55,
bits: 3,
},
Phase::Read {
data: [7, 6, 5, 4, 3, 2, 1],
len: 3,
bit: 17,
},
Phase::Done,
];
for phase in phases {
let bytes = encoded(&phase);
let mut restored = Phase::Done;
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, phase);
assert_prefixes(phase);
}
let mut invalid_register = encoded(&Register::Control);
invalid_register[4] = 0xff;
assert_eq!(
Register::Control.load(&mut Reader::new(&invalid_register)),
Err(SnapshotError::Corrupt("RTC register"))
);
let mut invalid_phase = encoded(&Phase::Done);
invalid_phase[4] = 0xff;
assert_eq!(
Phase::Done.load(&mut Reader::new(&invalid_phase)),
Err(SnapshotError::Corrupt("RTC phase"))
);
}
}

View file

@ -1,6 +1,7 @@
use std::{error::Error, fmt};
use crate::memory::{Memory, MemoryError};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
// Semantic tests compare architectural state after successful instructions.
// Error/rollback tests keep full Cpu equality, including buffered fetch results.
@ -333,3 +334,201 @@ impl Cpu {
}
}
}
fn save_instruction_set(instruction_set: InstructionSet, writer: &mut Writer) {
writer.u8(match instruction_set {
InstructionSet::Arm => 0,
InstructionSet::Thumb => 1,
});
}
fn load_instruction_set(reader: &mut Reader<'_>) -> Result<InstructionSet, SnapshotError> {
match reader.u8()? {
0 => Ok(InstructionSet::Arm),
1 => Ok(InstructionSet::Thumb),
_ => Err(SnapshotError::Corrupt("instruction set")),
}
}
fn save_mode(mode: Mode, writer: &mut Writer) {
writer.u8(match mode {
Mode::User => 0,
Mode::Fiq => 1,
Mode::Irq => 2,
Mode::Supervisor => 3,
Mode::Abort => 4,
Mode::Undefined => 5,
Mode::System => 6,
});
}
fn load_mode(reader: &mut Reader<'_>) -> Result<Mode, SnapshotError> {
match reader.u8()? {
0 => Ok(Mode::User),
1 => Ok(Mode::Fiq),
2 => Ok(Mode::Irq),
3 => Ok(Mode::Supervisor),
4 => Ok(Mode::Abort),
5 => Ok(Mode::Undefined),
6 => Ok(Mode::System),
_ => Err(SnapshotError::Corrupt("processor mode")),
}
}
impl Snapshot for Cpu {
fn save(&self, writer: &mut Writer) {
let Self {
registers,
flags,
instruction_set,
mode,
irq_disabled,
fiq_disabled,
banks,
pipeline,
} = self;
writer.tag(b"CPU0");
for register in registers {
writer.u32(*register);
}
let Flags {
negative,
zero,
carry,
overflow,
} = flags;
writer.bool(*negative);
writer.bool(*zero);
writer.bool(*carry);
writer.bool(*overflow);
save_instruction_set(*instruction_set, writer);
save_mode(*mode, writer);
writer.bool(*irq_disabled);
writer.bool(*fiq_disabled);
banks.save_snapshot(writer);
writer.option(pipeline.as_ref(), |pipeline, writer| {
pipeline.save_snapshot(writer);
});
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
registers,
flags,
instruction_set,
mode,
irq_disabled,
fiq_disabled,
banks,
pipeline,
} = self;
reader.tag(b"CPU0")?;
for register in registers {
*register = reader.u32()?;
}
let Flags {
negative,
zero,
carry,
overflow,
} = flags;
*negative = reader.bool()?;
*zero = reader.bool()?;
*carry = reader.bool()?;
*overflow = reader.bool()?;
*instruction_set = load_instruction_set(reader)?;
*mode = load_mode(reader)?;
*irq_disabled = reader.bool()?;
*fiq_disabled = reader.bool()?;
banks.load_snapshot(reader)?;
*pipeline = reader.option(|reader| pipeline::Pipeline::load_snapshot(reader))?;
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
use crate::memory::ROM_START;
fn populated_cpu() -> Cpu {
const THUMB_STORE: [u8; 2] = [0x08, 0x60];
let mut cpu = Cpu::new(ROM_START);
cpu.instruction_set = InstructionSet::Thumb;
cpu.registers[0] = 0xaabb_ccdd;
cpu.registers[1] = 0x0200_0000;
let mut memory = Memory::new(THUMB_STORE.repeat(8)).unwrap();
cpu.step(&mut memory).unwrap();
for (index, register) in cpu.registers[..15].iter_mut().enumerate() {
*register = 0x1000 + index as u32;
}
cpu.registers[15] = ROM_START + 2;
cpu.flags = Flags {
negative: true,
zero: false,
carry: true,
overflow: true,
};
cpu.irq_disabled = true;
cpu.fiq_disabled = true;
for (mode, seed) in [
(Mode::User, 0x10_000),
(Mode::Fiq, 0x20_000),
(Mode::Irq, 0x30_000),
(Mode::Supervisor, 0x40_000),
(Mode::Abort, 0x50_000),
(Mode::Undefined, 0x60_000),
(Mode::System, 0x70_000),
] {
cpu.switch_mode(mode);
for (index, register) in cpu.registers[8..15].iter_mut().enumerate() {
*register = seed + index as u32;
}
if !matches!(mode, Mode::User | Mode::System) {
cpu.set_spsr(seed + 0x800);
}
}
cpu.switch_mode(Mode::Irq);
cpu
}
fn save(cpu: &Cpu) -> Vec<u8> {
let mut writer = Writer::new();
cpu.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn cpu_round_trips_register_banks_flags_and_pipeline() {
let source = populated_cpu();
let bytes = save(&source);
let mut restored = Cpu::new(0);
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
}
#[test]
fn every_strict_cpu_prefix_is_rejected() {
let bytes = save(&populated_cpu());
for end in 0..bytes.len() {
let mut restored = Cpu::new(0);
assert!(
restored.load(&mut Reader::new(&bytes[..end])).is_err(),
"accepted prefix of length {end}"
);
}
}
#[test]
fn cpu_rejects_unknown_instruction_set_discriminant() {
let mut bytes = save(&populated_cpu());
bytes[72] = 0xff;
let mut restored = Cpu::new(0);
assert!(matches!(
restored.load(&mut Reader::new(&bytes)),
Err(SnapshotError::Corrupt(_))
));
}
}

View file

@ -1,10 +1,12 @@
//! ARM/Thumb instruction buffering and the next CPU fetch access kind.
use super::{Cpu, CpuError, InstructionSet};
use crate::{
memory::{InstructionFetch, Memory, MemoryError},
memory::{load_fetch_result, save_fetch_result, InstructionFetch, Memory, MemoryError},
timing::AccessKind,
};
use crate::snapshot::{Reader, SnapshotError, Writer};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Pipeline {
pc: u32,
@ -105,3 +107,40 @@ impl Cpu {
});
}
}
impl Pipeline {
pub(super) fn save_snapshot(&self, writer: &mut Writer) {
let Self {
pc,
instruction_set,
instructions,
next_access,
} = self;
writer.u32(*pc);
super::save_instruction_set(*instruction_set, writer);
for instruction in instructions {
save_fetch_result(instruction, writer);
}
writer.u8(match next_access {
AccessKind::NonSequential => 0,
AccessKind::Sequential => 1,
});
}
pub(super) fn load_snapshot(reader: &mut Reader<'_>) -> Result<Self, SnapshotError> {
let pc = reader.u32()?;
let instruction_set = super::load_instruction_set(reader)?;
let instructions = [load_fetch_result(reader)?, load_fetch_result(reader)?];
let next_access = match reader.u8()? {
0 => AccessKind::NonSequential,
1 => AccessKind::Sequential,
_ => return Err(SnapshotError::Corrupt("access kind")),
};
Ok(Self {
pc,
instruction_set,
instructions,
next_access,
})
}
}

View file

@ -1,6 +1,7 @@
//! ARM7 processor modes, register banks, and status-register transfers.
use super::{Cpu, CpuError, Flags, InstructionSet};
use crate::snapshot::{Reader, SnapshotError, Writer};
/// ARMv4T processor mode. User and System share a register bank.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -181,3 +182,40 @@ impl Cpu {
Ok(())
}
}
impl Banks {
pub(super) fn save_snapshot(&self, writer: &mut Writer) {
let Self { high, sp_lr, spsr } = self;
for bank in high {
for register in bank {
writer.u32(*register);
}
}
for bank in sp_lr {
for register in bank {
writer.u32(*register);
}
}
for register in spsr {
writer.u32(*register);
}
}
pub(super) fn load_snapshot(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { high, sp_lr, spsr } = self;
for bank in high {
for register in bank {
*register = reader.u32()?;
}
}
for bank in sp_lr {
for register in bank {
*register = reader.u32()?;
}
}
for register in spsr {
*register = reader.u32()?;
}
Ok(())
}
}

View file

@ -3,6 +3,8 @@
//! or HALT-idle boundaries; display bus contention and IRQ synchronization delays
//! are not modeled. Forced blank does not stop this clock.
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
pub const CPU_HZ: u32 = 16_777_216;
pub const CYCLES_PER_LINE: u32 = 1232;
pub const HBLANK_START: u32 = 1006;
@ -130,3 +132,83 @@ fn crossings(start: u32, elapsed: u32, period: u32, offset: u32) -> u64 {
let begin = u64::from(start) + shift;
(begin + u64::from(elapsed)) / u64::from(period) - begin / u64::from(period)
}
impl Snapshot for Display {
fn save(&self, writer: &mut Writer) {
let Self {
phase,
control,
frames,
vblanks,
} = self;
writer.tag(b"DISP");
writer.u32(*phase);
writer.u16(*control);
writer.u64(*frames);
writer.u64(*vblanks);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
phase,
control,
frames,
vblanks,
} = self;
reader.tag(b"DISP")?;
*phase = reader.u32()?;
*control = reader.u16()?;
*frames = reader.u64()?;
*vblanks = reader.u64()?;
if *phase >= CYCLES_PER_FRAME {
return Err(SnapshotError::Corrupt("display phase"));
}
if *control & !0xff38 != 0 {
return Err(SnapshotError::Corrupt("display control"));
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encode(display: &Display) -> Vec<u8> {
let mut writer = Writer::new();
display.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn round_trip_and_reject_every_strict_prefix() {
let source = Display {
phase: CYCLES_PER_LINE * 137 + 1009,
control: 0xab38,
frames: 0x1234_5678,
vblanks: 0x8765_4321,
};
let bytes = encode(&source);
let mut restored = Display::default();
let mut reader = Reader::new(&bytes);
restored.load(&mut reader).unwrap();
reader.finish().unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
let mut truncated = Reader::new(&bytes[..end]);
assert!(
Display::default().load(&mut truncated).is_err(),
"accepted prefix of length {end}"
);
}
}
#[test]
fn out_of_range_phase_is_corrupt() {
let mut bytes = encode(&Display::default());
bytes[4..8].copy_from_slice(&CYCLES_PER_FRAME.to_le_bytes());
let result = Display::default().load(&mut Reader::new(&bytes));
assert!(matches!(result, Err(SnapshotError::Corrupt(_))));
}
}

View file

@ -6,6 +6,7 @@
use std::{error::Error, fmt};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use crate::{memory::MemoryError, timing::AccessWidth};
pub const DMA_BASE: u32 = 0x0400_00b0;
@ -283,3 +284,185 @@ fn replace_byte(previous: u32, byte: u32, value: u8) -> u32 {
let shift = byte * 8;
(previous & !(0xff << shift)) | (u32::from(value) << shift)
}
fn save_channel(channel: &Channel, writer: &mut Writer) {
let Channel {
source,
destination,
count,
control,
current_source,
current_destination,
remaining,
active,
first,
data_latch,
} = channel;
writer.u32(*source);
writer.u32(*destination);
writer.u16(*count);
writer.u16(*control);
writer.u32(*current_source);
writer.u32(*current_destination);
writer.u32(*remaining);
writer.bool(*active);
writer.bool(*first);
writer.option(data_latch.as_ref(), |value, writer| writer.u32(*value));
}
fn load_channel(
channel: &mut Channel,
index: usize,
reader: &mut Reader<'_>,
) -> Result<(), SnapshotError> {
let Channel {
source,
destination,
count,
control,
current_source,
current_destination,
remaining,
active,
first,
data_latch,
} = channel;
*source = reader.u32()?;
*destination = reader.u32()?;
*count = reader.u16()?;
*control = reader.u16()?;
*current_source = reader.u32()?;
*current_destination = reader.u32()?;
*remaining = reader.u32()?;
*active = reader.bool()?;
*first = reader.bool()?;
*data_latch = reader.option(Reader::u32)?;
let source_mask = if index == 0 { 0x07ff_fffe } else { 0x0fff_fffe };
let destination_mask = if index == 3 { 0x0fff_fffe } else { 0x07ff_fffe };
let count_mask = if index == 3 { 0xffff } else { 0x3fff };
let control_mask = if index == 3 { 0xffe0 } else { 0xf7e0 };
let max_remaining = if index == 3 { 0x1_0000 } else { 0x4000 };
if *source & !source_mask != 0
|| *destination & !destination_mask != 0
|| *count & !count_mask != 0
|| *control & !control_mask != 0
|| *remaining > max_remaining
|| (*active && (*remaining == 0 || *control & 0x8000 == 0))
{
return Err(SnapshotError::Corrupt("DMA channel"));
}
Ok(())
}
impl Snapshot for Dma {
fn save(&self, writer: &mut Writer) {
let Self { channels } = self;
writer.tag(b"DMA0");
for channel in channels {
save_channel(channel, writer);
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { channels } = self;
reader.tag(b"DMA0")?;
for (index, channel) in channels.iter_mut().enumerate() {
load_channel(channel, index, reader)?;
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn state() -> Dma {
Dma {
channels: [
Channel {
source: 0x0200_1000,
destination: 0x0300_1000,
count: 0x1234,
control: 0x8000,
current_source: 0x0200_2000,
current_destination: 0x0300_3000,
remaining: 0x1234,
active: true,
first: true,
data_latch: Some(0x1122_3344),
},
Channel {
source: 0x0800_0000,
destination: 0x0600_1000,
count: 0x2345,
control: 0xc660,
current_source: 0x0800_1234,
current_destination: 0x0600_2344,
remaining: 0x2345,
active: true,
first: false,
data_latch: None,
},
Channel {
source: 0x0800_2000,
destination: 0x0600_2000,
count: 0x0234,
control: 0xa000,
current_source: 0x0800_3000,
current_destination: 0x0600_4000,
remaining: 5,
active: true,
first: false,
data_latch: Some(0x5566_7788),
},
Channel {
source: 0x0a00_4000,
destination: 0x0800_4000,
count: 0xabcd,
control: 0x4000,
current_source: 0x0a00_5000,
current_destination: 0x0800_6000,
remaining: 0x4567,
active: false,
first: true,
data_latch: None,
},
],
}
}
fn encode(dma: &Dma) -> Vec<u8> {
let mut writer = Writer::new();
dma.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn round_trip_and_reject_every_strict_prefix() {
let source = state();
let bytes = encode(&source);
let mut restored = Dma::default();
let mut reader = Reader::new(&bytes);
restored.load(&mut reader).unwrap();
reader.finish().unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
let mut truncated = Reader::new(&bytes[..end]);
assert!(
Dma::default().load(&mut truncated).is_err(),
"accepted prefix of length {end}"
);
}
}
#[test]
fn invalid_boolean_discriminant_is_corrupt() {
let mut bytes = encode(&state());
bytes[28] = 2;
let result = Dma::default().load(&mut Reader::new(&bytes));
assert!(matches!(result, Err(SnapshotError::Corrupt(_))));
}
}

View file

@ -3,6 +3,7 @@
//! snapshots, following mGBA's polling model. Exact hardware edges are unverified.
use super::Buttons;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Keypad {
@ -102,3 +103,79 @@ mod tests {
}
}
}
impl Snapshot for Keypad {
fn save(&self, writer: &mut Writer) {
let Self {
buttons,
control,
last_match,
} = self;
writer.tag(b"KEYP");
writer.u16(buttons.bits());
writer.u16(*control);
writer.option(last_match.as_ref(), |value, writer| writer.u16(*value));
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
buttons,
control,
last_match,
} = self;
reader.tag(b"KEYP")?;
let button_bits = reader.u16()?;
*control = reader.u16()?;
*last_match = reader.option(Reader::u16)?;
if button_bits & !0x03ff != 0
|| *control & !0xc3ff != 0
|| last_match.is_some_and(|pressed| pressed & !0x03ff != 0)
{
return Err(SnapshotError::Corrupt("keypad state"));
}
*buttons = Buttons::from_bits(button_bits);
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encode(keypad: &Keypad) -> Vec<u8> {
let mut writer = Writer::new();
keypad.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn round_trip_and_reject_every_strict_prefix() {
let source = Keypad {
buttons: Buttons::from_bits(0x0155),
control: 0xc155,
last_match: Some(0x02aa),
};
let bytes = encode(&source);
let mut restored = Keypad::default();
let mut reader = Reader::new(&bytes);
restored.load(&mut reader).unwrap();
reader.finish().unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
let mut truncated = Reader::new(&bytes[..end]);
assert!(
Keypad::default().load(&mut truncated).is_err(),
"accepted prefix of length {end}"
);
}
}
#[test]
fn invalid_option_discriminant_is_corrupt() {
let mut bytes = encode(&Keypad::default());
bytes[8] = 2;
let result = Keypad::default().load(&mut Reader::new(&bytes));
assert!(matches!(result, Err(SnapshotError::Corrupt(_))));
}
}

View file

@ -11,6 +11,7 @@ pub use serial::{
JOYCNT, JOYSTAT, JOY_RECV, JOY_TRANS, RCNT, SIOCNT, SIODATA32, SIODATA8, SIOMLT_SEND,
SIOMULTI0, SIOMULTI1, SIOMULTI2, SIOMULTI3,
};
mod snapshot;
mod timer_step;
pub(crate) use timer_step::TimerStep;
use timer_step::STAGED_IRQ_MASK;

View file

@ -1,5 +1,6 @@
//! Disconnected serial: normal shifting, idle multiplayer, and local Joybus registers.
use super::replace_byte;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
pub const SIODATA32: u32 = 0x0400_0120;
pub const SIOMULTI0: u32 = SIODATA32;
@ -283,3 +284,139 @@ impl Serial {
0
}
}
impl Snapshot for Serial {
fn save(&self, writer: &mut Writer) {
let Self {
data,
multi_extra,
send,
control,
rcnt,
joy_control,
joy_receive,
joy_transmit,
joy_status,
bits_left,
until_bit,
} = self;
writer.tag(b"SER0");
writer.u32(*data);
writer.u32(*multi_extra);
writer.u16(*send);
writer.u16(*control);
writer.u16(*rcnt);
writer.u8(*joy_control);
writer.u32(*joy_receive);
writer.u32(*joy_transmit);
writer.u8(*joy_status);
writer.u8(*bits_left);
writer.u32(*until_bit);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
data,
multi_extra,
send,
control,
rcnt,
joy_control,
joy_receive,
joy_transmit,
joy_status,
bits_left,
until_bit,
} = self;
reader.tag(b"SER0")?;
*data = reader.u32()?;
*multi_extra = reader.u32()?;
*send = reader.u16()?;
*control = reader.u16()?;
*rcnt = reader.u16()?;
*joy_control = reader.u8()?;
*joy_receive = reader.u32()?;
*joy_transmit = reader.u32()?;
*joy_status = reader.u8()?;
*bits_left = reader.u8()?;
*until_bit = reader.u32()?;
let control_mask = if *control & 0x3000 == 0x2000 {
0x6f03
} else {
0x508b
};
if *control & !control_mask != 0 {
return Err(SnapshotError::Corrupt("serial control"));
}
if *rcnt & !0xc1ff != 0 || *joy_control & !0x40 != 0 || *joy_status & !0x32 != 0 {
return Err(SnapshotError::Corrupt("serial register"));
}
let transfer = *rcnt & 0x8000 == 0 && *control & 0x3000 != 0x2000 && *control & 0x80 != 0;
if transfer {
let width = if *control & 0x1000 != 0 { 32 } else { 8 };
let period = if *control & 2 != 0 { 8 } else { 64 };
if *bits_left == 0 || *bits_left > width || *until_bit == 0 || *until_bit > period {
return Err(SnapshotError::Corrupt("serial transfer"));
}
} else if *bits_left != 0 || *until_bit != 0 {
return Err(SnapshotError::Corrupt("serial transfer"));
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn state() -> Serial {
Serial {
data: 0x1234_5678,
multi_extra: 0x8765_4321,
send: 0x5aa5,
control: 0x1083,
rcnt: 0x0123,
joy_control: 0x40,
joy_receive: 0x0bad_cafe,
joy_transmit: 0xfeed_face,
joy_status: 0x32,
bits_left: 23,
until_bit: 4,
}
}
fn encode(serial: &Serial) -> Vec<u8> {
let mut writer = Writer::new();
serial.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn round_trip_and_reject_every_strict_prefix() {
let source = state();
let bytes = encode(&source);
let mut restored = Serial::default();
let mut reader = Reader::new(&bytes);
restored.load(&mut reader).unwrap();
reader.finish().unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
let mut truncated = Reader::new(&bytes[..end]);
assert!(
Serial::default().load(&mut truncated).is_err(),
"accepted prefix of length {end}"
);
}
}
#[test]
fn invalid_control_bits_are_corrupt() {
let mut bytes = encode(&state());
bytes[14..16].copy_from_slice(&u16::MAX.to_le_bytes());
let result = Serial::default().load(&mut Reader::new(&bytes));
assert!(matches!(result, Err(SnapshotError::Corrupt(_))));
}
}

View file

@ -0,0 +1,165 @@
//! Save-state encoding for the I/O register file and its timers.
use super::{Io, PowerState, Timer};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
/// Timer control bits that the register can hold (see `Timer::set_control`).
const TIMER_CONTROL_MASK: u8 = 0xc7;
impl Snapshot for Timer {
fn save(&self, writer: &mut Writer) {
let Self {
reload,
counter,
control,
} = self;
writer.u16(*reload);
writer.u16(*counter);
writer.u8(*control);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
reload,
counter,
control,
} = self;
*reload = reader.u16()?;
*counter = reader.u16()?;
*control = reader.u8()?;
if *control & !TIMER_CONTROL_MASK != 0 {
return Err(SnapshotError::Corrupt("timer control"));
}
Ok(())
}
}
impl PowerState {
fn to_byte(self) -> u8 {
match self {
Self::Running => 0,
Self::Halt => 1,
Self::Stop => 2,
}
}
fn from_byte(byte: u8) -> Result<Self, SnapshotError> {
match byte {
0 => Ok(Self::Running),
1 => Ok(Self::Halt),
2 => Ok(Self::Stop),
_ => Err(SnapshotError::Corrupt("power state")),
}
}
}
impl Snapshot for Io {
fn save(&self, writer: &mut Writer) {
let Self {
serial,
audio,
audio_capture,
timers,
timer_phase,
enable,
pending,
master_enable,
waitcnt,
dispcnt,
greenswap,
mosaic,
vertical_mosaic,
backgrounds,
effects,
window_edges,
horizontal_windows,
keypad,
display,
dma,
postflg,
power,
} = self;
writer.tag(b"IO00");
serial.save(writer);
audio.save(writer);
audio_capture.save(writer);
for timer in timers {
timer.save(writer);
}
writer.u16(*timer_phase);
writer.u16(*enable);
writer.u16(*pending);
writer.bool(*master_enable);
writer.u16(*waitcnt);
writer.u16(*dispcnt);
writer.bool(*greenswap);
writer.u16(*mosaic);
vertical_mosaic.save(writer);
for background in backgrounds {
background.save(writer);
}
effects.save(writer);
window_edges.save(writer);
horizontal_windows.save(writer);
keypad.save(writer);
display.save(writer);
dma.save(writer);
writer.bool(*postflg);
writer.u8(power.to_byte());
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
serial,
audio,
audio_capture,
timers,
timer_phase,
enable,
pending,
master_enable,
waitcnt,
dispcnt,
greenswap,
mosaic,
vertical_mosaic,
backgrounds,
effects,
window_edges,
horizontal_windows,
keypad,
display,
dma,
postflg,
power,
} = self;
reader.tag(b"IO00")?;
serial.load(reader)?;
audio.load(reader)?;
audio_capture.load(reader)?;
for timer in timers {
timer.load(reader)?;
}
*timer_phase = reader.u16()?;
*enable = reader.u16()?;
*pending = reader.u16()?;
*master_enable = reader.bool()?;
*waitcnt = reader.u16()?;
*dispcnt = reader.u16()?;
*greenswap = reader.bool()?;
*mosaic = reader.u16()?;
vertical_mosaic.load(reader)?;
for background in backgrounds {
background.load(reader)?;
}
effects.load(reader)?;
window_edges.load(reader)?;
horizontal_windows.load(reader)?;
keypad.load(reader)?;
display.load(reader)?;
dma.load(reader)?;
*postflg = reader.bool()?;
*power = PowerState::from_byte(reader.u8()?)?;
Ok(())
}
}

View file

@ -10,5 +10,6 @@ pub mod input;
pub mod io;
pub mod machine;
pub mod memory;
pub mod snapshot;
pub mod timing;
pub mod video;

View file

@ -9,6 +9,7 @@ use crate::{
memory::Memory,
timing::StepTiming,
};
mod snapshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepKind {

View file

@ -0,0 +1,131 @@
//! Whole-machine save states.
//!
//! Layout: `GBAS`, format version, ROM length and fingerprint, firmware
//! presence and fingerprint, cartridge setup, payload length, payload, then a
//! fingerprint of every preceding byte. The payload is CPU, memory bus (with all
//! devices), and last step timing, each in the positional encoding of
//! [`crate::snapshot`].
use super::Machine;
use crate::snapshot::{fingerprint, Reader, Snapshot, SnapshotError, Writer};
const MAGIC: &[u8; 4] = b"GBAS";
/// Bump on any change to an encoded type; older states are rejected, not migrated.
const VERSION: u32 = 1;
const CHECKSUM_LEN: usize = 8;
impl Machine {
/// Encode the emulated hardware at the current step boundary.
///
/// The state holds no ROM or firmware bytes: it records their fingerprints and
/// can be restored only into a machine built from the same images and the same
/// cartridge hardware/save-device selection. Fails with `MidStep` inside a step
/// and `Unsupported` when a deferred diagnostic cannot be encoded.
pub fn save_state(&self) -> Result<Vec<u8>, SnapshotError> {
let mut payload = Writer::new();
self.cpu.save(&mut payload);
self.memory.save(&mut payload);
save_timing(&self.last_timing, &mut payload);
let payload = payload.finish()?;
let mut writer = Writer::new();
writer.raw(MAGIC);
writer.u32(VERSION);
self.write_identity(&mut writer);
writer.bytes(&payload);
let mut bytes = writer.finish()?;
let checksum = fingerprint(&bytes);
bytes.extend_from_slice(&checksum.to_le_bytes());
Ok(bytes)
}
/// Restore a state produced by [`Machine::save_state`].
///
/// The format, checksum, and image/cartridge identity are all verified before
/// any machine state changes, so every such failure leaves the machine intact.
/// A later decoding error is reported as `RestoreFailed`: it indicates an encoder
/// defect, and the machine is then unspecified and must be rebuilt from the ROM.
/// Host configuration (ROM, firmware, cartridge setup, row-capture and
/// audio-capture enablement) is kept; capture queues and counters are cleared.
pub fn load_state(&mut self, bytes: &[u8]) -> Result<(), SnapshotError> {
if bytes.len() < MAGIC.len() + 4 + CHECKSUM_LEN {
return Err(SnapshotError::Truncated);
}
if &bytes[..MAGIC.len()] != MAGIC {
return Err(SnapshotError::BadMagic);
}
let mut reader = Reader::new(&bytes[MAGIC.len()..]);
let version = reader.u32()?;
if version != VERSION {
return Err(SnapshotError::UnsupportedVersion(version));
}
let (body, checksum) = bytes.split_at(bytes.len() - CHECKSUM_LEN);
if fingerprint(body).to_le_bytes() != checksum {
return Err(SnapshotError::Checksum);
}
let mut reader = Reader::new(&body[MAGIC.len() + 4..]);
self.check_identity(&mut reader)?;
let payload = reader.bytes_vec(body.len())?;
reader.finish()?;
let mut payload = Reader::new(&payload);
self.restore(&mut payload)
.map_err(|cause| SnapshotError::RestoreFailed(Box::new(cause)))
}
fn restore(&mut self, payload: &mut Reader<'_>) -> Result<(), SnapshotError> {
self.cpu.load(payload)?;
self.memory.load(payload)?;
self.last_timing = load_timing(payload)?;
payload.finish()
}
fn write_identity(&self, writer: &mut Writer) {
let (rom, bios) = self.memory.images();
writer.u64(rom.len() as u64);
writer.u64(fingerprint(rom));
writer.option(bios.as_ref(), |bios, writer| {
writer.u64(fingerprint(bios));
});
writer.raw(&self.memory.setup_identity());
}
fn check_identity(&self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let (rom, bios) = self.memory.images();
if reader.u64()? != rom.len() as u64 || reader.u64()? != fingerprint(rom) {
return Err(SnapshotError::ConfigMismatch("ROM image"));
}
let saved_bios = reader.option(Reader::u64)?;
if saved_bios != bios.map(fingerprint) {
return Err(SnapshotError::ConfigMismatch("firmware image"));
}
let mut setup = [0; 2];
reader.raw_into(&mut setup)?;
if setup != self.memory.setup_identity() {
return Err(SnapshotError::ConfigMismatch("cartridge setup"));
}
Ok(())
}
}
fn save_timing(timing: &crate::timing::StepTiming, writer: &mut Writer) {
let crate::timing::StepTiming {
code_cycles,
data_cycles,
internal_cycles,
idle_cycles,
} = timing;
writer.u32(*code_cycles);
writer.u32(*data_cycles);
writer.u32(*internal_cycles);
writer.u32(*idle_cycles);
}
fn load_timing(reader: &mut Reader<'_>) -> Result<crate::timing::StepTiming, SnapshotError> {
Ok(crate::timing::StepTiming {
code_cycles: reader.u32()?,
data_cycles: reader.u32()?,
internal_cycles: reader.u32()?,
idle_cycles: reader.u32()?,
})
}

View file

@ -26,13 +26,14 @@ mod multiplayer_step_tests;
mod prefetch_tests;
#[cfg(test)]
mod serial_step_tests;
mod snapshot;
#[cfg(test)]
mod timer_step_tests;
#[cfg(test)]
mod timing_event_tests;
#[cfg(test)]
mod wave_step_tests;
pub(crate) use fetch::InstructionFetch;
pub(crate) use fetch::{load_fetch_result, save_fetch_result, InstructionFetch};
use iwram_bus::IwramBus;
use crate::{

View file

@ -1,6 +1,7 @@
//! One mapped sample supplies instruction bits and the supported bus observation.
//! Sampling is side-effect-free; execution commits history under the diagnostic policy.
use super::{InstructionSet, Memory, MemoryError};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InstructionFetch {
@ -75,3 +76,170 @@ impl Memory {
Ok(u32::from_le_bytes(bytes))
}
}
impl Snapshot for InstructionFetch {
fn save(&self, writer: &mut Writer) {
let Self {
address,
state,
instruction,
bus_word,
} = self;
writer.tag(b"FETC");
writer.u32(*address);
writer.u8(match state {
InstructionSet::Arm => 0,
InstructionSet::Thumb => 1,
});
save_fetch_result(instruction, writer);
writer.option(bus_word.as_ref(), |word, writer| writer.u32(*word));
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
address,
state,
instruction,
bus_word,
} = self;
reader.tag(b"FETC")?;
*address = reader.u32()?;
*state = match reader.u8()? {
0 => InstructionSet::Arm,
1 => InstructionSet::Thumb,
_ => return Err(SnapshotError::Corrupt("instruction set")),
};
*instruction = load_fetch_result(reader)?;
*bus_word = reader.option(|reader| reader.u32())?;
Ok(())
}
}
/// A deferred fetch diagnostic is stored beside the instruction it would have
/// produced. Address-only errors are encoded; errors that carry a static
/// description string are not, and fail the save.
pub(crate) fn save_fetch_result(instruction: &Result<u32, MemoryError>, writer: &mut Writer) {
match instruction {
Ok(value) => {
writer.u8(0);
writer.u32(*value);
}
Err(MemoryError::Unmapped(address)) => {
writer.u8(1);
writer.u32(*address);
}
Err(MemoryError::ReadOnly(address)) => {
writer.u8(2);
writer.u32(*address);
}
Err(MemoryError::Unaligned(address)) => {
writer.u8(3);
writer.u32(*address);
}
Err(_) => {
writer.u8(0xff);
writer.fail(SnapshotError::Unsupported("deferred fetch diagnostic"));
}
}
}
pub(crate) fn load_fetch_result(
reader: &mut Reader<'_>,
) -> Result<Result<u32, MemoryError>, SnapshotError> {
Ok(match reader.u8()? {
0 => Ok(reader.u32()?),
1 => Err(MemoryError::Unmapped(reader.u32()?)),
2 => Err(MemoryError::ReadOnly(reader.u32()?)),
3 => Err(MemoryError::Unaligned(reader.u32()?)),
_ => return Err(SnapshotError::Corrupt("fetch result")),
})
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn populated_fetch() -> InstructionFetch {
InstructionFetch {
address: 0x0800_0022,
state: InstructionSet::Thumb,
instruction: Ok(0x46c0),
bus_word: Some(0x1234_46c0),
}
}
fn save(fetch: &InstructionFetch) -> Vec<u8> {
let mut writer = Writer::new();
fetch.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn instruction_fetch_round_trips_all_fields() {
let source = populated_fetch();
let bytes = save(&source);
let mut restored = InstructionFetch {
address: 0,
state: InstructionSet::Arm,
instruction: Ok(0),
bus_word: None,
};
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
}
#[test]
fn every_strict_fetch_prefix_is_rejected() {
let bytes = save(&populated_fetch());
for end in 0..bytes.len() {
let mut restored = populated_fetch();
assert!(
restored.load(&mut Reader::new(&bytes[..end])).is_err(),
"accepted prefix of length {end}"
);
}
}
#[test]
fn instruction_fetch_rejects_unknown_instruction_set() {
let mut bytes = save(&populated_fetch());
bytes[8] = 0xff;
let mut restored = populated_fetch();
assert!(matches!(
restored.load(&mut Reader::new(&bytes)),
Err(SnapshotError::Corrupt(_))
));
}
#[test]
fn address_diagnostics_round_trip_and_described_ones_cannot_be_saved() {
for error in [
MemoryError::Unmapped(0x0200_0000),
MemoryError::ReadOnly(0x0800_0000),
MemoryError::Unaligned(0x0300_0001),
] {
let source = InstructionFetch {
instruction: Err(error),
..populated_fetch()
};
let bytes = save(&source);
let mut restored = populated_fetch();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
}
let fetch = InstructionFetch {
instruction: Err(MemoryError::UnsupportedIo {
address: 0x0400_0000,
value: 0,
operation: "write",
}),
..populated_fetch()
};
let mut writer = Writer::new();
fetch.save(&mut writer);
assert_eq!(
writer.finish(),
Err(SnapshotError::Unsupported("deferred fetch diagnostic"))
);
}
}

View file

@ -2,6 +2,7 @@
//! CPU instructions stage changes; successful DMA units commit accesses in order.
use std::cell::Cell;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use crate::timing::AccessWidth;
#[derive(Clone, Copy, Default)]
@ -115,3 +116,101 @@ mod tests {
assert_eq!(bus.snapshot(), Some(0x5678_3344));
}
}
impl Snapshot for IwramBus {
fn save(&self, writer: &mut Writer) {
let Self { committed, pending } = self;
if pending.get().is_some() {
writer.fail(SnapshotError::MidStep);
return;
}
writer.tag(b"IWBS");
let Lanes { value, known } = committed;
writer.u32(*value);
writer.u32(*known);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { committed, pending } = self;
reader.tag(b"IWBS")?;
if pending.get().is_some() {
return Err(SnapshotError::MidStep);
}
let value = reader.u32()?;
let known = reader.u32()?;
if value & !known != 0
|| [0x0000_00ff, 0x0000_ff00, 0x00ff_0000, 0xff00_0000]
.into_iter()
.any(|lane| {
let bits = known & lane;
bits != 0 && bits != lane
})
{
return Err(SnapshotError::Corrupt("IWRAM bus lanes"));
}
*committed = Lanes { value, known };
pending.set(None);
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn populated_bus() -> IwramBus {
let mut bus = IwramBus::default();
bus.dma_access(0x0300_0000, AccessWidth::Byte, 0x12);
bus.dma_access(0x0300_0002, AccessWidth::Halfword, 0x3456);
bus
}
fn save(bus: &IwramBus) -> Vec<u8> {
let mut writer = Writer::new();
bus.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn iwram_bus_round_trips_committed_lane_history() {
let source = populated_bus();
let bytes = save(&source);
let mut restored = IwramBus::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
let mut writer = Writer::new();
restored.save(&mut writer);
assert_eq!(writer.finish().unwrap(), bytes);
}
#[test]
fn every_strict_iwram_bus_prefix_is_rejected() {
let bytes = save(&populated_bus());
for end in 0..bytes.len() {
let mut restored = IwramBus::default();
assert!(
restored.load(&mut Reader::new(&bytes[..end])).is_err(),
"accepted prefix of length {end}"
);
}
}
#[test]
fn iwram_bus_rejects_values_in_unknown_lanes() {
let mut bytes = save(&populated_bus());
bytes[5] |= 0x80;
let mut restored = IwramBus::default();
assert!(matches!(
restored.load(&mut Reader::new(&bytes)),
Err(SnapshotError::Corrupt(_))
));
}
#[test]
fn iwram_bus_rejects_transaction_staging() {
let mut bus = populated_bus();
bus.begin();
let mut writer = Writer::new();
bus.save(&mut writer);
assert_eq!(writer.finish(), Err(SnapshotError::MidStep));
}
}

View file

@ -0,0 +1,142 @@
//! Save-state encoding for the memory bus and everything it owns.
use super::Memory;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use crate::video::capture::Capture;
impl Memory {
/// ROM and firmware bytes, for state identity checks.
pub(crate) fn images(&self) -> (&[u8], Option<&[u8]>) {
(&self.rom, self.bios.as_deref())
}
/// Cartridge hardware and save-device selection, which a state must match.
pub(crate) fn setup_identity(&self) -> [u8; 2] {
self.cartridge.setup_identity()
}
/// Staged per-step transactions exist only inside `Machine::step`.
fn mid_step(&self) -> bool {
self.cartridge_step.get().is_some()
|| self.timer_step.get().is_some()
|| self.cpu_timing.get().is_some()
|| self.cpu_access.is_some()
|| self.cartridge_elapsed.get() != 0
}
}
impl Snapshot for Memory {
fn save(&self, writer: &mut Writer) {
if self.mid_step() {
writer.fail(SnapshotError::MidStep);
return;
}
let Self {
external_ram,
internal_ram,
palette_ram,
video_ram,
oam,
rom: _, // Host image, identified by the state header.
save_image,
save_modified,
cartridge_elapsed: _, // Zero between steps; checked above.
cartridge,
cartridge_step: _, // Staged transaction; empty between steps.
bios: _, // Host image, identified by the state header.
io,
cycles,
cpu_timing: _, // Staged transaction; empty between steps.
timer_step: _, // Staged transaction; empty between steps.
gamepak_prefetch,
#[cfg(test)]
last_cpu_timing: _, // Test trace only.
cpu_resume_nonsequential,
cpu_access: _, // Staged transaction; empty between steps.
bios_prefetch,
iwram_bus,
scanline_capture,
sprite_pipeline,
} = self;
writer.tag(b"MEM0");
// The cartridge comes first so a differing save device reports a
// configuration mismatch before any memory-size error.
cartridge.save(writer);
writer.bytes(external_ram);
writer.bytes(internal_ram);
writer.bytes(palette_ram);
writer.bytes(video_ram);
writer.bytes(oam);
writer.bytes(save_image);
writer.bool(*save_modified);
io.save(writer);
writer.u64(*cycles);
gamepak_prefetch.save(writer);
writer.bool(*cpu_resume_nonsequential);
writer.option(bios_prefetch.as_ref(), |value, writer| writer.u32(*value));
iwram_bus.save(writer);
writer.option(scanline_capture.as_ref(), |capture, writer| {
capture.save(writer);
});
sprite_pipeline.save(writer);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
if self.mid_step() {
return Err(SnapshotError::MidStep);
}
let Self {
external_ram,
internal_ram,
palette_ram,
video_ram,
oam,
rom: _,
save_image,
save_modified,
cartridge_elapsed: _,
cartridge,
cartridge_step: _,
bios: _,
io,
cycles,
cpu_timing: _,
timer_step: _,
gamepak_prefetch,
#[cfg(test)]
last_cpu_timing: _,
cpu_resume_nonsequential,
cpu_access: _,
bios_prefetch,
iwram_bus,
scanline_capture,
sprite_pipeline,
} = self;
reader.tag(b"MEM0")?;
cartridge.load(reader)?;
reader.bytes_into(external_ram)?;
reader.bytes_into(internal_ram)?;
reader.bytes_into(palette_ram)?;
reader.bytes_into(video_ram)?;
reader.bytes_into(oam)?;
reader.bytes_into(save_image)?;
*save_modified = reader.bool()?;
io.load(reader)?;
*cycles = reader.u64()?;
gamepak_prefetch.load(reader)?;
*cpu_resume_nonsequential = reader.bool()?;
*bios_prefetch = reader.option(Reader::u32)?;
iwram_bus.load(reader)?;
// Whether row capture runs is host configuration; only its contents are state.
let captured = reader.option(|reader| {
let mut capture = Capture::default();
capture.load(reader)?;
Ok(capture)
})?;
if scanline_capture.is_some() {
*scanline_capture = Some(captured.unwrap_or_default());
}
sprite_pipeline.load(reader)?;
Ok(())
}
}

View file

@ -0,0 +1,339 @@
//! Binary encoding primitives for save states.
//!
//! A state captures emulated hardware only. Configuration chosen by the host
//! (ROM, firmware image, cartridge hardware and save-device selection) is
//! supplied when the target machine is built and is checked, not restored.
//! Every multi-byte value is little-endian. The encoding is positional: a type
//! must read exactly what it wrote, in the same order. Section tags make a
//! misaligned reader fail at the next boundary instead of decoding garbage.
use std::error::Error;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SnapshotError {
/// The stream ended before a value or section was complete.
Truncated,
/// Bytes remain after the final section.
TrailingData,
/// The header does not identify a save state.
BadMagic,
/// The format version is not understood by this build.
UnsupportedVersion(u32),
/// The payload does not match its recorded checksum.
Checksum,
/// The state was captured with a different ROM, firmware, or cartridge setup.
ConfigMismatch(&'static str),
/// A section tag, enum value, or length is outside its valid range.
Corrupt(&'static str),
/// The machine is inside a CPU step; states exist only between steps.
MidStep,
/// The machine holds live state that has no encoding, such as a deferred
/// diagnostic error. Saving at a later step boundary normally succeeds.
Unsupported(&'static str),
/// Decoding failed after the machine began to change. The machine state is
/// unspecified and must be rebuilt. Valid states never produce this.
RestoreFailed(Box<SnapshotError>),
}
impl fmt::Display for SnapshotError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Truncated => write!(formatter, "save state is truncated"),
Self::TrailingData => write!(formatter, "save state has trailing data"),
Self::BadMagic => write!(formatter, "not a save state"),
Self::UnsupportedVersion(version) => {
write!(formatter, "unsupported save-state version {version}")
}
Self::Checksum => write!(formatter, "save state checksum mismatch"),
Self::ConfigMismatch(what) => {
write!(formatter, "save state does not match this machine: {what}")
}
Self::Corrupt(what) => write!(formatter, "corrupt save state: {what}"),
Self::MidStep => write!(formatter, "cannot save state inside a CPU step"),
Self::Unsupported(what) => write!(formatter, "cannot save state: {what}"),
Self::RestoreFailed(cause) => {
write!(
formatter,
"restore failed midway, machine must be rebuilt: {cause}"
)
}
}
}
}
impl Error for SnapshotError {}
/// Hardware state that survives in a save state.
///
/// `load` overwrites emulated state in place and keeps host configuration.
/// A failed `load` may leave `self` partly overwritten; callers validate the
/// whole stream (`Machine::load_state` does) before the first mutation.
pub(crate) trait Snapshot {
fn save(&self, writer: &mut Writer);
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError>;
}
#[derive(Debug, Default)]
pub(crate) struct Writer {
bytes: Vec<u8>,
error: Option<SnapshotError>,
}
impl Writer {
pub(crate) fn new() -> Self {
Self::default()
}
/// The first recorded failure, or the encoded bytes.
pub(crate) fn finish(self) -> Result<Vec<u8>, SnapshotError> {
match self.error {
Some(error) => Err(error),
None => Ok(self.bytes),
}
}
/// Record that the current state cannot be encoded. The first failure wins;
/// callers may keep writing, and `finish` reports it.
pub(crate) fn fail(&mut self, error: SnapshotError) {
self.error.get_or_insert(error);
}
/// Four-byte section marker, checked by `Reader::tag`.
pub(crate) fn tag(&mut self, tag: &[u8; 4]) {
self.bytes.extend_from_slice(tag);
}
pub(crate) fn u8(&mut self, value: u8) {
self.bytes.push(value);
}
pub(crate) fn bool(&mut self, value: bool) {
self.u8(u8::from(value));
}
pub(crate) fn u16(&mut self, value: u16) {
self.bytes.extend_from_slice(&value.to_le_bytes());
}
pub(crate) fn i16(&mut self, value: i16) {
self.bytes.extend_from_slice(&value.to_le_bytes());
}
pub(crate) fn u32(&mut self, value: u32) {
self.bytes.extend_from_slice(&value.to_le_bytes());
}
pub(crate) fn u64(&mut self, value: u64) {
self.bytes.extend_from_slice(&value.to_le_bytes());
}
/// Raw bytes with no length prefix. The reader must know the length.
pub(crate) fn raw(&mut self, bytes: &[u8]) {
self.bytes.extend_from_slice(bytes);
}
/// Length-prefixed bytes.
pub(crate) fn bytes(&mut self, bytes: &[u8]) {
self.u32(u32::try_from(bytes.len()).expect("snapshot field exceeds 4 GiB"));
self.raw(bytes);
}
/// `false`, or `true` followed by the value written by `save`.
pub(crate) fn option<T>(&mut self, value: Option<&T>, save: impl FnOnce(&T, &mut Self)) {
self.bool(value.is_some());
if let Some(value) = value {
save(value, self);
}
}
}
#[derive(Debug)]
pub(crate) struct Reader<'a> {
bytes: &'a [u8],
}
impl<'a> Reader<'a> {
pub(crate) fn new(bytes: &'a [u8]) -> Self {
Self { bytes }
}
pub(crate) fn remaining(&self) -> usize {
self.bytes.len()
}
/// Fails with `TrailingData` unless every byte was consumed.
pub(crate) fn finish(&self) -> Result<(), SnapshotError> {
if self.bytes.is_empty() {
Ok(())
} else {
Err(SnapshotError::TrailingData)
}
}
fn take(&mut self, length: usize) -> Result<&'a [u8], SnapshotError> {
if self.bytes.len() < length {
return Err(SnapshotError::Truncated);
}
let (head, tail) = self.bytes.split_at(length);
self.bytes = tail;
Ok(head)
}
fn array<const N: usize>(&mut self) -> Result<[u8; N], SnapshotError> {
let mut array = [0; N];
array.copy_from_slice(self.take(N)?);
Ok(array)
}
pub(crate) fn tag(&mut self, expected: &[u8; 4]) -> Result<(), SnapshotError> {
if self.take(4)? == expected {
Ok(())
} else {
Err(SnapshotError::Corrupt("section tag"))
}
}
pub(crate) fn u8(&mut self) -> Result<u8, SnapshotError> {
Ok(self.array::<1>()?[0])
}
pub(crate) fn bool(&mut self) -> Result<bool, SnapshotError> {
match self.u8()? {
0 => Ok(false),
1 => Ok(true),
_ => Err(SnapshotError::Corrupt("boolean")),
}
}
pub(crate) fn u16(&mut self) -> Result<u16, SnapshotError> {
Ok(u16::from_le_bytes(self.array()?))
}
pub(crate) fn i16(&mut self) -> Result<i16, SnapshotError> {
Ok(i16::from_le_bytes(self.array()?))
}
pub(crate) fn u32(&mut self) -> Result<u32, SnapshotError> {
Ok(u32::from_le_bytes(self.array()?))
}
pub(crate) fn u64(&mut self) -> Result<u64, SnapshotError> {
Ok(u64::from_le_bytes(self.array()?))
}
/// Fill `destination` from bytes written by `Writer::raw`.
pub(crate) fn raw_into(&mut self, destination: &mut [u8]) -> Result<(), SnapshotError> {
destination.copy_from_slice(self.take(destination.len())?);
Ok(())
}
/// Fill `destination` from bytes written by `Writer::bytes`. The recorded
/// length must equal `destination.len()`; fixed-size memories never resize.
pub(crate) fn bytes_into(&mut self, destination: &mut [u8]) -> Result<(), SnapshotError> {
let length = self.u32()? as usize;
if length != destination.len() {
return Err(SnapshotError::Corrupt("memory size"));
}
self.raw_into(destination)
}
/// Length-prefixed bytes as a vector, rejecting lengths above `limit`.
pub(crate) fn bytes_vec(&mut self, limit: usize) -> Result<Vec<u8>, SnapshotError> {
let length = self.u32()? as usize;
if length > limit {
return Err(SnapshotError::Corrupt("length"));
}
Ok(self.take(length)?.to_vec())
}
pub(crate) fn option<T>(
&mut self,
load: impl FnOnce(&mut Self) -> Result<T, SnapshotError>,
) -> Result<Option<T>, SnapshotError> {
if self.bool()? {
load(self).map(Some)
} else {
Ok(None)
}
}
}
/// FNV-1a over `bytes`; identifies images and detects accidental corruption.
/// It is not a security boundary.
pub(crate) fn fingerprint(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_round_trip_in_order() {
let mut writer = Writer::new();
writer.tag(b"TEST");
writer.u8(0xab);
writer.bool(true);
writer.u16(0xbeef);
writer.i16(-2);
writer.u32(0xdead_beef);
writer.u64(u64::MAX - 1);
writer.bytes(&[1, 2, 3]);
writer.option(Some(&7u16), |value, writer| writer.u16(*value));
writer.option(None::<&u16>, |value, writer| writer.u16(*value));
let bytes = writer.finish().unwrap();
let mut reader = Reader::new(&bytes);
reader.tag(b"TEST").unwrap();
assert_eq!(reader.u8().unwrap(), 0xab);
assert!(reader.bool().unwrap());
assert_eq!(reader.u16().unwrap(), 0xbeef);
assert_eq!(reader.i16().unwrap(), -2);
assert_eq!(reader.u32().unwrap(), 0xdead_beef);
assert_eq!(reader.u64().unwrap(), u64::MAX - 1);
let mut three = [0; 3];
reader.bytes_into(&mut three).unwrap();
assert_eq!(three, [1, 2, 3]);
assert_eq!(reader.option(Reader::u16).unwrap(), Some(7));
assert_eq!(reader.option(Reader::u16).unwrap(), None);
reader.finish().unwrap();
}
#[test]
fn first_recorded_failure_is_reported() {
let mut writer = Writer::new();
writer.fail(SnapshotError::Unsupported("first"));
writer.u8(1);
writer.fail(SnapshotError::MidStep);
assert_eq!(writer.finish(), Err(SnapshotError::Unsupported("first")));
}
#[test]
fn malformed_input_is_rejected_without_panicking() {
assert_eq!(Reader::new(&[1]).u16(), Err(SnapshotError::Truncated));
assert_eq!(
Reader::new(&[2]).bool(),
Err(SnapshotError::Corrupt("boolean"))
);
assert_eq!(
Reader::new(b"NOPE").tag(b"TEST"),
Err(SnapshotError::Corrupt("section tag"))
);
assert_eq!(Reader::new(&[0]).finish(), Err(SnapshotError::TrailingData));
// A length prefix larger than the stream or the destination fails cleanly.
let mut huge = Writer::new();
huge.u32(u32::MAX);
let huge = huge.finish().unwrap();
assert_eq!(
Reader::new(&huge).bytes_vec(1 << 20),
Err(SnapshotError::Corrupt("length"))
);
assert_eq!(
Reader::new(&huge).bytes_into(&mut [0; 4]),
Err(SnapshotError::Corrupt("memory size"))
);
}
}

View file

@ -1,6 +1,7 @@
//! Nominal Game Pak opcode prefetch timing. No ROM bytes or CPU instructions live here.
//! Full-buffer pause/restart follows the independently reviewed ares/jgenesis model.
use super::{bus_cycles, AccessKind, AccessWidth};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Prefetch {
@ -163,3 +164,100 @@ impl Prefetch {
#[cfg(test)]
mod tests;
impl Snapshot for Prefetch {
fn save(&self, writer: &mut Writer) {
let Self { waitcnt, stream } = self;
writer.tag(b"PFCH");
writer.u16(*waitcnt);
writer.option(stream.as_ref(), |stream, writer| {
let Stream {
head,
ready,
remaining,
running,
} = stream;
writer.u32(*head);
writer.u8(*ready);
writer.u32(*remaining);
writer.bool(*running);
});
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { waitcnt, stream } = self;
reader.tag(b"PFCH")?;
*waitcnt = reader.u16()?;
*stream = reader.option(|reader| {
let head = reader.u32()?;
let ready = reader.u8()?;
let remaining = reader.u32()?;
let running = reader.bool()?;
if ready > 8 {
return Err(SnapshotError::Corrupt("prefetch queue"));
}
Ok(Stream {
head,
ready,
remaining,
running,
})
})?;
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn populated_prefetch() -> Prefetch {
Prefetch {
waitcnt: 0x5a34,
stream: Some(Stream {
head: 0x0a12_3456,
ready: 3,
remaining: 7,
running: true,
}),
}
}
fn save(prefetch: &Prefetch) -> Vec<u8> {
let mut writer = Writer::new();
prefetch.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn prefetch_round_trips_waitcnt_and_stream() {
let source = populated_prefetch();
let bytes = save(&source);
let mut restored = Prefetch::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
}
#[test]
fn every_strict_prefetch_prefix_is_rejected() {
let bytes = save(&populated_prefetch());
for end in 0..bytes.len() {
let mut restored = Prefetch::default();
assert!(
restored.load(&mut Reader::new(&bytes[..end])).is_err(),
"accepted prefix of length {end}"
);
}
}
#[test]
fn prefetch_rejects_invalid_stream_values() {
let mut bytes = save(&populated_prefetch());
bytes[11] = 9;
let mut restored = Prefetch::default();
assert!(matches!(
restored.load(&mut Reader::new(&bytes)),
Err(SnapshotError::Corrupt(_))
));
}
}

View file

@ -10,6 +10,7 @@ pub(crate) mod mosaic;
pub(crate) mod sprites;
pub(crate) mod windows;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use std::{error::Error, fmt};
pub const WIDTH: usize = 240;
@ -67,6 +68,86 @@ pub(crate) struct Background {
pub affine: affine::AffineBackground,
}
impl Snapshot for Background {
fn save(&self, writer: &mut Writer) {
let Self {
control,
x,
y,
affine,
} = self;
writer.tag(b"BGND");
writer.u16(*control);
writer.u16(*x);
writer.u16(*y);
affine.save(writer);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
control,
x,
y,
affine,
} = self;
reader.tag(b"BGND")?;
*control = reader.u16()?;
*x = reader.u16()?;
*y = reader.u16()?;
affine.load(reader)
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encoded<T: Snapshot>(value: &T) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
fn assert_prefixes(bytes: &[u8]) {
for end in 0..bytes.len() {
let mut value = Background::default();
assert!(
value.load(&mut Reader::new(&bytes[..end])).is_err(),
"prefix {end}"
);
}
}
#[test]
fn background_round_trips_and_rejects_bad_affine_boolean() {
let mut affine = affine::AffineBackground::default();
for offset in 0..8 {
affine.write8(offset, offset as u8 + 1, false);
}
for offset in 8..16 {
affine.write8(offset, offset as u8 + 1, true);
}
let source = Background {
control: 0xa51d,
x: 0x1357,
y: 0x2468,
affine,
};
let bytes = encoded(&source);
let mut restored = Background::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
assert_prefixes(&bytes);
let mut invalid = bytes;
*invalid.last_mut().unwrap() = 2;
assert_eq!(
Background::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}
pub(crate) struct RenderSettings<'a> {
pub control: u16,
pub prepared_objects: Option<&'a Result<sprites::PreparedObjects, VideoError>>,

View file

@ -5,6 +5,7 @@
use super::{halfword, vram_index, Background, VideoError, HEIGHT, WIDTH};
#[cfg(test)]
use crate::display::{CYCLES_PER_FRAME, CYCLES_PER_LINE};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
const REFERENCE_MASK: u32 = 0x0fff_ffff;
@ -86,6 +87,89 @@ impl AffineBackground {
}
}
impl Snapshot for AffineBackground {
fn save(&self, writer: &mut Writer) {
let Self {
matrix,
reference,
internal,
reload_next,
} = self;
writer.tag(b"AFBG");
for value in matrix {
writer.u16(*value);
}
for value in reference {
writer.u32(*value);
}
for value in internal {
writer.u32(*value);
}
for value in reload_next {
writer.bool(*value);
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
matrix,
reference,
internal,
reload_next,
} = self;
reader.tag(b"AFBG")?;
for value in matrix {
*value = reader.u16()?;
}
for value in reference {
*value = reader.u32()?;
}
for value in internal {
*value = reader.u32()?;
}
for value in reload_next {
*value = reader.bool()?;
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encoded(value: &AffineBackground) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn affine_background_round_trips_every_latch_and_checks_prefixes() {
let source = AffineBackground {
matrix: [1, 0x2345, 0x6789, 0xabcd],
reference: [0x0123_4567, 0x089a_bcde],
internal: [0x0765_4321, 0x0123_4567],
reload_next: [true, false],
};
let bytes = encoded(&source);
let mut restored = AffineBackground::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
assert!(AffineBackground::default()
.load(&mut Reader::new(&bytes[..end]))
.is_err());
}
let mut invalid = bytes;
*invalid.last_mut().unwrap() = 2;
assert_eq!(
AffineBackground::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}
pub(super) fn pixel(
bg: Background,
vram: &[u8],

View file

@ -2,6 +2,7 @@
//! happens at VBlank entry. This is not a pixel fetch pipeline.
use super::{Framebuffer, VideoError, HEIGHT, WIDTH};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
pub(crate) struct Capture {
drawing: Vec<u32>,
@ -74,3 +75,160 @@ impl Capture {
Ok(true)
}
}
impl Snapshot for Capture {
fn save(&self, writer: &mut Writer) {
let Self {
drawing,
completed,
rows,
drawing_error,
completed_error,
vblank,
} = self;
writer.tag(b"CAPT");
let pixels = WIDTH * HEIGHT;
if drawing.len() != pixels || completed.len() != pixels {
writer.fail(SnapshotError::Unsupported("invalid capture buffer size"));
}
writer.u64(drawing.len() as u64);
for pixel in drawing {
writer.u32(*pixel);
}
writer.u64(completed.len() as u64);
for pixel in completed {
writer.u32(*pixel);
}
if *rows > HEIGHT {
writer.fail(SnapshotError::Unsupported("invalid capture row count"));
}
writer.u64(*rows as u64);
save_video_error(drawing_error, writer);
save_video_error(completed_error, writer);
writer.option(vblank.as_ref(), |value, writer| writer.u64(*value));
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
drawing,
completed,
rows,
drawing_error,
completed_error,
vblank,
} = self;
reader.tag(b"CAPT")?;
let pixels = WIDTH * HEIGHT;
if drawing.len() != pixels || completed.len() != pixels {
return Err(SnapshotError::Corrupt("capture buffer size"));
}
const MIN_PAYLOAD: usize = 8 + WIDTH * HEIGHT * 4 + 8 + WIDTH * HEIGHT * 4 + 8 + 1 + 1 + 1;
if reader.remaining() < MIN_PAYLOAD {
return Err(SnapshotError::Truncated);
}
let drawing_len = usize::try_from(reader.u64()?)
.map_err(|_| SnapshotError::Corrupt("capture buffer size"))?;
if drawing_len != pixels {
return Err(SnapshotError::Corrupt("capture buffer size"));
}
for pixel in drawing {
*pixel = reader.u32()?;
}
let completed_len = usize::try_from(reader.u64()?)
.map_err(|_| SnapshotError::Corrupt("capture buffer size"))?;
if completed_len != pixels {
return Err(SnapshotError::Corrupt("capture buffer size"));
}
for pixel in completed {
*pixel = reader.u32()?;
}
let loaded_rows = usize::try_from(reader.u64()?)
.map_err(|_| SnapshotError::Corrupt("capture row count"))?;
if loaded_rows > HEIGHT {
return Err(SnapshotError::Corrupt("capture row count"));
}
*rows = loaded_rows;
*drawing_error = load_video_error(reader)?;
*completed_error = load_video_error(reader)?;
*vblank = reader.option(|reader| reader.u64())?;
Ok(())
}
}
fn save_video_error(error: &Option<VideoError>, writer: &mut Writer) {
writer.bool(error.is_some());
if error.is_some() {
writer.fail(SnapshotError::Unsupported("deferred video diagnostic"));
}
}
fn load_video_error(reader: &mut Reader<'_>) -> Result<Option<VideoError>, SnapshotError> {
if reader.bool()? {
Err(SnapshotError::Corrupt("deferred video diagnostic"))
} else {
Ok(None)
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encoded(value: &Capture) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
#[test]
fn capture_round_trips_buffers_and_rejects_every_strict_prefix() {
let mut source = Capture::default();
for (index, pixel) in source.drawing.iter_mut().enumerate() {
*pixel = index as u32 ^ 0x1234_5678;
}
for (index, pixel) in source.completed.iter_mut().enumerate() {
*pixel = index as u32 ^ 0x8765_4321;
}
source.rows = 37;
source.vblank = Some(0x1234_5678_9abc_def0);
let bytes = encoded(&source);
let mut restored = Capture::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(encoded(&restored), bytes);
let mut prefix_target = Capture::default();
for end in 0..bytes.len() {
assert!(
prefix_target.load(&mut Reader::new(&bytes[..end])).is_err(),
"prefix {end}"
);
}
let mut invalid = bytes.clone();
// The final nine bytes are the vblank option flag and its u64 payload.
let option_flag = invalid.len() - 9;
invalid[option_flag] = 2;
assert_eq!(
Capture::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
invalid[4] = 1;
assert_eq!(
Capture::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("capture buffer size"))
);
}
#[test]
fn deferred_capture_errors_cannot_be_saved() {
let capture = Capture {
drawing_error: Some(VideoError::UnsupportedMode(7)),
..Capture::default()
};
let mut writer = Writer::new();
capture.save(&mut writer);
assert_eq!(
writer.finish(),
Err(SnapshotError::Unsupported("deferred video diagnostic"))
);
}
}

View file

@ -3,6 +3,7 @@
//! and horizontal comparator history. Layer/effect selection remains row-sampled.
use super::Pixel;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Effects {
@ -93,3 +94,89 @@ fn channels(first: u16, second: u16, operation: impl Fn(u16, u16) -> u16) -> u16
color | (operation((first >> shift) & 31, (second >> shift) & 31) << shift)
})
}
impl Snapshot for Effects {
fn save(&self, writer: &mut Writer) {
let Self {
horizontal,
vertical,
inside,
outside,
control,
alpha,
brightness,
} = self;
writer.tag(b"EFCT");
for value in horizontal {
writer.u16(*value);
}
for value in vertical {
writer.u16(*value);
}
writer.u16(*inside);
writer.u16(*outside);
writer.u16(*control);
writer.u16(*alpha);
writer.u16(*brightness);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
horizontal,
vertical,
inside,
outside,
control,
alpha,
brightness,
} = self;
reader.tag(b"EFCT")?;
for value in horizontal {
*value = reader.u16()?;
}
for value in vertical {
*value = reader.u16()?;
}
*inside = reader.u16()?;
*outside = reader.u16()?;
*control = reader.u16()?;
*alpha = reader.u16()?;
*brightness = reader.u16()?;
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
#[test]
fn effects_round_trip_and_reject_every_strict_prefix() {
let source = Effects {
horizontal: [0x1234, 0x5678],
vertical: [0x9abc, 0xdef0],
inside: 0x1357,
outside: 0x2468,
control: 0xaaaa,
alpha: 0x5555,
brightness: 0xdead,
};
let mut writer = Writer::new();
source.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut restored = Effects::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
assert!(Effects::default()
.load(&mut Reader::new(&bytes[..end]))
.is_err());
}
let mut invalid = bytes;
invalid[0] ^= 1;
assert_eq!(
Effects::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("section tag"))
);
}
}

View file

@ -3,6 +3,7 @@
//! for sprite preparation. Per-access size latching is not modeled.
use crate::display::{CYCLES_PER_FRAME, CYCLES_PER_LINE, VISIBLE_LINES};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct VerticalMosaic {
@ -188,3 +189,83 @@ mod tests {
assert_eq!((mosaic.background, mosaic.object), (0, 1)); // Row1 phase, row0 already prepared.
}
}
impl Snapshot for VerticalMosaic {
fn save(&self, writer: &mut Writer) {
let Self {
background,
object,
started,
} = self;
writer.tag(b"VMOS");
if *background >= 16 || *object >= 16 {
writer.fail(SnapshotError::Unsupported(
"invalid vertical mosaic counter",
));
}
writer.u8(*background);
writer.u8(*object);
writer.bool(*started);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
background,
object,
started,
} = self;
reader.tag(b"VMOS")?;
let loaded_background = reader.u8()?;
let loaded_object = reader.u8()?;
if loaded_background >= 16 || loaded_object >= 16 {
return Err(SnapshotError::Corrupt("vertical mosaic counter"));
}
*background = loaded_background;
*object = loaded_object;
*started = reader.bool()?;
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
#[test]
fn vertical_mosaic_round_trips_and_rejects_invalid_values() {
let source = VerticalMosaic {
background: 13,
object: 7,
started: true,
};
let mut writer = Writer::new();
source.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut restored = VerticalMosaic::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
assert!(VerticalMosaic::default()
.load(&mut Reader::new(&bytes[..end]))
.is_err());
}
let mut invalid = bytes;
*invalid.last_mut().unwrap() = 2;
assert_eq!(
VerticalMosaic::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
let mut invalid = encoded_mosaic(&source);
invalid[4] = 16;
assert_eq!(
VerticalMosaic::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("vertical mosaic counter"))
);
}
fn encoded_mosaic(value: &VerticalMosaic) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
}

View file

@ -4,7 +4,8 @@
mod budget;
pub(crate) mod pipeline;
use super::{halfword, Pixel, VideoError, WIDTH};
use super::{halfword, Pixel, VideoError, HEIGHT, WIDTH};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
const SIZES: [[(usize, usize); 4]; 3] = [
[(8, 8), (16, 16), (32, 32), (64, 64)],
@ -17,19 +18,20 @@ pub(super) struct Objects {
pub window: Vec<bool>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct PreparedObjects {
samples: Vec<Sample>,
window: Vec<bool>,
}
#[derive(Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ObjectPixel {
palette_index: u16,
semi_transparent: bool,
}
// Transparent texels can change priority and mosaic without replacing color.
#[derive(Clone, Copy, Default)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct Sample {
pixel: Option<ObjectPixel>,
priority: u8,
@ -228,3 +230,308 @@ impl PreparedObjects {
output
}
}
impl ObjectPixel {
fn save_fields(palette_index: &u16, semi_transparent: &bool, writer: &mut Writer) {
if *palette_index > 255 {
writer.fail(SnapshotError::Unsupported("invalid OBJ palette index"));
}
writer.u16(*palette_index);
writer.bool(*semi_transparent);
}
fn load_fields(
palette_index: &mut u16,
semi_transparent: &mut bool,
reader: &mut Reader<'_>,
) -> Result<(), SnapshotError> {
let loaded_index = reader.u16()?;
let loaded_transparency = reader.bool()?;
if loaded_index > 255 {
return Err(SnapshotError::Corrupt("OBJ palette index"));
}
*palette_index = loaded_index;
*semi_transparent = loaded_transparency;
Ok(())
}
fn save_payload(&self, writer: &mut Writer) {
let Self {
palette_index,
semi_transparent,
} = self;
Self::save_fields(palette_index, semi_transparent, writer);
}
fn load_payload(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
palette_index,
semi_transparent,
} = self;
Self::load_fields(palette_index, semi_transparent, reader)
}
}
impl Snapshot for ObjectPixel {
fn save(&self, writer: &mut Writer) {
let Self {
palette_index,
semi_transparent,
} = self;
writer.tag(b"OPXL");
Self::save_fields(palette_index, semi_transparent, writer);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
palette_index,
semi_transparent,
} = self;
reader.tag(b"OPXL")?;
Self::load_fields(palette_index, semi_transparent, reader)
}
}
impl Sample {
fn save_fields(pixel: &Option<ObjectPixel>, priority: &u8, mosaic: &bool, writer: &mut Writer) {
writer.option(pixel.as_ref(), |pixel, writer| {
pixel.save_payload(writer);
});
if *priority > 3 {
writer.fail(SnapshotError::Unsupported("invalid OBJ priority"));
}
writer.u8(*priority);
writer.bool(*mosaic);
}
fn load_fields(
pixel: &mut Option<ObjectPixel>,
priority: &mut u8,
mosaic: &mut bool,
reader: &mut Reader<'_>,
) -> Result<(), SnapshotError> {
let loaded_pixel = reader.option(|reader| {
let mut pixel = ObjectPixel {
palette_index: 0,
semi_transparent: false,
};
pixel.load_payload(reader)?;
Ok(pixel)
})?;
let loaded_priority = reader.u8()?;
if loaded_priority > 3 {
return Err(SnapshotError::Corrupt("OBJ priority"));
}
let loaded_mosaic = reader.bool()?;
*pixel = loaded_pixel;
*priority = loaded_priority;
*mosaic = loaded_mosaic;
Ok(())
}
fn save_payload(&self, writer: &mut Writer) {
let Self {
pixel,
priority,
mosaic,
} = self;
Self::save_fields(pixel, priority, mosaic, writer);
}
fn load_payload(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
pixel,
priority,
mosaic,
} = self;
Self::load_fields(pixel, priority, mosaic, reader)
}
}
impl Snapshot for Sample {
fn save(&self, writer: &mut Writer) {
let Self {
pixel,
priority,
mosaic,
} = self;
writer.tag(b"SAMP");
Self::save_fields(pixel, priority, mosaic, writer);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self {
pixel,
priority,
mosaic,
} = self;
reader.tag(b"SAMP")?;
Self::load_fields(pixel, priority, mosaic, reader)
}
}
impl Snapshot for PreparedObjects {
fn save(&self, writer: &mut Writer) {
let Self { samples, window } = self;
writer.tag(b"POBJ");
if samples.len() != window.len()
|| samples.len() % WIDTH != 0
|| samples.len() > WIDTH * HEIGHT
{
writer.fail(SnapshotError::Unsupported(
"invalid prepared OBJ dimensions",
));
}
writer.u64(samples.len() as u64);
writer.u64(window.len() as u64);
for sample in samples {
sample.save_payload(writer);
}
for value in window {
writer.bool(*value);
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { samples, window } = self;
reader.tag(b"POBJ")?;
let sample_len = usize::try_from(reader.u64()?)
.map_err(|_| SnapshotError::Corrupt("prepared OBJ dimensions"))?;
let window_len = usize::try_from(reader.u64()?)
.map_err(|_| SnapshotError::Corrupt("prepared OBJ dimensions"))?;
if sample_len != window_len || sample_len % WIDTH != 0 || sample_len > WIDTH * HEIGHT {
return Err(SnapshotError::Corrupt("prepared OBJ dimensions"));
}
samples.resize(sample_len, Sample::default());
window.resize(window_len, false);
for sample in samples {
sample.load_payload(reader)?;
}
for value in window {
*value = reader.bool()?;
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
fn encoded<T: Snapshot>(value: &T) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
fn assert_prefixes<T: Snapshot>(bytes: &[u8], mut fresh: impl FnMut() -> T) {
for end in 0..bytes.len() {
assert!(
fresh().load(&mut Reader::new(&bytes[..end])).is_err(),
"prefix {end}"
);
}
}
#[test]
fn object_pixel_round_trips_and_rejects_invalid_boolean() {
let source = ObjectPixel {
palette_index: 219,
semi_transparent: true,
};
let bytes = encoded(&source);
let mut restored = ObjectPixel {
palette_index: 0,
semi_transparent: false,
};
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
assert_prefixes(&bytes, || ObjectPixel {
palette_index: 0,
semi_transparent: false,
});
let mut invalid = bytes.clone();
invalid[6] = 2;
assert_eq!(
ObjectPixel {
palette_index: 0,
semi_transparent: false,
}
.load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
invalid[4] = 0xff;
invalid[5] = 0x01;
invalid[6] = 1;
assert_eq!(
ObjectPixel {
palette_index: 0,
semi_transparent: false,
}
.load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("OBJ palette index"))
);
}
#[test]
fn sample_round_trips_and_validates_priority() {
let source = Sample {
pixel: Some(ObjectPixel {
palette_index: 173,
semi_transparent: true,
}),
priority: 3,
mosaic: true,
};
let bytes = encoded(&source);
let mut restored = Sample::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
assert_prefixes(&bytes, Sample::default);
let mut invalid = bytes.clone();
invalid[8] = 4;
assert_eq!(
Sample::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("OBJ priority"))
);
invalid[4] = 2;
assert_eq!(
Sample::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
}
#[test]
fn prepared_objects_round_trip_and_bound_lengths() {
let samples = (0..WIDTH * 2)
.map(|index| Sample {
pixel: (index % 2 == 0).then_some(ObjectPixel {
palette_index: (index % 256) as u16,
semi_transparent: index % 3 == 0,
}),
priority: (index % 4) as u8,
mosaic: index % 5 == 0,
})
.collect();
let window = (0..WIDTH * 2).map(|index| index % 7 == 0).collect();
let source = PreparedObjects { samples, window };
let bytes = encoded(&source);
let mut restored = PreparedObjects::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
assert_prefixes(&bytes, PreparedObjects::default);
let mut invalid = bytes;
invalid[4] = 1;
invalid[5..8].fill(0);
assert_eq!(
PreparedObjects::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("prepared OBJ dimensions"))
);
let mut invalid = encoded(&source);
invalid[20] = 2;
assert_eq!(
PreparedObjects::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}

View file

@ -3,11 +3,12 @@
//! individual fetches, fetch budgets, or OAM/VRAM contention.
use super::PreparedObjects;
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use crate::{display::CYCLES_PER_LINE, video::VideoError};
pub(crate) const PREPARE_CYCLE: u32 = 40;
#[derive(Default)]
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct SpritePipeline {
rows: [Option<(usize, Result<PreparedObjects, VideoError>)>; 2],
pub started: bool,
@ -120,3 +121,130 @@ mod tests {
}
}
}
impl Snapshot for SpritePipeline {
fn save(&self, writer: &mut Writer) {
let Self { rows, started } = self;
writer.tag(b"SPRP");
writer.bool(*started);
for row in rows {
writer.option(row.as_ref(), |(index, result), writer| {
if *index >= crate::video::HEIGHT {
writer.fail(SnapshotError::Unsupported("invalid sprite row index"));
}
writer.u64(*index as u64);
match result {
Ok(objects) => {
writer.u8(0);
objects.save(writer);
}
Err(_) => {
writer.u8(1);
writer.fail(SnapshotError::Unsupported("deferred video diagnostic"));
}
}
});
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { rows, started } = self;
reader.tag(b"SPRP")?;
*started = reader.bool()?;
for (slot, destination) in rows.iter_mut().enumerate() {
*destination = reader.option(|reader| {
let index = usize::try_from(reader.u64()?)
.map_err(|_| SnapshotError::Corrupt("sprite row index"))?;
if index >= crate::video::HEIGHT || index % 2 != slot {
return Err(SnapshotError::Corrupt("sprite row index"));
}
let result = match reader.u8()? {
0 => {
let mut objects = PreparedObjects::default();
objects.load(reader)?;
Ok(objects)
}
1 => return Err(SnapshotError::Corrupt("deferred video diagnostic")),
_ => return Err(SnapshotError::Corrupt("sprite row result")),
};
Ok((index, result))
})?;
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
use crate::video::sprites::{ObjectPixel, Sample};
fn encoded(value: &SpritePipeline) -> Vec<u8> {
let mut writer = Writer::new();
value.save(&mut writer);
writer.finish().unwrap()
}
fn prepared(index: usize) -> PreparedObjects {
PreparedObjects {
samples: vec![
Sample {
pixel: Some(ObjectPixel {
palette_index: (index % 256) as u16,
semi_transparent: true,
}),
priority: (index % 4) as u8,
mosaic: true,
};
crate::video::WIDTH
],
window: vec![index % 2 == 0; crate::video::WIDTH],
}
}
#[test]
fn sprite_pipeline_round_trips_rows_and_checks_prefixes() {
let source = SpritePipeline {
rows: [Some((4, Ok(prepared(13)))), Some((5, Ok(prepared(17))))],
started: true,
};
let bytes = encoded(&source);
let mut restored = SpritePipeline::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
assert!(
SpritePipeline::default()
.load(&mut Reader::new(&bytes[..end]))
.is_err(),
"prefix {end}"
);
}
let mut invalid = bytes.clone();
invalid[4] = 2;
assert_eq!(
SpritePipeline::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
invalid.copy_from_slice(&bytes);
invalid[6] = crate::video::HEIGHT as u8;
invalid[7..14].fill(0);
assert_eq!(
SpritePipeline::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("sprite row index"))
);
}
#[test]
fn deferred_sprite_diagnostic_cannot_be_saved() {
let mut source = SpritePipeline::default();
source.rows[0] = Some((0, Err(VideoError::UnsupportedMode(7))));
let mut writer = Writer::new();
source.save(&mut writer);
assert_eq!(
writer.finish(),
Err(SnapshotError::Unsupported("deferred video diagnostic"))
);
}
}

View file

@ -4,6 +4,7 @@
pub(crate) mod horizontal;
use crate::display::{CYCLES_PER_LINE, LINES_PER_FRAME};
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct WindowEdges {
@ -131,3 +132,54 @@ mod tests {
assert_eq!(edges.active, [true, false]); // The row1 comparator already ran.
}
}
impl Snapshot for WindowEdges {
fn save(&self, writer: &mut Writer) {
let Self { active, started } = self;
writer.tag(b"WEDG");
for value in active {
writer.bool(*value);
}
writer.bool(*started);
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { active, started } = self;
reader.tag(b"WEDG")?;
for value in active {
*value = reader.bool()?;
}
*started = reader.bool()?;
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
#[test]
fn window_edges_round_trip_and_validate_booleans() {
let source = WindowEdges {
active: [true, false],
started: true,
};
let mut writer = Writer::new();
source.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut restored = WindowEdges::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
assert!(WindowEdges::default()
.load(&mut Reader::new(&bytes[..end]))
.is_err());
}
let mut invalid = bytes;
invalid[4] = 2;
assert_eq!(
WindowEdges::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}

View file

@ -1,6 +1,7 @@
//! Horizontal comparator history only, not pixel-fetch or color-register timing.
//! Sample X=0..255 at line cycles 0,4,..1020; right-edge matches clear after left.
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
use crate::{display::CYCLES_PER_LINE, video::WIDTH};
#[derive(Debug, Clone, PartialEq, Eq)]
@ -166,3 +167,67 @@ mod tests {
assert_eq!(state, before); // X0 already passed, X1 has not started.
}
}
impl Snapshot for HorizontalWindows {
fn save(&self, writer: &mut Writer) {
let Self { active, pixels } = self;
writer.tag(b"HWIN");
for value in active {
writer.bool(*value);
}
for window in pixels {
for value in window {
writer.bool(*value);
}
}
}
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
let Self { active, pixels } = self;
reader.tag(b"HWIN")?;
for value in active {
*value = reader.bool()?;
}
for window in pixels {
for value in window {
*value = reader.bool()?;
}
}
Ok(())
}
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
#[test]
fn horizontal_windows_round_trip_comparator_history() {
let mut source = HorizontalWindows {
active: [true, false],
..HorizontalWindows::default()
};
for window in 0..2 {
for x in 0..WIDTH {
source.pixels[window][x] = (x + window) % 3 != 0;
}
}
let mut writer = Writer::new();
source.save(&mut writer);
let bytes = writer.finish().unwrap();
let mut restored = HorizontalWindows::default();
restored.load(&mut Reader::new(&bytes)).unwrap();
assert_eq!(restored, source);
for end in 0..bytes.len() {
assert!(HorizontalWindows::default()
.load(&mut Reader::new(&bytes[..end]))
.is_err());
}
let mut invalid = bytes;
invalid[4] = 2;
assert_eq!(
HorizontalWindows::default().load(&mut Reader::new(&invalid)),
Err(SnapshotError::Corrupt("boolean"))
);
}
}

View file

@ -0,0 +1,197 @@
//! Save states must be indistinguishable from uninterrupted execution.
//! Original programs only; no game ROMs or recordings.
use gba_core::{
audio::{StereoLevel, AUDIO_QUEUE_CAPACITY},
bios,
cartridge::{CartridgeHardware, SaveDevice, SAVE_START},
input::{Button, Buttons},
io::{SOUNDBIAS, SOUNDCNT_H, SOUNDCNT_X, SOUND_START, TIMER_BASE},
machine::Machine,
snapshot::SnapshotError,
video::Framebuffer,
};
use gba_demos::{input_rom, tile_demo};
const FRAME_STEPS: usize = 400_000;
fn configured(rom: Vec<u8>, device: SaveDevice) -> Machine {
let mut machine = bios::boot(rom).unwrap();
let memory = machine.memory_mut();
memory.set_cartridge_hardware(CartridgeHardware::Rtc);
memory.set_save_device(device);
memory.set_scanline_rendering(true);
memory.set_audio_capture(true);
machine
}
/// Free-running timers, a triggered pulse channel, and master sound enable, so the
/// state under test includes devices that the demo program itself never touches.
fn start_devices(machine: &mut Machine) {
let memory = machine.memory_mut();
memory.write16(SOUNDCNT_X, 0x80).unwrap();
memory.write16(SOUNDBIAS, 0x200).unwrap();
memory.write16(SOUNDCNT_H, 0x0302).unwrap();
memory.write16(SOUND_START + 2, 0xf141).unwrap(); // Duty, envelope sweep.
memory.write16(SOUND_START + 4, 0x8600).unwrap(); // Frequency and trigger.
memory.write32(TIMER_BASE, 0x0081_f000).unwrap(); // /1 timer.
memory.write32(TIMER_BASE + 4, 0x0084_ffff).unwrap(); // Cascaded timer.
}
fn buttons(frame: u64) -> Buttons {
Buttons::default()
.with(Button::Right, frame % 7 < 3)
.with(Button::Down, frame % 11 < 4)
.with(Button::A, frame % 5 == 0)
.with(Button::L, frame % 13 < 6)
}
fn fold(hash: &mut u64, value: u64) {
*hash = (*hash ^ value).wrapping_mul(0x0000_0100_0000_01b3);
}
/// One observation per frame: pixels, audio, registers, and the device clock.
fn run(machine: &mut Machine, frames: std::ops::Range<u64>) -> Vec<u64> {
let mut frame = Framebuffer::default();
let mut samples = vec![StereoLevel::default(); AUDIO_QUEUE_CAPACITY];
let mut trace = Vec::new();
for index in frames {
machine.memory_mut().set_buttons(buttons(index));
machine.run_until_vblank(FRAME_STEPS).unwrap();
let mut hash = 0xcbf2_9ce4_8422_2325;
if machine.memory().present_frame(&mut frame).unwrap() {
frame
.pixels()
.iter()
.for_each(|&p| fold(&mut hash, p.into()));
}
let count = machine.memory_mut().drain_audio_samples(&mut samples);
for level in &samples[..count] {
fold(&mut hash, level.left as u64);
fold(&mut hash, level.right as u64);
}
machine
.cpu()
.registers()
.iter()
.for_each(|&r| fold(&mut hash, r.into()));
fold(&mut hash, machine.cycles());
trace.push(hash);
}
trace
}
#[test]
fn restored_machine_continues_exactly_like_the_original() {
let mut original = configured(tile_demo::rom(), SaveDevice::Flash128);
start_devices(&mut original);
run(&mut original, 0..40);
let state = original.save_state().unwrap();
let expected = run(&mut original, 40..70);
let expected_end = original.save_state().unwrap();
let mut restored = configured(tile_demo::rom(), SaveDevice::Flash128);
restored.load_state(&state).unwrap();
assert_eq!(run(&mut restored, 40..70), expected);
assert_eq!(restored.save_state().unwrap(), expected_end);
assert_ne!(state, expected_end, "the run must change the state");
}
#[test]
fn saving_and_loading_the_same_machine_is_idempotent() {
let mut machine = configured(tile_demo::rom(), SaveDevice::Flash64);
start_devices(&mut machine);
run(&mut machine, 0..25);
let state = machine.save_state().unwrap();
let expected = run(&mut machine, 25..35);
machine.load_state(&state).unwrap();
assert_eq!(machine.save_state().unwrap(), state);
assert_eq!(run(&mut machine, 25..35), expected);
}
#[test]
fn flash_operation_in_flight_completes_identically_after_restore() {
let command = |machine: &mut Machine, byte: u8| {
let memory = machine.memory_mut();
memory.write8(SAVE_START + 0x5555, 0xaa).unwrap();
memory.write8(SAVE_START + 0x2aaa, 0x55).unwrap();
memory.write8(SAVE_START + 0x5555, byte).unwrap();
};
let mut original = configured(input_rom(), SaveDevice::Flash128);
command(&mut original, 0xa0);
original
.memory_mut()
.write8(SAVE_START + 0x123, 0x42)
.unwrap();
for _ in 0..10 {
original.step().unwrap();
}
assert!(
original.memory().save_write_pending(),
"operation must be in flight"
);
let state = original.save_state().unwrap();
let mut restored = configured(input_rom(), SaveDevice::Flash128);
restored.load_state(&state).unwrap();
assert!(restored.memory().save_write_pending());
for machine in [&mut original, &mut restored] {
for _ in 0..200_000 {
machine.step().unwrap();
}
}
assert!(!original.memory().save_write_pending());
assert_eq!(
original.memory().save_image(),
restored.memory().save_image()
);
assert_eq!(original.memory().save_image().unwrap()[0x123], 0x42);
assert!(restored.memory().save_modified());
assert_eq!(
original.save_state().unwrap(),
restored.save_state().unwrap()
);
}
#[test]
fn rejected_states_leave_the_machine_untouched() {
let mut source = configured(tile_demo::rom(), SaveDevice::Flash128);
run(&mut source, 0..10);
let state = source.save_state().unwrap();
let mut target = configured(tile_demo::rom(), SaveDevice::Flash128);
run(&mut target, 0..3);
let before = target.save_state().unwrap();
let mut wrong_rom = state.clone();
// Same layout, different image: rebuild a source from another ROM.
let other = configured(input_rom(), SaveDevice::Flash128);
wrong_rom.splice(.., other.save_state().unwrap());
let mut flipped = state.clone();
let middle = flipped.len() / 2;
flipped[middle] ^= 0x01;
let mut version = state.clone();
version[4..8].copy_from_slice(&99u32.to_le_bytes());
let mut magic = state.clone();
magic[0] = b'X';
let cases = [
(wrong_rom, SnapshotError::ConfigMismatch("ROM image")),
(flipped, SnapshotError::Checksum),
(version, SnapshotError::UnsupportedVersion(99)),
(magic, SnapshotError::BadMagic),
(state[..state.len() - 1].to_vec(), SnapshotError::Checksum),
(Vec::new(), SnapshotError::Truncated),
];
for (bytes, error) in cases {
assert_eq!(target.load_state(&bytes), Err(error));
assert_eq!(target.save_state().unwrap(), before);
}
let mut other_device = configured(tile_demo::rom(), SaveDevice::Flash64);
let before = other_device.save_state().unwrap();
assert_eq!(
other_device.load_state(&state),
Err(SnapshotError::ConfigMismatch("cartridge setup"))
);
assert_eq!(other_device.save_state().unwrap(), before);
}

View file

@ -38,6 +38,11 @@ impl TileDemo {
}
}
/// The original Mode 0 scene as a raw ROM, for hosts that drive their own machine.
pub fn rom() -> Vec<u8> {
program(Scene::Tiles)
}
fn literal(code: &mut Vec<u32>, patches: &mut Vec<(usize, u32, u32)>, reg: u32, value: u32) {
patches.push((code.len(), reg, value));
code.push(0);

View file

@ -99,6 +99,10 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/display.rs` | Display clock, scanline status, comparison edges, and display IRQ events |
| `crates/gba-core/src/dma.rs` | DMA registers, internal pointers, retained channel data, trigger state, priority, and completion IRQs |
| `crates/gba-core/src/machine.rs` | Timed CPU/DMA/device stepping and IRQ delivery |
| `crates/gba-core/src/snapshot.rs` | Save-state binary primitives (writer, reader, tags, FNV fingerprint) and `SnapshotError` |
| `crates/gba-core/src/machine/snapshot.rs` | `Machine::save_state`/`load_state`: header, image and cartridge identity, checksum, verify-before-mutate |
| `crates/gba-core/src/memory/snapshot.rs`, `crates/gba-core/src/io/snapshot.rs` | Memory bus and I/O register encoders; every other state type implements `Snapshot` beside its definition |
| `crates/gba-core/tests/save_state.rs` | Restore-equals-uninterrupted determinism, in-flight Flash operation, and rejected-input tests |
| `crates/gba-core/src/timing.rs` | Bus widths, wait-state costs, ordered CPU timing transactions, and timing breakdowns |
| `crates/gba-core/src/timing/prefetch.rs` | Nominal eight-halfword opcode queue, partial transfers, capacity stops, and cancellation |
| `crates/gba-core/src/timing/prefetch/tests.rs` | Wait-state matrices, partial words, cancellation phases, boundaries, configuration, and progress consistency |
@ -175,6 +179,7 @@ The repository is a Cargo workspace with three crates:
| `crates/desktop/tests/save_cli.rs` | Original program/save/restart/load tests, diagnostic isolation, and native-window persistence |
| `crates/desktop/src/cartridge/rtc_clock.rs` | Host-only UTC seed and monotonic elapsed-time adapter, with injected epoch/duration tests |
| `crates/desktop/src/cartridge/window.rs` | ROM-window session, bounded slices, capture, STOP/input handling, and progress limits |
| `crates/desktop/src/cartridge/state_file.rs` | Atomic save-state file writes and bounded reads |
| `crates/desktop/src/cartridge/window/tests.rs` | Original ROM pixels, keyboard mapping, STOP wake, HALT, frame budgets, and diagnostics |
| `crates/desktop/src/desktop/rom.rs` | Main-thread ROM window, input polling, optional audio draining/muting, speed-scaled host pacing, and frame-limited exit |
| `crates/desktop/examples/write_rom_demo.rs` | Non-overwriting export of the original input-test ROM |

View file

@ -97,6 +97,7 @@ Controls use the same focus correction and key mapping as the existing demos:
| Backspace | Select |
Escape or the window close control ends execution. Enter does not reset the emulator.
F5 writes and F9 loads a [save state](save-states.md) in the file chosen by `--state-file PATH` (default: the ROM path with a `.state` extension).
Focus loss releases all buttons but does not pause execution.
The host samples buttons before each execution slice, including while stopped.

View file

@ -0,0 +1,62 @@
# Save states
A save state freezes the emulated machine at a step boundary and restores it later.
It is separate from the game's own save (Flash) and does not replace it: [Flash persistence](saves.md) still needs the game's SAVE command and a clean exit.
## Desktop use
Window mode binds two keys while the window has focus:
| Key | Action |
| --- | --- |
| F5 | Write the state file |
| F9 | Load the state file |
The slot is `--state-file PATH`. Without it, the ROM path with its extension replaced by `.state` is used (`roms/pokemon-emerald.gba` becomes `roms/pokemon-emerald.state`).
Only one slot exists. F5 replaces it, via a unique sibling temporary file that is synced and renamed, so an interrupted write keeps the previous state.
State files are created with mode `0600`. The option is accepted only with `--window`.
A rejected load (missing file, wrong ROM, corrupt or older-format file) is reported in the terminal and the game keeps running unchanged.
Loading discards queued host audio and shows the state's last captured frame immediately.
The Flash image inside the state replaces the running one. It is written to `--save-file` only on a clean exit, and only if it differs from the last persisted bytes.
The RTC resumes at the saved calendar value and then follows host elapsed time again.
Quitting with Escape does not write a state. Press F5 first.
## What a state holds
`Machine::save_state` and `Machine::load_state` (`crates/gba-core/src/machine/snapshot.rs`) encode:
- CPU registers, banks, status, instruction buffer, and retained fetch history.
- All RAMs, VRAM, palette, and OAM, plus the Flash image and its modified flag.
- Every device: timers and the shared prescaler, DMA channels and latches, display position, interrupts, keypad, serial, audio (FIFOs, PSG channels, wave RAM), Flash and RTC/GPIO state machines.
- Video latches: affine accumulators, mosaic and window counters, sprite row pipeline, and the double-buffered scanline capture.
- Game Pak prefetch, IWRAM bus lanes, BIOS protected-read snapshot, the device cycle count, and the last step timing.
It does not hold the ROM or firmware bytes, the host's choice of cartridge hardware and save device, row-capture and audio-capture enablement, or host audio queues and drop counters.
An enabled audio capture keeps running and resumes its sample phase.
## Format and checks
The layout is `GBAS`, a format version, ROM length and fingerprint, firmware presence and fingerprint, cartridge setup, a length-prefixed payload, and a trailing checksum.
Fingerprints and the checksum are 64-bit FNV-1a: they catch mismatched images and accidental corruption, not tampering.
The payload is positional with a four-byte tag per section, so a reader out of step fails at the next boundary.
Integers are little-endian. Allocations are bounded by fixed memory sizes; no length field drives an unbounded allocation.
`load_state` verifies the magic, version, checksum, and ROM/firmware/cartridge identity before changing anything.
These failures leave the machine untouched.
A failure after that point returns `RestoreFailed`; it indicates an encoder defect, and the machine must be rebuilt.
Any format change bumps the version. Older states are rejected, not migrated.
## Limits
- `save_state` fails with `MidStep` inside a step and `Unsupported` while the machine holds a deferred diagnostic error (a stored failed instruction fetch or failed captured frame). Saving at a later step boundary normally works.
- States are for the same build line: any change to encoded hardware state needs a version bump and invalidates old files.
- Checksum and fingerprints are not authentication. Do not load untrusted state files expecting more than memory safety.
- A state restores hardware, not the host: window frame counters, audio device buffers, and the host RTC baseline are not part of it.
## Evidence
`crates/gba-core/tests/save_state.rs` runs original programs, snapshots mid-run, restores into a freshly built machine, and compares per-frame hashes of pixels, captured audio, registers, and the device clock, then compares the final state bytes.
It also restores a Flash program operation in flight and checks that every rejected input leaves the machine unchanged.
Each encoded type destructures its struct exhaustively, so a new field is a compile error until it is encoded or excluded with a reason.
Per-type unit tests check round trips, rejection of every truncated prefix, and invalid discriminants.

View file

@ -130,7 +130,7 @@ The frontend persists changed bytes only on a clean exit:
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.
There is no autosave, crash recovery, or RTC persistence. [Save states](save-states.md) are a separate, whole-machine feature and do not replace the game's own save.
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.

View file

@ -84,6 +84,7 @@ Update this file when a feature lands or a limit is removed.
- 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.
- Whole-machine [save states](hardware/save-states.md): `Machine::save_state`/`load_state` with image and cartridge identity checks, a checksum, and verify-before-mutate loading; ROM windows bind F5 (save) and F9 (load) to a `--state-file` slot.
- Raw ROM-file loading with original BIOS boot, terminal diagnostics, and final CPU/step reports.
- Firmware boot initializes BG2/BG3 identity scales for bitmap and affine programs that do not program their own matrices.
- RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request.
@ -192,7 +193,7 @@ That run used no scripted input or pixel assertions.
A [fixed-time input probe](research/emerald-wave.md) found a wave trigger with its gate disabled.
Wave support now passes that point and reaches 1,320 frames with scheduled Start/A presses and releases.
Output differs from a no-input control, but readable menus and gameplay remain unverified.
Flash writing and host persistence now pass original save/restart/load tests. Emerald saving and reloading remain unverified.
Flash writing and host persistence now pass original save/restart/load tests. Emerald saving and reloading remain unverified; save states offer a separate restore point.
A user reached starter selection, then reported a BIOS invalid-argument trap.
An [isolated reproduction](research/emerald-empty-lz.md) identified a zero-length LZ77 header rejected before the length check.
The replacement BIOS now returns for that empty stream. Full starter-selection progress after this correction needs user retesting.