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