Skip to main content

khora_io/asset/
service.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//! Asset management service — on-demand loading, not an Agent.
16//!
17//! This service provides a `load()` API backed by a VFS + IO layer + decoder registry.
18//! No GORNA negotiation, no per-frame budget — assets are loaded on-demand.
19
20use std::any::{Any, TypeId};
21use std::collections::HashMap;
22use std::sync::Arc;
23
24use anyhow::{anyhow, Context, Result};
25use khora_core::asset::{Asset, AssetHandle, AssetUUID};
26use khora_data::assets::Assets;
27use khora_telemetry::MetricsRegistry;
28
29use super::io::AssetIo;
30use super::manifest::PackManifest;
31use super::registry::DecoderRegistry;
32use crate::vfs::VirtualFileSystem;
33
34/// Trait-object adapter so the service's storage map can dispatch
35/// per-uuid removal without knowing the concrete `A` at the call site.
36///
37/// One impl exists for every `Assets<A: Asset>` (see the blanket below).
38trait AnyAssets: Any + Send + Sync {
39    /// Removes the cached handle for `uuid` if present. Returns `true` if
40    /// something was removed.
41    fn remove_uuid(&mut self, uuid: &AssetUUID) -> bool;
42
43    /// Required for downcast back to `Assets<A>` from the service's
44    /// `HashMap<TypeId, Box<dyn AnyAssets>>` map.
45    fn as_any_mut(&mut self) -> &mut dyn Any;
46}
47
48impl<A: Asset + Send + Sync + 'static> AnyAssets for Assets<A> {
49    fn remove_uuid(&mut self, uuid: &AssetUUID) -> bool {
50        self.remove(uuid).is_some()
51    }
52    fn as_any_mut(&mut self) -> &mut dyn Any {
53        self
54    }
55}
56
57/// The asset management service.
58///
59/// Provides on-demand asset loading through a VFS → IO → Decode → Store pipeline.
60/// Registered in `ServiceRegistry` and accessed by game code via `AppContext`.
61pub struct AssetService {
62    vfs: VirtualFileSystem,
63    io: Box<dyn AssetIo>,
64    decoders: DecoderRegistry,
65    storages: HashMap<TypeId, Box<dyn AnyAssets>>,
66    load_count: usize,
67    /// When `Some`, every byte slice produced by `io.load_bytes` is
68    /// re-hashed against the manifest before reaching the decoder.
69    /// Constructed by the runtime only when `RuntimeConfig::verify_integrity`
70    /// is set and `manifest.bin` is present next to `data.pack`.
71    manifest: Option<PackManifest>,
72}
73
74impl AssetService {
75    /// Creates a new `AssetService`.
76    ///
77    /// When `manifest` is `Some`, both `load` and `load_raw` re-hash the
78    /// bytes returned by the underlying `AssetIo` against the recorded
79    /// BLAKE3 digest and refuse to proceed on mismatch.
80    pub fn new(
81        index_bytes: &[u8],
82        io: Box<dyn AssetIo>,
83        metrics_registry: Arc<MetricsRegistry>,
84        manifest: Option<PackManifest>,
85    ) -> Result<Self> {
86        let vfs = VirtualFileSystem::new(index_bytes)
87            .context("Failed to initialize VirtualFileSystem from index bytes")?;
88
89        Ok(Self {
90            vfs,
91            io,
92            decoders: DecoderRegistry::new(metrics_registry),
93            storages: HashMap::new(),
94            load_count: 0,
95            manifest,
96        })
97    }
98
99    /// Returns a reference to the underlying VFS for metadata enumeration
100    /// (asset browser) and direct UUID lookup.
101    pub fn vfs(&self) -> &VirtualFileSystem {
102        &self.vfs
103    }
104
105    /// Registers a decoder for a specific asset type.
106    pub fn register_decoder<A: Asset>(
107        &mut self,
108        type_name: &str,
109        decoder: impl super::decoder::AssetDecoder<A> + Send + Sync + 'static,
110    ) {
111        self.decoders.register::<A>(type_name, decoder);
112    }
113
114    /// Walks `inventory::iter::<DecoderRegistration>` and runs each entry's
115    /// `register` fn. Use this once after construction to pull in every
116    /// decoder declared via `inventory::submit!` — currently `texture` and
117    /// `font`. Audio and mesh decoders are intentionally explicit (see
118    /// `decoders/audio` and `decoders/mesh` for the rationale).
119    pub fn register_inventory_decoders(&mut self) {
120        for reg in inventory::iter::<super::registry::DecoderRegistration> {
121            (reg.register)(self);
122        }
123    }
124
125    /// Loads, decodes, and returns a typed handle to an asset.
126    pub fn load<A: Asset>(&mut self, uuid: &AssetUUID) -> Result<AssetHandle<A>> {
127        let type_id = TypeId::of::<A>();
128
129        // Get or create the typed storage. Inserts a fresh `Assets<A>` the
130        // first time we see this `A`.
131        let storage = self
132            .storages
133            .entry(type_id)
134            .or_insert_with(|| Box::new(Assets::<A>::new()));
135
136        let assets = storage
137            .as_any_mut()
138            .downcast_mut::<Assets<A>>()
139            .ok_or_else(|| anyhow!("Mismatched asset storage type"))?;
140
141        // Return cached handle if already loaded.
142        if let Some(handle) = assets.get(uuid) {
143            return Ok(handle.clone());
144        }
145
146        // VFS lookup → IO → Decode → Store
147        let metadata = self
148            .vfs
149            .get_metadata(uuid)
150            .ok_or_else(|| anyhow!("Asset with UUID {:?} not found in VFS", uuid))?;
151
152        let source = metadata
153            .variants
154            .get("default")
155            .ok_or_else(|| anyhow!("Asset {:?} has no 'default' variant", uuid))?;
156
157        let bytes = self.io.load_bytes(source)?;
158        if let Some(m) = &self.manifest {
159            m.verify(uuid, &bytes)
160                .context("Asset integrity check failed")?;
161        }
162        let asset: A = self
163            .decoders
164            .decode::<A>(&metadata.asset_type_name, &bytes)?;
165
166        let handle = AssetHandle::new(asset);
167        assets.insert(*uuid, handle.clone());
168
169        self.load_count += 1;
170        Ok(handle)
171    }
172
173    /// Loads an asset's raw bytes via the VFS → IO path, skipping the
174    /// decoder. Used by scene loading where the bytes are then decoded by a
175    /// `SerializationService` strategy rather than an `AssetDecoder<A>`.
176    pub fn load_raw(&mut self, uuid: &AssetUUID) -> Result<Vec<u8>> {
177        let metadata = self
178            .vfs
179            .get_metadata(uuid)
180            .ok_or_else(|| anyhow!("Asset with UUID {:?} not found in VFS", uuid))?;
181        let source = metadata
182            .variants
183            .get("default")
184            .ok_or_else(|| anyhow!("Asset {:?} has no 'default' variant", uuid))?;
185        let bytes = self.io.load_bytes(source)?;
186        if let Some(m) = &self.manifest {
187            m.verify(uuid, &bytes)
188                .context("Asset integrity check failed")?;
189        }
190        Ok(bytes)
191    }
192
193    /// Drops the cached handle for `uuid` across every typed storage.
194    /// Subsequent `load::<A>()` calls re-run the IO + decoder pipeline.
195    /// Returns `true` if any storage held this UUID.
196    ///
197    /// Outstanding clones of the previously-cached `AssetHandle<A>` keep the
198    /// old asset alive until they themselves drop — by design (in-flight
199    /// readers don't see a half-loaded replacement).
200    pub fn invalidate(&mut self, uuid: &AssetUUID) -> bool {
201        let mut any = false;
202        for storage in self.storages.values_mut() {
203            if storage.remove_uuid(uuid) {
204                any = true;
205            }
206        }
207        any
208    }
209
210    /// Atomically replaces the inner `VirtualFileSystem` with a freshly
211    /// decoded one. Cached handles are kept; callers should `invalidate`
212    /// any UUID whose underlying bytes changed (the editor's hot-reload
213    /// pump only invokes `reindex` when files were *added or removed* —
214    /// pure in-place modifications go through `invalidate`).
215    pub fn reindex(&mut self, index_bytes: &[u8]) -> Result<()> {
216        let new_vfs = VirtualFileSystem::new(index_bytes)
217            .context("Failed to decode replacement VFS index bytes")?;
218        self.vfs = new_vfs;
219        Ok(())
220    }
221
222    /// Returns the total number of assets loaded so far.
223    pub fn load_count(&self) -> usize {
224        self.load_count
225    }
226
227    /// Returns the number of cached asset type storages.
228    pub fn cached_type_count(&self) -> usize {
229        self.storages.len()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::asset::IndexBuilder;
237    use khora_core::asset::AssetSource;
238    use std::fs;
239    use tempfile::tempdir;
240
241    /// Mock IO that returns bytes from an in-memory map keyed by rel-path.
242    /// Lets the service tests cover invalidate/reindex without touching real
243    /// decoders (which need real format-specific bytes).
244    struct MockIo {
245        files: HashMap<std::path::PathBuf, Vec<u8>>,
246    }
247    impl AssetIo for MockIo {
248        fn load_bytes(&mut self, source: &AssetSource) -> Result<Vec<u8>> {
249            match source {
250                AssetSource::Path(rel) => self
251                    .files
252                    .get(rel)
253                    .cloned()
254                    .ok_or_else(|| anyhow!("not in mock: {:?}", rel)),
255                AssetSource::Packed { .. } => Err(anyhow!("mock doesn't support Packed")),
256            }
257        }
258    }
259
260    #[test]
261    fn vfs_accessor_returns_underlying_vfs() {
262        let dir = tempdir().unwrap();
263        fs::create_dir_all(dir.path().join("textures")).unwrap();
264        fs::write(dir.path().join("textures").join("a.png"), b"PNG").unwrap();
265        let bytes = IndexBuilder::new(dir.path()).build_index_bytes().unwrap();
266        let metrics = Arc::new(MetricsRegistry::new());
267        let svc = AssetService::new(
268            &bytes,
269            Box::new(MockIo {
270                files: HashMap::new(),
271            }),
272            metrics,
273            None,
274        )
275        .unwrap();
276        assert_eq!(svc.vfs().asset_count(), 1);
277    }
278
279    #[test]
280    fn load_raw_returns_bytes_without_decode() {
281        let dir = tempdir().unwrap();
282        fs::create_dir_all(dir.path().join("scenes")).unwrap();
283        fs::write(dir.path().join("scenes").join("a.kscene"), b"SCN").unwrap();
284        let bytes = IndexBuilder::new(dir.path()).build_index_bytes().unwrap();
285
286        let mut files = HashMap::new();
287        files.insert(std::path::PathBuf::from("scenes/a.kscene"), b"SCN".to_vec());
288        let metrics = Arc::new(MetricsRegistry::new());
289        let mut svc = AssetService::new(&bytes, Box::new(MockIo { files }), metrics, None).unwrap();
290
291        let uuid = AssetUUID::new_v5("scenes/a.kscene");
292        let raw = svc.load_raw(&uuid).unwrap();
293        assert_eq!(raw, b"SCN");
294    }
295
296    #[test]
297    fn reindex_swaps_vfs() {
298        let dir = tempdir().unwrap();
299        fs::create_dir_all(dir.path().join("textures")).unwrap();
300        fs::write(dir.path().join("textures").join("a.png"), b"PNG").unwrap();
301        let bytes_a = IndexBuilder::new(dir.path()).build_index_bytes().unwrap();
302        let metrics = Arc::new(MetricsRegistry::new());
303        let mut svc = AssetService::new(
304            &bytes_a,
305            Box::new(MockIo {
306                files: HashMap::new(),
307            }),
308            metrics,
309            None,
310        )
311        .unwrap();
312        assert_eq!(svc.vfs().asset_count(), 1);
313
314        // Add another asset, rebuild, reindex.
315        fs::write(dir.path().join("textures").join("b.png"), b"PNG2").unwrap();
316        let bytes_b = IndexBuilder::new(dir.path()).build_index_bytes().unwrap();
317        svc.reindex(&bytes_b).unwrap();
318        assert_eq!(svc.vfs().asset_count(), 2);
319
320        let new_uuid = AssetUUID::new_v5("textures/b.png");
321        assert!(svc.vfs().get_metadata(&new_uuid).is_some());
322    }
323
324    #[test]
325    fn invalidate_returns_false_when_nothing_cached() {
326        let dir = tempdir().unwrap();
327        let bytes = IndexBuilder::new(dir.path()).build_index_bytes().unwrap();
328        let metrics = Arc::new(MetricsRegistry::new());
329        let mut svc = AssetService::new(
330            &bytes,
331            Box::new(MockIo {
332                files: HashMap::new(),
333            }),
334            metrics,
335            None,
336        )
337        .unwrap();
338        assert!(!svc.invalidate(&AssetUUID::new_v5("missing")));
339    }
340
341    #[test]
342    fn load_raw_verifies_against_manifest_and_rejects_corruption() {
343        let dir = tempdir().unwrap();
344        fs::create_dir_all(dir.path().join("scenes")).unwrap();
345        let payload = b"SCENE-PAYLOAD";
346        fs::write(dir.path().join("scenes").join("a.kscene"), payload).unwrap();
347        let index_bytes = IndexBuilder::new(dir.path()).build_index_bytes().unwrap();
348
349        let uuid = AssetUUID::new_v5("scenes/a.kscene");
350
351        // Build a manifest matching the genuine payload.
352        let mut manifest = PackManifest::new();
353        manifest.insert(uuid, payload);
354
355        // Mock returns the genuine payload — verification should pass.
356        let mut files = HashMap::new();
357        files.insert(
358            std::path::PathBuf::from("scenes/a.kscene"),
359            payload.to_vec(),
360        );
361        let metrics = Arc::new(MetricsRegistry::new());
362        let mut svc = AssetService::new(
363            &index_bytes,
364            Box::new(MockIo { files }),
365            metrics,
366            Some(manifest.clone()),
367        )
368        .unwrap();
369        let raw = svc.load_raw(&uuid).unwrap();
370        assert_eq!(raw, payload);
371
372        // Now corrupt the bytes the mock returns. Same uuid, same manifest,
373        // different bytes => verification must fail.
374        let mut bad_files = HashMap::new();
375        bad_files.insert(
376            std::path::PathBuf::from("scenes/a.kscene"),
377            b"CORRUPTED-PAY".to_vec(),
378        );
379        let metrics2 = Arc::new(MetricsRegistry::new());
380        let mut svc_bad = AssetService::new(
381            &index_bytes,
382            Box::new(MockIo { files: bad_files }),
383            metrics2,
384            Some(manifest),
385        )
386        .unwrap();
387        let err = svc_bad.load_raw(&uuid).unwrap_err();
388        let msg = format!("{:#}", err);
389        assert!(
390            msg.contains("integrity") || msg.contains("BLAKE3") || msg.contains("size mismatch"),
391            "expected integrity error, got: {}",
392            msg
393        );
394    }
395}