Allow savestate creation/loading during netplay (#2)
All checks were successful
CI probe / probe (push) Successful in 0s
All checks were successful
CI probe / probe (push) Successful in 0s
This commit is contained in:
commit
c857feada3
@ -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(),
|
||||
)),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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<SavestateData>,
|
||||
#[serde(skip)]
|
||||
pub rewind_pool:
|
||||
std::sync::Arc<tokio::sync::Mutex<std::collections::BTreeMap<i32, SavestateData>>>,
|
||||
@ -88,6 +93,56 @@ pub struct SavestateData {
|
||||
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)));
|
||||
|
||||
@ -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<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(
|
||||
@ -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<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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user