jevsnes.git / packages / replay / src / keyframe.rs
1//! Compressing one console snapshot (`console::Console::snapshot_fixed`,
2//! 1.3 MB) against a reference, usually the previous keyframe of its tier
3//! (`crate::keyframes`): XOR the two, then zstd the result.
4//!
5//! XOR turns every byte the snapshot did not change into zero, so the input
6//! to zstd is mostly zero runs with scattered pockets of change, which is
7//! what zstd compresses best. A keyframe with no reference is XORed against
8//! nothing (the bytes themselves), and zstd alone then finds the state's own
9//! redundancy: long zero regions, repeated tiles.
10//!
11//! The XOR is only as good as the alignment. It pairs byte `i` with byte
12//! `i`, so a snapshot whose fields moved relative to its reference deltas
13//! badly. That is why keyframes are `snapshot_fixed`: varint snapshots came
14//! in 70 lengths across 83 keyframes, and 2 s deltas averaged 866 KB of 1.31
15//! MB (apps/replay-probe, 2026-09-22). zstd limits that damage too, where the
16//! hand-rolled run-length coder this replaced could not.
17//!
18//! The zstd is `ruzstd` (pure Rust), whose encoder implements only its
19//! fastest level. `third-party/rust/Cargo.toml` says why that crate.
20
21use std::io::{self, Read};
22
23use ruzstd::decoding::StreamingDecoder;
24use ruzstd::encoding::{CompressionLevel, compress_to_vec};
25
26/// `data` XORed against `reference` byte for byte, where `reference` is
27/// shorter than `data` (or absent) its missing bytes count as zero.
28fn xor(reference: Option<&[u8]>, data: &[u8]) -> Vec<u8> {
29    let reference = reference.unwrap_or(&[]);
30    data.iter().enumerate().map(|(i, &b)| b ^ reference.get(i).copied().unwrap_or(0)).collect()
31}
32
33/// `data` compressed against `reference`. [`decompress`] needs the exact
34/// same `reference` and `data.len()` back.
35pub fn compress(reference: Option<&[u8]>, data: &[u8]) -> Vec<u8> {
36    compress_to_vec(xor(reference, data).as_slice(), CompressionLevel::Fastest)
37}
38
39/// The inverse of [`compress`]. `len` is the original snapshot's length;
40/// a stream that decodes to anything else is corrupt and is refused rather
41/// than handed on as a machine.
42pub fn decompress(reference: Option<&[u8]>, compressed: &[u8], len: usize) -> io::Result<Vec<u8>> {
43    let mut source = compressed;
44    let mut decoder = StreamingDecoder::new(&mut source).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
45    let mut xored = Vec::with_capacity(len);
46    decoder.read_to_end(&mut xored)?;
47    if xored.len() != len {
48        return Err(io::Error::new(
49            io::ErrorKind::InvalidData,
50            format!("keyframe decodes to {} bytes, expected {len}", xored.len()),
51        ));
52    }
53    Ok(xor(reference, &xored))
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    fn roundtrip(reference: Option<&[u8]>, data: &[u8]) {
61        let compressed = compress(reference, data);
62        let back = decompress(reference, &compressed, data.len()).unwrap();
63        assert_eq!(back, data, "roundtrip with reference of len {:?}", reference.map(<[u8]>::len));
64    }
65
66    #[test]
67    fn empty_data_round_trips() {
68        roundtrip(None, &[]);
69        roundtrip(Some(&[1, 2, 3]), &[]);
70    }
71
72    #[test]
73    fn identical_to_reference_compresses_to_almost_nothing() {
74        let data: Vec<u8> = (0..100_000u32).map(|i| (i * 31 % 251) as u8).collect();
75        let compressed = compress(Some(&data), &data);
76        assert!(compressed.len() < 200, "all-zero XOR should be tiny, got {}", compressed.len());
77        assert_eq!(decompress(Some(&data), &compressed, data.len()).unwrap(), data);
78    }
79
80    #[test]
81    fn no_reference_still_round_trips() {
82        let data: Vec<u8> = (0..2000).map(|i| (i * 37 % 251) as u8).collect();
83        roundtrip(None, &data);
84    }
85
86    #[test]
87    fn a_reference_of_a_different_length_still_round_trips() {
88        let reference = vec![5u8; 40];
89        let data: Vec<u8> = (0..300).map(|i| (i % 17) as u8).collect();
90        roundtrip(Some(&reference), &data);
91        roundtrip(Some(&data), &reference);
92    }
93
94    #[test]
95    fn scattered_changes_compress_well() {
96        // The shape of a live console against its keyframe 2 s earlier:
97        // mostly unchanged, with isolated changed bytes.
98        let n = 100_000;
99        let reference: Vec<u8> = (0..n).map(|i| (i * 7 % 251) as u8).collect();
100        let mut data = reference.clone();
101        for i in (0..data.len()).step_by(40) {
102            data[i] ^= 1;
103        }
104        roundtrip(Some(&reference), &data);
105        let compressed = compress(Some(&reference), &data);
106        assert!(compressed.len() < n / 10, "compressed {} vs raw {n}", compressed.len());
107    }
108
109    #[test]
110    fn a_wrong_length_is_refused() {
111        let data = vec![3u8; 500];
112        let compressed = compress(None, &data);
113        assert!(decompress(None, &compressed, 499).is_err());
114    }
115
116    #[test]
117    fn garbage_is_refused_not_decoded() {
118        assert!(decompress(None, &[1, 2, 3, 4, 5, 6, 7, 8], 8).is_err());
119    }
120}