Skip to main content

gasm_host/
assets.rs

1//! Read-only guest assets: in-memory buffers, or files read on demand.
2//!
3//! - **File-backed** (`--asset`, `--rom`, `--asset-dir`): the size comes from file
4//!   metadata and reads are positioned (`pread` on Unix, `seek_read` on
5//!   Windows) straight into guest memory. Nothing is loaded up front, so a
6//!   200 MB asset costs no RAM and no start-up time. A file that shrinks or
7//!   disappears while running just yields fewer bytes (possibly 0); reads never
8//!   panic. Explicit files are opened at start; folder entries are opened on
9//!   first read and kept in a small cache, so a CD-sized tree doesn't hit the
10//!   open-file limit.
11//! - **Folders** (`--asset-dir [prefix=]dir`): every regular file under `dir`,
12//!   named by its `/`-separated relative path as stored on disk. Symlinks are
13//!   skipped (so nothing outside `dir` is reachable; a file replaced by a
14//!   symlink later is refused too) and so are hidden entries (any path
15//!   component starting with `.`, e.g. `.DS_Store`, `._foo`). The set of names
16//!   is fixed at start-up.
17//! - **Lookup:** an exact name always wins (explicit `--asset` entries override
18//!   folder entries of the same name). Otherwise folder entries also match
19//!   **case-insensitively** (ASCII folding): CD file systems are upper case,
20//!   games ask in mixed case. If several folder entries differ only in case, a
21//!   warning is logged at start-up and the first in sorted order wins.
22//! - Sizes are 64-bit (`asset_size64`, `asset_read_at64`); `asset_size`
23//!   reports assets of 2 GiB and more as -2.
24//! - **Replacing assets while the game runs:** the embedder calls
25//!   [`Assets::set`] / [`Assets::set_file`] / [`Assets::remove`] between frames
26//!   (`Game::set_asset`, or `--watch-asset`, which re-opens a file whenever it
27//!   changes: [`AssetWatch`]). Each replacement gives the asset a new version
28//!   (`gasm.asset_version`): 0 for assets given at start, then increasing
29//!   numbers from one counter, so a removed and re-added asset never repeats one.
30
31use std::cell::RefCell;
32use std::collections::{BTreeMap, HashMap, VecDeque};
33use std::fs::File;
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36
37/// Folder entries kept open at once (least recently used are closed).
38const OPEN_FILES: usize = 64;
39
40enum Source {
41    Memory(Vec<u8>),
42    /// explicit file: opened at start (the path the user named)
43    File { file: File, path: PathBuf },
44    /// folder entry: opened on first read
45    Lazy { path: PathBuf, len: u64 },
46}
47
48struct Entry {
49    source: Source,
50    from_dir: bool,
51    version: u32,
52}
53
54#[derive(Default)]
55pub struct Assets {
56    exact: HashMap<String, Entry>,
57    /// ASCII-lowercased name -> folder entry names, sorted
58    folded: BTreeMap<String, Vec<String>>,
59    /// sorted names, built by `finish`
60    sorted: Vec<String>,
61    /// open folder entries, most recently used last
62    open: RefCell<VecDeque<(PathBuf, Arc<File>)>>,
63    /// the last version handed out by a replacement (assets given at start are 0)
64    last_version: u32,
65}
66
67impl From<HashMap<String, Vec<u8>>> for Assets {
68    fn from(map: HashMap<String, Vec<u8>>) -> Assets {
69        let mut a = Assets::default();
70        for (name, bytes) in map {
71            a.insert_memory(&name, bytes);
72        }
73        a.finish();
74        a
75    }
76}
77
78#[cfg(unix)]
79fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
80    std::os::unix::fs::FileExt::read_at(file, buf, offset)
81}
82
83#[cfg(windows)]
84fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
85    std::os::windows::fs::FileExt::seek_read(file, buf, offset)
86}
87
88/// Fill `dst` from `offset`; stops early at the end of the file or on an error.
89fn read_file(file: &File, path: &Path, offset: u64, dst: &mut [u8]) -> usize {
90    let mut done = 0;
91    while done < dst.len() {
92        match pread(file, &mut dst[done..], offset + done as u64) {
93            Ok(0) => break,
94            Ok(n) => done += n,
95            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
96            Err(e) => {
97                eprintln!("[gasm] assets: {}: {e}", path.display());
98                break;
99            }
100        }
101    }
102    done
103}
104
105/// A resolved asset: its size and reads, without looking the name up again.
106pub struct Asset<'a> {
107    assets: &'a Assets,
108    source: &'a Source,
109    version: u32,
110}
111
112impl Asset<'_> {
113    /// 0 if given at start, else a new number each time it was replaced.
114    pub fn version(&self) -> u32 {
115        self.version
116    }
117
118    /// Size in bytes (for folder entries: as found at start-up).
119    pub fn size(&self) -> u64 {
120        match self.source {
121            Source::Memory(b) => b.len() as u64,
122            Source::File { file, .. } => file.metadata().map_or(0, |m| m.len()),
123            Source::Lazy { len, .. } => *len,
124        }
125    }
126
127    /// Copy bytes starting at `offset` into `dst`. Returns bytes copied (0 at or
128    /// past the end). Short reads (a file that shrank) return what was
129    /// available; I/O errors end the read early.
130    pub fn read_at(&self, offset: u64, dst: &mut [u8]) -> usize {
131        match self.source {
132            Source::Memory(b) => {
133                let start = offset.min(b.len() as u64) as usize;
134                let n = (b.len() - start).min(dst.len());
135                dst[..n].copy_from_slice(&b[start..start + n]);
136                n
137            }
138            Source::File { file, path } => read_file(file, path, offset, dst),
139            Source::Lazy { path, .. } => match self.assets.open_lazy(path) {
140                Some(file) => read_file(&file, path, offset, dst),
141                None => 0,
142            },
143        }
144    }
145}
146
147impl Assets {
148    pub fn new() -> Assets {
149        Assets::default()
150    }
151
152    pub fn len(&self) -> usize {
153        self.exact.len()
154    }
155
156    pub fn is_empty(&self) -> bool {
157        self.exact.is_empty()
158    }
159
160    /// Asset from memory (overrides any entry with the same name).
161    pub fn insert_memory(&mut self, name: &str, bytes: Vec<u8>) {
162        self.exact.insert(name.to_owned(), Entry { source: Source::Memory(bytes), from_dir: false, version: 0 });
163    }
164
165    /// Asset backed by a file, opened now and read on demand (overrides any
166    /// entry with the same name).
167    pub fn insert_file(&mut self, name: &str, path: &Path) -> Result<(), String> {
168        let file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
169        if !file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() {
170            return Err(format!("{}: not a regular file", path.display()));
171        }
172        self.exact.insert(name.to_owned(), Entry { source: Source::File { file, path: path.to_owned() }, from_dir: false, version: 0 });
173        Ok(())
174    }
175
176    /// Expose every regular file under `dir` (recursively), named
177    /// `[prefix/]relative/path`. Existing names are kept (explicit assets win).
178    /// Returns the number of files added. Call [`Assets::finish`] afterwards.
179    pub fn add_dir(&mut self, prefix: Option<&str>, dir: &Path) -> Result<usize, String> {
180        let meta = std::fs::metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
181        if !meta.is_dir() {
182            return Err(format!("{}: not a directory", dir.display()));
183        }
184        let mut files = Vec::new();
185        walk(dir, &mut Vec::new(), &mut files)?;
186        let mut added = 0;
187        for (segments, path, len) in files {
188            let rel = segments.join("/");
189            let name = match prefix {
190                Some(p) if !p.is_empty() => format!("{}/{rel}", p.trim_end_matches('/')),
191                _ => rel,
192            };
193            if self.exact.contains_key(&name) {
194                continue;
195            }
196            self.exact.insert(name, Entry { source: Source::Lazy { path, len }, from_dir: true, version: 0 });
197            added += 1;
198        }
199        Ok(added)
200    }
201
202    /// Add or replace an asset from memory while the game runs (between frames):
203    /// it gets a new version. Returns that version.
204    pub fn set(&mut self, name: &str, bytes: Vec<u8>) -> u32 {
205        self.replace(name, Source::Memory(bytes))
206    }
207
208    /// [`Assets::set`] with a file read on demand (opened now).
209    pub fn set_file(&mut self, name: &str, path: &Path) -> Result<u32, String> {
210        let file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
211        if !file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() {
212            return Err(format!("{}: not a regular file", path.display()));
213        }
214        Ok(self.replace(name, Source::File { file, path: path.to_owned() }))
215    }
216
217    /// Remove an asset while the game runs (between frames). False if there was none.
218    pub fn remove(&mut self, name: &str) -> bool {
219        let gone = self.exact.remove(name).is_some();
220        if gone {
221            self.index();
222        }
223        gone
224    }
225
226    fn replace(&mut self, name: &str, source: Source) -> u32 {
227        self.last_version += 1;
228        self.exact.insert(name.to_owned(), Entry { source, from_dir: false, version: self.last_version });
229        self.index();
230        self.last_version
231    }
232
233    /// Build the case-insensitive index and the sorted name list; logs collisions.
234    pub fn finish(&mut self) {
235        self.index();
236        for names in self.folded.values() {
237            if names.len() > 1 {
238                eprintln!(
239                    "[gasm] assets: {} differ only in case; case-insensitive lookups use {:?}",
240                    names.join(", "),
241                    names[0]
242                );
243            }
244        }
245    }
246
247    fn index(&mut self) {
248        self.folded.clear();
249        for (name, entry) in &self.exact {
250            if entry.from_dir {
251                self.folded.entry(name.to_ascii_lowercase()).or_default().push(name.clone());
252            }
253        }
254        for names in self.folded.values_mut() {
255            names.sort();
256        }
257        self.sorted = self.exact.keys().cloned().collect();
258        self.sorted.sort();
259    }
260
261    /// Look an asset up (exact name, then case-insensitively among folder entries).
262    pub fn get(&self, name: &str) -> Option<Asset<'_>> {
263        let entry = match self.exact.get(name) {
264            Some(e) => e,
265            None => self.exact.get(self.folded.get(&name.to_ascii_lowercase())?.first()?)?,
266        };
267        Some(Asset { assets: self, source: &entry.source, version: entry.version })
268    }
269
270    /// Current size in bytes, or `None` if there is no such asset.
271    pub fn size(&self, name: &str) -> Option<u64> {
272        self.get(name).map(|a| a.size())
273    }
274
275    /// Copy bytes starting at `offset` into `dst` (see [`Asset::read_at`]), or
276    /// `None` if there is no such asset.
277    pub fn read_at(&self, name: &str, offset: u64, dst: &mut [u8]) -> Option<usize> {
278        self.get(name).map(|a| a.read_at(offset, dst))
279    }
280
281    /// The asset's version (see [`Asset::version`]), or `None` if there is no such asset.
282    pub fn version(&self, name: &str) -> Option<u32> {
283        self.get(name).map(|a| a.version())
284    }
285
286    /// All asset names, sorted (by UTF-8 bytes).
287    pub fn names(&self) -> &[String] {
288        &self.sorted
289    }
290
291    /// Open a folder entry (cached). Refuses anything that is no longer a regular
292    /// file (e.g. replaced by a symlink since start-up).
293    fn open_lazy(&self, path: &Path) -> Option<Arc<File>> {
294        let mut open = self.open.borrow_mut();
295        if let Some(i) = open.iter().position(|(p, _)| p == path) {
296            let hit = open.remove(i)?;
297            let file = hit.1.clone();
298            open.push_back(hit);
299            return Some(file);
300        }
301        let ok = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file());
302        let file = match ok.then(|| File::open(path)) {
303            Some(Ok(f)) => Arc::new(f),
304            Some(Err(e)) => {
305                eprintln!("[gasm] assets: {}: {e}", path.display());
306                return None;
307            }
308            None => {
309                eprintln!("[gasm] assets: {}: no longer a regular file", path.display());
310                return None;
311            }
312        };
313        if open.len() >= OPEN_FILES {
314            open.pop_front();
315        }
316        open.push_back((path.to_owned(), file.clone()));
317        Some(file)
318    }
319}
320
321/// `--watch-asset name=path`: the file is re-opened as asset `name` whenever its
322/// size or modification time changes (in place or replaced by a rename), checked
323/// before each frame. A file that disappears keeps the last version until it's back.
324pub struct AssetWatch {
325    pub name: String,
326    pub path: PathBuf,
327    stamp: Option<(std::time::SystemTime, u64)>,
328}
329
330impl AssetWatch {
331    /// Watch `path`; the asset itself is added by [`AssetWatch::poll`] (or already given at start).
332    pub fn new(name: &str, path: &Path) -> AssetWatch {
333        AssetWatch { name: name.to_owned(), path: path.to_owned(), stamp: Self::stamp(path) }
334    }
335
336    fn stamp(path: &Path) -> Option<(std::time::SystemTime, u64)> {
337        let m = std::fs::metadata(path).ok()?;
338        Some((m.modified().ok()?, m.len()))
339    }
340
341    /// Re-open the file if it changed. Returns the new version, if any.
342    pub fn poll(&mut self, assets: &mut Assets) -> Option<u32> {
343        let now = Self::stamp(&self.path)?;
344        if self.stamp == Some(now) && assets.get(&self.name).is_some() {
345            return None;
346        }
347        self.stamp = Some(now);
348        match assets.set_file(&self.name, &self.path) {
349            Ok(v) => Some(v),
350            Err(e) => {
351                eprintln!("[gasm] assets: --watch-asset {e}");
352                None
353            }
354        }
355    }
356}
357
358/// Recursive, sorted walk collecting regular files (with their sizes); skips
359/// symlinks and hidden entries. Names must be valid UTF-8 (others are skipped
360/// with a warning).
361fn walk(dir: &Path, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, PathBuf, u64)>) -> Result<(), String> {
362    let mut entries: Vec<_> = std::fs::read_dir(dir)
363        .map_err(|e| format!("{}: {e}", dir.display()))?
364        .filter_map(Result::ok)
365        .collect();
366    entries.sort_by_key(|e| e.file_name());
367    for entry in entries {
368        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
369            eprintln!("[gasm] assets: skipping non-UTF-8 name {:?}", entry.path());
370            continue;
371        };
372        if name.starts_with('.') {
373            continue;
374        }
375        let Ok(ft) = entry.file_type() else { continue }; // does not follow symlinks
376        if ft.is_symlink() {
377            continue;
378        }
379        prefix.push(name);
380        if ft.is_dir() {
381            walk(&entry.path(), prefix, out)?;
382        } else if ft.is_file() {
383            match entry.metadata() {
384                Ok(m) => out.push((prefix.clone(), entry.path(), m.len())),
385                Err(e) => eprintln!("[gasm] assets: skipping {}: {e}", entry.path().display()),
386            }
387        }
388        prefix.pop();
389    }
390    Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn tree() -> PathBuf {
398        let d = std::env::temp_dir().join(format!("gasm-assets-test-{}", std::process::id()));
399        let _ = std::fs::remove_dir_all(&d);
400        std::fs::create_dir_all(d.join("ART")).unwrap();
401        std::fs::create_dir_all(d.join("WORLDS/1PLAYER")).unwrap();
402        std::fs::write(d.join("ART/ART.CAR"), b"car").unwrap();
403        std::fs::write(d.join("WORLDS/1PLAYER/MAP.RFM"), b"map-data").unwrap();
404        std::fs::write(d.join(".DS_Store"), b"x").unwrap();
405        std::fs::write(d.join("dup.txt"), b"lower").unwrap();
406        std::fs::write(d.join("DUP.TXT"), b"upper").unwrap();
407        #[cfg(unix)]
408        std::os::unix::fs::symlink("/etc/hosts", d.join("escape")).unwrap();
409        d
410    }
411
412    #[test]
413    fn replace_and_watch() {
414        let read = |a: &Assets, n: &str| {
415            let mut buf = vec![0u8; 64];
416            a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
417        };
418        let mut a = Assets::new();
419        a.insert_memory("weather.json", b"{}".to_vec());
420        a.finish();
421        assert_eq!(a.version("weather.json"), Some(0));
422        assert_eq!(a.set("weather.json", b"{\"t\":1}".to_vec()), 1);
423        assert_eq!((a.version("weather.json"), read(&a, "weather.json").as_deref()), (Some(1), Some("{\"t\":1}")));
424        assert_eq!(a.set("tiles/0.png", b"png".to_vec()), 2); // a new name is listed
425        assert_eq!(a.names(), ["tiles/0.png", "weather.json"]);
426        assert!(a.remove("tiles/0.png") && !a.remove("tiles/0.png"));
427        assert_eq!((a.version("tiles/0.png"), a.names().len()), (None, 1));
428        assert_eq!(a.set("tiles/0.png", b"png".to_vec()), 3); // never a version seen before
429
430        let d = std::env::temp_dir().join(format!("gasm-assets-watch-{}", std::process::id()));
431        let _ = std::fs::remove_dir_all(&d);
432        std::fs::create_dir_all(&d).unwrap();
433        let f = d.join("w.json");
434        std::fs::write(&f, b"one").unwrap();
435        let mut w = AssetWatch::new("w.json", &f);
436        assert_eq!(w.poll(&mut a), Some(4)); // not an asset yet: added
437        assert_eq!(w.poll(&mut a), None); // unchanged
438        std::fs::write(d.join("tmp"), b"second").unwrap();
439        std::fs::rename(d.join("tmp"), &f).unwrap(); // atomic replace: a new size
440        assert_eq!(w.poll(&mut a), Some(5));
441        assert_eq!(read(&a, "w.json").as_deref(), Some("second"));
442        std::fs::remove_file(&f).unwrap();
443        assert_eq!(w.poll(&mut a), None); // gone: the last version stays
444        assert_eq!(read(&a, "w.json").as_deref(), Some("second"));
445        let _ = std::fs::remove_dir_all(&d);
446    }
447
448    #[test]
449    fn folders_case_and_precedence() {
450        let d = tree();
451        let mut a = Assets::new();
452        a.insert_memory("ART/ART.CAR", b"explicit".to_vec());
453        a.add_dir(None, &d).unwrap();
454        a.add_dir(Some("cd"), &d).unwrap();
455        a.finish();
456        let read = |a: &Assets, n: &str| {
457            let mut buf = vec![0u8; 64];
458            a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
459        };
460        assert_eq!(read(&a, "ART/ART.CAR").as_deref(), Some("explicit")); // explicit wins
461        assert_eq!(read(&a, "cd/art/art.car").as_deref(), Some("car")); // case-insensitive
462        assert_eq!(read(&a, "Worlds/1player/map.rfm").as_deref(), Some("map-data"));
463        // Case collisions only exist on case-sensitive file systems (not default macOS/Windows).
464        let case_sensitive = std::fs::read_dir(&d).unwrap().filter_map(Result::ok)
465            .filter(|e| e.file_name().to_string_lossy().eq_ignore_ascii_case("dup.txt")).count() == 2;
466        if case_sensitive {
467            assert_eq!(read(&a, "dup.txt").as_deref(), Some("lower")); // exact beats folding
468            assert_eq!(read(&a, "Dup.Txt").as_deref(), Some("upper")); // first in sorted order ("DUP.TXT" < "dup.txt")
469        }
470        assert!(a.size(".DS_Store").is_none()); // hidden skipped
471        assert!(a.size("escape").is_none()); // symlink skipped
472        assert_eq!(a.size("worlds/1player/MAP.RFM"), Some(8));
473        let mut buf = [0u8; 4];
474        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 4, &mut buf), Some(4));
475        assert_eq!(&buf, b"data");
476        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 100, &mut buf), Some(0)); // past the end
477        assert!(a.read_at("missing", 0, &mut buf).is_none());
478        assert!(a.names().windows(2).all(|w| w[0] < w[1]));
479        let _ = std::fs::remove_dir_all(&d);
480    }
481
482    #[test]
483    fn many_folder_files_stay_under_the_open_file_limit() {
484        let d = std::env::temp_dir().join(format!("gasm-assets-many-{}", std::process::id()));
485        let _ = std::fs::remove_dir_all(&d);
486        std::fs::create_dir_all(&d).unwrap();
487        for i in 0..(OPEN_FILES * 3) {
488            std::fs::write(d.join(format!("F{i:04}")), format!("{i}")).unwrap();
489        }
490        let mut a = Assets::new();
491        a.add_dir(None, &d).unwrap();
492        a.finish();
493        let mut buf = [0u8; 8];
494        for round in 0..2 {
495            for i in 0..(OPEN_FILES * 3) {
496                let n = a.read_at(&format!("f{i:04}"), 0, &mut buf).unwrap();
497                assert_eq!(&buf[..n], format!("{i}").as_bytes(), "round {round}");
498            }
499        }
500        assert!(a.open.borrow().len() <= OPEN_FILES);
501        let _ = std::fs::remove_dir_all(&d);
502    }
503
504    #[test]
505    fn shrinking_file_never_panics() {
506        let p = std::env::temp_dir().join(format!("gasm-shrink-{}", std::process::id()));
507        std::fs::write(&p, vec![7u8; 1000]).unwrap();
508        let mut a = Assets::new();
509        a.insert_file("f", &p).unwrap();
510        a.finish();
511        std::fs::write(&p, vec![7u8; 10]).unwrap(); // truncate while "running"
512        let mut buf = vec![0u8; 100];
513        assert_eq!(a.size("f"), Some(10));
514        assert_eq!(a.read_at("f", 0, &mut buf), Some(10));
515        assert_eq!(a.read_at("f", 500, &mut buf), Some(0));
516        std::fs::remove_file(&p).unwrap(); // vanishes
517        let _ = a.read_at("f", 0, &mut buf); // must not panic (Unix keeps the inode)
518        let _ = std::fs::remove_file(&p);
519    }
520}