1use 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
24fn 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
31const MESH_TYPE: &str = "Mesh";
35const SKINNED_MESH_TYPE: &str = "SkinnedMesh";
36
37pub 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
74pub 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
95pub 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 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
133fn errors_to_io(errors: Vec<String>) -> std::io::Error {
137 std::io::Error::new(std::io::ErrorKind::InvalidData, errors.join("\n"))
138}
139
140#[derive(Debug, Clone, Default, PartialEq)]
148pub struct TextureSourceInfo {
149 pub name_id: u32,
151 pub source: String,
153 pub image_index: u32,
155}
156
157#[derive(Debug, Clone, Default, PartialEq)]
164pub struct MeshSourceInfo {
165 pub source: String,
167 pub primitive_index: u32,
169 pub lod_levels: u32,
171 pub lod_distances: Vec<f32>,
173}
174
175pub struct PipelineResult {
179 pub defs: Vec<BlobAssetDef>,
181 pub names: Vec<String>,
184 pub resources: Vec<ResourceRecord>,
188 pub scene_groups: Vec<SceneGroup>,
190 pub mesh_bounds: Vec<MeshBoundsRecord>,
192 pub physics_budget: Option<PhysicsBudgetRecord>,
194 pub(crate) mesh_component_names: Vec<(u32, String)>,
200 pub payloads: Vec<Vec<u8>>,
202 pub(crate) cache_hits: usize,
204 pub(crate) cache_misses: usize,
206 pub texture_sources: Vec<TextureSourceInfo>,
209 pub mesh_sources: Vec<MeshSourceInfo>,
213 pub(crate) resource_locks: Vec<crate::blob::LockedResource>,
217}
218
219impl PipelineResult {
220 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 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
258pub fn validate_asset(
269 asset_type: &str,
270 name: &str,
271 args: &serde_json::Value,
272) -> Result<(), String> {
273 asset_id::reset_interner();
279 crate::resource_handles::reset_resource_handles();
280 let type_norm = asset_type.to_lowercase().replace('_', "");
281
282 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 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
316pub 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#[derive(Debug, Clone, Copy)]
335pub struct BuildProgress {
336 pub stage: &'static str,
338 pub done: u32,
340 pub total: u32,
342}
343
344pub 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
357pub 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 let mesh_cache = probe_mesh_payload_cache(&assets, assets_dir, artifacts_dir);
382
383 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 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 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 crate::resource_handles::assign_mesh_source_handles(&mut resource_handles, &assets);
427 crate::resource_handles::assign_shader_handles(&mut resource_handles, &assets);
430 crate::resource_handles::install_resource_handles(resource_handles.clone());
433
434 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 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 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 let partition = crate::scene_partition::partition_scenes(&assets);
562
563 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 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 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 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#[derive(Clone)]
652struct MeshCacheEntry {
653 key: String,
654 bytes: Option<Vec<u8>>,
655}
656
657fn 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 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 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
712fn 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
722fn 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
734fn 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 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
855fn 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
932fn 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 type Chunk = (Vec<VertexData>, Vec<u16>);
947
948 let mut parsed_cache: HashMap<String, crate::gltf_source::GltfDoc> = HashMap::new();
949 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 let lower = source.to_lowercase();
969 if !lower.ends_with(".glb") && !lower.ends_with(".gltf") {
970 continue;
971 }
972 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
1095fn 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 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
1229fn 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 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 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
1389fn desugar_root_motion(assets: &mut [WorldJsonlAsset]) -> std::io::Result<()> {
1396 use crate::components::Animation;
1397 use crate::ecs::Component;
1398
1399 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
1453pub 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 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
1488const RESOURCE_CACHE_DISC_BASE: u8 = 128;
1492
1493struct PendingResource {
1499 kind: u8,
1500 handle: u32,
1501 bytes: Vec<u8>,
1502 is_data: bool,
1503 extra_data: Vec<u8>,
1504}
1505
1506struct 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
1518fn 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1889fn 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
1922fn 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
1956fn 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 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 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 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 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 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 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 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 assert_eq!(baked.mesh, Some(crate::ecs::MeshHandle(0)));
2078 assert_eq!(baked.scene, Some(crate::ecs::asset_id::AssetId(1)));
2080 }
2081
2082 #[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 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 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 #[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 assert!(font.payload_blob.is_some());
2180 assert!(!result.names.iter().any(|n| n == "f"));
2182 }
2183
2184 #[test]
2188 fn visual_novel_world_validates() {
2189 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 #[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 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 #[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 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 #[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 #[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 #[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 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 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; }
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 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 #[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 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 #[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 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 #[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 #[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 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 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 assert_eq!(assets[0].args, inline_args);
2651 assert_eq!(assets[1].args, cached_args);
2652 }
2653
2654 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 #[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 assert_eq!(args["skeleton"].as_array().unwrap().len(), 2);
2681 assert!(args.get("morph_target_names").is_none());
2683 assert!(args.get("morph_deltas").is_none());
2684 }
2685
2686 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(); bin.extend(f32s(&[0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0])); bin.extend(f32s(&[0.0, 1.0])); 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 #[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 assert_eq!(args["morph_deltas"].as_array().unwrap().len(), 3);
2738 }
2739
2740 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 const KTIME_PER_SEC: i64 = 46_186_158_000;
3103
3104 fn skinned_triangle_fbx() -> Vec<u8> {
3105 skinned_fbx(false)
3106 }
3107
3108 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, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 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 #[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 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 #[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 assert!(args.get("morph_track").is_none());
3574 }
3575
3576 #[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 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 assert_eq!(
3639 assets[0].args["tracks"][0]["keyframes"][0]["translation"][0],
3640 1.0
3641 );
3642 }
3643
3644 #[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 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 #[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 assert_eq!(assets[2].args["scene"], "level_boss");
3709 assert_eq!(assets[3].args["scene"], "other");
3711 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 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 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 assert!(entry.bytes.is_none());
3784 }
3785 }
3786
3787 #[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 #[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 #[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 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 fn tga_2x2() -> Vec<u8> {
3844 let mut v = vec![0u8; 18];
3845 v[2] = 2; 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; v.extend_from_slice(&[10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]);
3851 v
3852 }
3853
3854 #[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 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 #[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 assert_eq!(result.defs.len(), 1);
3986 assert!(result.defs[0].payload.is_some());
3987 }
3988
3989 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 #[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 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 fn ct(name: &str) -> RegisteredType {
4587 RegisteredType::parse(name).unwrap_or_else(|| panic!("{name} is a registered component"))
4588 }
4589
4590 #[test]
4594 fn compile_by_type_dispatches_deterministic_arms() {
4595 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 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 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}