From a0dce58c55dc4fce68f3f648f2b2a09a2daf5435 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 04:37:36 -0500 Subject: [PATCH] Allow savestate creation/loading during netplay (#2) --- src/device.rs | 1 + src/device/vi.rs | 10 +- src/savestates.rs | 593 +++++++++++++++++++++++++++++++++++----------- src/ui/video.rs | 23 ++ 4 files changed, 487 insertions(+), 140 deletions(-) diff --git a/src/device.rs b/src/device.rs index 7c17ed2..af42092 100644 --- a/src/device.rs +++ b/src/device.rs @@ -349,6 +349,7 @@ impl Device { load_state: false, load_rewind: false, last_rewind_saved: 0.0, + last_netplay_state: None, rewind_pool: std::sync::Arc::new(tokio::sync::Mutex::new( std::collections::BTreeMap::new(), )), diff --git a/src/device/vi.rs b/src/device/vi.rs index 298c90e..a363419 100644 --- a/src/device/vi.rs +++ b/src/device/vi.rs @@ -100,9 +100,13 @@ pub fn write_regs(device: &mut device::Device, address: u64, value: u32, mask: u let current_origin = device.vi.regs[reg as usize]; device::memory::masked_write_32(&mut device.vi.regs[reg as usize], value, mask); if current_origin != device.vi.regs[reg as usize] { - if device.netplay.is_none() { - savestates::process_savestates(device); - } + // Savestate requests are processed during netplay too. + // `process_savestates` routes explicit saves/loads to the + // in-memory netplay savestate when a session is active, while + // auto-rewind stays offline-only. Offline behavior is + // unchanged (this previously ran unconditionally for + // `netplay.is_none()`). + savestates::process_savestates(device); if !netplay::in_rollback(device.netplay.as_ref()) { let _ = device.ui.video.fps_tx.as_ref().unwrap().send(true); } diff --git a/src/savestates.rs b/src/savestates.rs index 21cf91f..2b5b3d2 100644 --- a/src/savestates.rs +++ b/src/savestates.rs @@ -76,6 +76,11 @@ pub struct Savestate { 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, #[serde(skip)] pub rewind_pool: std::sync::Arc>>, @@ -88,6 +93,56 @@ pub struct SavestateData { ra_state: Vec, } +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 { + 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 { + 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>> = std::sync::LazyLock::new(|| tokio::sync::Mutex::new(device::Device::new(false))); @@ -106,10 +161,47 @@ pub fn process_savestates(device: &mut device::Device) { }; if device.savestate.save_state || save_rewind { - create_savestate(device, !device.savestate.save_state, None); + // 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 { - load_savestate(device, device.savestate.load_rewind, None); + // 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; } @@ -200,6 +292,189 @@ pub fn create_savestate(device: &mut device::Device, rewind: bool, rewind_frame: }); } +pub fn create_savestate_in_memory(device: &mut device::Device) -> Option { + // 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 = vec![0; ui::video::state_size()]; + ui::video::save_state(rdp_state.as_mut_ptr()); + + let mut ra_state: Vec = 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) { if retroachievements::get_hardcore() { ui::video::onscreen_message( @@ -251,145 +526,22 @@ pub fn load_savestate(device: &mut device::Device, rewind: bool, rewind_frame: O None }; - if let Some(mut state) = state_data - && device.rdram.size == state.device.rdram.size - { - 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]); + 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, + ); } - } - - 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()); - } - - if !rewind { - ui::video::onscreen_message( - &format!( - "Savestate loaded from slot {}", - device.ui.storage.save_state_slot - ), - ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT, - ); + show_savestate_load_failure(device, rewind); } } else { - let (message, length) = if !rewind { - ( - 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() { - ( - "Failed to rewind".to_string(), - ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT, - ) - } else { - ( - "Failed to rollback".to_string(), - ui::video::MESSAGE_LENGTH_MESSAGE_SHORT, - ) - }; - ui::video::onscreen_message(&message, length); + show_savestate_load_failure(device, rewind); } } @@ -421,3 +573,170 @@ pub fn default_memory_read() 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 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::::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"); + } +} diff --git a/src/ui/video.rs b/src/ui/video.rs index dcfe08c..6776784 100644 --- a/src/ui/video.rs +++ b/src/ui/video.rs @@ -10,6 +10,11 @@ const NTSC_WIDESCREEN_WIDTH: i32 = 426; const NTSC_STANDARD_WIDTH: i32 = 320; const NTSC_HEIGHT: i32 = 240; +/// Tracks whether the parallel-RDP backend has been initialized so state +/// capture/restore can be a safe no-op before `init` (or after `close`). +static VIDEO_INITIALIZED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + fn build_gfx_info(device: &mut device::Device, netplay: bool) -> GFX_INFO { GFX_INFO { RDRAM: device.rdram.mem.as_mut_ptr(), @@ -117,6 +122,8 @@ pub fn init(device: &mut device::Device, netplay: bool) { } fps_counter(&mut device.ui); + + VIDEO_INITIALIZED.store(true, std::sync::atomic::Ordering::Relaxed); } fn fps_counter(ui: &mut ui::Ui) { @@ -151,6 +158,7 @@ pub fn close(ui: &ui::Ui) { rdp_close(); sdl3_sys::video::SDL_DestroyWindow(ui.video.window); } + VIDEO_INITIALIZED.store(false, std::sync::atomic::Ordering::Relaxed); } pub fn update_screen() { @@ -162,18 +170,30 @@ pub fn render_frame() { } pub fn state_size() -> usize { + if !VIDEO_INITIALIZED.load(std::sync::atomic::Ordering::Relaxed) { + return 0; + } unsafe { rdp_state_size() } } pub fn save_state(rdp_state: *mut u8) { + if !VIDEO_INITIALIZED.load(std::sync::atomic::Ordering::Relaxed) { + return; + } unsafe { rdp_save_state(rdp_state) } } pub fn idle() { + if !VIDEO_INITIALIZED.load(std::sync::atomic::Ordering::Relaxed) { + return; + } unsafe { rdp_idle() } } pub fn load_state(device: &mut device::Device, rdp_state: *const u8) { + if !VIDEO_INITIALIZED.load(std::sync::atomic::Ordering::Relaxed) { + return; + } let gfx_info = build_gfx_info(device, device.netplay.is_some()); unsafe { rdp_load_state(gfx_info, rdp_state); @@ -273,6 +293,9 @@ pub fn check_framebuffers(address: u32, length: u32) { } pub fn onscreen_message(message: &str, milliseconds: MESSAGE_LENGTH) { + if !VIDEO_INITIALIZED.load(std::sync::atomic::Ordering::Relaxed) { + return; + } unsafe { let c_message = std::ffi::CString::new(message).unwrap(); rdp_onscreen_message(c_message.as_ptr(), milliseconds)