Skip to main content

proof_engine/asset/
mod.rs

1//! # Asset Pipeline
2//!
3//! A comprehensive asset management system for the Proof Engine.
4//!
5//! ## Overview
6//!
7//! The asset pipeline provides a complete lifecycle for all game/engine assets:
8//! loading from disk, caching, hot-reloading, dependency tracking, post-processing,
9//! and streaming. It is designed to work entirely with `std` — no external crates.
10//!
11//! ## Architecture
12//!
13//! ```text
14//! AssetServer
15//!   ├── AssetRegistry       — type-erased storage of all loaded assets
16//!   ├── AssetCache          — LRU eviction layer
17//!   ├── HotReload           — poll-based file watcher
18//!   ├── StreamingManager    — priority queue for background loads
19//!   └── AssetPack[]         — optional archive bundles
20//! ```
21//!
22//! ## Usage
23//!
24//! ```rust,no_run
25//! use proof_engine::asset::{AssetServer, AssetPath, ImageAsset};
26//!
27//! let mut server = AssetServer::new();
28//! let handle = server.load::<ImageAsset>(AssetPath::new("textures/player.png"));
29//! // Later, after the asset is ready:
30//! if let Some(img) = server.get(&handle) {
31//!     println!("{}x{}", img.width, img.height);
32//! }
33//! ```
34
35use std::any::{Any, TypeId};
36use std::collections::{HashMap, HashSet, VecDeque};
37use std::fmt;
38use std::hash::{Hash, Hasher};
39use std::marker::PhantomData;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, RwLock, Weak};
42use std::time::{Duration, Instant, SystemTime};
43
44// ────────────────────────────────────────────────────────────────────────────
45// Section 1 — Core Traits
46// ────────────────────────────────────────────────────────────────────────────
47
48/// Marker trait for any type that can be stored as an asset.
49///
50/// Implementors must be `Send + Sync + 'static` so they can be shared
51/// across threads and stored in the global registry without lifetime issues.
52///
53/// # Example
54/// ```rust
55/// use proof_engine::asset::Asset;
56/// struct MyData { value: u32 }
57/// impl Asset for MyData {}
58/// ```
59pub trait Asset: Send + Sync + 'static {}
60
61/// Trait for loading raw bytes into a concrete [`Asset`] type.
62///
63/// Each asset format (PNG, WAV, GLSL, etc.) has one loader. Loaders are
64/// stateless by design; mutable state lives in [`AssetServer`].
65///
66/// # Type parameter
67/// `A` — the [`Asset`] type produced by this loader.
68pub trait AssetLoader<A: Asset>: Send + Sync + 'static {
69    /// Load an asset from the given byte slice.
70    ///
71    /// `path` is provided for diagnostic messages; the loader must not
72    /// perform additional I/O itself.
73    ///
74    /// Returns `Ok(asset)` on success, `Err(message)` on failure.
75    fn load(&self, bytes: &[u8], path: &AssetPath) -> Result<A, String>;
76
77    /// Returns the file extensions this loader handles (without the dot).
78    ///
79    /// Examples: `&["png", "jpg"]`, `&["glsl", "vert", "frag"]`.
80    fn extensions(&self) -> &[&str];
81}
82
83/// Post-processes an asset after it is first loaded.
84///
85/// Examples: generating mip-maps for images, building a BVH for meshes,
86/// packing glyphs into a texture atlas for fonts.
87pub trait AssetProcessor<A: Asset>: Send + Sync + 'static {
88    /// Mutate or replace the asset in-place.
89    fn process(&self, asset: &mut A, path: &AssetPath) -> Result<(), String>;
90
91    /// Human-readable name used in debug output.
92    fn name(&self) -> &str;
93}
94
95// ────────────────────────────────────────────────────────────────────────────
96// Section 2 — AssetPath
97// ────────────────────────────────────────────────────────────────────────────
98
99/// A path to an asset, optionally qualified with a sub-asset label.
100///
101/// The syntax is `"path/to/file.ext"` or `"path/to/file.ext#label"`.
102///
103/// Sub-asset labels allow a single file to expose multiple logical assets.
104/// For example a GLTF file can expose `"scene.gltf#mesh0"`, `"scene.gltf#mesh1"`.
105///
106/// # Example
107/// ```rust
108/// use proof_engine::asset::AssetPath;
109///
110/// let p = AssetPath::parse("models/robot.gltf#body_mesh");
111/// assert_eq!(p.path().to_str().unwrap(), "models/robot.gltf");
112/// assert_eq!(p.label(), Some("body_mesh"));
113/// ```
114#[derive(Debug, Clone, PartialEq, Eq, Hash)]
115pub struct AssetPath {
116    path: PathBuf,
117    label: Option<String>,
118}
119
120impl AssetPath {
121    /// Create a new path with no sub-asset label.
122    pub fn new<P: AsRef<Path>>(path: P) -> Self {
123        Self {
124            path: path.as_ref().to_path_buf(),
125            label: None,
126        }
127    }
128
129    /// Create a path with an explicit sub-asset label.
130    pub fn with_label<P: AsRef<Path>>(path: P, label: impl Into<String>) -> Self {
131        Self {
132            path: path.as_ref().to_path_buf(),
133            label: Some(label.into()),
134        }
135    }
136
137    /// Parse a combined `"path#label"` string.
138    ///
139    /// If no `#` is present the label is `None`.
140    pub fn parse(s: &str) -> Self {
141        match s.find('#') {
142            Some(idx) => Self {
143                path: PathBuf::from(&s[..idx]),
144                label: Some(s[idx + 1..].to_owned()),
145            },
146            None => Self {
147                path: PathBuf::from(s),
148                label: None,
149            },
150        }
151    }
152
153    /// The file-system component of the path.
154    pub fn path(&self) -> &Path {
155        &self.path
156    }
157
158    /// The optional sub-asset label.
159    pub fn label(&self) -> Option<&str> {
160        self.label.as_deref()
161    }
162
163    /// File extension of the path (without the dot), lowercased.
164    pub fn extension(&self) -> Option<String> {
165        self.path
166            .extension()
167            .and_then(|e| e.to_str())
168            .map(|e| e.to_lowercase())
169    }
170
171    /// Returns the canonical string representation `"path"` or `"path#label"`.
172    pub fn to_string_repr(&self) -> String {
173        match &self.label {
174            Some(l) => format!("{}#{}", self.path.display(), l),
175            None => self.path.display().to_string(),
176        }
177    }
178}
179
180impl fmt::Display for AssetPath {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(f, "{}", self.to_string_repr())
183    }
184}
185
186impl From<&str> for AssetPath {
187    fn from(s: &str) -> Self {
188        Self::parse(s)
189    }
190}
191
192impl From<String> for AssetPath {
193    fn from(s: String) -> Self {
194        Self::parse(&s)
195    }
196}
197
198// ────────────────────────────────────────────────────────────────────────────
199// Section 3 — AssetId & Handles
200// ────────────────────────────────────────────────────────────────────────────
201
202/// Opaque numeric identifier for a loaded asset, parameterised by asset type.
203///
204/// Two `AssetId<T>` values are equal iff they refer to the same loaded asset.
205/// The phantom type parameter prevents mixing IDs for different asset types.
206#[derive(Debug)]
207pub struct AssetId<T: Asset> {
208    id: u64,
209    _marker: PhantomData<fn() -> T>,
210}
211
212impl<T: Asset> AssetId<T> {
213    fn new(id: u64) -> Self {
214        Self { id, _marker: PhantomData }
215    }
216
217    /// The raw numeric identifier.
218    pub fn raw(&self) -> u64 {
219        self.id
220    }
221}
222
223impl<T: Asset> Clone for AssetId<T> {
224    fn clone(&self) -> Self {
225        Self::new(self.id)
226    }
227}
228
229impl<T: Asset> Copy for AssetId<T> {}
230
231impl<T: Asset> PartialEq for AssetId<T> {
232    fn eq(&self, other: &Self) -> bool {
233        self.id == other.id
234    }
235}
236
237impl<T: Asset> Eq for AssetId<T> {}
238
239impl<T: Asset> Hash for AssetId<T> {
240    fn hash<H: Hasher>(&self, state: &mut H) {
241        self.id.hash(state);
242    }
243}
244
245impl<T: Asset> fmt::Display for AssetId<T> {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        write!(f, "AssetId({})", self.id)
248    }
249}
250
251/// A **strong** handle that keeps the underlying asset alive.
252///
253/// Dropping the last `AssetHandle` for a given asset does not automatically
254/// unload the asset — the [`AssetCache`] decides eviction — but it signals
255/// to the server that no live code holds a reference.
256///
257/// Cloning an `AssetHandle` is cheap: it increments an `Arc` reference count.
258#[derive(Debug)]
259pub struct AssetHandle<T: Asset> {
260    id: AssetId<T>,
261    inner: Arc<RwLock<Option<T>>>,
262}
263
264impl<T: Asset> AssetHandle<T> {
265    fn new(id: AssetId<T>, inner: Arc<RwLock<Option<T>>>) -> Self {
266        Self { id, inner }
267    }
268
269    /// The typed identifier for this asset.
270    pub fn id(&self) -> AssetId<T> {
271        self.id
272    }
273
274    /// Obtain a weak handle that does not prevent eviction.
275    pub fn downgrade(&self) -> WeakHandle<T> {
276        WeakHandle {
277            id: self.id,
278            inner: Arc::downgrade(&self.inner),
279        }
280    }
281
282    /// Returns `true` if the asset data is currently available.
283    pub fn is_loaded(&self) -> bool {
284        self.inner
285            .read()
286            .map(|g| g.is_some())
287            .unwrap_or(false)
288    }
289}
290
291impl<T: Asset> Clone for AssetHandle<T> {
292    fn clone(&self) -> Self {
293        Self {
294            id: self.id,
295            inner: Arc::clone(&self.inner),
296        }
297    }
298}
299
300impl<T: Asset> PartialEq for AssetHandle<T> {
301    fn eq(&self, other: &Self) -> bool {
302        self.id == other.id
303    }
304}
305
306impl<T: Asset> Eq for AssetHandle<T> {}
307
308/// A **weak** handle that does not prevent the asset from being evicted.
309///
310/// Upgrade to an [`AssetHandle`] before accessing the data. If the upgrade
311/// returns `None` the asset has been evicted and must be re-loaded.
312#[derive(Debug, Clone)]
313pub struct WeakHandle<T: Asset> {
314    id: AssetId<T>,
315    inner: Weak<RwLock<Option<T>>>,
316}
317
318impl<T: Asset> WeakHandle<T> {
319    /// Try to upgrade to a strong handle.
320    ///
321    /// Returns `None` if all strong handles have been dropped (asset evicted).
322    pub fn upgrade(&self) -> Option<AssetHandle<T>> {
323        self.inner.upgrade().map(|arc| AssetHandle::new(self.id, arc))
324    }
325
326    /// The typed identifier for this asset.
327    pub fn id(&self) -> AssetId<T> {
328        self.id
329    }
330}
331
332// ────────────────────────────────────────────────────────────────────────────
333// Section 4 — LoadState
334// ────────────────────────────────────────────────────────────────────────────
335
336/// The current load state of an asset identified by its raw ID.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub enum LoadState {
339    /// The asset has never been requested.
340    NotLoaded,
341    /// The asset is queued or currently being read from disk.
342    Loading,
343    /// The asset is available in the registry.
344    Loaded,
345    /// Loading failed; the message describes the error.
346    Failed(String),
347}
348
349impl fmt::Display for LoadState {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        match self {
352            LoadState::NotLoaded => write!(f, "NotLoaded"),
353            LoadState::Loading => write!(f, "Loading"),
354            LoadState::Loaded => write!(f, "Loaded"),
355            LoadState::Failed(msg) => write!(f, "Failed: {msg}"),
356        }
357    }
358}
359
360// ────────────────────────────────────────────────────────────────────────────
361// Section 5 — AssetDependency
362// ────────────────────────────────────────────────────────────────────────────
363
364/// Records which other assets a given asset depends on.
365///
366/// When an asset is reloaded all of its dependents are also re-queued so
367/// that stale composed assets (e.g. a material that references a texture)
368/// are always consistent.
369#[derive(Debug, Default, Clone)]
370pub struct AssetDependency {
371    /// Raw IDs of the assets that this asset directly depends upon.
372    pub depends_on: HashSet<u64>,
373    /// Raw IDs of the assets that depend on this asset (reverse edges).
374    pub depended_by: HashSet<u64>,
375}
376
377impl AssetDependency {
378    /// Create a new, empty dependency record.
379    pub fn new() -> Self {
380        Self::default()
381    }
382
383    /// Record that `owner` depends on `dependency`.
384    pub fn add(&mut self, owner: u64, dependency: u64) {
385        self.depends_on.insert(dependency);
386        let _ = owner;
387    }
388}
389
390// ────────────────────────────────────────────────────────────────────────────
391// Section 6 — Type-erased internals
392// ────────────────────────────────────────────────────────────────────────────
393
394/// Internal type-erased slot stored in the registry.
395struct ErasedSlot {
396    /// `TypeId` of the concrete `T`.
397    type_id: TypeId,
398    /// The asset value, downcasting via `Any`.
399    value: Arc<dyn Any + Send + Sync>,
400    /// Load state of this slot.
401    state: LoadState,
402    /// The path this asset was loaded from.
403    path: AssetPath,
404    /// When the file was last read (for hot-reload comparison).
405    file_mtime: Option<SystemTime>,
406    /// Dependency graph entry.
407    dependency: AssetDependency,
408    /// How many times this asset has been accessed (for LRU).
409    access_count: u64,
410    /// When was this asset last accessed.
411    last_access: Instant,
412}
413
414impl ErasedSlot {
415    fn new(type_id: TypeId, path: AssetPath) -> Self {
416        Self {
417            type_id,
418            value: Arc::new(()),
419            state: LoadState::NotLoaded,
420            path,
421            file_mtime: None,
422            dependency: AssetDependency::new(),
423            access_count: 0,
424            last_access: Instant::now(),
425        }
426    }
427}
428
429// ────────────────────────────────────────────────────────────────────────────
430// Section 7 — AssetRegistry
431// ────────────────────────────────────────────────────────────────────────────
432
433/// Central type-erased registry that maps numeric IDs to asset slots.
434///
435/// The registry does not know the concrete type of the assets it stores;
436/// type safety is enforced at the [`AssetHandle`] / [`AssetId`] boundary.
437///
438/// External code normally interacts with the registry through [`AssetServer`].
439pub struct AssetRegistry {
440    slots: HashMap<u64, ErasedSlot>,
441    path_to_id: HashMap<AssetPath, u64>,
442    next_id: u64,
443}
444
445impl AssetRegistry {
446    /// Create an empty registry.
447    pub fn new() -> Self {
448        Self {
449            slots: HashMap::new(),
450            path_to_id: HashMap::new(),
451            next_id: 1,
452        }
453    }
454
455    /// Allocate a new slot and return its ID.
456    pub fn alloc(&mut self, type_id: TypeId, path: AssetPath) -> u64 {
457        let id = self.next_id;
458        self.next_id += 1;
459        self.path_to_id.insert(path.clone(), id);
460        self.slots.insert(id, ErasedSlot::new(type_id, path));
461        id
462    }
463
464    /// Look up the ID already assigned to `path`, if any.
465    pub fn id_for_path(&self, path: &AssetPath) -> Option<u64> {
466        self.path_to_id.get(path).copied()
467    }
468
469    /// Return the current [`LoadState`] for the given raw ID.
470    pub fn load_state(&self, id: u64) -> LoadState {
471        self.slots
472            .get(&id)
473            .map(|s| s.state.clone())
474            .unwrap_or(LoadState::NotLoaded)
475    }
476
477    /// Mark a slot as [`LoadState::Loading`].
478    pub fn mark_loading(&mut self, id: u64) {
479        if let Some(slot) = self.slots.get_mut(&id) {
480            slot.state = LoadState::Loading;
481        }
482    }
483
484    /// Store a successfully loaded value.
485    pub fn store<T: Asset>(&mut self, id: u64, value: T, mtime: Option<SystemTime>) {
486        if let Some(slot) = self.slots.get_mut(&id) {
487            slot.value = Arc::new(value);
488            slot.state = LoadState::Loaded;
489            slot.file_mtime = mtime;
490            slot.last_access = Instant::now();
491        }
492    }
493
494    /// Mark a slot as failed.
495    pub fn mark_failed(&mut self, id: u64, message: String) {
496        if let Some(slot) = self.slots.get_mut(&id) {
497            slot.state = LoadState::Failed(message);
498        }
499    }
500
501    /// Try to read a stored value, downcasting to `T`.
502    pub fn get<T: Asset>(&mut self, id: u64) -> Option<Arc<T>> {
503        let slot = self.slots.get_mut(&id)?;
504        if slot.state != LoadState::Loaded {
505            return None;
506        }
507        slot.access_count += 1;
508        slot.last_access = Instant::now();
509        Arc::clone(&slot.value).downcast::<T>().ok()
510    }
511
512    /// Return the [`AssetPath`] for a given raw ID.
513    pub fn path_for_id(&self, id: u64) -> Option<&AssetPath> {
514        self.slots.get(&id).map(|s| &s.path)
515    }
516
517    /// Return the [`TypeId`] stored in a slot.
518    pub fn type_id_for(&self, id: u64) -> Option<TypeId> {
519        self.slots.get(&id).map(|s| s.type_id)
520    }
521
522    /// Iterate over all IDs in the registry.
523    pub fn all_ids(&self) -> impl Iterator<Item = u64> + '_ {
524        self.slots.keys().copied()
525    }
526
527    /// Total number of slots (includes not-yet-loaded and failed).
528    pub fn len(&self) -> usize {
529        self.slots.len()
530    }
531
532    /// `true` if there are no slots.
533    pub fn is_empty(&self) -> bool {
534        self.slots.is_empty()
535    }
536
537    /// Remove a slot entirely, returning whether it existed.
538    pub fn evict(&mut self, id: u64) -> bool {
539        if let Some(slot) = self.slots.remove(&id) {
540            self.path_to_id.remove(&slot.path);
541            true
542        } else {
543            false
544        }
545    }
546}
547
548impl Default for AssetRegistry {
549    fn default() -> Self {
550        Self::new()
551    }
552}
553
554impl fmt::Debug for AssetRegistry {
555    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556        f.debug_struct("AssetRegistry")
557            .field("slot_count", &self.slots.len())
558            .field("next_id", &self.next_id)
559            .finish()
560    }
561}
562
563// ────────────────────────────────────────────────────────────────────────────
564// Section 8 — AssetCache (LRU)
565// ────────────────────────────────────────────────────────────────────────────
566
567/// LRU eviction cache that sits on top of [`AssetRegistry`].
568///
569/// When the cache is full, the least-recently-used asset is evicted from the
570/// registry. Assets held by live [`AssetHandle`]s will not be collected by
571/// the OS even after eviction, but the registry entry is removed so the next
572/// request will trigger a reload.
573///
574/// Capacity is measured in number of assets, not bytes.
575pub struct AssetCache {
576    /// Maximum number of assets to keep loaded simultaneously.
577    capacity: usize,
578    /// Access-ordered queue: front = least recently used.
579    lru_queue: VecDeque<u64>,
580    /// Set for O(1) existence checks.
581    lru_set: HashSet<u64>,
582}
583
584impl AssetCache {
585    /// Create a new cache with the given capacity.
586    ///
587    /// A `capacity` of `0` disables eviction (unlimited).
588    pub fn new(capacity: usize) -> Self {
589        Self {
590            capacity,
591            lru_queue: VecDeque::new(),
592            lru_set: HashSet::new(),
593        }
594    }
595
596    /// Notify the cache that `id` was accessed.
597    ///
598    /// Moves `id` to the most-recently-used position and returns any ID that
599    /// should now be evicted (the least-recently-used), or `None`.
600    pub fn touch(&mut self, id: u64) -> Option<u64> {
601        if self.lru_set.contains(&id) {
602            self.lru_queue.retain(|&x| x != id);
603        } else {
604            self.lru_set.insert(id);
605        }
606        self.lru_queue.push_back(id);
607
608        if self.capacity > 0 && self.lru_queue.len() > self.capacity {
609            let victim = self.lru_queue.pop_front().unwrap();
610            self.lru_set.remove(&victim);
611            Some(victim)
612        } else {
613            None
614        }
615    }
616
617    /// Remove `id` from the tracking queue (called after explicit eviction).
618    pub fn remove(&mut self, id: u64) {
619        self.lru_queue.retain(|&x| x != id);
620        self.lru_set.remove(&id);
621    }
622
623    /// Current number of tracked entries.
624    pub fn len(&self) -> usize {
625        self.lru_queue.len()
626    }
627
628    /// `true` if no entries are tracked.
629    pub fn is_empty(&self) -> bool {
630        self.lru_queue.is_empty()
631    }
632
633    /// Configured capacity (0 = unlimited).
634    pub fn capacity(&self) -> usize {
635        self.capacity
636    }
637
638    /// Change the capacity. If the new capacity is smaller, returns a list of
639    /// IDs that must be evicted immediately.
640    pub fn set_capacity(&mut self, new_cap: usize) -> Vec<u64> {
641        self.capacity = new_cap;
642        let mut evicted = Vec::new();
643        while new_cap > 0 && self.lru_queue.len() > new_cap {
644            if let Some(victim) = self.lru_queue.pop_front() {
645                self.lru_set.remove(&victim);
646                evicted.push(victim);
647            }
648        }
649        evicted
650    }
651}
652
653impl fmt::Debug for AssetCache {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        f.debug_struct("AssetCache")
656            .field("capacity", &self.capacity)
657            .field("len", &self.lru_queue.len())
658            .finish()
659    }
660}
661
662// ────────────────────────────────────────────────────────────────────────────
663// Section 9 — HotReload
664// ────────────────────────────────────────────────────────────────────────────
665
666/// Record of a file being watched for changes.
667#[derive(Debug, Clone)]
668struct WatchedFile {
669    path: PathBuf,
670    last_mtime: Option<SystemTime>,
671    asset_ids: Vec<u64>,
672}
673
674/// Poll-based hot-reload watcher.
675///
676/// No platform-specific file-watch APIs are used. Instead, `poll()` is called
677/// periodically (e.g. every second) and compares the `mtime` of each watched
678/// file against the value recorded at load time. Changed files are returned
679/// so [`AssetServer`] can queue them for reload.
680///
681/// # Limitations
682/// * mtime resolution is operating-system dependent (commonly 1 s on FAT32).
683/// * Files inside [`AssetPack`] archives are not watched.
684pub struct HotReload {
685    watched: HashMap<PathBuf, WatchedFile>,
686    poll_interval: Duration,
687    last_poll: Instant,
688    enabled: bool,
689}
690
691impl HotReload {
692    /// Create a new watcher.
693    ///
694    /// `poll_interval` — how often to stat files when `poll()` is called.
695    /// `enabled` — pass `false` in release builds to disable entirely.
696    pub fn new(poll_interval: Duration, enabled: bool) -> Self {
697        Self {
698            watched: HashMap::new(),
699            poll_interval,
700            last_poll: Instant::now(),
701            enabled,
702        }
703    }
704
705    /// Register a file to be watched for changes.
706    pub fn watch(&mut self, path: PathBuf, asset_id: u64, current_mtime: Option<SystemTime>) {
707        let entry = self.watched.entry(path.clone()).or_insert(WatchedFile {
708            path,
709            last_mtime: current_mtime,
710            asset_ids: Vec::new(),
711        });
712        if !entry.asset_ids.contains(&asset_id) {
713            entry.asset_ids.push(asset_id);
714        }
715        if current_mtime.is_some() {
716            entry.last_mtime = current_mtime;
717        }
718    }
719
720    /// Unwatch a specific asset ID. Removes the file entry if no more IDs use it.
721    pub fn unwatch(&mut self, asset_id: u64) {
722        self.watched.retain(|_, wf| {
723            wf.asset_ids.retain(|&id| id != asset_id);
724            !wf.asset_ids.is_empty()
725        });
726    }
727
728    /// Poll all watched files. Returns a list of `(path, asset_ids)` for every
729    /// file whose mtime has changed since the last observation.
730    ///
731    /// Returns an empty list if polling is disabled or the interval hasn't elapsed.
732    pub fn poll(&mut self) -> Vec<(PathBuf, Vec<u64>)> {
733        if !self.enabled {
734            return Vec::new();
735        }
736        if self.last_poll.elapsed() < self.poll_interval {
737            return Vec::new();
738        }
739        self.last_poll = Instant::now();
740
741        let mut changed = Vec::new();
742        for wf in self.watched.values_mut() {
743            let current_mtime = std::fs::metadata(&wf.path)
744                .and_then(|m| m.modified())
745                .ok();
746            if current_mtime != wf.last_mtime {
747                wf.last_mtime = current_mtime;
748                changed.push((wf.path.clone(), wf.asset_ids.clone()));
749            }
750        }
751        changed
752    }
753
754    /// Force the next `poll()` call to check all files regardless of interval.
755    pub fn force_next_poll(&mut self) {
756        self.last_poll = Instant::now()
757            .checked_sub(self.poll_interval + Duration::from_millis(1))
758            .unwrap_or(Instant::now());
759    }
760
761    /// Enable or disable hot-reload at runtime.
762    pub fn set_enabled(&mut self, enabled: bool) {
763        self.enabled = enabled;
764    }
765
766    /// Number of files currently being watched.
767    pub fn watched_count(&self) -> usize {
768        self.watched.len()
769    }
770}
771
772impl fmt::Debug for HotReload {
773    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
774        f.debug_struct("HotReload")
775            .field("enabled", &self.enabled)
776            .field("watched_files", &self.watched.len())
777            .field("poll_interval_ms", &self.poll_interval.as_millis())
778            .finish()
779    }
780}
781
782// ────────────────────────────────────────────────────────────────────────────
783// Section 10 — StreamingManager
784// ────────────────────────────────────────────────────────────────────────────
785
786/// Priority level for a streaming request.
787///
788/// Higher-priority assets are loaded first.
789#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
790pub enum StreamPriority {
791    /// Background / speculative pre-fetch.
792    Low = 0,
793    /// Normal assets needed soon.
794    Normal = 1,
795    /// Assets needed immediately (e.g. blocking the next frame).
796    High = 2,
797    /// Assets that must be available before rendering can continue.
798    Critical = 3,
799}
800
801impl Default for StreamPriority {
802    fn default() -> Self {
803        StreamPriority::Normal
804    }
805}
806
807/// An entry in the streaming queue.
808#[derive(Debug, Clone)]
809pub struct StreamRequest {
810    /// Raw asset ID.
811    pub id: u64,
812    /// Path to load from.
813    pub path: AssetPath,
814    /// TypeId of the expected asset.
815    pub type_id: TypeId,
816    /// Priority controlling load order.
817    pub priority: StreamPriority,
818    /// When this request was enqueued.
819    pub enqueued_at: Instant,
820}
821
822/// Priority queue of pending asset loads.
823///
824/// Requests are dequeued in descending priority order. Ties are broken by
825/// enqueue time (older requests first — FIFO within a priority tier).
826pub struct StreamingManager {
827    queue: Vec<StreamRequest>,
828    /// Maximum number of requests to process per `drain()` call.
829    batch_size: usize,
830    /// Total requests processed since creation.
831    total_processed: u64,
832}
833
834impl StreamingManager {
835    /// Create a new manager.
836    ///
837    /// `batch_size` — how many requests `drain()` returns at once.
838    pub fn new(batch_size: usize) -> Self {
839        Self {
840            queue: Vec::new(),
841            batch_size,
842            total_processed: 0,
843        }
844    }
845
846    /// Enqueue a load request.
847    ///
848    /// If a request for the same ID already exists at a lower priority it is
849    /// upgraded in-place. Duplicate same-priority requests are ignored.
850    pub fn enqueue(&mut self, req: StreamRequest) {
851        if let Some(existing) = self.queue.iter_mut().find(|r| r.id == req.id) {
852            if req.priority > existing.priority {
853                existing.priority = req.priority;
854            }
855            return;
856        }
857        self.queue.push(req);
858    }
859
860    /// Drain up to `batch_size` requests from the queue, highest priority first.
861    ///
862    /// The returned requests have been removed from the queue.
863    pub fn drain(&mut self) -> Vec<StreamRequest> {
864        if self.queue.is_empty() {
865            return Vec::new();
866        }
867        self.queue.sort_unstable_by(|a, b| {
868            b.priority
869                .cmp(&a.priority)
870                .then(a.enqueued_at.cmp(&b.enqueued_at))
871        });
872
873        let take = self.batch_size.min(self.queue.len());
874        let drained: Vec<_> = self.queue.drain(0..take).collect();
875        self.total_processed += drained.len() as u64;
876        drained
877    }
878
879    /// Remove a pending request by ID (e.g. asset was evicted before loading).
880    pub fn cancel(&mut self, id: u64) -> bool {
881        let before = self.queue.len();
882        self.queue.retain(|r| r.id != id);
883        self.queue.len() < before
884    }
885
886    /// Number of requests currently queued.
887    pub fn pending(&self) -> usize {
888        self.queue.len()
889    }
890
891    /// Total requests ever processed (drained).
892    pub fn total_processed(&self) -> u64 {
893        self.total_processed
894    }
895
896    /// `true` if no requests are waiting.
897    pub fn is_idle(&self) -> bool {
898        self.queue.is_empty()
899    }
900}
901
902impl fmt::Debug for StreamingManager {
903    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
904        f.debug_struct("StreamingManager")
905            .field("pending", &self.queue.len())
906            .field("batch_size", &self.batch_size)
907            .field("total_processed", &self.total_processed)
908            .finish()
909    }
910}
911
912// ────────────────────────────────────────────────────────────────────────────
913// Section 11 — AssetPack (simple archive format)
914// ────────────────────────────────────────────────────────────────────────────
915
916/// In-memory record of a single file inside a pack archive.
917#[derive(Debug, Clone)]
918struct PackEntry {
919    /// Virtual path within the pack (e.g. `"textures/ui/button.png"`).
920    virtual_path: String,
921    /// Byte offset in the pack file's data blob.
922    offset: usize,
923    /// Byte length of the entry.
924    length: usize,
925}
926
927/// A bundle of multiple asset files stored in a single archive.
928///
929/// # Format
930///
931/// ```text
932/// [4 bytes magic "PACK"]
933/// [4 bytes version = 1 as little-endian u32]
934/// [4 bytes entry_count as little-endian u32]
935/// For each entry:
936///   [4 bytes path_len as little-endian u32]
937///   [path_len bytes UTF-8 path]
938///   [8 bytes data_offset as little-endian u64]
939///   [8 bytes data_length as little-endian u64]
940/// [raw concatenated asset bytes]
941/// ```
942///
943/// The data section immediately follows the directory.
944pub struct AssetPack {
945    /// Human-readable name for diagnostic output.
946    name: String,
947    /// Directory of all entries.
948    entries: Vec<PackEntry>,
949    /// The full archive bytes, kept in memory.
950    data: Vec<u8>,
951    /// Byte offset where the data blob begins (after the directory).
952    data_offset: usize,
953}
954
955impl AssetPack {
956    /// Parse an asset pack from raw bytes.
957    ///
958    /// Returns `Err` if the bytes do not match the expected format.
959    pub fn from_bytes(name: impl Into<String>, bytes: Vec<u8>) -> Result<Self, String> {
960        if bytes.len() < 12 {
961            return Err("pack too small".into());
962        }
963        if &bytes[0..4] != b"PACK" {
964            return Err("invalid magic bytes".into());
965        }
966        let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
967        if version != 1 {
968            return Err(format!("unsupported pack version {version}"));
969        }
970        let entry_count = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
971
972        let mut cursor = 12usize;
973        let mut entries = Vec::with_capacity(entry_count);
974
975        for _ in 0..entry_count {
976            if cursor + 4 > bytes.len() {
977                return Err("truncated directory".into());
978            }
979            let path_len = u32::from_le_bytes(bytes[cursor..cursor + 4].try_into().unwrap()) as usize;
980            cursor += 4;
981            if cursor + path_len > bytes.len() {
982                return Err("truncated path".into());
983            }
984            let virtual_path = std::str::from_utf8(&bytes[cursor..cursor + path_len])
985                .map_err(|e| format!("invalid UTF-8 in path: {e}"))?
986                .to_owned();
987            cursor += path_len;
988            if cursor + 16 > bytes.len() {
989                return Err("truncated entry offsets".into());
990            }
991            let offset = u64::from_le_bytes(bytes[cursor..cursor + 8].try_into().unwrap()) as usize;
992            let length = u64::from_le_bytes(bytes[cursor + 8..cursor + 16].try_into().unwrap()) as usize;
993            cursor += 16;
994            entries.push(PackEntry { virtual_path, offset, length });
995        }
996
997        let data_offset = cursor;
998        Ok(Self {
999            name: name.into(),
1000            entries,
1001            data: bytes,
1002            data_offset,
1003        })
1004    }
1005
1006    /// Build a pack from a map of `virtual_path → bytes`.
1007    pub fn build(name: impl Into<String>, files: &[(&str, &[u8])]) -> Vec<u8> {
1008        let mut dir: Vec<u8> = Vec::new();
1009        let mut blob: Vec<u8> = Vec::new();
1010
1011        dir.extend_from_slice(b"PACK");
1012        dir.extend_from_slice(&1u32.to_le_bytes());
1013        dir.extend_from_slice(&(files.len() as u32).to_le_bytes());
1014
1015        for (path, data) in files {
1016            let path_bytes = path.as_bytes();
1017            dir.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
1018            dir.extend_from_slice(path_bytes);
1019            dir.extend_from_slice(&(blob.len() as u64).to_le_bytes());
1020            dir.extend_from_slice(&(data.len() as u64).to_le_bytes());
1021            blob.extend_from_slice(data);
1022        }
1023
1024        let _ = name;
1025        let mut out = dir;
1026        out.extend_from_slice(&blob);
1027        out
1028    }
1029
1030    /// Read a virtual file from the pack by its path.
1031    pub fn read(&self, virtual_path: &str) -> Option<&[u8]> {
1032        for entry in &self.entries {
1033            if entry.virtual_path == virtual_path {
1034                let start = self.data_offset + entry.offset;
1035                let end = start + entry.length;
1036                return self.data.get(start..end);
1037            }
1038        }
1039        None
1040    }
1041
1042    /// List all virtual paths in this pack.
1043    pub fn paths(&self) -> impl Iterator<Item = &str> {
1044        self.entries.iter().map(|e| e.virtual_path.as_str())
1045    }
1046
1047    /// The name of this pack.
1048    pub fn name(&self) -> &str {
1049        &self.name
1050    }
1051
1052    /// Number of entries in this pack.
1053    pub fn entry_count(&self) -> usize {
1054        self.entries.len()
1055    }
1056}
1057
1058impl fmt::Debug for AssetPack {
1059    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1060        f.debug_struct("AssetPack")
1061            .field("name", &self.name)
1062            .field("entries", &self.entries.len())
1063            .field("total_bytes", &self.data.len())
1064            .finish()
1065    }
1066}
1067
1068// ────────────────────────────────────────────────────────────────────────────
1069// Section 12 — AssetManifest
1070// ────────────────────────────────────────────────────────────────────────────
1071
1072/// A single entry in an [`AssetManifest`].
1073#[derive(Debug, Clone)]
1074pub struct ManifestEntry {
1075    /// Path (and optional label) for this asset.
1076    pub path: AssetPath,
1077    /// Desired load priority.
1078    pub priority: StreamPriority,
1079    /// Whether this asset must be loaded before the application can start.
1080    pub required: bool,
1081    /// Optional human-readable tag (e.g. `"ui"`, `"world"`, `"audio"`).
1082    pub tag: Option<String>,
1083}
1084
1085impl ManifestEntry {
1086    /// Create a required, high-priority entry with no tag.
1087    pub fn required(path: impl Into<AssetPath>) -> Self {
1088        Self {
1089            path: path.into(),
1090            priority: StreamPriority::High,
1091            required: true,
1092            tag: None,
1093        }
1094    }
1095
1096    /// Create an optional, normal-priority entry.
1097    pub fn optional(path: impl Into<AssetPath>) -> Self {
1098        Self {
1099            path: path.into(),
1100            priority: StreamPriority::Normal,
1101            required: false,
1102            tag: None,
1103        }
1104    }
1105
1106    /// Attach a tag.
1107    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
1108        self.tag = Some(tag.into());
1109        self
1110    }
1111
1112    /// Set the priority.
1113    pub fn with_priority(mut self, priority: StreamPriority) -> Self {
1114        self.priority = priority;
1115        self
1116    }
1117}
1118
1119/// A declarative list of assets to pre-load.
1120///
1121/// Manifests allow level designers or content pipelines to specify exactly
1122/// which assets are needed for a scene without writing Rust code.
1123///
1124/// # Example
1125/// ```rust
1126/// use proof_engine::asset::{AssetManifest, ManifestEntry};
1127///
1128/// let mut manifest = AssetManifest::new("level1");
1129/// manifest.add(ManifestEntry::required("textures/floor.png").with_tag("level1"));
1130/// manifest.add(ManifestEntry::optional("sounds/ambient.wav").with_tag("level1"));
1131/// ```
1132#[derive(Debug, Clone)]
1133pub struct AssetManifest {
1134    /// Name of this manifest (e.g. level name).
1135    pub name: String,
1136    /// All entries in declaration order.
1137    pub entries: Vec<ManifestEntry>,
1138}
1139
1140impl AssetManifest {
1141    /// Create an empty manifest.
1142    pub fn new(name: impl Into<String>) -> Self {
1143        Self { name: name.into(), entries: Vec::new() }
1144    }
1145
1146    /// Add an entry to the manifest.
1147    pub fn add(&mut self, entry: ManifestEntry) {
1148        self.entries.push(entry);
1149    }
1150
1151    /// Iterate over only the required entries.
1152    pub fn required_entries(&self) -> impl Iterator<Item = &ManifestEntry> {
1153        self.entries.iter().filter(|e| e.required)
1154    }
1155
1156    /// Iterate over entries that carry the given tag.
1157    pub fn entries_with_tag<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a ManifestEntry> {
1158        self.entries.iter().filter(move |e| {
1159            e.tag.as_deref() == Some(tag)
1160        })
1161    }
1162
1163    /// Total number of entries.
1164    pub fn len(&self) -> usize {
1165        self.entries.len()
1166    }
1167
1168    /// `true` if no entries are present.
1169    pub fn is_empty(&self) -> bool {
1170        self.entries.is_empty()
1171    }
1172}
1173
1174// ────────────────────────────────────────────────────────────────────────────
1175// Section 13 — Built-in Asset Types
1176// ────────────────────────────────────────────────────────────────────────────
1177
1178// ── ImageAsset ──────────────────────────────────────────────────────────────
1179
1180/// Pixel format for [`ImageAsset`] data.
1181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1182pub enum PixelFormat {
1183    /// 1 byte per pixel, luminance.
1184    R8,
1185    /// 2 bytes per pixel, luminance + alpha.
1186    Rg8,
1187    /// 3 bytes per pixel, RGB.
1188    Rgb8,
1189    /// 4 bytes per pixel, RGBA.
1190    Rgba8,
1191    /// 4 bytes per component, HDR float RGBA.
1192    Rgba32F,
1193}
1194
1195impl PixelFormat {
1196    /// Bytes per pixel for this format.
1197    pub fn bytes_per_pixel(self) -> usize {
1198        match self {
1199            PixelFormat::R8 => 1,
1200            PixelFormat::Rg8 => 2,
1201            PixelFormat::Rgb8 => 3,
1202            PixelFormat::Rgba8 => 4,
1203            PixelFormat::Rgba32F => 16,
1204        }
1205    }
1206}
1207
1208/// A loaded image — raw decoded pixel data.
1209///
1210/// Mip-maps can be generated by an [`AssetProcessor`] after load.
1211#[derive(Debug, Clone)]
1212pub struct ImageAsset {
1213    /// Width in pixels.
1214    pub width: u32,
1215    /// Height in pixels.
1216    pub height: u32,
1217    /// Pixel format.
1218    pub format: PixelFormat,
1219    /// Raw pixel bytes, row-major top-to-bottom.
1220    pub data: Vec<u8>,
1221    /// Mip levels (empty if no mips have been generated).
1222    pub mip_levels: Vec<Vec<u8>>,
1223}
1224
1225impl ImageAsset {
1226    /// Create a solid-colour image of the given size.
1227    pub fn solid_color(width: u32, height: u32, rgba: [u8; 4]) -> Self {
1228        let pixels = (width * height) as usize;
1229        let mut data = Vec::with_capacity(pixels * 4);
1230        for _ in 0..pixels {
1231            data.extend_from_slice(&rgba);
1232        }
1233        Self {
1234            width,
1235            height,
1236            format: PixelFormat::Rgba8,
1237            data,
1238            mip_levels: Vec::new(),
1239        }
1240    }
1241
1242    /// Total byte size of the base level.
1243    pub fn byte_size(&self) -> usize {
1244        self.data.len()
1245    }
1246
1247    /// Number of channels implied by the pixel format.
1248    pub fn channels(&self) -> usize {
1249        self.format.bytes_per_pixel()
1250    }
1251}
1252
1253impl Asset for ImageAsset {}
1254
1255// ── ShaderAsset ─────────────────────────────────────────────────────────────
1256
1257/// Which pipeline stage a shader belongs to.
1258#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1259pub enum ShaderStage {
1260    /// Vertex processing stage.
1261    Vertex,
1262    /// Fragment / pixel shading stage.
1263    Fragment,
1264    /// Compute / general-purpose GPU stage.
1265    Compute,
1266    /// Geometry shading stage.
1267    Geometry,
1268}
1269
1270impl fmt::Display for ShaderStage {
1271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1272        match self {
1273            ShaderStage::Vertex => write!(f, "vertex"),
1274            ShaderStage::Fragment => write!(f, "fragment"),
1275            ShaderStage::Compute => write!(f, "compute"),
1276            ShaderStage::Geometry => write!(f, "geometry"),
1277        }
1278    }
1279}
1280
1281/// A loaded GLSL / WGSL / SPIR-V shader source.
1282#[derive(Debug, Clone)]
1283pub struct ShaderAsset {
1284    /// Identifier used in error messages.
1285    pub name: String,
1286    /// The full shader source text.
1287    pub source: String,
1288    /// Which pipeline stage this shader belongs to.
1289    pub stage: ShaderStage,
1290    /// Optional pre-processor defines injected at load time.
1291    pub defines: Vec<(String, String)>,
1292}
1293
1294impl ShaderAsset {
1295    /// Create a shader asset from source text.
1296    pub fn new(name: impl Into<String>, source: impl Into<String>, stage: ShaderStage) -> Self {
1297        Self {
1298            name: name.into(),
1299            source: source.into(),
1300            stage,
1301            defines: Vec::new(),
1302        }
1303    }
1304
1305    /// Add a preprocessor define.
1306    pub fn with_define(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1307        self.defines.push((key.into(), value.into()));
1308        self
1309    }
1310
1311    /// Line count of the source.
1312    pub fn line_count(&self) -> usize {
1313        self.source.lines().count()
1314    }
1315}
1316
1317impl Asset for ShaderAsset {}
1318
1319// ── FontAsset ───────────────────────────────────────────────────────────────
1320
1321/// Metrics for the whole font face.
1322#[derive(Debug, Clone, Copy)]
1323pub struct FontMetrics {
1324    /// Height of capital letters above the baseline, in font units.
1325    pub cap_height: f32,
1326    /// Distance from baseline to top of tallest ascender.
1327    pub ascender: f32,
1328    /// Distance from baseline to bottom of deepest descender (negative).
1329    pub descender: f32,
1330    /// Recommended line gap between successive text lines.
1331    pub line_gap: f32,
1332    /// Units per em (coordinate space of the font).
1333    pub units_per_em: f32,
1334}
1335
1336impl Default for FontMetrics {
1337    fn default() -> Self {
1338        Self {
1339            cap_height: 700.0,
1340            ascender: 800.0,
1341            descender: -200.0,
1342            line_gap: 0.0,
1343            units_per_em: 1000.0,
1344        }
1345    }
1346}
1347
1348/// Geometry and metrics for a single glyph.
1349#[derive(Debug, Clone)]
1350pub struct GlyphData {
1351    /// Unicode codepoint.
1352    pub codepoint: char,
1353    /// Advance width (how far the cursor moves after this glyph).
1354    pub advance_width: f32,
1355    /// Bounding box: (min_x, min_y, max_x, max_y) in font units.
1356    pub bounds: (f32, f32, f32, f32),
1357    /// UV coordinates in the atlas texture (if packed).
1358    pub atlas_uv: Option<(f32, f32, f32, f32)>,
1359    /// Raw outline commands (simplified path, optional).
1360    pub outline: Vec<OutlineCommand>,
1361}
1362
1363/// A single command in a glyph outline path.
1364#[derive(Debug, Clone, Copy)]
1365pub enum OutlineCommand {
1366    /// Move to (x, y) without drawing.
1367    MoveTo(f32, f32),
1368    /// Straight line to (x, y).
1369    LineTo(f32, f32),
1370    /// Quadratic Bézier to (x, y) with control point (cx, cy).
1371    QuadTo(f32, f32, f32, f32),
1372    /// Close the current contour.
1373    Close,
1374}
1375
1376/// A loaded font, decomposed into per-glyph data and face metrics.
1377#[derive(Debug, Clone)]
1378pub struct FontAsset {
1379    /// Font family name.
1380    pub name: String,
1381    /// Per-character glyph data.
1382    pub glyphs: HashMap<char, GlyphData>,
1383    /// Whole-face metrics.
1384    pub metrics: FontMetrics,
1385    /// Optional packed atlas image (generated by a processor).
1386    pub atlas: Option<ImageAsset>,
1387}
1388
1389impl FontAsset {
1390    /// Look up a glyph, falling back to the replacement character `'?'`.
1391    /// On the first miss the fallback glyph is lazily inserted into the map
1392    /// so that subsequent lookups for the same character succeed directly.
1393    pub fn glyph(&mut self, ch: char) -> Option<&GlyphData> {
1394        if self.glyphs.contains_key(&ch) {
1395            return self.glyphs.get(&ch);
1396        }
1397        // First miss — copy the fallback glyph into the map for next time,
1398        // but return None for this call so callers can detect the miss.
1399        if let Some(fallback) = self.glyphs.get(&'?').cloned() {
1400            self.glyphs.insert(ch, fallback);
1401            return None;
1402        }
1403        None
1404    }
1405
1406    /// Number of glyphs loaded.
1407    pub fn glyph_count(&self) -> usize {
1408        self.glyphs.len()
1409    }
1410}
1411
1412impl Asset for FontAsset {}
1413
1414// ── SoundAsset ──────────────────────────────────────────────────────────────
1415
1416/// A loaded audio clip stored as normalised float samples.
1417#[derive(Debug, Clone)]
1418pub struct SoundAsset {
1419    /// Samples per second (e.g. 44100).
1420    pub sample_rate: u32,
1421    /// Number of audio channels (1 = mono, 2 = stereo).
1422    pub channels: u16,
1423    /// Interleaved normalised samples in `[-1.0, 1.0]`.
1424    pub samples: Vec<f32>,
1425    /// Optional loop start sample index.
1426    pub loop_start: Option<usize>,
1427    /// Optional loop end sample index.
1428    pub loop_end: Option<usize>,
1429}
1430
1431impl SoundAsset {
1432    /// Duration of the clip in seconds.
1433    pub fn duration_secs(&self) -> f32 {
1434        if self.sample_rate == 0 || self.channels == 0 {
1435            return 0.0;
1436        }
1437        self.samples.len() as f32 / (self.sample_rate as f32 * self.channels as f32)
1438    }
1439
1440    /// Total number of frames (one frame = one sample per channel).
1441    pub fn frame_count(&self) -> usize {
1442        if self.channels == 0 { 0 } else { self.samples.len() / self.channels as usize }
1443    }
1444}
1445
1446impl Asset for SoundAsset {}
1447
1448// ── ScriptAsset ─────────────────────────────────────────────────────────────
1449
1450/// A raw script source file.
1451///
1452/// Actual parsing / compilation is deferred to the scripting subsystem.
1453#[derive(Debug, Clone)]
1454pub struct ScriptAsset {
1455    /// Module name.
1456    pub name: String,
1457    /// Full source text.
1458    pub source: String,
1459    /// Optional language hint (e.g. `"lua"`, `"wren"`, `"rhai"`).
1460    pub language: Option<String>,
1461}
1462
1463impl ScriptAsset {
1464    /// Create a script from a string.
1465    pub fn new(name: impl Into<String>, source: impl Into<String>) -> Self {
1466        Self {
1467            name: name.into(),
1468            source: source.into(),
1469            language: None,
1470        }
1471    }
1472
1473    /// Line count of the source.
1474    pub fn line_count(&self) -> usize {
1475        self.source.lines().count()
1476    }
1477
1478    /// Byte length of the source.
1479    pub fn byte_len(&self) -> usize {
1480        self.source.len()
1481    }
1482}
1483
1484impl Asset for ScriptAsset {}
1485
1486// ── MeshAsset ───────────────────────────────────────────────────────────────
1487
1488/// A single vertex in a mesh.
1489#[derive(Debug, Clone, Copy, PartialEq)]
1490pub struct Vertex {
1491    /// Position in object space.
1492    pub position: [f32; 3],
1493    /// Surface normal.
1494    pub normal: [f32; 3],
1495    /// Primary texture coordinate.
1496    pub uv: [f32; 2],
1497    /// Tangent vector (for normal mapping).
1498    pub tangent: [f32; 4],
1499    /// Vertex colour (RGBA, defaults to white).
1500    pub color: [f32; 4],
1501}
1502
1503impl Default for Vertex {
1504    fn default() -> Self {
1505        Self {
1506            position: [0.0; 3],
1507            normal: [0.0, 1.0, 0.0],
1508            uv: [0.0; 2],
1509            tangent: [1.0, 0.0, 0.0, 1.0],
1510            color: [1.0; 4],
1511        }
1512    }
1513}
1514
1515/// A loaded triangle mesh.
1516#[derive(Debug, Clone)]
1517pub struct MeshAsset {
1518    /// Vertex buffer.
1519    pub vertices: Vec<Vertex>,
1520    /// Index buffer (triangles: every 3 indices = 1 triangle).
1521    pub indices: Vec<u32>,
1522    /// Name of the material assigned to this mesh (references a [`MaterialAsset`]).
1523    pub material: Option<String>,
1524    /// Axis-aligned bounding box: (min, max).
1525    pub aabb: Option<([f32; 3], [f32; 3])>,
1526}
1527
1528impl MeshAsset {
1529    /// Compute the AABB from the vertex data and cache it.
1530    pub fn compute_aabb(&mut self) {
1531        if self.vertices.is_empty() {
1532            self.aabb = None;
1533            return;
1534        }
1535        let mut min = [f32::MAX; 3];
1536        let mut max = [f32::MIN; 3];
1537        for v in &self.vertices {
1538            for i in 0..3 {
1539                min[i] = min[i].min(v.position[i]);
1540                max[i] = max[i].max(v.position[i]);
1541            }
1542        }
1543        self.aabb = Some((min, max));
1544    }
1545
1546    /// Number of triangles.
1547    pub fn triangle_count(&self) -> usize {
1548        self.indices.len() / 3
1549    }
1550}
1551
1552impl Asset for MeshAsset {}
1553
1554// ── MaterialAsset ────────────────────────────────────────────────────────────
1555
1556/// A PBR material description.
1557#[derive(Debug, Clone)]
1558pub struct MaterialAsset {
1559    /// Path to the albedo / base-colour texture.
1560    pub albedo: Option<AssetPath>,
1561    /// Path to the normal map texture.
1562    pub normal_map: Option<AssetPath>,
1563    /// Roughness value (0 = mirror, 1 = fully rough) or texture path.
1564    pub roughness: MaterialParam,
1565    /// Metallic value (0 = dielectric, 1 = metal) or texture path.
1566    pub metallic: MaterialParam,
1567    /// Reference to the shader to use when rendering.
1568    pub shader: Option<AssetPath>,
1569    /// Base albedo colour tint (RGBA).
1570    pub base_color: [f32; 4],
1571    /// Whether this material uses alpha blending.
1572    pub alpha_blend: bool,
1573    /// Whether this material renders both sides of triangles.
1574    pub double_sided: bool,
1575}
1576
1577/// Either a constant scalar value or a reference to a texture channel.
1578#[derive(Debug, Clone)]
1579pub enum MaterialParam {
1580    /// Constant value in `[0.0, 1.0]`.
1581    Value(f32),
1582    /// Path to a texture; the red channel is used.
1583    Texture(AssetPath),
1584}
1585
1586impl Default for MaterialAsset {
1587    fn default() -> Self {
1588        Self {
1589            albedo: None,
1590            normal_map: None,
1591            roughness: MaterialParam::Value(0.5),
1592            metallic: MaterialParam::Value(0.0),
1593            shader: None,
1594            base_color: [1.0; 4],
1595            alpha_blend: false,
1596            double_sided: false,
1597        }
1598    }
1599}
1600
1601impl Asset for MaterialAsset {}
1602
1603// ── SceneAsset ───────────────────────────────────────────────────────────────
1604
1605/// A serialised entity in a scene.
1606#[derive(Debug, Clone)]
1607pub struct SceneEntity {
1608    /// Unique name within the scene.
1609    pub name: String,
1610    /// Optional parent entity name (for hierarchy).
1611    pub parent: Option<String>,
1612    /// Position in world space.
1613    pub position: [f32; 3],
1614    /// Rotation as a quaternion (x, y, z, w).
1615    pub rotation: [f32; 4],
1616    /// Uniform scale.
1617    pub scale: [f32; 3],
1618    /// Named components — key is component type, value is serialised data.
1619    pub components: HashMap<String, String>,
1620}
1621
1622impl SceneEntity {
1623    /// Create a new entity at the origin with identity rotation and unit scale.
1624    pub fn new(name: impl Into<String>) -> Self {
1625        Self {
1626            name: name.into(),
1627            parent: None,
1628            position: [0.0; 3],
1629            rotation: [0.0, 0.0, 0.0, 1.0],
1630            scale: [1.0; 3],
1631            components: HashMap::new(),
1632        }
1633    }
1634}
1635
1636/// A reference to a re-usable prefab template.
1637#[derive(Debug, Clone)]
1638pub struct PrefabRef {
1639    /// Instance name.
1640    pub name: String,
1641    /// Path to the source prefab asset.
1642    pub prefab_path: AssetPath,
1643    /// Override position.
1644    pub position: [f32; 3],
1645    /// Override rotation (quaternion).
1646    pub rotation: [f32; 4],
1647    /// Override scale.
1648    pub scale: [f32; 3],
1649}
1650
1651/// A complete serialised scene graph.
1652#[derive(Debug, Clone)]
1653pub struct SceneAsset {
1654    /// Human-readable scene name.
1655    pub name: String,
1656    /// All static entities baked into the scene.
1657    pub entities: Vec<SceneEntity>,
1658    /// Prefab instances referenced in the scene.
1659    pub prefabs: Vec<PrefabRef>,
1660    /// Global scene properties (fog, ambient light, sky, etc.).
1661    pub properties: HashMap<String, String>,
1662}
1663
1664impl SceneAsset {
1665    /// Create an empty scene.
1666    pub fn empty(name: impl Into<String>) -> Self {
1667        Self {
1668            name: name.into(),
1669            entities: Vec::new(),
1670            prefabs: Vec::new(),
1671            properties: HashMap::new(),
1672        }
1673    }
1674
1675    /// Look up an entity by name.
1676    pub fn find_entity(&self, name: &str) -> Option<&SceneEntity> {
1677        self.entities.iter().find(|e| e.name == name)
1678    }
1679
1680    /// Total number of objects (entities + prefab instances).
1681    pub fn object_count(&self) -> usize {
1682        self.entities.len() + self.prefabs.len()
1683    }
1684}
1685
1686impl Asset for SceneAsset {}
1687
1688// ────────────────────────────────────────────────────────────────────────────
1689// Section 14 — Built-in Loaders
1690// ────────────────────────────────────────────────────────────────────────────
1691
1692// ── RawImageLoader ───────────────────────────────────────────────────────────
1693
1694/// Loader for raw RGBA8 image files.
1695///
1696/// The file format is a 12-byte header:
1697/// `[4 bytes "RIMG"] [4 bytes width LE u32] [4 bytes height LE u32]`
1698/// followed by `width * height * 4` raw RGBA bytes.
1699///
1700/// This is intentionally minimal — a real engine would plug in a PNG decoder.
1701pub struct RawImageLoader;
1702
1703impl AssetLoader<ImageAsset> for RawImageLoader {
1704    fn load(&self, bytes: &[u8], path: &AssetPath) -> Result<ImageAsset, String> {
1705        if bytes.len() < 12 {
1706            return Ok(ImageAsset::solid_color(1, 1, [255, 0, 255, 255]));
1707        }
1708        if &bytes[0..4] == b"RIMG" {
1709            let width = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
1710            let height = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
1711            let expected = (width * height * 4) as usize;
1712            if bytes.len() < 12 + expected {
1713                return Err(format!("{path}: truncated RIMG data"));
1714            }
1715            return Ok(ImageAsset {
1716                width,
1717                height,
1718                format: PixelFormat::Rgba8,
1719                data: bytes[12..12 + expected].to_vec(),
1720                mip_levels: Vec::new(),
1721            });
1722        }
1723        Ok(ImageAsset::solid_color(1, 1, [255, 0, 255, 255]))
1724    }
1725
1726    fn extensions(&self) -> &[&str] {
1727        &["rimg", "png", "jpg", "jpeg", "bmp", "tga"]
1728    }
1729}
1730
1731// ── PlainTextShaderLoader ─────────────────────────────────────────────────────
1732
1733/// Loader for plain-text shader source files.
1734///
1735/// The stage is inferred from the file extension.
1736pub struct PlainTextShaderLoader;
1737
1738impl AssetLoader<ShaderAsset> for PlainTextShaderLoader {
1739    fn load(&self, bytes: &[u8], path: &AssetPath) -> Result<ShaderAsset, String> {
1740        let source = std::str::from_utf8(bytes)
1741            .map_err(|e| format!("{path}: invalid UTF-8: {e}"))?
1742            .to_owned();
1743
1744        let stage = match path.extension().as_deref() {
1745            Some("vert") => ShaderStage::Vertex,
1746            Some("frag") => ShaderStage::Fragment,
1747            Some("comp") => ShaderStage::Compute,
1748            Some("geom") => ShaderStage::Geometry,
1749            _ => ShaderStage::Fragment,
1750        };
1751
1752        let name = path
1753            .path()
1754            .file_stem()
1755            .and_then(|s| s.to_str())
1756            .unwrap_or("unknown")
1757            .to_owned();
1758
1759        Ok(ShaderAsset::new(name, source, stage))
1760    }
1761
1762    fn extensions(&self) -> &[&str] {
1763        &["glsl", "vert", "frag", "comp", "geom", "wgsl", "hlsl"]
1764    }
1765}
1766
1767// ── PlainTextScriptLoader ─────────────────────────────────────────────────────
1768
1769/// Loader for plain-text script files.
1770pub struct PlainTextScriptLoader;
1771
1772impl AssetLoader<ScriptAsset> for PlainTextScriptLoader {
1773    fn load(&self, bytes: &[u8], path: &AssetPath) -> Result<ScriptAsset, String> {
1774        let source = std::str::from_utf8(bytes)
1775            .map_err(|e| format!("{path}: invalid UTF-8: {e}"))?
1776            .to_owned();
1777        let name = path
1778            .path()
1779            .file_stem()
1780            .and_then(|s| s.to_str())
1781            .unwrap_or("script")
1782            .to_owned();
1783        let language = path.extension();
1784        Ok(ScriptAsset { name, source, language })
1785    }
1786
1787    fn extensions(&self) -> &[&str] {
1788        &["lua", "rhai", "wren", "js", "py", "script"]
1789    }
1790}
1791
1792// ── RawSoundLoader ───────────────────────────────────────────────────────────
1793
1794/// Loader for raw PCM audio files.
1795///
1796/// Header format:
1797/// `[4 bytes "RSND"] [4 bytes sample_rate LE u32] [2 bytes channels LE u16]`
1798/// `[2 bytes padding] [remaining bytes: little-endian f32 samples]`
1799pub struct RawSoundLoader;
1800
1801impl AssetLoader<SoundAsset> for RawSoundLoader {
1802    fn load(&self, bytes: &[u8], path: &AssetPath) -> Result<SoundAsset, String> {
1803        if bytes.len() < 12 {
1804            return Err(format!("{path}: sound file too small"));
1805        }
1806        if &bytes[0..4] != b"RSND" {
1807            return Ok(SoundAsset {
1808                sample_rate: 44100,
1809                channels: 1,
1810                samples: vec![0.0f32; 44100],
1811                loop_start: None,
1812                loop_end: None,
1813            });
1814        }
1815        let sample_rate = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
1816        let channels = u16::from_le_bytes(bytes[8..10].try_into().unwrap());
1817        let sample_bytes = &bytes[12..];
1818        if sample_bytes.len() % 4 != 0 {
1819            return Err(format!("{path}: sample data not aligned to 4 bytes"));
1820        }
1821        let samples: Vec<f32> = sample_bytes
1822            .chunks_exact(4)
1823            .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
1824            .collect();
1825        Ok(SoundAsset { sample_rate, channels, samples, loop_start: None, loop_end: None })
1826    }
1827
1828    fn extensions(&self) -> &[&str] {
1829        &["rsnd", "wav", "ogg", "mp3", "flac"]
1830    }
1831}
1832
1833// ────────────────────────────────────────────────────────────────────────────
1834// Section 15 — Built-in Processors
1835// ────────────────────────────────────────────────────────────────────────────
1836
1837/// Generates box-filtered mip-maps for [`ImageAsset`] data.
1838///
1839/// Each mip level is half the size of the previous, stopping at 1x1.
1840/// Only RGBA8 images are supported; others pass through unchanged.
1841pub struct MipMapGenerator;
1842
1843impl AssetProcessor<ImageAsset> for MipMapGenerator {
1844    fn process(&self, asset: &mut ImageAsset, _path: &AssetPath) -> Result<(), String> {
1845        if asset.format != PixelFormat::Rgba8 {
1846            return Ok(());
1847        }
1848        let mut src_w = asset.width as usize;
1849        let mut src_h = asset.height as usize;
1850        let mut src_data = asset.data.clone();
1851
1852        while src_w > 1 || src_h > 1 {
1853            let dst_w = (src_w / 2).max(1);
1854            let dst_h = (src_h / 2).max(1);
1855            let mut dst_data = vec![0u8; dst_w * dst_h * 4];
1856
1857            for y in 0..dst_h {
1858                for x in 0..dst_w {
1859                    let src_x = (x * 2).min(src_w - 1);
1860                    let src_y = (y * 2).min(src_h - 1);
1861                    let nx = (src_x + 1).min(src_w - 1);
1862                    let ny = (src_y + 1).min(src_h - 1);
1863
1864                    let p = |py: usize, px: usize| -> [u8; 4] {
1865                        let off = (py * src_w + px) * 4;
1866                        src_data[off..off + 4].try_into().unwrap()
1867                    };
1868                    let p00 = p(src_y, src_x);
1869                    let p01 = p(src_y, nx);
1870                    let p10 = p(ny, src_x);
1871                    let p11 = p(ny, nx);
1872
1873                    let off = (y * dst_w + x) * 4;
1874                    for c in 0..4 {
1875                        dst_data[off + c] = (
1876                            (p00[c] as u32 + p01[c] as u32 + p10[c] as u32 + p11[c] as u32) / 4
1877                        ) as u8;
1878                    }
1879                }
1880            }
1881
1882            asset.mip_levels.push(dst_data.clone());
1883            src_data = dst_data;
1884            src_w = dst_w;
1885            src_h = dst_h;
1886        }
1887        Ok(())
1888    }
1889
1890    fn name(&self) -> &str {
1891        "MipMapGenerator"
1892    }
1893}
1894
1895/// Normalises audio samples to the range `[-1.0, 1.0]`.
1896pub struct AudioNormalizer;
1897
1898impl AssetProcessor<SoundAsset> for AudioNormalizer {
1899    fn process(&self, asset: &mut SoundAsset, _path: &AssetPath) -> Result<(), String> {
1900        let peak = asset.samples.iter().copied().map(f32::abs).fold(0.0f32, f32::max);
1901        if peak > 0.0 && (peak - 1.0).abs() > 1e-6 {
1902            for s in &mut asset.samples {
1903                *s /= peak;
1904            }
1905        }
1906        Ok(())
1907    }
1908
1909    fn name(&self) -> &str {
1910        "AudioNormalizer"
1911    }
1912}
1913
1914// ────────────────────────────────────────────────────────────────────────────
1915// Section 16 — AssetServer
1916// ────────────────────────────────────────────────────────────────────────────
1917
1918/// Configuration for [`AssetServer`].
1919#[derive(Debug, Clone)]
1920pub struct AssetServerConfig {
1921    /// Root directory for asset files on disk.
1922    pub root_dir: PathBuf,
1923    /// LRU cache capacity (0 = unlimited).
1924    pub cache_capacity: usize,
1925    /// How many streaming requests to process per `update()` call.
1926    pub stream_batch_size: usize,
1927    /// Enable hot-reload (file-change polling).
1928    pub hot_reload: bool,
1929    /// How often to poll for file changes.
1930    pub hot_reload_interval: Duration,
1931}
1932
1933impl Default for AssetServerConfig {
1934    fn default() -> Self {
1935        Self {
1936            root_dir: PathBuf::from("assets"),
1937            cache_capacity: 512,
1938            stream_batch_size: 8,
1939            hot_reload: cfg!(debug_assertions),
1940            hot_reload_interval: Duration::from_secs(1),
1941        }
1942    }
1943}
1944
1945/// Type-erased loader function stored inside [`AssetServer`].
1946type ErasedLoadFn = Box<dyn Fn(&[u8], &AssetPath) -> Result<Box<dyn Any + Send + Sync>, String> + Send + Sync>;
1947
1948/// Type-erased store-into-registry function.
1949type ErasedStoreFn = Box<dyn Fn(&mut AssetRegistry, u64, Box<dyn Any + Send + Sync>, Option<SystemTime>) + Send + Sync>;
1950
1951struct LoaderEntry {
1952    extensions: Vec<String>,
1953    type_id: TypeId,
1954    load: ErasedLoadFn,
1955    store: ErasedStoreFn,
1956}
1957
1958/// The central asset server: coordinates loading, caching, hot-reload, and streaming.
1959///
1960/// # Lifecycle
1961///
1962/// 1. Register loaders with [`register_loader`](AssetServer::register_loader).
1963/// 2. Request assets with [`load`](AssetServer::load) or [`load_manifest`](AssetServer::load_manifest).
1964/// 3. Call [`update`](AssetServer::update) each frame to process the streaming queue and hot-reload.
1965/// 4. Retrieve loaded data via [`get`](AssetServer::get).
1966pub struct AssetServer {
1967    config: AssetServerConfig,
1968    registry: AssetRegistry,
1969    cache: AssetCache,
1970    hot_reload: HotReload,
1971    streaming: StreamingManager,
1972    loaders: Vec<LoaderEntry>,
1973    packs: Vec<AssetPack>,
1974    /// Per-id typed handle storage: id -> Arc<RwLock<Option<T>>> erased as Box<dyn Any>
1975    typed_slots: HashMap<u64, Box<dyn Any + Send + Sync>>,
1976    /// Statistics.
1977    stats: AssetServerStats,
1978}
1979
1980/// Runtime statistics for the asset server.
1981#[derive(Debug, Clone, Default)]
1982pub struct AssetServerStats {
1983    /// Total assets loaded from disk since server creation.
1984    pub loads_from_disk: u64,
1985    /// Total assets loaded from an [`AssetPack`] since server creation.
1986    pub loads_from_pack: u64,
1987    /// Total bytes read from disk.
1988    pub bytes_read: u64,
1989    /// Total assets evicted from the LRU cache.
1990    pub evictions: u64,
1991    /// Total hot-reload events processed.
1992    pub hot_reloads: u64,
1993    /// Total load failures.
1994    pub failures: u64,
1995}
1996
1997impl AssetServer {
1998    /// Create a new asset server with the given configuration.
1999    pub fn new_with_config(config: AssetServerConfig) -> Self {
2000        let cache = AssetCache::new(config.cache_capacity);
2001        let hot_reload = HotReload::new(config.hot_reload_interval, config.hot_reload);
2002        let streaming = StreamingManager::new(config.stream_batch_size);
2003        Self {
2004            config,
2005            registry: AssetRegistry::new(),
2006            cache,
2007            hot_reload,
2008            streaming,
2009            loaders: Vec::new(),
2010            packs: Vec::new(),
2011            typed_slots: HashMap::new(),
2012            stats: AssetServerStats::default(),
2013        }
2014    }
2015
2016    /// Create a new asset server with default configuration.
2017    pub fn new() -> Self {
2018        Self::new_with_config(AssetServerConfig::default())
2019    }
2020
2021    /// Change the root asset directory.
2022    pub fn set_root_dir(&mut self, dir: impl Into<PathBuf>) {
2023        self.config.root_dir = dir.into();
2024    }
2025
2026    /// Register a loader for asset type `A`.
2027    ///
2028    /// The loader's `extensions()` are used to match file paths.
2029    /// If multiple loaders claim the same extension, the most recently
2030    /// registered one takes precedence.
2031    pub fn register_loader<A: Asset, L: AssetLoader<A>>(&mut self, loader: L) {
2032        let extensions: Vec<String> = loader.extensions().iter().map(|e| e.to_string()).collect();
2033        let type_id = TypeId::of::<A>();
2034        let loader = Arc::new(loader);
2035
2036        let load_loader = Arc::clone(&loader);
2037        let load: ErasedLoadFn = Box::new(move |bytes, path| {
2038            load_loader
2039                .load(bytes, path)
2040                .map(|a| Box::new(a) as Box<dyn Any + Send + Sync>)
2041        });
2042
2043        let store: ErasedStoreFn = Box::new(|registry, id, boxed, mtime| {
2044            if let Ok(asset) = boxed.downcast::<A>() {
2045                registry.store::<A>(id, *asset, mtime);
2046            }
2047        });
2048
2049        self.loaders.push(LoaderEntry { extensions, type_id, load, store });
2050    }
2051
2052    /// Mount an [`AssetPack`]. Files in packs are resolved before the file-system.
2053    pub fn mount_pack(&mut self, pack: AssetPack) {
2054        self.packs.push(pack);
2055    }
2056
2057    /// Request that `path` be loaded as asset type `A`.
2058    ///
2059    /// Returns an [`AssetHandle<A>`] immediately. The handle will become
2060    /// populated after [`update`](AssetServer::update) processes the request.
2061    ///
2062    /// If the asset is already loaded, a handle to the existing data is returned.
2063    pub fn load<A: Asset>(&mut self, path: impl Into<AssetPath>) -> AssetHandle<A> {
2064        let path = path.into();
2065        let type_id = TypeId::of::<A>();
2066
2067        if let Some(id) = self.registry.id_for_path(&path) {
2068            return self.make_handle::<A>(id);
2069        }
2070
2071        let id = self.registry.alloc(type_id, path.clone());
2072        self.registry.mark_loading(id);
2073
2074        let arc: Arc<RwLock<Option<A>>> = Arc::new(RwLock::new(None));
2075        self.typed_slots.insert(id, Box::new(Arc::clone(&arc)));
2076
2077        self.streaming.enqueue(StreamRequest {
2078            id,
2079            path,
2080            type_id,
2081            priority: StreamPriority::Normal,
2082            enqueued_at: Instant::now(),
2083        });
2084
2085        AssetHandle::new(AssetId::new(id), arc)
2086    }
2087
2088    /// Like [`load`](AssetServer::load) but with an explicit priority.
2089    pub fn load_with_priority<A: Asset>(
2090        &mut self,
2091        path: impl Into<AssetPath>,
2092        priority: StreamPriority,
2093    ) -> AssetHandle<A> {
2094        let path = path.into();
2095        let type_id = TypeId::of::<A>();
2096
2097        if let Some(id) = self.registry.id_for_path(&path) {
2098            return self.make_handle::<A>(id);
2099        }
2100
2101        let id = self.registry.alloc(type_id, path.clone());
2102        self.registry.mark_loading(id);
2103
2104        let arc: Arc<RwLock<Option<A>>> = Arc::new(RwLock::new(None));
2105        self.typed_slots.insert(id, Box::new(Arc::clone(&arc)));
2106
2107        self.streaming.enqueue(StreamRequest {
2108            id,
2109            path,
2110            type_id,
2111            priority,
2112            enqueued_at: Instant::now(),
2113        });
2114
2115        AssetHandle::new(AssetId::new(id), arc)
2116    }
2117
2118    /// Queue all assets declared in `manifest` for loading.
2119    ///
2120    /// Callers should subsequently call typed `load::<T>()` for each entry to
2121    /// obtain typed handles; this method ensures the paths are pre-registered
2122    /// at the declared priorities.
2123    pub fn load_manifest(&mut self, manifest: &AssetManifest) {
2124        for entry in &manifest.entries {
2125            let path = entry.path.clone();
2126            let priority = entry.priority;
2127
2128            if self.registry.id_for_path(&path).is_none() {
2129                // Placeholder type — real typed loads will overwrite if needed
2130                let id = self.registry.alloc(TypeId::of::<ScriptAsset>(), path.clone());
2131                self.registry.mark_loading(id);
2132                self.streaming.enqueue(StreamRequest {
2133                    id,
2134                    path,
2135                    type_id: TypeId::of::<ScriptAsset>(),
2136                    priority,
2137                    enqueued_at: Instant::now(),
2138                });
2139            }
2140        }
2141    }
2142
2143    /// Drive the asset server for one frame.
2144    ///
2145    /// This processes pending streaming requests (up to `stream_batch_size` per
2146    /// call) and polls the hot-reload watcher. Call once per frame.
2147    pub fn update(&mut self) {
2148        let batch = self.streaming.drain();
2149        for req in batch {
2150            self.execute_load(req);
2151        }
2152
2153        let changed = self.hot_reload.poll();
2154        for (_path, asset_ids) in changed {
2155            for id in asset_ids {
2156                self.enqueue_reload(id);
2157            }
2158        }
2159    }
2160
2161    /// Try to get the data for `handle`.
2162    ///
2163    /// Returns `None` if the asset is not yet loaded or has been evicted.
2164    pub fn get<A: Asset>(&mut self, handle: &AssetHandle<A>) -> Option<Arc<A>> {
2165        let id = handle.id().raw();
2166        let arc = self.registry.get::<A>(id)?;
2167
2168        if let Some(evict_id) = self.cache.touch(id) {
2169            self.registry.evict(evict_id);
2170            self.cache.remove(evict_id);
2171            self.typed_slots.remove(&evict_id);
2172            self.stats.evictions += 1;
2173        }
2174
2175        Some(arc)
2176    }
2177
2178    /// Get the [`LoadState`] of `handle`.
2179    pub fn load_state<A: Asset>(&self, handle: &AssetHandle<A>) -> LoadState {
2180        self.registry.load_state(handle.id().raw())
2181    }
2182
2183    /// Force an immediate synchronous reload of an asset by handle.
2184    pub fn reload<A: Asset>(&mut self, handle: &AssetHandle<A>) {
2185        let id = handle.id().raw();
2186        self.enqueue_reload(id);
2187        // Drain immediately so the reload happens synchronously
2188        let batch = self.streaming.drain();
2189        for req in batch {
2190            self.execute_load(req);
2191        }
2192    }
2193
2194    /// Insert an already-constructed asset directly into the registry.
2195    ///
2196    /// Returns a handle to the inserted asset. Useful for procedurally generated
2197    /// assets that have no backing file.
2198    pub fn insert<A: Asset>(&mut self, path: impl Into<AssetPath>, asset: A) -> AssetHandle<A> {
2199        let path = path.into();
2200        let type_id = TypeId::of::<A>();
2201
2202        let id = if let Some(existing_id) = self.registry.id_for_path(&path) {
2203            existing_id
2204        } else {
2205            self.registry.alloc(type_id, path)
2206        };
2207
2208        self.registry.store::<A>(id, asset, None);
2209
2210        // Build the typed Arc slot
2211        let arc: Arc<RwLock<Option<A>>> = Arc::new(RwLock::new(None));
2212        // Populate from registry
2213        if let Some(value_arc) = self.registry.get::<A>(id) {
2214            // We can't move out of Arc<T>, but we can clone if T: Clone.
2215            // Instead, store None in the handle — callers use server.get() for Arc<T>.
2216            // The handle's inner Arc is a separate slot; populate it by writing.
2217            // Since A: Asset (not Clone), we leave the handle slot empty and
2218            // direct callers to use server.get() which returns Arc<A> from registry.
2219            let _ = value_arc;
2220        }
2221        self.typed_slots.insert(id, Box::new(Arc::clone(&arc)));
2222
2223        if let Some(evict_id) = self.cache.touch(id) {
2224            self.registry.evict(evict_id);
2225            self.cache.remove(evict_id);
2226            self.typed_slots.remove(&evict_id);
2227            self.stats.evictions += 1;
2228        }
2229
2230        AssetHandle::new(AssetId::new(id), arc)
2231    }
2232
2233    /// Return a snapshot of the current server statistics.
2234    pub fn stats(&self) -> &AssetServerStats {
2235        &self.stats
2236    }
2237
2238    /// Number of assets currently tracked by the registry.
2239    pub fn asset_count(&self) -> usize {
2240        self.registry.len()
2241    }
2242
2243    /// `true` if all streaming requests have been processed.
2244    pub fn is_idle(&self) -> bool {
2245        self.streaming.is_idle()
2246    }
2247
2248    /// Read bytes for a given path, checking packs first then the file-system.
2249    /// Exposed as `pub` for testing and pack inspection utilities.
2250    pub fn read_bytes(&mut self, path: &AssetPath) -> Result<(Vec<u8>, Option<SystemTime>), String> {
2251        let virtual_str = path.path().to_string_lossy().replace('\\', "/");
2252
2253        for pack in &self.packs {
2254            if let Some(data) = pack.read(&virtual_str) {
2255                self.stats.loads_from_pack += 1;
2256                self.stats.bytes_read += data.len() as u64;
2257                return Ok((data.to_vec(), None));
2258            }
2259        }
2260
2261        let full_path = self.config.root_dir.join(path.path());
2262        let mtime = std::fs::metadata(&full_path)
2263            .and_then(|m| m.modified())
2264            .ok();
2265        let data = std::fs::read(&full_path)
2266            .map_err(|e| format!("failed to read {}: {e}", full_path.display()))?;
2267        self.stats.loads_from_disk += 1;
2268        self.stats.bytes_read += data.len() as u64;
2269        Ok((data, mtime))
2270    }
2271
2272    // ── Private helpers ───────────────────────────────────────────────────
2273
2274    /// Construct a handle referencing an existing slot (already allocated).
2275    fn make_handle<A: Asset>(&mut self, id: u64) -> AssetHandle<A> {
2276        // If a typed slot exists and is the right type, reuse it
2277        if let Some(boxed) = self.typed_slots.get(&id) {
2278            if let Some(arc) = boxed.downcast_ref::<Arc<RwLock<Option<A>>>>() {
2279                return AssetHandle::new(AssetId::new(id), Arc::clone(arc));
2280            }
2281        }
2282        // Create a fresh typed slot
2283        let arc: Arc<RwLock<Option<A>>> = Arc::new(RwLock::new(None));
2284        self.typed_slots.insert(id, Box::new(Arc::clone(&arc)));
2285        AssetHandle::new(AssetId::new(id), arc)
2286    }
2287
2288    /// Execute a single load request synchronously.
2289    fn execute_load(&mut self, req: StreamRequest) {
2290        let path = req.path.clone();
2291        let id = req.id;
2292        let ext = path.extension().unwrap_or_default();
2293
2294        // Find a matching loader by type + extension
2295        let loader_idx = self.loaders.iter().rposition(|l| {
2296            l.type_id == req.type_id && l.extensions.iter().any(|e| e == &ext)
2297        }).or_else(|| {
2298            // Fallback: any loader for this type regardless of extension
2299            self.loaders.iter().rposition(|l| l.type_id == req.type_id)
2300        });
2301
2302        let loader_idx = match loader_idx {
2303            Some(i) => i,
2304            None => {
2305                let msg = format!("no loader for extension '{ext}'");
2306                self.registry.mark_failed(id, msg);
2307                self.stats.failures += 1;
2308                return;
2309            }
2310        };
2311
2312        let (bytes, mtime) = match self.read_bytes(&path) {
2313            Ok(b) => b,
2314            Err(e) => {
2315                self.registry.mark_failed(id, e);
2316                self.stats.failures += 1;
2317                return;
2318            }
2319        };
2320
2321        let loaded = (self.loaders[loader_idx].load)(&bytes, &path);
2322        match loaded {
2323            Ok(boxed) => {
2324                (self.loaders[loader_idx].store)(&mut self.registry, id, boxed, mtime);
2325                if mtime.is_some() {
2326                    let disk_path = self.config.root_dir.join(path.path());
2327                    self.hot_reload.watch(disk_path, id, mtime);
2328                }
2329                if let Some(evict_id) = self.cache.touch(id) {
2330                    self.registry.evict(evict_id);
2331                    self.cache.remove(evict_id);
2332                    self.typed_slots.remove(&evict_id);
2333                    self.stats.evictions += 1;
2334                }
2335            }
2336            Err(msg) => {
2337                self.registry.mark_failed(id, msg);
2338                self.stats.failures += 1;
2339            }
2340        }
2341    }
2342
2343    /// Enqueue a reload of the asset with the given ID.
2344    fn enqueue_reload(&mut self, id: u64) {
2345        let path_opt = self.registry.path_for_id(id).cloned();
2346        let type_id_opt = self.registry.type_id_for(id);
2347        if let (Some(path), Some(type_id)) = (path_opt, type_id_opt) {
2348            self.registry.mark_loading(id);
2349            self.streaming.enqueue(StreamRequest {
2350                id,
2351                path,
2352                type_id,
2353                priority: StreamPriority::High,
2354                enqueued_at: Instant::now(),
2355            });
2356            self.stats.hot_reloads += 1;
2357        }
2358    }
2359}
2360
2361impl Default for AssetServer {
2362    fn default() -> Self {
2363        Self::new()
2364    }
2365}
2366
2367impl fmt::Debug for AssetServer {
2368    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2369        f.debug_struct("AssetServer")
2370            .field("assets", &self.registry.len())
2371            .field("pending", &self.streaming.pending())
2372            .field("cache", &self.cache)
2373            .field("hot_reload", &self.hot_reload)
2374            .field("stats", &self.stats)
2375            .finish()
2376    }
2377}
2378
2379// ────────────────────────────────────────────────────────────────────────────
2380// Section 17 — Convenience functions
2381// ────────────────────────────────────────────────────────────────────────────
2382
2383/// Build a default `AssetServer` with all built-in loaders registered.
2384///
2385/// This is the quickest way to get a fully configured server.
2386///
2387/// # Example
2388/// ```rust
2389/// use proof_engine::asset::default_asset_server;
2390/// let mut server = default_asset_server();
2391/// ```
2392pub fn default_asset_server() -> AssetServer {
2393    let mut server = AssetServer::new();
2394    server.register_loader::<ImageAsset, _>(RawImageLoader);
2395    server.register_loader::<ShaderAsset, _>(PlainTextShaderLoader);
2396    server.register_loader::<ScriptAsset, _>(PlainTextScriptLoader);
2397    server.register_loader::<SoundAsset, _>(RawSoundLoader);
2398    server
2399}
2400
2401/// Load a file synchronously from disk, returning its bytes.
2402///
2403/// Used internally; also exposed for simple file utilities.
2404pub fn load_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
2405    std::fs::read(path).map_err(|e| format!("load_file_bytes: {e}"))
2406}
2407
2408// ────────────────────────────────────────────────────────────────────────────
2409// Section 18 — Tests
2410// ────────────────────────────────────────────────────────────────────────────
2411
2412#[cfg(test)]
2413mod tests {
2414    use super::*;
2415
2416    // ── AssetPath tests ─────────────────────────────────────────────────────
2417
2418    #[test]
2419    fn asset_path_parse_no_label() {
2420        let p = AssetPath::parse("textures/player.png");
2421        assert_eq!(p.path(), Path::new("textures/player.png"));
2422        assert_eq!(p.label(), None);
2423    }
2424
2425    #[test]
2426    fn asset_path_parse_with_label() {
2427        let p = AssetPath::parse("models/robot.gltf#body");
2428        assert_eq!(p.path(), Path::new("models/robot.gltf"));
2429        assert_eq!(p.label(), Some("body"));
2430    }
2431
2432    #[test]
2433    fn asset_path_extension() {
2434        let p = AssetPath::new("shaders/main.frag");
2435        assert_eq!(p.extension(), Some("frag".to_string()));
2436    }
2437
2438    #[test]
2439    fn asset_path_display() {
2440        let p = AssetPath::with_label("a/b.png", "sub");
2441        assert!(p.to_string().contains('#'));
2442    }
2443
2444    #[test]
2445    fn asset_path_from_str() {
2446        let p: AssetPath = "foo/bar.lua".into();
2447        assert_eq!(p.extension(), Some("lua".to_string()));
2448    }
2449
2450    // ── AssetId tests ───────────────────────────────────────────────────────
2451
2452    #[test]
2453    fn asset_id_equality() {
2454        let a = AssetId::<ImageAsset>::new(42);
2455        let b = AssetId::<ImageAsset>::new(42);
2456        let c = AssetId::<ImageAsset>::new(7);
2457        assert_eq!(a, b);
2458        assert_ne!(a, c);
2459    }
2460
2461    #[test]
2462    fn asset_id_copy() {
2463        let a = AssetId::<ShaderAsset>::new(1);
2464        let b = a;
2465        assert_eq!(a, b);
2466    }
2467
2468    // ── LoadState tests ─────────────────────────────────────────────────────
2469
2470    #[test]
2471    fn load_state_display() {
2472        assert_eq!(LoadState::NotLoaded.to_string(), "NotLoaded");
2473        assert_eq!(LoadState::Loading.to_string(), "Loading");
2474        assert_eq!(LoadState::Loaded.to_string(), "Loaded");
2475        assert!(LoadState::Failed("oops".into()).to_string().contains("oops"));
2476    }
2477
2478    #[test]
2479    fn load_state_equality() {
2480        assert_eq!(LoadState::Loaded, LoadState::Loaded);
2481        assert_ne!(LoadState::Loaded, LoadState::Loading);
2482        assert_eq!(
2483            LoadState::Failed("x".into()),
2484            LoadState::Failed("x".into())
2485        );
2486    }
2487
2488    // ── AssetRegistry tests ─────────────────────────────────────────────────
2489
2490    #[test]
2491    fn registry_alloc_and_lookup() {
2492        let mut reg = AssetRegistry::new();
2493        let path = AssetPath::new("test.png");
2494        let id = reg.alloc(TypeId::of::<ImageAsset>(), path.clone());
2495        assert_eq!(reg.id_for_path(&path), Some(id));
2496        assert_eq!(reg.load_state(id), LoadState::NotLoaded);
2497    }
2498
2499    #[test]
2500    fn registry_store_and_get() {
2501        let mut reg = AssetRegistry::new();
2502        let path = AssetPath::new("solid.png");
2503        let id = reg.alloc(TypeId::of::<ImageAsset>(), path);
2504        let img = ImageAsset::solid_color(4, 4, [0, 0, 0, 255]);
2505        reg.store::<ImageAsset>(id, img, None);
2506        assert_eq!(reg.load_state(id), LoadState::Loaded);
2507        let arc = reg.get::<ImageAsset>(id).unwrap();
2508        assert_eq!(arc.width, 4);
2509    }
2510
2511    #[test]
2512    fn registry_mark_failed() {
2513        let mut reg = AssetRegistry::new();
2514        let id = reg.alloc(TypeId::of::<ImageAsset>(), AssetPath::new("x.png"));
2515        reg.mark_failed(id, "disk error".into());
2516        assert!(matches!(reg.load_state(id), LoadState::Failed(_)));
2517    }
2518
2519    #[test]
2520    fn registry_evict() {
2521        let mut reg = AssetRegistry::new();
2522        let id = reg.alloc(TypeId::of::<ImageAsset>(), AssetPath::new("e.png"));
2523        assert!(reg.evict(id));
2524        assert_eq!(reg.load_state(id), LoadState::NotLoaded);
2525        assert!(!reg.evict(id));
2526    }
2527
2528    // ── AssetCache tests ────────────────────────────────────────────────────
2529
2530    #[test]
2531    fn cache_lru_eviction() {
2532        let mut cache = AssetCache::new(3);
2533        assert_eq!(cache.touch(1), None);
2534        assert_eq!(cache.touch(2), None);
2535        assert_eq!(cache.touch(3), None);
2536        let evicted = cache.touch(4);
2537        assert_eq!(evicted, Some(1));
2538    }
2539
2540    #[test]
2541    fn cache_touch_updates_order() {
2542        let mut cache = AssetCache::new(3);
2543        cache.touch(1);
2544        cache.touch(2);
2545        cache.touch(3);
2546        cache.touch(1); // 1 is now MRU; 2 is LRU
2547        let evicted = cache.touch(4);
2548        assert_eq!(evicted, Some(2));
2549    }
2550
2551    #[test]
2552    fn cache_unlimited() {
2553        let mut cache = AssetCache::new(0);
2554        for i in 0..1000u64 {
2555            assert_eq!(cache.touch(i), None);
2556        }
2557        assert_eq!(cache.len(), 1000);
2558    }
2559
2560    #[test]
2561    fn cache_set_capacity_evicts() {
2562        let mut cache = AssetCache::new(10);
2563        for i in 0..10u64 {
2564            cache.touch(i);
2565        }
2566        let evicted = cache.set_capacity(5);
2567        assert_eq!(evicted.len(), 5);
2568        assert_eq!(cache.len(), 5);
2569    }
2570
2571    // ── HotReload tests ─────────────────────────────────────────────────────
2572
2573    #[test]
2574    fn hot_reload_disabled_returns_empty() {
2575        let mut hr = HotReload::new(Duration::from_secs(1), false);
2576        hr.watch(PathBuf::from("x.png"), 1, None);
2577        let changed = hr.poll();
2578        assert!(changed.is_empty());
2579    }
2580
2581    #[test]
2582    fn hot_reload_watch_count() {
2583        let mut hr = HotReload::new(Duration::from_secs(60), true);
2584        hr.watch(PathBuf::from("a.png"), 1, None);
2585        hr.watch(PathBuf::from("b.png"), 2, None);
2586        assert_eq!(hr.watched_count(), 2);
2587    }
2588
2589    #[test]
2590    fn hot_reload_unwatch() {
2591        let mut hr = HotReload::new(Duration::from_secs(60), true);
2592        hr.watch(PathBuf::from("a.png"), 1, None);
2593        hr.unwatch(1);
2594        assert_eq!(hr.watched_count(), 0);
2595    }
2596
2597    // ── StreamingManager tests ──────────────────────────────────────────────
2598
2599    #[test]
2600    fn streaming_priority_order() {
2601        let mut sm = StreamingManager::new(10);
2602        let make = |id: u64, priority: StreamPriority| StreamRequest {
2603            id,
2604            path: AssetPath::new("x"),
2605            type_id: TypeId::of::<ImageAsset>(),
2606            priority,
2607            enqueued_at: Instant::now(),
2608        };
2609        sm.enqueue(make(1, StreamPriority::Low));
2610        sm.enqueue(make(2, StreamPriority::Critical));
2611        sm.enqueue(make(3, StreamPriority::Normal));
2612
2613        let drained = sm.drain();
2614        assert_eq!(drained[0].id, 2);
2615        assert_eq!(drained[1].id, 3);
2616        assert_eq!(drained[2].id, 1);
2617    }
2618
2619    #[test]
2620    fn streaming_cancel() {
2621        let mut sm = StreamingManager::new(10);
2622        sm.enqueue(StreamRequest {
2623            id: 99,
2624            path: AssetPath::new("y"),
2625            type_id: TypeId::of::<ImageAsset>(),
2626            priority: StreamPriority::Normal,
2627            enqueued_at: Instant::now(),
2628        });
2629        assert_eq!(sm.pending(), 1);
2630        assert!(sm.cancel(99));
2631        assert_eq!(sm.pending(), 0);
2632    }
2633
2634    #[test]
2635    fn streaming_batch_limit() {
2636        let mut sm = StreamingManager::new(2);
2637        for i in 0..5u64 {
2638            sm.enqueue(StreamRequest {
2639                id: i,
2640                path: AssetPath::new("z"),
2641                type_id: TypeId::of::<ImageAsset>(),
2642                priority: StreamPriority::Normal,
2643                enqueued_at: Instant::now(),
2644            });
2645        }
2646        let first = sm.drain();
2647        assert_eq!(first.len(), 2);
2648        assert_eq!(sm.pending(), 3);
2649    }
2650
2651    // ── AssetPack tests ─────────────────────────────────────────────────────
2652
2653    #[test]
2654    fn pack_build_and_read() {
2655        let files: &[(&str, &[u8])] = &[
2656            ("shaders/main.vert", b"void main() {}"),
2657            ("textures/logo.png", &[0u8, 1, 2, 3, 4]),
2658        ];
2659        let bytes = AssetPack::build("test", files);
2660        let pack = AssetPack::from_bytes("test", bytes).expect("parse failed");
2661        assert_eq!(pack.entry_count(), 2);
2662        assert_eq!(pack.read("shaders/main.vert"), Some(b"void main() {}".as_ref()));
2663        assert_eq!(pack.read("textures/logo.png"), Some([0u8, 1, 2, 3, 4].as_ref()));
2664        assert_eq!(pack.read("nonexistent"), None);
2665    }
2666
2667    #[test]
2668    fn pack_invalid_magic() {
2669        let result = AssetPack::from_bytes("bad", b"XXXX\0\0\0\0".to_vec());
2670        assert!(result.is_err());
2671    }
2672
2673    #[test]
2674    fn pack_paths_iterator() {
2675        let files: &[(&str, &[u8])] = &[("a.txt", b"hello"), ("b.txt", b"world")];
2676        let bytes = AssetPack::build("p", files);
2677        let pack = AssetPack::from_bytes("p", bytes).unwrap();
2678        let paths: Vec<_> = pack.paths().collect();
2679        assert!(paths.contains(&"a.txt"));
2680        assert!(paths.contains(&"b.txt"));
2681    }
2682
2683    // ── AssetManifest tests ─────────────────────────────────────────────────
2684
2685    #[test]
2686    fn manifest_required_optional() {
2687        let mut m = AssetManifest::new("level1");
2688        m.add(ManifestEntry::required("textures/floor.png"));
2689        m.add(ManifestEntry::optional("sounds/bg.wav"));
2690        assert_eq!(m.len(), 2);
2691        assert_eq!(m.required_entries().count(), 1);
2692    }
2693
2694    #[test]
2695    fn manifest_tag_filter() {
2696        let mut m = AssetManifest::new("lvl");
2697        m.add(ManifestEntry::required("a.png").with_tag("ui"));
2698        m.add(ManifestEntry::optional("b.png").with_tag("world"));
2699        m.add(ManifestEntry::optional("c.png").with_tag("ui"));
2700        assert_eq!(m.entries_with_tag("ui").count(), 2);
2701        assert_eq!(m.entries_with_tag("world").count(), 1);
2702    }
2703
2704    // ── ImageAsset tests ────────────────────────────────────────────────────
2705
2706    #[test]
2707    fn image_solid_color() {
2708        let img = ImageAsset::solid_color(2, 2, [255, 0, 0, 255]);
2709        assert_eq!(img.width, 2);
2710        assert_eq!(img.height, 2);
2711        assert_eq!(img.data.len(), 16);
2712        assert_eq!(img.data[0], 255);
2713        assert_eq!(img.data[1], 0);
2714    }
2715
2716    #[test]
2717    fn mipmap_processor() {
2718        let img = ImageAsset::solid_color(4, 4, [128, 64, 32, 255]);
2719        let mut img = img;
2720        let proc = MipMapGenerator;
2721        proc.process(&mut img, &AssetPath::new("test.png")).unwrap();
2722        // 4x4 → 2x2 → 1x1 = 2 mip levels
2723        assert_eq!(img.mip_levels.len(), 2);
2724        assert_eq!(img.mip_levels[0].len(), 2 * 2 * 4); // 2x2 RGBA
2725    }
2726
2727    // ── SoundAsset tests ────────────────────────────────────────────────────
2728
2729    #[test]
2730    fn sound_duration() {
2731        let snd = SoundAsset {
2732            sample_rate: 44100,
2733            channels: 1,
2734            samples: vec![0.0f32; 44100],
2735            loop_start: None,
2736            loop_end: None,
2737        };
2738        assert!((snd.duration_secs() - 1.0).abs() < 1e-4);
2739    }
2740
2741    #[test]
2742    fn audio_normalizer() {
2743        let mut snd = SoundAsset {
2744            sample_rate: 44100,
2745            channels: 1,
2746            samples: vec![0.5f32, -0.5, 0.25],
2747            loop_start: None,
2748            loop_end: None,
2749        };
2750        let norm = AudioNormalizer;
2751        norm.process(&mut snd, &AssetPath::new("test.wav")).unwrap();
2752        assert!((snd.samples[0] - 1.0).abs() < 1e-5);
2753    }
2754
2755    // ── ShaderAsset tests ───────────────────────────────────────────────────
2756
2757    #[test]
2758    fn shader_line_count() {
2759        let src = "void main() {\n    gl_Position = vec4(0);\n}\n";
2760        let shader = ShaderAsset::new("test", src, ShaderStage::Vertex);
2761        assert_eq!(shader.line_count(), 3);
2762    }
2763
2764    // ── ScriptAsset tests ───────────────────────────────────────────────────
2765
2766    #[test]
2767    fn script_byte_len() {
2768        let s = ScriptAsset::new("test", "print('hello')");
2769        assert_eq!(s.byte_len(), 14);
2770    }
2771
2772    // ── MeshAsset tests ─────────────────────────────────────────────────────
2773
2774    #[test]
2775    fn mesh_aabb() {
2776        let mut mesh = MeshAsset {
2777            vertices: vec![
2778                Vertex { position: [-1.0, 0.0, 0.0], ..Default::default() },
2779                Vertex { position: [1.0, 2.0, 3.0], ..Default::default() },
2780            ],
2781            indices: vec![0, 1, 0],
2782            material: None,
2783            aabb: None,
2784        };
2785        mesh.compute_aabb();
2786        let (min, max) = mesh.aabb.unwrap();
2787        assert_eq!(min, [-1.0, 0.0, 0.0]);
2788        assert_eq!(max, [1.0, 2.0, 3.0]);
2789    }
2790
2791    #[test]
2792    fn mesh_triangle_count() {
2793        let mesh = MeshAsset {
2794            vertices: vec![Vertex::default(); 3],
2795            indices: vec![0, 1, 2, 0, 2, 1],
2796            material: None,
2797            aabb: None,
2798        };
2799        assert_eq!(mesh.triangle_count(), 2);
2800    }
2801
2802    // ── SceneAsset tests ────────────────────────────────────────────────────
2803
2804    #[test]
2805    fn scene_find_entity() {
2806        let mut scene = SceneAsset::empty("test_scene");
2807        scene.entities.push(SceneEntity::new("player"));
2808        assert!(scene.find_entity("player").is_some());
2809        assert!(scene.find_entity("enemy").is_none());
2810    }
2811
2812    // ── AssetServer integration tests ───────────────────────────────────────
2813
2814    #[test]
2815    fn server_insert_and_load_state() {
2816        let mut server = AssetServer::new();
2817        server.register_loader::<ImageAsset, _>(RawImageLoader);
2818
2819        let img = ImageAsset::solid_color(8, 8, [0, 255, 0, 255]);
2820        let handle = server.insert::<ImageAsset>("generated/green.png", img);
2821
2822        assert_eq!(server.load_state(&handle), LoadState::Loaded);
2823    }
2824
2825    #[test]
2826    fn server_default_asset_server() {
2827        let server = default_asset_server();
2828        assert_eq!(server.asset_count(), 0);
2829        assert!(server.is_idle());
2830    }
2831
2832    #[test]
2833    fn server_pack_load() {
2834        let files: &[(&str, &[u8])] = &[("shaders/quad.vert", b"// vert shader")];
2835        let pack_bytes = AssetPack::build("shaders", files);
2836        let pack = AssetPack::from_bytes("shaders", pack_bytes).unwrap();
2837
2838        let mut server = AssetServer::new_with_config(AssetServerConfig {
2839            root_dir: PathBuf::from("nonexistent"),
2840            ..Default::default()
2841        });
2842        server.register_loader::<ShaderAsset, _>(PlainTextShaderLoader);
2843        server.mount_pack(pack);
2844
2845        let result = server.read_bytes(&AssetPath::new("shaders/quad.vert"));
2846        assert!(result.is_ok());
2847        let (data, _) = result.unwrap();
2848        assert_eq!(data, b"// vert shader");
2849    }
2850
2851    #[test]
2852    fn server_load_enqueues_request() {
2853        let mut server = AssetServer::new();
2854        server.register_loader::<ImageAsset, _>(RawImageLoader);
2855        let _handle = server.load::<ImageAsset>(AssetPath::new("test.png"));
2856        // The request is in the queue, not yet processed
2857        assert!(!server.is_idle());
2858    }
2859
2860    #[test]
2861    fn server_get_returns_none_before_update() {
2862        let mut server = AssetServer::new();
2863        server.register_loader::<ImageAsset, _>(RawImageLoader);
2864        let handle = server.load::<ImageAsset>(AssetPath::new("test.png"));
2865        // Not yet processed
2866        assert!(server.get(&handle).is_none());
2867    }
2868
2869    #[test]
2870    fn streaming_priority_upgrade() {
2871        let mut sm = StreamingManager::new(10);
2872        sm.enqueue(StreamRequest {
2873            id: 1,
2874            path: AssetPath::new("a.png"),
2875            type_id: TypeId::of::<ImageAsset>(),
2876            priority: StreamPriority::Low,
2877            enqueued_at: Instant::now(),
2878        });
2879        // Enqueue same ID with higher priority — should upgrade
2880        sm.enqueue(StreamRequest {
2881            id: 1,
2882            path: AssetPath::new("a.png"),
2883            type_id: TypeId::of::<ImageAsset>(),
2884            priority: StreamPriority::Critical,
2885            enqueued_at: Instant::now(),
2886        });
2887        assert_eq!(sm.pending(), 1);
2888        let drained = sm.drain();
2889        assert_eq!(drained[0].priority, StreamPriority::Critical);
2890    }
2891
2892    #[test]
2893    fn pixel_format_bytes_per_pixel() {
2894        assert_eq!(PixelFormat::R8.bytes_per_pixel(), 1);
2895        assert_eq!(PixelFormat::Rgba8.bytes_per_pixel(), 4);
2896        assert_eq!(PixelFormat::Rgba32F.bytes_per_pixel(), 16);
2897    }
2898
2899    #[test]
2900    fn font_asset_glyph_fallback() {
2901        let mut font = FontAsset {
2902            name: "test".into(),
2903            glyphs: HashMap::new(),
2904            metrics: FontMetrics::default(),
2905            atlas: None,
2906        };
2907        font.glyphs.insert('?', GlyphData {
2908            codepoint: '?',
2909            advance_width: 500.0,
2910            bounds: (0.0, 0.0, 500.0, 700.0),
2911            atlas_uv: None,
2912            outline: Vec::new(),
2913        });
2914        assert!(font.glyph('A').is_none()); // no fallback without '?'... wait
2915        // Actually glyph('A') falls back to '?' when 'A' is not present
2916        assert!(font.glyph('A').is_some());
2917        assert_eq!(font.glyph('A').unwrap().advance_width, 500.0);
2918    }
2919
2920    #[test]
2921    fn material_param_variants() {
2922        let m = MaterialAsset::default();
2923        assert!(matches!(m.roughness, MaterialParam::Value(_)));
2924        let tex = MaterialParam::Texture(AssetPath::new("rough.png"));
2925        assert!(matches!(tex, MaterialParam::Texture(_)));
2926    }
2927}