Skip to main content

botstadia_interface/
archive.rs

1//! The replay archive: one session's frames, for the player.
2//!
3//! Gameplay only. Telemetry, series, events and stats live in the metrics
4//! file beside it ([`crate::metrics`]), so the player never sees telemetry and
5//! the site never decodes a frame. The signed archive hash covers both files.
6//!
7//! Encoded with postcard behind a magic and a format version, so a reader
8//! refuses a file it can't read rather than mis-reading it.
9
10use serde::{Deserialize, Serialize};
11
12const MAGIC: &[u8; 4] = b"BSRA";
13/// Bumped on any change to these types.
14pub const ARCHIVE_FORMAT: u32 = 1;
15
16/// Everything needed to read the frames without asking the platform.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct ArchiveHeader {
19    pub arena_id: String,
20    pub arena_version: String,
21    /// `sha256-<hex>` of the engine that ran, or empty for a local build.
22    pub arena_digest: String,
23    pub payload_schema_version: u32,
24    /// Seat names, in seat order. Seats were drawn by the enclave.
25    pub seats: Vec<String>,
26    pub control_rate: u32,
27    /// Frames recorded, including turn 0's.
28    pub turns: u32,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Archive {
33    pub header: ArchiveHeader,
34    /// One complete-state frame per turn, turn 0 first. Opaque to everything
35    /// but the arena's own player.
36    pub frames: Vec<Vec<u8>>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ArchiveError {
41    BadMagic,
42    UnsupportedFormat(u32),
43    Corrupt(String),
44}
45
46impl std::fmt::Display for ArchiveError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::BadMagic => write!(f, "not a BotStadia replay archive"),
50            Self::UnsupportedFormat(format) => {
51                write!(f, "archive format {format} isn't one this reader knows")
52            }
53            Self::Corrupt(text) => write!(f, "archive is corrupt: {text}"),
54        }
55    }
56}
57
58impl std::error::Error for ArchiveError {}
59
60impl Archive {
61    pub fn encode(&self) -> Vec<u8> {
62        let mut bytes = Vec::new();
63        bytes.extend_from_slice(MAGIC);
64        bytes.extend_from_slice(&ARCHIVE_FORMAT.to_le_bytes());
65        bytes
66            .extend_from_slice(&postcard::to_allocvec(self).expect("an archive always serialises"));
67        bytes
68    }
69
70    pub fn decode(bytes: &[u8]) -> Result<Self, ArchiveError> {
71        if bytes.len() < 8 || &bytes[..4] != MAGIC {
72            return Err(ArchiveError::BadMagic);
73        }
74        let format = u32::from_le_bytes(bytes[4..8].try_into().expect("four bytes"));
75        if format != ARCHIVE_FORMAT {
76            return Err(ArchiveError::UnsupportedFormat(format));
77        }
78        postcard::from_bytes(&bytes[8..]).map_err(|error| ArchiveError::Corrupt(error.to_string()))
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn round_trips_and_refuses_strangers() {
88        let archive = Archive {
89            header: ArchiveHeader {
90                arena_id: "sample".into(),
91                arena_version: "0.1.0".into(),
92                arena_digest: String::new(),
93                payload_schema_version: 1,
94                seats: vec!["a".into(), "b".into()],
95                control_rate: 50,
96                turns: 2,
97            },
98            frames: vec![vec![1, 2], vec![3]],
99        };
100        let bytes = archive.encode();
101        assert_eq!(Archive::decode(&bytes).unwrap(), archive);
102        assert_eq!(Archive::decode(b"nope"), Err(ArchiveError::BadMagic));
103        let mut future = bytes.clone();
104        future[4] = 9;
105        assert_eq!(
106            Archive::decode(&future),
107            Err(ArchiveError::UnsupportedFormat(9))
108        );
109    }
110}