Skip to main content

pite_scene/
cache.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Binary scene cache: TOML stays source of truth; `_cache/` holds
3//! content-hash-keyed, version-stamped binaries.
4//!
5//! Pure logic lives here (hash, paths, encode/decode, stale matching);
6//! `lib.rs` owns the filesystem flow (`load_cached`).
7
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11
12use crate::{SceneDoc, SceneInstance, SceneNode, SceneScript, FORMAT_VERSION};
13
14/// Cache container version. A bump rebuilds every cache from TOML
15/// instead of misreading it.
16pub const CACHE_VERSION: u32 = 1;
17
18/// Sibling directory next to each scene file holding its binaries.
19pub const CACHE_DIR_NAME: &str = "_cache";
20
21/// Extension for cache files: `<stem>.<content-hash>.bin`.
22pub const CACHE_EXT: &str = "bin";
23
24/// Magic prefix of every cache file; anything else is corruption.
25const MAGIC: &[u8; 8] = b"PITSCN01";
26
27/// Header carried by every cache file: which container and scene
28/// versions it was written with, plus the exact source it came from.
29#[derive(Debug, Clone, PartialEq)]
30pub struct CacheHeader {
31    pub cache_version: u32,
32    pub format_version: u32,
33    pub source_len: u64,
34    pub source_hash: u64,
35}
36
37/// How a scene load resolved: cache hit, clean miss (no usable cache),
38/// or rebuild (cache existed but was stale/corrupt/mismatched).
39#[derive(Debug, Clone, PartialEq)]
40pub enum CacheStatus {
41    Hit,
42    Miss,
43    Rebuilt(String),
44}
45
46impl std::fmt::Display for CacheStatus {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            CacheStatus::Hit => write!(f, "hit"),
50            CacheStatus::Miss => write!(f, "miss"),
51            CacheStatus::Rebuilt(reason) => write!(f, "rebuilt ({reason})"),
52        }
53    }
54}
55
56/// FNV-1a 64-bit over bytes (same function as `pite-assets`, zero deps).
57pub fn fnv1a_u64(bytes: &[u8]) -> u64 {
58    let mut hash: u64 = 14695981039346656037;
59    for b in bytes {
60        hash ^= u64::from(*b);
61        hash = hash.wrapping_mul(1099511628211);
62    }
63    hash
64}
65
66/// Lowercase hex content hash used in cache filenames.
67pub fn content_hash(bytes: &[u8]) -> String {
68    format!("{:016x}", fnv1a_u64(bytes))
69}
70
71/// Cache path for a scene file + hex content hash:
72/// `<scene_dir>/_cache/<stem>.<hash>.bin`. The hash keys the cache,
73/// so any source change is a different file (old ones get pruned).
74pub fn cache_path_for(scene_path: &Path, source_hash_hex: &str) -> PathBuf {
75    let dir = scene_path.parent().unwrap_or_else(|| Path::new("."));
76    let stem = scene_path
77        .file_stem()
78        .and_then(|s| s.to_str())
79        .filter(|s| !s.is_empty())
80        .unwrap_or("scene");
81    dir.join(CACHE_DIR_NAME)
82        .join(format!("{stem}.{source_hash_hex}.{CACHE_EXT}"))
83}
84
85/// Remove sibling caches for `scene_path` whose hash differs from
86/// `keep_hash_hex`; returns removed paths. Best-effort: unreadable
87/// dirs or failed removals are ignored, never errors.
88pub fn prune_stale(scene_path: &Path, keep_hash_hex: &str) -> Vec<PathBuf> {
89    let dir = scene_path
90        .parent()
91        .unwrap_or_else(|| Path::new("."))
92        .join(CACHE_DIR_NAME);
93    let stem = scene_path
94        .file_stem()
95        .and_then(|s| s.to_str())
96        .filter(|s| !s.is_empty())
97        .unwrap_or("scene");
98    let keep = format!("{stem}.{keep_hash_hex}.{CACHE_EXT}");
99    let prefix = format!("{stem}.");
100    let suffix = format!(".{CACHE_EXT}");
101    let mut removed = Vec::new();
102    let Ok(entries) = std::fs::read_dir(&dir) else {
103        return removed;
104    };
105    for entry in entries.flatten() {
106        let name = entry.file_name().to_string_lossy().into_owned();
107        if name == keep || !name.starts_with(&prefix) || !name.ends_with(&suffix) {
108            continue;
109        }
110        let path = entry.path();
111        if std::fs::remove_file(&path).is_ok() {
112            removed.push(path);
113        }
114    }
115    removed.sort();
116    removed
117}
118
119fn put_u32(out: &mut Vec<u8>, v: u32) {
120    out.extend_from_slice(&v.to_le_bytes());
121}
122
123fn put_u64(out: &mut Vec<u8>, v: u64) {
124    out.extend_from_slice(&v.to_le_bytes());
125}
126
127fn put_i64(out: &mut Vec<u8>, v: i64) {
128    out.extend_from_slice(&v.to_le_bytes());
129}
130
131fn put_f64(out: &mut Vec<u8>, v: f64) {
132    out.extend_from_slice(&v.to_le_bytes());
133}
134
135fn put_str(out: &mut Vec<u8>, s: &str) {
136    put_u32(out, s.len() as u32);
137    out.extend_from_slice(s.as_bytes());
138}
139
140fn put_opt_str(out: &mut Vec<u8>, value: &Option<String>) {
141    out.push(u8::from(value.is_some()));
142    if let Some(s) = value {
143        put_str(out, s);
144    }
145}
146
147/// `toml::Value` tags. Tables sort by key on write (they already do:
148/// `toml::Map` is a `BTreeMap`), so encoding is deterministic.
149fn put_toml_value(out: &mut Vec<u8>, value: &toml::Value) {
150    match value {
151        toml::Value::String(s) => {
152            out.push(0);
153            put_str(out, s);
154        }
155        toml::Value::Integer(i) => {
156            out.push(1);
157            put_i64(out, *i);
158        }
159        toml::Value::Float(f) => {
160            out.push(2);
161            put_f64(out, *f);
162        }
163        toml::Value::Boolean(b) => {
164            out.push(3);
165            out.push(u8::from(*b));
166        }
167        toml::Value::Array(items) => {
168            out.push(4);
169            put_u32(out, items.len() as u32);
170            for item in items {
171                put_toml_value(out, item);
172            }
173        }
174        toml::Value::Table(table) => {
175            out.push(5);
176            put_u32(out, table.len() as u32);
177            for (k, v) in table {
178                put_str(out, k);
179                put_toml_value(out, v);
180            }
181        }
182        toml::Value::Datetime(dt) => {
183            out.push(6);
184            put_str(out, &dt.to_string());
185        }
186    }
187}
188
189fn put_script(out: &mut Vec<u8>, script: &Option<SceneScript>) {
190    out.push(u8::from(script.is_some()));
191    if let Some(s) = script {
192        put_str(out, &s.path);
193        put_str(out, &s.class);
194    }
195}
196
197fn put_node(out: &mut Vec<u8>, node: &SceneNode) {
198    put_str(out, &node.id);
199    put_str(out, &node.type_name);
200    put_str(out, &node.name);
201    put_opt_str(out, &node.parent);
202    let mut keys: Vec<&String> = node.props.keys().collect();
203    keys.sort();
204    put_u32(out, keys.len() as u32);
205    for k in keys {
206        put_str(out, k);
207        put_toml_value(out, &node.props[k]);
208    }
209    put_script(out, &node.script);
210}
211
212fn put_instance(out: &mut Vec<u8>, inst: &SceneInstance) {
213    put_str(out, &inst.scene);
214    put_opt_str(out, &inst.parent);
215    put_str(out, &inst.prefix);
216    let mut keys: Vec<&String> = inst.overrides.keys().collect();
217    keys.sort();
218    put_u32(out, keys.len() as u32);
219    for k in keys {
220        put_str(out, k);
221        put_toml_value(out, &inst.overrides[k]);
222    }
223}
224
225fn put_doc(out: &mut Vec<u8>, doc: &SceneDoc) {
226    put_u32(out, doc.format_version);
227    put_str(out, &doc.root);
228    put_u32(out, doc.node.len() as u32);
229    for node in &doc.node {
230        put_node(out, node);
231    }
232    put_u32(out, doc.instance.len() as u32);
233    for inst in &doc.instance {
234        put_instance(out, inst);
235    }
236}
237
238/// Encode a scene document as a cache payload stamped with the live
239/// [`CACHE_VERSION`] and [`FORMAT_VERSION`].
240pub fn encode(doc: &SceneDoc, source_hash: u64, source_len: u64) -> Vec<u8> {
241    encode_with(doc, source_hash, source_len, CACHE_VERSION, FORMAT_VERSION)
242}
243
244/// Encode with explicit stamps (test hook: simulates version bumps
245/// without touching the live constants).
246pub fn encode_with(
247    doc: &SceneDoc,
248    source_hash: u64,
249    source_len: u64,
250    cache_version: u32,
251    format_version: u32,
252) -> Vec<u8> {
253    let mut out = Vec::new();
254    out.extend_from_slice(MAGIC);
255    put_u32(&mut out, cache_version);
256    put_u32(&mut out, format_version);
257    put_u64(&mut out, source_len);
258    put_u64(&mut out, source_hash);
259    put_doc(&mut out, doc);
260    out
261}
262
263struct Cursor<'a> {
264    b: &'a [u8],
265    pos: usize,
266}
267
268impl<'a> Cursor<'a> {
269    fn take(&mut self, n: usize) -> Result<&'a [u8]> {
270        let end = self
271            .pos
272            .checked_add(n)
273            .context("scene cache is corrupt (truncated)")?;
274        if end > self.b.len() {
275            anyhow::bail!("scene cache is corrupt (truncated)");
276        }
277        let slice = &self.b[self.pos..end];
278        self.pos = end;
279        Ok(slice)
280    }
281
282    fn u8(&mut self) -> Result<u8> {
283        Ok(self.take(1)?[0])
284    }
285
286    fn u32(&mut self) -> Result<u32> {
287        let b: [u8; 4] = self.take(4)?.try_into().expect("take(4) is 4 bytes");
288        Ok(u32::from_le_bytes(b))
289    }
290
291    fn u64(&mut self) -> Result<u64> {
292        let b: [u8; 8] = self.take(8)?.try_into().expect("take(8) is 8 bytes");
293        Ok(u64::from_le_bytes(b))
294    }
295
296    fn i64(&mut self) -> Result<i64> {
297        let b: [u8; 8] = self.take(8)?.try_into().expect("take(8) is 8 bytes");
298        Ok(i64::from_le_bytes(b))
299    }
300
301    fn f64(&mut self) -> Result<f64> {
302        let b: [u8; 8] = self.take(8)?.try_into().expect("take(8) is 8 bytes");
303        Ok(f64::from_le_bytes(b))
304    }
305
306    fn str(&mut self) -> Result<String> {
307        let len = self.u32()? as usize;
308        let bytes = self.take(len)?;
309        String::from_utf8(bytes.to_vec()).context("scene cache is corrupt (bad UTF-8)")
310    }
311
312    fn opt_str(&mut self) -> Result<Option<String>> {
313        match self.u8()? {
314            0 => Ok(None),
315            1 => Ok(Some(self.str()?)),
316            tag => anyhow::bail!("scene cache is corrupt (bad option tag {tag})"),
317        }
318    }
319}
320
321fn get_toml_value(cur: &mut Cursor<'_>) -> Result<toml::Value> {
322    match cur.u8()? {
323        0 => Ok(toml::Value::String(cur.str()?)),
324        1 => Ok(toml::Value::Integer(cur.i64()?)),
325        2 => Ok(toml::Value::Float(cur.f64()?)),
326        3 => Ok(toml::Value::Boolean(match cur.u8()? {
327            0 => false,
328            1 => true,
329            tag => anyhow::bail!("scene cache is corrupt (bad bool tag {tag})"),
330        })),
331        4 => {
332            let len = cur.u32()? as usize;
333            let mut items = Vec::with_capacity(len.min(1024));
334            for _ in 0..len {
335                items.push(get_toml_value(cur)?);
336            }
337            Ok(toml::Value::Array(items))
338        }
339        5 => {
340            let len = cur.u32()? as usize;
341            let mut table = toml::map::Map::new();
342            for _ in 0..len {
343                let k = cur.str()?;
344                let v = get_toml_value(cur)?;
345                table.insert(k, v);
346            }
347            Ok(toml::Value::Table(table))
348        }
349        6 => {
350            let raw = cur.str()?;
351            let dt: toml::value::Datetime = raw
352                .parse()
353                .context("scene cache is corrupt (bad datetime)")?;
354            Ok(toml::Value::Datetime(dt))
355        }
356        tag => anyhow::bail!("scene cache is corrupt (bad value tag {tag})"),
357    }
358}
359
360fn get_script(cur: &mut Cursor<'_>) -> Result<Option<SceneScript>> {
361    match cur.u8()? {
362        0 => Ok(None),
363        1 => Ok(Some(SceneScript {
364            path: cur.str()?,
365            class: cur.str()?,
366        })),
367        tag => anyhow::bail!("scene cache is corrupt (bad script tag {tag})"),
368    }
369}
370
371fn get_node(cur: &mut Cursor<'_>) -> Result<SceneNode> {
372    let id = cur.str()?;
373    let type_name = cur.str()?;
374    let name = cur.str()?;
375    let parent = cur.opt_str()?;
376    let prop_len = cur.u32()? as usize;
377    let mut props = std::collections::BTreeMap::new();
378    for _ in 0..prop_len {
379        let k = cur.str()?;
380        let v = get_toml_value(cur)?;
381        props.insert(k, v);
382    }
383    let script = get_script(cur)?;
384    Ok(SceneNode {
385        id,
386        type_name,
387        name,
388        parent,
389        props,
390        script,
391    })
392}
393
394fn get_instance(cur: &mut Cursor<'_>) -> Result<SceneInstance> {
395    let scene = cur.str()?;
396    let parent = cur.opt_str()?;
397    let prefix = cur.str()?;
398    let len = cur.u32()? as usize;
399    let mut overrides = std::collections::BTreeMap::new();
400    for _ in 0..len {
401        let k = cur.str()?;
402        let v = get_toml_value(cur)?;
403        overrides.insert(k, v);
404    }
405    Ok(SceneInstance {
406        scene,
407        parent,
408        prefix,
409        overrides,
410    })
411}
412
413fn get_doc(cur: &mut Cursor<'_>) -> Result<SceneDoc> {
414    let format_version = cur.u32()?;
415    let root = cur.str()?;
416    let node_len = cur.u32()? as usize;
417    let mut node = Vec::with_capacity(node_len.min(1024));
418    for _ in 0..node_len {
419        node.push(get_node(cur)?);
420    }
421    let inst_len = cur.u32()? as usize;
422    let mut instance = Vec::with_capacity(inst_len.min(64));
423    for _ in 0..inst_len {
424        instance.push(get_instance(cur)?);
425    }
426    Ok(SceneDoc {
427        format_version,
428        root,
429        node,
430        instance,
431    })
432}
433
434/// Decode a cache payload. Bad magic, truncation, trailing bytes, or a
435/// version stamp that is not the live one all fail loudly — the caller
436/// falls back to TOML and says so, never silently reinterprets.
437pub fn decode(bytes: &[u8]) -> Result<(SceneDoc, CacheHeader)> {
438    let mut cur = Cursor { b: bytes, pos: 0 };
439    let magic = cur
440        .take(MAGIC.len())
441        .context("scene cache is corrupt (truncated header)")?;
442    if magic != MAGIC {
443        anyhow::bail!("scene cache is corrupt (bad magic)");
444    }
445    let header = CacheHeader {
446        cache_version: cur
447            .u32()
448            .context("scene cache is corrupt (truncated header)")?,
449        format_version: cur
450            .u32()
451            .context("scene cache is corrupt (truncated header)")?,
452        source_len: cur
453            .u64()
454            .context("scene cache is corrupt (truncated header)")?,
455        source_hash: cur
456            .u64()
457            .context("scene cache is corrupt (truncated header)")?,
458    };
459    if header.cache_version != CACHE_VERSION {
460        anyhow::bail!(
461            "scene cache has cache version {} (engine uses {CACHE_VERSION})",
462            header.cache_version
463        );
464    }
465    if header.format_version != FORMAT_VERSION {
466        anyhow::bail!(
467            "scene cache has scene format_version {} (engine uses {FORMAT_VERSION})",
468            header.format_version
469        );
470    }
471    let doc = get_doc(&mut cur)?;
472    if cur.pos != bytes.len() {
473        anyhow::bail!(
474            "scene cache is corrupt ({} trailing bytes)",
475            bytes.len() - cur.pos
476        );
477    }
478    Ok((doc, header))
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    const SCENE: &str = r#"
486format_version = 1
487root = "root"
488
489[[node]]
490id = "root"
491type = "Node2D"
492name = "Main"
493
494[[node]]
495id = "player"
496type = "Sprite2D"
497name = "Player"
498parent = "root"
499
500[node.props]
501texture = "res://assets/player.png"
502position = [100.0, 200.0]
503visible = true
504lives = 3
505speed = 1.5
506tags = ["a", "b"]
507stamped = 1979-05-27T07:32:00Z
508
509[node.props.extra]
510level = 2
511
512[node.script]
513path = "res://scripts/player.py"
514class = "Player"
515
516[[instance]]
517scene = "res://scenes/enemy.pitescene"
518parent = "root"
519prefix = "e1_"
520
521[instance.overrides]
522"sprite.position" = [300.0, 120.0]
523"#;
524
525    fn tmpdir(tag: &str) -> PathBuf {
526        let dir = std::env::temp_dir().join(format!("pite-cache-{tag}-{}", std::process::id()));
527        let _ = std::fs::remove_dir_all(&dir);
528        std::fs::create_dir_all(&dir).unwrap();
529        dir
530    }
531
532    fn write_scene(dir: &Path, text: &str) -> PathBuf {
533        let path = dir.join("main.pitescene");
534        std::fs::write(&path, text).unwrap();
535        path
536    }
537
538    fn cache_file_for(scene: &Path) -> PathBuf {
539        let bytes = std::fs::read(scene).unwrap();
540        cache_path_for(scene, &content_hash(&bytes))
541    }
542
543    #[test]
544    fn binary_round_trip_preserves_doc() {
545        let doc = crate::parse_scene_str(SCENE).unwrap();
546        let bytes = std::fs::read("Cargo.toml")
547            .map(|b| b.len() as u64)
548            .unwrap_or(0);
549        let (again, header) = decode(&encode(&doc, 0x1234, bytes)).unwrap();
550        assert_eq!(doc, again);
551        assert_eq!(header.cache_version, CACHE_VERSION);
552        assert_eq!(header.format_version, FORMAT_VERSION);
553    }
554
555    #[test]
556    fn miss_writes_cache_then_hit_reads() {
557        let dir = tmpdir("miss-hit");
558        let scene = write_scene(&dir, SCENE);
559        let (doc, status, warning) = crate::load_cached(&scene).unwrap();
560        assert_eq!(status, CacheStatus::Miss);
561        assert!(
562            warning.is_none(),
563            "clean miss is transparent, got {warning:?}"
564        );
565        assert!(
566            cache_file_for(&scene).is_file(),
567            "miss must write the cache"
568        );
569        let (again, status, warning) = crate::load_cached(&scene).unwrap();
570        assert_eq!(status, CacheStatus::Hit);
571        assert!(warning.is_none(), "hit is transparent, got {warning:?}");
572        assert_eq!(doc, again);
573        std::fs::remove_dir_all(&dir).ok();
574    }
575
576    #[test]
577    fn source_change_invalidates_and_prunes() {
578        let dir = tmpdir("invalidate");
579        let scene = write_scene(&dir, SCENE);
580        crate::load_cached(&scene).unwrap();
581        let old_cache = cache_file_for(&scene);
582        assert!(old_cache.is_file());
583        let changed = format!(
584            "{SCENE}\n[[node]]\nid = \"extra\"\ntype = \"Timer\"\nname = \"Extra\"\nparent = \"root\"\n"
585        );
586        std::fs::write(&scene, &changed).unwrap();
587        let (doc, status, warning) = crate::load_cached(&scene).unwrap();
588        assert_eq!(status, CacheStatus::Miss);
589        assert!(
590            warning.is_none(),
591            "source change is a clean miss, got {warning:?}"
592        );
593        assert!(doc.node.iter().any(|n| n.id == "extra"));
594        assert!(!old_cache.exists(), "stale hash file must be pruned");
595        assert!(cache_file_for(&scene).is_file());
596        std::fs::remove_dir_all(&dir).ok();
597    }
598
599    #[test]
600    fn cache_version_bump_rebuilds_loudly() {
601        let dir = tmpdir("cache-bump");
602        let scene = write_scene(&dir, SCENE);
603        crate::load_cached(&scene).unwrap();
604        let cpath = cache_file_for(&scene);
605        let doc = crate::parse_scene_str(SCENE).unwrap();
606        let bytes = std::fs::read(&scene).unwrap();
607        let stale = encode_with(
608            &doc,
609            fnv1a_u64(&bytes),
610            bytes.len() as u64,
611            CACHE_VERSION + 1,
612            FORMAT_VERSION,
613        );
614        std::fs::write(&cpath, &stale).unwrap();
615        let (again, status, warning) = crate::load_cached(&scene).unwrap();
616        assert!(matches!(status, CacheStatus::Rebuilt(_)), "got {status:?}");
617        let warning = warning.expect("version bump must fall back loudly");
618        assert!(warning.contains("cache version"), "got {warning:?}");
619        assert_eq!(doc, again);
620        let (_, status, warning) = crate::load_cached(&scene).unwrap();
621        assert_eq!(status, CacheStatus::Hit);
622        assert!(warning.is_none());
623        std::fs::remove_dir_all(&dir).ok();
624    }
625
626    #[test]
627    fn format_version_bump_rebuilds_loudly() {
628        let dir = tmpdir("format-bump");
629        let scene = write_scene(&dir, SCENE);
630        crate::load_cached(&scene).unwrap();
631        let cpath = cache_file_for(&scene);
632        let doc = crate::parse_scene_str(SCENE).unwrap();
633        let bytes = std::fs::read(&scene).unwrap();
634        let stale = encode_with(
635            &doc,
636            fnv1a_u64(&bytes),
637            bytes.len() as u64,
638            CACHE_VERSION,
639            FORMAT_VERSION + 1,
640        );
641        std::fs::write(&cpath, &stale).unwrap();
642        let (again, status, warning) = crate::load_cached(&scene).unwrap();
643        assert!(matches!(status, CacheStatus::Rebuilt(_)), "got {status:?}");
644        let warning = warning.expect("format bump must fall back loudly");
645        assert!(warning.contains("format_version"), "got {warning:?}");
646        assert_eq!(doc, again);
647        std::fs::remove_dir_all(&dir).ok();
648    }
649
650    #[test]
651    fn corrupt_cache_falls_back_loudly() {
652        let dir = tmpdir("corrupt");
653        let scene = write_scene(&dir, SCENE);
654        crate::load_cached(&scene).unwrap();
655        let cpath = cache_file_for(&scene);
656        std::fs::write(&cpath, b"definitely not a scene cache").unwrap();
657        let (doc, status, warning) = crate::load_cached(&scene).unwrap();
658        assert!(matches!(status, CacheStatus::Rebuilt(_)), "got {status:?}");
659        let warning = warning.expect("corruption must fall back loudly");
660        assert!(warning.contains("corrupt"), "got {warning:?}");
661        assert_eq!(doc, crate::parse_scene_str(SCENE).unwrap());
662        let (_, status, warning) = crate::load_cached(&scene).unwrap();
663        assert_eq!(status, CacheStatus::Hit);
664        assert!(
665            warning.is_none(),
666            "rewritten cache must be valid, got {warning:?}"
667        );
668        std::fs::remove_dir_all(&dir).ok();
669    }
670}