1use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11
12use crate::{SceneDoc, SceneInstance, SceneNode, SceneScript, FORMAT_VERSION};
13
14pub const CACHE_VERSION: u32 = 1;
17
18pub const CACHE_DIR_NAME: &str = "_cache";
20
21pub const CACHE_EXT: &str = "bin";
23
24const MAGIC: &[u8; 8] = b"PITSCN01";
26
27#[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#[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
56pub 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
66pub fn content_hash(bytes: &[u8]) -> String {
68 format!("{:016x}", fnv1a_u64(bytes))
69}
70
71pub 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
85pub 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
147fn 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
238pub 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
244pub 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
434pub 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}