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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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