Skip to main content

dotzuki_runner/
pack.rs

1//! The `.dzpk` binary game pack: one file a shipped game boots from.
2//!
3//! `game.bundle.json` (see [`crate::bundle`]) ships every asset as a base64
4//! JSON string — a ~1.33× size inflation plus a full base64 decode on boot,
5//! both painful once a project's graphics and audio grow. A `.dzpk` pack is
6//! the same `path → content` map as raw bytes:
7//!
8//! ```text
9//! 0..4    magic "DZPK"
10//! 4..8    u32 LE  format version (currently 1)
11//! 8..12   u32 LE  index JSON byte length
12//! 12..    index JSON (UTF-8)
13//! …       data section: every file's raw bytes, concatenated
14//! ```
15//!
16//! The index JSON is
17//! `{ "dotzuki": {…export metadata…}, "files": { "<path>": { "offset", "size" } } }`
18//! with `offset` relative to the start of the data section. The `dotzuki`
19//! metadata is informational only — nothing enforces it at runtime.
20//!
21//! [`PackFiles`] reads a pack through the [`ProjectFiles`] trait, so a packed
22//! project boots through the exact same `LoadedProject::load_with_files` path
23//! as a disk or in-memory project — native (`dotzuki-player`) and web
24//! (`WasmRunner.fromPack`) alike.
25
26use std::collections::BTreeMap;
27
28use anyhow::{bail, Context, Result};
29
30use crate::vfs::ProjectFiles;
31
32/// Pack magic bytes at offset 0.
33pub const MAGIC: [u8; 4] = *b"DZPK";
34/// The format version this crate reads and writes.
35pub const FORMAT_VERSION: u32 = 1;
36/// Header size: magic + version + index length.
37const HEADER_LEN: usize = 12;
38
39/// One index entry: a file's byte range within the data section.
40#[derive(serde::Deserialize, serde::Serialize)]
41struct IndexEntry {
42    offset: u64,
43    size: u64,
44}
45
46/// The parsed index JSON document.
47#[derive(serde::Deserialize, serde::Serialize)]
48struct Index {
49    /// Export metadata (`{ tool, version, exportedAt }`); informational only.
50    #[serde(default)]
51    #[allow(dead_code)]
52    dotzuki: serde_json::Value,
53    files: BTreeMap<String, IndexEntry>,
54}
55
56/// Encode `files` (`path → raw content`, iterated in sorted order) into a
57/// `.dzpk` pack. `dotzuki_meta` rides along as the informational
58/// `"dotzuki"` index member (the CLI stamps tool/version/export time).
59pub fn encode_pack(files: &BTreeMap<String, Vec<u8>>, dotzuki_meta: serde_json::Value) -> Vec<u8> {
60    let mut index_files = BTreeMap::new();
61    let mut data_len: u64 = 0;
62    let mut offset: u64 = 0;
63    for (path, bytes) in files {
64        index_files.insert(
65            path.clone(),
66            IndexEntry {
67                offset,
68                size: bytes.len() as u64,
69            },
70        );
71        offset += bytes.len() as u64;
72        data_len = offset;
73    }
74    let index = Index {
75        dotzuki: dotzuki_meta,
76        files: index_files,
77    };
78    // Serializing plain maps of integers/strings cannot fail.
79    let index_json = serde_json::to_vec(&index).expect("pack index serialization is infallible");
80
81    let mut out = Vec::with_capacity(HEADER_LEN + index_json.len() + data_len as usize);
82    out.extend_from_slice(&MAGIC);
83    out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
84    out.extend_from_slice(&(index_json.len() as u32).to_le_bytes());
85    out.extend_from_slice(&index_json);
86    for bytes in files.values() {
87        out.extend_from_slice(bytes);
88    }
89    out
90}
91
92/// A `.dzpk` pack held in memory, read through [`ProjectFiles`].
93///
94/// `read` slices the data section — no per-file decode, no copies beyond the
95/// returned `Vec`.
96pub struct PackFiles {
97    bytes: Vec<u8>,
98    /// Absolute offset of the data section within `bytes`.
99    data_start: usize,
100    /// `path → (offset, size)` within the data section.
101    entries: BTreeMap<String, (u64, u64)>,
102}
103
104impl PackFiles {
105    /// Parse and validate a `.dzpk` pack. Every index entry is bounds-checked
106    /// against the data section here, so `read` can slice unchecked.
107    ///
108    /// # Errors
109    ///
110    /// Fails on a bad magic, an unsupported format version, a truncated
111    /// header/index, a malformed index, or an entry pointing outside the data
112    /// section (the error names the offending file).
113    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
114        if bytes.len() < HEADER_LEN {
115            bail!("not a .dzpk pack: file is shorter than the {HEADER_LEN}-byte header");
116        }
117        if bytes[0..4] != MAGIC {
118            bail!("not a .dzpk pack: bad magic (expected DZPK)");
119        }
120        let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
121        if version != FORMAT_VERSION {
122            bail!("unsupported .dzpk format version {version} (this player reads {FORMAT_VERSION})");
123        }
124        let index_len = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
125        let data_start = HEADER_LEN + index_len;
126        if bytes.len() < data_start {
127            bail!("corrupt .dzpk pack: index is truncated");
128        }
129        let index: Index = serde_json::from_slice(&bytes[HEADER_LEN..data_start])
130            .context("corrupt .dzpk pack: index is not valid JSON")?;
131        let data_len = (bytes.len() - data_start) as u64;
132        let mut entries = BTreeMap::new();
133        for (path, entry) in index.files {
134            let end = entry.offset.checked_add(entry.size).with_context(|| {
135                format!("corrupt .dzpk pack: file '{path}' range overflows")
136            })?;
137            if end > data_len {
138                bail!(
139                    "corrupt .dzpk pack: file '{path}' (offset {}, size {}) points past the data section ({data_len} bytes)",
140                    entry.offset,
141                    entry.size
142                );
143            }
144            entries.insert(path, (entry.offset, entry.size));
145        }
146        Ok(Self {
147            bytes,
148            data_start,
149            entries,
150        })
151    }
152}
153
154impl std::fmt::Debug for PackFiles {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("PackFiles")
157            .field("files", &self.entries.len())
158            .field("bytes", &self.bytes.len())
159            .finish()
160    }
161}
162
163impl ProjectFiles for PackFiles {
164    fn read(&self, path: &str) -> Result<Vec<u8>> {
165        let (offset, size) = self
166            .entries
167            .get(path)
168            .with_context(|| format!("no such file '{path}'"))?;
169        let start = self.data_start + *offset as usize;
170        Ok(self.bytes[start..start + *size as usize].to_vec())
171    }
172
173    fn list(&self, prefix: &str) -> Vec<String> {
174        // BTreeMap iteration is already sorted.
175        self.entries
176            .keys()
177            .filter(|k| {
178                prefix.is_empty()
179                    || k.as_str() == prefix
180                    || k.strip_prefix(prefix)
181                        .is_some_and(|rest| rest.starts_with('/'))
182            })
183            .cloned()
184            .collect()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn sample_files() -> BTreeMap<String, Vec<u8>> {
193        BTreeMap::from([
194            ("data/a.txt".to_string(), b"hello".to_vec()),
195            ("data/deep/b.bin".to_string(), vec![0, 1, 2, 255]),
196            (".dotzuki-editor.json".to_string(), b"{}".to_vec()),
197        ])
198    }
199
200    fn sample_pack() -> Vec<u8> {
201        encode_pack(&sample_files(), serde_json::json!({"tool": "test", "version": "0.0.0"}))
202    }
203
204    #[test]
205    fn round_trip_reads_every_file_byte_exact() {
206        let pack = PackFiles::from_bytes(sample_pack()).unwrap();
207        for (path, bytes) in sample_files() {
208            assert_eq!(pack.read(&path).unwrap(), bytes, "{path}");
209        }
210        assert!(pack.read("nope").is_err());
211        assert!(!pack.exists("nope"));
212        assert!(pack.exists("data/a.txt"));
213    }
214
215    #[test]
216    fn list_is_sorted_and_prefix_bounded() {
217        let pack = PackFiles::from_bytes(sample_pack()).unwrap();
218        assert_eq!(
219            pack.list("data"),
220            vec!["data/a.txt".to_string(), "data/deep/b.bin".to_string()]
221        );
222        assert_eq!(pack.list("datas"), Vec::<String>::new());
223        assert_eq!(pack.list("").len(), 3);
224    }
225
226    #[test]
227    fn encoding_is_deterministic() {
228        assert_eq!(sample_pack(), sample_pack());
229    }
230
231    #[test]
232    fn rejects_a_short_buffer() {
233        let err = PackFiles::from_bytes(b"DZ".to_vec()).unwrap_err();
234        assert!(err.to_string().contains("shorter than"), "{err}");
235    }
236
237    #[test]
238    fn rejects_bad_magic() {
239        let mut pack = sample_pack();
240        pack[0] = b'X';
241        let err = PackFiles::from_bytes(pack).unwrap_err();
242        assert!(err.to_string().contains("bad magic"), "{err}");
243    }
244
245    #[test]
246    fn rejects_an_unknown_version() {
247        let mut pack = sample_pack();
248        pack[4..8].copy_from_slice(&99u32.to_le_bytes());
249        let err = PackFiles::from_bytes(pack).unwrap_err();
250        assert!(err.to_string().contains("version 99"), "{err}");
251    }
252
253    #[test]
254    fn rejects_a_truncated_index() {
255        let mut pack = sample_pack();
256        let index_len = u32::from_le_bytes(pack[8..12].try_into().unwrap()) as usize;
257        pack[8..12].copy_from_slice(&(index_len as u32 + 16).to_le_bytes());
258        let err = PackFiles::from_bytes(pack).unwrap_err();
259        assert!(err.to_string().contains("truncated"), "{err}");
260    }
261
262    #[test]
263    fn rejects_an_entry_past_the_data_section() {
264        let files = BTreeMap::from([("a.txt".to_string(), b"hi".to_vec())]);
265        let mut pack = encode_pack(&files, serde_json::json!({}));
266        // Rewrite the index with an inflated size for a.txt.
267        let index_json = br#"{"dotzuki":{},"files":{"a.txt":{"offset":0,"size":100}}}"#;
268        pack.truncate(HEADER_LEN);
269        pack[8..12].copy_from_slice(&(index_json.len() as u32).to_le_bytes());
270        pack.extend_from_slice(index_json);
271        pack.extend_from_slice(b"hi");
272        let err = PackFiles::from_bytes(pack).unwrap_err();
273        assert!(err.to_string().contains("a.txt"), "{err}");
274        assert!(err.to_string().contains("past the data section"), "{err}");
275    }
276
277    #[test]
278    fn an_empty_pack_boots_an_empty_file_set() {
279        let pack = PackFiles::from_bytes(encode_pack(&BTreeMap::new(), serde_json::json!({})))
280            .unwrap();
281        assert_eq!(pack.list(""), Vec::<String>::new());
282        assert!(pack.read("anything").is_err());
283    }
284}