diff --git a/src/netplay.rs b/src/netplay.rs index dc56b20..28d3301 100644 --- a/src/netplay.rs +++ b/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, + /// 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> { + 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 { + 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); + } +} diff --git a/src/savestates.rs b/src/savestates.rs index 21cf91f..52c01ae 100644 --- a/src/savestates.rs +++ b/src/savestates.rs @@ -88,6 +88,22 @@ pub struct SavestateData { ra_state: Vec, } +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> { + 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>> = 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 { + 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()); + + 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) { if retroachievements::get_hardcore() { ui::video::onscreen_message( diff --git a/src/ui.rs b/src/ui.rs index 7d97710..06fea06 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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, diff --git a/src/ui/config.rs b/src/ui/config.rs index ccced72..1d1f9cb 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -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, diff --git a/src/ui/input.rs b/src/ui/input.rs index 5cab30d..7c83391 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -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}"); diff --git a/src/ui/storage.rs b/src/ui/storage.rs index 637e305..0da997f 100644 --- a/src/ui/storage.rs +++ b/src/ui/storage.rs @@ -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) { diff --git a/src/ui/video.rs b/src/ui/video.rs index dcfe08c..d369153 100644 --- a/src/ui/video.rs +++ b/src/ui/video.rs @@ -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) }