netplay: session save to disk (host) [issue-3] #13
155
src/netplay.rs
155
src/netplay.rs
@ -1,4 +1,5 @@
|
||||
use crate::device;
|
||||
use crate::savestates;
|
||||
use crate::ui;
|
||||
use sha2::digest::Digest;
|
||||
|
||||
@ -200,6 +201,115 @@ pub fn receive_save(netplay: &mut Netplay, save_type: &str, save_data: &mut Vec<
|
||||
*save_data = message;
|
||||
}
|
||||
|
||||
/// On-disk representation of a saved netplay session (host side).
|
||||
/// `state` holds the compressed savestate payload produced by
|
||||
/// `SavestateData::save_to_memory` (zip entries for device, saves,
|
||||
/// rdp_state and ra_state); the whole struct is postcard-encoded and
|
||||
/// stored zstd-compressed under a single entry inside the
|
||||
/// `.netplay-state` file.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct NetplaySessionSave {
|
||||
pub state: Vec<u8>,
|
||||
/// GGRS frame the session was saved at, so a resume can restart here.
|
||||
pub frame: i32,
|
||||
pub input_delay: usize,
|
||||
pub rom_hash: String,
|
||||
pub players: usize,
|
||||
}
|
||||
|
||||
const NETPLAY_STATE_ENTRY: &str = "netplay_state";
|
||||
|
||||
/// Postcard-encode + compress a netplay session save into its on-disk form.
|
||||
pub fn encode_netplay_session(save: &NetplaySessionSave) -> Option<Vec<u8>> {
|
||||
let data = postcard::to_stdvec(save).ok()?;
|
||||
ui::storage::compress_file(&[(&data, NETPLAY_STATE_ENTRY)]).ok()
|
||||
}
|
||||
|
||||
/// Decompress + decode a .netplay-state file back into its session save.
|
||||
/// Used by the tests today; a future resume feature consumes it.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn decode_netplay_session(file_data: &[u8]) -> Option<NetplaySessionSave> {
|
||||
let data = ui::storage::decompress_file(file_data, NETPLAY_STATE_ENTRY).ok()?;
|
||||
postcard::from_bytes(&data).ok()
|
||||
}
|
||||
|
||||
fn write_atomic(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
|
||||
let tmp_path = path.with_extension("netplay-state.tmp");
|
||||
std::fs::write(&tmp_path, data)?;
|
||||
std::fs::rename(&tmp_path, path)
|
||||
}
|
||||
|
||||
/// Write a netplay session save to disk atomically (tmp + rename).
|
||||
pub fn write_netplay_session(
|
||||
path: &std::path::Path,
|
||||
save: &NetplaySessionSave,
|
||||
) -> std::io::Result<()> {
|
||||
let data = encode_netplay_session(save).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"failed to encode netplay session",
|
||||
)
|
||||
})?;
|
||||
write_atomic(path, &data)
|
||||
}
|
||||
|
||||
/// Host-side session save: capture the current emulator state together with
|
||||
/// the GGRS frame and session metadata into a `.netplay-state` file so the
|
||||
/// session can be resumed later. Only the host (player_number 0) can save;
|
||||
/// everyone else just gets an onscreen notice.
|
||||
pub fn save_netplay_session(device: &mut device::Device, path: &std::path::Path) {
|
||||
let Some(netplay) = device.netplay.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if netplay.player_number != 0 {
|
||||
ui::video::onscreen_message(
|
||||
"Only the host can save the session",
|
||||
ui::video::MESSAGE_LENGTH_MESSAGE_VERY_SHORT,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(state_data) = savestates::create_savestate_in_memory(device) else {
|
||||
eprintln!("Error capturing netplay session state");
|
||||
ui::video::onscreen_message(
|
||||
"Failed to save netplay session",
|
||||
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(state_zip) = state_data.save_to_memory() else {
|
||||
eprintln!("Error serializing netplay session state");
|
||||
ui::video::onscreen_message(
|
||||
"Failed to save netplay session",
|
||||
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let netplay = device.netplay.as_ref().unwrap();
|
||||
let save = NetplaySessionSave {
|
||||
state: state_zip,
|
||||
frame: netplay.session.current_frame(),
|
||||
input_delay: netplay.input_delay,
|
||||
rom_hash: device.ui.game_hash.clone(),
|
||||
players: netplay.peers.len() + 1,
|
||||
};
|
||||
|
||||
match write_netplay_session(path, &save) {
|
||||
Ok(()) => ui::video::onscreen_message(
|
||||
"Session saved — safe to quit",
|
||||
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
|
||||
),
|
||||
Err(e) => {
|
||||
eprintln!("Error writing netplay session: {e}");
|
||||
ui::video::onscreen_message(
|
||||
"Failed to save netplay session",
|
||||
ui::video::MESSAGE_LENGTH_MESSAGE_SHORT,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_input_delay(netplay: &mut Netplay, input_delay: usize) {
|
||||
if input_delay < 1 {
|
||||
return;
|
||||
@ -584,3 +694,48 @@ pub fn close(netplay: &mut Netplay) {
|
||||
|
||||
let _ = std::fs::remove_file(&netplay.ice_config_path);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_save(frame: i32) -> NetplaySessionSave {
|
||||
NetplaySessionSave {
|
||||
state: (0..256u32).map(|i| (i.wrapping_mul(13) % 251) as u8).collect(),
|
||||
frame,
|
||||
input_delay: 2,
|
||||
rom_hash: "12345678-90ABCDEF-C:45".to_string(),
|
||||
players: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn netplay_session_roundtrips_through_compress_decompress() {
|
||||
let save = test_save(123_456);
|
||||
let encoded = encode_netplay_session(&save).expect("encode should succeed");
|
||||
let decoded = decode_netplay_session(&encoded).expect("decode should succeed");
|
||||
assert_eq!(decoded, save);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn netplay_session_file_has_correct_metadata() {
|
||||
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let save = test_save(4321);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"gopher64-netplay-state-test-{}-{}.bin",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
write_netplay_session(&path, &save).expect("write should succeed");
|
||||
|
||||
let file_data = std::fs::read(&path).expect("file should exist on disk");
|
||||
let parsed = decode_netplay_session(&file_data).expect("file should parse");
|
||||
assert_eq!(parsed.frame, 4321);
|
||||
assert_eq!(parsed.rom_hash, "12345678-90ABCDEF-C:45");
|
||||
assert_eq!(parsed.input_delay, 2);
|
||||
assert_eq!(parsed.players, 2);
|
||||
assert_eq!(parsed, save);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,6 +88,22 @@ pub struct SavestateData {
|
||||
ra_state: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SavestateData {
|
||||
/// Serialize this savestate to the same compressed multi-entry format
|
||||
/// used for on-disk savestates, without touching the disk.
|
||||
pub fn save_to_memory(&self) -> Option<Vec<u8>> {
|
||||
let device_data = postcard::to_stdvec(&self.device).ok()?;
|
||||
let saves_data = postcard::to_stdvec(&self.saves).ok()?;
|
||||
ui::storage::compress_file(&[
|
||||
(&device_data, "device"),
|
||||
(&saves_data, "saves"),
|
||||
(&self.rdp_state, "rdp_state"),
|
||||
(&self.ra_state, "ra_state"),
|
||||
])
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
static DEVICE_CLONE: std::sync::LazyLock<tokio::sync::Mutex<Box<device::Device>>> =
|
||||
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(device::Device::new(false)));
|
||||
|
||||
@ -200,6 +216,29 @@ pub fn create_savestate(device: &mut device::Device, rewind: bool, rewind_frame:
|
||||
});
|
||||
}
|
||||
|
||||
pub fn create_savestate_in_memory(device: &mut device::Device) -> Option<SavestateData> {
|
||||
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 mut clone = device::Device::new(false);
|
||||
clone.clone_state(DEVICE_CLONE.blocking_lock().deref());
|
||||
|
||||
Some(SavestateData {
|
||||
device: clone,
|
||||
saves: SAVES_CLONE.blocking_lock().clone(),
|
||||
rdp_state,
|
||||
ra_state,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_savestate(device: &mut device::Device, rewind: bool, rewind_frame: Option<i32>) {
|
||||
if retroachievements::get_hardcore() {
|
||||
ui::video::onscreen_message(
|
||||
|
||||
@ -73,6 +73,12 @@ unsafe impl Sync for Audio {}
|
||||
pub struct Input {
|
||||
pub keyboard_state: *const bool,
|
||||
pub controllers: [input::Controllers; 4],
|
||||
/// Set on the rising edge of the netplay session-save hotkey; consumed
|
||||
/// (and cleared) once per frame by `ui::video::check_callback`.
|
||||
pub netplay_save_request: bool,
|
||||
/// Previous polled state of the netplay session-save hotkey, used for
|
||||
/// rising-edge detection so a held key issues a single save request.
|
||||
pub netplay_save_key_prev: bool,
|
||||
}
|
||||
|
||||
unsafe impl Send for Input {}
|
||||
@ -237,6 +243,8 @@ impl Ui {
|
||||
},
|
||||
],
|
||||
keyboard_state: std::ptr::null_mut(),
|
||||
netplay_save_request: false,
|
||||
netplay_save_key_prev: false,
|
||||
},
|
||||
storage: Storage {
|
||||
save_state_slot: 0,
|
||||
@ -249,6 +257,7 @@ impl Ui {
|
||||
sdcard_file_path: std::path::PathBuf::new(),
|
||||
romsave_file_path: std::path::PathBuf::new(),
|
||||
savestate_file_path: std::path::PathBuf::new(),
|
||||
netplay_state_file_path: std::path::PathBuf::new(),
|
||||
},
|
||||
saves: storage::Saves {
|
||||
write_to_disk: true,
|
||||
|
||||
@ -64,6 +64,14 @@ pub struct Emulation {
|
||||
pub disable_expansion_pak: bool,
|
||||
pub usb: bool,
|
||||
pub rewind: bool,
|
||||
/// SDL scancode for the netplay session save hotkey (default F7).
|
||||
/// Configurable so it can be moved away from other hotkeys.
|
||||
#[serde(default = "default_netplay_save_key")]
|
||||
pub netplay_save_key: i32,
|
||||
}
|
||||
|
||||
fn default_netplay_save_key() -> i32 {
|
||||
i32::from(sdl3_sys::scancode::SDL_SCANCODE_F7)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
@ -196,6 +204,7 @@ impl Config {
|
||||
disable_expansion_pak: false,
|
||||
usb: false,
|
||||
rewind: false,
|
||||
netplay_save_key: i32::from(sdl3_sys::scancode::SDL_SCANCODE_F7),
|
||||
},
|
||||
ui: Ui {
|
||||
theme: 0,
|
||||
|
||||
@ -419,6 +419,19 @@ fn handle_hotkeys(keys: u32, last_key_state: u32) {
|
||||
pub fn get(ui: &mut ui::Ui, channel: usize) -> InputData {
|
||||
handle_joystick_events(ui);
|
||||
|
||||
// Netplay session save hotkey (configurable; default F7). Edge-triggered
|
||||
// so holding the key only issues one save request per key press. The
|
||||
// request is consumed once per frame by `ui::video::check_callback`.
|
||||
let netplay_save_key = ui.config.emulation.netplay_save_key;
|
||||
if (0..512).contains(&netplay_save_key) {
|
||||
// SDL_NUM_SCANCODES is 512 in SDL3
|
||||
let pressed = unsafe { *ui.input.keyboard_state.offset(netplay_save_key as isize) };
|
||||
if pressed && !ui.input.netplay_save_key_prev {
|
||||
ui.input.netplay_save_request = true;
|
||||
}
|
||||
ui.input.netplay_save_key_prev = pressed;
|
||||
}
|
||||
|
||||
let profile_name = &ui.config.input.input_profile_binding[channel];
|
||||
let Some(profile) = ui.config.input.input_profiles.get(profile_name) else {
|
||||
eprintln!("Invalid profile name: {profile_name}");
|
||||
|
||||
@ -22,6 +22,7 @@ pub struct Paths {
|
||||
pub sdcard_file_path: std::path::PathBuf,
|
||||
pub romsave_file_path: std::path::PathBuf,
|
||||
pub savestate_file_path: std::path::PathBuf,
|
||||
pub netplay_state_file_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
// the bool indicates whether the save has been written to
|
||||
@ -205,6 +206,16 @@ pub fn init(ui: &mut ui::Ui, rom: &[u8]) {
|
||||
"{}-{}.state{}",
|
||||
prefix, ui.game_hash, ui.storage.save_state_slot
|
||||
));
|
||||
|
||||
// netplay session state lives next to the other state files
|
||||
ui.storage
|
||||
.paths
|
||||
.netplay_state_file_path
|
||||
.clone_from(&states_path);
|
||||
ui.storage
|
||||
.paths
|
||||
.netplay_state_file_path
|
||||
.push(format!("{}-{}.netplay-state", prefix, ui.game_hash));
|
||||
}
|
||||
|
||||
pub fn load_saves(ui: &mut ui::Ui, netplay: &mut Option<netplay::Netplay>) {
|
||||
|
||||
@ -255,6 +255,14 @@ pub fn check_callback(device: &mut device::Device) -> (bool, bool) {
|
||||
netplay::send_input_delay(netplay, netplay.input_delay + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if device.ui.input.netplay_save_request {
|
||||
device.ui.input.netplay_save_request = false;
|
||||
if device.netplay.is_some() {
|
||||
let path = device.ui.storage.paths.netplay_state_file_path.clone();
|
||||
netplay::save_netplay_session(device, &path);
|
||||
}
|
||||
}
|
||||
(speed_limiter_toggled, callback.paused)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user