log.rsannotatedlog.rssource146 lines · 5.3 KB · raw

A branch's own frame log: [FrameEntry] values, one per emulated frame, each length-prefixed so a process killed mid-write leaves a file whose whole entries are still all readable - the torn last one is dropped, not guessed at, which is what makes "written incrementally... so a killed window keeps everything up to its last flush" true rather than aspirational.

7use std::fs::{File, OpenOptions};
8use std::io::{self, BufWriter, Read, Write};
9use std::path::Path;
11use crate::format::FrameEntry;
12
13const CONFIG: bincode::config::Configuration = bincode::config::standard();

Appends to a branch's log.bin. Buffered; flush is the caller's to call on its own cadence (the recorder flushes at least once a second).

17pub struct LogWriter {
18    file: BufWriter<File>,
19}
21impl LogWriter {
22    pub fn create_or_append(path: &Path) -> io::Result<Self> {
23        let file = OpenOptions::new().create(true).append(true).open(path)?;
24        Ok(Self { file: BufWriter::new(file) })
25    }
26
27    pub fn append(&mut self, entry: &FrameEntry) -> io::Result<()> {
28        let bytes = bincode::encode_to_vec(entry, CONFIG).expect("encoding a FrameEntry never fails");
29        self.file.write_all(&(bytes.len() as u32).to_le_bytes())?;
30        self.file.write_all(&bytes)?;
31        Ok(())
32    }
33
34    pub fn flush(&mut self) -> io::Result<()> {
35        self.file.flush()
36    }
37}

Every whole entry in a branch's log, in order. A missing file reads as empty (a branch not yet written to). A truncated final entry - the process was killed mid-write - is silently dropped rather than an error.

42pub fn read_all(path: &Path) -> io::Result<Vec<FrameEntry>> {
43    let bytes = match File::open(path) {
44        Ok(mut f) => {
45            let mut buf = Vec::new();
46            f.read_to_end(&mut buf)?;
47            buf
48        }
49        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
50        Err(e) => return Err(e),
51    };
52    let mut out = Vec::new();
53    let mut pos = 0usize;
54    while pos + 4 <= bytes.len() {
55        let len = u32::from_le_bytes(bytes[pos..pos + 4].try_into().expect("4 bytes")) as usize;
56        pos += 4;
57        if pos + len > bytes.len() {
58            break; // a torn tail: the length header made it, the body didn't.
59        }
60        match bincode::decode_from_slice::<FrameEntry, _>(&bytes[pos..pos + len], CONFIG) {
61            Ok((entry, used)) if used == len => out.push(entry),
62            _ => break, // corrupt or torn: stop, keep everything whole before it.
63        }
64        pos += len;
65    }
66    Ok(out)
67}
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::format::Pick;
73
74    fn scratch(tag: &str) -> std::path::PathBuf {
75        let dir = std::env::temp_dir().join(format!("jev-replay-log-test-{tag}-{}", std::process::id()));
76        let _ = std::fs::remove_dir_all(&dir);
77        std::fs::create_dir_all(&dir).unwrap();
78        dir.join("log.bin")
79    }
80
81    fn entry(pad: u16) -> FrameEntry {
82        FrameEntry { console_pad: pad, human_pad: 0, wram_diff: vec![(1, 2)], decision: None }
83    }
84
85    #[test]
86    fn entries_come_back_in_order() {
87        let path = scratch("order");
88        let mut w = LogWriter::create_or_append(&path).unwrap();
89        for pad in [1u16, 2, 3, 4] {
90            w.append(&entry(pad)).unwrap();
91        }
92        w.flush().unwrap();
93        let back = read_all(&path).unwrap();
94        assert_eq!(back.iter().map(|e| e.console_pad).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
95    }
96
97    #[test]
98    fn appending_across_two_writers_is_still_one_log() {
99        let path = scratch("reopen");
100        {
101            let mut w = LogWriter::create_or_append(&path).unwrap();
102            w.append(&entry(1)).unwrap();
103            w.flush().unwrap();
104        }
105        {
106            let mut w = LogWriter::create_or_append(&path).unwrap();
107            w.append(&entry(2)).unwrap();
108            w.flush().unwrap();
109        }
110        let back = read_all(&path).unwrap();
111        assert_eq!(back.iter().map(|e| e.console_pad).collect::<Vec<_>>(), vec![1, 2]);
112    }
113
114    #[test]
115    fn a_missing_log_reads_as_empty() {
116        let path = scratch("missing");
117        let _ = std::fs::remove_file(&path);
118        assert_eq!(read_all(&path).unwrap(), Vec::new());
119    }
120
121    #[test]
122    fn a_torn_tail_is_dropped_and_everything_before_it_survives() {
123        let path = scratch("torn");
124        let mut w = LogWriter::create_or_append(&path).unwrap();
125        w.append(&entry(7)).unwrap();
126        w.append(&entry(8)).unwrap();
127        w.flush().unwrap();
128        drop(w);
129        // Simulate a kill mid-write: a length header with no body behind it.
130        let mut f = OpenOptions::new().append(true).open(&path).unwrap();
131        f.write_all(&999u32.to_le_bytes()).unwrap();
132        f.write_all(&[1, 2, 3]).unwrap();
133        let back = read_all(&path).unwrap();
134        assert_eq!(back.iter().map(|e| e.console_pad).collect::<Vec<_>>(), vec![7, 8]);
135    }
136
137    #[test]
138    fn a_decision_survives_the_round_trip() {
139        let path = scratch("decision");
140        let mut w = LogWriter::create_or_append(&path).unwrap();
141        w.append(&FrameEntry { console_pad: 0, human_pad: 0, wram_diff: Vec::new(), decision: Some(Pick::Index(2)) }).unwrap();
142        w.flush().unwrap();
143        let back = read_all(&path).unwrap();
144        assert_eq!(back[0].decision, Some(Pick::Index(2)));
145    }
146}