1//! Snapshots of the machine, kept as files beside the ROM: `zelda.sfc` keeps 2//! `zelda.states/<name>.state`. 3//! 4//! One name is the app's own. It writes [`Name::resume`] every few seconds and 5//! on the way out, and starts from it, which is what makes restarting the app 6//! cheap: the window closes and reopens on the same frame of the same game. 7//! 8//! The zbanks bot names snapshots of its own - `ap_init` loads "home" and 9//! "hpegs" (`packages/zbanks`) - and they come from the same directory. 10 11use std::path::{Path, PathBuf}; 12use std::{fmt, fs, io}; 13 14/// What a snapshot is called: lower-case letters, digits, `-` and `_`. It 15/// becomes a file name, so it is never anything a path could be made of. 16#[derive(Debug, Clone, PartialEq, Eq)] 17pub struct Name(String); 18 19impl Name { 20 pub fn new(name: &str) -> Option<Self> { 21 let fits = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'; 22 (!name.is_empty() && name.chars().all(fits)).then(|| Self(name.to_owned())) 23 } 24 25 /// Where the app left off. 26 pub fn resume() -> Self { 27 Self("resume".to_owned()) 28 } 29} 30 31impl fmt::Display for Name { 32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 33 f.write_str(&self.0) 34 } 35} 36 37pub struct States { 38 dir: PathBuf, 39} 40 41impl States { 42 pub fn beside(rom: &Path) -> Self { 43 Self { dir: rom.with_extension("states") } 44 } 45 46 fn path(&self, name: &Name) -> PathBuf { 47 self.dir.join(format!("{name}.state")) 48 } 49 50 /// Replace the snapshot of this name. The old one stays whole until the new 51 /// one is: a window killed halfway through still resumes. 52 pub fn write(&self, name: &Name, snapshot: &[u8]) -> io::Result<()> { 53 fs::create_dir_all(&self.dir)?; 54 let partial = self.dir.join(format!("{name}.partial")); 55 fs::write(&partial, snapshot)?; 56 fs::rename(partial, self.path(name)) 57 } 58 59 pub fn read(&self, name: &Name) -> io::Result<Vec<u8>> { 60 fs::read(self.path(name)) 61 } 62 63 /// The names there are, in order. 64 pub fn names(&self) -> Vec<String> { 65 let mut names: Vec<String> = fs::read_dir(&self.dir) 66 .into_iter() 67 .flatten() 68 .flatten() 69 .map(|entry| entry.path()) 70 .filter(|path| path.extension().is_some_and(|e| e == "state")) 71 .filter_map(|path| Some(path.file_stem()?.to_str()?.to_owned())) 72 .collect(); 73 names.sort(); 74 names 75 } 76} 77 78#[cfg(test)] 79mod tests { 80 use super::*; 81 82 fn scratch(tag: &str) -> States { 83 let dir = std::env::temp_dir().join(format!("jev-states-{tag}-{}", std::process::id())); 84 let _ = fs::remove_dir_all(&dir); 85 States { dir } 86 } 87 88 #[test] 89 fn a_snapshot_comes_back_as_written_and_only_snapshots_are_names() { 90 let states = scratch("roundtrip"); 91 let early = Name::new("early").expect("a name"); 92 let late = Name::new("late").expect("a name"); 93 states.write(&early, b"machine at t0").expect("written"); 94 states.write(&late, b"machine at t1").expect("written"); 95 states.write(&late, b"machine at t2").expect("rewritten"); 96 assert_eq!(states.read(&early).expect("read"), b"machine at t0"); 97 assert_eq!(states.read(&late).expect("read"), b"machine at t2"); 98 // Anything else in the directory (an old memory file) is not a name. 99 fs::write(states.dir.join("early.memory.json"), "{}").expect("written"); 100 assert_eq!(states.names(), vec!["early", "late"]); 101 let _ = fs::remove_dir_all(&states.dir); 102 } 103}