This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
gopher64-netplay/src/savestates.rs
Claude a0dce58c55
Some checks failed
Rust-lint / lint-linux (pull_request) Failing after 1s
Rust / build-linux (linux-x86_64, X64, ubuntu-24.04, x86_64-unknown-linux-gnu) (pull_request) Failing after 1s
Rust / build-android (29.0.14206865, ubuntu-24.04, 33) (pull_request) Failing after 1s
Rust / build-linux (linux-aarch64, ARM64, ubuntu-24.04-arm, aarch64-unknown-linux-gnu) (pull_request) Has been cancelled
Rust / build-windows (windows-aarch64, woa64, windows-11-arm, aarch64-pc-windows-msvc) (pull_request) Has been cancelled
Rust / build-windows (windows-x86_64, win64, windows-2025, x86_64-pc-windows-msvc) (pull_request) Has been cancelled
Rust / build-macos (macos-aarch64, ARM64, aarch64-apple-darwin) (pull_request) Has been cancelled
Rust / Collect release artifacts (pull_request) Has been cancelled
Allow savestate creation/loading during netplay (#2)
2026-08-25 04:37:36 -05:00

743 lines
28 KiB
Rust

use crate::{cheats, device, retroachievements, ui};
#[cfg(target_arch = "aarch64")]
use device::__m128i;
use serde::de::{Deserialize, Deserializer, SeqAccess, Visitor};
use serde::ser::{Serialize, SerializeSeq, Serializer};
#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;
use std::ops::Deref;
struct M128iArrayVisitor<const N: usize>;
impl<'de, const N: usize> Visitor<'de> for M128iArrayVisitor<N> {
type Value = [__m128i; N];
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(&format!("an array of {N} 128-bit integers"))
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut arr: [__m128i; N] = [device::zero_m128i(); N];
for (index, item) in arr.iter_mut().enumerate() {
match seq.next_element::<u128>()? {
Some(value) => *item = unsafe { std::mem::transmute::<u128, __m128i>(value) },
None => return Err(serde::de::Error::invalid_length(index, &self)),
}
}
Ok(arr)
}
}
pub fn deserialize_m128i_array<'de, D, const N: usize>(
deserializer: D,
) -> Result<[__m128i; N], D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_seq(M128iArrayVisitor)
}
pub fn serialize_m128i<S>(data: &__m128i, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let bytes: u128 = unsafe { std::mem::transmute(*data) };
bytes.serialize(serializer)
}
pub fn deserialize_m128i<'de, D>(deserializer: D) -> Result<__m128i, D::Error>
where
D: Deserializer<'de>,
{
let bytes = u128::deserialize(deserializer)?;
Ok(unsafe { std::mem::transmute::<u128, __m128i>(bytes) })
}
pub fn serialize_m128i_array<S>(value: &[__m128i], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut seq = serializer.serialize_seq(Some(value.len()))?;
for item in value {
let bytes: u128 = unsafe { std::mem::transmute(*item) };
seq.serialize_element(&bytes)?;
}
seq.end()
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct Savestate {
pub save_state: bool,
pub load_state: bool,
pub load_rewind: bool,
pub last_rewind_saved: f64,
/// Most recent savestate captured in memory while a netplay session is
/// active. Netplay savestates are kept in memory (never written to disk)
/// so a later explicit load can restore them within the same session.
#[serde(skip)]
pub last_netplay_state: Option<SavestateData>,
#[serde(skip)]
pub rewind_pool:
std::sync::Arc<tokio::sync::Mutex<std::collections::BTreeMap<i32, SavestateData>>>,
}
pub struct SavestateData {
device: Box<device::Device>,
saves: ui::storage::Saves,
rdp_state: Vec<u8>,
ra_state: Vec<u8>,
}
impl Clone for SavestateData {
fn clone(&self) -> Self {
let mut device = device::Device::new(false);
device.clone_state(&self.device);
SavestateData {
device,
saves: self.saves.clone(),
rdp_state: self.rdp_state.clone(),
ra_state: self.ra_state.clone(),
}
}
}
impl SavestateData {
/// Serialize this savestate into the same compressed multi-entry format
/// used for on-disk savestates, without touching the disk. Returns an
/// empty `Vec` if serialization or compression fails.
#[allow(dead_code)] // acceptance API: exercised by tests, used by callers
pub fn save_to_memory(&self) -> Vec<u8> {
if let Ok(device_data) = postcard::to_stdvec(self.device.deref())
&& let Ok(saves_data) = postcard::to_stdvec(&self.saves)
&& let Ok(compressed) = ui::storage::compress_file(&[
(&device_data, "device"),
(&saves_data, "saves"),
(&self.rdp_state, "rdp_state"),
(&self.ra_state, "ra_state"),
]) {
compressed
} else {
Vec::new()
}
}
/// Deserialize a savestate previously produced by
/// [`SavestateData::save_to_memory`].
#[allow(dead_code)] // acceptance API: exercised by tests, used by callers
pub fn load_from_memory(data: &[u8]) -> Option<Self> {
let device_data = ui::storage::decompress_file(data, "device").ok()?;
let saves_data = ui::storage::decompress_file(data, "saves").ok()?;
let rdp_state = ui::storage::decompress_file(data, "rdp_state").ok()?;
let ra_state = ui::storage::decompress_file(data, "ra_state").ok()?;
Some(SavestateData {
device: postcard::from_bytes(&device_data).ok()?,
saves: postcard::from_bytes(&saves_data).ok()?,
rdp_state,
ra_state,
})
}
}
static DEVICE_CLONE: std::sync::LazyLock<tokio::sync::Mutex<Box<device::Device>>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(device::Device::new(false)));
static SAVES_CLONE: std::sync::LazyLock<tokio::sync::Mutex<ui::storage::Saves>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(ui::storage::Saves::default()));
pub fn process_savestates(device: &mut device::Device) {
let save_rewind = if device.netplay.is_none()
&& device.ui.config.emulation.rewind
&& device.vi.elapsed_time - device.savestate.last_rewind_saved > 1.0
{
device.savestate.last_rewind_saved = device.vi.elapsed_time;
true
} else {
false
};
if device.savestate.save_state || save_rewind {
// During an active netplay session an explicit save is kept in memory
// (no disk I/O); the automatic rewind below is still offline-only.
if device.netplay.is_some() && device.savestate.save_state {
if let Some(state) = create_savestate_in_memory(device) {
device.savestate.last_netplay_state = Some(state);
}
} else {
create_savestate(device, !device.savestate.save_state, None);
}
device.savestate.save_state = false;
} else if device.savestate.load_state || device.savestate.load_rewind {
// During netplay an explicit load restores the in-memory savestate;
// rewind-frame loads stay on the GGRS rollback path.
if device.netplay.is_some()
&& device.savestate.load_state
&& !device.savestate.load_rewind
{
match device.savestate.last_netplay_state.clone() {
Some(state) => {
if load_savestate_from_data(device, &state) {
ui::video::onscreen_message(
"Savestate loaded",
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
);
} else {
ui::video::onscreen_message(
"Failed to load in-memory savestate",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
}
}
None => {
ui::video::onscreen_message(
"No in-memory savestate to load",
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
);
}
}
} else {
load_savestate(device, device.savestate.load_rewind, None);
}
device.savestate.load_state = false;
device.savestate.load_rewind = false;
}
}
pub fn create_savestate(device: &mut device::Device, rewind: bool, rewind_frame: Option<i32>) {
if !rewind {
ui::video::idle();
}
let mut rdp_state: Vec<u8> = vec![0; ui::video::state_size()];
ui::video::save_state(rdp_state.as_mut_ptr());
let mut ra_state: Vec<u8> = vec![0; retroachievements::state_size()];
retroachievements::save_state(ra_state.as_mut_ptr(), ra_state.len());
DEVICE_CLONE.blocking_lock().clone_state(device);
SAVES_CLONE
.blocking_lock()
.clone_from(&device.ui.storage.saves);
let save_path = device.ui.storage.paths.savestate_file_path.clone();
let save_state_slot = device.ui.storage.save_state_slot;
let rewind_pool = device.savestate.rewind_pool.clone();
tokio::spawn(async move {
let mut error = false;
if rewind {
let mut pool = rewind_pool.lock().await;
let mut state = device::Device::new(false);
state.clone_state(DEVICE_CLONE.lock().await.deref());
let key = if let Some(key) = rewind_frame {
key
} else if let Some(key) = pool.keys().last() {
key + 1
} else {
0
};
pool.insert(
key,
SavestateData {
device: state,
saves: SAVES_CLONE.lock().await.clone(),
rdp_state,
ra_state,
},
);
if pool.len() > 30 {
pool.pop_first();
}
} else {
let compressed_file = if let Ok(device_data) =
postcard::to_stdvec(DEVICE_CLONE.lock().await.deref())
&& let Ok(saves_data) = postcard::to_stdvec(SAVES_CLONE.lock().await.deref())
&& let Ok(compressed_file) = ui::storage::compress_file(&[
(&device_data, "device"),
(&saves_data, "saves"),
(&rdp_state, "rdp_state"),
(&ra_state, "ra_state"),
]) {
Some(compressed_file)
} else {
None
};
if let Some(compressed_file) = compressed_file {
if let Err(e) = tokio::fs::write(save_path, compressed_file).await {
eprintln!("Error writing savestate: {}", e);
error = true;
}
} else {
error = true;
eprintln!("Error compressing savestate");
}
}
if error {
ui::video::onscreen_message(
&format!("Failed to create savestate in slot {}", save_state_slot),
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
} else if !rewind {
ui::video::onscreen_message(
&format!("Savestate created in slot {}", save_state_slot),
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
);
}
});
}
pub fn create_savestate_in_memory(device: &mut device::Device) -> Option<SavestateData> {
// Capture auxiliary state exactly as `create_savestate` does. The video
// and RetroAchievements backends make these no-ops when they are not
// initialized (headless contexts get empty auxiliary state), which keeps
// this callable in tests and before UI init.
let mut rdp_state: Vec<u8> = vec![0; ui::video::state_size()];
ui::video::save_state(rdp_state.as_mut_ptr());
let mut ra_state: Vec<u8> = vec![0; retroachievements::state_size()];
retroachievements::save_state(ra_state.as_mut_ptr(), ra_state.len());
// Reuse the same clone statics as create_savestate so both code paths
// share one snapshot mechanism. Both guards are held for the entire
// capture (in DEVICE_CLONE then SAVES_CLONE order, matching the disk
// path) so a concurrent savestate caller cannot swap the clone statics
// in between the copy and the read-out.
let mut cloned_device = device::Device::new(false);
let saves;
{
let mut device_clone = DEVICE_CLONE.blocking_lock();
device_clone.clone_state(device);
let mut saves_clone = SAVES_CLONE.blocking_lock();
saves_clone.clone_from(&device.ui.storage.saves);
cloned_device.clone_state(device_clone.deref());
saves = saves_clone.clone();
}
Some(SavestateData {
device: cloned_device,
saves,
rdp_state,
ra_state,
})
}
fn show_savestate_load_failure(device: &device::Device, rewind: bool) {
if !rewind {
ui::video::onscreen_message(
&format!(
"Failed to load savestate from slot {}",
device.ui.storage.save_state_slot
),
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
);
} else if device.netplay.is_none() {
ui::video::onscreen_message(
"Failed to rewind",
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
);
} else {
ui::video::onscreen_message(
"Failed to rollback",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
}
}
/// Apply a captured `SavestateData` to the device by swapping its emulated
/// state in and rebuilding every derived structure. Shared by the on-disk
/// loader (`load_savestate`) and the in-memory netplay loader
/// (`load_savestate_from_data`). Returns false when the state is
/// incompatible (rdram size mismatch).
fn apply_savestate(device: &mut device::Device, state: &mut SavestateData) -> bool {
if device.rdram.size != state.device.rdram.size {
return false;
}
if device.netplay.is_none() {
ui::video::idle();
}
device.savestate.last_rewind_saved = state.device.vi.elapsed_time;
std::mem::swap(&mut device.rng, &mut state.device.rng);
std::mem::swap(&mut device.ui.storage.saves, &mut state.saves);
std::mem::swap(&mut device.cpu, &mut state.device.cpu);
std::mem::swap(&mut device.pif, &mut state.device.pif);
std::mem::swap(&mut device.cart, &mut state.device.cart);
std::mem::swap(&mut device.cart.rom, &mut state.device.cart.rom); // ROM is not included in the savestate
std::mem::swap(&mut device.memory, &mut state.device.memory);
std::mem::swap(&mut device.rsp, &mut state.device.rsp);
std::mem::swap(&mut device.rdp, &mut state.device.rdp);
device.rdram.mem.copy_from_slice(&state.device.rdram.mem); // RDRAM address should not change
std::mem::swap(&mut device.rdram.regs, &mut state.device.rdram.regs);
std::mem::swap(&mut device.mi, &mut state.device.mi);
std::mem::swap(&mut device.pi, &mut state.device.pi);
std::mem::swap(&mut device.vi, &mut state.device.vi);
std::mem::swap(&mut device.ai, &mut state.device.ai);
std::mem::swap(&mut device.si, &mut state.device.si);
std::mem::swap(&mut device.ri, &mut state.device.ri);
std::mem::swap(&mut device.vru, &mut state.device.vru);
std::mem::swap(&mut device.cheats, &mut state.device.cheats);
std::mem::swap(&mut device.transferpaks, &mut state.device.transferpaks);
for (i, item) in device.transferpaks.iter_mut().enumerate() {
std::mem::swap(
&mut item.cart.rom,
&mut state.device.transferpaks[i].cart.rom,
); // ROM is not included in the savestate
}
device::memory::init(device);
device::vi::set_expected_refresh_rate(device);
device::cpu::map_instructions(device);
device::cop0::map_instructions(device);
device::cop1::map_instructions(device);
device::cop2::map_instructions(device);
device::rsp_cpu::map_instructions(device);
let mut mem_addr = 0x1000;
while mem_addr < 0x2000 {
let data =
u32::from_be_bytes(device.rsp.mem[mem_addr..mem_addr + 4].try_into().unwrap());
device.rsp.cpu.instructions[(mem_addr & 0xFFF) / 4].func =
device::rsp_cpu::decode_opcode(device, data);
device.rsp.cpu.instructions[(mem_addr & 0xFFF) / 4].opcode = data;
mem_addr += 4;
}
for line_index in 0..512 {
for i in 0..8 {
device.memory.icache[line_index].instruction[i] =
device::cpu::decode_opcode(device, device.memory.icache[line_index].words[i]);
}
}
device::pif::connect_pif_channels(device);
for i in 0..4 {
if let Some(handler) = device.pif.channels[i].pak_handler {
if handler.pak_type == device::controller::PakType::RumblePak {
let rumblepak_handler = device::controller::PakHandler {
read: device::controller::rumble::read,
write: device::controller::rumble::write,
pak_type: device::controller::PakType::RumblePak,
};
device.pif.channels[i].pak_handler = Some(rumblepak_handler);
} else if handler.pak_type == device::controller::PakType::MemPak {
let mempak_handler = device::controller::PakHandler {
read: device::controller::mempak::read,
write: device::controller::mempak::write,
pak_type: device::controller::PakType::MemPak,
};
device.pif.channels[i].pak_handler = Some(mempak_handler);
} else if handler.pak_type == device::controller::PakType::TransferPak {
let tpak_handler = device::controller::PakHandler {
read: device::controller::transferpak::read,
write: device::controller::transferpak::write,
pak_type: device::controller::PakType::TransferPak,
};
device.pif.channels[i].pak_handler = Some(tpak_handler);
}
}
}
ui::audio::update_freq(device);
ui::video::load_state(device, state.rdp_state.as_ptr());
if !state.ra_state.is_empty() {
retroachievements::load_state(state.ra_state.as_ptr(), state.ra_state.len());
} else {
retroachievements::load_state(std::ptr::null(), 0);
}
if device.cheats.enabled {
cheats::execute_cheats(device, device.cheats.cheats.clone());
}
true
}
/// Load a savestate captured in memory (netplay path) into `device` without
/// any disk I/O. Returns whether the state was compatible and applied.
pub fn load_savestate_from_data(device: &mut device::Device, state: &SavestateData) -> bool {
let mut state = state.clone();
apply_savestate(device, &mut state)
}
pub fn load_savestate(device: &mut device::Device, rewind: bool, rewind_frame: Option<i32>) {
if retroachievements::get_hardcore() {
ui::video::onscreen_message(
"Cannot load savestate in RA hardcore mode",
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
);
return;
}
let state_data = if rewind {
if let Some(rewind_frame) = rewind_frame {
let timeout = std::time::Duration::from_secs(1);
let now = std::time::Instant::now();
loop {
if let Some(state) = device
.savestate
.rewind_pool
.blocking_lock()
.remove(&rewind_frame)
{
break Some(state);
}
if now.elapsed() > timeout {
break None;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
} else if let Some((_key, state)) = device.savestate.rewind_pool.blocking_lock().pop_last()
{
Some(state)
} else {
None
}
} else if let Ok(savestate) = std::fs::read(&device.ui.storage.paths.savestate_file_path)
&& let Ok(device_data) = ui::storage::decompress_file(&savestate, "device")
&& let Ok(saves_data) = ui::storage::decompress_file(&savestate, "saves")
&& let Ok(rdp_state) = ui::storage::decompress_file(&savestate, "rdp_state")
&& let Ok(ra_state) = ui::storage::decompress_file(&savestate, "ra_state")
&& let Ok(state) = postcard::from_bytes::<Box<device::Device>>(&device_data)
&& let Ok(saves) = postcard::from_bytes(&saves_data)
{
Some(SavestateData {
device: state,
saves,
rdp_state,
ra_state,
})
} else {
None
};
if let Some(mut state) = state_data {
if apply_savestate(device, &mut state) {
if !rewind {
ui::video::onscreen_message(
&format!(
"Savestate loaded from slot {}",
device.ui.storage.save_state_slot
),
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
);
}
} else {
show_savestate_load_failure(device, rewind);
}
} else {
show_savestate_load_failure(device, rewind);
}
}
pub fn default_pak_handler() -> fn(&mut device::Device, usize, u16, usize, usize) {
device::controller::mempak::read
}
pub fn default_instruction() -> fn(&mut device::Device, u32) {
device::cop0::reserved
}
pub fn default_instructions<const N: usize>() -> [fn(&mut device::Device, u32); N]
where
[fn(&mut device::Device, u32); N]: Sized,
{
[device::cop0::reserved; N]
}
pub fn default_memory_read_fast()
-> [fn(&device::Device, u64, device::memory::AccessSize) -> u32; 0x2000] {
[device::unmapped::read_mem_fast; 0x2000]
}
pub fn default_memory_read()
-> [fn(&mut device::Device, u64, device::memory::AccessSize) -> u32; 0x2000] {
[device::unmapped::read_mem; 0x2000]
}
pub fn default_memory_write() -> [fn(&mut device::Device, u64, u32, u32); 0x2000] {
[device::unmapped::write_mem; 0x2000]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{device, netplay};
/// No-op network socket: satisfies ggrs' `NonBlockingSocket` bound so a
/// local-only session can be constructed without any real network.
struct TestSocket;
impl ggrs::NonBlockingSocket<matchbox_socket::PeerId> for TestSocket {
fn send_to(&mut self, _msg: &ggrs::Message, _addr: &matchbox_socket::PeerId) {}
fn receive_all_messages(&mut self) -> Vec<(matchbox_socket::PeerId, ggrs::Message)> {
Vec::new()
}
}
/// Build a real `Netplay` value offline. WebRTC channels are created
/// eagerly by the socket builder (no connection is attempted until the
/// returned loop future is polled, which we never do), and a local-only
/// ggrs session starts in `Running` state without network I/O.
fn mock_netplay() -> netplay::Netplay {
let (mut socket, _loop_future) = matchbox_socket::WebRtcSocketBuilder::new(
"ws://127.0.0.1:9/room",
)
.add_unreliable_channel()
.add_reliable_channel()
.build();
let reliable_channel = socket.take_channel(1).unwrap();
let session = ggrs::SessionBuilder::<netplay::GgrsConfig>::new()
.with_num_players(1)
.unwrap()
.add_player(ggrs::PlayerType::Local, 0)
.unwrap()
.start_p2p_session(TestSocket)
.unwrap();
netplay::Netplay {
disconnected: false,
session,
reliable_channel,
peers: vec![],
player_number: 0,
connected: [true, false, false, false],
input_delay: 0,
messages: Default::default(),
received_data: Default::default(),
inputs: vec![],
requests: Default::default(),
incoming_message: vec![],
ice_config_path: std::path::PathBuf::new(),
}
}
/// Acceptance test: create → save-to-memory → load-from-memory →
/// apply round-trip, verifying `Device` state (cpu regs, rdram bytes,
/// vi regs) matches the snapshot, while an active (mocked) netplay
/// session is attached.
#[test]
fn netplay_in_memory_savestate_round_trip_restores_device() {
let mut device = device::Device::new(false);
device.netplay = Some(mock_netplay());
// Distinctive state to verify round-trip fidelity.
device.cpu.gpr[1] = 0x1234_5678_9abc_def0;
device.cpu.gpr[3] = 0x0fed_cba9_8765_4321;
device.rdram.mem.resize(0x800, 0);
device.rdram.mem[0] = 0xAB;
device.rdram.mem[0x3FF] = 0xCD;
device.rdram.mem[0x7FF] = 0xEF;
device.vi.regs[0] = 0x1122_3344;
device.vi.regs[2] = 0x5566_7788;
let snapshot = create_savestate_in_memory(&mut device)
.expect("create in-memory savestate during netplay");
let bytes = snapshot.save_to_memory();
assert!(!bytes.is_empty(), "in-memory savestate must serialize");
let restored = SavestateData::load_from_memory(&bytes)
.expect("load savestate back from memory");
// Clobber the live device, then restore from the in-memory snapshot.
device.cpu.gpr[1] = 0;
device.cpu.gpr[3] = 0;
device.rdram.mem[0x3FF] = 0;
device.vi.regs[0] = 0;
assert!(
load_savestate_from_data(&mut device, &restored),
"in-memory load must succeed during netplay"
);
assert_eq!(device.cpu.gpr[1], 0x1234_5678_9abc_def0);
assert_eq!(device.cpu.gpr[3], 0x0fed_cba9_8765_4321);
assert_eq!(device.rdram.mem[0], 0xAB);
assert_eq!(device.rdram.mem[0x3FF], 0xCD);
assert_eq!(device.rdram.mem[0x7FF], 0xEF);
assert_eq!(device.vi.regs[0], 0x1122_3344);
assert_eq!(device.vi.regs[2], 0x5566_7788);
}
/// Acceptance test: explicit save/load requests while a netplay session is
/// active route through `process_savestates` to the in-memory savestate
/// (never touching the disk), and a later load restores the state.
#[test]
fn netplay_process_savestates_uses_in_memory_snapshot() {
let mut device = device::Device::new(false);
device.netplay = Some(mock_netplay());
device.cpu.gpr[4] = 0xdead_beef_cafe_babe;
device.rdram.mem.resize(0x100, 0);
device.rdram.mem[0x40] = 0x42;
// Explicit save request during netplay.
device.savestate.save_state = true;
process_savestates(&mut device);
assert!(
device.savestate.last_netplay_state.is_some(),
"netplay savestate must be kept in memory"
);
assert!(!device.savestate.save_state, "save request must be consumed");
// Clobber the device.
device.cpu.gpr[4] = 0;
device.rdram.mem[0x40] = 0;
// Explicit load request during netplay restores from memory.
device.savestate.load_state = true;
process_savestates(&mut device);
assert!(!device.savestate.load_state, "load request must be consumed");
assert_eq!(device.cpu.gpr[4], 0xdead_beef_cafe_babe);
assert_eq!(device.rdram.mem[0x40], 0x42);
}
/// When no netplay session is active an in-memory savestate is not
/// kept behind: the explicit-save request is routed to the unchanged
/// disk path (offline savestate behavior identical to upstream) and the
/// state file is written. A tokio runtime must be live for the spawned
/// disk-write task, so this test builds one explicitly.
#[test]
fn offline_explicit_save_stays_on_disk_path_and_never_populates_memory() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
let _enter = runtime.enter();
let mut device = device::Device::new(false);
let save_path = std::env::temp_dir()
.join(format!("g64_offline_save_lane3_{}.state1", std::process::id()));
device.ui.storage.paths.savestate_file_path = save_path.clone();
device.savestate.save_state = true;
process_savestates(&mut device);
assert!(!device.savestate.save_state, "save request must be consumed");
assert!(
device.savestate.last_netplay_state.is_none(),
"offline savestates stay on the disk path"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !save_path.exists() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
}
let wrote_file = save_path.exists();
let _ = std::fs::remove_file(&save_path);
assert!(wrote_file, "offline save must write the state file to disk");
}
}