jevsnes.git / packages / mcp / src / states.rs

Snapshots of the machine, kept as files beside the ROM: zelda.sfc keeps zelda.states/<name>.state.

One name is the app's own. It writes [Name::resume] every few seconds and on the way out, and starts from it, which is what makes restarting the app cheap: the window closes and reopens on the same frame of the same game.

The zbanks bot names snapshots of its own - ap_init loads "home" and "hpegs" (packages/zbanks) - and they come from the same directory.

11use std::path::{Path, PathBuf};
12use std::{fmt, fs, io};

What a snapshot is called: lower-case letters, digits, - and _. It 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);
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    }

Where the app left off.

26    pub fn resume() -> Self {
27        Self("resume".to_owned())
28    }
29}
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    }

Replace the snapshot of this name. The old one stays whole until the new 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    }
59    pub fn read(&self, name: &Name) -> io::Result<Vec<u8>> {
60        fs::read(self.path(name))
61    }

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