diff --git a/src/device.rs b/src/device.rs index 858d7e93..881aa178 100644 --- a/src/device.rs +++ b/src/device.rs @@ -109,7 +109,11 @@ pub fn run_game( cpu::init(device); ui::storage::init(&mut device.ui, &device.cart.rom); - ui::storage::load_saves(&mut device.ui, &mut device.netplay, device.cart.cic_seed); + + if device.ui.state_test.is_none() { + ui::storage::load_saves(&mut device.ui, &mut device.netplay, device.cart.cic_seed); + } + ui::storage::format_saves(device); if !retroachievements::get_hardcore() { @@ -120,7 +124,9 @@ pub fn run_game( retroachievements::unload_game(discord_watch_tx, discord_handle); - ui::storage::write_saves(device); + if device.ui.state_test.is_none() { + ui::storage::write_saves(device); + } if let Some(netplay) = &mut device.netplay { netplay::close(netplay); @@ -144,6 +150,9 @@ fn init_rng_rtc(device: &mut Device) { rng_seed = netplay::receive_rng(netplay); } } + if device.ui.state_test.is_some() { + rng_seed = ui::state_test::RNG_SEED; + } device.rng = rand::rngs::Xoshiro256PlusPlus::seed_from_u64(rng_seed); device.cart.rtc_timestamp = std::time::SystemTime::now() @@ -157,6 +166,9 @@ fn init_rng_rtc(device: &mut Device) { device.cart.rtc_timestamp = netplay::receive_rtc(netplay); } } + if device.ui.state_test.is_some() { + device.cart.rtc_timestamp = ui::state_test::RTC_TIMESTAMP; + } } fn swap_rom(contents: Vec) -> Option> { diff --git a/src/device/controller.rs b/src/device/controller.rs index fead441e..49fa3fac 100644 --- a/src/device/controller.rs +++ b/src/device/controller.rs @@ -58,6 +58,11 @@ pub fn process(device: &mut device::Device, channel: usize) { let offset = device.pif.channels[channel].rx_buf.unwrap(); let input = if let Some(netplay) = &mut device.netplay { netplay.inputs[channel].0 + } else if device.ui.state_test.is_some() { + ui::input::InputData { + data: 0, + pak_change_pressed: false, + } } else { ui::input::get(&mut device.ui, channel) }; diff --git a/src/lib.rs b/src/lib.rs index bac31783..fad8b9a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,6 +97,13 @@ pub struct Args { help = "Password for RetroAchievements" )] pub ra_password: Option, + #[arg( + long, + value_name = "FRAMES", + value_parser = clap::value_parser!(u64).range(1..), + help = "Savestate test: run FRAMES, save state, run FRAMES and hash, load state, run FRAMES and hash again, then compare the hashes" + )] + pub savestate_test: Option, } #[cfg(feature = "gui")] @@ -161,8 +168,18 @@ pub fn run(args: Args, arg_count: usize) -> std::io::Result<()> { return Err(Error::other("Savestate slot must be between 0 and 9")); } + if args.savestate_test.is_some() && args.netplay_server_addr.is_some() { + return Err(Error::other("--savestate-test cannot be used with netplay")); + } + let mut device = device::Device::new(true); + if let Some(frames) = args.savestate_test { + device.ui.state_test = Some(ui::state_test::StateTest::new(frames)); + device.ui.speed_limiter.enabled = false; + device.ui.audio.gain = 0.0; + } + device.ui.config.ui.recent_roms.retain(|x| *x != game); device.ui.config.ui.recent_roms.insert(0, game); device.ui.config.ui.recent_roms.truncate(5); diff --git a/src/savestates.rs b/src/savestates.rs index f78c64f1..d66426f3 100644 --- a/src/savestates.rs +++ b/src/savestates.rs @@ -122,6 +122,8 @@ impl SavestateData { } pub fn process_savestates(device: &mut device::Device) { + ui::state_test::frame(device); + let save_rewind = if device.netplay.is_none() && device.ui.config.emulation.rewind && device.vi.elapsed_time - device.ui.savestate.last_rewind_saved > 1.0 diff --git a/src/ui.rs b/src/ui.rs index b99f552d..477cd62d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -14,6 +14,7 @@ pub mod macos; pub mod netplay; #[cfg(feature = "gui")] pub mod retroachievements; +pub mod state_test; pub mod storage; pub mod usb; pub mod video; @@ -153,6 +154,7 @@ pub struct Ui { pub usb: Usb, pub speed_limiter: SpeedLimiter, pub savestate: savestates::Savestate, + pub state_test: Option, } pub fn sdl_hints() { @@ -333,6 +335,7 @@ impl Ui { dirs, speed_limiter: SpeedLimiter::default(), savestate: savestates::Savestate::default(), + state_test: None, } } } diff --git a/src/ui/state_test.rs b/src/ui/state_test.rs new file mode 100644 index 00000000..130bbe0d --- /dev/null +++ b/src/ui/state_test.rs @@ -0,0 +1,90 @@ +use crate::{device, savestates, ui}; +use sha2::digest::Digest; + +// fixed so that the reported hash is reproducible across runs +pub const RNG_SEED: u64 = 0x6770_6865_7236_3400; +pub const RTC_TIMESTAMP: i64 = 946684800; // 2000-01-01 00:00:00 UTC + +enum Phase { + BeforeSave, + AfterSave, + AfterLoad, +} + +/// Savestate round-trip test (--savestate-test FRAMES): +/// run FRAMES, save state, run FRAMES and hash the device (A), +/// load the state, run FRAMES and hash the device again (B). A and B must match. +pub struct StateTest { + frames: u64, + frame_counter: u64, + phase: Phase, + hash: String, + finished: bool, +} + +impl StateTest { + pub fn new(frames: u64) -> Self { + Self { + frames, + frame_counter: 0, + phase: Phase::BeforeSave, + hash: String::new(), + finished: false, + } + } + + pub fn finished(&self) -> bool { + self.finished + } +} + +fn hash_device(device: &device::Device) -> String { + ui::video::idle(); + let data = postcard::to_stdvec(device).unwrap(); + sha2::Sha256::digest(&data) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +pub fn frame(device: &mut device::Device) { + let Some(mut test) = device.ui.state_test.take() else { + return; + }; + + if !test.finished() { + test.frame_counter += 1; + if test.frame_counter >= test.frames { + test.frame_counter = 0; + advance(device, &mut test); + } + } + + device.ui.state_test = Some(test); +} + +fn advance(device: &mut device::Device, test: &mut StateTest) { + match test.phase { + Phase::BeforeSave => { + savestates::create_savestate(device, false, None); + test.phase = Phase::AfterSave; + } + Phase::AfterSave => { + test.hash = hash_device(device); + savestates::load_savestate(device, false, None); + test.phase = Phase::AfterLoad; + } + Phase::AfterLoad => { + let hash = hash_device(device); + if hash == test.hash { + println!("Savestate test passed: {hash}"); + } else { + eprintln!( + "Savestate test failed: hash before load {} does not match hash after load {hash}", + test.hash + ); + } + test.finished = true; + } + } +} diff --git a/src/ui/video.rs b/src/ui/video.rs index fa091af2..7820b048 100644 --- a/src/ui/video.rs +++ b/src/ui/video.rs @@ -226,6 +226,10 @@ pub fn check_callback(device: &mut device::Device) -> (bool, bool) { let mut speed_limiter_toggled = false; let callback = unsafe { rdp_check_callback() }; device.cpu.running = callback.emu_running; + if let Some(test) = &device.ui.state_test { + device.cpu.running &= !test.finished(); + return (false, false); + } if device.netplay.is_none() { if callback.save_state { device.ui.savestate.save_state = true;