Skip to main content

khora_io/
asset_resolver.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Authored-reference → resolved-handle pump.
16//!
17//! A `PreExtract` data system that turns each entity's authored asset
18//! references into the runtime handles the GPU projection consumes. It covers
19//! two reference kinds with one collect-then-mutate pass each:
20//!
21//! **Materials** — [`MaterialRef`] → [`MaterialHandle`]
22//! (`HandleComponent<Box<dyn Material>>`):
23//!
24//! - [`MaterialRef::Inline`] embeds the material value — it is wrapped into a
25//!   handle keyed by a content-derived UUID, so two entities holding the same
26//!   inline material share one resolved handle (and one `GpuMaterial`).
27//! - [`MaterialRef::Asset`] references a `.kmat` in the VFS — it is loaded via
28//!   the [`AssetService`] and the resulting handle is keyed by the asset's
29//!   stable UUID.
30//!
31//! **Meshes** — [`MeshRef`] → `HandleComponent<Mesh>`:
32//!
33//! - [`MeshRef::Procedural`] carries primitive params — the geometry is rebuilt
34//!   via [`reconstruct_procedural_mesh`] and keyed by a content-derived UUID, so
35//!   identical procedural meshes dedup to one resolved handle (and one
36//!   `GpuMesh`).
37//! - [`MeshRef::Asset`] references an imported mesh (glTF, OBJ) in the VFS — it
38//!   is loaded via the [`AssetService`] and keyed by the asset's stable UUID.
39//!
40//! Each resolved CPU value is also inserted into the matching shared
41//! [`AssetStore`] sub-store (`store::<Box<dyn Material>>()` /
42//! `store::<Mesh>()`) so the GPU projection and future sharing see it. This
43//! system runs before both `gpu_material_sync` and `gpu_mesh_sync`, so a
44//! freshly-spawned entity resolves and projects in the same tick.
45//!
46//! The resolver is the sole authority that keeps resolved handles consistent
47//! with their authored references. Each authored ref carries an *identity
48//! UUID* (content-derived for `Inline`/`Procedural`, the stable asset UUID for
49//! `Asset`); every tick the resolver compares it against the resolved handle's
50//! UUID. In steady state this is a single cheap compare — nothing is loaded,
51//! cloned, or hashed. When the comparison fails (the developer/editor changed
52//! the ref, or no handle exists yet) the resolver (re)resolves, replaces the
53//! stale CPU handle, and drops the stale `HandleComponent<GpuMaterial>` /
54//! `HandleComponent<GpuMesh>` so the GPU projection re-projects under the new
55//! identity. This single read-phase compare covers editor inspector edits,
56//! `.kmat` assignment, save-as, and runtime gameplay mutation alike — there is
57//! no per-mutation-site invalidation.
58//!
59//! `khora-data` never depends on `khora-io`; this resolver lives here because
60//! it calls the `khora-io`-owned `AssetService` while mutating the
61//! `khora-data` `World` + `AssetStore`.
62
63use std::sync::{Arc, Mutex};
64
65use khora_core::asset::{AssetHandle, AssetUUID, Material};
66use khora_core::ecs::entity::EntityId;
67use khora_core::lane::OutputDeck;
68use khora_core::renderer::api::scene::{GpuMaterial, GpuMesh, Mesh};
69use khora_core::Runtime;
70use khora_data::ecs::{
71    reconstruct_procedural_mesh, DataSystemRegistration, HandleComponent, MaterialRef, MeshRef,
72    TickPhase, World,
73};
74use khora_data::AssetStore;
75
76use crate::asset::AssetService;
77
78/// One pending material resolution: the entity to tag, the resolved handle to
79/// store under `uuid`, and whether the entity already carried a (now stale)
80/// resolved handle. The stale flag decides whether phase 2 overwrites the CPU
81/// handle in place and drops the stale GPU projection (a changed ref) or simply
82/// adds the handle (a first resolve).
83struct ResolvedMaterial {
84    entity: EntityId,
85    uuid: AssetUUID,
86    handle: AssetHandle<Box<dyn Material>>,
87    had_stale_handle: bool,
88}
89
90/// One pending mesh resolution: the entity to tag, the resolved handle to store
91/// under `uuid`, and whether the entity already carried a (now stale) resolved
92/// handle (see [`ResolvedMaterial`]).
93struct ResolvedMesh {
94    entity: EntityId,
95    uuid: AssetUUID,
96    handle: AssetHandle<Mesh>,
97    had_stale_handle: bool,
98}
99
100fn asset_resolver_system(world: &mut World, runtime: &Runtime, _deck: &mut OutputDeck) {
101    resolve_materials(world, runtime);
102    resolve_meshes(world, runtime);
103}
104
105fn resolve_materials(world: &mut World, runtime: &Runtime) {
106    let asset_store = runtime.resources.get::<AssetStore>();
107    let asset_service = runtime.services.get::<Arc<Mutex<AssetService>>>().cloned();
108
109    // Phase 1: reconcile against the resolved handle while the query borrows
110    // the world read-only. Steady state is a cheap UUID compare: the authored
111    // ref's identity UUID already equals the resolved handle's UUID, so nothing
112    // is cloned, loaded, or hashed. Real work happens only when the authored
113    // ref changed (or has no handle yet).
114    let mut pending: Vec<ResolvedMaterial> = Vec::new();
115    {
116        let query = world.query::<(
117            EntityId,
118            &MaterialRef,
119            Option<&HandleComponent<Box<dyn Material>>>,
120        )>();
121
122        for (entity, material_ref, current) in query {
123            let expected = material_ref.uuid();
124            // Up to date: the resolved handle already carries the authored
125            // identity. Nothing to do.
126            if current.map(|h| h.uuid) == Some(expected) {
127                continue;
128            }
129            // A stale handle is present (the authored ref changed); the new
130            // handle must overwrite it in place and the GPU projection must be
131            // dropped so it re-projects under the new identity.
132            let had_stale_handle = current.is_some();
133
134            match material_ref {
135                MaterialRef::Inline { material, .. } => {
136                    let handle = AssetHandle::new(material.clone_box());
137                    pending.push(ResolvedMaterial {
138                        entity,
139                        uuid: expected,
140                        handle,
141                        had_stale_handle,
142                    });
143                }
144                MaterialRef::Asset(uuid) => {
145                    let Some(service) = &asset_service else {
146                        log::error!(
147                            "asset_resolver: AssetService missing; cannot resolve material \
148                             asset {uuid:?} on entity {entity:?}."
149                        );
150                        continue;
151                    };
152                    let loaded = match service.lock() {
153                        Ok(mut svc) => svc.load::<Box<dyn Material>>(uuid),
154                        Err(_) => {
155                            log::error!("asset_resolver: AssetService mutex poisoned.");
156                            continue;
157                        }
158                    };
159                    match loaded {
160                        Ok(handle) => pending.push(ResolvedMaterial {
161                            entity,
162                            uuid: *uuid,
163                            handle,
164                            had_stale_handle,
165                        }),
166                        Err(e) => {
167                            // No decoder yet (the `.kmat` decoder lands later) or
168                            // a genuine load failure: log + skip, never fall back.
169                            log::error!(
170                                "asset_resolver: failed to load material asset {uuid:?} for \
171                                 entity {entity:?}: {e:#}"
172                            );
173                        }
174                    }
175                }
176            }
177        }
178    }
179
180    if pending.is_empty() {
181        return;
182    }
183
184    // Phase 2: publish resolved data to the shared store and tag entities. A
185    // first resolve adds the handle; a changed ref overwrites the stale handle
186    // in place (`set_component`) and drops the stale `HandleComponent<GpuMaterial>`
187    // so the projection re-projects under the new identity. The removal only
188    // runs when a stale handle existed, so a still-valid steady-state projection
189    // is never disturbed (and steady state never reaches phase 2 at all).
190    let store = asset_store.map(|s| s.store::<Box<dyn Material>>());
191    for ResolvedMaterial {
192        entity,
193        uuid,
194        handle,
195        had_stale_handle,
196    } in pending
197    {
198        if let Some(store) = &store {
199            store
200                .write()
201                .unwrap_or_else(|e| e.into_inner())
202                .insert(uuid, handle.clone());
203        }
204        let component = HandleComponent { handle, uuid };
205        if had_stale_handle {
206            world.set_component(entity, component);
207            if let Err(e) = world.remove_component::<HandleComponent<GpuMaterial>>(entity) {
208                log::trace!(
209                    "asset_resolver: dropping stale GpuMaterial handle on {entity:?} skipped: {e:?}"
210                );
211            }
212        } else if let Err(e) = world.add_component(entity, component) {
213            log::warn!("asset_resolver: attaching material handle on {entity:?} failed: {e:?}");
214        }
215    }
216}
217
218fn resolve_meshes(world: &mut World, runtime: &Runtime) {
219    let asset_store = runtime.resources.get::<AssetStore>();
220    let asset_service = runtime.services.get::<Arc<Mutex<AssetService>>>().cloned();
221
222    // Phase 1: reconcile against the resolved handle while the query borrows
223    // the world read-only. Steady state is a cheap UUID compare (no rebuild,
224    // clone, or hash); geometry is regenerated only when the authored ref
225    // changed (or has no handle yet).
226    let mut pending: Vec<ResolvedMesh> = Vec::new();
227    {
228        let query = world.query::<(EntityId, &MeshRef, Option<&HandleComponent<Mesh>>)>();
229
230        for (entity, mesh_ref, current) in query {
231            let expected = mesh_ref.uuid();
232            // Up to date: the resolved handle already carries the authored
233            // identity. Nothing to do.
234            if current.map(|h| h.uuid) == Some(expected) {
235                continue;
236            }
237            let had_stale_handle = current.is_some();
238
239            match mesh_ref {
240                MeshRef::Procedural { kind, params, .. } => {
241                    let mesh = reconstruct_procedural_mesh(*kind, *params);
242                    pending.push(ResolvedMesh {
243                        entity,
244                        uuid: expected,
245                        handle: AssetHandle::new(mesh),
246                        had_stale_handle,
247                    });
248                }
249                MeshRef::Asset(uuid) => {
250                    let Some(service) = &asset_service else {
251                        log::error!(
252                            "asset_resolver: AssetService missing; cannot resolve mesh \
253                             asset {uuid:?} on entity {entity:?}."
254                        );
255                        continue;
256                    };
257                    let loaded = match service.lock() {
258                        Ok(mut svc) => svc.load::<Mesh>(uuid),
259                        Err(_) => {
260                            log::error!("asset_resolver: AssetService mutex poisoned.");
261                            continue;
262                        }
263                    };
264                    match loaded {
265                        Ok(handle) => pending.push(ResolvedMesh {
266                            entity,
267                            uuid: *uuid,
268                            handle,
269                            had_stale_handle,
270                        }),
271                        Err(e) => {
272                            // No mesh decoder registered or a genuine load
273                            // failure: log + skip, never fall back to a default.
274                            log::error!(
275                                "asset_resolver: failed to load mesh asset {uuid:?} for \
276                                 entity {entity:?}: {e:#}"
277                            );
278                        }
279                    }
280                }
281            }
282        }
283    }
284
285    if pending.is_empty() {
286        return;
287    }
288
289    // Phase 2: publish resolved data to the shared store and tag entities. A
290    // first resolve adds the handle; a changed ref overwrites the stale handle
291    // in place and drops the stale `HandleComponent<GpuMesh>` so the projection
292    // re-projects under the new identity, without disturbing a still-valid
293    // steady-state projection.
294    let store = asset_store.map(|s| s.store::<Mesh>());
295    for ResolvedMesh {
296        entity,
297        uuid,
298        handle,
299        had_stale_handle,
300    } in pending
301    {
302        if let Some(store) = &store {
303            store
304                .write()
305                .unwrap_or_else(|e| e.into_inner())
306                .insert(uuid, handle.clone());
307        }
308        let component = HandleComponent { handle, uuid };
309        if had_stale_handle {
310            world.set_component(entity, component);
311            if let Err(e) = world.remove_component::<HandleComponent<GpuMesh>>(entity) {
312                log::trace!(
313                    "asset_resolver: dropping stale GpuMesh handle on {entity:?} skipped: {e:?}"
314                );
315            }
316        } else if let Err(e) = world.add_component(entity, component) {
317            log::warn!("asset_resolver: attaching mesh handle on {entity:?} failed: {e:?}");
318        }
319    }
320}
321
322inventory::submit! {
323    DataSystemRegistration {
324        name: "asset_resolver",
325        phase: TickPhase::PreExtract,
326        run: asset_resolver_system,
327        // Lower than `gpu_mesh_sync` (order_hint 0) and `gpu_material_sync`
328        // (order_hint 1) so resolution precedes both GPU projections within the
329        // same PreExtract phase.
330        order_hint: -5,
331        runs_after: &[],
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use khora_core::asset::StandardMaterial;
339    use khora_core::math::LinearRgba;
340    use khora_core::renderer::api::command::BindGroupId;
341    use khora_core::renderer::api::pipeline::{PrimitiveTopology, ShaderVariantKey};
342    use khora_core::renderer::api::resource::{BufferId, SamplerId};
343    use khora_core::renderer::api::util::IndexFormat;
344
345    /// A dummy resolved `GpuMaterial` handle for a given uuid — stands in for a
346    /// projection that already ran, so the reconciliation logic can be tested
347    /// without a real GPU device.
348    fn gpu_material_stub(uuid: AssetUUID) -> HandleComponent<GpuMaterial> {
349        HandleComponent {
350            handle: AssetHandle::new(GpuMaterial {
351                uniform_buffer: BufferId(0),
352                base_color_view: None,
353                metallic_roughness_view: None,
354                normal_view: None,
355                emissive_view: None,
356                occlusion_view: None,
357                base_color_texture: None,
358                metallic_roughness_texture: None,
359                normal_texture: None,
360                emissive_texture: None,
361                occlusion_texture: None,
362                sampler: SamplerId(0),
363                bind_group: BindGroupId(0),
364                variant: ShaderVariantKey::empty(),
365                double_sided: false,
366                blend: false,
367            }),
368            uuid,
369        }
370    }
371
372    /// A dummy resolved `GpuMesh` handle for a given uuid.
373    fn gpu_mesh_stub(uuid: AssetUUID) -> HandleComponent<GpuMesh> {
374        HandleComponent {
375            handle: AssetHandle::new(GpuMesh {
376                vertex_buffer: BufferId(0),
377                index_buffer: BufferId(0),
378                index_count: 0,
379                index_format: IndexFormat::Uint32,
380                primitive_topology: PrimitiveTopology::TriangleList,
381            }),
382            uuid,
383        }
384    }
385
386    #[test]
387    fn resolves_inline_material_into_handle_and_store() {
388        let mut world = World::new();
389        let store = AssetStore::new();
390
391        let mut runtime = Runtime::default();
392        runtime.resources.insert(store.clone());
393
394        let distinctive = StandardMaterial {
395            base_color: LinearRgba::new(0.2, 0.4, 0.6, 1.0),
396            roughness: 0.33,
397            metallic: 0.7,
398            ..Default::default()
399        };
400        let entity = world.spawn(MaterialRef::inline(Box::new(distinctive.clone())));
401
402        let mut deck = OutputDeck::default();
403        asset_resolver_system(&mut world, &runtime, &mut deck);
404
405        let resolved = world
406            .get::<HandleComponent<Box<dyn Material>>>(entity)
407            .expect("inline material should resolve to a handle");
408        let material: &dyn Material = &**resolved.handle;
409        let standard = material
410            .as_any()
411            .downcast_ref::<StandardMaterial>()
412            .expect("resolved material should downcast to StandardMaterial");
413        assert_eq!(standard.base_color, distinctive.base_color);
414        assert_eq!(standard.roughness, distinctive.roughness);
415        assert_eq!(standard.metallic, distinctive.metallic);
416
417        // The shared store must also hold the resolved material under the same uuid.
418        let sub = store.store::<Box<dyn Material>>();
419        let guard = sub.read().unwrap();
420        assert!(
421            guard.get(&resolved.uuid).is_some(),
422            "resolved material should be inserted into the shared AssetStore"
423        );
424    }
425
426    #[test]
427    fn inline_resolution_is_idempotent() {
428        let mut world = World::new();
429        let runtime = Runtime::default();
430        let entity = world.spawn(MaterialRef::inline(Box::new(StandardMaterial::default())));
431
432        let mut deck = OutputDeck::default();
433        asset_resolver_system(&mut world, &runtime, &mut deck);
434        let first = world
435            .get::<HandleComponent<Box<dyn Material>>>(entity)
436            .expect("first pass resolves")
437            .uuid;
438
439        // A second pass finds the handle already present and changes nothing.
440        asset_resolver_system(&mut world, &runtime, &mut deck);
441        let second = world
442            .get::<HandleComponent<Box<dyn Material>>>(entity)
443            .expect("handle persists")
444            .uuid;
445        assert_eq!(first, second);
446    }
447
448    /// Changing an entity's `MaterialRef` to a different inline material
449    /// re-resolves the CPU handle under the new content UUID and drops the
450    /// stale `HandleComponent<GpuMaterial>` so the projection rebuilds it.
451    #[test]
452    fn changing_inline_material_reconciles_handle_and_drops_gpu() {
453        let mut world = World::new();
454        let store = AssetStore::new();
455        let mut runtime = Runtime::default();
456        runtime.resources.insert(store.clone());
457
458        let mat_a = StandardMaterial {
459            base_color: LinearRgba::new(0.1, 0.2, 0.3, 1.0),
460            ..Default::default()
461        };
462        let entity = world.spawn(MaterialRef::inline(Box::new(mat_a)));
463
464        let mut deck = OutputDeck::default();
465        asset_resolver_system(&mut world, &runtime, &mut deck);
466        let uuid_a = world
467            .get::<HandleComponent<Box<dyn Material>>>(entity)
468            .expect("material A resolves")
469            .uuid;
470
471        // Simulate the GPU projection having run for material A.
472        world
473            .add_component(entity, gpu_material_stub(uuid_a))
474            .unwrap();
475
476        // The developer/editor swaps in a different inline material.
477        let mat_b = StandardMaterial {
478            base_color: LinearRgba::new(0.9, 0.8, 0.7, 1.0),
479            roughness: 0.5,
480            ..Default::default()
481        };
482        assert!(
483            world.set_component(entity, MaterialRef::inline(Box::new(mat_b))),
484            "replacing the MaterialRef must succeed"
485        );
486
487        asset_resolver_system(&mut world, &runtime, &mut deck);
488
489        let uuid_b = world
490            .get::<HandleComponent<Box<dyn Material>>>(entity)
491            .expect("material B resolves")
492            .uuid;
493        assert_ne!(
494            uuid_a, uuid_b,
495            "different material content => different uuid"
496        );
497        assert!(
498            world.get::<HandleComponent<GpuMaterial>>(entity).is_none(),
499            "the stale GpuMaterial handle must be removed so the projection rebuilds"
500        );
501    }
502
503    /// Re-running the resolver with no ref change leaves the CPU handle and the
504    /// GPU stub untouched — steady state does no churn (just a UUID compare).
505    #[test]
506    fn steady_state_material_resolution_does_not_remove_gpu() {
507        let mut world = World::new();
508        let store = AssetStore::new();
509        let mut runtime = Runtime::default();
510        runtime.resources.insert(store.clone());
511
512        let entity = world.spawn(MaterialRef::inline(Box::new(StandardMaterial::default())));
513
514        let mut deck = OutputDeck::default();
515        asset_resolver_system(&mut world, &runtime, &mut deck);
516        let uuid = world
517            .get::<HandleComponent<Box<dyn Material>>>(entity)
518            .expect("resolves")
519            .uuid;
520
521        world
522            .add_component(entity, gpu_material_stub(uuid))
523            .unwrap();
524
525        // No ref change: the resolver must not touch either handle.
526        asset_resolver_system(&mut world, &runtime, &mut deck);
527
528        let after = world
529            .get::<HandleComponent<Box<dyn Material>>>(entity)
530            .expect("handle persists")
531            .uuid;
532        assert_eq!(uuid, after, "steady state keeps the same CPU handle uuid");
533        assert!(
534            world.get::<HandleComponent<GpuMaterial>>(entity).is_some(),
535            "steady state must not remove the still-valid GpuMaterial handle"
536        );
537    }
538
539    /// Changing an entity's `MeshRef` to different procedural params
540    /// re-resolves the CPU handle under the new content UUID and drops the
541    /// stale `HandleComponent<GpuMesh>` so the projection rebuilds it.
542    #[test]
543    fn changing_procedural_mesh_reconciles_handle_and_drops_gpu() {
544        use khora_data::ecs::ProceduralMeshKind;
545
546        let mut world = World::new();
547        let store = AssetStore::new();
548        let mut runtime = Runtime::default();
549        runtime.resources.insert(store.clone());
550
551        let entity = world.spawn(MeshRef::procedural(
552            ProceduralMeshKind::Cube,
553            [1.0, 0.0, 0.0, 0.0],
554        ));
555
556        let mut deck = OutputDeck::default();
557        asset_resolver_system(&mut world, &runtime, &mut deck);
558        let uuid_a = world
559            .get::<HandleComponent<Mesh>>(entity)
560            .expect("mesh A resolves")
561            .uuid;
562
563        world.add_component(entity, gpu_mesh_stub(uuid_a)).unwrap();
564
565        // Resize the cube — different params => different content uuid.
566        assert!(
567            world.set_component(
568                entity,
569                MeshRef::procedural(ProceduralMeshKind::Cube, [4.0, 0.0, 0.0, 0.0]),
570            ),
571            "replacing the MeshRef must succeed"
572        );
573
574        asset_resolver_system(&mut world, &runtime, &mut deck);
575
576        let uuid_b = world
577            .get::<HandleComponent<Mesh>>(entity)
578            .expect("mesh B resolves")
579            .uuid;
580        assert_ne!(uuid_a, uuid_b, "different params => different uuid");
581        assert!(
582            world.get::<HandleComponent<GpuMesh>>(entity).is_none(),
583            "the stale GpuMesh handle must be removed so the projection rebuilds"
584        );
585    }
586
587    #[test]
588    fn resolves_asset_material_from_kmat_via_service() {
589        use crate::asset::{FileLoader, IndexBuilder};
590        use khora_core::asset::StandardMaterial;
591        use khora_telemetry::MetricsRegistry;
592        use std::fs;
593        use tempfile::tempdir;
594
595        // A project assets dir with one `.kmat`. The on-disk form is RON of the
596        // `{ type_name, material }` JSON-value tree the decoder consumes.
597        let dir = tempdir().unwrap();
598        let assets_root = dir.path();
599        fs::create_dir_all(assets_root.join("materials")).unwrap();
600        let rel_path = "materials/brass.kmat";
601        let kmat = r#"{
602            "type_name": "StandardMaterial",
603            "material": {
604                "base_color": { "r": 0.71, "g": 0.65, "b": 0.26, "a": 1.0 },
605                "base_color_texture": (),
606                "metallic": 1.0,
607                "roughness": 0.18,
608                "metallic_roughness_texture": (),
609                "normal_map": (),
610                "occlusion_map": (),
611                "emissive": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 },
612                "emissive_texture": (),
613                "alpha_mode": "Opaque",
614                "alpha_cutoff": 0.5,
615                "double_sided": false,
616            },
617        }"#;
618        fs::write(assets_root.join(rel_path), kmat).unwrap();
619
620        // Build the VFS index. `.kmat` UUIDs are keyed by `new_v5` over the
621        // forward-slash relative path (the IndexBuilder convention), so an
622        // editor minting `MaterialRef::Asset` uses the same key.
623        let index_bytes = IndexBuilder::new(assets_root).build_index_bytes().unwrap();
624        let uuid = AssetUUID::new_v5(rel_path);
625
626        let metrics = Arc::new(MetricsRegistry::new());
627        let mut service = AssetService::new(
628            &index_bytes,
629            Box::new(FileLoader::new(assets_root)),
630            metrics,
631            None,
632        )
633        .unwrap();
634        // Pulls in the inventory-registered material decoder.
635        service.register_inventory_decoders();
636
637        let store = AssetStore::new();
638        let mut runtime = Runtime::default();
639        runtime.resources.insert(store.clone());
640        runtime
641            .services
642            .insert(Arc::new(Mutex::new(service)) as Arc<Mutex<AssetService>>);
643
644        let mut world = World::new();
645        let entity = world.spawn(MaterialRef::Asset(uuid));
646
647        let mut deck = OutputDeck::default();
648        asset_resolver_system(&mut world, &runtime, &mut deck);
649
650        let resolved = world
651            .get::<HandleComponent<Box<dyn Material>>>(entity)
652            .expect("asset material should resolve to a handle");
653        // The stable asset UUID is preserved on the resolved handle.
654        assert_eq!(resolved.uuid, uuid);
655
656        let material: &dyn Material = &**resolved.handle;
657        let standard = material
658            .as_any()
659            .downcast_ref::<StandardMaterial>()
660            .expect("resolved .kmat should decode to StandardMaterial");
661        assert_eq!(standard.metallic, 1.0);
662        assert_eq!(standard.roughness, 0.18);
663        assert_eq!(standard.base_color.r, 0.71);
664
665        // The shared store also holds the resolved material under the asset UUID,
666        // so the GPU projection (and a second referencing entity) see it.
667        let sub = store.store::<Box<dyn Material>>();
668        assert!(
669            sub.read().unwrap().get(&uuid).is_some(),
670            "resolved asset material should be published to the shared AssetStore"
671        );
672    }
673
674    #[test]
675    fn resolves_procedural_mesh_into_handle_with_cube_counts() {
676        use khora_data::ecs::ProceduralMeshKind;
677
678        let mut world = World::new();
679        let store = AssetStore::new();
680        let mut runtime = Runtime::default();
681        runtime.resources.insert(store.clone());
682
683        let entity = world.spawn(MeshRef::procedural(
684            ProceduralMeshKind::Cube,
685            [2.0, 0.0, 0.0, 0.0],
686        ));
687
688        let mut deck = OutputDeck::default();
689        asset_resolver_system(&mut world, &runtime, &mut deck);
690
691        let resolved = world
692            .get::<HandleComponent<Mesh>>(entity)
693            .expect("procedural mesh should resolve to a handle");
694        let mesh: &Mesh = &resolved.handle;
695        assert_eq!(mesh.positions.len(), 24, "cube has 24 vertices");
696        assert_eq!(
697            mesh.indices.as_ref().map_or(0, |i| i.len()),
698            36,
699            "cube has 36 indices"
700        );
701
702        // The shared store also holds the resolved mesh under the content uuid.
703        let sub = store.store::<Mesh>();
704        assert!(
705            sub.read().unwrap().get(&resolved.uuid).is_some(),
706            "resolved procedural mesh should be published to the shared AssetStore"
707        );
708    }
709
710    /// Two entities with identical procedural meshes dedup to one content uuid.
711    #[test]
712    fn identical_procedural_meshes_share_one_uuid() {
713        use khora_data::ecs::ProceduralMeshKind;
714
715        let mut world = World::new();
716        let runtime = Runtime::default();
717
718        let make = || MeshRef::procedural(ProceduralMeshKind::Sphere, [0.5, 16.0, 16.0, 0.0]);
719        let a = world.spawn(make());
720        let b = world.spawn(make());
721
722        let mut deck = OutputDeck::default();
723        asset_resolver_system(&mut world, &runtime, &mut deck);
724
725        let ua = world.get::<HandleComponent<Mesh>>(a).unwrap().uuid;
726        let ub = world.get::<HandleComponent<Mesh>>(b).unwrap().uuid;
727        assert_eq!(
728            ua, ub,
729            "identical procedural meshes must share a content uuid"
730        );
731    }
732
733    /// End-to-end: a `MeshRef::Asset` referencing a tiny `.obj` in the VFS is
734    /// loaded by the resolver through a real `AssetService`. This is the path
735    /// that previously resolved to a silent empty placeholder.
736    #[test]
737    fn resolves_asset_mesh_from_obj_via_service() {
738        use crate::asset::{FileLoader, IndexBuilder, MeshDispatcher};
739        use khora_telemetry::MetricsRegistry;
740        use std::fs;
741        use tempfile::tempdir;
742
743        let dir = tempdir().unwrap();
744        let assets_root = dir.path();
745        fs::create_dir_all(assets_root.join("meshes")).unwrap();
746        let rel_path = "meshes/tri.obj";
747        // A single triangle — the smallest renderable OBJ.
748        let obj = "v 0.0 0.0 0.0\nv 1.0 0.0 0.0\nv 0.0 1.0 0.0\nf 1 2 3\n";
749        fs::write(assets_root.join(rel_path), obj).unwrap();
750
751        let index_bytes = IndexBuilder::new(assets_root).build_index_bytes().unwrap();
752        let uuid = AssetUUID::new_v5(rel_path);
753
754        let metrics = Arc::new(MetricsRegistry::new());
755        let mut service = AssetService::new(
756            &index_bytes,
757            Box::new(FileLoader::new(assets_root)),
758            metrics,
759            None,
760        )
761        .unwrap();
762        // The mesh dispatcher has competing impls (gltf vs obj) so it is wired
763        // explicitly at the call site rather than via inventory.
764        service.register_decoder::<Mesh>("mesh", MeshDispatcher::default());
765
766        let store = AssetStore::new();
767        let mut runtime = Runtime::default();
768        runtime.resources.insert(store.clone());
769        runtime
770            .services
771            .insert(Arc::new(Mutex::new(service)) as Arc<Mutex<AssetService>>);
772
773        let mut world = World::new();
774        let entity = world.spawn(MeshRef::Asset(uuid));
775
776        let mut deck = OutputDeck::default();
777        asset_resolver_system(&mut world, &runtime, &mut deck);
778
779        let resolved = world
780            .get::<HandleComponent<Mesh>>(entity)
781            .expect("asset mesh should resolve to a handle");
782        // The stable asset UUID is preserved on the resolved handle.
783        assert_eq!(resolved.uuid, uuid);
784        let mesh: &Mesh = &resolved.handle;
785        assert_eq!(mesh.positions.len(), 3, "the triangle has 3 vertices");
786
787        let sub = store.store::<Mesh>();
788        assert!(
789            sub.read().unwrap().get(&uuid).is_some(),
790            "resolved asset mesh should be published to the shared AssetStore"
791        );
792    }
793}