//! A PCSX2 savestate (`.p2s`), read for its EE RAM. //! //! The format is a zip archive written by libzip, whose entries are the //! emulator's state: `eeMemory.bin` is main RAM, and there is a version id //! entry that says which PCSX2 wrote it. All of this is read from the file, //! never assumed: the version check and the size check are what stop a //! plausible-looking wrong read — a partial file, a state from a much older //! PCSX2 — from being compared against as though it were the game's RAM. //! //! Compression is whatever PCSX2 chose (zstd by default, deflate or stored if //! the user configured otherwise), which is [`crate::zip`]'s job. use std::path::Path; use anyhow::{anyhow, Context, Result}; use crate::zip::Zip; /// The savestate version PCSX2 v2.x writes; the high 16 bits are the load /// gate, the low 16 the patch level. const SAVE_VERSION: u32 = 0x9A59_0000; const VERSION_ID: &str = "PCSX2 Savestate Version.id"; const EE_MEMORY: &str = "eeMemory.bin"; /// A loaded savestate. pub struct Savestate { bytes: Vec, zip: Zip, /// The version word from the version-id entry, if present. pub version: Option, /// The version string that follows it. pub build: String, } impl Savestate { /// Open and fully validate a savestate file. pub fn open(path: &Path) -> Result { let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; let state = Self::from_bytes(bytes)?; state .validate() .with_context(|| format!("{}: not a supported savestate", path.display()))?; Ok(state) } /// The version gate. A state whose version entry is missing or from a /// different major line may have a different entry layout, so stop here /// rather than guess which entry is the RAM. /// /// Exposed so a fixture is checked the same way a real file is; `open` /// calls exactly this. fn validate(&self) -> Result<()> { let v = self .version .ok_or_else(|| anyhow!("no {VERSION_ID} entry; not a PCSX2 v2.x savestate"))?; if v & 0xFFFF_0000 != SAVE_VERSION { return Err(anyhow!( "savestate version {v:#010x}, this reader handles {SAVE_VERSION:#010x} (PCSX2 v2.8.x)" )); } Ok(()) } /// Build from an in-memory image. pub fn from_bytes(bytes: Vec) -> Result { let zip = Zip::parse(&bytes)?; let (version, build) = match zip.find(VERSION_ID) { Some(_) => { let id = zip.read(&bytes, VERSION_ID)?; if id.len() < 4 { return Err(anyhow!("version id entry is {} bytes", id.len())); } // The build string is read out of the first 32 bytes after the // version word. A longer entry carries more after it, which is // not read: only the version is load-bearing here. ( Some(u32::from_le_bytes(id[..4].try_into().unwrap())), String::from_utf8_lossy(&id[4..36.min(id.len())]) .trim_end_matches('\0') .to_string(), ) } None => (None, String::new()), }; Ok(Savestate { bytes, zip, version, build, }) } /// The entry names, which is also a cheap sanity check on what is here. pub fn entries(&self) -> Vec<&str> { self.zip.entries().iter().map(|e| e.name.as_str()).collect() } /// One decompressed entry. pub fn entry(&self, name: &str) -> Result> { self.zip.read(&self.bytes, name) } /// The decompressed `eeMemory.bin` as an owned buffer. /// /// Owned rather than borrowed: the caller wants a stable `EeRam` over the /// bytes, and the zip entry's payload cannot outlive this call anyway. pub fn ee_ram_owned(&self) -> Result> { let ram = self.entry(EE_MEMORY)?; Ok(ram) } } #[cfg(test)] mod tests { use super::*; use crate::zip::LOCAL_HEADER; fn build_zip(entries: &[(&str, &[u8])]) -> Vec { let mut out = Vec::new(); for (name, payload) in entries { out.extend_from_slice(&LOCAL_HEADER.to_le_bytes()); out.extend_from_slice(&20u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); // stored out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&crate::zip::crc32(payload).to_le_bytes()); out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); out.extend_from_slice(&(name.len() as u16).to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name.as_bytes()); out.extend_from_slice(payload); } out } fn version_id() -> Vec { let mut v = SAVE_VERSION.to_le_bytes().to_vec(); v.extend_from_slice(b"v2.8.2"); v.resize(36, 0); v } #[test] fn a_state_round_trips_its_ram() { let ram = vec![0x5au8; 16]; let z = build_zip(&[(VERSION_ID, &version_id()), (EE_MEMORY, &ram)]); let state = Savestate::from_bytes(z).expect("should read"); assert_eq!(state.version, Some(SAVE_VERSION)); assert!(state.build.starts_with("v2.8")); assert_eq!(state.entry(EE_MEMORY).unwrap(), ram); } #[test] fn a_foreign_savestate_version_is_an_error() { let bad = 0x9A3B_0000u32; // a future major line let mut v = bad.to_le_bytes().to_vec(); v.resize(36, 0); let z = build_zip(&[(VERSION_ID, &v), (EE_MEMORY, &[0u8; 8])]); let state = Savestate::from_bytes(z).expect("reads the archive"); assert_eq!(state.version, Some(bad)); let err = state.validate().unwrap_err().to_string(); assert!(err.contains("version"), "got {err}"); // The gate reads the high half only: a newer patch level of the same // major line is a state this reader handles. let patched = 0x9A59_0001u32; let mut v = patched.to_le_bytes().to_vec(); v.resize(36, 0); let z = build_zip(&[(VERSION_ID, &v), (EE_MEMORY, &[0u8; 8])]); assert!(Savestate::from_bytes(z).unwrap().validate().is_ok()); } #[test] fn a_missing_version_entry_is_reported_not_guessed() { let z = build_zip(&[(EE_MEMORY, &[0u8; 8])]); let state = Savestate::from_bytes(z).unwrap(); assert_eq!(state.version, None); assert!(state.validate().is_err()); } }