netplay: session save to disk (host) [issue-3] #13

Closed
pbwraith wants to merge 1 commits from issue-3 into main
7 changed files with 244 additions and 0 deletions

View File

@ -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);
}
}

View File

@ -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(

View File

@ -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,

View File

@ -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,

View File

@ -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}");

View File

@ -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>) {

View File

@ -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)
}