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 image files: PNG, JPEG, BMP, TGA and GIF, plus the engine's
1695/// own raw `RIMG` format.
1696///
1697/// Real image formats are decoded by the [`image`] crate into RGBA8, top
1698/// row first. A file that cannot be decoded is a load error, not a silent
1699/// placeholder, so a broken texture shows up in the asset server's state.
1700///
1701/// `RIMG` is a 12-byte header,
1702/// `[4 bytes "RIMG"] [4 bytes width LE u32] [4 bytes height LE u32]`,
1703/// followed by `width * height * 4` raw RGBA bytes.
1704pub struct RawImageLoader;
1705
1706impl AssetLoader<ImageAsset> for RawImageLoader {
1707    fn load(&self, bytes: &[u8], path: &AssetPath) -> Result<ImageAsset, String> {
1708        if bytes.len() >= 12 && &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 as usize) * (height as usize) * 4;
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        let (width, height, data) = crate::export::load_rgba(bytes)
1724            .map_err(|e| format!("{path}: {e}"))?;
1725        Ok(ImageAsset { width, height, format: PixelFormat::Rgba8, data, mip_levels: Vec::new() })
1726    }
1727
1728    fn extensions(&self) -> &[&str] {
1729        &["rimg", "png", "jpg", "jpeg", "bmp", "tga", "gif"]
1730    }
1731}
1732
1733// ── PlainTextShaderLoader ─────────────────────────────────────────────────────
1734
1735/// Loader for plain-text shader source files.
1736///
1737/// The stage is inferred from the file extension.
1738pub struct PlainTextShaderLoader;
1739
1740impl AssetLoader<ShaderAsset> for PlainTextShaderLoader {
1741    fn load(&self, bytes: &[u8], path: &AssetPath) -> Result<ShaderAsset, String> {
1742        let source = std::str::from_utf8(bytes)
1743            .map_err(|e| format!("{path}: invalid UTF-8: {e}"))?
1744            .to_owned();
1745
1746        let stage = match path.extension().as_deref() {
1747            Some("vert") => ShaderStage::Vertex,
1748            Some("frag") => ShaderStage::Fragment,
1749            Some("comp") => ShaderStage::Compute,
1750            Some("geom") => ShaderStage::Geometry,
1751            _ => ShaderStage::Fragment,
1752        };
1753
1754        let name = path
1755            .path()
1756            .file_stem()
1757            .and_then(|s| s.to_str())
1758            .unwrap_or("unknown")
1759            .to_owned();
1760
1761        Ok(ShaderAsset::new(name, source, stage))
1762    }
1763
1764    fn extensions(&self) -> &[&str] {
1765        &["glsl", "vert", "frag", "comp", "geom", "wgsl", "hlsl"]
1766    }
1767}
1768
1769// ── PlainTextScriptLoader ─────────────────────────────────────────────────────
1770
1771/// Loader for plain-text script files.
1772pub struct PlainTextScriptLoader;
1773
1774impl AssetLoader<ScriptAsset> for PlainTextScriptLoader {
1775    fn load(&self, bytes: &[u8], path: &AssetPath) -> Result<ScriptAsset, String> {
1776        let source = std::str::from_utf8(bytes)
1777            .map_err(|e| format!("{path}: invalid UTF-8: {e}"))?
1778            .to_owned();
1779        let name = path
1780            .path()
1781            .file_stem()
1782            .and_then(|s| s.to_str())
1783            .unwrap_or("script")
1784            .to_owned();
1785        let language = path.extension();
1786        Ok(ScriptAsset { name, source, language })
1787    }
1788
1789    fn extensions(&self) -> &[&str] {
1790        &["lua", "rhai", "wren", "js", "py", "script"]
1791    }
1792}
1793
1794// ── RawSoundLoader ───────────────────────────────────────────────────────────
1795
1796/// Loader for sound files: WAV (any PCM bit depth, or 32-bit float), read
1797/// by the [`hound`] crate, plus the engine's own raw `RSND` format.
1798///
1799/// `RSND` is `[4 bytes "RSND"] [4 bytes sample_rate LE u32]
1800/// [2 bytes channels LE u16] [2 bytes padding]` followed by little-endian
1801/// f32 samples.
1802///
1803/// Anything else is a load error. This loader used to list ogg, mp3 and
1804/// flac too and hand back a second of silence for them, which hid the
1805/// problem instead of reporting it.
1806pub struct RawSoundLoader;
1807
1808impl AssetLoader<SoundAsset> for RawSoundLoader {
1809    fn load(&self, bytes: &[u8], path: &AssetPath) -> Result<SoundAsset, String> {
1810        if bytes.len() >= 4 && &bytes[0..4] == b"RIFF" {
1811            let clip = crate::audio::wav::read_wav(bytes).map_err(|e| format!("{path}: {e}"))?;
1812            return Ok(SoundAsset {
1813                sample_rate: clip.sample_rate,
1814                channels: clip.channels,
1815                samples: clip.samples,
1816                loop_start: None,
1817                loop_end: None,
1818            });
1819        }
1820        if bytes.len() < 12 || &bytes[0..4] != b"RSND" {
1821            return Err(format!("{path}: not a WAV or RSND file"));
1822        }
1823        let sample_rate = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
1824        let channels = u16::from_le_bytes(bytes[8..10].try_into().unwrap());
1825        let sample_bytes = &bytes[12..];
1826        if sample_bytes.len() % 4 != 0 {
1827            return Err(format!("{path}: sample data not aligned to 4 bytes"));
1828        }
1829        let samples: Vec<f32> = sample_bytes
1830            .chunks_exact(4)
1831            .map(|c| f32::from_le_bytes(c.try_into().unwrap()))
1832            .collect();
1833        Ok(SoundAsset { sample_rate, channels, samples, loop_start: None, loop_end: None })
1834    }
1835
1836    fn extensions(&self) -> &[&str] {
1837        &["rsnd", "wav"]
1838    }
1839}
1840
1841// ────────────────────────────────────────────────────────────────────────────
1842// Section 15 — Built-in Processors
1843// ────────────────────────────────────────────────────────────────────────────
1844
1845/// Generates box-filtered mip-maps for [`ImageAsset`] data.
1846///
1847/// Each mip level is half the size of the previous, stopping at 1x1.
1848/// Only RGBA8 images are supported; others pass through unchanged.
1849pub struct MipMapGenerator;
1850
1851impl AssetProcessor<ImageAsset> for MipMapGenerator {
1852    fn process(&self, asset: &mut ImageAsset, _path: &AssetPath) -> Result<(), String> {
1853        if asset.format != PixelFormat::Rgba8 {
1854            return Ok(());
1855        }
1856        let mut src_w = asset.width as usize;
1857        let mut src_h = asset.height as usize;
1858        let mut src_data = asset.data.clone();
1859
1860        while src_w > 1 || src_h > 1 {
1861            let dst_w = (src_w / 2).max(1);
1862            let dst_h = (src_h / 2).max(1);
1863            let mut dst_data = vec![0u8; dst_w * dst_h * 4];
1864
1865            for y in 0..dst_h {
1866                for x in 0..dst_w {
1867                    let src_x = (x * 2).min(src_w - 1);
1868                    let src_y = (y * 2).min(src_h - 1);
1869                    let nx = (src_x + 1).min(src_w - 1);
1870                    let ny = (src_y + 1).min(src_h - 1);
1871
1872                    let p = |py: usize, px: usize| -> [u8; 4] {
1873                        let off = (py * src_w + px) * 4;
1874                        src_data[off..off + 4].try_into().unwrap()
1875                    };
1876                    let p00 = p(src_y, src_x);
1877                    let p01 = p(src_y, nx);
1878                    let p10 = p(ny, src_x);
1879                    let p11 = p(ny, nx);
1880
1881                    let off = (y * dst_w + x) * 4;
1882                    for c in 0..4 {
1883                        dst_data[off + c] = (
1884                            (p00[c] as u32 + p01[c] as u32 + p10[c] as u32 + p11[c] as u32) / 4
1885                        ) as u8;
1886                    }
1887                }
1888            }
1889
1890            asset.mip_levels.push(dst_data.clone());
1891            src_data = dst_data;
1892            src_w = dst_w;
1893            src_h = dst_h;
1894        }
1895        Ok(())
1896    }
1897
1898    fn name(&self) -> &str {
1899        "MipMapGenerator"
1900    }
1901}
1902
1903/// Normalises audio samples to the range `[-1.0, 1.0]`.
1904pub struct AudioNormalizer;
1905
1906impl AssetProcessor<SoundAsset> for AudioNormalizer {
1907    fn process(&self, asset: &mut SoundAsset, _path: &AssetPath) -> Result<(), String> {
1908        let peak = asset.samples.iter().copied().map(f32::abs).fold(0.0f32, f32::max);
1909        if peak > 0.0 && (peak - 1.0).abs() > 1e-6 {
1910            for s in &mut asset.samples {
1911                *s /= peak;
1912            }
1913        }
1914        Ok(())
1915    }
1916
1917    fn name(&self) -> &str {
1918        "AudioNormalizer"
1919    }
1920}
1921
1922// ────────────────────────────────────────────────────────────────────────────
1923// Section 16 — AssetServer
1924// ────────────────────────────────────────────────────────────────────────────
1925
1926/// Configuration for [`AssetServer`].
1927#[derive(Debug, Clone)]
1928pub struct AssetServerConfig {
1929    /// Root directory for asset files on disk.
1930    pub root_dir: PathBuf,
1931    /// LRU cache capacity (0 = unlimited).
1932    pub cache_capacity: usize,
1933    /// How many streaming requests to process per `update()` call.
1934    pub stream_batch_size: usize,
1935    /// Enable hot-reload (file-change polling).
1936    pub hot_reload: bool,
1937    /// How often to poll for file changes.
1938    pub hot_reload_interval: Duration,
1939}
1940
1941impl Default for AssetServerConfig {
1942    fn default() -> Self {
1943        Self {
1944            root_dir: PathBuf::from("assets"),
1945            cache_capacity: 512,
1946            stream_batch_size: 8,
1947            hot_reload: cfg!(debug_assertions),
1948            hot_reload_interval: Duration::from_secs(1),
1949        }
1950    }
1951}
1952
1953/// Type-erased loader function stored inside [`AssetServer`].
1954type ErasedLoadFn = Box<dyn Fn(&[u8], &AssetPath) -> Result<Box<dyn Any + Send + Sync>, String> + Send + Sync>;
1955
1956/// Type-erased store-into-registry function.
1957type ErasedStoreFn = Box<dyn Fn(&mut AssetRegistry, u64, Box<dyn Any + Send + Sync>, Option<SystemTime>) + Send + Sync>;
1958
1959struct LoaderEntry {
1960    extensions: Vec<String>,
1961    type_id: TypeId,
1962    load: ErasedLoadFn,
1963    store: ErasedStoreFn,
1964}
1965
1966/// The central asset server: coordinates loading, caching, hot-reload, and streaming.
1967///
1968/// # Lifecycle
1969///
1970/// 1. Register loaders with [`register_loader`](AssetServer::register_loader).
1971/// 2. Request assets with [`load`](AssetServer::load) or [`load_manifest`](AssetServer::load_manifest).
1972/// 3. Call [`update`](AssetServer::update) each frame to process the streaming queue and hot-reload.
1973/// 4. Retrieve loaded data via [`get`](AssetServer::get).
1974pub struct AssetServer {
1975    config: AssetServerConfig,
1976    registry: AssetRegistry,
1977    cache: AssetCache,
1978    hot_reload: HotReload,
1979    streaming: StreamingManager,
1980    loaders: Vec<LoaderEntry>,
1981    packs: Vec<AssetPack>,
1982    /// Per-id typed handle storage: id -> Arc<RwLock<Option`<T>`>> erased as Box`<dyn Any>`
1983    typed_slots: HashMap<u64, Box<dyn Any + Send + Sync>>,
1984    /// Statistics.
1985    stats: AssetServerStats,
1986}
1987
1988/// Runtime statistics for the asset server.
1989#[derive(Debug, Clone, Default)]
1990pub struct AssetServerStats {
1991    /// Total assets loaded from disk since server creation.
1992    pub loads_from_disk: u64,
1993    /// Total assets loaded from an [`AssetPack`] since server creation.
1994    pub loads_from_pack: u64,
1995    /// Total bytes read from disk.
1996    pub bytes_read: u64,
1997    /// Total assets evicted from the LRU cache.
1998    pub evictions: u64,
1999    /// Total hot-reload events processed.
2000    pub hot_reloads: u64,
2001    /// Total load failures.
2002    pub failures: u64,
2003}
2004
2005impl AssetServer {
2006    /// Create a new asset server with the given configuration.
2007    pub fn new_with_config(config: AssetServerConfig) -> Self {
2008        let cache = AssetCache::new(config.cache_capacity);
2009        let hot_reload = HotReload::new(config.hot_reload_interval, config.hot_reload);
2010        let streaming = StreamingManager::new(config.stream_batch_size);
2011        Self {
2012            config,
2013            registry: AssetRegistry::new(),
2014            cache,
2015            hot_reload,
2016            streaming,
2017            loaders: Vec::new(),
2018            packs: Vec::new(),
2019            typed_slots: HashMap::new(),
2020            stats: AssetServerStats::default(),
2021        }
2022    }
2023
2024    /// Create a new asset server with default configuration.
2025    pub fn new() -> Self {
2026        Self::new_with_config(AssetServerConfig::default())
2027    }
2028
2029    /// Change the root asset directory.
2030    pub fn set_root_dir(&mut self, dir: impl Into<PathBuf>) {
2031        self.config.root_dir = dir.into();
2032    }
2033
2034    /// Register a loader for asset type `A`.
2035    ///
2036    /// The loader's `extensions()` are used to match file paths.
2037    /// If multiple loaders claim the same extension, the most recently
2038    /// registered one takes precedence.
2039    pub fn register_loader<A: Asset, L: AssetLoader<A>>(&mut self, loader: L) {
2040        let extensions: Vec<String> = loader.extensions().iter().map(|e| e.to_string()).collect();
2041        let type_id = TypeId::of::<A>();
2042        let loader = Arc::new(loader);
2043
2044        let load_loader = Arc::clone(&loader);
2045        let load: ErasedLoadFn = Box::new(move |bytes, path| {
2046            load_loader
2047                .load(bytes, path)
2048                .map(|a| Box::new(a) as Box<dyn Any + Send + Sync>)
2049        });
2050
2051        let store: ErasedStoreFn = Box::new(|registry, id, boxed, mtime| {
2052            if let Ok(asset) = boxed.downcast::<A>() {
2053                registry.store::<A>(id, *asset, mtime);
2054            }
2055        });
2056
2057        self.loaders.push(LoaderEntry { extensions, type_id, load, store });
2058    }
2059
2060    /// Mount an [`AssetPack`]. Files in packs are resolved before the file-system.
2061    pub fn mount_pack(&mut self, pack: AssetPack) {
2062        self.packs.push(pack);
2063    }
2064
2065    /// Request that `path` be loaded as asset type `A`.
2066    ///
2067    /// Returns an [`AssetHandle<A>`] immediately. The handle will become
2068    /// populated after [`update`](AssetServer::update) processes the request.
2069    ///
2070    /// If the asset is already loaded, a handle to the existing data is returned.
2071    pub fn load<A: Asset>(&mut self, path: impl Into<AssetPath>) -> AssetHandle<A> {
2072        let path = path.into();
2073        let type_id = TypeId::of::<A>();
2074
2075        if let Some(id) = self.registry.id_for_path(&path) {
2076            return self.make_handle::<A>(id);
2077        }
2078
2079        let id = self.registry.alloc(type_id, path.clone());
2080        self.registry.mark_loading(id);
2081
2082        let arc: Arc<RwLock<Option<A>>> = Arc::new(RwLock::new(None));
2083        self.typed_slots.insert(id, Box::new(Arc::clone(&arc)));
2084
2085        self.streaming.enqueue(StreamRequest {
2086            id,
2087            path,
2088            type_id,
2089            priority: StreamPriority::Normal,
2090            enqueued_at: Instant::now(),
2091        });
2092
2093        AssetHandle::new(AssetId::new(id), arc)
2094    }
2095
2096    /// Like [`load`](AssetServer::load) but with an explicit priority.
2097    pub fn load_with_priority<A: Asset>(
2098        &mut self,
2099        path: impl Into<AssetPath>,
2100        priority: StreamPriority,
2101    ) -> AssetHandle<A> {
2102        let path = path.into();
2103        let type_id = TypeId::of::<A>();
2104
2105        if let Some(id) = self.registry.id_for_path(&path) {
2106            return self.make_handle::<A>(id);
2107        }
2108
2109        let id = self.registry.alloc(type_id, path.clone());
2110        self.registry.mark_loading(id);
2111
2112        let arc: Arc<RwLock<Option<A>>> = Arc::new(RwLock::new(None));
2113        self.typed_slots.insert(id, Box::new(Arc::clone(&arc)));
2114
2115        self.streaming.enqueue(StreamRequest {
2116            id,
2117            path,
2118            type_id,
2119            priority,
2120            enqueued_at: Instant::now(),
2121        });
2122
2123        AssetHandle::new(AssetId::new(id), arc)
2124    }
2125
2126    /// Queue all assets declared in `manifest` for loading.
2127    ///
2128    /// Callers should subsequently call typed `load::<T>()` for each entry to
2129    /// obtain typed handles; this method ensures the paths are pre-registered
2130    /// at the declared priorities.
2131    pub fn load_manifest(&mut self, manifest: &AssetManifest) {
2132        for entry in &manifest.entries {
2133            let path = entry.path.clone();
2134            let priority = entry.priority;
2135
2136            if self.registry.id_for_path(&path).is_none() {
2137                // Placeholder type — real typed loads will overwrite if needed
2138                let id = self.registry.alloc(TypeId::of::<ScriptAsset>(), path.clone());
2139                self.registry.mark_loading(id);
2140                self.streaming.enqueue(StreamRequest {
2141                    id,
2142                    path,
2143                    type_id: TypeId::of::<ScriptAsset>(),
2144                    priority,
2145                    enqueued_at: Instant::now(),
2146                });
2147            }
2148        }
2149    }
2150
2151    /// Drive the asset server for one frame.
2152    ///
2153    /// This processes pending streaming requests (up to `stream_batch_size` per
2154    /// call) and polls the hot-reload watcher. Call once per frame.
2155    pub fn update(&mut self) {
2156        let batch = self.streaming.drain();
2157        for req in batch {
2158            self.execute_load(req);
2159        }
2160
2161        let changed = self.hot_reload.poll();
2162        for (_path, asset_ids) in changed {
2163            for id in asset_ids {
2164                self.enqueue_reload(id);
2165            }
2166        }
2167    }
2168
2169    /// Try to get the data for `handle`.
2170    ///
2171    /// Returns `None` if the asset is not yet loaded or has been evicted.
2172    pub fn get<A: Asset>(&mut self, handle: &AssetHandle<A>) -> Option<Arc<A>> {
2173        let id = handle.id().raw();
2174        let arc = self.registry.get::<A>(id)?;
2175
2176        if let Some(evict_id) = self.cache.touch(id) {
2177            self.registry.evict(evict_id);
2178            self.cache.remove(evict_id);
2179            self.typed_slots.remove(&evict_id);
2180            self.stats.evictions += 1;
2181        }
2182
2183        Some(arc)
2184    }
2185
2186    /// Get the [`LoadState`] of `handle`.
2187    pub fn load_state<A: Asset>(&self, handle: &AssetHandle<A>) -> LoadState {
2188        self.registry.load_state(handle.id().raw())
2189    }
2190
2191    /// Force an immediate synchronous reload of an asset by handle.
2192    pub fn reload<A: Asset>(&mut self, handle: &AssetHandle<A>) {
2193        let id = handle.id().raw();
2194        self.enqueue_reload(id);
2195        // Drain immediately so the reload happens synchronously
2196        let batch = self.streaming.drain();
2197        for req in batch {
2198            self.execute_load(req);
2199        }
2200    }
2201
2202    /// Insert an already-constructed asset directly into the registry.
2203    ///
2204    /// Returns a handle to the inserted asset. Useful for procedurally generated
2205    /// assets that have no backing file.
2206    pub fn insert<A: Asset>(&mut self, path: impl Into<AssetPath>, asset: A) -> AssetHandle<A> {
2207        let path = path.into();
2208        let type_id = TypeId::of::<A>();
2209
2210        let id = if let Some(existing_id) = self.registry.id_for_path(&path) {
2211            existing_id
2212        } else {
2213            self.registry.alloc(type_id, path)
2214        };
2215
2216        self.registry.store::<A>(id, asset, None);
2217
2218        // Build the typed Arc slot
2219        let arc: Arc<RwLock<Option<A>>> = Arc::new(RwLock::new(None));
2220        // Populate from registry
2221        if let Some(value_arc) = self.registry.get::<A>(id) {
2222            // We can't move out of Arc<T>, but we can clone if T: Clone.
2223            // Instead, store None in the handle — callers use server.get() for Arc<T>.
2224            // The handle's inner Arc is a separate slot; populate it by writing.
2225            // Since A: Asset (not Clone), we leave the handle slot empty and
2226            // direct callers to use server.get() which returns Arc<A> from registry.
2227            let _ = value_arc;
2228        }
2229        self.typed_slots.insert(id, Box::new(Arc::clone(&arc)));
2230
2231        if let Some(evict_id) = self.cache.touch(id) {
2232            self.registry.evict(evict_id);
2233            self.cache.remove(evict_id);
2234            self.typed_slots.remove(&evict_id);
2235            self.stats.evictions += 1;
2236        }
2237
2238        AssetHandle::new(AssetId::new(id), arc)
2239    }
2240
2241    /// Return a snapshot of the current server statistics.
2242    pub fn stats(&self) -> &AssetServerStats {
2243        &self.stats
2244    }
2245
2246    /// Number of assets currently tracked by the registry.
2247    pub fn asset_count(&self) -> usize {
2248        self.registry.len()
2249    }
2250
2251    /// `true` if all streaming requests have been processed.
2252    pub fn is_idle(&self) -> bool {
2253        self.streaming.is_idle()
2254    }
2255
2256    /// Read bytes for a given path, checking packs first then the file-system.
2257    /// Exposed as `pub` for testing and pack inspection utilities.
2258    pub fn read_bytes(&mut self, path: &AssetPath) -> Result<(Vec<u8>, Option<SystemTime>), String> {
2259        let virtual_str = path.path().to_string_lossy().replace('\\', "/");
2260
2261        for pack in &self.packs {
2262            if let Some(data) = pack.read(&virtual_str) {
2263                self.stats.loads_from_pack += 1;
2264                self.stats.bytes_read += data.len() as u64;
2265                return Ok((data.to_vec(), None));
2266            }
2267        }
2268
2269        let full_path = self.config.root_dir.join(path.path());
2270        let mtime = std::fs::metadata(&full_path)
2271            .and_then(|m| m.modified())
2272            .ok();
2273        let data = std::fs::read(&full_path)
2274            .map_err(|e| format!("failed to read {}: {e}", full_path.display()))?;
2275        self.stats.loads_from_disk += 1;
2276        self.stats.bytes_read += data.len() as u64;
2277        Ok((data, mtime))
2278    }
2279
2280    // ── Private helpers ───────────────────────────────────────────────────
2281
2282    /// Construct a handle referencing an existing slot (already allocated).
2283    fn make_handle<A: Asset>(&mut self, id: u64) -> AssetHandle<A> {
2284        // If a typed slot exists and is the right type, reuse it
2285        if let Some(boxed) = self.typed_slots.get(&id) {
2286            if let Some(arc) = boxed.downcast_ref::<Arc<RwLock<Option<A>>>>() {
2287                return AssetHandle::new(AssetId::new(id), Arc::clone(arc));
2288            }
2289        }
2290        // Create a fresh typed slot
2291        let arc: Arc<RwLock<Option<A>>> = Arc::new(RwLock::new(None));
2292        self.typed_slots.insert(id, Box::new(Arc::clone(&arc)));
2293        AssetHandle::new(AssetId::new(id), arc)
2294    }
2295
2296    /// Execute a single load request synchronously.
2297    fn execute_load(&mut self, req: StreamRequest) {
2298        let path = req.path.clone();
2299        let id = req.id;
2300        let ext = path.extension().unwrap_or_default();
2301
2302        // Find a matching loader by type + extension
2303        let loader_idx = self.loaders.iter().rposition(|l| {
2304            l.type_id == req.type_id && l.extensions.iter().any(|e| e == &ext)
2305        }).or_else(|| {
2306            // Fallback: any loader for this type regardless of extension
2307            self.loaders.iter().rposition(|l| l.type_id == req.type_id)
2308        });
2309
2310        let loader_idx = match loader_idx {
2311            Some(i) => i,
2312            None => {
2313                let msg = format!("no loader for extension '{ext}'");
2314                self.registry.mark_failed(id, msg);
2315                self.stats.failures += 1;
2316                return;
2317            }
2318        };
2319
2320        let (bytes, mtime) = match self.read_bytes(&path) {
2321            Ok(b) => b,
2322            Err(e) => {
2323                self.registry.mark_failed(id, e);
2324                self.stats.failures += 1;
2325                return;
2326            }
2327        };
2328
2329        let loaded = (self.loaders[loader_idx].load)(&bytes, &path);
2330        match loaded {
2331            Ok(boxed) => {
2332                (self.loaders[loader_idx].store)(&mut self.registry, id, boxed, mtime);
2333                if mtime.is_some() {
2334                    let disk_path = self.config.root_dir.join(path.path());
2335                    self.hot_reload.watch(disk_path, id, mtime);
2336                }
2337                if let Some(evict_id) = self.cache.touch(id) {
2338                    self.registry.evict(evict_id);
2339                    self.cache.remove(evict_id);
2340                    self.typed_slots.remove(&evict_id);
2341                    self.stats.evictions += 1;
2342                }
2343            }
2344            Err(msg) => {
2345                self.registry.mark_failed(id, msg);
2346                self.stats.failures += 1;
2347            }
2348        }
2349    }
2350
2351    /// Enqueue a reload of the asset with the given ID.
2352    fn enqueue_reload(&mut self, id: u64) {
2353        let path_opt = self.registry.path_for_id(id).cloned();
2354        let type_id_opt = self.registry.type_id_for(id);
2355        if let (Some(path), Some(type_id)) = (path_opt, type_id_opt) {
2356            self.registry.mark_loading(id);
2357            self.streaming.enqueue(StreamRequest {
2358                id,
2359                path,
2360                type_id,
2361                priority: StreamPriority::High,
2362                enqueued_at: Instant::now(),
2363            });
2364            self.stats.hot_reloads += 1;
2365        }
2366    }
2367}
2368
2369impl Default for AssetServer {
2370    fn default() -> Self {
2371        Self::new()
2372    }
2373}
2374
2375impl fmt::Debug for AssetServer {
2376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2377        f.debug_struct("AssetServer")
2378            .field("assets", &self.registry.len())
2379            .field("pending", &self.streaming.pending())
2380            .field("cache", &self.cache)
2381            .field("hot_reload", &self.hot_reload)
2382            .field("stats", &self.stats)
2383            .finish()
2384    }
2385}
2386
2387// ────────────────────────────────────────────────────────────────────────────
2388// Section 17 — Convenience functions
2389// ────────────────────────────────────────────────────────────────────────────
2390
2391/// Build a default `AssetServer` with all built-in loaders registered.
2392///
2393/// This is the quickest way to get a fully configured server.
2394///
2395/// # Example
2396/// ```rust
2397/// use proof_engine::asset::default_asset_server;
2398/// let mut server = default_asset_server();
2399/// ```
2400pub fn default_asset_server() -> AssetServer {
2401    let mut server = AssetServer::new();
2402    server.register_loader::<ImageAsset, _>(RawImageLoader);
2403    server.register_loader::<ShaderAsset, _>(PlainTextShaderLoader);
2404    server.register_loader::<ScriptAsset, _>(PlainTextScriptLoader);
2405    server.register_loader::<SoundAsset, _>(RawSoundLoader);
2406    server
2407}
2408
2409/// Load a file synchronously from disk, returning its bytes.
2410///
2411/// Used internally; also exposed for simple file utilities.
2412pub fn load_file_bytes(path: &Path) -> Result<Vec<u8>, String> {
2413    std::fs::read(path).map_err(|e| format!("load_file_bytes: {e}"))
2414}
2415
2416// ────────────────────────────────────────────────────────────────────────────
2417// Section 18 — Tests
2418// ────────────────────────────────────────────────────────────────────────────
2419
2420#[cfg(test)]
2421mod tests {
2422    use super::*;
2423
2424    // ── AssetPath tests ─────────────────────────────────────────────────────
2425
2426    #[test]
2427    fn asset_path_parse_no_label() {
2428        let p = AssetPath::parse("textures/player.png");
2429        assert_eq!(p.path(), Path::new("textures/player.png"));
2430        assert_eq!(p.label(), None);
2431    }
2432
2433    #[test]
2434    fn asset_path_parse_with_label() {
2435        let p = AssetPath::parse("models/robot.gltf#body");
2436        assert_eq!(p.path(), Path::new("models/robot.gltf"));
2437        assert_eq!(p.label(), Some("body"));
2438    }
2439
2440    #[test]
2441    fn asset_path_extension() {
2442        let p = AssetPath::new("shaders/main.frag");
2443        assert_eq!(p.extension(), Some("frag".to_string()));
2444    }
2445
2446    #[test]
2447    fn asset_path_display() {
2448        let p = AssetPath::with_label("a/b.png", "sub");
2449        assert!(p.to_string().contains('#'));
2450    }
2451
2452    #[test]
2453    fn asset_path_from_str() {
2454        let p: AssetPath = "foo/bar.lua".into();
2455        assert_eq!(p.extension(), Some("lua".to_string()));
2456    }
2457
2458    // ── AssetId tests ───────────────────────────────────────────────────────
2459
2460    #[test]
2461    fn asset_id_equality() {
2462        let a = AssetId::<ImageAsset>::new(42);
2463        let b = AssetId::<ImageAsset>::new(42);
2464        let c = AssetId::<ImageAsset>::new(7);
2465        assert_eq!(a, b);
2466        assert_ne!(a, c);
2467    }
2468
2469    #[test]
2470    fn asset_id_copy() {
2471        let a = AssetId::<ShaderAsset>::new(1);
2472        let b = a;
2473        assert_eq!(a, b);
2474    }
2475
2476    // ── LoadState tests ─────────────────────────────────────────────────────
2477
2478    #[test]
2479    fn load_state_display() {
2480        assert_eq!(LoadState::NotLoaded.to_string(), "NotLoaded");
2481        assert_eq!(LoadState::Loading.to_string(), "Loading");
2482        assert_eq!(LoadState::Loaded.to_string(), "Loaded");
2483        assert!(LoadState::Failed("oops".into()).to_string().contains("oops"));
2484    }
2485
2486    #[test]
2487    fn load_state_equality() {
2488        assert_eq!(LoadState::Loaded, LoadState::Loaded);
2489        assert_ne!(LoadState::Loaded, LoadState::Loading);
2490        assert_eq!(
2491            LoadState::Failed("x".into()),
2492            LoadState::Failed("x".into())
2493        );
2494    }
2495
2496    // ── AssetRegistry tests ─────────────────────────────────────────────────
2497
2498    #[test]
2499    fn registry_alloc_and_lookup() {
2500        let mut reg = AssetRegistry::new();
2501        let path = AssetPath::new("test.png");
2502        let id = reg.alloc(TypeId::of::<ImageAsset>(), path.clone());
2503        assert_eq!(reg.id_for_path(&path), Some(id));
2504        assert_eq!(reg.load_state(id), LoadState::NotLoaded);
2505    }
2506
2507    #[test]
2508    fn registry_store_and_get() {
2509        let mut reg = AssetRegistry::new();
2510        let path = AssetPath::new("solid.png");
2511        let id = reg.alloc(TypeId::of::<ImageAsset>(), path);
2512        let img = ImageAsset::solid_color(4, 4, [0, 0, 0, 255]);
2513        reg.store::<ImageAsset>(id, img, None);
2514        assert_eq!(reg.load_state(id), LoadState::Loaded);
2515        let arc = reg.get::<ImageAsset>(id).unwrap();
2516        assert_eq!(arc.width, 4);
2517    }
2518
2519    #[test]
2520    fn registry_mark_failed() {
2521        let mut reg = AssetRegistry::new();
2522        let id = reg.alloc(TypeId::of::<ImageAsset>(), AssetPath::new("x.png"));
2523        reg.mark_failed(id, "disk error".into());
2524        assert!(matches!(reg.load_state(id), LoadState::Failed(_)));
2525    }
2526
2527    #[test]
2528    fn registry_evict() {
2529        let mut reg = AssetRegistry::new();
2530        let id = reg.alloc(TypeId::of::<ImageAsset>(), AssetPath::new("e.png"));
2531        assert!(reg.evict(id));
2532        assert_eq!(reg.load_state(id), LoadState::NotLoaded);
2533        assert!(!reg.evict(id));
2534    }
2535
2536    // ── AssetCache tests ────────────────────────────────────────────────────
2537
2538    #[test]
2539    fn cache_lru_eviction() {
2540        let mut cache = AssetCache::new(3);
2541        assert_eq!(cache.touch(1), None);
2542        assert_eq!(cache.touch(2), None);
2543        assert_eq!(cache.touch(3), None);
2544        let evicted = cache.touch(4);
2545        assert_eq!(evicted, Some(1));
2546    }
2547
2548    #[test]
2549    fn cache_touch_updates_order() {
2550        let mut cache = AssetCache::new(3);
2551        cache.touch(1);
2552        cache.touch(2);
2553        cache.touch(3);
2554        cache.touch(1); // 1 is now MRU; 2 is LRU
2555        let evicted = cache.touch(4);
2556        assert_eq!(evicted, Some(2));
2557    }
2558
2559    #[test]
2560    fn cache_unlimited() {
2561        let mut cache = AssetCache::new(0);
2562        for i in 0..1000u64 {
2563            assert_eq!(cache.touch(i), None);
2564        }
2565        assert_eq!(cache.len(), 1000);
2566    }
2567
2568    #[test]
2569    fn cache_set_capacity_evicts() {
2570        let mut cache = AssetCache::new(10);
2571        for i in 0..10u64 {
2572            cache.touch(i);
2573        }
2574        let evicted = cache.set_capacity(5);
2575        assert_eq!(evicted.len(), 5);
2576        assert_eq!(cache.len(), 5);
2577    }
2578
2579    // ── HotReload tests ─────────────────────────────────────────────────────
2580
2581    #[test]
2582    fn hot_reload_disabled_returns_empty() {
2583        let mut hr = HotReload::new(Duration::from_secs(1), false);
2584        hr.watch(PathBuf::from("x.png"), 1, None);
2585        let changed = hr.poll();
2586        assert!(changed.is_empty());
2587    }
2588
2589    #[test]
2590    fn hot_reload_watch_count() {
2591        let mut hr = HotReload::new(Duration::from_secs(60), true);
2592        hr.watch(PathBuf::from("a.png"), 1, None);
2593        hr.watch(PathBuf::from("b.png"), 2, None);
2594        assert_eq!(hr.watched_count(), 2);
2595    }
2596
2597    #[test]
2598    fn hot_reload_unwatch() {
2599        let mut hr = HotReload::new(Duration::from_secs(60), true);
2600        hr.watch(PathBuf::from("a.png"), 1, None);
2601        hr.unwatch(1);
2602        assert_eq!(hr.watched_count(), 0);
2603    }
2604
2605    // ── StreamingManager tests ──────────────────────────────────────────────
2606
2607    #[test]
2608    fn streaming_priority_order() {
2609        let mut sm = StreamingManager::new(10);
2610        let make = |id: u64, priority: StreamPriority| StreamRequest {
2611            id,
2612            path: AssetPath::new("x"),
2613            type_id: TypeId::of::<ImageAsset>(),
2614            priority,
2615            enqueued_at: Instant::now(),
2616        };
2617        sm.enqueue(make(1, StreamPriority::Low));
2618        sm.enqueue(make(2, StreamPriority::Critical));
2619        sm.enqueue(make(3, StreamPriority::Normal));
2620
2621        let drained = sm.drain();
2622        assert_eq!(drained[0].id, 2);
2623        assert_eq!(drained[1].id, 3);
2624        assert_eq!(drained[2].id, 1);
2625    }
2626
2627    #[test]
2628    fn streaming_cancel() {
2629        let mut sm = StreamingManager::new(10);
2630        sm.enqueue(StreamRequest {
2631            id: 99,
2632            path: AssetPath::new("y"),
2633            type_id: TypeId::of::<ImageAsset>(),
2634            priority: StreamPriority::Normal,
2635            enqueued_at: Instant::now(),
2636        });
2637        assert_eq!(sm.pending(), 1);
2638        assert!(sm.cancel(99));
2639        assert_eq!(sm.pending(), 0);
2640    }
2641
2642    #[test]
2643    fn streaming_batch_limit() {
2644        let mut sm = StreamingManager::new(2);
2645        for i in 0..5u64 {
2646            sm.enqueue(StreamRequest {
2647                id: i,
2648                path: AssetPath::new("z"),
2649                type_id: TypeId::of::<ImageAsset>(),
2650                priority: StreamPriority::Normal,
2651                enqueued_at: Instant::now(),
2652            });
2653        }
2654        let first = sm.drain();
2655        assert_eq!(first.len(), 2);
2656        assert_eq!(sm.pending(), 3);
2657    }
2658
2659    // ── AssetPack tests ─────────────────────────────────────────────────────
2660
2661    #[test]
2662    fn pack_build_and_read() {
2663        let files: &[(&str, &[u8])] = &[
2664            ("shaders/main.vert", b"void main() {}"),
2665            ("textures/logo.png", &[0u8, 1, 2, 3, 4]),
2666        ];
2667        let bytes = AssetPack::build("test", files);
2668        let pack = AssetPack::from_bytes("test", bytes).expect("parse failed");
2669        assert_eq!(pack.entry_count(), 2);
2670        assert_eq!(pack.read("shaders/main.vert"), Some(b"void main() {}".as_ref()));
2671        assert_eq!(pack.read("textures/logo.png"), Some([0u8, 1, 2, 3, 4].as_ref()));
2672        assert_eq!(pack.read("nonexistent"), None);
2673    }
2674
2675    #[test]
2676    fn pack_invalid_magic() {
2677        let result = AssetPack::from_bytes("bad", b"XXXX\0\0\0\0".to_vec());
2678        assert!(result.is_err());
2679    }
2680
2681    #[test]
2682    fn pack_paths_iterator() {
2683        let files: &[(&str, &[u8])] = &[("a.txt", b"hello"), ("b.txt", b"world")];
2684        let bytes = AssetPack::build("p", files);
2685        let pack = AssetPack::from_bytes("p", bytes).unwrap();
2686        let paths: Vec<_> = pack.paths().collect();
2687        assert!(paths.contains(&"a.txt"));
2688        assert!(paths.contains(&"b.txt"));
2689    }
2690
2691    // ── AssetManifest tests ─────────────────────────────────────────────────
2692
2693    #[test]
2694    fn manifest_required_optional() {
2695        let mut m = AssetManifest::new("level1");
2696        m.add(ManifestEntry::required("textures/floor.png"));
2697        m.add(ManifestEntry::optional("sounds/bg.wav"));
2698        assert_eq!(m.len(), 2);
2699        assert_eq!(m.required_entries().count(), 1);
2700    }
2701
2702    #[test]
2703    fn manifest_tag_filter() {
2704        let mut m = AssetManifest::new("lvl");
2705        m.add(ManifestEntry::required("a.png").with_tag("ui"));
2706        m.add(ManifestEntry::optional("b.png").with_tag("world"));
2707        m.add(ManifestEntry::optional("c.png").with_tag("ui"));
2708        assert_eq!(m.entries_with_tag("ui").count(), 2);
2709        assert_eq!(m.entries_with_tag("world").count(), 1);
2710    }
2711
2712    // ── ImageAsset tests ────────────────────────────────────────────────────
2713
2714    #[test]
2715    fn image_solid_color() {
2716        let img = ImageAsset::solid_color(2, 2, [255, 0, 0, 255]);
2717        assert_eq!(img.width, 2);
2718        assert_eq!(img.height, 2);
2719        assert_eq!(img.data.len(), 16);
2720        assert_eq!(img.data[0], 255);
2721        assert_eq!(img.data[1], 0);
2722    }
2723
2724    #[test]
2725    fn mipmap_processor() {
2726        let img = ImageAsset::solid_color(4, 4, [128, 64, 32, 255]);
2727        let mut img = img;
2728        let proc = MipMapGenerator;
2729        proc.process(&mut img, &AssetPath::new("test.png")).unwrap();
2730        // 4x4 → 2x2 → 1x1 = 2 mip levels
2731        assert_eq!(img.mip_levels.len(), 2);
2732        assert_eq!(img.mip_levels[0].len(), 2 * 2 * 4); // 2x2 RGBA
2733    }
2734
2735    // ── SoundAsset tests ────────────────────────────────────────────────────
2736
2737    #[test]
2738    fn sound_duration() {
2739        let snd = SoundAsset {
2740            sample_rate: 44100,
2741            channels: 1,
2742            samples: vec![0.0f32; 44100],
2743            loop_start: None,
2744            loop_end: None,
2745        };
2746        assert!((snd.duration_secs() - 1.0).abs() < 1e-4);
2747    }
2748
2749    #[test]
2750    fn audio_normalizer() {
2751        let mut snd = SoundAsset {
2752            sample_rate: 44100,
2753            channels: 1,
2754            samples: vec![0.5f32, -0.5, 0.25],
2755            loop_start: None,
2756            loop_end: None,
2757        };
2758        let norm = AudioNormalizer;
2759        norm.process(&mut snd, &AssetPath::new("test.wav")).unwrap();
2760        assert!((snd.samples[0] - 1.0).abs() < 1e-5);
2761    }
2762
2763    // ── ShaderAsset tests ───────────────────────────────────────────────────
2764
2765    #[test]
2766    fn shader_line_count() {
2767        let src = "void main() {\n    gl_Position = vec4(0);\n}\n";
2768        let shader = ShaderAsset::new("test", src, ShaderStage::Vertex);
2769        assert_eq!(shader.line_count(), 3);
2770    }
2771
2772    // ── ScriptAsset tests ───────────────────────────────────────────────────
2773
2774    #[test]
2775    fn script_byte_len() {
2776        let s = ScriptAsset::new("test", "print('hello')");
2777        assert_eq!(s.byte_len(), 14);
2778    }
2779
2780    // ── MeshAsset tests ─────────────────────────────────────────────────────
2781
2782    #[test]
2783    fn mesh_aabb() {
2784        let mut mesh = MeshAsset {
2785            vertices: vec![
2786                Vertex { position: [-1.0, 0.0, 0.0], ..Default::default() },
2787                Vertex { position: [1.0, 2.0, 3.0], ..Default::default() },
2788            ],
2789            indices: vec![0, 1, 0],
2790            material: None,
2791            aabb: None,
2792        };
2793        mesh.compute_aabb();
2794        let (min, max) = mesh.aabb.unwrap();
2795        assert_eq!(min, [-1.0, 0.0, 0.0]);
2796        assert_eq!(max, [1.0, 2.0, 3.0]);
2797    }
2798
2799    #[test]
2800    fn mesh_triangle_count() {
2801        let mesh = MeshAsset {
2802            vertices: vec![Vertex::default(); 3],
2803            indices: vec![0, 1, 2, 0, 2, 1],
2804            material: None,
2805            aabb: None,
2806        };
2807        assert_eq!(mesh.triangle_count(), 2);
2808    }
2809
2810    // ── SceneAsset tests ────────────────────────────────────────────────────
2811
2812    #[test]
2813    fn scene_find_entity() {
2814        let mut scene = SceneAsset::empty("test_scene");
2815        scene.entities.push(SceneEntity::new("player"));
2816        assert!(scene.find_entity("player").is_some());
2817        assert!(scene.find_entity("enemy").is_none());
2818    }
2819
2820    // ── AssetServer integration tests ───────────────────────────────────────
2821
2822    #[test]
2823    fn server_insert_and_load_state() {
2824        let mut server = AssetServer::new();
2825        server.register_loader::<ImageAsset, _>(RawImageLoader);
2826
2827        let img = ImageAsset::solid_color(8, 8, [0, 255, 0, 255]);
2828        let handle = server.insert::<ImageAsset>("generated/green.png", img);
2829
2830        assert_eq!(server.load_state(&handle), LoadState::Loaded);
2831    }
2832
2833    #[test]
2834    fn image_loader_decodes_real_png_and_rimg() {
2835        let px: Vec<u8> = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 9, 9, 9, 128];
2836        let path = std::env::temp_dir().join("proof_engine_asset_loader.png");
2837        crate::export::save_rgba(&path, 2, 2, &px).unwrap();
2838        let bytes = std::fs::read(&path).unwrap();
2839        let img = RawImageLoader.load(&bytes, &AssetPath::new("tex.png")).unwrap();
2840        assert_eq!((img.width, img.height), (2, 2));
2841        assert_eq!(img.format, PixelFormat::Rgba8);
2842        assert_eq!(img.data, px);
2843
2844        let mut rimg = b"RIMG".to_vec();
2845        rimg.extend_from_slice(&1u32.to_le_bytes());
2846        rimg.extend_from_slice(&1u32.to_le_bytes());
2847        rimg.extend_from_slice(&[1, 2, 3, 4]);
2848        let img = RawImageLoader.load(&rimg, &AssetPath::new("tex.rimg")).unwrap();
2849        assert_eq!(img.data, vec![1, 2, 3, 4]);
2850    }
2851
2852    #[test]
2853    fn image_loader_reports_garbage_as_an_error() {
2854        let err = RawImageLoader.load(b"definitely not a png file", &AssetPath::new("bad.png"));
2855        assert!(err.is_err());
2856    }
2857
2858    #[test]
2859    fn sound_loader_reads_wav_and_refuses_unknown_formats() {
2860        let samples = [0.0f32, 0.5, -0.5, 1.0];
2861        let path = std::env::temp_dir().join("proof_engine_asset_loader.wav");
2862        crate::audio::wav::write_wav(&path, 22050, 2, &samples).unwrap();
2863        let bytes = std::fs::read(&path).unwrap();
2864        let snd = RawSoundLoader.load(&bytes, &AssetPath::new("clip.wav")).unwrap();
2865        assert_eq!((snd.sample_rate, snd.channels), (22050, 2));
2866        assert_eq!(snd.frame_count(), 2);
2867        for (a, b) in snd.samples.iter().zip(samples) {
2868            assert!((a - b).abs() < 1e-4, "{a} vs {b}");
2869        }
2870        assert!(RawSoundLoader.load(b"OggS and then some bytes", &AssetPath::new("x.ogg")).is_err());
2871    }
2872
2873    #[test]
2874    fn server_default_asset_server() {
2875        let server = default_asset_server();
2876        assert_eq!(server.asset_count(), 0);
2877        assert!(server.is_idle());
2878    }
2879
2880    #[test]
2881    fn server_pack_load() {
2882        let files: &[(&str, &[u8])] = &[("shaders/quad.vert", b"// vert shader")];
2883        let pack_bytes = AssetPack::build("shaders", files);
2884        let pack = AssetPack::from_bytes("shaders", pack_bytes).unwrap();
2885
2886        let mut server = AssetServer::new_with_config(AssetServerConfig {
2887            root_dir: PathBuf::from("nonexistent"),
2888            ..Default::default()
2889        });
2890        server.register_loader::<ShaderAsset, _>(PlainTextShaderLoader);
2891        server.mount_pack(pack);
2892
2893        let result = server.read_bytes(&AssetPath::new("shaders/quad.vert"));
2894        assert!(result.is_ok());
2895        let (data, _) = result.unwrap();
2896        assert_eq!(data, b"// vert shader");
2897    }
2898
2899    #[test]
2900    fn server_load_enqueues_request() {
2901        let mut server = AssetServer::new();
2902        server.register_loader::<ImageAsset, _>(RawImageLoader);
2903        let _handle = server.load::<ImageAsset>(AssetPath::new("test.png"));
2904        // The request is in the queue, not yet processed
2905        assert!(!server.is_idle());
2906    }
2907
2908    #[test]
2909    fn server_get_returns_none_before_update() {
2910        let mut server = AssetServer::new();
2911        server.register_loader::<ImageAsset, _>(RawImageLoader);
2912        let handle = server.load::<ImageAsset>(AssetPath::new("test.png"));
2913        // Not yet processed
2914        assert!(server.get(&handle).is_none());
2915    }
2916
2917    #[test]
2918    fn streaming_priority_upgrade() {
2919        let mut sm = StreamingManager::new(10);
2920        sm.enqueue(StreamRequest {
2921            id: 1,
2922            path: AssetPath::new("a.png"),
2923            type_id: TypeId::of::<ImageAsset>(),
2924            priority: StreamPriority::Low,
2925            enqueued_at: Instant::now(),
2926        });
2927        // Enqueue same ID with higher priority — should upgrade
2928        sm.enqueue(StreamRequest {
2929            id: 1,
2930            path: AssetPath::new("a.png"),
2931            type_id: TypeId::of::<ImageAsset>(),
2932            priority: StreamPriority::Critical,
2933            enqueued_at: Instant::now(),
2934        });
2935        assert_eq!(sm.pending(), 1);
2936        let drained = sm.drain();
2937        assert_eq!(drained[0].priority, StreamPriority::Critical);
2938    }
2939
2940    #[test]
2941    fn pixel_format_bytes_per_pixel() {
2942        assert_eq!(PixelFormat::R8.bytes_per_pixel(), 1);
2943        assert_eq!(PixelFormat::Rgba8.bytes_per_pixel(), 4);
2944        assert_eq!(PixelFormat::Rgba32F.bytes_per_pixel(), 16);
2945    }
2946
2947    #[test]
2948    fn font_asset_glyph_fallback() {
2949        let mut font = FontAsset {
2950            name: "test".into(),
2951            glyphs: HashMap::new(),
2952            metrics: FontMetrics::default(),
2953            atlas: None,
2954        };
2955        font.glyphs.insert('?', GlyphData {
2956            codepoint: '?',
2957            advance_width: 500.0,
2958            bounds: (0.0, 0.0, 500.0, 700.0),
2959            atlas_uv: None,
2960            outline: Vec::new(),
2961        });
2962        assert!(font.glyph('A').is_none()); // no fallback without '?'... wait
2963        // Actually glyph('A') falls back to '?' when 'A' is not present
2964        assert!(font.glyph('A').is_some());
2965        assert_eq!(font.glyph('A').unwrap().advance_width, 500.0);
2966    }
2967
2968    #[test]
2969    fn material_param_variants() {
2970        let m = MaterialAsset::default();
2971        assert!(matches!(m.roughness, MaterialParam::Value(_)));
2972        let tex = MaterialParam::Texture(AssetPath::new("rough.png"));
2973        assert!(matches!(tex, MaterialParam::Texture(_)));
2974    }
2975}