diff --git a/AGENTS.md b/AGENTS.md index a4097f4..0bf6ff3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) | diff --git a/README.md b/README.md index 5b26703..d93dfd4 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/crates/desktop/src/cartridge.rs b/crates/desktop/src/cartridge.rs index c912e3e..7f74b73 100644 --- a/crates/desktop/src/cartridge.rs +++ b/crates/desktop/src/cartridge.rs @@ -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, + state_file: Option, } pub fn parse_args(args: &[OsString]) -> io::Result { @@ -52,6 +54,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result { 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 { 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 { 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 { 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 io::Result { + 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> { + 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(); + } +} diff --git a/crates/desktop/src/cartridge/tests.rs b/crates/desktop/src/cartridge/tests.rs index 4c59353..2d445f0 100644 --- a/crates/desktop/src/cartridge/tests.rs +++ b/crates/desktop/src/cartridge/tests.rs @@ -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!( diff --git a/crates/desktop/src/cartridge/window.rs b/crates/desktop/src/cartridge/window.rs index aefd0cf..550acbc 100644 --- a/crates/desktop/src/cartridge/window.rs +++ b/crates/desktop/src/cartridge/window.rs @@ -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, 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. diff --git a/crates/desktop/src/cartridge/window/tests.rs b/crates/desktop/src/cartridge/window/tests.rs index 89c6823..07c702e 100644 --- a/crates/desktop/src/cartridge/window/tests.rs +++ b/crates/desktop/src/cartridge/window/tests.rs @@ -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); +} diff --git a/crates/desktop/src/desktop.rs b/crates/desktop/src/desktop.rs index 1cc62e4..43dd55e 100644 --- a/crates/desktop/src/desktop.rs +++ b/crates/desktop/src/desktop.rs @@ -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, diff --git a/crates/desktop/src/desktop/rom.rs b/crates/desktop/src/desktop/rom.rs index 2d3884d..dc7c777 100644 --- a/crates/desktop/src/desktop/rom.rs +++ b/crates/desktop/src/desktop/rom.rs @@ -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> { + 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> { + 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() { diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index eefc940..ada2c52 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -58,6 +58,7 @@ fn parse_args(args: &[OsString]) -> Result { "--speed", "--save-type", "--save-file", + "--state-file", ] .iter() .any(|flag| arg == flag) @@ -90,7 +91,7 @@ fn parse_args(args: &[OsString]) -> Result { [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> { --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> { 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(()) diff --git a/crates/gba-core/src/audio.rs b/crates/gba-core/src/audio.rs index 9f95157..87e352c 100644 --- a/crates/gba-core/src/audio.rs +++ b/crates/gba-core/src/audio.rs @@ -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(value: &T) -> Vec { + let mut writer = Writer::new(); + value.save(&mut writer); + writer.finish().unwrap() + } + + fn check_prefixes(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")) + ); + } +} diff --git a/crates/gba-core/src/audio/capture.rs b/crates/gba-core/src/audio/capture.rs index 8cc7bdb..ee60cbf 100644 --- a/crates/gba-core/src/audio/capture.rs +++ b/crates/gba-core/src/audio/capture.rs @@ -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 { + 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)); + } +} diff --git a/crates/gba-core/src/audio/modulation.rs b/crates/gba-core/src/audio/modulation.rs index 7bc70ad..282b038 100644 --- a/crates/gba-core/src/audio/modulation.rs +++ b/crates/gba-core/src/audio/modulation.rs @@ -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")) + ); + } +} diff --git a/crates/gba-core/src/audio/noise.rs b/crates/gba-core/src/audio/noise.rs index 686ae77..1160b0a 100644 --- a/crates/gba-core/src/audio/noise.rs +++ b/crates/gba-core/src/audio/noise.rs @@ -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 { diff --git a/crates/gba-core/src/audio/pulse.rs b/crates/gba-core/src/audio/pulse.rs index 965f95b..9ebb2e2 100644 --- a/crates/gba-core/src/audio/pulse.rs +++ b/crates/gba-core/src/audio/pulse.rs @@ -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; diff --git a/crates/gba-core/src/audio/wave.rs b/crates/gba-core/src/audio/wave.rs index 3a28d97..14b628b 100644 --- a/crates/gba-core/src/audio/wave.rs +++ b/crates/gba-core/src/audio/wave.rs @@ -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; diff --git a/crates/gba-core/src/cartridge.rs b/crates/gba-core/src/cartridge.rs index c40e802..792e3a8 100644 --- a/crates/gba-core/src/cartridge.rs +++ b/crates/gba-core/src/cartridge.rs @@ -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(value: &T) -> Vec { + 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")) + ); + } +} diff --git a/crates/gba-core/src/cartridge/calendar.rs b/crates/gba-core/src/cartridge/calendar.rs index ee4e18c..bf8ad50 100644 --- a/crates/gba-core/src/cartridge/calendar.rs +++ b/crates/gba-core/src/cartridge/calendar.rs @@ -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; diff --git a/crates/gba-core/src/cartridge/calendar/tests.rs b/crates/gba-core/src/cartridge/calendar/tests.rs index 1392a61..1c9fd36 100644 --- a/crates/gba-core/src/cartridge/calendar/tests.rs +++ b/crates/gba-core/src/cartridge/calendar/tests.rs @@ -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")) + ); +} diff --git a/crates/gba-core/src/cartridge/flash.rs b/crates/gba-core/src/cartridge/flash.rs index d0b9adf..f49a597 100644 --- a/crates/gba-core/src/cartridge/flash.rs +++ b/crates/gba-core/src/cartridge/flash.rs @@ -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 { + 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 { + 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(value: &T) -> Vec { + let mut writer = Writer::new(); + value.save(&mut writer); + writer.finish().unwrap() + } + + fn assert_prefixes(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")) + ); + } +} diff --git a/crates/gba-core/src/cartridge/rtc.rs b/crates/gba-core/src/cartridge/rtc.rs index d7d1c1c..93023ff 100644 --- a/crates/gba-core/src/cartridge/rtc.rs +++ b/crates/gba-core/src/cartridge/rtc.rs @@ -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(value: &T) -> Vec { + let mut writer = Writer::new(); + value.save(&mut writer); + writer.finish().unwrap() + } + + fn assert_prefixes(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(®ister); + 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")) + ); + } +} diff --git a/crates/gba-core/src/cpu.rs b/crates/gba-core/src/cpu.rs index 66b1f65..a01ff9c 100644 --- a/crates/gba-core/src/cpu.rs +++ b/crates/gba-core/src/cpu.rs @@ -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 { + 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 { + 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 { + 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(_)) + )); + } +} diff --git a/crates/gba-core/src/cpu/pipeline.rs b/crates/gba-core/src/cpu/pipeline.rs index b160cc0..ca9fa1f 100644 --- a/crates/gba-core/src/cpu/pipeline.rs +++ b/crates/gba-core/src/cpu/pipeline.rs @@ -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 { + 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, + }) + } +} diff --git a/crates/gba-core/src/cpu/status.rs b/crates/gba-core/src/cpu/status.rs index 6081815..d98b406 100644 --- a/crates/gba-core/src/cpu/status.rs +++ b/crates/gba-core/src/cpu/status.rs @@ -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(()) + } +} diff --git a/crates/gba-core/src/display.rs b/crates/gba-core/src/display.rs index 6072e66..01e05c4 100644 --- a/crates/gba-core/src/display.rs +++ b/crates/gba-core/src/display.rs @@ -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 { + 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(_)))); + } +} diff --git a/crates/gba-core/src/dma.rs b/crates/gba-core/src/dma.rs index 28166a6..1a20ded 100644 --- a/crates/gba-core/src/dma.rs +++ b/crates/gba-core/src/dma.rs @@ -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 { + 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(_)))); + } +} diff --git a/crates/gba-core/src/input/keypad.rs b/crates/gba-core/src/input/keypad.rs index 3e0fac1..055fd89 100644 --- a/crates/gba-core/src/input/keypad.rs +++ b/crates/gba-core/src/input/keypad.rs @@ -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 { + 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(_)))); + } +} diff --git a/crates/gba-core/src/io.rs b/crates/gba-core/src/io.rs index 3d92104..d5815ea 100644 --- a/crates/gba-core/src/io.rs +++ b/crates/gba-core/src/io.rs @@ -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; diff --git a/crates/gba-core/src/io/serial.rs b/crates/gba-core/src/io/serial.rs index 874d3a1..f5249ef 100644 --- a/crates/gba-core/src/io/serial.rs +++ b/crates/gba-core/src/io/serial.rs @@ -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 { + 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(_)))); + } +} diff --git a/crates/gba-core/src/io/snapshot.rs b/crates/gba-core/src/io/snapshot.rs new file mode 100644 index 0000000..71f8de9 --- /dev/null +++ b/crates/gba-core/src/io/snapshot.rs @@ -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 { + 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(()) + } +} diff --git a/crates/gba-core/src/lib.rs b/crates/gba-core/src/lib.rs index 9870683..cd791fd 100644 --- a/crates/gba-core/src/lib.rs +++ b/crates/gba-core/src/lib.rs @@ -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; diff --git a/crates/gba-core/src/machine.rs b/crates/gba-core/src/machine.rs index 2712246..4800a64 100644 --- a/crates/gba-core/src/machine.rs +++ b/crates/gba-core/src/machine.rs @@ -9,6 +9,7 @@ use crate::{ memory::Memory, timing::StepTiming, }; +mod snapshot; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StepKind { diff --git a/crates/gba-core/src/machine/snapshot.rs b/crates/gba-core/src/machine/snapshot.rs new file mode 100644 index 0000000..622cb7a --- /dev/null +++ b/crates/gba-core/src/machine/snapshot.rs @@ -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, 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 { + Ok(crate::timing::StepTiming { + code_cycles: reader.u32()?, + data_cycles: reader.u32()?, + internal_cycles: reader.u32()?, + idle_cycles: reader.u32()?, + }) +} diff --git a/crates/gba-core/src/memory.rs b/crates/gba-core/src/memory.rs index da9b626..2437932 100644 --- a/crates/gba-core/src/memory.rs +++ b/crates/gba-core/src/memory.rs @@ -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::{ diff --git a/crates/gba-core/src/memory/fetch.rs b/crates/gba-core/src/memory/fetch.rs index 2cfe07c..c93dae1 100644 --- a/crates/gba-core/src/memory/fetch.rs +++ b/crates/gba-core/src/memory/fetch.rs @@ -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, 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, 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 { + 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")) + ); + } +} diff --git a/crates/gba-core/src/memory/iwram_bus.rs b/crates/gba-core/src/memory/iwram_bus.rs index cb4e965..9934e96 100644 --- a/crates/gba-core/src/memory/iwram_bus.rs +++ b/crates/gba-core/src/memory/iwram_bus.rs @@ -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 { + 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)); + } +} diff --git a/crates/gba-core/src/memory/snapshot.rs b/crates/gba-core/src/memory/snapshot.rs new file mode 100644 index 0000000..bf19131 --- /dev/null +++ b/crates/gba-core/src/memory/snapshot.rs @@ -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(()) + } +} diff --git a/crates/gba-core/src/snapshot.rs b/crates/gba-core/src/snapshot.rs new file mode 100644 index 0000000..7d725d2 --- /dev/null +++ b/crates/gba-core/src/snapshot.rs @@ -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), +} + +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, + error: Option, +} + +impl Writer { + pub(crate) fn new() -> Self { + Self::default() + } + + /// The first recorded failure, or the encoded bytes. + pub(crate) fn finish(self) -> Result, 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(&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(&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 { + Ok(self.array::<1>()?[0]) + } + + pub(crate) fn bool(&mut self) -> Result { + match self.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(SnapshotError::Corrupt("boolean")), + } + } + + pub(crate) fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.array()?)) + } + + pub(crate) fn i16(&mut self) -> Result { + Ok(i16::from_le_bytes(self.array()?)) + } + + pub(crate) fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.array()?)) + } + + pub(crate) fn u64(&mut self) -> Result { + 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, 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( + &mut self, + load: impl FnOnce(&mut Self) -> Result, + ) -> Result, 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")) + ); + } +} diff --git a/crates/gba-core/src/timing/prefetch.rs b/crates/gba-core/src/timing/prefetch.rs index d4c7692..5774e03 100644 --- a/crates/gba-core/src/timing/prefetch.rs +++ b/crates/gba-core/src/timing/prefetch.rs @@ -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 { + 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(_)) + )); + } +} diff --git a/crates/gba-core/src/video.rs b/crates/gba-core/src/video.rs index e2d393e..460ffb7 100644 --- a/crates/gba-core/src/video.rs +++ b/crates/gba-core/src/video.rs @@ -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(value: &T) -> Vec { + 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>, diff --git a/crates/gba-core/src/video/affine.rs b/crates/gba-core/src/video/affine.rs index 7320594..ddf86b2 100644 --- a/crates/gba-core/src/video/affine.rs +++ b/crates/gba-core/src/video/affine.rs @@ -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 { + 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], diff --git a/crates/gba-core/src/video/capture.rs b/crates/gba-core/src/video/capture.rs index bde3ad5..5e35954 100644 --- a/crates/gba-core/src/video/capture.rs +++ b/crates/gba-core/src/video/capture.rs @@ -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, @@ -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, 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, 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 { + 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")) + ); + } +} diff --git a/crates/gba-core/src/video/effects.rs b/crates/gba-core/src/video/effects.rs index 8731040..21ed807 100644 --- a/crates/gba-core/src/video/effects.rs +++ b/crates/gba-core/src/video/effects.rs @@ -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")) + ); + } +} diff --git a/crates/gba-core/src/video/mosaic.rs b/crates/gba-core/src/video/mosaic.rs index 27a4be4..386f1f9 100644 --- a/crates/gba-core/src/video/mosaic.rs +++ b/crates/gba-core/src/video/mosaic.rs @@ -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 { + let mut writer = Writer::new(); + value.save(&mut writer); + writer.finish().unwrap() + } +} diff --git a/crates/gba-core/src/video/sprites.rs b/crates/gba-core/src/video/sprites.rs index eb3697e..28a575f 100644 --- a/crates/gba-core/src/video/sprites.rs +++ b/crates/gba-core/src/video/sprites.rs @@ -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, } +#[derive(Debug, Default, PartialEq, Eq)] pub(crate) struct PreparedObjects { samples: Vec, window: Vec, } -#[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, 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, 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, + 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(value: &T) -> Vec { + let mut writer = Writer::new(); + value.save(&mut writer); + writer.finish().unwrap() + } + + fn assert_prefixes(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")) + ); + } +} diff --git a/crates/gba-core/src/video/sprites/pipeline.rs b/crates/gba-core/src/video/sprites/pipeline.rs index 35b9efd..3bad326 100644 --- a/crates/gba-core/src/video/sprites/pipeline.rs +++ b/crates/gba-core/src/video/sprites/pipeline.rs @@ -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)>; 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 { + 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")) + ); + } +} diff --git a/crates/gba-core/src/video/windows.rs b/crates/gba-core/src/video/windows.rs index 1e4b193..a2d92aa 100644 --- a/crates/gba-core/src/video/windows.rs +++ b/crates/gba-core/src/video/windows.rs @@ -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")) + ); + } +} diff --git a/crates/gba-core/src/video/windows/horizontal.rs b/crates/gba-core/src/video/windows/horizontal.rs index 5558194..7fd87a8 100644 --- a/crates/gba-core/src/video/windows/horizontal.rs +++ b/crates/gba-core/src/video/windows/horizontal.rs @@ -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")) + ); + } +} diff --git a/crates/gba-core/tests/save_state.rs b/crates/gba-core/tests/save_state.rs new file mode 100644 index 0000000..bbb99f2 --- /dev/null +++ b/crates/gba-core/tests/save_state.rs @@ -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, 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) -> Vec { + 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); +} diff --git a/crates/gba-demos/src/tile_demo.rs b/crates/gba-demos/src/tile_demo.rs index 1c1966d..7e26749 100644 --- a/crates/gba-demos/src/tile_demo.rs +++ b/crates/gba-demos/src/tile_demo.rs @@ -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 { + program(Scene::Tiles) +} + fn literal(code: &mut Vec, patches: &mut Vec<(usize, u32, u32)>, reg: u32, value: u32) { patches.push((code.len(), reg, value)); code.push(0); diff --git a/docs/architecture.md b/docs/architecture.md index a3c5fb8..b0d7476 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/hardware/cartridge.md b/docs/hardware/cartridge.md index a04a048..9ca6265 100644 --- a/docs/hardware/cartridge.md +++ b/docs/hardware/cartridge.md @@ -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. diff --git a/docs/hardware/save-states.md b/docs/hardware/save-states.md new file mode 100644 index 0000000..a5c35b6 --- /dev/null +++ b/docs/hardware/save-states.md @@ -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. diff --git a/docs/hardware/saves.md b/docs/hardware/saves.md index 67413bd..6eb999b 100644 --- a/docs/hardware/saves.md +++ b/docs/hardware/saves.md @@ -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. diff --git a/docs/status.md b/docs/status.md index f4f583c..a6f7f9b 100644 --- a/docs/status.md +++ b/docs/status.md @@ -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.