Add savestate test

This commit is contained in:
Logan McNaughton committed 2026-11-11 20:38:04 +01:00
1 parent f6b9fcd3d2
commit 2ebd1dde60
7 files changed
+135 -2

No files matched your search

+14 -2
View File
@@ -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<u8>) -> Option<Vec<u8>> {
+5
View File
@@ -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)
};
+17
View File
@@ -97,6 +97,13 @@ pub struct Args {
help = "Password for RetroAchievements"
)]
pub ra_password: Option<String>,
#[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<u64>,
}
#[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);
+2
View File
@@ -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
+3
View File
@@ -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<state_test::StateTest>,
}
pub fn sdl_hints() {
@@ -333,6 +335,7 @@ impl Ui {
dirs,
speed_limiter: SpeedLimiter::default(),
savestate: savestates::Savestate::default(),
state_test: None,
}
}
}
+90
View File
@@ -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;
}
}
}
+4
View File
@@ -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;