Skip to main content

concinnity_world/check/
mod.rs

1//! Semantic validation of an expanded world: per-asset arg checks, cross-asset
2//! reference checks, and world-shape rules (crate::check::shape). Structural
3//! validation (name/type present, known type, unique names) happens earlier in
4//! crate::world::load_world.
5//!
6//! The checks here are pure JSON-shape validation. A few asset types validate
7//! by running their compiler (mesh generators, texture generators, ...); those
8//! compilers live in concinnity-cook, which passes them into `check_world_with`
9//! as the per-asset `extra` hook and composes the full check set behind its own
10//! entry points.
11
12pub(crate) mod animation_graph;
13pub(crate) mod asset_refs;
14pub mod audio;
15pub mod behavior;
16pub mod cross_reference;
17pub mod fault;
18pub(crate) mod instanced_prop;
19pub mod physics;
20/// `Prop` argument checks.
21pub mod prop;
22/// `SdfVolume` argument checks.
23pub mod sdf_volume;
24/// `Shader` argument checks.
25pub mod shader;
26pub(crate) mod shape;
27pub mod voxel_chunk;
28pub(crate) mod voxel_world;
29
30use crate::world::WorldJsonlAsset;
31
32// A per-asset check supplied by the caller, run alongside the built-in ones:
33// (normalized type, asset name, args) -> error message on failure. cook uses
34// this to plug in its compile-backed checks.
35pub(crate) type ExtraAssetCheck<'a> =
36    &'a dyn Fn(&str, &str, &serde_json::Value) -> Result<(), String>;
37
38/// Print each validation error in CLI form and collapse them into a single
39/// io::Error. Shared by the `cn test` command and the build orchestrator so a
40/// failed world surfaces every problem in one pass.
41pub fn report_validation_errors(errors: &[String]) -> std::io::Error {
42    for e in errors {
43        eprintln!("error:   {}", e);
44    }
45    eprintln!("\nvalidation failed ({} error(s))", errors.len());
46    std::io::Error::new(
47        std::io::ErrorKind::InvalidData,
48        format!("validation failed with {} error(s)", errors.len()),
49    )
50}
51
52/// The pure per-asset checks. Types whose validation runs their compiler
53/// (mesh/proceduralmesh, texture, cubemap, environment map) are not handled
54/// here; cook covers them through the `extra` hook.
55pub fn check_asset(type_norm: &str, name: &str, args: &serde_json::Value) -> Result<(), String> {
56    match type_norm {
57        "animationgraph" => animation_graph::check(name, args),
58        "behavior" => behavior::check(name, args),
59        "variables" => behavior::check_variables(name, args),
60        "shader" => shader::check(name, args),
61        "prop" => prop::check(name, args),
62        "sdfvolume" | "sdf" => sdf_volume::check(name, args),
63        "voxelchunk" | "chunk" => voxel_chunk::check(name, args),
64        "voxelworld" => voxel_world::check(name, args),
65        "instancedprop" | "instanced" => instanced_prop::check(name, args),
66        "triggervolume" => physics::check(name, args),
67        "audioemitter" => audio::check_emitter(name, args),
68        "audiocue" => audio::check_cue(name, args),
69        "propbody" => audio::check_prop_body(name, args),
70        _ => Ok(()),
71    }
72}
73
74/// Run all semantic validation on a fully expanded world, with the caller's
75/// extra per-asset checks folded into the same pass. Collects every problem
76/// found (per-asset arg errors, unresolved cross-references, and graphics-rule
77/// violations) so the caller can report them in a single pass.
78pub fn check_world_with(
79    assets: &[WorldJsonlAsset],
80    extra: ExtraAssetCheck,
81) -> Result<(), Vec<String>> {
82    let mut errors: Vec<String> = Vec::new();
83
84    // Names must still be unique after expansion and injection: a duplicate
85    // here means a generated or injected asset silently aliased another (the
86    // authored world's uniqueness was already checked before expansion).
87    let mut seen_names: std::collections::HashSet<&str> = Default::default();
88    for asset in assets {
89        if !seen_names.insert(asset.name.as_str()) {
90            errors.push(format!(
91                "duplicate name '{}' after build-time expansion: a generated or \
92                 injected asset collides with another; rename one of them",
93                asset.name
94            ));
95        }
96    }
97
98    // The world's declared variable table, if it declares one. Behaviors are
99    // checked against it rather than in isolation, so a `set` resolves to the
100    // variable's declared type and a misspelled name is caught here.
101    let declared_vars = assets
102        .iter()
103        .find(|a| a.asset_type.to_lowercase().replace('_', "") == "variables")
104        .map(|a| behavior::DeclaredVars::from_args(&a.args))
105        .unwrap_or_default();
106
107    for asset in assets {
108        let type_norm = asset.asset_type.to_lowercase().replace('_', "");
109        let checked = if type_norm == "behavior" {
110            behavior::check_with_vars(&asset.name, &asset.args, &declared_vars)
111        } else {
112            check_asset(&type_norm, &asset.name, &asset.args)
113        };
114        if let Err(e) = checked {
115            errors.push(e);
116        }
117        if let Err(e) = extra(&type_norm, &asset.name, &asset.args) {
118            errors.push(e);
119        }
120    }
121
122    if let Err(ref_errors) = cross_reference::validate_cross_references(assets) {
123        errors.extend(ref_errors);
124    }
125
126    shape::check_shape(assets, &mut errors);
127
128    if errors.is_empty() {
129        Ok(())
130    } else {
131        Err(errors)
132    }
133}
134
135/// `check_world_with` with no extra checks: the pure-validation subset. Callers
136/// that have the compilers available (cook) compose theirs in instead.
137pub fn check_world(assets: &[WorldJsonlAsset]) -> Result<(), Vec<String>> {
138    check_world_with(assets, &|_, _, _| Ok(()))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn asset(name: &str, asset_type: &str, args: serde_json::Value) -> WorldJsonlAsset {
146        WorldJsonlAsset {
147            name: name.to_string(),
148            asset_type: asset_type.to_string(),
149            args,
150        }
151    }
152
153    #[test]
154    fn graphics_config_with_full_render_stack_passes_graphics_rules() {
155        let assets = vec![
156            asset("gfx", "GraphicsConfig", serde_json::json!({})),
157            asset("win", "Window", serde_json::json!({})),
158            asset(
159                "scene_shader",
160                "Shader",
161                serde_json::json!({
162                    "vertex": {"sources": {"metal": "x.metal", "hlsl": "x.hlsl", "glsl": "x.glsl"}},
163                    "fragment": {"sources": {"metal": "x.metal", "hlsl": "x.hlsl", "glsl": "x.glsl"}}
164                }),
165            ),
166        ];
167        assert!(check_world(&assets).is_ok());
168    }
169
170    #[test]
171    fn per_asset_and_cross_reference_errors_both_collected() {
172        // Prop with no mesh/model/prefab (per-asset error) plus a Material
173        // with a missing albedo texture (cross-reference error).
174        let assets = vec![
175            asset("bad_prop", "Prop", serde_json::json!({})),
176            asset("bad_mat", "Material", serde_json::json!({"albedo":"ghost"})),
177        ];
178        let errs = check_world(&assets).unwrap_err();
179        assert!(errs.iter().any(|e| e.contains("bad_prop")));
180        assert!(errs.iter().any(|e| e.contains("ghost")));
181    }
182
183    #[test]
184    fn extra_checks_fold_into_the_same_error_pass() {
185        let assets = vec![asset("t", "Texture", serde_json::json!({}))];
186        let errs = check_world_with(&assets, &|type_norm, name, _args| {
187            if type_norm == "texture" {
188                Err(format!("Asset '{name}': extra check fired"))
189            } else {
190                Ok(())
191            }
192        })
193        .unwrap_err();
194        assert!(errs.iter().any(|e| e.contains("extra check fired")));
195    }
196}