1use 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
34trait AnyAssets: Any + Send + Sync {
39 fn remove_uuid(&mut self, uuid: &AssetUUID) -> bool;
42
43 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
57pub 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 manifest: Option<PackManifest>,
72}
73
74impl AssetService {
75 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 pub fn vfs(&self) -> &VirtualFileSystem {
102 &self.vfs
103 }
104
105 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 pub fn register_inventory_decoders(&mut self) {
120 for reg in inventory::iter::<super::registry::DecoderRegistration> {
121 (reg.register)(self);
122 }
123 }
124
125 pub fn load<A: Asset>(&mut self, uuid: &AssetUUID) -> Result<AssetHandle<A>> {
127 let type_id = TypeId::of::<A>();
128
129 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 if let Some(handle) = assets.get(uuid) {
143 return Ok(handle.clone());
144 }
145
146 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 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 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 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 pub fn load_count(&self) -> usize {
224 self.load_count
225 }
226
227 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 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 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 let mut manifest = PackManifest::new();
353 manifest.insert(uuid, payload);
354
355 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 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}