Skip to main content

motion_core/
asset.rs

1//! Lazy typed assets with explicit bounded project resource stores and request diagnostics.
2//!
3//! ```
4//! use motion_core::{Asset, Assets, LoadContext, Source};
5//! use std::sync::Arc;
6//! struct Label(String);
7//! impl Asset for Label {
8//!     const KIND: &'static str = "label";
9//!     fn decode(bytes: &[u8], _: &LoadContext) -> motion_core::Result<Self> {
10//!         Ok(Self(std::str::from_utf8(bytes)?.to_owned()))
11//!     }
12//!     fn retained_bytes(&self) -> usize {
13//!         self.0.len()
14//!     }
15//! }
16//! let assets = Assets::new("assets")?;
17//! let handle = assets.load::<Label>(Source::Bytes {
18//!     key: "title".into(),
19//!     data: Arc::from(&b"Native resources"[..]),
20//! });
21//! assert!(handle.state().is_unloaded());
22//! assert_eq!(handle.get()?.0, "Native resources");
23//! # Ok::<(), motion_core::Error>(())
24//! ```
25
26use crate::sync::MutexExt;
27use std::{
28    any::{Any, TypeId},
29    collections::BTreeMap,
30    io::{self, Read},
31    path::{Path, PathBuf},
32    sync::{
33        Arc, Mutex, OnceLock, Weak,
34        atomic::{AtomicBool, AtomicUsize, Ordering},
35    },
36};
37
38use handle::Entry;
39
40pub mod environment;
41mod handle;
42mod store;
43#[cfg(test)]
44mod tests;
45
46pub use environment::{FontEnvironment, FontIdentity};
47pub use handle::{AssetState, Handle};
48pub use store::{ResourceLimits, ResourceStore};
49
50const MAX_ENCODED: usize = 64 * 1024 * 1024;
51
52const MAX_STORE_BYTES: usize = 256 * 1024 * 1024;
53
54const MAX_ENTRIES: usize = 4096;
55
56thread_local! {
57    static RESOLVING: std::cell::RefCell<Vec<usize>> = const { std::cell::RefCell::new(Vec::new()) };
58}
59
60/// Explicit request-owned diagnostic sink, safely shared with background tasks.
61/// Clones append to the same sink. Assets views and handles carry it explicitly.
62/// Resource load failures and scene-authoring failures are kept apart.
63#[derive(Clone, Default, Debug)]
64pub struct RequestDiagnostics(Arc<Mutex<RequestFailures>>);
65
66#[derive(Default, Debug)]
67struct RequestFailures {
68    loads: Vec<Arc<crate::Error>>,
69    scene: crate::ErrorReport,
70}
71
72impl RequestDiagnostics {
73    fn locked(&self) -> std::sync::MutexGuard<'_, RequestFailures> {
74        self.0.lock_unpoisoned()
75    }
76
77    /// Captures a resource failure once by Arc identity, not by its text or semantic equality.
78    pub fn record(&self, error: Arc<crate::Error>) {
79        let mut failures = self.locked();
80        if !failures
81            .loads
82            .iter()
83            .any(|existing| Arc::ptr_eq(existing, &error))
84        {
85            failures.loads.push(error);
86        }
87    }
88
89    /// Captures a scene-authoring failure once by Arc identity.
90    pub fn record_scene(&self, error: Arc<crate::Error>) {
91        let mut failures = self.locked();
92        if !failures
93            .scene
94            .errors()
95            .iter()
96            .any(|existing| Arc::ptr_eq(existing, &error))
97        {
98            failures.scene.record_shared(error);
99        }
100    }
101
102    /// Snapshot of resource failures belonging to this request only.
103    pub fn errors(&self) -> Vec<Arc<crate::Error>> {
104        self.locked().loads.clone()
105    }
106
107    /// Snapshot of scene-authoring failures belonging to this request only.
108    pub fn scene_errors(&self) -> crate::ErrorReport {
109        self.locked().scene.clone()
110    }
111}
112
113struct ResolveGuard;
114
115impl Drop for ResolveGuard {
116    fn drop(&mut self) {
117        RESOLVING.with(|entries| {
118            entries.borrow_mut().pop();
119        });
120    }
121}
122
123/// A custom typed resource decoded once for a shared source and project context.
124pub trait Asset: Sized + Send + Sync + 'static {
125    /// Human-readable resource category retained by load diagnostics.
126    const KIND: &'static str;
127
128    /// Decodes bounded input without requiring a rendering device.
129    /// Implementations must be deterministic for these bytes/context; recursive
130    /// resource dependency graphs are not a supported decoder contract.
131    fn decode(bytes: &[u8], context: &LoadContext) -> crate::Result<Self>;
132
133    /// Logical decoded payload charge; custom heap-owning assets should override.
134    /// At least the encoded input length is charged regardless of this value.
135    fn retained_bytes(&self) -> usize {
136        std::mem::size_of::<Self>()
137    }
138}
139
140/// Local file or immutable named bytes. Byte identity includes the content,
141/// so reusing a name with different bytes cannot return the wrong cached asset.
142#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
143pub enum Source {
144    /// Relative to the Assets root, or an explicitly supplied absolute path.
145    File(PathBuf),
146    /// Embedded bytes with a diagnostic identifier.
147    Bytes {
148        /// Stable user-facing resource name.
149        key: Arc<str>,
150        /// Immutable encoded content.
151        data: Arc<[u8]>,
152    },
153}
154
155impl std::fmt::Debug for Source {
156    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            Self::File(path) => formatter.debug_tuple("File").field(path).finish(),
159            Self::Bytes { key, data } => formatter
160                .debug_struct("Bytes")
161                .field("key", key)
162                .field("length", &data.len())
163                .finish(),
164        }
165    }
166}
167
168impl From<PathBuf> for Source {
169    fn from(path: PathBuf) -> Self {
170        Self::File(path)
171    }
172}
173
174impl From<&Path> for Source {
175    fn from(path: &Path) -> Self {
176        Self::File(path.to_path_buf())
177    }
178}
179
180impl From<&str> for Source {
181    fn from(path: &str) -> Self {
182        Self::File(path.into())
183    }
184}
185
186impl From<String> for Source {
187    fn from(path: String) -> Self {
188        Self::File(path.into())
189    }
190}
191
192/// Immutable loader configuration; no font discovery, GPU or encoder is created.
193#[derive(Clone, Debug)]
194pub struct LoadContext {
195    root: Arc<PathBuf>,
196    fonts: Option<crate::asset::environment::FontEnvironment>,
197    namespace: u64,
198}
199
200impl LoadContext {
201    /// Immutable font environment supplied by this resource owner, if any.
202    pub fn fonts(&self) -> Option<&crate::asset::environment::FontEnvironment> {
203        self.fonts.as_ref()
204    }
205
206    /// Absolute project asset directory captured when Assets is created.
207    pub fn root(&self) -> &Path {
208        &self.root
209    }
210
211    /// Maximum encoded bytes accepted by a single resource.
212    pub fn max_encoded_bytes(&self) -> usize {
213        MAX_ENCODED
214    }
215}
216
217type CacheKey = (TypeId, PathBuf, u64, u64, Source);
218
219type Erased = dyn Any + Send + Sync;
220
221struct OwnedEntry {
222    value: Arc<Erased>,
223    initialized: Box<dyn Fn() -> bool + Send + Sync>,
224}
225
226impl OwnedEntry {
227    fn new<AssetValue: Asset>(entry: Arc<Entry<AssetValue>>) -> Arc<Self> {
228        let weak = Arc::downgrade(&entry);
229        Arc::new(Self {
230            value: entry,
231            initialized: Box::new(move || {
232                weak.upgrade()
233                    .is_some_and(|entry| entry.result.get().is_some())
234            }),
235        })
236    }
237}
238
239/// Bounded project resource owner. Owners/handles retain project weak cache
240/// entries; dropping them releases cache payload charges. This is not a hard
241/// process-memory cap or an automatic file-change watcher.
242#[derive(Clone)]
243pub struct Assets {
244    context: LoadContext,
245    entries: Arc<Mutex<BTreeMap<CacheKey, Arc<OwnedEntry>>>>,
246    store: ResourceStore,
247    diagnostics: Option<RequestDiagnostics>,
248}
249
250impl std::fmt::Debug for Assets {
251    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        formatter
253            .debug_struct("Assets")
254            .field("root", &self.context.root)
255            .finish_non_exhaustive()
256    }
257}
258
259impl Default for Assets {
260    fn default() -> Self {
261        Self {
262            context: LoadContext {
263                root: Arc::new(PathBuf::new()),
264                fonts: None,
265                namespace: 0,
266            },
267            entries: Arc::new(Mutex::new(BTreeMap::new())),
268            store: ResourceStore::new(),
269            diagnostics: None,
270        }
271    }
272}
273
274impl Assets {
275    /// Derives a request-local view without changing resource identity or ownership.
276    /// Handles created from this view keep the explicit sink across threads.
277    pub fn with_diagnostics(mut self, diagnostics: RequestDiagnostics) -> Self {
278        self.diagnostics = Some(diagnostics);
279        self
280    }
281
282    /// Freezes existing entries while isolating future registrations from old clones.
283    /// Snapshot versions still share project accounting and existing decoded payloads.
284    pub fn snapshot(&self) -> Self {
285        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
286        let mut snapshot = self.clone();
287        snapshot.context.namespace = NEXT.fetch_add(1, Ordering::Relaxed);
288        let entries = self
289            .entries
290            .lock_unpoisoned()
291            .iter()
292            .filter(|(_, entry)| (entry.initialized)())
293            .map(|(key, entry)| {
294                let mut key = key.clone();
295                key.3 = snapshot.context.namespace;
296                (key, entry.clone())
297            })
298            .collect();
299        snapshot.entries = Arc::new(Mutex::new(entries));
300        snapshot
301    }
302
303    /// Registers decoded data in the same bounded entry used by lazy handles.
304    /// Performs no file read; an already resolved source is never replaced.
305    pub fn preload<A: Asset>(
306        &self,
307        source: impl Into<Source>,
308        value: Arc<A>,
309    ) -> crate::Result<Handle<A>> {
310        let handle = self.load::<A>(source);
311        let inserted = std::cell::Cell::new(false);
312        let result = handle.entry.result.get_or_init(|| {
313            inserted.set(true);
314            let charge = value
315                .retained_bytes()
316                .checked_add(handle.entry.charged.load(Ordering::Acquire));
317            let result = charge
318                .ok_or_else(|| {
319                    io::Error::new(
320                        io::ErrorKind::OutOfMemory,
321                        "Asset payload accounting overflow",
322                    )
323                })
324                .and_then(|charge| {
325                    let additional = charge - handle.entry.charged.load(Ordering::Acquire);
326                    self.store
327                        .0
328                        .bytes
329                        .try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
330                            current
331                                .checked_add(additional)
332                                .filter(|total| *total <= self.store.0.limits.max_bytes)
333                        })
334                        .map_err(|_| {
335                            io::Error::new(
336                                io::ErrorKind::OutOfMemory,
337                                "Shared asset-cache logical payload exceeds its allowance",
338                            )
339                        })?;
340                    handle.entry.charged.store(charge, Ordering::Release);
341                    Ok(value)
342                });
343            result.map_err(|error| {
344                Arc::new(crate::Error::asset(A::KIND, handle.source_name(), error))
345            })
346        });
347        if let Err(error) = result {
348            return Err(crate::Error::asset(
349                A::KIND,
350                handle.source_name(),
351                error.clone(),
352            ));
353        }
354        if !inserted.get() {
355            return Err(io::Error::new(
356                io::ErrorKind::AlreadyExists,
357                "Asset source is already resolved",
358            )
359            .into());
360        }
361        Ok(handle)
362    }
363
364    /// Uses an explicit project store and drops this value's retained entry table.
365    /// Existing owner clones, handles and decoded values keep their original ownership.
366    pub fn with_store(mut self, store: ResourceStore) -> Self {
367        self.store = store;
368        self.entries = Arc::new(Mutex::new(BTreeMap::new()));
369        self
370    }
371
372    /// Project cache identity and accounting shared by this owner's clones.
373    pub fn store(&self) -> &ResourceStore {
374        &self.store
375    }
376
377    /// Unregisters one typed source from this owner without invalidating handles.
378    /// Clones share registrations; use snapshot() before independent edits.
379    /// Retained snapshots and handles keep their payload charged until dropped.
380    pub fn remove<A: Asset>(&self, source: impl Into<Source>) -> bool {
381        let mut source = source.into();
382        if let Source::File(path) = &mut source
383            && !path.is_absolute()
384        {
385            *path = self.context.root.join(&*path);
386        }
387        let key = (
388            TypeId::of::<A>(),
389            self.context.root.as_ref().clone(),
390            self.context
391                .fonts
392                .as_ref()
393                .map_or(0, crate::asset::environment::FontEnvironment::snapshot_id),
394            self.context.namespace,
395            source,
396        );
397        let mut entries = self.entries.lock_unpoisoned();
398        let Some(entry) = entries.remove(&key) else {
399            return false;
400        };
401        let mut global = self.store.0.entries.lock_unpoisoned();
402        if global
403            .get(&key)
404            .and_then(Weak::upgrade)
405            .is_some_and(|value| Arc::ptr_eq(&value, &entry.value))
406        {
407            global.remove(&key);
408        }
409        true
410    }
411
412    /// Derives an independent resource view with an immutable font environment.
413    /// Cache identity includes its snapshot; existing owners/handles stay valid.
414    pub fn with_fonts(
415        mut self,
416        fonts: impl Into<crate::asset::environment::FontEnvironment>,
417    ) -> Self {
418        self.context.fonts = Some(fonts.into());
419        self.entries = Arc::new(Mutex::new(BTreeMap::new()));
420        self
421    }
422
423    /// Font snapshot shared by layout and explicit SVG parsing.
424    pub fn fonts(&self) -> Option<&crate::asset::environment::FontEnvironment> {
425        self.context.fonts()
426    }
427
428    /// Captures a project asset directory without reading/decode/device work.
429    pub fn new(root: impl AsRef<Path>) -> crate::Result<Self> {
430        Ok(Self {
431            context: LoadContext {
432                root: Arc::new(std::path::absolute(root)?),
433                fonts: None,
434                namespace: 0,
435            },
436            entries: Arc::new(Mutex::new(BTreeMap::new())),
437            store: ResourceStore::new(),
438            diagnostics: None,
439        })
440    }
441
442    /// Requests a lazy typed handle. Matching type/root/font snapshot/source shares
443    /// one entry only among owners attached to the same ResourceStore. Creating a
444    /// handle performs no file decode. Relative parent traversal is rejected.
445    pub fn load<A: Asset>(&self, source: impl Into<Source>) -> Handle<A> {
446        let mut source = source.into();
447        let failure = if let Source::File(path) = &mut source {
448            if self.context.root.as_os_str().is_empty() && !path.is_absolute() {
449                Some("Relative file assets require an explicit project root")
450            } else if path
451                .components()
452                .any(|component| component == std::path::Component::ParentDir)
453            {
454                Some("Asset paths must not contain parent traversal")
455            } else {
456                if !path.is_absolute() {
457                    *path = self.context.root.join(&*path);
458                }
459                None
460            }
461        } else if let Source::Bytes { key, data } = &source {
462            if data.len() > MAX_ENCODED {
463                Some("Asset source exceeds 64 MiB")
464            } else if key.is_empty() || key.len() > 1024 || key.chars().any(char::is_control) {
465                Some("Asset byte keys require 1..=1024 bytes without control characters")
466            } else {
467                None
468            }
469        } else {
470            None
471        };
472        let key = (
473            TypeId::of::<A>(),
474            self.context.root.as_ref().clone(),
475            self.context
476                .fonts
477                .as_ref()
478                .map_or(0, crate::asset::environment::FontEnvironment::snapshot_id),
479            self.context.namespace,
480            source.clone(),
481        );
482        let mut entries = self.entries.lock_unpoisoned();
483        if failure.is_none()
484            && let Some(entry) = entries.get(&key)
485        {
486            return Handle {
487                entry: entry
488                    .value
489                    .clone()
490                    .downcast::<Entry<A>>()
491                    .expect("typed resource key"),
492                diagnostics: self.diagnostics.clone(),
493            };
494        }
495        let mut global = self.store.0.entries.lock_unpoisoned();
496        global.retain(|_, entry| entry.strong_count() > 0);
497        let failure = failure.or_else(|| {
498            (entries.len() >= self.store.0.limits.max_entries)
499                .then_some("Resource cache exceeds its entry allowance")
500        });
501        if failure.is_none()
502            && let Some(entry) = global.get(&key).and_then(Weak::upgrade)
503        {
504            let entry = entry.downcast::<Entry<A>>().expect("typed global key");
505            entries.insert(key, OwnedEntry::new(entry.clone()));
506            return Handle {
507                entry,
508                diagnostics: self.diagnostics.clone(),
509            };
510        }
511        let mut failure = failure.or_else(|| {
512            (global.len() >= self.store.0.limits.max_entries)
513                .then_some("Resource cache exceeds its entry allowance")
514        });
515        let mut charged = 0;
516        if failure.is_none()
517            && let Source::Bytes { data, .. } = &source
518        {
519            if self
520                .store
521                .0
522                .bytes
523                .try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
524                    current
525                        .checked_add(data.len())
526                        .filter(|total| *total <= self.store.0.limits.max_bytes)
527                })
528                .is_ok()
529            {
530                charged = data.len();
531            } else {
532                failure = Some("Shared asset-cache logical payload exceeds its allowance");
533            }
534        }
535        if failure.is_some()
536            && let Source::Bytes { data, .. } = &mut source
537        {
538            *data = Arc::from([]);
539        }
540        let entry = Arc::new(Entry {
541            store: self.store.clone(),
542            source,
543            context: self.context.clone(),
544            result: OnceLock::new(),
545            loading: AtomicBool::new(false),
546            charged: AtomicUsize::new(charged),
547        });
548        if let Some(message) = failure {
549            let source_name = match &entry.source {
550                Source::File(path) => path.display().to_string(),
551                Source::Bytes { key, .. } => key.to_string(),
552            };
553            let _result = entry.result.set(Err(Arc::new(crate::Error::asset(
554                A::KIND,
555                source_name,
556                io::Error::new(io::ErrorKind::InvalidInput, message),
557            ))));
558        } else {
559            let erased: Arc<Erased> = entry.clone();
560            global.insert(key.clone(), Arc::downgrade(&erased));
561            entries.insert(key, OwnedEntry::new(entry.clone()));
562        }
563        Handle {
564            entry,
565            diagnostics: self.diagnostics.clone(),
566        }
567    }
568}