jevsnes.git / packages / mcp / src / states.rs
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}