Skip to main content

concinnity_cook/pipeline/
entry.rs

1//! The entry points of the compile stage, and the run they drive: probe the
2//! payload cache, desugar source-backed assets, intern names, resolve scene
3//! references, then compile and pack.
4
5use std::path::Path;
6
7use concinnity_core::platform::Platform;
8
9use crate::asset_api::{self, AssetRequest};
10use crate::authoring::world::WorldJsonlAsset;
11use crate::ecs::{BlobAssetDef, asset_id};
12
13use super::desugar::{
14    desugar_animation_imports, desugar_fbx_meshes, desugar_fbx_skinned_meshes, desugar_gltf_meshes,
15    desugar_gltf_skinned_meshes, desugar_root_motion,
16};
17use super::errors_to_io;
18use super::pack::{PackContext, compile_and_pack_payloads, probe_mesh_payload_cache};
19use super::result::{MeshSourceInfo, PipelineResult, TextureSourceInfo};
20use super::scene_refs::resolve_scene_refs;
21
22/// Build the world at `json_path` for `platform` into `tree` and write its
23/// blobs to disk: sources resolve under the tree's `assets/` and the blobs land
24/// in its `data/`. The whole-tree entry point; a host that builds against
25/// unrelated directories calls
26/// [`prepare_world`](crate::build_only::prepare_world) + [`build_compiled`].
27pub fn build_from_path(
28    tree: &crate::paths::StateTree,
29    json_path: &str,
30    platform: Platform,
31) -> std::io::Result<()> {
32    let content = std::fs::read_to_string(json_path)?;
33    let assets_dir = tree.assets_dir();
34    let loaded = crate::build_only::prepare_world(&content, Some(&assets_dir))
35        .map_err(|errs| crate::check::report_validation_errors(&errs))?;
36
37    let result = build_compiled(loaded.assets, Some(&assets_dir), None, platform)?;
38
39    let pack_result = write_build_outputs(tree, &result, &loaded.injected, &loaded.shadowed)?;
40    for (blob_idx, path) in pack_result.blob_paths.iter().enumerate() {
41        let payload_bytes = result.payloads.get(blob_idx).map(|b| b.len()).unwrap_or(0);
42        println!("Wrote {} ({} payload bytes)", path, payload_bytes);
43    }
44
45    if result.cache_hits + result.cache_misses > 0 {
46        println!(
47            "Build cache: {} reused, {} compiled",
48            result.cache_hits, result.cache_misses
49        );
50    }
51
52    let lock = tree.world_lock_path();
53    if !loaded.injected.is_empty() {
54        println!(
55            "Injected {} default asset(s) (see {})",
56            loaded.injected.len(),
57            lock.display()
58        );
59    }
60    println!("Wrote {}", lock.display());
61
62    Ok(())
63}
64
65/// Write a compiled world's blob files, naming the primary blob `primary`.
66/// Every overflow payload blob is written as its sibling named by index, which
67/// is the layout the runtime reads back. No lock file and no thumbnails: this
68/// is the blob output alone.
69pub fn write_blobs_to(
70    result: &PipelineResult,
71    primary: &std::path::Path,
72) -> std::io::Result<crate::blob::PackResult> {
73    crate::blob::write_blobs(
74        crate::blob::BlobStreams {
75            defs: &result.defs,
76            resources: &result.resources,
77            scene_groups: &result.scene_groups,
78            mesh_bounds: &result.mesh_bounds,
79            physics_budget: result.physics_budget,
80        },
81        &result.payloads,
82        primary,
83    )
84}
85
86/// Write the blobs and world-lock.json for a compiled world into `tree`: the
87/// shared build tail used by the CLI and the FFI host. The lock records each
88/// asset under its real name plus every injected default with its full args.
89pub fn write_build_outputs(
90    tree: &crate::paths::StateTree,
91    result: &PipelineResult,
92    injected: &[crate::build_only::InjectedAsset],
93    shadowed: &[crate::build_only::ShadowedAsset],
94) -> std::io::Result<crate::blob::PackResult> {
95    let pack_result = write_blobs_to(
96        result,
97        &concinnity_host::store::blob::primary_in(&tree.data_dir()),
98    )?;
99    let named_refs: Vec<(&str, &BlobAssetDef)> = result
100        .names
101        .iter()
102        .map(|n| n.as_str())
103        .zip(result.defs.iter())
104        .collect();
105    crate::blob::write_lock(
106        tree,
107        &named_refs,
108        &result.resource_locks,
109        injected,
110        shadowed,
111        &pack_result.blob_paths,
112    )?;
113    // Thumbnails are a best-effort side product: they are rendered after the
114    // blobs the build exists to produce, and land in the build cache segment
115    // beside the payloads they were rendered from.
116    let thumbs = crate::compile::thumbnail::bake_thumbnails(result);
117    if thumbs.baked > 0 {
118        println!(
119            "Baked {} thumbnail(s) ({} reused)",
120            thumbs.baked, thumbs.reused
121        );
122    }
123    Ok(pack_result)
124}
125
126/// Run the full build pipeline on an in-memory JSONL string without writing any
127/// blobs. Loads, expands, and validates the world (crate::build_only::prepare_world),
128/// then compiles it. `assets_dir` is the asset search root a bare `source`
129/// filename is searched under; `artifacts_dir` is an optional directory
130/// consulted when resolving bare shader filenames not found there, so pass the
131/// account's artifact directory to compile user-written shaders.
132pub fn build_pipeline_from_str(
133    content: &str,
134    assets_dir: Option<&Path>,
135    artifacts_dir: Option<&str>,
136    platform: Platform,
137) -> std::io::Result<PipelineResult> {
138    let loaded = crate::build_only::prepare_world(content, assets_dir).map_err(errors_to_io)?;
139    build_compiled(loaded.assets, assets_dir, artifacts_dir, platform)
140}
141
142/// A progress report from the compile pipeline: the stage's name and its
143/// done / total counts. `total == 0` marks a stage that cannot count its work
144/// (progress there is indeterminate).
145#[derive(Debug, Clone, Copy)]
146pub struct BuildProgress {
147    /// The stage's name.
148    pub stage: &'static str,
149    /// Work completed in this stage.
150    pub done: u32,
151    /// Total work in this stage; 0 when the stage cannot count it.
152    pub total: u32,
153}
154
155/// Compile an already-prepared world (expanded + structurally and semantically
156/// validated) into in-memory blobs. This is the compile-only stage; it assumes
157/// the assets have passed crate::build_only::prepare_world, which should have been
158/// given the same `assets_dir`: an asset resolves its source the same way in
159/// both halves.
160pub fn build_compiled(
161    assets: Vec<WorldJsonlAsset>,
162    assets_dir: Option<&Path>,
163    artifacts_dir: Option<&str>,
164    platform: Platform,
165) -> std::io::Result<PipelineResult> {
166    build_compiled_with_progress(assets, assets_dir, artifacts_dir, platform, None)
167}
168
169/// [`build_compiled`] with a progress callback. The callback fires from the
170/// desugar stage and, concurrently, from the parallel payload compile (hence
171/// `Sync`); it must be cheap and non-blocking.
172pub fn build_compiled_with_progress(
173    mut assets: Vec<WorldJsonlAsset>,
174    assets_dir: Option<&Path>,
175    artifacts_dir: Option<&str>,
176    platform: Platform,
177    progress: Option<&(dyn Fn(BuildProgress) + Sync)>,
178) -> std::io::Result<PipelineResult> {
179    if let Some(p) = progress {
180        p(BuildProgress {
181            stage: "desugar",
182            done: 0,
183            total: 0,
184        });
185    }
186
187    // Cache probe runs before desugar. For every glTF-sourced Mesh /
188    // SkinnedMesh, hash the un-desugared args + referenced .glb and look up
189    // the compiled payload by that key. On a hit, we hold the bytes and skip
190    // the .glb parse entirely (the original goal: an unchanged source file
191    // means no work). On a miss, the recorded key is used when the compile
192    // step stores the freshly produced payload, so the next build's probe
193    // can re-use it.
194    let mesh_cache = probe_mesh_payload_cache(&assets, assets_dir, artifacts_dir, platform);
195
196    // Expand any glTF-sourced SkinnedMesh and Mesh assets into inline geometry
197    // before anything else looks at their args. Animations expand after the
198    // skinned-mesh pass so an importer that wanted to share state could read
199    // already-imported skeletons; today both passes parse the .glb fresh,
200    // but the ordering keeps that option open without an API churn.
201    desugar_gltf_skinned_meshes(&mut assets, &mesh_cache, assets_dir)?;
202    desugar_fbx_skinned_meshes(&mut assets, &mesh_cache)?;
203    desugar_gltf_meshes(&mut assets, &mesh_cache, assets_dir)?;
204    desugar_fbx_meshes(&mut assets, &mesh_cache)?;
205    desugar_animation_imports(&mut assets, assets_dir)?;
206    desugar_root_motion(&mut assets)?;
207    crate::compile::character_shape::warn_unresolved(&assets);
208    crate::compile::character::bake::bake_shapes(&mut assets, |name| {
209        mesh_cache
210            .get(name)
211            .map(|e| crate::compile::character::bake::TargetCache {
212                key: e.key.clone(),
213                replayed: e.capsule_scale,
214            })
215    })?;
216
217    // Intern every asset name to a dense AssetId in declaration order, then
218    // resolve the scene-by-naming-convention references that the runtime can
219    asset_id::reset_interner();
220    let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect();
221    asset_id::intern_all(&names);
222    resolve_scene_refs(&mut assets);
223
224    // Assign each resource its dense per-kind handle in declaration order and
225    // install the map so resource references resolve during the reserialize pass
226    // below: texture references (Material.albedo, Room.*_texture,
227    // Decal/ParticleEmitter.texture) to a `TextureHandle`, and audio-clip
228    // references (AudioEmitter.clip, AudioCue.clip, Story music/sounds) to an
229    // `AudioClipHandle`. The assignment walks this same `assets` list that the
230    // blob is emitted from, so a resource's handle equals the position the
231    // runtime encounters it (a texture's albedo pool slot, an audio clip's drain
232    // index / resource-table slot).
233    crate::resource_handles::reset_resource_handles();
234    let resource_assets = assets.iter().filter_map(|a| {
235        crate::resource_handles::asset_resource_kind(&a.asset_type)
236            .map(|kind| (asset_id::intern(&a.name), kind))
237    });
238    let mut resource_handles =
239        crate::resource_handles::ResourceHandles::from_assets(resource_assets);
240    // The mesh-source handle space spans four kinds (Mesh, ProceduralMesh,
241    // VoxelChunk, mesh-kind File) and File is polymorphic, so it is assigned in a
242    // second pass in the fixed block order the runtime enumerates mesh sources
243    // rather than through the per-type classifier above.
244    crate::resource_handles::assign_mesh_source_handles(&mut resource_handles, &assets);
245    // Shader handles walk the same list, so a Material's `shader` reference
246    // bakes to the position the runtime encounters that Shader at drain time.
247    crate::resource_handles::assign_shader_handles(&mut resource_handles, &assets);
248    // Install a clone; the original is kept to look up each resource asset's
249    // handle while partitioning below.
250    crate::resource_handles::install_resource_handles(resource_handles.clone());
251
252    // Partition the world into component assets (each becomes a `BlobAssetDef`)
253    // and resource assets (each becomes a resource-stream record). A resource
254    // asset (AudioClip) has left the component registry, so it never goes through
255    // `create_asset_def`; it is compiled + packed as a resource below. `named` is
256    // therefore no longer 1:1 with `assets`, so `named_src[i]` records the source
257    // asset index of each component def.
258    use crate::registry::RegisteredType;
259    let mut named: Vec<(String, BlobAssetDef)> = Vec::new();
260    let mut named_src: Vec<usize> = Vec::new();
261    let mut resource_jobs: Vec<(usize, RegisteredType, u32)> = Vec::new();
262    for (i, asset) in assets.iter().enumerate() {
263        if let Some((rt, kind)) =
264            RegisteredType::parse(&asset.asset_type).and_then(|t| t.resource_kind().map(|k| (t, k)))
265        {
266            let id = asset_id::intern(&asset.name);
267            let handle = resource_handles
268                .get(kind, id)
269                .expect("resource asset was assigned a handle above");
270            resource_jobs.push((i, rt, handle));
271            continue;
272        }
273        let req = AssetRequest {
274            asset_type: asset.asset_type.clone(),
275            args: Some(asset.args.clone()),
276        };
277        let mut def = asset_api::create_asset_def(&req).map_err(|e| {
278            std::io::Error::new(
279                std::io::ErrorKind::InvalidData,
280                format!("Asset '{}': {}", asset.name, e),
281            )
282        })?;
283        def.name = Some(asset_id::intern(&asset.name));
284        named.push((asset.name.clone(), def));
285        named_src.push(i);
286    }
287
288    // Dev-only: the file source behind each texture handle, so `cn debug`'s
289    // hot-reload watcher can map a saved file back to its handle. Built in
290    // handle order from the same resource jobs; a procedural texture (generator
291    // set) leaves an empty source (nothing to watch).
292    let texture_count = resource_jobs
293        .iter()
294        .filter(|(_, rt, _)| *rt == RegisteredType::Texture)
295        .map(|(_, _, h)| *h as usize + 1)
296        .max()
297        .unwrap_or(0);
298    let mut texture_sources = vec![TextureSourceInfo::default(); texture_count];
299    for (asset_idx, rt, handle) in &resource_jobs {
300        if *rt != RegisteredType::Texture {
301            continue;
302        }
303        let asset = &assets[*asset_idx];
304        let generator = asset
305            .args
306            .get("generator")
307            .and_then(|v| v.as_str())
308            .unwrap_or("");
309        let (source, image_index) = if generator.is_empty() {
310            (
311                asset
312                    .args
313                    .get("source")
314                    .and_then(|v| v.as_str())
315                    .unwrap_or("")
316                    .to_string(),
317                asset
318                    .args
319                    .get("image_index")
320                    .and_then(|v| v.as_u64())
321                    .unwrap_or(0) as u32,
322            )
323        } else {
324            (String::new(), 0)
325        };
326        texture_sources[*handle as usize] = TextureSourceInfo {
327            name_id: asset_id::intern(&asset.name).0,
328            source,
329            image_index,
330        };
331    }
332
333    // Dev-only: the file source behind each mesh handle, so `cn debug`'s
334    // hot-reload watcher can re-import a saved `.glb`/`.fbx` into its draw
335    // slots. Mesh handles are dense from 0 (the Mesh block leads the shared
336    // mesh-source space); an inline-authored mesh leaves an empty source.
337    let mesh_count = resource_jobs
338        .iter()
339        .filter(|(_, rt, _)| *rt == RegisteredType::Mesh)
340        .map(|(_, _, h)| *h as usize + 1)
341        .max()
342        .unwrap_or(0);
343    let mut mesh_sources = vec![MeshSourceInfo::default(); mesh_count];
344    for (asset_idx, rt, handle) in &resource_jobs {
345        if *rt != RegisteredType::Mesh {
346            continue;
347        }
348        let args = &assets[*asset_idx].args;
349        let str_arg = |key: &str| {
350            args.get(key)
351                .and_then(|v| v.as_str())
352                .unwrap_or("")
353                .to_string()
354        };
355        let u32_arg = |key: &str, default: u32| {
356            args.get(key)
357                .and_then(|v| v.as_u64())
358                .unwrap_or(default as u64) as u32
359        };
360        mesh_sources[*handle as usize] = MeshSourceInfo {
361            source: str_arg("source"),
362            primitive_index: u32_arg("primitive_index", 0),
363            lod_levels: u32_arg("lod_levels", 1),
364            lod_distances: args
365                .get("lod_distances")
366                .and_then(|v| v.as_array())
367                .map(|a| {
368                    a.iter()
369                        .filter_map(|d| d.as_f64())
370                        .map(|d| d as f32)
371                        .collect()
372                })
373                .unwrap_or_default(),
374        };
375    }
376
377    // Scene payload ownership, derived from the resolved scene memberships and
378    // the reference graph; drives the grouped packing below.
379    let partition = crate::compile::scene_partition::partition_scenes(&assets);
380
381    // The world's physics reservation, counted from the same fully expanded
382    // asset list the blob is emitted from.
383    let physics_budget = crate::compile::physics_budget::compute(&assets);
384    crate::compile::physics_budget::report_spawn_reservation(&assets);
385
386    let compiled = compile_and_pack_payloads(
387        &mut named,
388        &named_src,
389        PackContext {
390            assets: &assets,
391            resource_jobs: &resource_jobs,
392            partition: &partition,
393            mesh_source_handles: &resource_handles,
394            max_blob_bytes: crate::blob::DEFAULT_MAX_BLOB_BYTES,
395            assets_dir,
396            artifacts_dir,
397            platform,
398            mesh_cache: &mesh_cache,
399            progress,
400        },
401    )?;
402
403    // Lock-file provenance for the resource stream: `compiled.resources` is
404    // emitted in `resource_jobs` order, so the two zip index-aligned. Texture
405    // and Mesh records also carry their hot-reload source info so a blob boot
406    // can reconstruct the catalogues without the authored args.
407    let resource_locks: Vec<crate::blob::LockedResource> = resource_jobs
408        .iter()
409        .zip(compiled.resources.iter())
410        .map(|((asset_idx, rt, handle), record)| {
411            let asset = &assets[*asset_idx];
412            crate::blob::LockedResource {
413                name: asset.name.clone(),
414                // Already interned by the declaration-order pass above, so
415                // this is a lookup of the id the build assigned.
416                id: Some(asset_id::intern(&asset.name).0),
417                kind: rt.as_str().to_string(),
418                handle: *handle,
419                args_hash: crate::blob::checksum(asset.args.to_string().as_bytes()),
420                payload_blob: record.payload.as_ref().map(|p| p.blob_index),
421                texture_source: (*rt == RegisteredType::Texture).then(|| {
422                    let t = &texture_sources[*handle as usize];
423                    crate::blob::LockedTextureSource {
424                        source: t.source.clone(),
425                        image_index: t.image_index,
426                    }
427                }),
428                mesh_source: (*rt == RegisteredType::Mesh).then(|| {
429                    let m = &mesh_sources[*handle as usize];
430                    crate::blob::LockedMeshSource {
431                        source: m.source.clone(),
432                        primitive_index: m.primitive_index,
433                        lod_levels: m.lod_levels,
434                        lod_distances: m.lod_distances.clone(),
435                    }
436                }),
437            }
438        })
439        .collect();
440
441    // The blob carries components (emitted in declaration order) plus the
442    // resource stream. (System run order is no longer a build concern: every
443    // system is internal client code ordered by the client's
444    // `World::start` schedule.)
445    let (names, defs): (Vec<String>, Vec<BlobAssetDef>) = named.into_iter().unzip();
446
447    // The compile is done producing entries, so the segment holding them is
448    // written once, here, rather than per payload while the compile runs.
449    crate::cache::flush();
450
451    Ok(PipelineResult {
452        defs,
453        names,
454        resources: compiled.resources,
455        scene_groups: compiled.scene_groups,
456        mesh_bounds: compiled.mesh_bounds,
457        physics_budget,
458        mesh_component_names: compiled.mesh_component_names,
459        payloads: compiled.blobs,
460        cache_hits: compiled.cache_hits,
461        cache_misses: compiled.cache_misses,
462        texture_sources,
463        mesh_sources,
464        resource_locks,
465    })
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::pipeline::MESH_TYPE;
472    use crate::pipeline::fixtures::{wja, write_fixture};
473
474    #[test]
475    fn build_pipeline_interns_names_and_resolves_refs() {
476        // box=0, day=1, day_crate=2 in declaration order.
477        let world = concat!(
478            r#"{"name":"box","type":"ProceduralMesh","args":{"generator":"box","half_extents":[1,1,1]}}"#,
479            "\n",
480            r#"{"name":"day","type":"Scene","args":{}}"#,
481            "\n",
482            r#"{"name":"day_crate","type":"Prop","args":{"mesh":"box"}}"#,
483            "\n",
484        );
485        let result =
486            build_pipeline_from_str(world, None, None, Platform::Metal).expect("build pipeline");
487
488        // The Prop def's identity is the interned id, not a name string.
489        let prop = result
490            .defs
491            .iter()
492            .find(|d| d.name == Some(crate::ecs::asset_id::AssetId(2)))
493            .expect("day_crate def present with interned id 2");
494
495        let baked: crate::components::Prop = postcard::from_bytes(&prop.args_bytes).unwrap();
496        // The `mesh` reference resolved to box's handle (0).
497        assert_eq!(baked.mesh, Some(crate::ecs::MeshHandle(0)));
498        // The `day_` name prefix resolved to Scene `day`'s id (1).
499        assert_eq!(baked.scene, Some(crate::ecs::asset_id::AssetId(1)));
500    }
501
502    // A world with physics content but no PhysicsConfig receives one at world
503    // start rather than in the build, so the blob carries none and the shipped
504    // budget is derived from the same defaults either way.
505    #[test]
506    fn a_physics_world_carries_no_config_into_the_blob() {
507        let world = concat!(
508            r#"{"name":"box","type":"ProceduralMesh","args":{"generator":"box","half_extents":[1,1,1]}}"#,
509            "\n",
510            r#"{"name":"crate_a","type":"Prop","args":{"mesh":"box","collider":{"shape":"cuboid"}}}"#,
511            "\n",
512            r#"{"name":"crate_body","type":"PropBody","args":{"prop_name":"crate_a"}}"#,
513            "\n",
514        );
515        let result =
516            build_pipeline_from_str(world, None, None, Platform::Metal).expect("build pipeline");
517
518        assert!(
519            !result.names.iter().any(|n| n == "physics_config"),
520            "no config is compiled in: {:?}",
521            result.names
522        );
523        // The reservation is the authored content plus the floor, on the same
524        // strict spawn cap the injected config carries.
525        let budget = result.physics_budget.expect("a physics budget");
526        assert_eq!(budget.spawn_headroom, 0);
527        assert_eq!(budget.dynamic, 1, "the crate");
528    }
529
530    // The opt-out directive is a stored component now, so it survives the build
531    // and reaches the world-start pass that reads it.
532    #[test]
533    fn engine_defaults_reach_the_blob_as_a_component() {
534        let world = concat!(
535            r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
536            "\n",
537            r#"{"name":"defaults","type":"EngineDefaults","args":{"sky":false}}"#,
538            "\n",
539        );
540        let result =
541            build_pipeline_from_str(world, None, None, Platform::Metal).expect("build pipeline");
542
543        let index = result
544            .names
545            .iter()
546            .position(|n| n == "defaults")
547            .expect("the directive compiled into the blob");
548        let baked: concinnity_core::components::EngineDefaults =
549            postcard::from_bytes(&result.defs[index].args_bytes).unwrap();
550        assert!(!baked.sky);
551        assert!(baked.debug_hud, "the flags it does not name stay on");
552    }
553
554    // A resource asset (here a Font) leaves no component def, so the lock
555    // records it through `resource_locks` instead: name, kind, handle, args
556    // hash, and the blob its payload landed in.
557    #[test]
558    fn build_pipeline_records_resource_lock_provenance() {
559        let world = concat!(
560            r#"{"name":"f","type":"Font","args":{"size_px":20}}"#,
561            "\n",
562            r#"{"name":"pause","type":"Screen","args":{}}"#,
563            "\n",
564        );
565        let result = build_pipeline_from_str(world, None, None, Platform::Metal).expect("build");
566
567        assert_eq!(result.resource_locks.len(), result.resources.len());
568        let font = result
569            .resource_locks
570            .iter()
571            .find(|r| r.name == "f")
572            .expect("font provenance recorded");
573        assert_eq!(font.kind, "Font");
574        assert_eq!(font.handle, 0);
575        assert_eq!(font.args_hash.len(), 64);
576        // A payload resource records which blob holds its bytes.
577        assert!(font.payload_blob.is_some());
578        // The resource is not in the component asset list.
579        assert!(!result.names.iter().any(|n| n == "f"));
580    }
581
582    #[test]
583    fn build_from_path_missing_world_file_errors() {
584        let tree = crate::paths::StateTree::at(concinnity_testing::TempTree::new().path());
585        assert!(build_from_path(&tree, "/no/such/world.jsonl", Platform::Metal).is_err());
586    }
587
588    #[test]
589    fn build_from_path_reports_a_malformed_world_file() {
590        let dir = concinnity_testing::TempTree::new();
591        let world = dir.path().join("world.jsonl");
592        std::fs::write(&world, "{not json\n").expect("write world");
593        let tree = crate::paths::StateTree::at(dir.path());
594        let err = build_from_path(&tree, world.to_str().unwrap(), Platform::Metal)
595            .expect_err("malformed world");
596        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
597    }
598
599    // A lock that cannot be written fails the build: shipping blobs without the
600    // record of what went into them would leave the output unexplainable.
601    #[test]
602    fn write_build_outputs_fails_when_the_lock_cannot_be_written() {
603        let output = crate::blob::test_output::Output::new();
604        // A directory where the lock file belongs makes the write fail.
605        std::fs::create_dir_all(output.lock_path()).expect("occupy the lock path");
606
607        let result = PipelineResult {
608            defs: Vec::new(),
609            names: Vec::new(),
610            resources: Vec::new(),
611            scene_groups: Vec::new(),
612            mesh_bounds: Vec::new(),
613            physics_budget: None,
614            mesh_component_names: Vec::new(),
615            payloads: vec![vec![1, 2, 3]],
616            cache_hits: 0,
617            cache_misses: 0,
618            texture_sources: Vec::new(),
619            mesh_sources: Vec::new(),
620            resource_locks: Vec::new(),
621        };
622        assert!(
623            write_build_outputs(output.tree(), &result, &[], &[]).is_err(),
624            "an unwritable lock must fail the build"
625        );
626    }
627
628    // The full build tail: compile the world, ship the blobs under the state
629    // root, and record every asset in the lock beside them.
630    #[test]
631    fn build_from_path_writes_the_blobs_and_the_lock_beside_them() {
632        let output = crate::blob::test_output::Output::new();
633
634        let dir = concinnity_testing::TempTree::new();
635        let world_path = dir.path().join("world.jsonl");
636        std::fs::write(
637            &world_path,
638            concat!(
639                r#"{"name":"gfx","type":"GraphicsConfig","args":{}}"#,
640                "\n",
641                r#"{"name":"f","type":"Font","args":{"size_px":20}}"#,
642                "\n",
643                r#"{"name":"pause","type":"Screen","args":{}}"#,
644                "\n",
645            ),
646        )
647        .expect("write world");
648
649        build_from_path(output.tree(), world_path.to_str().unwrap(), Platform::Metal)
650            .expect("build");
651
652        let raw = std::fs::read_to_string(output.lock_path()).expect("lock written");
653        let lock: crate::blob::BlobLock = serde_json::from_str(&raw).expect("lock is valid json");
654        assert_eq!(lock.blobs.len(), 1);
655
656        let (meta, _) = crate::blob::read_cnb(&lock.blobs[0].path).expect("blob 0 parses");
657        assert_eq!(
658            meta.defs.len(),
659            lock.assets.len(),
660            "the lock names every def the blob ships"
661        );
662        assert_eq!(meta.resources.len(), lock.resources.len());
663        assert!(lock.assets.iter().any(|a| a.name == "pause"));
664
665        let font = lock
666            .resources
667            .iter()
668            .find(|r| r.name == "f")
669            .expect("the font is recorded in the resource stream");
670        assert_eq!(font.kind, "Font");
671        assert_eq!(font.payload_blob, Some(0));
672        assert!(
673            !lock.injected.is_empty(),
674            "engine defaults are recorded so they can be overridden"
675        );
676    }
677
678    #[test]
679    fn build_pipeline_from_str_rejects_malformed_jsonl() {
680        let Err(err) = build_pipeline_from_str("{not json\n", None, None, Platform::Metal) else {
681            panic!("malformed line must not build");
682        };
683        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
684    }
685
686    #[test]
687    fn build_pipeline_from_str_reports_unknown_asset_types() {
688        let world = r#"{"name":"mystery","type":"NotAType","args":{}}"#;
689        let Err(err) = build_pipeline_from_str(world, None, None, Platform::Metal) else {
690            panic!("unknown type must not build");
691        };
692        assert!(
693            err.to_string().contains("NotAType"),
694            "error should name the unknown type: {err}"
695        );
696    }
697
698    // `build_compiled` runs on an already-prepared world, so a type the
699    // component registry cannot resolve surfaces here rather than upstream.
700    #[test]
701    fn build_compiled_names_the_asset_whose_type_will_not_resolve() {
702        let assets = vec![wja("mystery", "NotAType", serde_json::json!({}))];
703        let Err(err) = build_compiled(assets, None, None, Platform::Metal) else {
704            panic!("unknown type must not compile");
705        };
706        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
707        assert!(err.to_string().contains("Asset 'mystery'"), "got: {err}");
708    }
709
710    // A payload that will not compile fails the whole build; the error names
711    // the asset so the author knows which line to fix.
712    #[test]
713    fn build_compiled_surfaces_a_payload_compile_failure() {
714        let assets = vec![wja(
715            "shape",
716            "ProceduralMesh",
717            serde_json::json!({"generator": "not_a_generator"}),
718        )];
719        let Err(err) = build_compiled(assets, None, None, Platform::Metal) else {
720            panic!("an uncompilable payload must not build");
721        };
722        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
723        assert!(err.to_string().contains("not_a_generator"), "got: {err}");
724    }
725
726    // An uncompressed 24-bit BGR Targa, the cheapest real image source to
727    // author inline.
728    fn tga_2x2() -> Vec<u8> {
729        let mut v = vec![0u8; 18];
730        v[2] = 2; // uncompressed true-color
731        v[12..14].copy_from_slice(&2u16.to_le_bytes());
732        v[14..16].copy_from_slice(&2u16.to_le_bytes());
733        v[16] = 24;
734        v[17] = 0x20; // top origin
735        v.extend_from_slice(&[10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]);
736        v
737    }
738
739    // `cn debug`'s hot-reload watcher maps a saved file back to the handle it
740    // feeds, so every file-backed texture and mesh records its source in handle
741    // order. A generated asset has nothing to watch and records an empty source.
742    #[test]
743    fn build_compiled_records_hot_reload_sources_in_handle_order() {
744        let dir = tempfile::tempdir().expect("tempdir");
745        let tga = write_fixture(&dir, "wall.tga", &tga_2x2());
746        let glb = write_fixture(
747            &dir,
748            "scene.glb",
749            &crate::import::glb::test_fixtures::static_triangle_glb(),
750        );
751        let assets = vec![
752            wja(
753                "proc_tex",
754                "Texture",
755                serde_json::json!({"generator": "checker", "resolution": 8}),
756            ),
757            wja(
758                "wall_tex",
759                "Texture",
760                serde_json::json!({"source": tga, "image_index": 3}),
761            ),
762            wja(
763                "inline_mesh",
764                MESH_TYPE,
765                serde_json::json!({"generator": "box", "half_extents": [1, 1, 1]}),
766            ),
767            wja(
768                "file_mesh",
769                MESH_TYPE,
770                serde_json::json!({
771                    "source": glb,
772                    "primitive_index": 0,
773                    "lod_levels": 3,
774                    "lod_distances": [10.0, 20.0],
775                }),
776            ),
777        ];
778        let result = build_compiled(assets, None, None, Platform::Metal).expect("build");
779
780        assert_eq!(result.texture_sources.len(), 2);
781        assert_eq!(
782            result.texture_sources[0],
783            TextureSourceInfo {
784                name_id: 0,
785                source: String::new(),
786                image_index: 0,
787            },
788            "a generated texture has no file to watch"
789        );
790        assert_eq!(
791            result.texture_sources[1],
792            TextureSourceInfo {
793                name_id: 1,
794                source: tga.clone(),
795                image_index: 3,
796            }
797        );
798
799        assert_eq!(result.mesh_sources.len(), 2);
800        assert_eq!(
801            result.mesh_sources[0],
802            MeshSourceInfo {
803                source: String::new(),
804                primitive_index: 0,
805                lod_levels: 1,
806                lod_distances: Vec::new(),
807            },
808            "a generated mesh has no file to watch"
809        );
810        assert_eq!(
811            result.mesh_sources[1],
812            MeshSourceInfo {
813                source: glb.clone(),
814                primitive_index: 0,
815                lod_levels: 3,
816                lod_distances: vec![10.0, 20.0],
817            }
818        );
819
820        // The lock records mirror the catalogues so a blob boot can rebuild
821        // them without the authored args.
822        let lock_tex: Vec<_> = result
823            .resource_locks
824            .iter()
825            .filter(|r| r.kind == "Texture")
826            .collect();
827        assert_eq!(lock_tex.len(), 2);
828        assert_eq!(lock_tex[0].texture_source.as_ref().unwrap().source, "");
829        let wall = lock_tex[1].texture_source.as_ref().unwrap();
830        assert_eq!(wall.source, tga);
831        assert_eq!(wall.image_index, 3);
832        assert!(lock_tex[1].mesh_source.is_none());
833
834        let lock_mesh: Vec<_> = result
835            .resource_locks
836            .iter()
837            .filter(|r| r.kind == "Mesh")
838            .collect();
839        assert_eq!(lock_mesh.len(), 2);
840        assert!(lock_mesh[0].texture_source.is_none());
841        let file_mesh = lock_mesh[1].mesh_source.as_ref().unwrap();
842        assert_eq!(file_mesh.source, glb);
843        assert_eq!(file_mesh.lod_levels, 3);
844        assert_eq!(file_mesh.lod_distances, vec![10.0, 20.0]);
845    }
846
847    // A data resource carries its bytes inline in the record rather than in a
848    // blob payload section, so the lock records no payload blob for it.
849    #[test]
850    fn build_compiled_keeps_a_data_resource_out_of_the_payload_sections() {
851        let assets = vec![
852            wja("wood", "Material", serde_json::json!({})),
853            wja(
854                "shape",
855                "ProceduralMesh",
856                serde_json::json!({"generator": "box"}),
857            ),
858        ];
859        let result = build_compiled(assets, None, None, Platform::Metal).expect("build");
860
861        assert_eq!(result.resources.len(), 1);
862        let material = &result.resources[0];
863        assert!(material.payload.is_none(), "a Material rides inline");
864        assert!(!material.data_bytes.is_empty());
865        postcard::from_bytes::<crate::components::Material>(&material.data_bytes)
866            .expect("the inline bytes decode as a Material");
867        assert_eq!(result.resource_locks[0].name, "wood");
868        assert_eq!(result.resource_locks[0].payload_blob, None);
869        // The component's payload is what actually occupies the blob.
870        assert_eq!(result.defs.len(), 1);
871        assert!(result.defs[0].payload.is_some());
872    }
873
874    // Only a File whose kind maps to a mesh payload is compiled; every other
875    // kind stays a plain reference with no blob bytes.
876    #[test]
877    fn build_compiled_compiles_only_mesh_kind_file_assets() {
878        let dir = tempfile::tempdir().expect("tempdir");
879        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");
880        let png = write_fixture(&dir, "icon.png", b"not read");
881        let assets = vec![
882            wja(
883                "model",
884                "File",
885                serde_json::json!({"path": obj, "kind": "obj"}),
886            ),
887            wja(
888                "icon",
889                "File",
890                serde_json::json!({"path": png, "kind": "png"}),
891            ),
892        ];
893        let result = build_compiled(assets, None, None, Platform::Metal).expect("build");
894
895        assert_eq!(result.names, vec!["model".to_string(), "icon".to_string()]);
896        let mesh_payload = result.defs[0]
897            .payload
898            .as_ref()
899            .expect("the obj File compiles");
900        assert!(mesh_payload.len > 0);
901        assert!(
902            result.defs[1].payload.is_none(),
903            "a png File produces no blob payload"
904        );
905    }
906}