Skip to main content

pite_scene/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Scene serialization, node tree instantiation, validation, and binary caching
3//! for the Pite game engine.
4//!
5//! Human-readable TOML files (`.ptscn`) serve as the canonical source of truth
6//! for scene layouts, while derived binary snapshots (`_cache/*.bin`) accelerate
7//! runtime loading. Depends solely on `pite-core`.
8
9#![forbid(unsafe_code)]
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15use pite_core::{Color, NodeDesc, NodeId, NodeTree, PropValue, Props, ScriptRef};
16use serde::{Deserialize, Serialize};
17
18pub mod cache;
19pub mod color;
20pub mod tilemap;
21
22pub use cache::{CacheStatus, CACHE_VERSION};
23pub use color::{format_hex_color, parse_hex_color};
24pub use tilemap::{
25    load_tilemap, map_to_world, parse_tilemap_str, save_tilemap, world_to_map, TileCell,
26    TileMapDoc, TileMapLayer,
27};
28
29/// Current scene format version. Bump = migrate or error loudly, never silent.
30pub const FORMAT_VERSION: u32 = 1;
31
32/// Canonical `.ptscn` document.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct SceneDoc {
35    pub format_version: u32,
36    pub root: String,
37    #[serde(default)]
38    pub node: Vec<SceneNode>,
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub instance: Vec<SceneInstance>,
41}
42
43/// One flat node entry. `props` preserves unknown keys (forward compat).
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct SceneNode {
46    pub id: String,
47    #[serde(rename = "type")]
48    pub type_name: String,
49    pub name: String,
50    pub parent: Option<String>,
51    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
52    pub props: BTreeMap<String, toml::Value>,
53    pub script: Option<SceneScript>,
54}
55
56/// Attached script reference (project-relative path + class).
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct SceneScript {
59    pub path: String,
60    pub class: String,
61}
62
63/// An instantiation entry: load the referenced file, clone its subtree
64/// under `parent`, remap ids with `prefix`, apply prop overrides.
65/// Overrides are keyed `"node_id.prop"`; unknown targets fail loudly.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct SceneInstance {
68    pub scene: String,
69    pub parent: Option<String>,
70    #[serde(default)]
71    pub prefix: String,
72    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73    pub overrides: BTreeMap<String, toml::Value>,
74}
75
76/// Format migration hook (§12). New fields are additive; breaking
77/// change = version bump, never silent reinterpretation.
78pub fn migrate(from: u32, _doc: &toml::Value) -> Result<()> {
79    if from == FORMAT_VERSION {
80        return Ok(());
81    }
82    anyhow::bail!("unsupported scene format_version {from} (engine supports {FORMAT_VERSION})");
83}
84
85pub fn parse_scene_str(text: &str) -> Result<SceneDoc> {
86    let raw: toml::Value = toml::from_str(text).context("scene is not valid TOML")?;
87    let version = raw
88        .get("format_version")
89        .and_then(toml::Value::as_integer)
90        .unwrap_or(0);
91    migrate(version as u32, &raw)?;
92    let doc: SceneDoc = raw.try_into().context("scene does not match schema")?;
93    Ok(doc)
94}
95
96pub fn load_scene(path: &Path) -> Result<SceneDoc> {
97    let text = std::fs::read_to_string(path)
98        .with_context(|| format!("cannot read scene {}", path.display()))?;
99    parse_scene_str(&text).with_context(|| format!("cannot parse scene {}", path.display()))
100}
101
102/// Load with the binary cache: a valid cache wins; a missing cache parses
103/// TOML and writes the cache; a corrupt or version-mismatched cache parses
104/// TOML, rewrites the cache, and reports the fallback loudly via the
105/// returned warning — never silently. TOML stays source of truth.
106pub fn load_cached(path: &Path) -> Result<(SceneDoc, CacheStatus, Option<String>)> {
107    let bytes =
108        std::fs::read(path).with_context(|| format!("cannot read scene {}", path.display()))?;
109    let source_hash = cache::fnv1a_u64(&bytes);
110    let source_hex = format!("{source_hash:016x}");
111    let cache_path = cache::cache_path_for(path, &source_hex);
112
113    let mut rebuilt_reason: Option<String> = None;
114    if cache_path.is_file() {
115        match std::fs::read(&cache_path) {
116            Ok(cached) => match cache::decode(&cached) {
117                Ok((doc, header))
118                    if header.source_hash == source_hash
119                        && header.source_len == bytes.len() as u64 =>
120                {
121                    cache::prune_stale(path, &source_hex);
122                    return Ok((doc, CacheStatus::Hit, None));
123                }
124                Ok(_) => {
125                    rebuilt_reason = Some(format!(
126                        "scene cache {} does not match {}",
127                        cache_path.display(),
128                        path.display()
129                    ));
130                }
131                Err(e) => {
132                    rebuilt_reason = Some(format!(
133                        "scene cache {} ignored ({e:#}); rebuilding from TOML",
134                        cache_path.display()
135                    ));
136                }
137            },
138            Err(e) => {
139                rebuilt_reason = Some(format!(
140                    "scene cache {} unreadable ({e:#}); rebuilding from TOML",
141                    cache_path.display()
142                ));
143            }
144        }
145    }
146
147    let text = std::str::from_utf8(&bytes)
148        .with_context(|| format!("cannot parse scene {}", path.display()))?;
149    let doc =
150        parse_scene_str(text).with_context(|| format!("cannot parse scene {}", path.display()))?;
151
152    let payload = cache::encode(&doc, source_hash, bytes.len() as u64);
153    let mut warning = rebuilt_reason.clone();
154    if let Some(dir) = cache_path.parent() {
155        if let Err(e) =
156            std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&cache_path, &payload))
157        {
158            let write_warn = format!("cannot write scene cache {} ({e:#})", cache_path.display());
159            warning = Some(match warning {
160                Some(w) => format!("{w}; {write_warn}"),
161                None => write_warn,
162            });
163        } else {
164            cache::prune_stale(path, &source_hex);
165        }
166    }
167
168    let status = match rebuilt_reason {
169        Some(reason) => CacheStatus::Rebuilt(reason),
170        None => CacheStatus::Miss,
171    };
172    Ok((doc, status, warning))
173}
174
175/// Preferred entry point: [`load_cached`] with the fallback warning
176/// surfaced loudly on stderr. Corruption anywhere falls back to TOML,
177/// never silently.
178pub fn load_scene_cached(path: &Path) -> Result<SceneDoc> {
179    let (doc, _, warning) = load_cached(path)?;
180    if let Some(w) = warning {
181        tracing::warn!("{w}");
182    }
183    Ok(doc)
184}
185
186pub fn save_scene(doc: &SceneDoc) -> Result<String> {
187    toml::to_string(doc).context("cannot serialize scene")
188}
189
190/// Structural validation. Missing script = placeholder, never a load
191/// failure; unknown props warn and are preserved on save.
192pub fn validate(doc: &SceneDoc) -> Vec<String> {
193    let mut issues = Vec::new();
194    if doc.format_version != FORMAT_VERSION {
195        issues.push(format!(
196            "format_version {} != supported {FORMAT_VERSION}",
197            doc.format_version
198        ));
199    }
200    let ids: std::collections::HashSet<&str> = doc.node.iter().map(|n| n.id.as_str()).collect();
201    if !ids.contains(doc.root.as_str()) {
202        issues.push(format!("root {:?} not found in node list", doc.root));
203    }
204    for n in &doc.node {
205        if let Some(parent) = &n.parent {
206            if !ids.contains(parent.as_str()) {
207                issues.push(format!("node {:?} has unknown parent {parent:?}", n.id));
208            }
209        }
210    }
211    issues
212}
213
214pub fn toml_to_prop(value: &toml::Value) -> PropValue {
215    match value {
216        toml::Value::String(s) => {
217            if let Some(c) = parse_hex_color(s) {
218                PropValue::Color(c.r, c.g, c.b, c.a)
219            } else {
220                PropValue::Str(s.clone())
221            }
222        }
223        toml::Value::Integer(i) => PropValue::Int(*i),
224        toml::Value::Float(f) => PropValue::Num(*f),
225        toml::Value::Boolean(b) => PropValue::Bool(*b),
226        toml::Value::Array(items) => {
227            let nums: Vec<f64> = items
228                .iter()
229                .map(|v| match v {
230                    toml::Value::Integer(i) => *i as f64,
231                    toml::Value::Float(f) => *f,
232                    _ => f64::NAN,
233                })
234                .collect();
235            if nums.len() == 2 && nums.iter().all(|n| !n.is_nan()) {
236                PropValue::Vec2(nums[0], nums[1])
237            } else {
238                PropValue::Str(value.to_string())
239            }
240        }
241        _ => PropValue::Str(value.to_string()),
242    }
243}
244
245/// Convert a `PropValue` back to its scene-file TOML shape. The inverse of
246/// [`toml_to_prop`]: `Color` becomes `"#rrggbbaa"`, `Vec2` becomes `[x, y]`,
247/// primitives map to their natural TOML types, and any unrecognized variant
248/// (future) falls back to a string.
249pub fn prop_to_toml(value: &PropValue) -> toml::Value {
250    match value {
251        PropValue::Str(s) => toml::Value::String(s.clone()),
252        PropValue::Num(f) => toml::Value::Float(*f),
253        PropValue::Int(i) => toml::Value::Integer(*i),
254        PropValue::Bool(b) => toml::Value::Boolean(*b),
255        PropValue::Vec2(x, y) => {
256            toml::Value::Array(vec![toml::Value::Float(*x), toml::Value::Float(*y)])
257        }
258        PropValue::Color(r, g, b, a) => {
259            toml::Value::String(format_hex_color(Color::new(*r, *g, *b, *a)))
260        }
261    }
262}
263
264/// Build `NodeDesc`s from a scene doc, filling schema defaults (inherited
265/// props included) for every declared prop not present in the scene. Ids,
266/// parents, and scripts copy verbatim; callers that remap (e.g.
267/// `instantiate`) do so after.
268///
269/// After this, every schema prop for each node's type is present with a
270/// compatible value — the invariant the runtime getters rely on.
271fn descs_from_doc(doc: &SceneDoc) -> Vec<NodeDesc> {
272    doc.node
273        .iter()
274        .map(|n| {
275            let mut props = Props::default();
276            for prop in pite_core::all_props(&n.type_name) {
277                props.insert(prop.key, prop.default_value.to_prop_value());
278            }
279            // Overlay scene-set values. Coerce integer literals into floats
280            // when the schema expects `Num`, so `mass = 1` (integer syntax)
281            // lands as `Num(1.0)` — matching `mass = 1.0`. Other shape
282            // conversions (array → Vec2, hex → Color) already happen in
283            // `toml_to_prop`.
284            for (k, v) in &n.props {
285                let raw = toml_to_prop(v);
286                let coerced = match (props.get(k), raw) {
287                    (Some(pite_core::PropValue::Num(_)), pite_core::PropValue::Int(i)) => {
288                        pite_core::PropValue::Num(i as f64)
289                    }
290                    (_, other) => other,
291                };
292                props.insert(k.clone(), coerced);
293            }
294            NodeDesc {
295                id: NodeId::from(n.id.clone()),
296                type_name: n.type_name.clone(),
297                name: n.name.clone(),
298                parent: n.parent.clone().map(NodeId::from),
299                props,
300                script: n.script.as_ref().map(|s| ScriptRef {
301                    path: s.path.clone(),
302                    class_name: s.class.clone(),
303                }),
304            }
305        })
306        .collect()
307}
308
309/// Convert a scene document into a runtime [`NodeTree`] with schema defaults
310/// filled. Missing scripts are placeholders here, never a load failure.
311pub fn to_node_tree(doc: &SceneDoc) -> Result<NodeTree> {
312    let mut tree = NodeTree::new();
313    for desc in descs_from_doc(doc) {
314        let id_str = desc.id.to_string();
315        tree.insert(desc)
316            .map_err(|e| anyhow::anyhow!("node {id_str:?}: {e}"))?;
317    }
318    Ok(tree)
319}
320
321/// Load the referenced scene, fill defaults, remap ids with `prefix`, apply
322/// prop overrides. Returns node records ready to insert into the host tree
323/// plus the remapped id of the referenced scene's root.
324pub fn instantiate(
325    scene_path: &Path,
326    prefix: &str,
327    overrides: &BTreeMap<String, toml::Value>,
328) -> Result<(Vec<NodeDesc>, String)> {
329    let doc = load_scene_cached(scene_path)?;
330    let mut descs = descs_from_doc(&doc);
331    for desc in &mut descs {
332        desc.id = NodeId::from(format!("{prefix}{}", desc.id.as_str()));
333        desc.parent = desc
334            .parent
335            .as_ref()
336            .map(|p| NodeId::from(format!("{prefix}{}", p.as_str())));
337    }
338    for (key, value) in overrides {
339        let (node_id, prop) = key.split_once('.').with_context(|| {
340            format!(
341                "override {key:?} must be \"node_id.prop\" in {}",
342                scene_path.display()
343            )
344        })?;
345        let target = format!("{prefix}{node_id}");
346        let desc = descs
347            .iter_mut()
348            .find(|d| d.id.as_str() == target)
349            .with_context(|| {
350                format!(
351                    "override target {target:?} not found in {}",
352                    scene_path.display()
353                )
354            })?;
355        desc.props.insert(prop.to_string(), toml_to_prop(value));
356    }
357    Ok((descs, format!("{prefix}{}", doc.root)))
358}
359
360pub fn resolve_scene_ref(
361    scene_ref: &str,
362    scene_dir: &Path,
363    project_dir: Option<&Path>,
364) -> Result<PathBuf> {
365    if let Some(rel) = scene_ref.strip_prefix("res://") {
366        let root = project_dir.with_context(|| {
367            format!("scene {scene_ref:?} uses res:// but no project root is known")
368        })?;
369        Ok(root.join(rel))
370    } else {
371        let p = Path::new(scene_ref);
372        if p.is_absolute() {
373            Ok(p.to_path_buf())
374        } else {
375            Ok(scene_dir.join(p))
376        }
377    }
378}
379
380/// Build the full runtime tree: host nodes plus every `[[instance]]`
381/// subtree (loaded, id-remapped, overrides applied).
382pub fn build_tree(
383    doc: &SceneDoc,
384    scene_dir: &Path,
385    project_dir: Option<&Path>,
386) -> Result<NodeTree> {
387    let mut tree = to_node_tree(doc)?;
388    let host_ids: std::collections::HashSet<&str> =
389        doc.node.iter().map(|n| n.id.as_str()).collect();
390    for inst in &doc.instance {
391        let ref_path = resolve_scene_ref(&inst.scene, scene_dir, project_dir)?;
392        let (mut descs, subtree_root) = instantiate(&ref_path, &inst.prefix, &inst.overrides)?;
393        let attach_at = inst.parent.clone().unwrap_or_else(|| doc.root.clone());
394        if !host_ids.contains(attach_at.as_str())
395            && tree.get(&NodeId::from(attach_at.clone())).is_none()
396        {
397            return Err(pite_core::CoreError::MissingParent {
398                child: format!("instance of {}", inst.scene),
399                parent: attach_at,
400            }
401            .into());
402        }
403        for desc in descs.drain(..) {
404            let mut desc = desc;
405            if desc.id.as_str() == subtree_root {
406                desc.parent = Some(NodeId::from(attach_at.clone()));
407            }
408            tree.insert(desc.clone())
409                .map_err(|e| anyhow::anyhow!("instance of {}: {e}", inst.scene))?;
410        }
411    }
412    Ok(tree)
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    const HOST: &str = r#"
420format_version = 1
421root = "root"
422
423[[node]]
424id = "root"
425type = "Node2D"
426name = "Main"
427
428[[node]]
429id = "player"
430type = "Sprite2D"
431name = "Player"
432parent = "root"
433
434[node.props]
435texture = "res://assets/player.png"
436position = [100.0, 200.0]
437mystery = "kept"
438
439[node.script]
440path = "res://scripts/player.py"
441class = "Player"
442"#;
443
444    #[test]
445    fn round_trip_preserves_unknown_props() {
446        let doc = parse_scene_str(HOST).unwrap();
447        let saved = save_scene(&doc).unwrap();
448        let again = parse_scene_str(&saved).unwrap();
449        assert_eq!(doc, again);
450        let player = again.node.iter().find(|n| n.id == "player").unwrap();
451        assert_eq!(
452            player.props.get("mystery"),
453            Some(&toml::Value::String("kept".to_string()))
454        );
455    }
456
457    #[test]
458    fn version_bump_errors_loudly() {
459        let err = parse_scene_str("format_version = 99\nroot = \"root\"\n").unwrap_err();
460        assert!(err.to_string().contains("unsupported scene format_version"));
461    }
462
463    fn write_fixture(dir: &Path, name: &str, text: &str) -> PathBuf {
464        let path = dir.join(name);
465        std::fs::write(&path, text).unwrap();
466        path
467    }
468
469    #[test]
470    fn instantiate_clones_subtree_and_applies_overrides() {
471        let dir = std::env::temp_dir().join(format!("pite-inst-{}", std::process::id()));
472        std::fs::create_dir_all(&dir).unwrap();
473        let ref_path = write_fixture(
474            &dir,
475            "enemy.ptscn",
476            r#"
477format_version = 1
478root = "enemy"
479
480[[node]]
481id = "enemy"
482type = "Node2D"
483name = "Enemy"
484
485[[node]]
486id = "sprite"
487type = "Sprite2D"
488name = "Sprite"
489parent = "enemy"
490
491[node.props]
492position = [0.0, 0.0]
493"#,
494        );
495        let mut overrides = BTreeMap::new();
496        overrides.insert(
497            "sprite.position".to_string(),
498            toml::Value::Array(vec![toml::Value::Float(10.0), toml::Value::Float(20.0)]),
499        );
500        let (descs, root) = instantiate(&ref_path, "e1_", &overrides).unwrap();
501        assert_eq!(root, "e1_enemy");
502        assert_eq!(descs.len(), 2);
503        let sprite = descs.iter().find(|d| d.id.as_str() == "e1_sprite").unwrap();
504        assert_eq!(sprite.parent, Some(NodeId::from("e1_enemy".to_string())));
505        assert_eq!(
506            sprite.props.get("position"),
507            Some(&PropValue::Vec2(10.0, 20.0))
508        );
509        std::fs::remove_dir_all(&dir).ok();
510    }
511
512    #[test]
513    fn instantiate_unknown_override_target_fails() {
514        let dir = std::env::temp_dir().join(format!("pite-inst-bad-{}", std::process::id()));
515        std::fs::create_dir_all(&dir).unwrap();
516        let ref_path = write_fixture(
517            &dir,
518            "tiny.ptscn",
519            "format_version = 1\nroot = \"solo\"\n\n[[node]]\nid = \"solo\"\ntype = \"Node\"\nname = \"Solo\"\n",
520        );
521        let mut overrides = BTreeMap::new();
522        overrides.insert("ghost.x".to_string(), toml::Value::Integer(1));
523        let err = instantiate(&ref_path, "", &overrides).unwrap_err();
524        assert!(err.to_string().contains("override target"));
525        std::fs::remove_dir_all(&dir).ok();
526    }
527
528    #[test]
529    fn color_round_trips_through_toml() {
530        let hex = toml::Value::String("#4ADE80".to_string());
531        let prop = toml_to_prop(&hex);
532        assert_eq!(prop, PropValue::Color(0x4A, 0xDE, 0x80, 0xFF));
533        let back = prop_to_toml(&prop);
534        assert_eq!(back, toml::Value::String("#4ade80ff".to_string()));
535    }
536}