Skip to main content

concinnity_cook/
pipeline.rs

1//! Compile stage of the build pipeline. The world is loaded, expanded, and
2//! validated upstream by crate::world::prepare_world; this module takes the
3//! resulting WorldJsonlAsset list and:
4//! - Resolves each asset to a BlobAssetDef via asset_api::create_asset_def()
5//! - Compiles payloads for assets that need compilation
6//! - Packs all payloads into blobs using PayloadPacker (fills locators)
7//! - Sorts: components first, then systems in declared order
8
9use std::path::Path;
10
11use concinnity_core::blob::{MeshBoundsRecord, PhysicsBudgetRecord, ResourceKind, SceneGroup};
12use serde::Deserialize;
13
14use crate::components::FileKind;
15use crate::world::WorldJsonlAsset;
16
17use crate::asset_api::{self, AssetRequest};
18use crate::blob::PayloadPacker;
19use crate::ecs::asset_id;
20use crate::ecs::{AssetKind, BlobAssetDef, ResourceRecord};
21use crate::registry::RegisteredType;
22use crate::resource_handles::ResourceAssetCompile;
23
24// The resource kind of a job selected by `collect_resource_jobs`. Every entry
25// there was chosen by having one, so the lookup cannot fail.
26fn job_resource_kind(rt: crate::registry::RegisteredType) -> crate::resource_handles::ResourceKind {
27    rt.resource_kind()
28        .expect("a resource job carries a resource type")
29}
30
31// The mesh kinds' declarable type names. Both are resource assets (no
32// `Component` impl, so no `::NAME` const); the desugar passes and the cache
33// probe match on these.
34const MESH_TYPE: &str = "Mesh";
35const SKINNED_MESH_TYPE: &str = "SkinnedMesh";
36
37/// Build the world at `json_path` and write its blobs to disk, against the
38/// installed state root: sources resolve under its `assets/` and the blobs land
39/// in its `data/`. The one entry point anchored to the process-wide state tree;
40/// a host that builds against its own directories calls
41/// [`prepare_world`](crate::world::prepare_world) + [`build_compiled`].
42pub fn build_from_path(json_path: &str) -> std::io::Result<()> {
43    let content = std::fs::read_to_string(json_path)?;
44    let assets_dir = crate::paths::assets_dir();
45    let loaded = crate::world::prepare_world(&content, assets_dir.as_deref())
46        .map_err(|errs| crate::check::report_validation_errors(&errs))?;
47
48    let result = build_compiled(loaded.assets, assets_dir.as_deref(), None)?;
49
50    let pack_result = write_build_outputs(&result, &loaded.injected, &loaded.shadowed)?;
51    for (blob_idx, path) in pack_result.blob_paths.iter().enumerate() {
52        let payload_bytes = result.payloads.get(blob_idx).map(|b| b.len()).unwrap_or(0);
53        println!("Wrote {} ({} payload bytes)", path, payload_bytes);
54    }
55
56    if result.cache_hits + result.cache_misses > 0 {
57        println!(
58            "Build cache: {} reused, {} compiled",
59            result.cache_hits, result.cache_misses
60        );
61    }
62
63    if !loaded.injected.is_empty() {
64        println!(
65            "Injected {} default asset(s) (see world-lock.json)",
66            loaded.injected.len()
67        );
68    }
69    println!("Wrote world-lock.json");
70
71    Ok(())
72}
73
74/// Write a compiled world's blob files, naming the primary blob `primary`.
75/// Every overflow payload blob is written as its sibling named by index, which
76/// is the layout the runtime reads back. No lock file and no thumbnails: this
77/// is the blob output alone.
78pub fn write_blobs_to(
79    result: &PipelineResult,
80    primary: &std::path::Path,
81) -> std::io::Result<crate::blob::PackResult> {
82    crate::blob::write_blobs(
83        crate::blob::BlobStreams {
84            defs: &result.defs,
85            resources: &result.resources,
86            scene_groups: &result.scene_groups,
87            mesh_bounds: &result.mesh_bounds,
88            physics_budget: result.physics_budget,
89        },
90        &result.payloads,
91        primary,
92    )
93}
94
95/// Write the blobs and world-lock.json for a compiled world: the shared build
96/// tail used by the CLI and the FFI host. The lock records each asset under its
97/// real name plus every injected default with its full args.
98pub fn write_build_outputs(
99    result: &PipelineResult,
100    injected: &[crate::world::InjectedAsset],
101    shadowed: &[crate::world::ShadowedAsset],
102) -> std::io::Result<crate::blob::PackResult> {
103    let primary = crate::blob::blob_path(0).ok_or_else(|| {
104        std::io::Error::new(
105            std::io::ErrorKind::NotFound,
106            "no project state directory to write blobs into",
107        )
108    })?;
109    let pack_result = write_blobs_to(result, std::path::Path::new(&primary))?;
110    let named_refs: Vec<(&str, &BlobAssetDef)> = result
111        .names
112        .iter()
113        .map(|n| n.as_str())
114        .zip(result.defs.iter())
115        .collect();
116    crate::blob::write_lock(
117        &named_refs,
118        &result.resource_locks,
119        injected,
120        shadowed,
121        &pack_result.blob_paths,
122    )?;
123    // Thumbnails are a best-effort side product: a bake failure must never
124    // fail the build that produced valid blobs.
125    match crate::thumbnail::bake_thumbnails(result) {
126        Ok(r) if r.baked > 0 => println!("Baked {} thumbnail(s) ({} reused)", r.baked, r.reused),
127        Ok(_) => {}
128        Err(e) => println!("Thumbnail bake skipped: {e}"),
129    }
130    Ok(pack_result)
131}
132
133// Collapse a list of validation errors into a single io::Error. The messages
134// are newline-joined so an upstream caller (e.g. the infra agentic loop) sees
135// every problem from one call.
136fn errors_to_io(errors: Vec<String>) -> std::io::Error {
137    std::io::Error::new(std::io::ErrorKind::InvalidData, errors.join("\n"))
138}
139
140/// A texture's identity + on-disk source, in `TextureHandle` order. Now that
141/// Texture is a resource (no `source`/`asset_id` on a component the renderer
142/// drains), this is how a dev build hands the `cn debug` tools what they need: the
143/// hot-reload watcher maps `source` -> handle, and the runtime spawn-by-name path
144/// maps `name_id` -> handle. `source` is empty for a procedural texture (nothing
145/// to watch). `name_id` is the interned asset name (same interner the runtime
146/// shares in-process under `cn debug`), so nothing is interned at runtime.
147#[derive(Debug, Clone, Default, PartialEq)]
148pub struct TextureSourceInfo {
149    /// The interned asset name.
150    pub name_id: u32,
151    /// Authored source path; empty for a procedural texture.
152    pub source: String,
153    /// Index of the image within the source document.
154    pub image_index: u32,
155}
156
157/// A file-backed Mesh's re-import inputs, in `MeshHandle` order (the Mesh block
158/// leads the shared mesh-source handle space, so Mesh handles are dense from 0).
159/// Now that Mesh is a resource (no `source` on a component the renderer drains),
160/// this is how a dev build hands the `cn debug` hot-reload watcher what it needs
161/// to re-import a saved `.glb`/`.fbx`. `source` is empty for an inline-authored
162/// mesh (nothing to watch).
163#[derive(Debug, Clone, Default, PartialEq)]
164pub struct MeshSourceInfo {
165    /// Authored source path; empty for an inline-authored mesh.
166    pub source: String,
167    /// Index of the primitive within the source document.
168    pub primitive_index: u32,
169    /// How many LODs the mesh declares, including LOD0.
170    pub lod_levels: u32,
171    /// Camera distance at which each LOD past 0 takes over.
172    pub lod_distances: Vec<f32>,
173}
174
175/// The in-memory result of a complete build pipeline run.
176/// Defs have payload locators filled in; `payloads[i]` is the raw bytes for
177/// blob i. This can be used directly without touching disk.
178pub struct PipelineResult {
179    /// The compiled component defs, with payload locators filled in.
180    pub defs: Vec<BlobAssetDef>,
181    /// Asset name of each def, index-aligned with `defs` (defs only carry the
182    /// interned id; the lock file records the readable name).
183    pub names: Vec<String>,
184    /// The blob's resource stream: compiled resources addressed by their dense
185    /// per-kind handle, carried alongside the component defs. Empty until a
186    /// resource kind migrates off the component registry (AudioClip first).
187    pub resources: Vec<ResourceRecord>,
188    /// Per-scene exclusively-owned blob content, in scene declaration order.
189    pub scene_groups: Vec<SceneGroup>,
190    /// Baked AABB + counts per static mesh payload, by mesh-source handle.
191    pub mesh_bounds: Vec<MeshBoundsRecord>,
192    /// The world's physics reservation, or `None` when it runs no physics.
193    pub physics_budget: Option<PhysicsBudgetRecord>,
194    // Unified mesh-source handle -> asset name for mesh payloads compiled as
195    // component defs (ProceduralMesh and friends). Resource-stream Mesh
196    // handles lead the space and resolve through `resources`; these resolve
197    // through `names`/`defs`. Consumed by the thumbnail baker to compose a
198    // Model's sub-meshes.
199    pub(crate) mesh_component_names: Vec<(u32, String)>,
200    /// Raw bytes of each blob, indexed by blob number.
201    pub payloads: Vec<Vec<u8>>,
202    // Compiled-asset payloads served from the build cache this run.
203    pub(crate) cache_hits: usize,
204    // Compiled-asset payloads compiled fresh this run.
205    pub(crate) cache_misses: usize,
206    /// File-backed texture sources in `TextureHandle` order, for the `cn debug`
207    /// hot-reload watcher. Dev-only info; not written to the shipped blob.
208    pub texture_sources: Vec<TextureSourceInfo>,
209    /// File-backed mesh sources in `MeshHandle` order (dense over the Mesh block
210    /// of the shared mesh-source space), for the `cn debug` hot-reload watcher.
211    /// Dev-only info; not written to the shipped blob.
212    pub mesh_sources: Vec<MeshSourceInfo>,
213    // Lock-file provenance for the resource stream, index-aligned with
214    // `resources` (records only carry the kind tag + handle; the lock records
215    // the readable name and args hash).
216    pub(crate) resource_locks: Vec<crate::blob::LockedResource>,
217}
218
219impl PipelineResult {
220    /// The interned asset name of every compiled resource of `kind`, dense by
221    /// its per-kind handle: the identity a runtime that addresses resources by
222    /// handle has no other way to recover (a resource record carries its kind
223    /// and handle, not its name). 0 where the build recorded no id.
224    pub fn resource_names(&self, kind: ResourceKind) -> Vec<u32> {
225        let mut names = Vec::new();
226        for (record, lock) in self.resources.iter().zip(self.resource_locks.iter()) {
227            if record.resource_kind != kind as u8 {
228                continue;
229            }
230            let slot = record.handle as usize;
231            if names.len() <= slot {
232                names.resize(slot + 1, 0);
233            }
234            names[slot] = lock.id.unwrap_or_default();
235        }
236        names
237    }
238
239    /// The compiled payload bytes of the resource of `kind` declared under
240    /// `name`, sliced out of the in-memory blob sections. `None` when no such
241    /// resource was compiled or it carries no payload. The editor's glTF
242    /// export reads a SkinnedMesh's composed geometry through this.
243    pub fn resource_payload(&self, kind: ResourceKind, name: &str) -> Option<&[u8]> {
244        let record = self
245            .resources
246            .iter()
247            .zip(self.resource_locks.iter())
248            .find(|(r, l)| r.resource_kind == kind as u8 && l.name == name)?
249            .0;
250        let loc = record.payload.as_ref()?;
251        let blob = self.payloads.get(loc.blob_index as usize)?;
252        let start = usize::try_from(loc.offset).ok()?;
253        let end = start.checked_add(usize::try_from(loc.len).ok()?)?;
254        blob.get(start..end)
255    }
256}
257
258/// Validate a single asset's type and generator without running the full build
259/// pipeline. Called by the server on each world_add so the LLM gets per-asset
260/// feedback without waiting for a WebSocket round-trip.
261///
262/// Checks:
263///
264/// - asset type is registered (via `asset_api::create_asset_def`)
265/// - per-type structural checks via `crate::check`
266///
267/// Shader assets are not compiled here; use the validate_shader tool for that.
268pub fn validate_asset(
269    asset_type: &str,
270    name: &str,
271    args: &serde_json::Value,
272) -> Result<(), String> {
273    // Single-asset validation has no surrounding world to intern against; the
274    // resulting ids are throwaway. Reset so calls do not accumulate entries.
275    // Clear the resource handle map too: with no world there are no handles, so
276    // a resource reference falls back to the interner (parses without resolving
277    // to a real slot, which single-asset validation never needs).
278    asset_id::reset_interner();
279    crate::resource_handles::reset_resource_handles();
280    let type_norm = asset_type.to_lowercase().replace('_', "");
281
282    // Build-time types are valid in world.jsonl; they are consumed by expansion
283    // functions before the runtime asset registry sees them.
284    if matches!(
285        type_norm.as_str(),
286        "environment"
287            | "lightrig"
288            | "materialpalette"
289            | "camerashot"
290            | "prefab"
291            | "sceneimport"
292            | "characterschema"
293            | "charactermodel"
294    ) {
295        return Ok(());
296    }
297
298    // A resource asset never builds a component def; validate it as a known type
299    // with a structural check instead of routing through `create_asset_def`.
300    if crate::registry::RegisteredType::parse(asset_type).is_some_and(|t| t.is_resource()) {
301        crate::check::check_asset(&type_norm, name, args)?;
302        return Ok(());
303    }
304
305    let req = AssetRequest {
306        asset_type: asset_type.to_string(),
307        args: Some(args.clone()),
308    };
309    asset_api::create_asset_def(&req).map_err(|e| format!("Asset '{}': {}", name, e))?;
310
311    crate::check::check_asset(&type_norm, name, args)?;
312
313    Ok(())
314}
315
316/// Run the full build pipeline on an in-memory JSONL string without writing any
317/// blobs. Loads, expands, and validates the world (crate::world::prepare_world),
318/// then compiles it. `assets_dir` is the asset search root a bare `source`
319/// filename is searched under; `artifacts_dir` is an optional directory
320/// consulted when resolving bare shader filenames not found there, so pass the
321/// account's artifact directory to compile user-written shaders.
322pub fn build_pipeline_from_str(
323    content: &str,
324    assets_dir: Option<&Path>,
325    artifacts_dir: Option<&str>,
326) -> std::io::Result<PipelineResult> {
327    let loaded = crate::world::prepare_world(content, assets_dir).map_err(errors_to_io)?;
328    build_compiled(loaded.assets, assets_dir, artifacts_dir)
329}
330
331/// A progress report from the compile pipeline: the stage's name and its
332/// done / total counts. `total == 0` marks a stage that cannot count its work
333/// (progress there is indeterminate).
334#[derive(Debug, Clone, Copy)]
335pub struct BuildProgress {
336    /// The stage's name.
337    pub stage: &'static str,
338    /// Work completed in this stage.
339    pub done: u32,
340    /// Total work in this stage; 0 when the stage cannot count it.
341    pub total: u32,
342}
343
344/// Compile an already-prepared world (expanded + structurally and semantically
345/// validated) into in-memory blobs. This is the compile-only stage; it assumes
346/// the assets have passed crate::world::prepare_world, which should have been
347/// given the same `assets_dir`: an asset resolves its source the same way in
348/// both halves.
349pub fn build_compiled(
350    assets: Vec<WorldJsonlAsset>,
351    assets_dir: Option<&Path>,
352    artifacts_dir: Option<&str>,
353) -> std::io::Result<PipelineResult> {
354    build_compiled_with_progress(assets, assets_dir, artifacts_dir, None)
355}
356
357/// [`build_compiled`] with a progress callback. The callback fires from the
358/// desugar stage and, concurrently, from the parallel payload compile (hence
359/// `Sync`); it must be cheap and non-blocking.
360pub fn build_compiled_with_progress(
361    mut assets: Vec<WorldJsonlAsset>,
362    assets_dir: Option<&Path>,
363    artifacts_dir: Option<&str>,
364    progress: Option<&(dyn Fn(BuildProgress) + Sync)>,
365) -> std::io::Result<PipelineResult> {
366    if let Some(p) = progress {
367        p(BuildProgress {
368            stage: "desugar",
369            done: 0,
370            total: 0,
371        });
372    }
373
374    // Cache probe runs before desugar. For every glTF-sourced Mesh /
375    // SkinnedMesh, hash the un-desugared args + referenced .glb and look up
376    // the compiled payload by that key. On a hit, we hold the bytes and skip
377    // the .glb parse entirely (the original goal: an unchanged source file
378    // means no work). On a miss, the recorded key is used when the compile
379    // step stores the freshly produced payload, so the next build's probe
380    // can re-use it.
381    let mesh_cache = probe_mesh_payload_cache(&assets, assets_dir, artifacts_dir);
382
383    // Expand any glTF-sourced SkinnedMesh and Mesh assets into inline geometry
384    // before anything else looks at their args. Animations expand after the
385    // skinned-mesh pass so an importer that wanted to share state could read
386    // already-imported skeletons; today both passes parse the .glb fresh,
387    // but the ordering keeps that option open without an API churn.
388    desugar_gltf_skinned_meshes(&mut assets, &mesh_cache, assets_dir)?;
389    desugar_fbx_skinned_meshes(&mut assets, &mesh_cache)?;
390    desugar_gltf_meshes(&mut assets, &mesh_cache, assets_dir)?;
391    desugar_fbx_meshes(&mut assets, &mesh_cache)?;
392    desugar_animation_imports(&mut assets, assets_dir)?;
393    desugar_root_motion(&mut assets)?;
394    crate::character_shape::warn_unresolved(&assets);
395    crate::character::bake::bake_shapes(&mut assets, |name| {
396        mesh_cache.get(name).and_then(|e| e.bytes.as_deref())
397    })?;
398
399    // Intern every asset name to a dense AssetId in declaration order, then
400    // resolve the scene-by-naming-convention references that the runtime can
401    asset_id::reset_interner();
402    let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect();
403    asset_id::intern_all(&names);
404    resolve_scene_refs(&mut assets);
405
406    // Assign each resource its dense per-kind handle in declaration order and
407    // install the map so resource references resolve during the reserialize pass
408    // below: texture references (Material.albedo, Room.*_texture,
409    // Decal/ParticleEmitter.texture) to a `TextureHandle`, and audio-clip
410    // references (AudioEmitter.clip, AudioCue.clip, Story music/sounds) to an
411    // `AudioClipHandle`. The assignment walks this same `assets` list that the
412    // blob is emitted from, so a resource's handle equals the position the
413    // runtime encounters it (a texture's albedo pool slot, an audio clip's drain
414    // index / resource-table slot).
415    crate::resource_handles::reset_resource_handles();
416    let resource_assets = assets.iter().filter_map(|a| {
417        crate::resource_handles::asset_resource_kind(&a.asset_type)
418            .map(|kind| (asset_id::intern(&a.name), kind))
419    });
420    let mut resource_handles =
421        crate::resource_handles::ResourceHandles::from_assets(resource_assets);
422    // The mesh-source handle space spans four kinds (Mesh, ProceduralMesh,
423    // VoxelChunk, mesh-kind File) and File is polymorphic, so it is assigned in a
424    // second pass in the fixed block order the runtime enumerates mesh sources
425    // rather than through the per-type classifier above.
426    crate::resource_handles::assign_mesh_source_handles(&mut resource_handles, &assets);
427    // Shader handles walk the same list, so a Material's `shader` reference
428    // bakes to the position the runtime encounters that Shader at drain time.
429    crate::resource_handles::assign_shader_handles(&mut resource_handles, &assets);
430    // Install a clone; the original is kept to look up each resource asset's
431    // handle while partitioning below.
432    crate::resource_handles::install_resource_handles(resource_handles.clone());
433
434    // Partition the world into component assets (each becomes a `BlobAssetDef`)
435    // and resource assets (each becomes a resource-stream record). A resource
436    // asset (AudioClip) has left the component registry, so it never goes through
437    // `create_asset_def`; it is compiled + packed as a resource below. `named` is
438    // therefore no longer 1:1 with `assets`, so `named_src[i]` records the source
439    // asset index of each component def.
440    use crate::registry::RegisteredType;
441    let mut named: Vec<(String, BlobAssetDef)> = Vec::new();
442    let mut named_src: Vec<usize> = Vec::new();
443    let mut resource_jobs: Vec<(usize, RegisteredType, u32)> = Vec::new();
444    for (i, asset) in assets.iter().enumerate() {
445        if let Some((rt, kind)) =
446            RegisteredType::parse(&asset.asset_type).and_then(|t| t.resource_kind().map(|k| (t, k)))
447        {
448            let id = asset_id::intern(&asset.name);
449            let handle = resource_handles
450                .get(kind, id)
451                .expect("resource asset was assigned a handle above");
452            resource_jobs.push((i, rt, handle));
453            continue;
454        }
455        let req = AssetRequest {
456            asset_type: asset.asset_type.clone(),
457            args: Some(asset.args.clone()),
458        };
459        let mut def = asset_api::create_asset_def(&req).map_err(|e| {
460            std::io::Error::new(
461                std::io::ErrorKind::InvalidData,
462                format!("Asset '{}': {}", asset.name, e),
463            )
464        })?;
465        def.name = Some(asset_id::intern(&asset.name));
466        named.push((asset.name.clone(), def));
467        named_src.push(i);
468    }
469
470    // Dev-only: the file source behind each texture handle, so `cn debug`'s
471    // hot-reload watcher can map a saved file back to its handle. Built in
472    // handle order from the same resource jobs; a procedural texture (generator
473    // set) leaves an empty source (nothing to watch).
474    let texture_count = resource_jobs
475        .iter()
476        .filter(|(_, rt, _)| *rt == RegisteredType::Texture)
477        .map(|(_, _, h)| *h as usize + 1)
478        .max()
479        .unwrap_or(0);
480    let mut texture_sources = vec![TextureSourceInfo::default(); texture_count];
481    for (asset_idx, rt, handle) in &resource_jobs {
482        if *rt != RegisteredType::Texture {
483            continue;
484        }
485        let asset = &assets[*asset_idx];
486        let generator = asset
487            .args
488            .get("generator")
489            .and_then(|v| v.as_str())
490            .unwrap_or("");
491        let (source, image_index) = if generator.is_empty() {
492            (
493                asset
494                    .args
495                    .get("source")
496                    .and_then(|v| v.as_str())
497                    .unwrap_or("")
498                    .to_string(),
499                asset
500                    .args
501                    .get("image_index")
502                    .and_then(|v| v.as_u64())
503                    .unwrap_or(0) as u32,
504            )
505        } else {
506            (String::new(), 0)
507        };
508        texture_sources[*handle as usize] = TextureSourceInfo {
509            name_id: asset_id::intern(&asset.name).0,
510            source,
511            image_index,
512        };
513    }
514
515    // Dev-only: the file source behind each mesh handle, so `cn debug`'s
516    // hot-reload watcher can re-import a saved `.glb`/`.fbx` into its draw
517    // slots. Mesh handles are dense from 0 (the Mesh block leads the shared
518    // mesh-source space); an inline-authored mesh leaves an empty source.
519    let mesh_count = resource_jobs
520        .iter()
521        .filter(|(_, rt, _)| *rt == RegisteredType::Mesh)
522        .map(|(_, _, h)| *h as usize + 1)
523        .max()
524        .unwrap_or(0);
525    let mut mesh_sources = vec![MeshSourceInfo::default(); mesh_count];
526    for (asset_idx, rt, handle) in &resource_jobs {
527        if *rt != RegisteredType::Mesh {
528            continue;
529        }
530        let args = &assets[*asset_idx].args;
531        let str_arg = |key: &str| {
532            args.get(key)
533                .and_then(|v| v.as_str())
534                .unwrap_or("")
535                .to_string()
536        };
537        let u32_arg = |key: &str, default: u32| {
538            args.get(key)
539                .and_then(|v| v.as_u64())
540                .unwrap_or(default as u64) as u32
541        };
542        mesh_sources[*handle as usize] = MeshSourceInfo {
543            source: str_arg("source"),
544            primitive_index: u32_arg("primitive_index", 0),
545            lod_levels: u32_arg("lod_levels", 1),
546            lod_distances: args
547                .get("lod_distances")
548                .and_then(|v| v.as_array())
549                .map(|a| {
550                    a.iter()
551                        .filter_map(|d| d.as_f64())
552                        .map(|d| d as f32)
553                        .collect()
554                })
555                .unwrap_or_default(),
556        };
557    }
558
559    // Scene payload ownership, derived from the resolved scene memberships and
560    // the reference graph; drives the grouped packing below.
561    let partition = crate::scene_partition::partition_scenes(&assets);
562
563    // The world's physics reservation, counted from the same fully expanded
564    // asset list the blob is emitted from.
565    let physics_budget = crate::physics_budget::compute(&assets);
566    crate::physics_budget::report_spawn_reservation(&assets);
567
568    let compiled = compile_and_pack_payloads(
569        &mut named,
570        &named_src,
571        PackContext {
572            assets: &assets,
573            resource_jobs: &resource_jobs,
574            partition: &partition,
575            mesh_source_handles: &resource_handles,
576            max_blob_bytes: crate::blob::DEFAULT_MAX_BLOB_BYTES,
577            assets_dir,
578            artifacts_dir,
579            mesh_cache: &mesh_cache,
580            progress,
581        },
582    )?;
583
584    // Lock-file provenance for the resource stream: `compiled.resources` is
585    // emitted in `resource_jobs` order, so the two zip index-aligned. Texture
586    // and Mesh records also carry their hot-reload source info so a blob boot
587    // can reconstruct the catalogues without the authored args.
588    let resource_locks: Vec<crate::blob::LockedResource> = resource_jobs
589        .iter()
590        .zip(compiled.resources.iter())
591        .map(|((asset_idx, rt, handle), record)| {
592            let asset = &assets[*asset_idx];
593            crate::blob::LockedResource {
594                name: asset.name.clone(),
595                // Already interned by the declaration-order pass above, so
596                // this is a lookup of the id the build assigned.
597                id: Some(asset_id::intern(&asset.name).0),
598                kind: rt.as_str().to_string(),
599                handle: *handle,
600                args_hash: crate::blob::checksum(asset.args.to_string().as_bytes()),
601                payload_blob: record.payload.as_ref().map(|p| p.blob_index),
602                texture_source: (*rt == RegisteredType::Texture).then(|| {
603                    let t = &texture_sources[*handle as usize];
604                    crate::blob::LockedTextureSource {
605                        source: t.source.clone(),
606                        image_index: t.image_index,
607                    }
608                }),
609                mesh_source: (*rt == RegisteredType::Mesh).then(|| {
610                    let m = &mesh_sources[*handle as usize];
611                    crate::blob::LockedMeshSource {
612                        source: m.source.clone(),
613                        primitive_index: m.primitive_index,
614                        lod_levels: m.lod_levels,
615                        lod_distances: m.lod_distances.clone(),
616                    }
617                }),
618            }
619        })
620        .collect();
621
622    // The blob carries components (emitted in declaration order) plus the
623    // resource stream. (System run order is no longer a build concern: every
624    // system is internal client code ordered by the client's
625    // `World::start` schedule.)
626    let (names, defs): (Vec<String>, Vec<BlobAssetDef>) = named.into_iter().unzip();
627
628    Ok(PipelineResult {
629        defs,
630        names,
631        resources: compiled.resources,
632        scene_groups: compiled.scene_groups,
633        mesh_bounds: compiled.mesh_bounds,
634        physics_budget,
635        mesh_component_names: compiled.mesh_component_names,
636        payloads: compiled.blobs,
637        cache_hits: compiled.cache_hits,
638        cache_misses: compiled.cache_misses,
639        texture_sources,
640        mesh_sources,
641        resource_locks,
642    })
643}
644
645// Per-asset state recorded by `probe_mesh_payload_cache`. `key` is the cache key
646// computed from the asset's pre-desugar args; `bytes` is `Some` when the
647// cache already held a compiled payload for that key. On a hit, the desugar
648// pass skips the .glb parse for this asset; on a miss, compile_and_pack
649// stores the freshly compiled payload under the same `key` so the next
650// build's probe can re-use it.
651#[derive(Clone)]
652struct MeshCacheEntry {
653    key: String,
654    bytes: Option<Vec<u8>>,
655}
656
657// Hash every source-backed Mesh / SkinnedMesh asset's pre-desugar args and
658// referenced source file (`.glb` or `.fbx`), then probe the content-addressed
659// payload cache. Returns one entry per source-backed asset name. Assets
660// without a `source` are not probed: their args don't depend on a file, so the
661// regular per-asset cache path inside compile_and_pack_payloads is sufficient.
662fn probe_mesh_payload_cache(
663    assets: &[WorldJsonlAsset],
664    assets_dir: Option<&Path>,
665    artifacts_dir: Option<&str>,
666) -> std::collections::HashMap<String, MeshCacheEntry> {
667    use crate::resource_handles::{RegisteredType, ResourceAssetCompile};
668
669    let mut out = std::collections::HashMap::new();
670    let empty: [WorldJsonlAsset; 0] = [];
671    for asset in assets {
672        let has_source = asset
673            .args
674            .get("source")
675            .and_then(|v| v.as_str())
676            .map(|s| !s.is_empty())
677            .unwrap_or(false);
678        if !has_source && asset.args.get("character_model").is_none() {
679            continue;
680        }
681
682        // Both mesh kinds are resource assets: their caches key on the
683        // resource discriminant and resource source list.
684        let rt = if asset.asset_type == MESH_TYPE {
685            RegisteredType::Mesh
686        } else if asset.asset_type == SKINNED_MESH_TYPE {
687            RegisteredType::SkinnedMesh
688        } else {
689            continue;
690        };
691        let ctx = crate::asset::BuildCtx {
692            name: asset.name.as_str(),
693            assets_dir,
694            artifacts_dir,
695            all_assets: &empty,
696        };
697        let discriminant = RESOURCE_CACHE_DISC_BASE + job_resource_kind(rt) as u8;
698        let inputs = crate::asset::CacheInputs::extra(rt.source_files(&asset.args, assets_dir));
699        // A shape baked into the mesh changes its payload as much as the
700        // source does, so its args join the key.
701        let keyed = match crate::character::bake::baking_shape_args(assets, &asset.name) {
702            Some(shape) => serde_json::json!({"mesh": asset.args, "baked_shape": shape}),
703            None => asset.args.clone(),
704        };
705        let key = crate::cache::payload_key(discriminant, &keyed, &ctx, &inputs);
706        let bytes = crate::cache::load(&key);
707        out.insert(asset.name.clone(), MeshCacheEntry { key, bytes });
708    }
709    out
710}
711
712// Which skinned mesh of the asset's source file it selects; absent means the
713// file's first.
714fn skin_index_arg(asset: &WorldJsonlAsset) -> u32 {
715    asset
716        .args
717        .get("skin_index")
718        .and_then(|v| v.as_u64())
719        .unwrap_or(0) as u32
720}
721
722// Skin selector per SkinnedMesh asset name. An Animation resolves its channels
723// against its target's skeleton, so it inherits the target's selector rather
724// than carrying its own: the two must agree or joint indices bind to a
725// different skeleton and the clip silently mis-poses.
726fn skin_index_by_target(assets: &[WorldJsonlAsset]) -> std::collections::HashMap<String, u32> {
727    assets
728        .iter()
729        .filter(|a| a.asset_type == SKINNED_MESH_TYPE)
730        .map(|a| (a.name.clone(), skin_index_arg(a)))
731        .collect()
732}
733
734// Expand glTF-sourced SkinnedMesh assets in place: parse the referenced .glb
735// and write the imported geometry + skeleton into the asset's inline
736// `vertices` / `indices` / `skeleton` args. A SkinnedMesh with no `source` is
737// left untouched, so an inline-authored mesh is byte-for-byte unchanged;
738// `.fbx` sources belong to `desugar_fbx_skinned_meshes`.
739// Skips an asset whose cache probe found a precompiled payload: there is no
740// reason to parse the .glb when the bytes are already in hand.
741fn desugar_gltf_skinned_meshes(
742    assets: &mut [WorldJsonlAsset],
743    mesh_cache: &std::collections::HashMap<String, MeshCacheEntry>,
744    assets_dir: Option<&Path>,
745) -> std::io::Result<()> {
746    for asset in assets.iter_mut() {
747        if asset.asset_type != SKINNED_MESH_TYPE {
748            continue;
749        }
750        let source = asset
751            .args
752            .get("source")
753            .and_then(|v| v.as_str())
754            .unwrap_or("")
755            .to_string();
756        let character_model = asset.args.get("character_model").cloned();
757        if character_model.is_none()
758            && (source.is_empty() || source.to_lowercase().ends_with(".fbx"))
759        {
760            continue;
761        }
762        // Cache probe found a compiled payload for this asset, no need
763        // to parse the .glb. compile_and_pack_payloads will use the bytes
764        // directly. Leave the args un-desugared so they keep matching the
765        // pre-desugar cache key on the next build.
766        if matches!(
767            mesh_cache.get(&asset.name),
768            Some(MeshCacheEntry { bytes: Some(_), .. })
769        ) {
770            continue;
771        }
772
773        let invalid = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
774        let imported = match character_model {
775            Some(arg) => {
776                let arg: crate::character::import::CharacterModelArg = serde_json::from_value(arg)
777                    .map_err(|e| {
778                        invalid(format!("Asset '{}': character_model: {e}", asset.name))
779                    })?;
780                crate::character::import::import_model(
781                    &asset.name,
782                    &arg.schema,
783                    &arg.model,
784                    assets_dir,
785                )
786                .map_err(invalid)?
787            }
788            None => crate::gltf::import_skinned_glb(&source, skin_index_arg(asset), assets_dir)
789                .map_err(|e| {
790                    invalid(format!("Asset '{}': glTF import failed: {}", asset.name, e))
791                })?,
792        };
793
794        let name = asset.name.clone();
795        let obj = asset.args.as_object_mut().ok_or_else(|| {
796            std::io::Error::new(
797                std::io::ErrorKind::InvalidData,
798                format!("Asset '{}': args is not a JSON object", name),
799            )
800        })?;
801        let encode = |field: &str, value: serde_json::Result<serde_json::Value>| {
802            value.map_err(|e| {
803                std::io::Error::new(
804                    std::io::ErrorKind::InvalidData,
805                    format!(
806                        "Asset '{}': failed to encode imported {}: {}",
807                        name, field, e
808                    ),
809                )
810            })
811        };
812        obj.insert(
813            "vertices".to_string(),
814            encode("vertices", serde_json::to_value(&imported.vertices))?,
815        );
816        obj.insert(
817            "indices".to_string(),
818            encode("indices", serde_json::to_value(&imported.indices))?,
819        );
820        obj.insert(
821            "skeleton".to_string(),
822            encode("skeleton", serde_json::to_value(&imported.skeleton))?,
823        );
824        if !imported.morph_target_names.is_empty() {
825            obj.insert(
826                "morph_target_names".to_string(),
827                encode(
828                    "morph_target_names",
829                    serde_json::to_value(&imported.morph_target_names),
830                )?,
831            );
832            obj.insert(
833                "morph_deltas".to_string(),
834                encode("morph_deltas", serde_json::to_value(&imported.morph_deltas))?,
835            );
836        }
837        obj.remove("character_model");
838        tracing::info!(
839            "Asset '{}': imported glTF '{}': {} vertices, {} indices, {} joints, {} morph target(s)",
840            asset.name,
841            if source.is_empty() {
842                "character model"
843            } else {
844                &source
845            },
846            imported.vertices.len(),
847            imported.indices.len(),
848            imported.skeleton.len(),
849            imported.morph_target_names.len()
850        );
851    }
852    Ok(())
853}
854
855// Expand FBX-sourced SkinnedMesh assets in place, mirroring the glTF pass:
856// the file's first skinned geometry lands in the asset's inline `vertices` /
857// `indices` / `skeleton` args.
858fn desugar_fbx_skinned_meshes(
859    assets: &mut [WorldJsonlAsset],
860    mesh_cache: &std::collections::HashMap<String, MeshCacheEntry>,
861) -> std::io::Result<()> {
862    for asset in assets.iter_mut() {
863        if asset.asset_type != SKINNED_MESH_TYPE {
864            continue;
865        }
866        let source = asset
867            .args
868            .get("source")
869            .and_then(|v| v.as_str())
870            .unwrap_or("")
871            .to_string();
872        if !source.to_lowercase().ends_with(".fbx") {
873            continue;
874        }
875        if matches!(
876            mesh_cache.get(&asset.name),
877            Some(MeshCacheEntry { bytes: Some(_), .. })
878        ) {
879            continue;
880        }
881
882        let imported =
883            crate::fbx::import_skinned_fbx(&source, skin_index_arg(asset)).map_err(|e| {
884                std::io::Error::new(
885                    std::io::ErrorKind::InvalidData,
886                    format!("Asset '{}': FBX import failed: {}", asset.name, e),
887                )
888            })?;
889
890        let name = asset.name.clone();
891        let obj = asset.args.as_object_mut().ok_or_else(|| {
892            std::io::Error::new(
893                std::io::ErrorKind::InvalidData,
894                format!("Asset '{}': args is not a JSON object", name),
895            )
896        })?;
897        let encode = |field: &str, value: serde_json::Result<serde_json::Value>| {
898            value.map_err(|e| {
899                std::io::Error::new(
900                    std::io::ErrorKind::InvalidData,
901                    format!(
902                        "Asset '{}': failed to encode imported {}: {}",
903                        name, field, e
904                    ),
905                )
906            })
907        };
908        obj.insert(
909            "vertices".to_string(),
910            encode("vertices", serde_json::to_value(&imported.vertices))?,
911        );
912        obj.insert(
913            "indices".to_string(),
914            encode("indices", serde_json::to_value(&imported.indices))?,
915        );
916        obj.insert(
917            "skeleton".to_string(),
918            encode("skeleton", serde_json::to_value(&imported.skeleton))?,
919        );
920        tracing::info!(
921            "Asset '{}': imported FBX '{}': {} vertices, {} indices, {} joints",
922            asset.name,
923            source,
924            imported.vertices.len(),
925            imported.indices.len(),
926            imported.skeleton.len()
927        );
928    }
929    Ok(())
930}
931
932// Expand glTF-sourced static `Mesh` assets in place: parse the referenced
933// `.glb` and write the imported primitive geometry into the asset's inline
934// `vertices` / `indices` args. A Mesh with no `source` is left untouched. The
935// GLB is parsed once per unique path; ABeautifulGame fans 35+ Mesh assets out
936// of one file, so memoization keeps this O(files) rather than O(primitives).
937fn desugar_gltf_meshes(
938    assets: &mut [WorldJsonlAsset],
939    mesh_cache: &std::collections::HashMap<String, MeshCacheEntry>,
940    assets_dir: Option<&Path>,
941) -> std::io::Result<()> {
942    use crate::components::VertexData;
943    use std::collections::HashMap;
944
945    // One split chunk: its vertices and index buffer.
946    type Chunk = (Vec<VertexData>, Vec<u16>);
947
948    let mut parsed_cache: HashMap<String, crate::gltf_source::GltfDoc> = HashMap::new();
949    // Memoize the chunk split per (source, primitive_index) so an oversized
950    // primitive that fans into N chunked Mesh assets is split exactly once.
951    let mut chunk_cache: HashMap<(String, u32), Vec<Chunk>> = HashMap::new();
952
953    for asset in assets.iter_mut() {
954        if asset.asset_type != MESH_TYPE {
955            continue;
956        }
957        let source = asset
958            .args
959            .get("source")
960            .and_then(|v| v.as_str())
961            .unwrap_or("")
962            .to_string();
963        if source.is_empty() {
964            continue;
965        }
966        // `.fbx` sources are handled by `desugar_fbx_meshes`; this pass owns
967        // only the glTF containers.
968        let lower = source.to_lowercase();
969        if !lower.ends_with(".glb") && !lower.ends_with(".gltf") {
970            continue;
971        }
972        // Skip the .glb parse when the cache probe already produced bytes
973        // for this asset (see `desugar_gltf_skinned_meshes` for the same
974        // pattern). Args stay pre-desugar so the next build's probe hits.
975        if matches!(
976            mesh_cache.get(&asset.name),
977            Some(MeshCacheEntry { bytes: Some(_), .. })
978        ) {
979            continue;
980        }
981        let primitive_index = asset
982            .args
983            .get("primitive_index")
984            .and_then(|v| v.as_u64())
985            .unwrap_or(0) as u32;
986        let chunk_index = asset
987            .args
988            .get("chunk_index")
989            .and_then(|v| v.as_u64())
990            .map(|n| n as usize);
991
992        if !parsed_cache.contains_key(&source) {
993            let doc = crate::glb::parse_glb(&source, assets_dir).map_err(|e| {
994                std::io::Error::new(
995                    std::io::ErrorKind::InvalidData,
996                    format!("Asset '{}': glTF import failed: {}", asset.name, e),
997                )
998            })?;
999            parsed_cache.insert(source.clone(), doc);
1000        }
1001        let doc = parsed_cache.get(&source).expect("just inserted");
1002
1003        let (vertices, indices) = if let Some(chunk_idx) = chunk_index {
1004            let key = (source.clone(), primitive_index);
1005            if !chunk_cache.contains_key(&key) {
1006                let (verts, indices32) =
1007                    crate::glb::read_primitive_geometry(doc, &source, primitive_index).map_err(
1008                        |e| {
1009                            std::io::Error::new(
1010                                std::io::ErrorKind::InvalidData,
1011                                format!("Asset '{}': glTF import failed: {}", asset.name, e),
1012                            )
1013                        },
1014                    )?;
1015                let chunks = crate::glb::split_into_u16_chunks(&verts, &indices32);
1016                chunk_cache.insert(key.clone(), chunks);
1017            }
1018            let chunks = chunk_cache.get(&key).expect("just inserted");
1019            let chunk = chunks.get(chunk_idx).ok_or_else(|| {
1020                std::io::Error::new(
1021                    std::io::ErrorKind::InvalidData,
1022                    format!(
1023                        "Asset '{}': chunk_index {} out of range, '{}' primitive {} \
1024                         splits into {} chunk(s)",
1025                        asset.name,
1026                        chunk_idx,
1027                        source,
1028                        primitive_index,
1029                        chunks.len(),
1030                    ),
1031                )
1032            })?;
1033            chunk.clone()
1034        } else {
1035            crate::glb::import_static_glb_primitive_from_doc(doc, &source, primitive_index)
1036                .map_err(|e| {
1037                    std::io::Error::new(
1038                        std::io::ErrorKind::InvalidData,
1039                        format!("Asset '{}': glTF import failed: {}", asset.name, e),
1040                    )
1041                })?
1042        };
1043
1044        let name = asset.name.clone();
1045        let obj = asset.args.as_object_mut().ok_or_else(|| {
1046            std::io::Error::new(
1047                std::io::ErrorKind::InvalidData,
1048                format!("Asset '{}': args is not a JSON object", name),
1049            )
1050        })?;
1051        let encode = |field: &str, value: serde_json::Result<serde_json::Value>| {
1052            value.map_err(|e| {
1053                std::io::Error::new(
1054                    std::io::ErrorKind::InvalidData,
1055                    format!(
1056                        "Asset '{}': failed to encode imported {}: {}",
1057                        name, field, e
1058                    ),
1059                )
1060            })
1061        };
1062        let vlen = vertices.len();
1063        let ilen = indices.len();
1064        obj.insert(
1065            "vertices".to_string(),
1066            encode("vertices", serde_json::to_value(&vertices))?,
1067        );
1068        obj.insert(
1069            "indices".to_string(),
1070            encode("indices", serde_json::to_value(&indices))?,
1071        );
1072        match chunk_index {
1073            Some(c) => tracing::info!(
1074                "Asset '{}': imported glTF '{}' primitive {} chunk {}: {} vertices, {} indices",
1075                asset.name,
1076                source,
1077                primitive_index,
1078                c,
1079                vlen,
1080                ilen,
1081            ),
1082            None => tracing::info!(
1083                "Asset '{}': imported glTF '{}' primitive {}: {} vertices, {} indices",
1084                asset.name,
1085                source,
1086                primitive_index,
1087                vlen,
1088                ilen,
1089            ),
1090        }
1091    }
1092    Ok(())
1093}
1094
1095// Expand FBX-sourced Mesh assets in place: parse the `.fbx` into an FbxScene
1096// and write the imported geometry into each asset's inline `vertices` /
1097// `indices` args, keyed by `primitive_index` and optional `chunk_index`. A Mesh
1098// whose source is not a `.fbx` is left to `desugar_gltf_meshes`. The FBX is
1099// parsed once per unique path (Bistro fans thousands of Mesh assets out of one
1100// file) and each primitive's u16 chunk split is memoized.
1101fn desugar_fbx_meshes(
1102    assets: &mut [WorldJsonlAsset],
1103    mesh_cache: &std::collections::HashMap<String, MeshCacheEntry>,
1104) -> std::io::Result<()> {
1105    use crate::components::VertexData;
1106    use crate::fbx::FbxScene;
1107    use std::collections::HashMap;
1108
1109    type Chunk = (Vec<VertexData>, Vec<u16>);
1110
1111    let mut parsed_cache: HashMap<String, FbxScene> = HashMap::new();
1112    let mut chunk_cache: HashMap<(String, u32), Vec<Chunk>> = HashMap::new();
1113
1114    for asset in assets.iter_mut() {
1115        if asset.asset_type != MESH_TYPE {
1116            continue;
1117        }
1118        let source = asset
1119            .args
1120            .get("source")
1121            .and_then(|v| v.as_str())
1122            .unwrap_or("")
1123            .to_string();
1124        if !source.to_lowercase().ends_with(".fbx") {
1125            continue;
1126        }
1127        // Honour the same content-addressed cache the glTF pass uses: a probe
1128        // hit means the compiled payload is already in hand, so skip the parse.
1129        if matches!(
1130            mesh_cache.get(&asset.name),
1131            Some(MeshCacheEntry { bytes: Some(_), .. })
1132        ) {
1133            continue;
1134        }
1135        let primitive_index = asset
1136            .args
1137            .get("primitive_index")
1138            .and_then(|v| v.as_u64())
1139            .unwrap_or(0) as u32;
1140        let chunk_index = asset
1141            .args
1142            .get("chunk_index")
1143            .and_then(|v| v.as_u64())
1144            .map(|n| n as usize)
1145            .unwrap_or(0);
1146
1147        if !parsed_cache.contains_key(&source) {
1148            let scene = crate::fbx::parse_fbx(&source).map_err(|e| {
1149                std::io::Error::new(
1150                    std::io::ErrorKind::InvalidData,
1151                    format!("Asset '{}': FBX import failed: {}", asset.name, e),
1152                )
1153            })?;
1154            parsed_cache.insert(source.clone(), scene);
1155        }
1156        let scene = parsed_cache.get(&source).expect("just inserted");
1157
1158        let key = (source.clone(), primitive_index);
1159        if !chunk_cache.contains_key(&key) {
1160            let (verts, indices32) = crate::fbx::read_primitive_geometry(scene, primitive_index)
1161                .map_err(|e| {
1162                    std::io::Error::new(
1163                        std::io::ErrorKind::InvalidData,
1164                        format!("Asset '{}': FBX import failed: {}", asset.name, e),
1165                    )
1166                })?;
1167            let chunks = crate::glb::split_into_u16_chunks(&verts, &indices32);
1168            chunk_cache.insert(key.clone(), chunks);
1169        }
1170        let chunks = chunk_cache.get(&key).expect("just inserted");
1171        let chunk = chunks.get(chunk_index).ok_or_else(|| {
1172            std::io::Error::new(
1173                std::io::ErrorKind::InvalidData,
1174                format!(
1175                    "Asset '{}': chunk_index {} out of range, '{}' primitive {} splits into {} chunk(s)",
1176                    asset.name,
1177                    chunk_index,
1178                    source,
1179                    primitive_index,
1180                    chunks.len(),
1181                ),
1182            )
1183        })?;
1184        let (vertices, indices) = chunk.clone();
1185
1186        let name = asset.name.clone();
1187        let obj = asset.args.as_object_mut().ok_or_else(|| {
1188            std::io::Error::new(
1189                std::io::ErrorKind::InvalidData,
1190                format!("Asset '{}': args is not a JSON object", name),
1191            )
1192        })?;
1193        let vlen = vertices.len();
1194        let ilen = indices.len();
1195        obj.insert(
1196            "vertices".to_string(),
1197            serde_json::to_value(&vertices).map_err(|e| {
1198                std::io::Error::new(
1199                    std::io::ErrorKind::InvalidData,
1200                    format!(
1201                        "Asset '{}': failed to encode imported vertices: {}",
1202                        name, e
1203                    ),
1204                )
1205            })?,
1206        );
1207        obj.insert(
1208            "indices".to_string(),
1209            serde_json::to_value(&indices).map_err(|e| {
1210                std::io::Error::new(
1211                    std::io::ErrorKind::InvalidData,
1212                    format!("Asset '{}': failed to encode imported indices: {}", name, e),
1213                )
1214            })?,
1215        );
1216        tracing::info!(
1217            "Asset '{}': imported FBX '{}' primitive {} chunk {}: {} vertices, {} indices",
1218            asset.name,
1219            source,
1220            primitive_index,
1221            chunk_index,
1222            vlen,
1223            ilen,
1224        );
1225    }
1226    Ok(())
1227}
1228
1229// Expand file-sourced `Animation` assets in place, dispatching on the source
1230// extension: `.fbx` clips bake through the FBX importer (at the asset's
1231// `sample_rate`), everything else parses as glTF. The clip is picked by
1232// `animation_name` (preferred) or `animation_index` and the asset's
1233// `duration` + `tracks` are replaced with the imported data. An Animation
1234// with no `source` is left untouched, so inline-authored clips are
1235// byte-for-byte unchanged. Channels targeting non-joint nodes are dropped
1236// silently by the importers.
1237fn desugar_animation_imports(
1238    assets: &mut [WorldJsonlAsset],
1239    assets_dir: Option<&Path>,
1240) -> std::io::Result<()> {
1241    use crate::components::Animation;
1242    use crate::ecs::Component;
1243
1244    let skin_by_target = skin_index_by_target(assets);
1245
1246    for asset in assets.iter_mut() {
1247        if asset.asset_type != Animation::NAME {
1248            continue;
1249        }
1250        let source = asset
1251            .args
1252            .get("source")
1253            .and_then(|v| v.as_str())
1254            .unwrap_or("")
1255            .to_string();
1256        if source.is_empty() {
1257            continue;
1258        }
1259        let animation_name = asset
1260            .args
1261            .get("animation_name")
1262            .and_then(|v| v.as_str())
1263            .unwrap_or("")
1264            .to_string();
1265        let animation_index = asset
1266            .args
1267            .get("animation_index")
1268            .and_then(|v| v.as_u64())
1269            .unwrap_or(0) as usize;
1270        let skin_index = asset
1271            .args
1272            .get("target")
1273            .and_then(|v| v.as_str())
1274            .and_then(|t| skin_by_target.get(t))
1275            .copied()
1276            .unwrap_or(0);
1277
1278        let imported = if source.to_lowercase().ends_with(".fbx") {
1279            let sample_rate = asset
1280                .args
1281                .get("sample_rate")
1282                .and_then(|v| v.as_f64())
1283                .unwrap_or(30.0) as f32;
1284            crate::fbx::import_fbx_animation(
1285                &source,
1286                animation_index as u32,
1287                &animation_name,
1288                sample_rate,
1289                skin_index,
1290            )
1291            .map_err(|e| {
1292                std::io::Error::new(
1293                    std::io::ErrorKind::InvalidData,
1294                    format!("Asset '{}': FBX import failed: {}", asset.name, e),
1295                )
1296            })?
1297        } else {
1298            // Look up by name when authored; fall back to the numeric index.
1299            let resolved_index = if !animation_name.is_empty() {
1300                let names = crate::gltf::glb_animation_names(&source, assets_dir).map_err(|e| {
1301                    std::io::Error::new(
1302                        std::io::ErrorKind::InvalidData,
1303                        format!("Asset '{}': glTF import failed: {}", asset.name, e),
1304                    )
1305                })?;
1306                names
1307                    .iter()
1308                    .position(|n| n == &animation_name)
1309                    .ok_or_else(|| {
1310                        std::io::Error::new(
1311                            std::io::ErrorKind::InvalidData,
1312                            format!(
1313                                "Asset '{}': glTF '{}' has no animation named '{}' \
1314                                 (file contains: {:?})",
1315                                asset.name, source, animation_name, names
1316                            ),
1317                        )
1318                    })?
1319            } else {
1320                animation_index
1321            };
1322
1323            crate::gltf::import_glb_animation(&source, resolved_index, skin_index, assets_dir)
1324                .map_err(|e| {
1325                    std::io::Error::new(
1326                        std::io::ErrorKind::InvalidData,
1327                        format!("Asset '{}': glTF import failed: {}", asset.name, e),
1328                    )
1329                })?
1330        };
1331
1332        // Convert ImportedAnimation -> the asset's serialised track shape.
1333        let tracks_json: Vec<serde_json::Value> = imported
1334            .tracks
1335            .iter()
1336            .map(|track| {
1337                let keyframes: Vec<serde_json::Value> = track
1338                    .keys
1339                    .iter()
1340                    .map(|k| {
1341                        serde_json::json!({
1342                            "time": k.time,
1343                            "translation": k.pose.translation,
1344                            "rotation_deg": k.pose.rotation_deg,
1345                            "scale": k.pose.scale,
1346                        })
1347                    })
1348                    .collect();
1349                serde_json::json!({
1350                    "joint": track.joint,
1351                    "keyframes": keyframes,
1352                })
1353            })
1354            .collect();
1355
1356        let name = asset.name.clone();
1357        let obj = asset.args.as_object_mut().ok_or_else(|| {
1358            std::io::Error::new(
1359                std::io::ErrorKind::InvalidData,
1360                format!("Asset '{}': args is not a JSON object", name),
1361            )
1362        })?;
1363        obj.insert("duration".to_string(), serde_json::json!(imported.duration));
1364        obj.insert("tracks".to_string(), serde_json::Value::Array(tracks_json));
1365        if !imported.morph_track.is_empty() {
1366            let morph_json: Vec<serde_json::Value> = imported
1367                .morph_track
1368                .iter()
1369                .map(|k| serde_json::json!({"time": k.time, "weights": k.weights}))
1370                .collect();
1371            obj.insert(
1372                "morph_track".to_string(),
1373                serde_json::Value::Array(morph_json),
1374            );
1375        }
1376        tracing::info!(
1377            "Asset '{}': imported '{}' animation '{}': {:.3} s, {} track(s), {} morph key(s)",
1378            asset.name,
1379            source,
1380            imported.name,
1381            imported.duration,
1382            imported.tracks.len(),
1383            imported.morph_track.len(),
1384        );
1385    }
1386    Ok(())
1387}
1388
1389// Bake root motion on every Animation that opted in: strip the root joint's
1390// travel out of the pose tracks into the asset's `root_track` (see
1391// `root_motion::bake_root_motion`). Runs after the glTF pass so imported
1392// tracks are already inline; an Animation without `root_motion` is
1393// untouched. A root-motion clip whose root joint has no track produces an
1394// empty curve, which would silently never move a character, so it warns.
1395fn desugar_root_motion(assets: &mut [WorldJsonlAsset]) -> std::io::Result<()> {
1396    use crate::components::Animation;
1397    use crate::ecs::Component;
1398
1399    // This deserializes each flagged clip (whose `target` is a name reference),
1400    // so the name resolver must be installed. The full pipeline resets the
1401    // interner before reaching here; installing it again is a cheap no-op and
1402    // keeps this pass correct when called on its own.
1403    crate::ecs::asset_id::ensure_name_resolver();
1404
1405    for asset in assets.iter_mut() {
1406        if asset.asset_type != Animation::NAME
1407            || asset.args.get("root_motion").and_then(|v| v.as_bool()) != Some(true)
1408        {
1409            continue;
1410        }
1411        let mut anim: Animation = Deserialize::deserialize(&asset.args).map_err(|e| {
1412            std::io::Error::new(
1413                std::io::ErrorKind::InvalidData,
1414                format!(
1415                    "Asset '{}': root-motion bake failed to parse args: {}",
1416                    asset.name, e
1417                ),
1418            )
1419        })?;
1420        crate::root_motion::bake_root_motion(&mut anim);
1421        if anim.root_track.is_empty() {
1422            tracing::warn!(
1423                "Asset '{}': root_motion is set but the clip has no track on the root \
1424                 joint; the character will not move",
1425                asset.name
1426            );
1427        }
1428        let name = asset.name.clone();
1429        let obj = asset.args.as_object_mut().ok_or_else(|| {
1430            std::io::Error::new(
1431                std::io::ErrorKind::InvalidData,
1432                format!("Asset '{}': args is not a JSON object", name),
1433            )
1434        })?;
1435        obj.insert(
1436            "tracks".to_string(),
1437            serde_json::to_value(&anim.tracks).expect("serialize animation tracks"),
1438        );
1439        obj.insert(
1440            "root_track".to_string(),
1441            serde_json::to_value(&anim.root_track).expect("serialize root track"),
1442        );
1443        tracing::info!(
1444            "Asset '{}': baked root motion ({} key(s){})",
1445            asset.name,
1446            anim.root_track.len(),
1447            if anim.root_motion_y { ", incl. Y" } else { "" },
1448        );
1449    }
1450    Ok(())
1451}
1452
1453/// Validate world JSONL without running compilation. Runs the full front half
1454/// of the pipeline (load, expand, semantic checks) plus a per-asset type/args
1455/// resolution, but stops short of compiling payloads: intended for fast
1456/// server-side pre-deploy checks where shader compilation is not needed.
1457/// `assets_dir` is the asset search root the expansion passes resolve their
1458/// sources and presets against. Every problem found is reported in a single
1459/// newline-joined error.
1460pub fn validate_world_jsonl(content: &str, assets_dir: Option<&Path>) -> std::io::Result<()> {
1461    let loaded = crate::world::prepare_world(content, assets_dir).map_err(errors_to_io)?;
1462
1463    let mut errors: Vec<String> = Vec::new();
1464    for asset in &loaded.assets {
1465        // A resource asset does not build a component def, so skip the component
1466        // resolution for it.
1467        if crate::registry::RegisteredType::parse(&asset.asset_type)
1468            .is_some_and(|t| t.is_resource())
1469        {
1470            continue;
1471        }
1472        let req = AssetRequest {
1473            asset_type: asset.asset_type.clone(),
1474            args: Some(asset.args.clone()),
1475        };
1476        if let Err(e) = asset_api::create_asset_def(&req) {
1477            errors.push(format!("Asset '{}': {}", asset.name, e));
1478        }
1479    }
1480
1481    if errors.is_empty() {
1482        Ok(())
1483    } else {
1484        Err(errors_to_io(errors))
1485    }
1486}
1487
1488// Cache-key discriminant base for resource payloads. Resource kinds are keyed
1489// as `128 + ResourceKind as u8` so their cache keys never collide with a
1490// component discriminant (all < 128, the `ComponentMask` ceiling).
1491const RESOURCE_CACHE_DISC_BASE: u8 = 128;
1492
1493// One compiled resource awaiting packing: its kind tag + handle, the compiled
1494// bytes, whether those bytes are the record's inline data (a data resource like
1495// Material) or a blob payload, and the baked runtime data a hybrid kind
1496// (SkinnedMesh) carries alongside its payload (empty for everything else; it
1497// bakes from the authored args, so it sits outside the payload cache).
1498struct PendingResource {
1499    kind: u8,
1500    handle: u32,
1501    bytes: Vec<u8>,
1502    is_data: bool,
1503    extra_data: Vec<u8>,
1504}
1505
1506// The output of the compile + pack pass: the packed blob payload sections, the
1507// resource-stream records (each with its payload locator), and cache accounting.
1508struct CompiledOutput {
1509    scene_groups: Vec<SceneGroup>,
1510    mesh_bounds: Vec<MeshBoundsRecord>,
1511    mesh_component_names: Vec<(u32, String)>,
1512    blobs: Vec<Vec<u8>>,
1513    resources: Vec<ResourceRecord>,
1514    cache_hits: usize,
1515    cache_misses: usize,
1516}
1517
1518// Baked geometry summary of one compiled static-mesh payload, keyed by its
1519// unified mesh-source handle. None when the payload does not parse as a
1520// static mesh (VoxelChunk voxel data, a malformed payload); absence means the
1521// runtime decodes that payload eagerly.
1522fn mesh_bounds_record(handle: u32, bytes: &[u8]) -> Option<MeshBoundsRecord> {
1523    let (verts, idxs, _) = concinnity_core::gfx::mesh_payload::deserialise_with_lods(bytes).ok()?;
1524    let first = verts.first()?;
1525    let mut min = first.pos;
1526    let mut max = first.pos;
1527    for v in &verts {
1528        for axis in 0..3 {
1529            min[axis] = min[axis].min(v.pos[axis]);
1530            max[axis] = max[axis].max(v.pos[axis]);
1531        }
1532    }
1533    Some(MeshBoundsRecord {
1534        handle,
1535        min,
1536        max,
1537        vertex_count: verts.len() as u32,
1538        index_count: idxs.len() as u32,
1539    })
1540}
1541
1542// Read-only inputs to the compile + pack pass: the world being packed and the
1543// build context, as opposed to the def stream the pass mutates.
1544#[derive(Clone, Copy)]
1545struct PackContext<'a> {
1546    assets: &'a [WorldJsonlAsset],
1547    resource_jobs: &'a [(usize, crate::registry::RegisteredType, u32)],
1548    partition: &'a crate::scene_partition::ScenePartition,
1549    mesh_source_handles: &'a crate::resource_handles::ResourceHandles,
1550    max_blob_bytes: u64,
1551    assets_dir: Option<&'a Path>,
1552    artifacts_dir: Option<&'a str>,
1553    mesh_cache: &'a std::collections::HashMap<String, MeshCacheEntry>,
1554    progress: Option<&'a (dyn Fn(BuildProgress) + Sync)>,
1555}
1556
1557fn compile_and_pack_payloads(
1558    named: &mut [(String, BlobAssetDef)],
1559    named_src: &[usize],
1560    pack_ctx: PackContext<'_>,
1561) -> std::io::Result<CompiledOutput> {
1562    use rayon::prelude::*;
1563    use std::sync::atomic::{AtomicUsize, Ordering};
1564
1565    let PackContext {
1566        assets,
1567        resource_jobs,
1568        partition,
1569        mesh_source_handles,
1570        max_blob_bytes,
1571        assets_dir,
1572        artifacts_dir,
1573        mesh_cache,
1574        progress,
1575    } = pack_ctx;
1576
1577    let compiled_indices: Vec<usize> = named
1578        .iter()
1579        .enumerate()
1580        .filter(|(i, (_, def))| {
1581            if def.kind != AssetKind::Component {
1582                return false;
1583            }
1584            let Some(ct) = RegisteredType::from_discriminant(def.discriminant) else {
1585                return false;
1586            };
1587            if ct.as_str() == "File" {
1588                // only compile File assets whose kind maps to a supported payload
1589                // `named[i]` maps to `assets[named_src[i]]`.
1590                return assets[named_src[*i]]
1591                    .args
1592                    .get("kind")
1593                    .and_then(|k| k.as_str())
1594                    .and_then(FileKind::from_ext)
1595                    .map(|fk| fk.is_mesh())
1596                    .unwrap_or(false);
1597            }
1598            ct.registration().needs_compilation()
1599        })
1600        .map(|(i, _)| i)
1601        .collect();
1602
1603    // Snapshot each job's inputs so the parallel compile borrows nothing from
1604    // `named`, which is mutated afterwards to record payload locators.
1605    let jobs: Vec<(usize, String, u8)> = compiled_indices
1606        .iter()
1607        .map(|&idx| {
1608            let (name, def) = &named[idx];
1609            (idx, name.clone(), def.discriminant)
1610        })
1611        .collect();
1612
1613    // Compile assets in parallel. Each job is independent (it reads only its
1614    // own args and produces its own payload bytes) and the payload cache is
1615    // content-addressed, so concurrent hits and stores never collide. The
1616    // collected order follows `jobs`, so packing below stays deterministic.
1617    let cache_hits = AtomicUsize::new(0);
1618    let compile_total = jobs.len() as u32;
1619    let compiled_count = AtomicUsize::new(0);
1620    let report_one = || {
1621        if let Some(p) = progress {
1622            let done = compiled_count.fetch_add(1, Ordering::Relaxed) as u32 + 1;
1623            p(BuildProgress {
1624                stage: "compile",
1625                done,
1626                total: compile_total,
1627            });
1628        }
1629    };
1630    let pending: Vec<(usize, Vec<u8>)> = jobs
1631        .par_iter()
1632        .map(
1633            |(idx, name, discriminant)| -> std::io::Result<(usize, Vec<u8>)> {
1634                let ct = RegisteredType::from_discriminant(*discriminant).ok_or_else(|| {
1635                    std::io::Error::new(
1636                        std::io::ErrorKind::InvalidData,
1637                        format!("Invalid RegisteredType discriminant for asset '{}'", name),
1638                    )
1639                })?;
1640
1641                // The job carries the `named` index; map it to its source asset
1642                // via `named_src` (`named` is not 1:1 with `assets` once resource
1643                // assets are partitioned out).
1644                let asset_args = &assets[named_src[*idx]].args;
1645
1646                let ctx = crate::asset::BuildCtx {
1647                    name: name.as_str(),
1648                    assets_dir,
1649                    artifacts_dir,
1650                    all_assets: assets,
1651                };
1652
1653                // GLB-sourced Mesh / SkinnedMesh assets are probed before
1654                // desugar; honor those results here so the .glb parse really
1655                // is skipped on cache hits. On a miss the precomputed key is
1656                // used at store time, keeping the next build's probe valid.
1657                if let Some(entry) = mesh_cache.get(name) {
1658                    if let Some(bytes) = &entry.bytes {
1659                        cache_hits.fetch_add(1, Ordering::Relaxed);
1660                        return Ok((*idx, bytes.clone()));
1661                    }
1662                    let compiled_bytes = compile_by_type(ct, asset_args, &ctx)?;
1663                    crate::cache::store(&entry.key, &compiled_bytes);
1664                    return Ok((*idx, compiled_bytes));
1665                }
1666
1667                // Reuse a cached payload when the asset's inputs are unchanged;
1668                // otherwise compile and populate the cache for the next build.
1669                let inputs = cache_inputs_by_type(ct, asset_args, &ctx);
1670                let key = crate::cache::payload_key(*discriminant, asset_args, &ctx, &inputs);
1671                if let Some(bytes) = crate::cache::load(&key) {
1672                    cache_hits.fetch_add(1, Ordering::Relaxed);
1673                    return Ok((*idx, bytes));
1674                }
1675                let compiled_bytes = compile_by_type(ct, asset_args, &ctx)?;
1676                crate::cache::store(&key, &compiled_bytes);
1677                Ok((*idx, compiled_bytes))
1678            },
1679        )
1680        .inspect(|_| report_one())
1681        .collect::<std::io::Result<Vec<_>>>()?;
1682
1683    let component_hits = cache_hits.into_inner();
1684
1685    // Compile the resource-stream payloads (AudioClip today). Few and cheap, so
1686    // this stays serial; the content-addressed payload cache still short-circuits
1687    // an unchanged source. Bypasses the `BuildAsset`/`RegisteredType` path a
1688    // component takes -- a resource is no longer a component.
1689    let mut resource_hits = 0usize;
1690    let mut resource_pending: Vec<PendingResource> = Vec::new();
1691    for (asset_idx, rt, handle) in resource_jobs {
1692        let asset = &assets[*asset_idx];
1693        let ctx = crate::asset::BuildCtx {
1694            name: asset.name.as_str(),
1695            assets_dir,
1696            artifacts_dir,
1697            all_assets: assets,
1698        };
1699        let extra_data = rt
1700            .compile_data(&asset.name, &asset.args)?
1701            .unwrap_or_default();
1702        // A glTF/FBX-sourced mesh was probed before desugar; honor that result so
1703        // the source parse really is skipped on a hit and the pre-desugar key is
1704        // reused at store time (same contract as the component gltf-cache path).
1705        let bytes = if let Some(entry) = mesh_cache.get(&asset.name) {
1706            match &entry.bytes {
1707                Some(bytes) => {
1708                    resource_hits += 1;
1709                    bytes.clone()
1710                }
1711                None => {
1712                    let compiled = rt.compile_payload(&asset.args, assets_dir)?;
1713                    crate::cache::store(&entry.key, &compiled);
1714                    compiled
1715                }
1716            }
1717        } else {
1718            // Every resource asset compiles identically on every backend, so its
1719            // entry is shared across a DirectX and a Vulkan cook.
1720            let inputs = crate::asset::CacheInputs::extra(rt.source_files(&asset.args, assets_dir));
1721            let key = crate::cache::payload_key(
1722                RESOURCE_CACHE_DISC_BASE + job_resource_kind(*rt) as u8,
1723                &asset.args,
1724                &ctx,
1725                &inputs,
1726            );
1727            match crate::cache::load(&key) {
1728                Some(bytes) => {
1729                    resource_hits += 1;
1730                    bytes
1731                }
1732                None => {
1733                    let compiled = rt.compile_payload(&asset.args, assets_dir)?;
1734                    crate::cache::store(&key, &compiled);
1735                    compiled
1736                }
1737            }
1738        };
1739        resource_pending.push(PendingResource {
1740            kind: job_resource_kind(*rt) as u8,
1741            handle: *handle,
1742            bytes,
1743            is_data: rt.is_data(),
1744            extra_data,
1745        });
1746    }
1747
1748    let cache_hits = component_hits + resource_hits;
1749    let cache_misses = (pending.len() - component_hits) + (resource_pending.len() - resource_hits);
1750
1751    // Ownership of each payload, precomputed so the packing loops below can
1752    // mutate `named` freely. Resource jobs and `resource_pending` are
1753    // index-aligned.
1754    use crate::scene_partition::Owner;
1755    let comp_owners: Vec<Owner> = pending
1756        .iter()
1757        .map(|(idx, _)| partition.owner(&named[*idx].0))
1758        .collect();
1759    let res_owners: Vec<Owner> = resource_jobs
1760        .iter()
1761        .map(|(asset_idx, _, _)| partition.owner(&assets[*asset_idx].name))
1762        .collect();
1763
1764    // Baked AABB + counts for every static mesh payload, resource-stream Mesh
1765    // entries first (their resource handle IS the mesh-source handle) then the
1766    // compiled mesh-source components, sorted by handle for determinism.
1767    let mut mesh_bounds: Vec<MeshBoundsRecord> = Vec::new();
1768    for ((_, rt, handle), res) in resource_jobs.iter().zip(&resource_pending) {
1769        if *rt == crate::registry::RegisteredType::Mesh
1770            && let Some(record) = mesh_bounds_record(*handle, &res.bytes)
1771        {
1772            mesh_bounds.push(record);
1773        }
1774    }
1775    let mut mesh_component_names: Vec<(u32, String)> = Vec::new();
1776    for (idx, bytes) in &pending {
1777        let asset = &assets[named_src[*idx]];
1778        if !crate::resource_handles::is_mesh_source(&asset.asset_type, &asset.args) {
1779            continue;
1780        }
1781        let id = asset_id::intern(&asset.name);
1782        if let Some(handle) =
1783            mesh_source_handles.get(crate::resource_handles::ResourceKind::Mesh, id)
1784        {
1785            // Handle -> asset name for mesh payloads riding component defs,
1786            // so a consumer can find any sub-mesh payload by unified handle
1787            // (resource-stream Mesh handles lead the space and resolve
1788            // through the resource records instead).
1789            mesh_component_names.push((handle, asset.name.clone()));
1790            if let Some(record) = mesh_bounds_record(handle, bytes) {
1791                mesh_bounds.push(record);
1792            }
1793        }
1794    }
1795    mesh_bounds.sort_unstable_by_key(|r| r.handle);
1796
1797    // One group per scene (declaration order, possibly empty), carrying the
1798    // resource-stream entries and payload defs that scene exclusively owns.
1799    let scene_groups: Vec<SceneGroup> = (0..partition.scenes.len())
1800        .map(|s| SceneGroup {
1801            scene: asset_id::intern(&partition.scenes[s]),
1802            resources: resource_jobs
1803                .iter()
1804                .zip(&res_owners)
1805                .filter(|(_, o)| **o == Owner::Scene(s))
1806                .map(|((_, rt, handle), _)| (job_resource_kind(*rt) as u8, *handle))
1807                .collect(),
1808            defs: pending
1809                .iter()
1810                .zip(&comp_owners)
1811                .filter(|(_, o)| **o == Owner::Scene(s))
1812                .filter_map(|((idx, _), _)| named[*idx].1.name)
1813                .collect(),
1814        })
1815        .collect();
1816
1817    if pending.is_empty() && resource_pending.is_empty() {
1818        return Ok(CompiledOutput {
1819            scene_groups,
1820            mesh_bounds,
1821            mesh_component_names,
1822            blobs: vec![Vec::new()],
1823            resources: Vec::new(),
1824            cache_hits: 0,
1825            cache_misses: 0,
1826        });
1827    }
1828
1829    // Pack payloads by ownership group: the global set first (blob 0 onward),
1830    // then each scene's exclusive set starting at a fresh blob, so a scene's
1831    // payloads are contiguous and stay unread until the scene loads. Within a
1832    // group, component payloads pack before resource payloads, both in their
1833    // stream order. One packer, so every group addresses the same blob space;
1834    // record order in the metadata streams is unchanged (locators are
1835    // per-record, so packing order is independent).
1836    let mut packer = PayloadPacker::new(max_blob_bytes);
1837    let mut resource_locators: Vec<Option<concinnity_core::ecs::PayloadLocator>> =
1838        vec![None; resource_pending.len()];
1839
1840    for group in 0..=partition.scenes.len() {
1841        let owner = match group {
1842            0 => Owner::Global,
1843            s => Owner::Scene(s - 1),
1844        };
1845        if group > 0 {
1846            packer.start_group();
1847        }
1848        for ((idx, bytes), item_owner) in pending.iter().zip(&comp_owners) {
1849            if *item_owner == owner {
1850                named[*idx].1.payload = Some(packer.push(bytes));
1851            }
1852        }
1853        for (i, (res, item_owner)) in resource_pending.iter().zip(&res_owners).enumerate() {
1854            if !res.is_data && *item_owner == owner {
1855                resource_locators[i] = Some(packer.push(&res.bytes));
1856            }
1857        }
1858    }
1859
1860    let mut resources: Vec<ResourceRecord> = Vec::with_capacity(resource_pending.len());
1861    for (pending, locator) in resource_pending.iter().zip(resource_locators) {
1862        // A data resource (Material) carries its bytes inline; a payload
1863        // resource parks its bytes in a blob section and records the locator,
1864        // plus any hybrid baked data (SkinnedMesh) inline beside it.
1865        let (payload, data_bytes) = if pending.is_data {
1866            (None, pending.bytes.clone())
1867        } else {
1868            (locator, pending.extra_data.clone())
1869        };
1870        resources.push(ResourceRecord {
1871            resource_kind: pending.kind,
1872            handle: pending.handle,
1873            payload,
1874            data_bytes,
1875        });
1876    }
1877
1878    Ok(CompiledOutput {
1879        scene_groups,
1880        mesh_bounds,
1881        mesh_component_names,
1882        blobs: packer.finish(),
1883        resources,
1884        cache_hits,
1885        cache_misses,
1886    })
1887}
1888
1889// Dispatch payload compilation by RegisteredType. Every variant listed below
1890// has a `BuildAsset` impl in its asset file; the body of each call here is a
1891// one-liner that delegates to the trait. Adding a new compiled component
1892// means:
1893//   1. impl `Component` with `PAYLOAD = AssetPayload::Compiled` for the type
1894//   2. impl `BuildAsset` for the type in its asset file
1895//   3. Add one match arm here
1896fn compile_by_type(
1897    ct: RegisteredType,
1898    args: &serde_json::Value,
1899    ctx: &crate::asset::BuildCtx<'_>,
1900) -> std::io::Result<Vec<u8>> {
1901    use crate::asset::BuildAsset;
1902    use crate::components::{File, ProceduralMesh, Room, SdfVolume, Shader, VoxelChunk};
1903    match ct {
1904        RegisteredType::ProceduralMesh => {
1905            <ProceduralMesh as BuildAsset>::compile_payload(args, ctx)
1906        }
1907        RegisteredType::VoxelChunk => <VoxelChunk as BuildAsset>::compile_payload(args, ctx),
1908        RegisteredType::File => <File as BuildAsset>::compile_payload(args, ctx),
1909        RegisteredType::Room => <Room as BuildAsset>::compile_payload(args, ctx),
1910        RegisteredType::Shader => <Shader as BuildAsset>::compile_payload(args, ctx),
1911        RegisteredType::SdfVolume => <SdfVolume as BuildAsset>::compile_payload(args, ctx),
1912        other => Err(std::io::Error::new(
1913            std::io::ErrorKind::InvalidData,
1914            format!(
1915                "Asset '{}' is marked Compiled but has no BuildAsset impl (RegisteredType {:?})",
1916                ctx.name, other
1917            ),
1918        )),
1919    }
1920}
1921
1922// Dispatch each asset's payload-cache contribution by RegisteredType. Mirrors
1923// `compile_by_type` so the cache layer can fold a hash of every input the
1924// compile reads into its payload key. Types with no `BuildAsset` impl, or with
1925// the trait default, contribute nothing.
1926//
1927// `source_files` and `TARGET_DEPENDENT` are read together per arm: a new
1928// asset whose payload differs per backend cannot pick up one without the
1929// other.
1930fn cache_inputs_by_type(
1931    ct: RegisteredType,
1932    args: &serde_json::Value,
1933    ctx: &crate::asset::BuildCtx<'_>,
1934) -> crate::asset::CacheInputs {
1935    use crate::asset::{BuildAsset, CacheInputs};
1936    use crate::components::{File, ProceduralMesh, Room, SdfVolume, Shader, VoxelChunk};
1937    macro_rules! inputs {
1938        ($t:ty) => {
1939            CacheInputs {
1940                sources: <$t as BuildAsset>::source_files(args, ctx),
1941                target_dependent: <$t as BuildAsset>::TARGET_DEPENDENT,
1942            }
1943        };
1944    }
1945    match ct {
1946        RegisteredType::ProceduralMesh => inputs!(ProceduralMesh),
1947        RegisteredType::VoxelChunk => inputs!(VoxelChunk),
1948        RegisteredType::File => inputs!(File),
1949        RegisteredType::Room => inputs!(Room),
1950        RegisteredType::Shader => inputs!(Shader),
1951        RegisteredType::SdfVolume => inputs!(SdfVolume),
1952        _ => CacheInputs::extra(Vec::new()),
1953    }
1954}
1955
1956// Resolve scene + screen associations that the runtime can no longer derive
1957// from name strings, baking them into the asset args so they survive as
1958// AssetId ids.
1959//
1960// Naming-convention relationships handled:
1961//   - A Prop named `<scene>_*` belongs to Scene `<scene>`. The matched scene
1962//     name is written into the prop's `scene` arg.
1963//   - A UI element (Sprite, ImageOverlay, TextLabel, Text, TextInput,
1964//     HitRegion, ScrollPanel) named `<screen>_*` belongs to Screen `<screen>`.
1965//     The matched screen name is written into the asset's `screen` arg.
1966//   - A HitRegion or KeyBinding `action` of the form `scene:<name>`,
1967//     `screen:show:<name>`, `screen:push:<name>`, or `screen:toggle:<name>`
1968//     has its `<name>` part rewritten to the interned id, so `UiInputSystem`
1969//     can parse an integer at runtime instead of a name.
1970fn resolve_scene_refs(assets: &mut [WorldJsonlAsset]) {
1971    let norm = |s: &str| s.to_lowercase().replace('_', "");
1972
1973    let scene_names: Vec<String> = assets
1974        .iter()
1975        .filter(|a| norm(&a.asset_type) == "scene")
1976        .map(|a| a.name.clone())
1977        .collect();
1978
1979    let screen_names: Vec<String> = assets
1980        .iter()
1981        .filter(|a| norm(&a.asset_type) == "screen")
1982        .map(|a| a.name.clone())
1983        .collect();
1984
1985    // Longest matching prefix wins so a nested name (e.g. `level_boss_*` under
1986    // both `level` and `level_boss`) binds to the most specific host.
1987    // Equivalent to first-match when no host name prefixes another.
1988    let longest_prefix_host = |name: &str, hosts: &[String]| -> Option<String> {
1989        hosts
1990            .iter()
1991            .filter(|h| name.starts_with(&format!("{h}_")))
1992            .max_by_key(|h| h.len())
1993            .cloned()
1994    };
1995
1996    // Rewrite an action string, replacing the trailing `<name>` after the
1997    // given action prefix with its interned id. Returns Some(new_action) when
1998    // the action used the prefix with an unresolved name; None otherwise.
1999    let resolve_action = |action: &str| -> Option<String> {
2000        for prefix in ["scene:", "screen:show:", "screen:push:", "screen:toggle:"] {
2001            if let Some(rest) = action.strip_prefix(prefix) {
2002                if !rest.is_empty() && rest.parse::<u32>().is_err() {
2003                    return Some(format!("{prefix}{}", asset_id::intern(rest).0));
2004                }
2005                return None;
2006            }
2007        }
2008        None
2009    };
2010
2011    for asset in assets.iter_mut() {
2012        let ty = norm(&asset.asset_type);
2013
2014        // Host binding by name prefix: a Prop takes its Scene, a UI element
2015        // takes its Screen. An asset that already names its host is left alone.
2016        let host = match ty.as_str() {
2017            "prop" => Some(("scene", &scene_names)),
2018            "sprite" | "imageoverlay" | "textlabel" | "text" | "textinput" | "hitregion"
2019            | "scrollpanel" => Some(("screen", &screen_names)),
2020            _ => None,
2021        };
2022        if let Some((key, hosts)) = host
2023            && asset.args.get(key).is_none()
2024            && let Some(matched) = longest_prefix_host(&asset.name, hosts)
2025            && let serde_json::Value::Object(m) = &mut asset.args
2026        {
2027            m.insert(key.to_string(), serde_json::Value::String(matched));
2028        }
2029
2030        // Resolve screen:* / scene:* action targets to interned ids.
2031        if matches!(ty.as_str(), "hitregion" | "keybinding") {
2032            let new_action = asset
2033                .args
2034                .get("action")
2035                .and_then(|v| v.as_str())
2036                .and_then(resolve_action);
2037            if let (Some(action), serde_json::Value::Object(m)) = (new_action, &mut asset.args) {
2038                m.insert("action".to_string(), serde_json::Value::String(action));
2039            }
2040        }
2041    }
2042}
2043
2044#[cfg(test)]
2045mod tests {
2046    use super::*;
2047
2048    // Default-shader compilation writes intermediates to a shared
2049    // data path keyed by asset name, so tests whose worlds pull in
2050    // the default Shader (any rendering world) must not build concurrently.
2051    static SHADER_BUILD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2052
2053    #[test]
2054    fn build_pipeline_interns_names_and_resolves_refs() {
2055        let _guard = SHADER_BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2056        crate::shader::install_stub_toolchain();
2057        // box=0, day=1, day_crate=2 in declaration order.
2058        let world = concat!(
2059            r#"{"name":"box","type":"ProceduralMesh","args":{"generator":"box","half_extents":[1,1,1]}}"#,
2060            "\n",
2061            r#"{"name":"day","type":"Scene","args":{}}"#,
2062            "\n",
2063            r#"{"name":"day_crate","type":"Prop","args":{"mesh":"box"}}"#,
2064            "\n",
2065        );
2066        let result = build_pipeline_from_str(world, None, None).expect("build pipeline");
2067
2068        // The Prop def's identity is the interned id, not a name string.
2069        let prop = result
2070            .defs
2071            .iter()
2072            .find(|d| d.name == Some(crate::ecs::asset_id::AssetId(2)))
2073            .expect("day_crate def present with interned id 2");
2074
2075        let baked: crate::components::Prop = postcard::from_bytes(&prop.args_bytes).unwrap();
2076        // The `mesh` reference resolved to box's handle (0).
2077        assert_eq!(baked.mesh, Some(crate::ecs::MeshHandle(0)));
2078        // The `day_` name prefix resolved to Scene `day`'s id (1).
2079        assert_eq!(baked.scene, Some(crate::ecs::asset_id::AssetId(1)));
2080    }
2081
2082    // A world with physics content but no PhysicsConfig has one injected during
2083    // expansion; it must compile to the same values the runtime falls back to,
2084    // and leave the shipped budget alone.
2085    #[test]
2086    fn an_injected_physics_config_round_trips_through_the_blob() {
2087        let _guard = SHADER_BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2088        crate::shader::install_stub_toolchain();
2089        let world = concat!(
2090            r#"{"name":"box","type":"ProceduralMesh","args":{"generator":"box","half_extents":[1,1,1]}}"#,
2091            "\n",
2092            r#"{"name":"crate_a","type":"Prop","args":{"mesh":"box","collider":{"shape":"cuboid"}}}"#,
2093            "\n",
2094            r#"{"name":"crate_body","type":"PropBody","args":{"prop_name":"crate_a"}}"#,
2095            "\n",
2096        );
2097        let result = build_pipeline_from_str(world, None, None).expect("build pipeline");
2098
2099        let index = result
2100            .names
2101            .iter()
2102            .position(|n| n == "physics_config")
2103            .expect("the injected config compiled into the blob");
2104        let baked: crate::components::PhysicsConfig =
2105            postcard::from_bytes(&result.defs[index].args_bytes).unwrap();
2106        let default = crate::components::PhysicsConfig::default();
2107        assert_eq!(baked.floor_y, default.floor_y);
2108        assert_eq!(baked.terrain_subdivisions, default.terrain_subdivisions);
2109        assert_eq!(baked.terrain_mesh, default.terrain_mesh);
2110        assert!(baked.layers.is_empty());
2111        assert!(baked.no_collide.is_empty());
2112        assert_eq!(baked.contact_min_impulse, default.contact_min_impulse);
2113        assert_eq!(baked.spawn_headroom, 0, "the strict spawn cap is untouched");
2114
2115        // The reservation is the authored content plus the floor, unchanged by
2116        // the config becoming visible.
2117        let budget = result.physics_budget.expect("a physics budget");
2118        assert_eq!(budget.spawn_headroom, 0);
2119        assert_eq!(budget.dynamic, 1, "the crate");
2120    }
2121
2122    #[test]
2123    fn resource_payload_slices_the_named_resource() {
2124        let _guard = SHADER_BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2125        crate::shader::install_stub_toolchain();
2126        let world = concat!(
2127            r#"{"name":"prism","type":"SkinnedMesh","args":{"#,
2128            r#""vertices":[{"pos":[0,0,0]},{"pos":[1,0,0]},{"pos":[0,1,0]}],"#,
2129            r#""indices":[0,1,2],"skeleton":[{"name":"root","parent":-1}],"#,
2130            r#""scale":[1,1,1]}}"#,
2131            "\n",
2132        );
2133        let result = build_pipeline_from_str(world, None, None).expect("build");
2134        let bytes = result
2135            .resource_payload(ResourceKind::SkinnedMesh, "prism")
2136            .expect("named payload");
2137        let payload =
2138            concinnity_core::gfx::mesh_payload::deserialise_skinned_with_lods(bytes).unwrap();
2139        assert_eq!(payload.vertices.len(), 3);
2140        assert_eq!(payload.joints[0].name, "root");
2141        // The wrong name or the wrong kind finds nothing.
2142        assert!(
2143            result
2144                .resource_payload(ResourceKind::SkinnedMesh, "ghost")
2145                .is_none()
2146        );
2147        assert!(
2148            result
2149                .resource_payload(ResourceKind::Texture, "prism")
2150                .is_none()
2151        );
2152    }
2153
2154    // A resource asset (here a Font) leaves no component def, so the lock
2155    // records it through `resource_locks` instead: name, kind, handle, args
2156    // hash, and the blob its payload landed in.
2157    #[test]
2158    fn build_pipeline_records_resource_lock_provenance() {
2159        let _guard = SHADER_BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2160        crate::shader::install_stub_toolchain();
2161        let world = concat!(
2162            r#"{"name":"f","type":"Font","args":{"size_px":20}}"#,
2163            "\n",
2164            r#"{"name":"pause","type":"Screen","args":{}}"#,
2165            "\n",
2166        );
2167        let result = build_pipeline_from_str(world, None, None).expect("build");
2168
2169        assert_eq!(result.resource_locks.len(), result.resources.len());
2170        let font = result
2171            .resource_locks
2172            .iter()
2173            .find(|r| r.name == "f")
2174            .expect("font provenance recorded");
2175        assert_eq!(font.kind, "Font");
2176        assert_eq!(font.handle, 0);
2177        assert_eq!(font.args_hash.len(), 64);
2178        // A payload resource records which blob holds its bytes.
2179        assert!(font.payload_blob.is_some());
2180        // The resource is not in the component asset list.
2181        assert!(!result.names.iter().any(|n| n == "f"));
2182    }
2183
2184    // The visual_novel demo world (in concinnity-infra/worlds) exercises
2185    // Sprite + Screen + KeyBinding together. Validating it here catches asset
2186    // registration / pipeline regressions before we ship the world.
2187    #[test]
2188    fn visual_novel_world_validates() {
2189        // Inline a representative subset of the world so the test stays
2190        // hermetic (no infra path lookup needed). Covers: an initial Screen,
2191        // a Sprite under that screen's prefix, a TextLabel under it, a
2192        // HitRegion firing screen:show on another Screen, and a KeyBinding to
2193        // toggle a third (modal) Screen.
2194        let world = r#"{"name":"gfx","type":"GraphicsConfig","args":{}}
2195{"name":"f","type":"Font","args":{"size_px":20}}
2196{"name":"title_menu","type":"Screen","args":{"initial":true}}
2197{"name":"title_menu_bg","type":"Sprite","args":{"x":0,"y":0,"width":640,"height":360,"tint":[0.1,0.1,0.1,1]}}
2198{"name":"title_menu_lbl","type":"TextLabel","args":{"font":"f","content":"Start","x":260,"y":160}}
2199{"name":"title_menu_btn","type":"HitRegion","args":{"x":260,"y":156,"width":120,"height":40,"label":"title_menu_lbl","action":"screen:show:vn_page_1"}}
2200{"name":"vn_page_1","type":"Screen","args":{}}
2201{"name":"vn_page_1_text","type":"TextLabel","args":{"font":"f","content":"hello","x":40,"y":40}}
2202{"name":"vn_page_1_next","type":"HitRegion","args":{"x":0,"y":0,"width":640,"height":360,"action":"screen:show:title_menu"}}
2203{"name":"pause_menu","type":"Screen","args":{}}
2204{"name":"pause_menu_dim","type":"Sprite","args":{"x":0,"y":0,"width":640,"height":360,"tint":[0,0,0,0.6]}}
2205{"name":"esc","type":"KeyBinding","args":{"key":"Escape","action":"screen:toggle:pause_menu"}}
2206"#;
2207        validate_world_jsonl(world, None).expect("visual_novel-shaped world should validate");
2208    }
2209
2210    // `screen:show:<name>` / `screen:toggle:<name>` action targets are
2211    // rewritten to interned ids at build time, like `scene:<name>`.
2212    #[test]
2213    fn build_pipeline_resolves_screen_action_refs() {
2214        let world = concat!(
2215            r#"{"name":"pause_menu","type":"Screen","args":{}}"#,
2216            "\n",
2217            r#"{"name":"btn","type":"HitRegion","args":{"x":0,"y":0,"width":10,"height":10,"action":"screen:toggle:pause_menu"}}"#,
2218            "\n",
2219            r#"{"name":"esc","type":"KeyBinding","args":{"key":"Escape","action":"screen:toggle:pause_menu"}}"#,
2220            "\n",
2221        );
2222        let result = build_pipeline_from_str(world, None, None).expect("build");
2223        // pause_menu interned id = 0 (first declared name).
2224        let btn = result
2225            .defs
2226            .iter()
2227            .find(|d| d.name == Some(crate::ecs::asset_id::AssetId(1)))
2228            .expect("HitRegion def");
2229        let baked: crate::components::HitRegion = postcard::from_bytes(&btn.args_bytes).unwrap();
2230        assert_eq!(baked.action, "screen:toggle:0");
2231
2232        let esc = result
2233            .defs
2234            .iter()
2235            .find(|d| d.name == Some(crate::ecs::asset_id::AssetId(2)))
2236            .expect("KeyBinding def");
2237        let baked: crate::components::KeyBinding = postcard::from_bytes(&esc.args_bytes).unwrap();
2238        assert_eq!(baked.action, "screen:toggle:0");
2239    }
2240
2241    // A Sprite/TextLabel/HitRegion named `<screen>_*` has its `screen` arg
2242    // resolved from the prefix at build time, mirroring Prop scene refs.
2243    #[test]
2244    fn build_pipeline_resolves_screen_prefix_on_ui_assets() {
2245        let _guard = SHADER_BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2246        crate::shader::install_stub_toolchain();
2247        let world = concat!(
2248            r#"{"name":"pause_menu","type":"Screen","args":{}}"#,
2249            "\n",
2250            r#"{"name":"pause_menu_dim","type":"Sprite","args":{"x":0,"y":0,"width":10,"height":10}}"#,
2251            "\n",
2252            r#"{"name":"pause_menu_title","type":"TextLabel","args":{"font":"f","content":"x","x":0,"y":0}}"#,
2253            "\n",
2254            r#"{"name":"pause_menu_btn","type":"HitRegion","args":{"x":0,"y":0,"width":10,"height":10,"action":"screen:hide"}}"#,
2255            "\n",
2256            r#"{"name":"f","type":"Font","args":{"size_px":16}}"#,
2257            "\n",
2258        );
2259        let result = build_pipeline_from_str(world, None, None).expect("build");
2260        // pause_menu interned id = 0; the UI assets intern in declaration order.
2261        let baked_view = |id: u32, expect: &str| {
2262            let def = result
2263                .defs
2264                .iter()
2265                .find(|d| d.name == Some(crate::ecs::asset_id::AssetId(id)))
2266                .unwrap_or_else(|| panic!("expected a def for {expect}"));
2267            let ct = crate::registry::RegisteredType::from_discriminant(def.discriminant)
2268                .unwrap_or_else(|| panic!("{expect}: unknown discriminant"));
2269            match ct {
2270                crate::registry::RegisteredType::Sprite => {
2271                    postcard::from_bytes::<crate::components::Sprite>(&def.args_bytes)
2272                        .unwrap()
2273                        .screen
2274                }
2275                crate::registry::RegisteredType::TextLabel => {
2276                    postcard::from_bytes::<crate::components::TextLabel>(&def.args_bytes)
2277                        .unwrap()
2278                        .screen
2279                }
2280                crate::registry::RegisteredType::HitRegion => {
2281                    postcard::from_bytes::<crate::components::HitRegion>(&def.args_bytes)
2282                        .unwrap()
2283                        .screen
2284                }
2285                other => panic!("{expect}: unexpected type {other:?}"),
2286            }
2287        };
2288        for (id, name) in [
2289            (1, "pause_menu_dim"),
2290            (2, "pause_menu_title"),
2291            (3, "pause_menu_btn"),
2292        ] {
2293            assert_eq!(
2294                baked_view(id, name),
2295                Some(crate::ecs::asset_id::AssetId(0)),
2296                "expected {name} to have screen=0"
2297            );
2298        }
2299    }
2300
2301    // Nested screen names resolve by longest prefix: `<menu>_settings_*` binds
2302    // to the `<menu>_settings` screen, not the enclosing `<menu>` screen that is
2303    // declared first. (Regression: first-match claimed the nested elements,
2304    // so a MainMenu's settings sub-screen rendered on top of the main menu.)
2305    #[test]
2306    fn resolve_scene_refs_picks_longest_screen_prefix() {
2307        let mk = |name: &str, ty: &str| crate::world::WorldJsonlAsset {
2308            name: name.to_string(),
2309            asset_type: ty.to_string(),
2310            args: serde_json::json!({}),
2311        };
2312        let mut assets = vec![
2313            mk("menu", "Screen"),
2314            mk("menu_settings", "Screen"),
2315            mk("menu_title", "TextLabel"),
2316            mk("menu_settings_title", "TextLabel"),
2317        ];
2318        super::resolve_scene_refs(&mut assets);
2319        let view_of = |n: &str| {
2320            assets
2321                .iter()
2322                .find(|a| a.name == n)
2323                .and_then(|a| a.args.get("screen"))
2324                .and_then(|v| v.as_str())
2325                .map(str::to_string)
2326        };
2327        assert_eq!(view_of("menu_title").as_deref(), Some("menu"));
2328        assert_eq!(
2329            view_of("menu_settings_title").as_deref(),
2330            Some("menu_settings")
2331        );
2332    }
2333
2334    // Animation with no `source` is left byte-for-byte unchanged: the
2335    // inline-authored path must not regress.
2336    #[test]
2337    fn desugar_animation_imports_skips_inline_clips() {
2338        let original = serde_json::json!({
2339            "target": "flag",
2340            "duration": 2.0,
2341            "tracks": [{"joint": 0, "keyframes": [{"time": 0.0, "rotation_deg": [0,0,0]}]}],
2342        });
2343        let mut assets = vec![crate::world::WorldJsonlAsset {
2344            name: "wave".to_string(),
2345            asset_type: "Animation".to_string(),
2346            args: original.clone(),
2347        }];
2348        desugar_animation_imports(&mut assets, None).expect("desugar succeeds");
2349        assert_eq!(assets[0].args, original);
2350    }
2351
2352    // Opting into root motion strips the root joint's X/Z travel into
2353    // `root_track` and anchors the pose; a clip without the flag is
2354    // untouched, and a second pass over already-baked args is a no-op.
2355    #[test]
2356    fn desugar_root_motion_bakes_the_root_track() {
2357        let walk = serde_json::json!({
2358            "target": "hero",
2359            "duration": 1.0,
2360            "root_motion": true,
2361            "tracks": [{"joint": 0, "keyframes": [
2362                {"time": 0.0, "translation": [0.0, 1.0, 0.0]},
2363                {"time": 1.0, "translation": [2.0, 1.0, 0.0]}
2364            ]}],
2365        });
2366        let plain = serde_json::json!({
2367            "target": "hero",
2368            "duration": 1.0,
2369            "tracks": [{"joint": 0, "keyframes": [
2370                {"time": 1.0, "translation": [2.0, 1.0, 0.0]}
2371            ]}],
2372        });
2373        let mut assets = vec![
2374            crate::world::WorldJsonlAsset {
2375                name: "walk".to_string(),
2376                asset_type: "Animation".to_string(),
2377                args: walk,
2378            },
2379            crate::world::WorldJsonlAsset {
2380                name: "plain".to_string(),
2381                asset_type: "Animation".to_string(),
2382                args: plain.clone(),
2383            },
2384        ];
2385        desugar_root_motion(&mut assets).expect("desugar succeeds");
2386
2387        let baked = &assets[0].args;
2388        assert_eq!(baked["root_track"][1]["translation"][0], 2.0);
2389        assert_eq!(baked["root_track"][1]["translation"][1], 0.0);
2390        // The pose keeps Y but stays anchored on X.
2391        assert_eq!(baked["tracks"][0]["keyframes"][1]["translation"][0], 0.0);
2392        assert_eq!(baked["tracks"][0]["keyframes"][1]["translation"][1], 1.0);
2393        assert_eq!(assets[1].args, plain, "flag-less clip untouched");
2394
2395        let after_first = assets[0].args.clone();
2396        desugar_root_motion(&mut assets).expect("second pass succeeds");
2397        assert_eq!(assets[0].args, after_first, "re-bake is a no-op");
2398    }
2399
2400    #[test]
2401    fn voxel_chunk_payload_compiles_end_to_end() {
2402        let world = r#"{"name":"scene_shader","type":"Shader","args":{"vertex":{"source":"x.metal"},"fragment":{"source":"x.metal"}}}
2403{"name":"air","type":"BlockType","args":{"solid":false}}
2404{"name":"stone","type":"BlockType","args":{"uv_min":[0,0],"uv_max":[1,1]}}
2405{"name":"chunk","type":"VoxelChunk","args":{"palette":["air","stone"],"dim":[2,1,1],"blocks":[1,1]}}
2406"#;
2407        // We can't easily compile shaders here, so go through the geometry
2408        // entry point directly to verify the voxel chunk produces a non-empty
2409        // payload for two adjacent solid blocks (10 faces after interior cull).
2410        let chunk_args = serde_json::json!({
2411            "palette": ["air", "stone"],
2412            "dim": [2, 1, 1],
2413            "blocks": [1, 1],
2414            "block_size": 1.0,
2415        });
2416        let bt = |name: &str| -> Option<serde_json::Value> {
2417            match name {
2418                "air" => Some(serde_json::json!({"solid": false})),
2419                "stone" => Some(serde_json::json!({"uv_min":[0,0],"uv_max":[1,1]})),
2420                _ => None,
2421            }
2422        };
2423        let bytes = crate::geometry::compile_voxel_chunk_payload(&chunk_args, bt).unwrap();
2424        assert!(!bytes.is_empty());
2425        let _ = world; // keeps the inline jsonl reference for documentation
2426    }
2427
2428    fn wja(name: &str, ty: &str, args: serde_json::Value) -> crate::world::WorldJsonlAsset {
2429        crate::world::WorldJsonlAsset {
2430            name: name.to_string(),
2431            asset_type: ty.to_string(),
2432            args,
2433        }
2434    }
2435
2436    fn ctx() -> crate::asset::BuildCtx<'static> {
2437        crate::asset::BuildCtx {
2438            name: "test",
2439            assets_dir: None,
2440            artifacts_dir: None,
2441            all_assets: &[],
2442        }
2443    }
2444
2445    // A cache map that claims a compiled payload is already in hand for the
2446    // named asset, so every desugar pass must skip its source parse.
2447    fn hit_cache(name: &str) -> std::collections::HashMap<String, MeshCacheEntry> {
2448        let mut m = std::collections::HashMap::new();
2449        m.insert(
2450            name.to_string(),
2451            MeshCacheEntry {
2452                key: "k".to_string(),
2453                bytes: Some(vec![1, 2, 3]),
2454            },
2455        );
2456        m
2457    }
2458
2459    #[test]
2460    fn build_from_path_missing_world_file_errors() {
2461        assert!(build_from_path("/no/such/world.jsonl").is_err());
2462    }
2463
2464    #[test]
2465    fn build_from_path_reports_a_malformed_world_file() {
2466        let dir = tempfile::tempdir().expect("tempdir");
2467        let world = dir.path().join("world.jsonl");
2468        std::fs::write(&world, "{not json\n").expect("write world");
2469        let err = build_from_path(world.to_str().unwrap()).expect_err("malformed world");
2470        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2471    }
2472
2473    // A lock that cannot be written fails the build: shipping blobs without the
2474    // record of what went into them would leave the output unexplainable.
2475    #[test]
2476    fn write_build_outputs_fails_when_the_lock_cannot_be_written() {
2477        let _output = crate::blob::test_output::LOCK
2478            .lock()
2479            .unwrap_or_else(|e| e.into_inner());
2480        let _state = crate::blob::test_output::StateDir::new();
2481        let _lock_file = crate::blob::test_output::LockFile;
2482        // A directory where the lock file belongs makes the write fail.
2483        std::fs::create_dir_all(crate::blob::LOCK_PATH).expect("occupy the lock path");
2484
2485        let result = PipelineResult {
2486            defs: Vec::new(),
2487            names: Vec::new(),
2488            resources: Vec::new(),
2489            scene_groups: Vec::new(),
2490            mesh_bounds: Vec::new(),
2491            physics_budget: None,
2492            mesh_component_names: Vec::new(),
2493            payloads: vec![vec![1, 2, 3]],
2494            cache_hits: 0,
2495            cache_misses: 0,
2496            texture_sources: Vec::new(),
2497            mesh_sources: Vec::new(),
2498            resource_locks: Vec::new(),
2499        };
2500        assert!(
2501            write_build_outputs(&result, &[], &[]).is_err(),
2502            "an unwritable lock must fail the build"
2503        );
2504    }
2505
2506    // The full build tail: compile the world, ship the blobs under the state
2507    // root, and record every asset in the lock beside them.
2508    #[test]
2509    fn build_from_path_writes_the_blobs_and_the_lock_beside_them() {
2510        let _shaders = SHADER_BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2511        crate::shader::install_stub_toolchain();
2512        let _output = crate::blob::test_output::LOCK
2513            .lock()
2514            .unwrap_or_else(|e| e.into_inner());
2515        let _state = crate::blob::test_output::StateDir::new();
2516        let _lock_file = crate::blob::test_output::LockFile;
2517
2518        let dir = tempfile::tempdir().expect("tempdir");
2519        let world_path = dir.path().join("world.jsonl");
2520        std::fs::write(
2521            &world_path,
2522            concat!(
2523                r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
2524                "\n",
2525                r#"{"name":"f","type":"Font","args":{"size_px":20}}"#,
2526                "\n",
2527                r#"{"name":"pause","type":"Screen","args":{}}"#,
2528                "\n",
2529            ),
2530        )
2531        .expect("write world");
2532
2533        build_from_path(world_path.to_str().unwrap()).expect("build");
2534
2535        let raw = std::fs::read_to_string(crate::blob::LOCK_PATH).expect("lock written");
2536        let lock: crate::blob::BlobLock = serde_json::from_str(&raw).expect("lock is valid json");
2537        assert_eq!(lock.blobs.len(), 1);
2538
2539        let (meta, _) = crate::blob::read_cnb(&lock.blobs[0].path).expect("blob 0 parses");
2540        assert_eq!(
2541            meta.defs.len(),
2542            lock.assets.len(),
2543            "the lock names every def the blob ships"
2544        );
2545        assert_eq!(meta.resources.len(), lock.resources.len());
2546        assert!(lock.assets.iter().any(|a| a.name == "pause"));
2547
2548        let font = lock
2549            .resources
2550            .iter()
2551            .find(|r| r.name == "f")
2552            .expect("the font is recorded in the resource stream");
2553        assert_eq!(font.kind, "Font");
2554        assert_eq!(font.payload_blob, Some(0));
2555        assert!(
2556            !lock.injected.is_empty(),
2557            "engine defaults are recorded so they can be overridden"
2558        );
2559    }
2560
2561    #[test]
2562    fn build_pipeline_from_str_rejects_malformed_jsonl() {
2563        let Err(err) = build_pipeline_from_str("{not json\n", None, None) else {
2564            panic!("malformed line must not build");
2565        };
2566        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2567    }
2568
2569    #[test]
2570    fn build_pipeline_from_str_reports_unknown_asset_types() {
2571        let world = r#"{"name":"mystery","type":"NotAType","args":{}}"#;
2572        let Err(err) = build_pipeline_from_str(world, None, None) else {
2573            panic!("unknown type must not build");
2574        };
2575        assert!(
2576            err.to_string().contains("NotAType"),
2577            "error should name the unknown type: {err}"
2578        );
2579    }
2580
2581    #[test]
2582    fn validate_asset_accepts_build_time_expansion_types() {
2583        // Build-time types are expanded before the runtime registry sees
2584        // them, so they validate structurally regardless of args.
2585        for ty in [
2586            "SceneImport",
2587            "Environment",
2588            "LightRig",
2589            "Prefab",
2590            "CharacterSchema",
2591            "CharacterModel",
2592        ] {
2593            validate_asset(ty, "x", &serde_json::json!({}))
2594                .unwrap_or_else(|e| panic!("{ty} should validate: {e}"));
2595        }
2596    }
2597
2598    // A resource-only type never builds a component def, so it is validated
2599    // through the structural check alone rather than `create_asset_def`.
2600    #[test]
2601    fn validate_asset_routes_resource_only_types_past_the_component_registry() {
2602        validate_asset("AudioClip", "clip", &serde_json::json!({"source": "a.wav"}))
2603            .expect("a source-backed AudioClip validates");
2604        let err = validate_asset("Texture", "tex", &serde_json::json!({"generator": "nope"}))
2605            .expect_err("an unknown texture generator is rejected");
2606        assert!(err.contains("nope"), "got: {err}");
2607    }
2608
2609    // A type that resolves through `create_asset_def` still has to satisfy its
2610    // structural check, and a clean asset returns Ok.
2611    #[test]
2612    fn validate_asset_runs_the_structural_check_after_type_resolution() {
2613        validate_asset("Scene", "day", &serde_json::json!({})).expect("a Scene validates");
2614        // A Prop resolves as a type but has no mesh source to render.
2615        let err = validate_asset("Prop", "empty_prop", &serde_json::json!({}))
2616            .expect_err("a source-less Prop is rejected");
2617        assert!(err.contains("empty_prop"), "got: {err}");
2618    }
2619
2620    #[test]
2621    fn validate_asset_unknown_type_mentions_the_asset_name() {
2622        let err =
2623            validate_asset("Bogus", "my_thing", &serde_json::json!({})).expect_err("unknown type");
2624        assert!(err.contains("my_thing"), "got: {err}");
2625    }
2626
2627    #[test]
2628    fn validate_asset_bad_args_mention_the_asset_name() {
2629        // `generator` must be a string; a number fails args deserialization.
2630        let err = validate_asset(
2631            "ProceduralMesh",
2632            "bad_mesh",
2633            &serde_json::json!({"generator": 5}),
2634        )
2635        .expect_err("bad args");
2636        assert!(err.contains("bad_mesh"), "got: {err}");
2637    }
2638
2639    #[test]
2640    fn desugar_gltf_skinned_meshes_leaves_inline_and_cached_untouched() {
2641        let inline_args = serde_json::json!({"vertices": [], "indices": []});
2642        let cached_args = serde_json::json!({"source": "/no/such/hero.glb"});
2643        let mut assets = vec![
2644            wja("inline", SKINNED_MESH_TYPE, inline_args.clone()),
2645            wja("cached", SKINNED_MESH_TYPE, cached_args.clone()),
2646        ];
2647        desugar_gltf_skinned_meshes(&mut assets, &hit_cache("cached"), None).expect("desugar");
2648        // No source: untouched. Cache hit: the missing .glb is never parsed
2649        // and the args stay pre-desugar so the next probe key matches.
2650        assert_eq!(assets[0].args, inline_args);
2651        assert_eq!(assets[1].args, cached_args);
2652    }
2653
2654    // Write a fixture container into `dir` and return its path as a string.
2655    fn write_fixture(dir: &tempfile::TempDir, name: &str, bytes: &[u8]) -> String {
2656        let path = dir.path().join(name);
2657        std::fs::write(&path, bytes).expect("write fixture");
2658        path.to_string_lossy().into_owned()
2659    }
2660
2661    // A source-backed SkinnedMesh has its geometry and skeleton written into
2662    // the asset's inline args, replacing the `source` reference for the
2663    // compile step that follows.
2664    #[test]
2665    fn desugar_gltf_skinned_meshes_inlines_geometry_and_skeleton() {
2666        let dir = tempfile::tempdir().expect("tempdir");
2667        let src = write_fixture(&dir, "hero.glb", &crate::glb::test_fixtures::skinned_glb());
2668        let mut assets = vec![wja(
2669            "hero",
2670            SKINNED_MESH_TYPE,
2671            serde_json::json!({"source": src}),
2672        )];
2673        desugar_gltf_skinned_meshes(&mut assets, &Default::default(), None).expect("desugar");
2674
2675        let args = &assets[0].args;
2676        assert_eq!(args["vertices"].as_array().unwrap().len(), 3);
2677        assert_eq!(args["indices"].as_array().unwrap(), &vec![0, 1, 2]);
2678        // The two-joint skin is reordered parents-before-children, so the
2679        // skeleton lands with the root first.
2680        assert_eq!(args["skeleton"].as_array().unwrap().len(), 2);
2681        // The fixture carries no morph targets, so no morph args appear.
2682        assert!(args.get("morph_target_names").is_none());
2683        assert!(args.get("morph_deltas").is_none());
2684    }
2685
2686    // The shared skinned fixture with one morph target ("bulge", +Y on every
2687    // vertex) and an animation channel driving its weight from 0 to 1.
2688    fn morphing_skinned_glb() -> Vec<u8> {
2689        use crate::glb::test_fixtures::{f32s, make_glb, skinned_bin, skinned_json};
2690
2691        let mut bin = skinned_bin(); // 136 bytes
2692        bin.extend(f32s(&[0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0])); // deltas -> 172
2693        bin.extend(f32s(&[0.0, 1.0])); // morph weights -> 180
2694
2695        let mut json = skinned_json(true, true, true);
2696        json["buffers"][0]["byteLength"] = 180.into();
2697        let views = json["bufferViews"].as_array_mut().expect("bufferViews");
2698        views.push(serde_json::json!({"buffer": 0, "byteOffset": 136, "byteLength": 36}));
2699        views.push(serde_json::json!({"buffer": 0, "byteOffset": 172, "byteLength": 8}));
2700        let accessors = json["accessors"].as_array_mut().expect("accessors");
2701        accessors.push(
2702            serde_json::json!({"bufferView": 6, "componentType": 5126, "count": 3, "type": "VEC3"}),
2703        );
2704        accessors.push(serde_json::json!(
2705            {"bufferView": 7, "componentType": 5126, "count": 2, "type": "SCALAR"}
2706        ));
2707        json["meshes"][0]["primitives"][0]["targets"] = serde_json::json!([{"POSITION": 6}]);
2708        json["meshes"][0]["extras"] = serde_json::json!({"targetNames": ["bulge"]});
2709        json["animations"][0]["samplers"]
2710            .as_array_mut()
2711            .expect("samplers")
2712            .push(serde_json::json!({"input": 4, "output": 7, "interpolation": "LINEAR"}));
2713        json["animations"][0]["channels"]
2714            .as_array_mut()
2715            .expect("channels")
2716            .push(serde_json::json!({"sampler": 2, "target": {"node": 0, "path": "weights"}}));
2717
2718        make_glb(&json, Some(&bin))
2719    }
2720
2721    // A source carrying morph targets writes the target names and the dense
2722    // delta block into the asset alongside the base geometry.
2723    #[test]
2724    fn desugar_gltf_skinned_meshes_inlines_morph_targets() {
2725        let dir = tempfile::tempdir().expect("tempdir");
2726        let src = write_fixture(&dir, "hero.glb", &morphing_skinned_glb());
2727        let mut assets = vec![wja(
2728            "hero",
2729            SKINNED_MESH_TYPE,
2730            serde_json::json!({"source": src}),
2731        )];
2732        desugar_gltf_skinned_meshes(&mut assets, &Default::default(), None).expect("desugar");
2733
2734        let args = &assets[0].args;
2735        assert_eq!(args["morph_target_names"], serde_json::json!(["bulge"]));
2736        // One target over three vertices: a dense target-major delta block.
2737        assert_eq!(args["morph_deltas"].as_array().unwrap().len(), 3);
2738    }
2739
2740    // A clip that animates morph weights carries a morph track beside its
2741    // joint tracks.
2742    #[test]
2743    fn desugar_animation_imports_inlines_a_morph_weight_track() {
2744        let dir = tempfile::tempdir().expect("tempdir");
2745        let src = write_fixture(&dir, "hero.glb", &morphing_skinned_glb());
2746        let mut assets = vec![wja(
2747            "wave",
2748            "Animation",
2749            serde_json::json!({"source": src, "animation_index": 0}),
2750        )];
2751        desugar_animation_imports(&mut assets, None).expect("desugar");
2752
2753        let morph = assets[0].args["morph_track"]
2754            .as_array()
2755            .expect("morph track inlined");
2756        assert_eq!(morph.len(), 2);
2757        assert_eq!(morph[0], serde_json::json!({"time": 0.0, "weights": [0.0]}));
2758        assert_eq!(morph[1], serde_json::json!({"time": 1.0, "weights": [1.0]}));
2759    }
2760
2761    #[test]
2762    fn desugar_gltf_skinned_meshes_missing_source_errors() {
2763        let mut assets = vec![wja(
2764            "hero",
2765            SKINNED_MESH_TYPE,
2766            serde_json::json!({"source": "/no/such/hero.glb"}),
2767        )];
2768        let err = desugar_gltf_skinned_meshes(&mut assets, &Default::default(), None)
2769            .expect_err("missing .glb");
2770        assert!(err.to_string().contains("Asset 'hero'"), "got: {err}");
2771    }
2772
2773    #[test]
2774    fn desugar_gltf_meshes_skips_non_glb_sources_and_cache_hits() {
2775        let fbx_args = serde_json::json!({"source": "/no/such/scene.fbx"});
2776        let cached_args = serde_json::json!({"source": "/no/such/scene.glb"});
2777        let inline_args = serde_json::json!({"vertices": [], "indices": []});
2778        let mut assets = vec![
2779            wja("from_fbx", MESH_TYPE, fbx_args.clone()),
2780            wja("cached", MESH_TYPE, cached_args.clone()),
2781            wja("inline", MESH_TYPE, inline_args.clone()),
2782        ];
2783        desugar_gltf_meshes(&mut assets, &hit_cache("cached"), None).expect("desugar");
2784        assert_eq!(
2785            assets[0].args, fbx_args,
2786            ".fbx sources belong to the fbx pass"
2787        );
2788        assert_eq!(assets[1].args, cached_args, "cache hit skips the parse");
2789        assert_eq!(assets[2].args, inline_args, "no source: untouched");
2790    }
2791
2792    #[test]
2793    fn desugar_gltf_meshes_missing_source_errors() {
2794        let mut assets = vec![wja(
2795            "crate_mesh",
2796            MESH_TYPE,
2797            serde_json::json!({"source": "/no/such/scene.glb"}),
2798        )];
2799        let err =
2800            desugar_gltf_meshes(&mut assets, &Default::default(), None).expect_err("missing .glb");
2801        assert!(err.to_string().contains("Asset 'crate_mesh'"), "got: {err}");
2802    }
2803
2804    // The text `.gltf` container flows through the same desugar as `.glb`:
2805    // geometry lands inline from the external `.bin` beside the source.
2806    #[test]
2807    fn desugar_gltf_meshes_imports_a_text_gltf_with_an_external_buffer() {
2808        let dir = tempfile::tempdir().unwrap();
2809        std::fs::write(
2810            dir.path().join("geo.bin"),
2811            crate::glb::test_fixtures::static_triangle_bin(),
2812        )
2813        .unwrap();
2814        let mut json = crate::glb::test_fixtures::static_triangle_json();
2815        json["buffers"][0]["uri"] = "geo.bin".into();
2816        let gltf = dir.path().join("tri.gltf");
2817        std::fs::write(&gltf, serde_json::to_vec(&json).unwrap()).unwrap();
2818
2819        let mut assets = vec![wja(
2820            "tri",
2821            MESH_TYPE,
2822            serde_json::json!({"source": gltf.to_str().unwrap(), "primitive_index": 0}),
2823        )];
2824        desugar_gltf_meshes(&mut assets, &Default::default(), None).expect("desugar");
2825        let vertices = assets[0].args.get("vertices").expect("inline vertices");
2826        assert_eq!(vertices.as_array().unwrap().len(), 3);
2827        assert_eq!(
2828            assets[0]
2829                .args
2830                .get("indices")
2831                .unwrap()
2832                .as_array()
2833                .unwrap()
2834                .len(),
2835            3
2836        );
2837    }
2838
2839    // Two Mesh assets fanned out of one container parse the file once; both
2840    // still land with their own inline geometry.
2841    #[test]
2842    fn desugar_gltf_meshes_parses_a_shared_source_once_for_every_asset() {
2843        let dir = tempfile::tempdir().expect("tempdir");
2844        let src = write_fixture(
2845            &dir,
2846            "scene.glb",
2847            &crate::glb::test_fixtures::static_triangle_glb(),
2848        );
2849        let mut assets = vec![
2850            wja(
2851                "part_a",
2852                MESH_TYPE,
2853                serde_json::json!({"source": src, "primitive_index": 0}),
2854            ),
2855            wja(
2856                "part_b",
2857                MESH_TYPE,
2858                serde_json::json!({"source": src, "primitive_index": 0}),
2859            ),
2860        ];
2861        desugar_gltf_meshes(&mut assets, &Default::default(), None).expect("desugar");
2862        for asset in &assets {
2863            assert_eq!(asset.args["vertices"].as_array().unwrap().len(), 3);
2864            assert_eq!(asset.args["indices"].as_array().unwrap().len(), 3);
2865        }
2866    }
2867
2868    // A primitive the container does not have fails on both the chunked and
2869    // the whole-primitive route, naming the asset either way.
2870    #[test]
2871    fn desugar_gltf_meshes_reports_a_primitive_the_file_does_not_have() {
2872        let dir = tempfile::tempdir().expect("tempdir");
2873        let src = write_fixture(
2874            &dir,
2875            "scene.glb",
2876            &crate::glb::test_fixtures::static_triangle_glb(),
2877        );
2878        for extra in [serde_json::json!({}), serde_json::json!({"chunk_index": 0})] {
2879            let mut args = serde_json::json!({"source": src, "primitive_index": 7});
2880            for (k, v) in extra.as_object().unwrap() {
2881                args[k] = v.clone();
2882            }
2883            let mut assets = vec![wja("ghost", MESH_TYPE, args)];
2884            let err = desugar_gltf_meshes(&mut assets, &Default::default(), None)
2885                .expect_err("primitive 7 does not exist");
2886            let msg = err.to_string();
2887            assert!(msg.contains("Asset 'ghost'"), "got: {msg}");
2888            assert!(msg.contains("glTF import failed"), "got: {msg}");
2889        }
2890    }
2891
2892    // An oversized primitive fanned into several chunked Mesh assets is split
2893    // exactly once; every asset still gets its own inline geometry.
2894    #[test]
2895    fn desugar_gltf_meshes_splits_a_primitive_once_for_every_chunk_asset() {
2896        let dir = tempfile::tempdir().expect("tempdir");
2897        let src = write_fixture(
2898            &dir,
2899            "scene.glb",
2900            &crate::glb::test_fixtures::static_triangle_glb(),
2901        );
2902        let chunk = |name: &str| {
2903            wja(
2904                name,
2905                MESH_TYPE,
2906                serde_json::json!({"source": src, "primitive_index": 0, "chunk_index": 0}),
2907            )
2908        };
2909        let mut assets = vec![chunk("part_a"), chunk("part_b")];
2910        desugar_gltf_meshes(&mut assets, &Default::default(), None).expect("desugar");
2911        for asset in &assets {
2912            assert_eq!(asset.args["vertices"].as_array().unwrap().len(), 3);
2913        }
2914    }
2915
2916    // An authored `chunk_index` routes through the u16 chunk split instead of
2917    // the whole-primitive import; an index past the last chunk names the file,
2918    // the primitive, and how many chunks it really produced.
2919    #[test]
2920    fn desugar_gltf_meshes_reads_a_chunk_and_rejects_one_out_of_range() {
2921        let dir = tempfile::tempdir().expect("tempdir");
2922        let src = write_fixture(
2923            &dir,
2924            "scene.glb",
2925            &crate::glb::test_fixtures::static_triangle_glb(),
2926        );
2927        let mut assets = vec![wja(
2928            "chunk0",
2929            MESH_TYPE,
2930            serde_json::json!({"source": src, "chunk_index": 0}),
2931        )];
2932        desugar_gltf_meshes(&mut assets, &Default::default(), None).expect("desugar");
2933        assert_eq!(assets[0].args["vertices"].as_array().unwrap().len(), 3);
2934
2935        let mut past_end = vec![wja(
2936            "chunk9",
2937            MESH_TYPE,
2938            serde_json::json!({"source": src, "chunk_index": 9}),
2939        )];
2940        let err = desugar_gltf_meshes(&mut past_end, &Default::default(), None)
2941            .expect_err("chunk 9 does not exist");
2942        let msg = err.to_string();
2943        assert!(msg.contains("Asset 'chunk9'"), "got: {msg}");
2944        assert!(msg.contains("chunk_index 9 out of range"), "got: {msg}");
2945        assert!(msg.contains("1 chunk(s)"), "got: {msg}");
2946    }
2947
2948    // Synthetic binary FBX containers. The importer walks a node tree, so the
2949    // fixtures are described as one and serialized by `write_fbx`; no binary
2950    // asset needs to live in the repo.
2951    enum Attr {
2952        Int(i64),
2953        Double(f64),
2954        Text(String),
2955        Doubles(Vec<f64>),
2956        Ints(Vec<i32>),
2957        Longs(Vec<i64>),
2958        Floats(Vec<f32>),
2959    }
2960
2961    struct Node {
2962        name: &'static str,
2963        attrs: Vec<Attr>,
2964        children: Vec<Node>,
2965    }
2966
2967    fn node(name: &'static str, attrs: Vec<Attr>, children: Vec<Node>) -> Node {
2968        Node {
2969            name,
2970            attrs,
2971            children,
2972        }
2973    }
2974
2975    // An FBX object's second attribute: the authored name, the object class,
2976    // and the `\0\u{1}` separator between them.
2977    fn object_name(name: &str, class: &str) -> Attr {
2978        Attr::Text(format!("{name}\u{0}\u{1}{class}"))
2979    }
2980
2981    fn connection(child: i64, parent: i64) -> Node {
2982        node(
2983            "C",
2984            vec![
2985                Attr::Text("OO".to_string()),
2986                Attr::Int(child),
2987                Attr::Int(parent),
2988            ],
2989            Vec::new(),
2990        )
2991    }
2992
2993    // An object-to-property connection: the parent's named property is what
2994    // the child drives.
2995    fn property_connection(child: i64, parent: i64, property: &str) -> Node {
2996        node(
2997            "C",
2998            vec![
2999                Attr::Text("OP".to_string()),
3000                Attr::Int(child),
3001                Attr::Int(parent),
3002                Attr::Text(property.to_string()),
3003            ],
3004            Vec::new(),
3005        )
3006    }
3007
3008    fn write_fbx(nodes: &[Node]) -> Vec<u8> {
3009        use fbxcel::low::FbxVersion;
3010        use fbxcel::writer::v7400::binary::{FbxFooter, Writer};
3011
3012        fn emit<W: std::io::Write + std::io::Seek>(
3013            w: &mut Writer<W>,
3014            n: &Node,
3015        ) -> std::io::Result<()> {
3016            {
3017                let mut attrs = w.new_node(n.name).expect("open node");
3018                for a in &n.attrs {
3019                    match a {
3020                        Attr::Int(v) => attrs.append_i64(*v),
3021                        Attr::Double(v) => attrs.append_f64(*v),
3022                        Attr::Text(s) => attrs.append_string_direct(s),
3023                        Attr::Doubles(v) => attrs.append_arr_f64_from_iter(None, v.iter().copied()),
3024                        Attr::Ints(v) => attrs.append_arr_i32_from_iter(None, v.iter().copied()),
3025                        Attr::Longs(v) => attrs.append_arr_i64_from_iter(None, v.iter().copied()),
3026                        Attr::Floats(v) => attrs.append_arr_f32_from_iter(None, v.iter().copied()),
3027                    }
3028                    .expect("append attribute");
3029                }
3030            }
3031            for c in &n.children {
3032                emit(w, c)?;
3033            }
3034            w.close_node().expect("close node");
3035            Ok(())
3036        }
3037
3038        let mut w =
3039            Writer::new(std::io::Cursor::new(Vec::new()), FbxVersion::V7_4).expect("fbx writer");
3040        for n in nodes {
3041            emit(&mut w, n).expect("emit node");
3042        }
3043        w.finalize_and_flush(&FbxFooter::default())
3044            .expect("finalize")
3045            .into_inner()
3046    }
3047
3048    // One triangle as an FBX Geometry object. The last corner of a polygon is
3049    // stored bitwise-negated, which is how the importer finds polygon bounds.
3050    fn triangle_geometry(id: i64) -> Node {
3051        node(
3052            "Geometry",
3053            vec![
3054                Attr::Int(id),
3055                object_name("tri", "Geometry"),
3056                Attr::Text("Mesh".to_string()),
3057            ],
3058            vec![
3059                node(
3060                    "Vertices",
3061                    vec![Attr::Doubles(vec![
3062                        0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0,
3063                    ])],
3064                    Vec::new(),
3065                ),
3066                node(
3067                    "PolygonVertexIndex",
3068                    vec![Attr::Ints(vec![0, 1, !2])],
3069                    Vec::new(),
3070                ),
3071            ],
3072        )
3073    }
3074
3075    // One Model connected to one Geometry: `parse_fbx` yields a single
3076    // primitive from it.
3077    fn static_triangle_fbx() -> Vec<u8> {
3078        const GEOMETRY: i64 = 1000;
3079        const MODEL: i64 = 2000;
3080        write_fbx(&[
3081            node(
3082                "Objects",
3083                Vec::new(),
3084                vec![
3085                    triangle_geometry(GEOMETRY),
3086                    node(
3087                        "Model",
3088                        vec![
3089                            Attr::Int(MODEL),
3090                            object_name("tri", "Model"),
3091                            Attr::Text("Mesh".to_string()),
3092                        ],
3093                        Vec::new(),
3094                    ),
3095                ],
3096            ),
3097            node("Connections", Vec::new(), vec![connection(GEOMETRY, MODEL)]),
3098        ])
3099    }
3100
3101    // FBX time unit: ticks per second.
3102    const KTIME_PER_SEC: i64 = 46_186_158_000;
3103
3104    fn skinned_triangle_fbx() -> Vec<u8> {
3105        skinned_fbx(false)
3106    }
3107
3108    // The same triangle bound to a one-bone skin: a Skin deformer over the
3109    // geometry, a Cluster linking every control point to the bone Model at an
3110    // identity bind, and a unit scale of 100 so file units are already meters.
3111    // With `animated`, a one-second stack slides the bone 0 -> 2 along X.
3112    fn skinned_fbx(animated: bool) -> Vec<u8> {
3113        const GEOMETRY: i64 = 3000;
3114        const MESH_MODEL: i64 = 4000;
3115        const BONE: i64 = 5000;
3116        const SKIN: i64 = 6000;
3117        const CLUSTER: i64 = 7000;
3118        const STACK: i64 = 8000;
3119        const LAYER: i64 = 8100;
3120        const CURVE_NODE: i64 = 8200;
3121        const CURVE: i64 = 8300;
3122        let identity = vec![
3123            1.0, 0.0, 0.0, 0.0, //
3124            0.0, 1.0, 0.0, 0.0, //
3125            0.0, 0.0, 1.0, 0.0, //
3126            0.0, 0.0, 0.0, 1.0,
3127        ];
3128        let unit_scale = node(
3129            "GlobalSettings",
3130            Vec::new(),
3131            vec![node(
3132                "Properties70",
3133                Vec::new(),
3134                vec![node(
3135                    "P",
3136                    vec![
3137                        Attr::Text("UnitScaleFactor".to_string()),
3138                        Attr::Text("double".to_string()),
3139                        Attr::Text("Number".to_string()),
3140                        Attr::Text(String::new()),
3141                        Attr::Double(100.0),
3142                    ],
3143                    Vec::new(),
3144                )],
3145            )],
3146        );
3147        let mut objects = vec![
3148            triangle_geometry(GEOMETRY),
3149            node(
3150                "Model",
3151                vec![
3152                    Attr::Int(MESH_MODEL),
3153                    object_name("mesh", "Model"),
3154                    Attr::Text("Mesh".to_string()),
3155                ],
3156                Vec::new(),
3157            ),
3158            node(
3159                "Model",
3160                vec![
3161                    Attr::Int(BONE),
3162                    object_name("Root", "Model"),
3163                    Attr::Text("LimbNode".to_string()),
3164                ],
3165                Vec::new(),
3166            ),
3167            node(
3168                "Deformer",
3169                vec![
3170                    Attr::Int(SKIN),
3171                    object_name("skin", "Deformer"),
3172                    Attr::Text("Skin".to_string()),
3173                ],
3174                Vec::new(),
3175            ),
3176            node(
3177                "Deformer",
3178                vec![
3179                    Attr::Int(CLUSTER),
3180                    object_name("cluster", "SubDeformer"),
3181                    Attr::Text("Cluster".to_string()),
3182                ],
3183                vec![
3184                    node("Indexes", vec![Attr::Ints(vec![0, 1, 2])], Vec::new()),
3185                    node(
3186                        "Weights",
3187                        vec![Attr::Doubles(vec![1.0, 1.0, 1.0])],
3188                        Vec::new(),
3189                    ),
3190                    node(
3191                        "TransformLink",
3192                        vec![Attr::Doubles(identity.clone())],
3193                        Vec::new(),
3194                    ),
3195                    node("Transform", vec![Attr::Doubles(identity)], Vec::new()),
3196                ],
3197            ),
3198        ];
3199        let mut connections = vec![
3200            connection(GEOMETRY, MESH_MODEL),
3201            connection(SKIN, GEOMETRY),
3202            connection(CLUSTER, SKIN),
3203            connection(BONE, CLUSTER),
3204        ];
3205
3206        if animated {
3207            objects.extend([
3208                node(
3209                    "AnimationStack",
3210                    vec![Attr::Int(STACK), object_name("wave", "AnimStack")],
3211                    Vec::new(),
3212                ),
3213                node(
3214                    "AnimationLayer",
3215                    vec![Attr::Int(LAYER), object_name("Base Layer", "AnimLayer")],
3216                    Vec::new(),
3217                ),
3218                node(
3219                    "AnimationCurveNode",
3220                    vec![Attr::Int(CURVE_NODE), object_name("T", "AnimCurveNode")],
3221                    Vec::new(),
3222                ),
3223                node(
3224                    "AnimationCurve",
3225                    vec![Attr::Int(CURVE), object_name("", "AnimCurve")],
3226                    vec![
3227                        node(
3228                            "KeyTime",
3229                            vec![Attr::Longs(vec![0, KTIME_PER_SEC])],
3230                            Vec::new(),
3231                        ),
3232                        node(
3233                            "KeyValueFloat",
3234                            vec![Attr::Floats(vec![0.0, 2.0])],
3235                            Vec::new(),
3236                        ),
3237                    ],
3238                ),
3239            ]);
3240            connections.extend([
3241                connection(LAYER, STACK),
3242                connection(CURVE_NODE, LAYER),
3243                property_connection(CURVE, CURVE_NODE, "d|X"),
3244                property_connection(CURVE_NODE, BONE, "Lcl Translation"),
3245            ]);
3246        }
3247
3248        write_fbx(&[
3249            unit_scale,
3250            node("Objects", Vec::new(), objects),
3251            node("Connections", Vec::new(), connections),
3252        ])
3253    }
3254
3255    #[test]
3256    fn fbx_fixture_parses_into_one_primitive() {
3257        let dir = tempfile::tempdir().expect("tempdir");
3258        let src = write_fixture(&dir, "tri.fbx", &static_triangle_fbx());
3259        let scene = crate::fbx::parse_fbx(&src).expect("fixture parses");
3260        let (vertices, indices) =
3261            crate::fbx::read_primitive_geometry(&scene, 0).expect("primitive 0");
3262        assert_eq!(vertices.len(), 3);
3263        assert_eq!(indices, vec![0, 1, 2]);
3264    }
3265
3266    // A `.fbx`-sourced Mesh lands with inline geometry, and several assets fanned
3267    // out of one file share a single parse and a single chunk split.
3268    #[test]
3269    fn desugar_fbx_meshes_inlines_geometry_for_every_asset_sharing_a_source() {
3270        let dir = tempfile::tempdir().expect("tempdir");
3271        let src = write_fixture(&dir, "scene.fbx", &static_triangle_fbx());
3272        let mut assets = vec![
3273            wja(
3274                "part_a",
3275                MESH_TYPE,
3276                serde_json::json!({"source": src, "primitive_index": 0}),
3277            ),
3278            wja(
3279                "part_b",
3280                MESH_TYPE,
3281                serde_json::json!({"source": src, "primitive_index": 0, "chunk_index": 0}),
3282            ),
3283        ];
3284        desugar_fbx_meshes(&mut assets, &Default::default()).expect("desugar");
3285        for asset in &assets {
3286            assert_eq!(asset.args["vertices"].as_array().unwrap().len(), 3);
3287            assert_eq!(asset.args["indices"].as_array().unwrap(), &vec![0, 1, 2]);
3288        }
3289    }
3290
3291    // The two failure modes past the parse: a primitive the file does not have,
3292    // and a chunk index past the split.
3293    #[test]
3294    fn desugar_fbx_meshes_rejects_an_unknown_primitive_and_chunk() {
3295        let dir = tempfile::tempdir().expect("tempdir");
3296        let src = write_fixture(&dir, "scene.fbx", &static_triangle_fbx());
3297
3298        let mut ghost = vec![wja(
3299            "ghost",
3300            MESH_TYPE,
3301            serde_json::json!({"source": src, "primitive_index": 7}),
3302        )];
3303        let err =
3304            desugar_fbx_meshes(&mut ghost, &Default::default()).expect_err("primitive 7 is absent");
3305        let msg = err.to_string();
3306        assert!(msg.contains("Asset 'ghost'"), "got: {msg}");
3307        assert!(msg.contains("FBX import failed"), "got: {msg}");
3308
3309        let mut past_end = vec![wja(
3310            "chunk9",
3311            MESH_TYPE,
3312            serde_json::json!({"source": src, "chunk_index": 9}),
3313        )];
3314        let err = desugar_fbx_meshes(&mut past_end, &Default::default())
3315            .expect_err("chunk 9 is past the split");
3316        let msg = err.to_string();
3317        assert!(msg.contains("chunk_index 9 out of range"), "got: {msg}");
3318        assert!(msg.contains("1 chunk(s)"), "got: {msg}");
3319    }
3320
3321    // A `.fbx`-sourced SkinnedMesh lands with inline geometry and a skeleton,
3322    // mirroring the glTF pass; the `source` reference is what the compile step
3323    // no longer needs.
3324    #[test]
3325    fn desugar_fbx_skinned_meshes_inlines_geometry_and_skeleton() {
3326        let dir = tempfile::tempdir().expect("tempdir");
3327        let src = write_fixture(&dir, "hero.fbx", &skinned_triangle_fbx());
3328        let mut assets = vec![wja(
3329            "hero",
3330            SKINNED_MESH_TYPE,
3331            serde_json::json!({"source": src}),
3332        )];
3333        desugar_fbx_skinned_meshes(&mut assets, &Default::default()).expect("desugar");
3334
3335        let args = &assets[0].args;
3336        assert_eq!(args["vertices"].as_array().unwrap().len(), 3);
3337        assert_eq!(args["indices"].as_array().unwrap(), &vec![0, 1, 2]);
3338        let skeleton = args["skeleton"].as_array().expect("skeleton inlined");
3339        assert_eq!(skeleton.len(), 1);
3340        assert_eq!(skeleton[0]["name"], "Root");
3341        assert_eq!(skeleton[0]["parent"], -1);
3342        // Every control point binds fully to the single cluster bone.
3343        assert_eq!(
3344            args["vertices"][0]["weights"],
3345            serde_json::json!([1.0, 0.0, 0.0, 0.0])
3346        );
3347    }
3348
3349    #[test]
3350    fn desugar_skinned_meshes_import_the_selected_skin() {
3351        use crate::glb::test_fixtures::two_skin_glb;
3352
3353        let dir = tempfile::tempdir().expect("tempdir");
3354        let src = write_fixture(&dir, "hero.glb", &two_skin_glb());
3355        let mut assets = vec![
3356            wja(
3357                "body",
3358                SKINNED_MESH_TYPE,
3359                serde_json::json!({"source": src, "skin_index": 0}),
3360            ),
3361            wja(
3362                "hair",
3363                SKINNED_MESH_TYPE,
3364                serde_json::json!({"source": src, "skin_index": 1}),
3365            ),
3366        ];
3367        desugar_gltf_skinned_meshes(&mut assets, &Default::default(), None).expect("desugar");
3368
3369        // Each asset inlines its own part's geometry.
3370        assert_eq!(
3371            assets[0].args["vertices"][0]["pos"],
3372            serde_json::json!([0.0, 0.0, 0.0])
3373        );
3374        assert_eq!(
3375            assets[1].args["vertices"][0]["pos"],
3376            serde_json::json!([5.0, 0.0, 0.0])
3377        );
3378    }
3379
3380    #[test]
3381    fn desugar_animation_imports_inherit_the_targets_skin() {
3382        use crate::glb::test_fixtures::two_skin_glb;
3383
3384        let dir = tempfile::tempdir().expect("tempdir");
3385        let src = write_fixture(&dir, "hero.glb", &two_skin_glb());
3386        let mut assets = vec![
3387            wja(
3388                "hair",
3389                SKINNED_MESH_TYPE,
3390                serde_json::json!({"source": src, "skin_index": 1}),
3391            ),
3392            wja(
3393                "hair_wave",
3394                "Animation",
3395                serde_json::json!({"target": "hair", "source": src}),
3396            ),
3397        ];
3398        // The clip carries no selector of its own; resolving it against the
3399        // target's skin is what keeps the joint indices in the same space.
3400        assert_eq!(skin_index_by_target(&assets).get("hair"), Some(&1));
3401        desugar_animation_imports(&mut assets, None).expect("desugar");
3402        assert!(
3403            !assets[1].args["tracks"]
3404                .as_array()
3405                .expect("tracks")
3406                .is_empty()
3407        );
3408    }
3409
3410    #[test]
3411    fn an_animation_without_a_resolvable_target_falls_back_to_the_first_skin() {
3412        let assets = vec![
3413            wja(
3414                "body",
3415                SKINNED_MESH_TYPE,
3416                serde_json::json!({"skin_index": 2}),
3417            ),
3418            wja("orphan", "Animation", serde_json::json!({"target": "gone"})),
3419        ];
3420        let by_target = skin_index_by_target(&assets);
3421        assert_eq!(by_target.get("body"), Some(&2));
3422        assert!(!by_target.contains_key("gone"));
3423    }
3424
3425    // `.glb` sources belong to the glTF pass, and a probe hit means the
3426    // compiled payload is already in hand; neither is parsed here.
3427    #[test]
3428    fn desugar_fbx_skinned_meshes_skips_glb_sources_and_cache_hits() {
3429        let glb_args = serde_json::json!({"source": "/no/such/hero.glb"});
3430        let cached_args = serde_json::json!({"source": "/no/such/hero.fbx"});
3431        let mut assets = vec![
3432            wja("from_glb", SKINNED_MESH_TYPE, glb_args.clone()),
3433            wja("cached", SKINNED_MESH_TYPE, cached_args.clone()),
3434        ];
3435        desugar_fbx_skinned_meshes(&mut assets, &hit_cache("cached")).expect("desugar");
3436        assert_eq!(assets[0].args, glb_args);
3437        assert_eq!(assets[1].args, cached_args);
3438    }
3439
3440    // A file with no skin deformer is a hard error, named against the asset.
3441    #[test]
3442    fn desugar_fbx_skinned_meshes_reports_a_file_without_a_skin() {
3443        let dir = tempfile::tempdir().expect("tempdir");
3444        let src = write_fixture(&dir, "static.fbx", &static_triangle_fbx());
3445        let mut assets = vec![wja(
3446            "hero",
3447            SKINNED_MESH_TYPE,
3448            serde_json::json!({"source": src}),
3449        )];
3450        let err = desugar_fbx_skinned_meshes(&mut assets, &Default::default())
3451            .expect_err("a static file has no skin");
3452        let msg = err.to_string();
3453        assert!(msg.contains("Asset 'hero'"), "got: {msg}");
3454        assert!(msg.contains("FBX import failed"), "got: {msg}");
3455    }
3456
3457    #[test]
3458    fn desugar_fbx_meshes_missing_source_errors() {
3459        let mut assets = vec![wja(
3460            "bistro",
3461            MESH_TYPE,
3462            serde_json::json!({"source": "/no/such/scene.fbx"}),
3463        )];
3464        let err = desugar_fbx_meshes(&mut assets, &Default::default()).expect_err("missing .fbx");
3465        assert!(err.to_string().contains("Asset 'bistro'"), "got: {err}");
3466    }
3467
3468    #[test]
3469    fn desugar_fbx_meshes_skips_cache_hits_and_non_fbx_sources() {
3470        let cached_args = serde_json::json!({"source": "/no/such/scene.fbx"});
3471        let glb_args = serde_json::json!({"source": "/no/such/scene.glb"});
3472        let mut assets = vec![
3473            wja("cached", MESH_TYPE, cached_args.clone()),
3474            wja("from_glb", MESH_TYPE, glb_args.clone()),
3475        ];
3476        desugar_fbx_meshes(&mut assets, &hit_cache("cached")).expect("desugar");
3477        assert_eq!(assets[0].args, cached_args);
3478        assert_eq!(assets[1].args, glb_args);
3479    }
3480
3481    // An `.fbx` source routes to the FBX importer, which bakes the clip at the
3482    // asset's `sample_rate` rather than replaying authored keys.
3483    #[test]
3484    fn desugar_animation_imports_bakes_an_fbx_clip_at_the_sample_rate() {
3485        let dir = tempfile::tempdir().expect("tempdir");
3486        let src = write_fixture(&dir, "hero.fbx", &skinned_fbx(true));
3487        let mut assets = vec![wja(
3488            "wave",
3489            "Animation",
3490            serde_json::json!({"source": src, "sample_rate": 10.0}),
3491        )];
3492        desugar_animation_imports(&mut assets, None).expect("desugar");
3493
3494        let args = &assets[0].args;
3495        assert!(
3496            (args["duration"].as_f64().expect("duration") - 1.0).abs() < 1e-3,
3497            "got: {}",
3498            args["duration"]
3499        );
3500        let tracks = args["tracks"].as_array().expect("tracks inlined");
3501        assert_eq!(tracks.len(), 1);
3502        let keys = tracks[0]["keyframes"].as_array().expect("keyframes");
3503        // One second at 10 samples per second, inclusive of both ends.
3504        assert_eq!(keys.len(), 11);
3505        assert_eq!(keys[0]["translation"][0], 0.0);
3506        assert_eq!(keys[10]["translation"][0], 2.0);
3507    }
3508
3509    // A clip named by `animation_name` that the file does not contain fails
3510    // through the FBX importer too.
3511    #[test]
3512    fn desugar_animation_imports_reports_an_unknown_fbx_clip_name() {
3513        let dir = tempfile::tempdir().expect("tempdir");
3514        let src = write_fixture(&dir, "hero.fbx", &skinned_fbx(true));
3515        let mut assets = vec![wja(
3516            "run",
3517            "Animation",
3518            serde_json::json!({"source": src, "animation_name": "sprint"}),
3519        )];
3520        let err = desugar_animation_imports(&mut assets, None).expect_err("no 'sprint' clip");
3521        let msg = err.to_string();
3522        assert!(msg.contains("Asset 'run'"), "got: {msg}");
3523        assert!(msg.contains("FBX import failed"), "got: {msg}");
3524    }
3525
3526    #[test]
3527    fn desugar_animation_imports_missing_source_errors() {
3528        let mut assets = vec![wja(
3529            "walk",
3530            "Animation",
3531            serde_json::json!({"source": "/no/such/anim.glb"}),
3532        )];
3533        let err = desugar_animation_imports(&mut assets, None).expect_err("missing .glb");
3534        assert!(err.to_string().contains("Asset 'walk'"), "got: {err}");
3535    }
3536
3537    #[test]
3538    fn desugar_animation_imports_missing_named_clip_errors() {
3539        // The by-name lookup also starts by reading the file, so a missing
3540        // source fails before the name search; the error still names the asset.
3541        let mut assets = vec![wja(
3542            "run",
3543            "Animation",
3544            serde_json::json!({"source": "/no/such/anim.glb", "animation_name": "Run"}),
3545        )];
3546        let err = desugar_animation_imports(&mut assets, None).expect_err("missing .glb");
3547        assert!(err.to_string().contains("Asset 'run'"), "got: {err}");
3548    }
3549
3550    // A source-backed Animation is replaced by the imported clip's duration
3551    // and tracks. Channels targeting non-joint nodes are dropped, so the
3552    // fixture's two channels yield one track.
3553    #[test]
3554    fn desugar_animation_imports_inlines_the_indexed_clip() {
3555        let dir = tempfile::tempdir().expect("tempdir");
3556        let src = write_fixture(&dir, "hero.glb", &crate::glb::test_fixtures::skinned_glb());
3557        let mut assets = vec![wja(
3558            "wave",
3559            "Animation",
3560            serde_json::json!({"source": src, "animation_index": 0}),
3561        )];
3562        desugar_animation_imports(&mut assets, None).expect("desugar");
3563
3564        let args = &assets[0].args;
3565        assert_eq!(args["duration"], 1.0);
3566        let tracks = args["tracks"].as_array().expect("tracks inlined");
3567        assert_eq!(tracks.len(), 1, "the non-joint channel is dropped");
3568        let keys = tracks[0]["keyframes"].as_array().expect("keyframes");
3569        assert_eq!(keys.len(), 2);
3570        assert_eq!(keys[0]["time"], 0.0);
3571        assert_eq!(keys[1]["translation"], serde_json::json!([0.0, 2.0, 0.0]));
3572        // The fixture animates no morph weights, so no morph track appears.
3573        assert!(args.get("morph_track").is_none());
3574    }
3575
3576    // `animation_name` picks the clip by name; a name the file does not carry
3577    // is a hard error that lists what it does contain.
3578    #[test]
3579    fn desugar_animation_imports_resolves_and_rejects_clip_names() {
3580        let dir = tempfile::tempdir().expect("tempdir");
3581        let src = write_fixture(&dir, "hero.glb", &crate::glb::test_fixtures::skinned_glb());
3582        let mut assets = vec![wja(
3583            "wave",
3584            "Animation",
3585            serde_json::json!({"source": src, "animation_name": "wave"}),
3586        )];
3587        desugar_animation_imports(&mut assets, None).expect("desugar");
3588        assert_eq!(assets[0].args["tracks"].as_array().unwrap().len(), 1);
3589
3590        let mut missing = vec![wja(
3591            "run",
3592            "Animation",
3593            serde_json::json!({"source": src, "animation_name": "sprint"}),
3594        )];
3595        let err = desugar_animation_imports(&mut missing, None)
3596            .expect_err("the file has no 'sprint' clip");
3597        let msg = err.to_string();
3598        assert!(
3599            msg.contains("has no animation named 'sprint'"),
3600            "got: {msg}"
3601        );
3602        assert!(msg.contains("wave"), "the error lists the clips: {msg}");
3603    }
3604
3605    #[test]
3606    fn desugar_root_motion_rejects_malformed_args() {
3607        let mut assets = vec![wja(
3608            "walk",
3609            "Animation",
3610            serde_json::json!({"root_motion": true, "duration": "long"}),
3611        )];
3612        let err = desugar_root_motion(&mut assets).expect_err("bad duration");
3613        assert!(
3614            err.to_string()
3615                .contains("root-motion bake failed to parse args"),
3616            "got: {err}"
3617        );
3618    }
3619
3620    #[test]
3621    fn desugar_root_motion_tolerates_a_clip_with_no_root_track() {
3622        // Only joint 1 is animated: there is nothing to strip from the root,
3623        // so the bake warns and leaves an empty curve rather than failing.
3624        let mut assets = vec![wja(
3625            "wave",
3626            "Animation",
3627            serde_json::json!({
3628                "root_motion": true,
3629                "duration": 1.0,
3630                "tracks": [{"joint": 1, "keyframes": [
3631                    {"time": 0.0, "translation": [1.0, 0.0, 0.0]}
3632                ]}],
3633            }),
3634        )];
3635        desugar_root_motion(&mut assets).expect("bake succeeds");
3636        assert_eq!(assets[0].args["root_track"], serde_json::json!([]));
3637        // The non-root track is untouched.
3638        assert_eq!(
3639            assets[0].args["tracks"][0]["keyframes"][0]["translation"][0],
3640            1.0
3641        );
3642    }
3643
3644    // Opting into vertical root motion keeps the Y travel in the root track
3645    // instead of anchoring it back into the pose.
3646    #[test]
3647    fn desugar_root_motion_keeps_y_travel_when_asked() {
3648        let clip = |root_motion_y: bool| {
3649            serde_json::json!({
3650                "target": "hero",
3651                "duration": 1.0,
3652                "root_motion": true,
3653                "root_motion_y": root_motion_y,
3654                "tracks": [{"joint": 0, "keyframes": [
3655                    {"time": 0.0, "translation": [0.0, 0.0, 0.0]},
3656                    {"time": 1.0, "translation": [0.0, 3.0, 0.0]}
3657                ]}],
3658            })
3659        };
3660        let mut assets = vec![
3661            wja("jump", "Animation", clip(true)),
3662            wja("walk", "Animation", clip(false)),
3663        ];
3664        desugar_root_motion(&mut assets).expect("bake succeeds");
3665
3666        assert_eq!(assets[0].args["root_track"][1]["translation"][1], 3.0);
3667        assert_eq!(
3668            assets[0].args["tracks"][0]["keyframes"][1]["translation"][1],
3669            0.0
3670        );
3671        // Without the flag the rise stays in the pose and the root track is flat.
3672        assert_eq!(assets[1].args["root_track"][1]["translation"][1], 0.0);
3673        assert_eq!(
3674            assets[1].args["tracks"][0]["keyframes"][1]["translation"][1],
3675            3.0
3676        );
3677    }
3678
3679    // An authored `screen` arg wins over the name-prefix convention, exactly
3680    // as an authored `scene` does on a Prop.
3681    #[test]
3682    fn resolve_scene_refs_keeps_an_authored_screen_arg() {
3683        let mut assets = vec![
3684            wja("menu", "Screen", serde_json::json!({})),
3685            wja("other", "Screen", serde_json::json!({})),
3686            wja(
3687                "menu_title",
3688                "TextLabel",
3689                serde_json::json!({"screen": "other"}),
3690            ),
3691        ];
3692        super::resolve_scene_refs(&mut assets);
3693        assert_eq!(assets[2].args["screen"], "other");
3694    }
3695
3696    #[test]
3697    fn resolve_scene_refs_prop_scene_prefix_rules() {
3698        let mut assets = vec![
3699            wja("level", "Scene", serde_json::json!({})),
3700            wja("level_boss", "Scene", serde_json::json!({})),
3701            wja("level_boss_door", "Prop", serde_json::json!({})),
3702            wja("level_gate", "Prop", serde_json::json!({"scene": "other"})),
3703            wja("solo_thing", "Prop", serde_json::json!({})),
3704        ];
3705        super::resolve_scene_refs(&mut assets);
3706
3707        // Longest scene prefix wins for the nested name.
3708        assert_eq!(assets[2].args["scene"], "level_boss");
3709        // An authored `scene` arg is never overwritten.
3710        assert_eq!(assets[3].args["scene"], "other");
3711        // No matching prefix: no `scene` arg appears.
3712        assert!(assets[4].args.get("scene").is_none());
3713    }
3714
3715    #[test]
3716    fn resolve_scene_refs_rewrites_action_names_to_interned_ids() {
3717        crate::ecs::asset_id::reset_interner();
3718        let mut assets = vec![
3719            wja(
3720                "btn",
3721                "HitRegion",
3722                serde_json::json!({"action": "screen:show:pause"}),
3723            ),
3724            wja(
3725                "key",
3726                "KeyBinding",
3727                serde_json::json!({"action": "scene:day"}),
3728            ),
3729        ];
3730        super::resolve_scene_refs(&mut assets);
3731
3732        // Names intern in resolution order on this thread's fresh interner:
3733        // "pause" -> 0, "day" -> 1.
3734        assert_eq!(assets[0].args["action"], "screen:show:0");
3735        assert_eq!(assets[1].args["action"], "scene:1");
3736    }
3737
3738    #[test]
3739    fn resolve_scene_refs_leaves_numeric_and_foreign_actions_alone() {
3740        let mut assets = vec![
3741            wja(
3742                "a",
3743                "HitRegion",
3744                serde_json::json!({"action": "screen:toggle:3"}),
3745            ),
3746            wja("b", "HitRegion", serde_json::json!({"action": "quit"})),
3747            wja("c", "KeyBinding", serde_json::json!({"action": "scene:"})),
3748        ];
3749        super::resolve_scene_refs(&mut assets);
3750
3751        // Already an id, not a recognised prefix, and an empty target: all
3752        // pass through unchanged.
3753        assert_eq!(assets[0].args["action"], "screen:toggle:3");
3754        assert_eq!(assets[1].args["action"], "quit");
3755        assert_eq!(assets[2].args["action"], "scene:");
3756    }
3757
3758    #[test]
3759    fn probe_mesh_payload_cache_probes_only_source_backed_mesh_assets() {
3760        let assets = vec![
3761            wja("m", MESH_TYPE, serde_json::json!({"source": "x.glb"})),
3762            wja("inline", MESH_TYPE, serde_json::json!({"vertices": []})),
3763            wja(
3764                "s",
3765                SKINNED_MESH_TYPE,
3766                serde_json::json!({"source": "y.glb"}),
3767            ),
3768            wja(
3769                "p",
3770                "ProceduralMesh",
3771                serde_json::json!({"generator": "box"}),
3772            ),
3773        ];
3774        let probed = probe_mesh_payload_cache(&assets, None, None);
3775
3776        let mut names: Vec<&str> = probed.keys().map(|s| s.as_str()).collect();
3777        names.sort_unstable();
3778        assert_eq!(names, vec!["m", "s"]);
3779        for entry in probed.values() {
3780            assert!(!entry.key.is_empty());
3781            // The payload cache is disabled under cargo test, so a probe can
3782            // only ever record a miss here.
3783            assert!(entry.bytes.is_none());
3784        }
3785    }
3786
3787    // `build_compiled` runs on an already-prepared world, so a type the
3788    // component registry cannot resolve surfaces here rather than upstream.
3789    #[test]
3790    fn build_compiled_names_the_asset_whose_type_will_not_resolve() {
3791        let assets = vec![wja("mystery", "NotAType", serde_json::json!({}))];
3792        let Err(err) = build_compiled(assets, None, None) else {
3793            panic!("unknown type must not compile");
3794        };
3795        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
3796        assert!(err.to_string().contains("Asset 'mystery'"), "got: {err}");
3797    }
3798
3799    // A payload that will not compile fails the whole build; the error names
3800    // the asset so the author knows which line to fix.
3801    #[test]
3802    fn build_compiled_surfaces_a_payload_compile_failure() {
3803        let assets = vec![wja(
3804            "shape",
3805            "ProceduralMesh",
3806            serde_json::json!({"generator": "not_a_generator"}),
3807        )];
3808        let Err(err) = build_compiled(assets, None, None) else {
3809            panic!("an uncompilable payload must not build");
3810        };
3811        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
3812        assert!(err.to_string().contains("not_a_generator"), "got: {err}");
3813    }
3814
3815    // The per-asset resolution pass reports every asset that fails, not just
3816    // the first, and a clean world returns Ok.
3817    #[test]
3818    fn validate_world_jsonl_collects_every_resolution_failure() {
3819        let world = concat!(
3820            r#"{"name":"first","type":"ProceduralMesh","args":{"generator":"box"}}"#,
3821            "\n",
3822            r#"{"name":"clip","type":"AudioClip","args":{"source":"a.wav"}}"#,
3823            "\n",
3824        );
3825        validate_world_jsonl(world, None).expect("a resolvable world validates");
3826
3827        // Args of the wrong shape survive the structural world checks and are
3828        // rejected when the def is built.
3829        let bad = concat!(
3830            r#"{"name":"t1","type":"PointLight","args":{"intensity":"soon"}}"#,
3831            "\n",
3832            r#"{"name":"t2","type":"PointLight","args":{"intensity":"later"}}"#,
3833            "\n",
3834        );
3835        let err = validate_world_jsonl(bad, None).expect_err("mistyped args do not resolve");
3836        let msg = err.to_string();
3837        assert!(msg.contains("Asset 't1'"), "got: {msg}");
3838        assert!(msg.contains("Asset 't2'"), "got: {msg}");
3839    }
3840
3841    // An uncompressed 24-bit BGR Targa, the cheapest real image source to
3842    // author inline.
3843    fn tga_2x2() -> Vec<u8> {
3844        let mut v = vec![0u8; 18];
3845        v[2] = 2; // uncompressed true-color
3846        v[12..14].copy_from_slice(&2u16.to_le_bytes());
3847        v[14..16].copy_from_slice(&2u16.to_le_bytes());
3848        v[16] = 24;
3849        v[17] = 0x20; // top origin
3850        v.extend_from_slice(&[10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]);
3851        v
3852    }
3853
3854    // `cn debug`'s hot-reload watcher maps a saved file back to the handle it
3855    // feeds, so every file-backed texture and mesh records its source in handle
3856    // order. A generated asset has nothing to watch and records an empty source.
3857    #[test]
3858    fn build_compiled_records_hot_reload_sources_in_handle_order() {
3859        let dir = tempfile::tempdir().expect("tempdir");
3860        let tga = write_fixture(&dir, "wall.tga", &tga_2x2());
3861        let glb = write_fixture(
3862            &dir,
3863            "scene.glb",
3864            &crate::glb::test_fixtures::static_triangle_glb(),
3865        );
3866        let assets = vec![
3867            wja(
3868                "proc_tex",
3869                "Texture",
3870                serde_json::json!({"generator": "checker", "resolution": 8}),
3871            ),
3872            wja(
3873                "wall_tex",
3874                "Texture",
3875                serde_json::json!({"source": tga, "image_index": 3}),
3876            ),
3877            wja(
3878                "inline_mesh",
3879                MESH_TYPE,
3880                serde_json::json!({"generator": "box", "half_extents": [1, 1, 1]}),
3881            ),
3882            wja(
3883                "file_mesh",
3884                MESH_TYPE,
3885                serde_json::json!({
3886                    "source": glb,
3887                    "primitive_index": 0,
3888                    "lod_levels": 3,
3889                    "lod_distances": [10.0, 20.0],
3890                }),
3891            ),
3892        ];
3893        let result = build_compiled(assets, None, None).expect("build");
3894
3895        assert_eq!(result.texture_sources.len(), 2);
3896        assert_eq!(
3897            result.texture_sources[0],
3898            TextureSourceInfo {
3899                name_id: 0,
3900                source: String::new(),
3901                image_index: 0,
3902            },
3903            "a generated texture has no file to watch"
3904        );
3905        assert_eq!(
3906            result.texture_sources[1],
3907            TextureSourceInfo {
3908                name_id: 1,
3909                source: tga.clone(),
3910                image_index: 3,
3911            }
3912        );
3913
3914        assert_eq!(result.mesh_sources.len(), 2);
3915        assert_eq!(
3916            result.mesh_sources[0],
3917            MeshSourceInfo {
3918                source: String::new(),
3919                primitive_index: 0,
3920                lod_levels: 1,
3921                lod_distances: Vec::new(),
3922            },
3923            "a generated mesh has no file to watch"
3924        );
3925        assert_eq!(
3926            result.mesh_sources[1],
3927            MeshSourceInfo {
3928                source: glb.clone(),
3929                primitive_index: 0,
3930                lod_levels: 3,
3931                lod_distances: vec![10.0, 20.0],
3932            }
3933        );
3934
3935        // The lock records mirror the catalogues so a blob boot can rebuild
3936        // them without the authored args.
3937        let lock_tex: Vec<_> = result
3938            .resource_locks
3939            .iter()
3940            .filter(|r| r.kind == "Texture")
3941            .collect();
3942        assert_eq!(lock_tex.len(), 2);
3943        assert_eq!(lock_tex[0].texture_source.as_ref().unwrap().source, "");
3944        let wall = lock_tex[1].texture_source.as_ref().unwrap();
3945        assert_eq!(wall.source, tga);
3946        assert_eq!(wall.image_index, 3);
3947        assert!(lock_tex[1].mesh_source.is_none());
3948
3949        let lock_mesh: Vec<_> = result
3950            .resource_locks
3951            .iter()
3952            .filter(|r| r.kind == "Mesh")
3953            .collect();
3954        assert_eq!(lock_mesh.len(), 2);
3955        assert!(lock_mesh[0].texture_source.is_none());
3956        let file_mesh = lock_mesh[1].mesh_source.as_ref().unwrap();
3957        assert_eq!(file_mesh.source, glb);
3958        assert_eq!(file_mesh.lod_levels, 3);
3959        assert_eq!(file_mesh.lod_distances, vec![10.0, 20.0]);
3960    }
3961
3962    // A data resource carries its bytes inline in the record rather than in a
3963    // blob payload section, so the lock records no payload blob for it.
3964    #[test]
3965    fn build_compiled_keeps_a_data_resource_out_of_the_payload_sections() {
3966        let assets = vec![
3967            wja("wood", "Material", serde_json::json!({})),
3968            wja(
3969                "shape",
3970                "ProceduralMesh",
3971                serde_json::json!({"generator": "box"}),
3972            ),
3973        ];
3974        let result = build_compiled(assets, None, None).expect("build");
3975
3976        assert_eq!(result.resources.len(), 1);
3977        let material = &result.resources[0];
3978        assert!(material.payload.is_none(), "a Material rides inline");
3979        assert!(!material.data_bytes.is_empty());
3980        postcard::from_bytes::<crate::components::Material>(&material.data_bytes)
3981            .expect("the inline bytes decode as a Material");
3982        assert_eq!(result.resource_locks[0].name, "wood");
3983        assert_eq!(result.resource_locks[0].payload_blob, None);
3984        // The component's payload is what actually occupies the blob.
3985        assert_eq!(result.defs.len(), 1);
3986        assert!(result.defs[0].payload.is_some());
3987    }
3988
3989    // Only a File whose kind maps to a mesh payload is compiled; every other
3990    // kind stays a plain reference with no blob bytes.
3991    #[test]
3992    fn build_compiled_compiles_only_mesh_kind_file_assets() {
3993        let dir = tempfile::tempdir().expect("tempdir");
3994        let obj = write_fixture(&dir, "tri.obj", b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n");
3995        let png = write_fixture(&dir, "icon.png", b"not read");
3996        let assets = vec![
3997            wja(
3998                "model",
3999                "File",
4000                serde_json::json!({"path": obj, "kind": "obj"}),
4001            ),
4002            wja(
4003                "icon",
4004                "File",
4005                serde_json::json!({"path": png, "kind": "png"}),
4006            ),
4007        ];
4008        let result = build_compiled(assets, None, None).expect("build");
4009
4010        assert_eq!(result.names, vec!["model".to_string(), "icon".to_string()]);
4011        let mesh_payload = result.defs[0]
4012            .payload
4013            .as_ref()
4014            .expect("the obj File compiles");
4015        assert!(mesh_payload.len > 0);
4016        assert!(
4017            result.defs[1].payload.is_none(),
4018            "a png File produces no blob payload"
4019        );
4020    }
4021
4022    // A source-backed asset that is neither mesh kind is skipped: its payload
4023    // cache is handled by the per-asset path inside the compile pass.
4024    #[test]
4025    fn probe_mesh_payload_cache_skips_a_source_backed_non_mesh_asset() {
4026        let assets = vec![
4027            wja("tex", "Texture", serde_json::json!({"source": "wall.png"})),
4028            wja("m", MESH_TYPE, serde_json::json!({"source": "x.glb"})),
4029        ];
4030        let probed = probe_mesh_payload_cache(&assets, None, None);
4031        assert_eq!(probed.len(), 1);
4032        assert!(probed.contains_key("m"));
4033    }
4034
4035    use crate::resource_handles::{RegisteredType, ResourceKind};
4036
4037    fn procedural_mesh_def() -> BlobAssetDef {
4038        asset_api::create_asset_def(&AssetRequest {
4039            asset_type: "ProceduralMesh".to_string(),
4040            args: Some(serde_json::json!({"generator": "box"})),
4041        })
4042        .expect("ProceduralMesh def")
4043    }
4044
4045    // The pre-desugar probe is the whole point of the glTF cache: when it holds
4046    // bytes for an asset, neither the component nor the resource path may touch
4047    // the source again. Both assets here name inputs that would fail to
4048    // compile, so a recompile would be loud.
4049    #[test]
4050    fn compile_and_pack_payloads_serves_probed_bytes_without_recompiling() {
4051        let assets = vec![
4052            wja(
4053                "shape",
4054                "ProceduralMesh",
4055                serde_json::json!({"generator": "not_a_generator"}),
4056            ),
4057            wja(
4058                "body",
4059                MESH_TYPE,
4060                serde_json::json!({"source": "/no/such/body.glb"}),
4061            ),
4062        ];
4063        let mut named = vec![("shape".to_string(), procedural_mesh_def())];
4064        let resource_jobs = vec![(1usize, RegisteredType::Mesh, 0u32)];
4065        let mut cache = std::collections::HashMap::new();
4066        cache.insert(
4067            "shape".to_string(),
4068            MeshCacheEntry {
4069                key: "shape-key".to_string(),
4070                bytes: Some(vec![1, 2, 3]),
4071            },
4072        );
4073        cache.insert(
4074            "body".to_string(),
4075            MeshCacheEntry {
4076                key: "body-key".to_string(),
4077                bytes: Some(vec![4, 5, 6, 7]),
4078            },
4079        );
4080
4081        let out = compile_and_pack_payloads(
4082            &mut named,
4083            &[0],
4084            PackContext {
4085                assets: &assets,
4086                resource_jobs: &resource_jobs,
4087                partition: &crate::scene_partition::partition_scenes(&assets),
4088                mesh_source_handles: &Default::default(),
4089                max_blob_bytes: 1024,
4090                assets_dir: None,
4091                artifacts_dir: None,
4092                mesh_cache: &cache,
4093                progress: None,
4094            },
4095        )
4096        .expect("probed payloads need no compiler");
4097
4098        assert_eq!(out.cache_hits, 2);
4099        assert_eq!(out.cache_misses, 0);
4100        // Components pack first, then the resource stream, into one blob.
4101        assert_eq!(out.blobs, vec![vec![1, 2, 3, 4, 5, 6, 7]]);
4102        let component = named[0].1.payload.as_ref().expect("component locator");
4103        assert_eq!(
4104            (component.blob_index, component.offset, component.len),
4105            (0, 0, 3)
4106        );
4107        let resource = out.resources[0].payload.as_ref().expect("resource locator");
4108        assert_eq!(
4109            (resource.blob_index, resource.offset, resource.len),
4110            (0, 3, 4)
4111        );
4112        assert_eq!(out.resources[0].resource_kind, ResourceKind::Mesh as u8);
4113        assert_eq!(out.resources[0].handle, 0);
4114    }
4115
4116    // A scene-exclusive resource packs into its own blob after the global set,
4117    // and the scene group records it; record order stays resource_jobs order.
4118    #[test]
4119    fn scene_owned_payloads_pack_into_their_own_blob() {
4120        let assets = vec![
4121            wja("day", "Scene", serde_json::json!({})),
4122            wja(
4123                "day_prop",
4124                "Prop",
4125                serde_json::json!({"mesh":"day_mesh","scene":"day"}),
4126            ),
4127            wja("bg_prop", "Prop", serde_json::json!({"mesh":"bg_mesh"})),
4128            wja(
4129                "day_mesh",
4130                MESH_TYPE,
4131                serde_json::json!({"source": "/no/such/day.glb"}),
4132            ),
4133            wja(
4134                "bg_mesh",
4135                MESH_TYPE,
4136                serde_json::json!({"source": "/no/such/bg.glb"}),
4137            ),
4138        ];
4139        let mut named: Vec<(String, BlobAssetDef)> = Vec::new();
4140        let resource_jobs = vec![
4141            (3usize, RegisteredType::Mesh, 0u32),
4142            (4usize, RegisteredType::Mesh, 1u32),
4143        ];
4144        let cache = std::collections::HashMap::from([
4145            (
4146                "day_mesh".to_string(),
4147                MeshCacheEntry {
4148                    key: "day-key".to_string(),
4149                    bytes: Some(vec![0xDD; 4]),
4150                },
4151            ),
4152            (
4153                "bg_mesh".to_string(),
4154                MeshCacheEntry {
4155                    key: "bg-key".to_string(),
4156                    bytes: Some(vec![0xBB; 2]),
4157                },
4158            ),
4159        ]);
4160
4161        let out = compile_and_pack_payloads(
4162            &mut named,
4163            &[],
4164            PackContext {
4165                assets: &assets,
4166                resource_jobs: &resource_jobs,
4167                partition: &crate::scene_partition::partition_scenes(&assets),
4168                mesh_source_handles: &Default::default(),
4169                max_blob_bytes: 1 << 20,
4170                assets_dir: None,
4171                artifacts_dir: None,
4172                mesh_cache: &cache,
4173                progress: None,
4174            },
4175        )
4176        .expect("probed payloads need no compiler");
4177
4178        // Global set (bg) fills blob 0; day's exclusive mesh starts blob 1.
4179        assert_eq!(out.blobs, vec![vec![0xBB; 2], vec![0xDD; 4]]);
4180        let day = out.resources[0].payload.as_ref().expect("day locator");
4181        assert_eq!((day.blob_index, day.offset, day.len), (1, 0, 4));
4182        let bg = out.resources[1].payload.as_ref().expect("bg locator");
4183        assert_eq!((bg.blob_index, bg.offset, bg.len), (0, 0, 2));
4184
4185        assert_eq!(out.scene_groups.len(), 1);
4186        assert_eq!(
4187            out.scene_groups[0].resources,
4188            vec![(ResourceKind::Mesh as u8, 0)]
4189        );
4190        assert!(out.scene_groups[0].defs.is_empty());
4191    }
4192
4193    // Every compiled static-mesh payload gets a baked AABB + counts record
4194    // keyed by its mesh-source handle.
4195    #[test]
4196    fn mesh_bounds_are_baked_for_compiled_mesh_sources() {
4197        let assets = vec![wja(
4198            "shape",
4199            "ProceduralMesh",
4200            serde_json::json!({"generator": "box"}),
4201        )];
4202        let mut named = vec![("shape".to_string(), procedural_mesh_def())];
4203        let mut handles = crate::resource_handles::ResourceHandles::default();
4204        crate::resource_handles::assign_mesh_source_handles(&mut handles, &assets);
4205        let out = compile_and_pack_payloads(
4206            &mut named,
4207            &[0],
4208            PackContext {
4209                assets: &assets,
4210                resource_jobs: &[],
4211                partition: &crate::scene_partition::partition_scenes(&assets),
4212                mesh_source_handles: &handles,
4213                max_blob_bytes: 1 << 20,
4214                assets_dir: None,
4215                artifacts_dir: None,
4216                mesh_cache: &Default::default(),
4217                progress: None,
4218            },
4219        )
4220        .expect("box compiles");
4221        assert_eq!(out.mesh_bounds.len(), 1);
4222        let record = out.mesh_bounds[0];
4223        assert_eq!(record.handle, 0);
4224        assert!(record.vertex_count > 0 && record.index_count > 0);
4225        for axis in 0..3 {
4226            assert!(record.min[axis] < record.max[axis]);
4227        }
4228    }
4229
4230    // A probe that recorded a miss compiles for real, on both the component and
4231    // the resource path, and both payloads land in the packed blob.
4232    #[test]
4233    fn compile_and_pack_payloads_compiles_a_probe_miss() {
4234        let assets = vec![
4235            wja(
4236                "shape",
4237                "ProceduralMesh",
4238                serde_json::json!({"generator": "box"}),
4239            ),
4240            wja(
4241                "body",
4242                MESH_TYPE,
4243                serde_json::json!({"generator": "sphere", "radius": 1.0}),
4244            ),
4245        ];
4246        let mut named = vec![("shape".to_string(), procedural_mesh_def())];
4247        let resource_jobs = vec![(1usize, RegisteredType::Mesh, 0u32)];
4248        let miss = |key: &str| MeshCacheEntry {
4249            key: key.to_string(),
4250            bytes: None,
4251        };
4252        let cache = std::collections::HashMap::from([
4253            ("shape".to_string(), miss("shape-key")),
4254            ("body".to_string(), miss("body-key")),
4255        ]);
4256
4257        let out = compile_and_pack_payloads(
4258            &mut named,
4259            &[0],
4260            PackContext {
4261                assets: &assets,
4262                resource_jobs: &resource_jobs,
4263                partition: &crate::scene_partition::partition_scenes(&assets),
4264                mesh_source_handles: &Default::default(),
4265                max_blob_bytes: 1 << 20,
4266                assets_dir: None,
4267                artifacts_dir: None,
4268                mesh_cache: &cache,
4269                progress: None,
4270            },
4271        )
4272        .expect("a probe miss compiles");
4273
4274        assert_eq!(out.cache_hits, 0);
4275        assert_eq!(out.cache_misses, 2);
4276        let component = named[0].1.payload.as_ref().expect("component locator");
4277        let resource = out.resources[0].payload.as_ref().expect("resource locator");
4278        assert!(component.len > 0);
4279        assert!(resource.len > 0);
4280        assert_eq!(
4281            out.blobs[0].len() as u64,
4282            component.len + resource.len,
4283            "both compiled payloads land in the blob"
4284        );
4285    }
4286
4287    // A world whose assets all carry inline args produces no payload sections
4288    // at all, and still reports one (empty) blob for the metadata to ride in.
4289    #[test]
4290    fn compile_and_pack_payloads_returns_one_empty_blob_for_a_payload_less_world() {
4291        let assets = vec![wja("day", "Scene", serde_json::json!({}))];
4292        let mut named = vec![(
4293            "day".to_string(),
4294            asset_api::create_asset_def(&AssetRequest {
4295                asset_type: "Scene".to_string(),
4296                args: Some(serde_json::json!({})),
4297            })
4298            .expect("Scene def"),
4299        )];
4300        let out = compile_and_pack_payloads(
4301            &mut named,
4302            &[0],
4303            PackContext {
4304                assets: &assets,
4305                resource_jobs: &[],
4306                partition: &crate::scene_partition::partition_scenes(&assets),
4307                mesh_source_handles: &Default::default(),
4308                max_blob_bytes: 1024,
4309                assets_dir: None,
4310                artifacts_dir: None,
4311                mesh_cache: &Default::default(),
4312                progress: None,
4313            },
4314        )
4315        .expect("pack");
4316
4317        assert_eq!(out.blobs, vec![Vec::<u8>::new()]);
4318        assert!(out.resources.is_empty());
4319        assert_eq!((out.cache_hits, out.cache_misses), (0, 0));
4320        assert!(named[0].1.payload.is_none());
4321    }
4322
4323    // The compile pass selects its work by discriminant. A def carrying one the
4324    // component registry does not know is skipped, so an unrecognised record
4325    // cannot abort a build.
4326    #[test]
4327    fn compile_and_pack_payloads_skips_a_def_with_an_unknown_discriminant() {
4328        let assets = vec![wja("mystery", "ProceduralMesh", serde_json::json!({}))];
4329        let mut named = vec![(
4330            "mystery".to_string(),
4331            BlobAssetDef {
4332                name: None,
4333                kind: AssetKind::Component,
4334                discriminant: 200,
4335                args_bytes: Vec::new(),
4336                payload: None,
4337            },
4338        )];
4339        assert!(
4340            RegisteredType::from_discriminant(200).is_none(),
4341            "200 must stay outside the registered discriminant range"
4342        );
4343
4344        let out = compile_and_pack_payloads(
4345            &mut named,
4346            &[0],
4347            PackContext {
4348                assets: &assets,
4349                resource_jobs: &[],
4350                partition: &crate::scene_partition::partition_scenes(&assets),
4351                mesh_source_handles: &Default::default(),
4352                max_blob_bytes: 1024,
4353                assets_dir: None,
4354                artifacts_dir: None,
4355                mesh_cache: &Default::default(),
4356                progress: None,
4357            },
4358        )
4359        .expect("pack");
4360
4361        assert!(named[0].1.payload.is_none());
4362        assert_eq!(out.blobs, vec![Vec::<u8>::new()]);
4363    }
4364
4365    // The blob size ceiling is packing policy, not a format limit: payloads
4366    // that overflow it roll into the next blob and their locators follow.
4367    #[test]
4368    fn compile_and_pack_payloads_rolls_payloads_into_overflow_blobs() {
4369        let assets = vec![
4370            wja("a", "ProceduralMesh", serde_json::json!({})),
4371            wja("b", "ProceduralMesh", serde_json::json!({})),
4372        ];
4373        let mut named = vec![
4374            ("a".to_string(), procedural_mesh_def()),
4375            ("b".to_string(), procedural_mesh_def()),
4376        ];
4377        let cache = std::collections::HashMap::from([
4378            (
4379                "a".to_string(),
4380                MeshCacheEntry {
4381                    key: "a".to_string(),
4382                    bytes: Some(vec![0xAA; 6]),
4383                },
4384            ),
4385            (
4386                "b".to_string(),
4387                MeshCacheEntry {
4388                    key: "b".to_string(),
4389                    bytes: Some(vec![0xBB; 6]),
4390                },
4391            ),
4392        ]);
4393
4394        let out = compile_and_pack_payloads(
4395            &mut named,
4396            &[0, 1],
4397            PackContext {
4398                assets: &assets,
4399                resource_jobs: &[],
4400                partition: &crate::scene_partition::partition_scenes(&assets),
4401                mesh_source_handles: &Default::default(),
4402                max_blob_bytes: 8,
4403                assets_dir: None,
4404                artifacts_dir: None,
4405                mesh_cache: &cache,
4406                progress: None,
4407            },
4408        )
4409        .expect("pack");
4410
4411        assert_eq!(out.blobs, vec![vec![0xAA; 6], vec![0xBB; 6]]);
4412        assert_eq!(named[0].1.payload.as_ref().unwrap().blob_index, 0);
4413        let second = named[1].1.payload.as_ref().unwrap();
4414        assert_eq!((second.blob_index, second.offset), (1, 0));
4415    }
4416
4417    #[test]
4418    fn compile_by_type_without_build_impl_errors() {
4419        let ct = RegisteredType::parse("Prop").expect("Prop is a registered component");
4420        let err = compile_by_type(ct, &serde_json::json!({}), &ctx())
4421            .expect_err("Prop has no BuildAsset impl");
4422        assert!(err.to_string().contains("no BuildAsset impl"), "got: {err}");
4423    }
4424
4425    #[test]
4426    fn cache_inputs_by_type_defaults_to_empty_extras() {
4427        use crate::asset::SourceFiles;
4428        let ct = RegisteredType::parse("Prop").expect("Prop is a registered component");
4429        let inputs = cache_inputs_by_type(ct, &serde_json::json!({}), &ctx());
4430        assert_eq!(inputs.sources, SourceFiles::Extra(Vec::new()));
4431        assert!(!inputs.target_dependent);
4432    }
4433
4434    // The arms that take the trait default report no inputs of their own: every
4435    // file they read is named by an args string, which the payload cache's
4436    // generic walk already hashes.
4437    #[test]
4438    fn cache_inputs_by_type_covers_the_args_walk_arms() {
4439        use crate::asset::SourceFiles;
4440        for name in ["ProceduralMesh", "VoxelChunk", "File", "Room"] {
4441            let inputs = cache_inputs_by_type(ct(name), &serde_json::json!({}), &ctx());
4442            assert_eq!(
4443                inputs.sources,
4444                SourceFiles::Extra(Vec::new()),
4445                "{name} must not narrow the generic args walk"
4446            );
4447            assert!(
4448                !inputs.target_dependent,
4449                "{name} compiles the same everywhere"
4450            );
4451        }
4452    }
4453
4454    // AudioClip compiles through `RegisteredType` now, not `compile_by_type`
4455    // (it left the component registry). Its source-less error still surfaces, and
4456    // its source file is folded into the payload cache key.
4457    #[test]
4458    fn resource_asset_types_compile_audio_clip_texture_cubemap_env_lut_and_font() {
4459        use crate::registry::RegisteredType;
4460        let rt = RegisteredType::parse("AudioClip").expect("AudioClip is a resource asset");
4461        let err = rt
4462            .compile_payload(&serde_json::json!({}), None)
4463            .expect_err("a source-less AudioClip must fail to compile");
4464        assert!(err.to_string().contains("missing 'source'"), "got: {err}");
4465        assert_eq!(
4466            rt.source_files(&serde_json::json!({"source": "a.wav"}), None),
4467            vec!["a.wav".to_string()]
4468        );
4469        assert!(rt.source_files(&serde_json::json!({}), None).is_empty());
4470
4471        // Texture is also a resource asset (it left the component registry). A
4472        // procedural texture compiles a non-empty payload, and a file-backed one
4473        // folds its source into the payload cache key.
4474        let tex = RegisteredType::parse("Texture").expect("Texture is a resource asset");
4475        let bytes = tex
4476            .compile_payload(
4477                &serde_json::json!({"generator": "checker", "resolution": 32}),
4478                None,
4479            )
4480            .expect("a procedural texture compiles");
4481        assert!(!bytes.is_empty());
4482        assert_eq!(
4483            tex.source_files(&serde_json::json!({"source": "a.png"}), None),
4484            vec!["a.png".to_string()]
4485        );
4486
4487        // CubemapTexture is a resource asset too. Source-less args fail, and its
4488        // `.hdr` source folds into the payload cache key.
4489        let cube =
4490            RegisteredType::parse("CubemapTexture").expect("CubemapTexture is a resource asset");
4491        let err = cube
4492            .compile_payload(&serde_json::json!({}), None)
4493            .expect_err("a source-less CubemapTexture must fail to compile");
4494        assert!(
4495            err.to_string().contains("requires a `source` path"),
4496            "got: {err}"
4497        );
4498        assert_eq!(
4499            cube.source_files(&serde_json::json!({"source": "c.hdr"}), None),
4500            vec!["c.hdr".to_string()]
4501        );
4502
4503        // EnvironmentMap and ColorLut are resource assets too. Both surface their
4504        // source-less error through `RegisteredType::compile_payload`, and fold
4505        // their `source` into the payload cache key.
4506        let env =
4507            RegisteredType::parse("EnvironmentMap").expect("EnvironmentMap is a resource asset");
4508        let err = env
4509            .compile_payload(&serde_json::json!({}), None)
4510            .expect_err("a source-less EnvironmentMap must fail to compile");
4511        assert!(
4512            err.to_string()
4513                .contains("requires either `source` or `generator`"),
4514            "got: {err}"
4515        );
4516        assert_eq!(
4517            env.source_files(&serde_json::json!({"source": "e.hdr"}), None),
4518            vec!["e.hdr".to_string()]
4519        );
4520
4521        let lut = RegisteredType::parse("ColorLut").expect("ColorLut is a resource asset");
4522        let err = lut
4523            .compile_payload(&serde_json::json!({}), None)
4524            .expect_err("a source-less ColorLut must fail to compile");
4525        assert!(
4526            err.to_string().contains("requires a `source` path"),
4527            "got: {err}"
4528        );
4529        assert_eq!(
4530            lut.source_files(&serde_json::json!({"source": "l.cube"}), None),
4531            vec!["l.cube".to_string()]
4532        );
4533
4534        // Font is a resource asset. The built-in font (empty `path`) compiles a
4535        // non-empty atlas, and a file-backed font folds its `path` (not `source`)
4536        // into the payload cache key.
4537        let font = RegisteredType::parse("Font").expect("Font is a resource asset");
4538        let bytes = font
4539            .compile_payload(&serde_json::json!({"size_px": 20}), None)
4540            .expect("the built-in font compiles");
4541        assert!(!bytes.is_empty());
4542        assert_eq!(
4543            font.source_files(&serde_json::json!({"path": "f.ttf"}), None),
4544            vec!["f.ttf".to_string()]
4545        );
4546        assert!(
4547            font.source_files(&serde_json::json!({"source": "x.ttf"}), None)
4548                .is_empty()
4549        );
4550    }
4551
4552    // A mesh whose source is a text `.gltf` reads sibling files the args never
4553    // name; `source_files` must report them so an edited external buffer or
4554    // image busts the payload cache.
4555    #[test]
4556    fn gltf_sources_fold_referenced_sibling_files_into_source_files() {
4557        use crate::registry::RegisteredType;
4558
4559        let dir = tempfile::tempdir().unwrap();
4560        let json = serde_json::json!({
4561            "asset": {"version": "2.0"},
4562            "buffers": [{"byteLength": 4, "uri": "geo.bin"}],
4563            "images": [{"uri": "albedo.png"}]
4564        });
4565        let gltf_path = dir.path().join("tri.gltf");
4566        std::fs::write(&gltf_path, serde_json::to_vec(&json).unwrap()).unwrap();
4567        let src = gltf_path.to_str().unwrap().to_string();
4568
4569        for rt in [RegisteredType::Mesh, RegisteredType::SkinnedMesh] {
4570            let files = rt.source_files(&serde_json::json!({"source": src}), None);
4571            assert_eq!(files.len(), 3, "{rt:?}: {files:?}");
4572            assert_eq!(files[0], src);
4573            assert!(files.iter().any(|f| f.ends_with("geo.bin")), "{files:?}");
4574            assert!(files.iter().any(|f| f.ends_with("albedo.png")), "{files:?}");
4575        }
4576
4577        // A `.glb` source reports only itself.
4578        let glb =
4579            RegisteredType::Mesh.source_files(&serde_json::json!({"source": "scene.glb"}), None);
4580        assert_eq!(glb, vec!["scene.glb".to_string()]);
4581    }
4582
4583    // Dispatch coverage: compile_by_type / source_files_by_type route each
4584    // compiled RegisteredType to its asset_impls wrapper.
4585
4586    fn ct(name: &str) -> RegisteredType {
4587        RegisteredType::parse(name).unwrap_or_else(|| panic!("{name} is a registered component"))
4588    }
4589
4590    // Arms whose outcome is deterministic from inline args alone: a valid
4591    // minimal payload for the ones that need no source file, and the expected
4592    // error for the ones that require a source but got none.
4593    #[test]
4594    fn compile_by_type_dispatches_deterministic_arms() {
4595        // Mesh is a resource asset now: it compiles through
4596        // `RegisteredType::compile_payload`, not the RegisteredType dispatch.
4597        let mesh_bytes = crate::registry::RegisteredType::Mesh
4598            .compile_payload(
4599                &serde_json::json!({"generator": "box", "half_extents": [1, 1, 1]}),
4600                None,
4601            )
4602            .expect("Mesh compiles through the resource path");
4603        assert!(!mesh_bytes.is_empty());
4604
4605        let ok_cases: &[(&str, serde_json::Value)] = &[
4606            (
4607                "ProceduralMesh",
4608                serde_json::json!({"generator": "sphere", "radius": 1.0}),
4609            ),
4610            ("Room", serde_json::json!({})),
4611        ];
4612        for case in ok_cases {
4613            let name = case.0;
4614            let args = &case.1;
4615            let bytes = compile_by_type(ct(name), args, &ctx())
4616                .unwrap_or_else(|e| panic!("{name} should compile: {e}"));
4617            assert!(!bytes.is_empty(), "{name} payload should be non-empty");
4618        }
4619
4620        let err_cases: &[(&str, serde_json::Value, &str)] =
4621            &[("File", serde_json::json!({}), "unsupported File kind")];
4622        for case in err_cases {
4623            let name = case.0;
4624            let args = &case.1;
4625            let needle = case.2;
4626            let err = compile_by_type(ct(name), args, &ctx())
4627                .expect_err(&format!("{name} with empty args should error"));
4628            assert!(
4629                err.to_string().contains(needle),
4630                "{name} error should mention '{needle}', got: {err}"
4631            );
4632        }
4633    }
4634
4635    // The File wrapper decodes an OBJ mesh source into a non-empty payload.
4636    #[test]
4637    fn compile_by_type_file_compiles_an_obj_source() {
4638        let dir = tempfile::tempdir().expect("tempdir");
4639        let obj = dir.path().join("tri.obj");
4640        std::fs::write(&obj, "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n").expect("write obj");
4641        let args = serde_json::json!({"path": obj.to_str().unwrap(), "kind": "obj"});
4642        let bytes = compile_by_type(ct("File"), &args, &ctx()).expect("obj compiles");
4643        assert!(!bytes.is_empty());
4644    }
4645
4646    // The SkinnedMesh resource compiler deserialises args + an optional
4647    // skeleton, then bakes geometry: one vertex is enough for a payload, no
4648    // vertices and a malformed skeleton are the two error arms. Its baked
4649    // data form carries the interned name id and drops the geometry.
4650    #[test]
4651    fn skinned_mesh_resource_compile_paths() {
4652        use crate::registry::RegisteredType;
4653        let rt = RegisteredType::SkinnedMesh;
4654
4655        let ok = serde_json::json!({"vertices": [{"pos": [0.0, 0.0, 0.0]}], "indices": []});
4656        let bytes = rt.compile_payload(&ok, None).expect("skinned compiles");
4657        assert!(!bytes.is_empty());
4658
4659        let no_verts = rt
4660            .compile_payload(&serde_json::json!({}), None)
4661            .expect_err("no vertices");
4662        assert!(
4663            no_verts.to_string().contains("at least one vertex"),
4664            "got: {no_verts}"
4665        );
4666
4667        let bad_skeleton = rt
4668            .compile_payload(
4669                &serde_json::json!({"vertices": [{"pos": [0.0, 0.0, 0.0]}], "skeleton": 5}),
4670                None,
4671            )
4672            .expect_err("malformed skeleton");
4673        assert!(
4674            bad_skeleton.to_string().contains("invalid skeleton args"),
4675            "got: {bad_skeleton}"
4676        );
4677
4678        // The baked data tuple: name id first, then the clamped mesh with its
4679        // geometry cleared.
4680        crate::ecs::asset_id::reset_interner();
4681        let name_id = crate::ecs::asset_id::intern("hero");
4682        let data = rt
4683            .compile_data(
4684                "hero",
4685                &serde_json::json!({
4686                    "vertices": [{"pos": [0.0, 0.0, 0.0]}],
4687                    "scale": [0.0, 0.0, 0.0],
4688                    "max_instances": 999999,
4689                    "capsule": {"half_height": 0.6, "radius": 0.2},
4690                }),
4691            )
4692            .expect("data bakes")
4693            .expect("skinned mesh carries baked data");
4694        let (baked_name, sm): (u32, crate::components::SkinnedMesh) =
4695            postcard::from_bytes(&data).unwrap();
4696        assert_eq!(baked_name, name_id.0);
4697        assert_eq!(sm.scale, [1.0, 1.0, 1.0], "zero scale clamps to unit");
4698        assert_eq!(sm.max_instances, 4096, "reserve caps at 4096");
4699        assert!(sm.vertices.is_empty(), "geometry rides the payload");
4700        assert!(sm.capsule.is_some());
4701    }
4702
4703    // The VoxelChunk wrapper resolves its palette from sibling BlockType assets
4704    // in the build context.
4705    #[test]
4706    fn compile_by_type_voxel_chunk_resolves_palette_from_ctx() {
4707        let blocks = vec![
4708            wja("air", "BlockType", serde_json::json!({"solid": false})),
4709            wja(
4710                "stone",
4711                "BlockType",
4712                serde_json::json!({"uv_min": [0, 0], "uv_max": [1, 1]}),
4713            ),
4714        ];
4715        let vctx = crate::asset::BuildCtx {
4716            name: "chunk",
4717            assets_dir: None,
4718            artifacts_dir: None,
4719            all_assets: &blocks,
4720        };
4721        let args = serde_json::json!({
4722            "palette": ["air", "stone"],
4723            "dim": [2, 1, 1],
4724            "blocks": [1, 1],
4725            "block_size": 1.0,
4726        });
4727        let bytes = compile_by_type(ct("VoxelChunk"), &args, &vctx).expect("voxel compiles");
4728        assert!(!bytes.is_empty());
4729    }
4730
4731    // The SdfVolume wrapper transports the current backend's fragment shader
4732    // bytes verbatim (no MSL/GLSL compilation); a missing source is a hard
4733    // error rather than a silent empty payload.
4734    #[test]
4735    fn compile_by_type_sdf_volume_transports_shader_bytes() {
4736        let dir = tempfile::tempdir().expect("tempdir");
4737        let shader = dir.path().join("blob.metal");
4738        let source = b"// sdf fragment source\n";
4739        std::fs::write(&shader, source).expect("write shader");
4740        let path = shader.to_str().unwrap();
4741        // Set every backend's key to the same file so the test is
4742        // platform-independent: only the current backend's entry is read.
4743        let args = serde_json::json!({
4744            "fragment_shaders": {"metal": path, "hlsl": path, "glsl": path}
4745        });
4746        let bytes = compile_by_type(ct("SdfVolume"), &args, &ctx()).expect("sdf reads source");
4747        assert_eq!(bytes, source);
4748
4749        let err = compile_by_type(ct("SdfVolume"), &serde_json::json!({}), &ctx())
4750            .expect_err("no fragment shader source");
4751        assert!(
4752            err.to_string().contains("no fragment shader source"),
4753            "got: {err}"
4754        );
4755    }
4756
4757    // The Shader wrapper's non-compiling arms: a missing stage source is
4758    // either a hard error (Metal/HLSL) or the inline-GLSL stub (Vulkan).
4759    // Neither shells out to a shader toolchain, so the test stays
4760    // backend-agnostic.
4761    #[test]
4762    fn compile_by_type_shader_missing_source_does_not_shell_out() {
4763        let out = compile_by_type(
4764            ct("Shader"),
4765            &serde_json::json!({"vertex": {}, "fragment": {}}),
4766            &ctx(),
4767        );
4768        match out {
4769            Ok(bytes) => {
4770                let payload = concinnity_core::components::ShaderPayload::decode(&bytes)
4771                    .expect("empty container decodes");
4772                assert!(payload.stages.is_empty(), "glsl stub compiles no stages");
4773            }
4774            Err(e) => assert!(e.to_string().contains("no shader source"), "got: {e}"),
4775        }
4776    }
4777
4778    // cache_inputs_by_type routes to the two overriding wrappers. Both report
4779    // `Only` -- the complete input set the current backend reads -- so an edit
4780    // to a sibling backend's shader leaves this backend's payload cached.
4781    #[test]
4782    fn cache_inputs_by_type_covers_the_overriding_wrappers() {
4783        use crate::asset::SourceFiles;
4784        let dir = tempfile::tempdir().expect("tempdir");
4785        let shader = dir.path().join("blob.metal");
4786        std::fs::write(&shader, b"x").expect("write shader");
4787        let path = shader.to_str().unwrap();
4788
4789        // SdfVolume reports the resolved path for the current backend, and
4790        // transports it verbatim, so the compile target is not an input.
4791        let sdf_args = serde_json::json!({
4792            "fragment_shaders": {"metal": path, "hlsl": path, "glsl": path}
4793        });
4794        let sdf = cache_inputs_by_type(ct("SdfVolume"), &sdf_args, &ctx());
4795        assert_eq!(sdf.sources, SourceFiles::Only(vec![path.to_string()]));
4796        assert!(!sdf.target_dependent);
4797        assert_eq!(
4798            cache_inputs_by_type(ct("SdfVolume"), &serde_json::json!({}), &ctx()).sources,
4799            SourceFiles::Only(Vec::new())
4800        );
4801
4802        // Shader compiles its stage sources, so the target is an input.
4803        let no_source = cache_inputs_by_type(ct("Shader"), &serde_json::json!({}), &ctx());
4804        assert_eq!(no_source.sources, SourceFiles::Only(Vec::new()));
4805        assert!(no_source.target_dependent);
4806    }
4807}