Skip to main content

game_toolkit_assets/
lib.rs

1//! Asset filesystem helpers + optional hot-reload via `notify`.
2//!
3//! Asset paths are relative to a base directory (defaults to `assets/` next to the executable
4//! or the current working directory). Reads return raw bytes; decoding is the caller's job —
5//! `game_toolkit_gfx` decodes PNGs, `game_toolkit_audio` decodes ogg/wav, etc.
6//!
7//! Hot-reload is opt-in: call [`Assets::watch`] to start a `notify` watcher on the asset root,
8//! then drain change events each frame with [`Assets::drain_changes`].
9//!
10//! # Web
11//!
12//! The browser has no filesystem and can only fetch asynchronously, so on `wasm32` the asset
13//! root is a URL prefix relative to the page and [`Assets::read`] serves bytes from memory. The
14//! toolkit's core fills that store with `Assets::preload` before `Game::init` runs, from the
15//! list in `manifest.txt` (one asset path per line) under the asset root. Hot-reload is not
16//! available there: [`Assets::watch`] is a logged no-op.
17//!
18//! A file missing from the manifest only fails in the browser, since native builds read from
19//! disk. To catch that during development, native debug builds load `manifest.txt` too (if
20//! the asset root has one) and log a warning, once per file, when [`Assets::read`] serves a
21//! file the manifest doesn't list.
22//!
23//! On every platform, bytes added with [`Assets::insert`] take precedence over the filesystem.
24
25#![forbid(unsafe_code)]
26
27use std::collections::{HashMap, HashSet};
28#[cfg(not(target_arch = "wasm32"))]
29use std::path::Component;
30use std::path::{Path, PathBuf};
31#[cfg(not(target_arch = "wasm32"))]
32use std::sync::Mutex;
33#[cfg(not(target_arch = "wasm32"))]
34use std::sync::mpsc::{Receiver, channel};
35
36#[cfg(not(target_arch = "wasm32"))]
37use anyhow::Context;
38use anyhow::Result;
39#[cfg(not(target_arch = "wasm32"))]
40use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
41
42#[cfg(target_arch = "wasm32")]
43mod web;
44
45/// Name of the asset list the web build preloads, relative to the asset root.
46pub const MANIFEST_FILE: &str = "manifest.txt";
47
48pub struct Assets {
49    root: PathBuf,
50    /// In-memory files keyed by resolved path; consulted before the filesystem.
51    mem: HashMap<PathBuf, Vec<u8>>,
52    #[cfg(not(target_arch = "wasm32"))]
53    watcher: Option<RecommendedWatcher>,
54    #[cfg(not(target_arch = "wasm32"))]
55    rx: Option<Receiver<PathBuf>>,
56    pending: HashSet<PathBuf>,
57    /// Native debug builds: the web preload list, to warn about reads it doesn't cover.
58    #[cfg(not(target_arch = "wasm32"))]
59    web_list: Option<WebList>,
60}
61
62/// The files a web build would preload, and the unlisted ones already warned about.
63#[cfg(not(target_arch = "wasm32"))]
64struct WebList {
65    /// Where the list came from, and how to fix a missing entry, for the warning.
66    source: String,
67    hint: &'static str,
68    listed: HashSet<PathBuf>,
69    warned: Mutex<HashSet<PathBuf>>,
70}
71
72/// Parse a `manifest.txt`: one path per line, blank lines and `#` comments ignored.
73pub fn parse_manifest(text: &str) -> Vec<String> {
74    text.lines()
75        .map(str::trim)
76        .filter(|l| !l.is_empty() && !l.starts_with('#'))
77        .map(str::to_owned)
78        .collect()
79}
80
81/// Drop `.` components, so `./a.png` and `a.png` compare equal.
82#[cfg(not(target_arch = "wasm32"))]
83fn normalize(path: &Path) -> PathBuf {
84    path.components()
85        .filter(|c| !matches!(c, Component::CurDir))
86        .collect()
87}
88
89impl Assets {
90    /// Create with `root` as the asset directory. Path is canonicalized eagerly to make
91    /// matching against `notify` events deterministic. On the web, `root` is a URL path
92    /// relative to the page and is used as-is.
93    pub fn new(root: impl AsRef<Path>) -> Result<Self> {
94        let root = root.as_ref();
95        #[cfg(not(target_arch = "wasm32"))]
96        let root = root
97            .canonicalize()
98            .with_context(|| format!("asset root {} not found", root.display()))?;
99        #[cfg(target_arch = "wasm32")]
100        let root = root.to_path_buf();
101        #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
102        let mut assets = Self {
103            root,
104            mem: HashMap::new(),
105            #[cfg(not(target_arch = "wasm32"))]
106            watcher: None,
107            #[cfg(not(target_arch = "wasm32"))]
108            rx: None,
109            pending: HashSet::new(),
110            #[cfg(not(target_arch = "wasm32"))]
111            web_list: None,
112        };
113        #[cfg(not(target_arch = "wasm32"))]
114        if cfg!(debug_assertions) {
115            assets.load_web_manifest();
116        }
117        Ok(assets)
118    }
119
120    /// Read `manifest.txt` from the asset root, if there is one, as the web preload list.
121    #[cfg(not(target_arch = "wasm32"))]
122    fn load_web_manifest(&mut self) {
123        let path = self.root.join(MANIFEST_FILE);
124        if let Ok(text) = std::fs::read_to_string(&path) {
125            self.set_web_list(
126                path.display().to_string(),
127                "regenerate the manifest, e.g. `task assets:manifest`",
128                parse_manifest(&text),
129            );
130        }
131    }
132
133    /// Natively, in debug builds: check reads against `paths` instead of `manifest.txt`, for a
134    /// game that sets `AppConfig::web_preload`. No-op in release builds and on the web.
135    pub fn set_web_preload<I, S>(&mut self, paths: I)
136    where
137        I: IntoIterator<Item = S>,
138        S: AsRef<str>,
139    {
140        #[cfg(not(target_arch = "wasm32"))]
141        if cfg!(debug_assertions) {
142            let paths = paths.into_iter().map(|p| p.as_ref().to_owned()).collect();
143            self.set_web_list(
144                "AppConfig::web_preload".into(),
145                "add it to that list",
146                paths,
147            );
148        }
149        #[cfg(target_arch = "wasm32")]
150        let _ = paths;
151    }
152
153    #[cfg(not(target_arch = "wasm32"))]
154    fn set_web_list(&mut self, source: String, hint: &'static str, paths: Vec<String>) {
155        let listed = paths.iter().map(|p| normalize(&self.resolve(p))).collect();
156        self.web_list = Some(WebList {
157            source,
158            hint,
159            listed,
160            warned: Mutex::new(HashSet::new()),
161        });
162    }
163
164    /// Natively, in debug builds: warn (once per file) if `rel` isn't in the web preload list,
165    /// i.e. loading it would fail in the browser. [`Assets::read`] does this itself; call it
166    /// when reading an asset some other way, e.g. by path. Does nothing when there is no
167    /// list (no `manifest.txt`), for in-memory files, in release builds and on the web.
168    pub fn check_web_listed(&self, rel: impl AsRef<Path>) {
169        #[cfg(not(target_arch = "wasm32"))]
170        {
171            let Some(list) = &self.web_list else {
172                return;
173            };
174            let p = normalize(&self.resolve(rel.as_ref()));
175            if list.listed.contains(&p) || self.mem.contains_key(&p) || !p.starts_with(&self.root) {
176                return;
177            }
178            let mut warned = list
179                .warned
180                .lock()
181                .unwrap_or_else(std::sync::PoisonError::into_inner);
182            if warned.insert(p) {
183                log::warn!(
184                    "asset {} is not listed in {}; it loads natively but will fail on the web ({})",
185                    rel.as_ref().display(),
186                    list.source,
187                    list.hint
188                );
189            }
190        }
191        #[cfg(target_arch = "wasm32")]
192        let _ = rel;
193    }
194
195    pub fn root(&self) -> &Path {
196        &self.root
197    }
198
199    pub fn resolve(&self, rel: impl AsRef<Path>) -> PathBuf {
200        let rel = rel.as_ref();
201        if rel.is_absolute() {
202            rel.to_path_buf()
203        } else {
204            self.root.join(rel)
205        }
206    }
207
208    /// Read an asset's bytes: from memory if it was inserted or preloaded, otherwise from disk
209    /// (native only).
210    pub fn read(&self, rel: impl AsRef<Path>) -> Result<Vec<u8>> {
211        let p = self.resolve(rel.as_ref());
212        if let Some(bytes) = self.mem.get(&p) {
213            return Ok(bytes.clone());
214        }
215        #[cfg(not(target_arch = "wasm32"))]
216        {
217            let bytes = std::fs::read(&p).with_context(|| format!("read {}", p.display()))?;
218            self.check_web_listed(rel);
219            Ok(bytes)
220        }
221        #[cfg(target_arch = "wasm32")]
222        {
223            anyhow::bail!(
224                "asset {} was not preloaded; list it in {}/{MANIFEST_FILE}",
225                p.display(),
226                self.root.display()
227            )
228        }
229    }
230
231    /// Store `bytes` under `rel`, shadowing any file at that path.
232    pub fn insert(&mut self, rel: impl AsRef<Path>, bytes: impl Into<Vec<u8>>) {
233        let p = self.resolve(rel);
234        self.mem.insert(p, bytes.into());
235    }
236
237    /// Whether `rel` is available in memory (inserted or preloaded).
238    pub fn is_loaded(&self, rel: impl AsRef<Path>) -> bool {
239        self.mem.contains_key(&self.resolve(rel))
240    }
241
242    /// Start watching the asset root recursively. Idempotent. On the web this logs that
243    /// hot-reload is unavailable and returns `Ok`.
244    #[cfg(not(target_arch = "wasm32"))]
245    pub fn watch(&mut self) -> Result<()> {
246        if self.watcher.is_some() {
247            return Ok(());
248        }
249        let (tx, rx) = channel::<PathBuf>();
250        let mut watcher =
251            notify::recommended_watcher(move |res: notify::Result<Event>| match res {
252                Ok(event) => {
253                    if matches!(
254                        event.kind,
255                        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
256                    ) {
257                        for p in event.paths {
258                            let _ = tx.send(p);
259                        }
260                    }
261                }
262                Err(e) => log::warn!("notify error: {e}"),
263            })?;
264        watcher.watch(&self.root, RecursiveMode::Recursive)?;
265        self.watcher = Some(watcher);
266        self.rx = Some(rx);
267        Ok(())
268    }
269
270    /// Start watching the asset root recursively. Idempotent. On the web this logs that
271    /// hot-reload is unavailable and returns `Ok`.
272    #[cfg(target_arch = "wasm32")]
273    pub fn watch(&mut self) -> Result<()> {
274        log::info!("asset hot-reload is not available on the web");
275        Ok(())
276    }
277
278    /// Drain all queued filesystem changes since last call. Paths are canonicalized when
279    /// possible. Idempotent across re-saves within one frame (HashSet-deduped). Always empty
280    /// on the web.
281    pub fn drain_changes(&mut self) -> Vec<PathBuf> {
282        #[cfg(not(target_arch = "wasm32"))]
283        if let Some(rx) = self.rx.as_ref() {
284            while let Ok(p) = rx.try_recv() {
285                let canon = p.canonicalize().unwrap_or(p);
286                self.pending.insert(canon);
287            }
288        }
289        self.pending.drain().collect()
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn inserted_bytes_shadow_the_filesystem() {
299        let mut assets = Assets::new(".").unwrap();
300        assert!(!assets.is_loaded("Cargo.toml"));
301        assets.insert("Cargo.toml", b"in memory".to_vec());
302        assert!(assets.is_loaded("Cargo.toml"));
303        assert_eq!(assets.read("Cargo.toml").unwrap(), b"in memory");
304    }
305
306    #[test]
307    fn parse_manifest_skips_comments_and_blanks() {
308        let text = "# header\n\nsprites/a.png\n  b.wav  \n#c.png\n";
309        assert_eq!(parse_manifest(text), ["sprites/a.png", "b.wav"]);
310    }
311
312    #[test]
313    #[cfg(all(not(target_arch = "wasm32"), debug_assertions))]
314    fn web_list_flags_unlisted_files_once() {
315        let mut assets = Assets::new(".").unwrap();
316        assets.set_web_preload(["./Cargo.toml"]);
317        let list = assets
318            .web_list
319            .as_ref()
320            .expect("debug build loads the list");
321        assert!(
322            list.listed
323                .contains(&normalize(&assets.resolve("Cargo.toml")))
324        );
325
326        assets.check_web_listed("Cargo.toml");
327        assets.check_web_listed("src/lib.rs");
328        assets.check_web_listed("src/lib.rs");
329        let warned = assets.web_list.as_ref().unwrap().warned.lock().unwrap();
330        assert_eq!(warned.len(), 1);
331        assert!(warned.contains(&assets.resolve("src/lib.rs")));
332    }
333
334    #[test]
335    fn falls_back_to_disk() {
336        let assets = Assets::new(".").unwrap();
337        assert!(assets.read("Cargo.toml").unwrap().starts_with(b"[package]"));
338        assert!(assets.read("does-not-exist.bin").is_err());
339    }
340}