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 Assets {
68    /// A copy for a worker thread (wasi-threads): the same names and versions, its own
69    /// file handles and cache (files are reopened or duplicated, memory copied). Changes
70    /// made later (`set_asset`, `--watch-asset`) reach threads started after them.
71    pub fn for_thread(&self) -> Assets {
72        let exact = self
73            .exact
74            .iter()
75            .filter_map(|(name, e)| {
76                let source = match &e.source {
77                    Source::Memory(b) => Source::Memory(b.clone()),
78                    Source::File { file, path } => Source::File { file: file.try_clone().ok()?, path: path.clone() },
79                    Source::Lazy { path, len } => Source::Lazy { path: path.clone(), len: *len },
80                };
81                Some((name.clone(), Entry { source, from_dir: e.from_dir, version: e.version }))
82            })
83            .collect();
84        Assets { exact, folded: self.folded.clone(), sorted: self.sorted.clone(), open: RefCell::new(VecDeque::new()), last_version: self.last_version }
85    }
86}
87
88impl From<HashMap<String, Vec<u8>>> for Assets {
89    fn from(map: HashMap<String, Vec<u8>>) -> Assets {
90        let mut a = Assets::default();
91        for (name, bytes) in map {
92            a.insert_memory(&name, bytes);
93        }
94        a.finish();
95        a
96    }
97}
98
99#[cfg(unix)]
100fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
101    std::os::unix::fs::FileExt::read_at(file, buf, offset)
102}
103
104#[cfg(windows)]
105fn pread(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
106    std::os::windows::fs::FileExt::seek_read(file, buf, offset)
107}
108
109/// Fill `dst` from `offset`; stops early at the end of the file or on an error.
110fn read_file(file: &File, path: &Path, offset: u64, dst: &mut [u8]) -> usize {
111    let mut done = 0;
112    while done < dst.len() {
113        match pread(file, &mut dst[done..], offset + done as u64) {
114            Ok(0) => break,
115            Ok(n) => done += n,
116            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
117            Err(e) => {
118                eprintln!("[gasm] assets: {}: {e}", path.display());
119                break;
120            }
121        }
122    }
123    done
124}
125
126/// A resolved asset: its size and reads, without looking the name up again.
127pub struct Asset<'a> {
128    assets: &'a Assets,
129    source: &'a Source,
130    version: u32,
131}
132
133impl Asset<'_> {
134    /// 0 if given at start, else a new number each time it was replaced.
135    pub fn version(&self) -> u32 {
136        self.version
137    }
138
139    /// Size in bytes (for folder entries: as found at start-up).
140    pub fn size(&self) -> u64 {
141        match self.source {
142            Source::Memory(b) => b.len() as u64,
143            Source::File { file, .. } => file.metadata().map_or(0, |m| m.len()),
144            Source::Lazy { len, .. } => *len,
145        }
146    }
147
148    /// Copy bytes starting at `offset` into `dst`. Returns bytes copied (0 at or
149    /// past the end). Short reads (a file that shrank) return what was
150    /// available; I/O errors end the read early.
151    pub fn read_at(&self, offset: u64, dst: &mut [u8]) -> usize {
152        match self.source {
153            Source::Memory(b) => {
154                let start = offset.min(b.len() as u64) as usize;
155                let n = (b.len() - start).min(dst.len());
156                dst[..n].copy_from_slice(&b[start..start + n]);
157                n
158            }
159            Source::File { file, path } => read_file(file, path, offset, dst),
160            Source::Lazy { path, .. } => match self.assets.open_lazy(path) {
161                Some(file) => read_file(&file, path, offset, dst),
162                None => 0,
163            },
164        }
165    }
166}
167
168impl Assets {
169    pub fn new() -> Assets {
170        Assets::default()
171    }
172
173    pub fn len(&self) -> usize {
174        self.exact.len()
175    }
176
177    pub fn is_empty(&self) -> bool {
178        self.exact.is_empty()
179    }
180
181    /// Asset from memory (overrides any entry with the same name).
182    pub fn insert_memory(&mut self, name: &str, bytes: Vec<u8>) {
183        self.exact.insert(name.to_owned(), Entry { source: Source::Memory(bytes), from_dir: false, version: 0 });
184    }
185
186    /// Asset backed by a file, opened now and read on demand (overrides any
187    /// entry with the same name).
188    pub fn insert_file(&mut self, name: &str, path: &Path) -> Result<(), String> {
189        let file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
190        if !file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() {
191            return Err(format!("{}: not a regular file", path.display()));
192        }
193        self.exact.insert(name.to_owned(), Entry { source: Source::File { file, path: path.to_owned() }, from_dir: false, version: 0 });
194        Ok(())
195    }
196
197    /// Expose every regular file under `dir` (recursively), named
198    /// `[prefix/]relative/path`. Existing names are kept (explicit assets win).
199    /// Returns the number of files added. Call [`Assets::finish`] afterwards.
200    pub fn add_dir(&mut self, prefix: Option<&str>, dir: &Path) -> Result<usize, String> {
201        let meta = std::fs::metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
202        if !meta.is_dir() {
203            return Err(format!("{}: not a directory", dir.display()));
204        }
205        let mut files = Vec::new();
206        walk(dir, &mut Vec::new(), &mut files)?;
207        let mut added = 0;
208        for (segments, path, len) in files {
209            let rel = segments.join("/");
210            let name = match prefix {
211                Some(p) if !p.is_empty() => format!("{}/{rel}", p.trim_end_matches('/')),
212                _ => rel,
213            };
214            if self.exact.contains_key(&name) {
215                continue;
216            }
217            self.exact.insert(name, Entry { source: Source::Lazy { path, len }, from_dir: true, version: 0 });
218            added += 1;
219        }
220        Ok(added)
221    }
222
223    /// `--mods <dir>`: the folder's resource packs (`*.pck`, `*.zip`, top level, not
224    /// hidden), each read on demand as `mods/<file name>`, sorted by name (the load
225    /// order). Files that can't be read are refused, not fatal: the game gets them as
226    /// asset `mods.refused` (`name<TAB>reason` lines) and they're logged. Returns
227    /// (mounted, refused).
228    pub fn add_mods(&mut self, dir: &Path) -> Result<(Vec<String>, Vec<(String, String)>), String> {
229        let entries = std::fs::read_dir(dir).map_err(|e| format!("--mods {}: {e}", dir.display()))?;
230        let mut names: Vec<(String, PathBuf)> = entries
231            .filter_map(|e| e.ok())
232            .filter_map(|e| Some((e.file_name().into_string().ok()?, e.path())))
233            .filter(|(n, _)| !n.starts_with('.'))
234            .filter(|(n, _)| {
235                let l = n.to_ascii_lowercase();
236                l.ends_with(".pck") || l.ends_with(".zip")
237            })
238            .collect();
239        names.sort();
240        let (mut mounted, mut refused) = (Vec::new(), Vec::new());
241        for (name, path) in names {
242            let ok = std::fs::symlink_metadata(&path)
243                .map_err(|e| e.to_string())
244                .and_then(|m| if m.is_file() { Ok(m.len()) } else { Err("not a regular file".into()) })
245                .and_then(|len| File::open(&path).map(|_| len).map_err(|e| e.to_string()));
246            match ok {
247                Ok(len) => {
248                    self.exact.insert(format!("mods/{name}"), Entry { source: Source::Lazy { path, len }, from_dir: true, version: 0 });
249                    mounted.push(name);
250                }
251                Err(why) => refused.push((name, why)),
252            }
253        }
254        if !refused.is_empty() {
255            let text: String = refused.iter().map(|(n, w)| format!("{n}\t{w}\n")).collect();
256            self.exact.insert("mods.refused".into(), Entry { source: Source::Memory(text.into_bytes()), from_dir: false, version: 0 });
257        }
258        Ok((mounted, refused))
259    }
260
261    /// Refuse a mounted mod (before the game starts): unmounted, and its line added to
262    /// `mods.refused`.
263    pub fn refuse_mod(&mut self, file: &str, why: &str) {
264        self.exact.remove(&format!("mods/{file}"));
265        let mut text = match self.exact.get("mods.refused").map(|e| &e.source) {
266            Some(Source::Memory(b)) => String::from_utf8_lossy(b).into_owned(),
267            _ => String::new(),
268        };
269        text += &format!("{file}\t{why}\n");
270        self.exact.insert("mods.refused".into(), Entry { source: Source::Memory(text.into_bytes()), from_dir: false, version: 0 });
271        self.index();
272    }
273
274    /// Add or replace an asset from memory while the game runs (between frames):
275    /// it gets a new version. Returns that version.
276    pub fn set(&mut self, name: &str, bytes: Vec<u8>) -> u32 {
277        self.replace(name, Source::Memory(bytes))
278    }
279
280    /// [`Assets::set`] with a file read on demand (opened now).
281    pub fn set_file(&mut self, name: &str, path: &Path) -> Result<u32, String> {
282        let file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
283        if !file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() {
284            return Err(format!("{}: not a regular file", path.display()));
285        }
286        Ok(self.replace(name, Source::File { file, path: path.to_owned() }))
287    }
288
289    /// Remove an asset while the game runs (between frames). False if there was none.
290    pub fn remove(&mut self, name: &str) -> bool {
291        let gone = self.exact.remove(name).is_some();
292        if gone {
293            self.index();
294        }
295        gone
296    }
297
298    fn replace(&mut self, name: &str, source: Source) -> u32 {
299        self.last_version += 1;
300        self.exact.insert(name.to_owned(), Entry { source, from_dir: false, version: self.last_version });
301        self.index();
302        self.last_version
303    }
304
305    /// Build the case-insensitive index and the sorted name list; logs collisions.
306    pub fn finish(&mut self) {
307        self.index();
308        for names in self.folded.values() {
309            if names.len() > 1 {
310                eprintln!(
311                    "[gasm] assets: {} differ only in case; case-insensitive lookups use {:?}",
312                    names.join(", "),
313                    names[0]
314                );
315            }
316        }
317    }
318
319    fn index(&mut self) {
320        self.folded.clear();
321        for (name, entry) in &self.exact {
322            if entry.from_dir {
323                self.folded.entry(name.to_ascii_lowercase()).or_default().push(name.clone());
324            }
325        }
326        for names in self.folded.values_mut() {
327            names.sort();
328        }
329        self.sorted = self.exact.keys().cloned().collect();
330        self.sorted.sort();
331    }
332
333    /// Look an asset up (exact name, then case-insensitively among folder entries).
334    pub fn get(&self, name: &str) -> Option<Asset<'_>> {
335        let entry = match self.exact.get(name) {
336            Some(e) => e,
337            None => self.exact.get(self.folded.get(&name.to_ascii_lowercase())?.first()?)?,
338        };
339        Some(Asset { assets: self, source: &entry.source, version: entry.version })
340    }
341
342    /// Current size in bytes, or `None` if there is no such asset.
343    pub fn size(&self, name: &str) -> Option<u64> {
344        self.get(name).map(|a| a.size())
345    }
346
347    /// Copy bytes starting at `offset` into `dst` (see [`Asset::read_at`]), or
348    /// `None` if there is no such asset.
349    pub fn read_at(&self, name: &str, offset: u64, dst: &mut [u8]) -> Option<usize> {
350        self.get(name).map(|a| a.read_at(offset, dst))
351    }
352
353    /// The asset's version (see [`Asset::version`]), or `None` if there is no such asset.
354    pub fn version(&self, name: &str) -> Option<u32> {
355        self.get(name).map(|a| a.version())
356    }
357
358    /// All asset names, sorted (by UTF-8 bytes).
359    pub fn names(&self) -> &[String] {
360        &self.sorted
361    }
362
363    /// Open a folder entry (cached). Refuses anything that is no longer a regular
364    /// file (e.g. replaced by a symlink since start-up).
365    fn open_lazy(&self, path: &Path) -> Option<Arc<File>> {
366        let mut open = self.open.borrow_mut();
367        if let Some(i) = open.iter().position(|(p, _)| p == path) {
368            let hit = open.remove(i)?;
369            let file = hit.1.clone();
370            open.push_back(hit);
371            return Some(file);
372        }
373        let ok = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file());
374        let file = match ok.then(|| File::open(path)) {
375            Some(Ok(f)) => Arc::new(f),
376            Some(Err(e)) => {
377                eprintln!("[gasm] assets: {}: {e}", path.display());
378                return None;
379            }
380            None => {
381                eprintln!("[gasm] assets: {}: no longer a regular file", path.display());
382                return None;
383            }
384        };
385        if open.len() >= OPEN_FILES {
386            open.pop_front();
387        }
388        open.push_back((path.to_owned(), file.clone()));
389        Some(file)
390    }
391}
392
393/// `--watch-asset name=path`: the file is re-opened as asset `name` whenever its
394/// size or modification time changes (in place or replaced by a rename), checked
395/// before each frame. A file that disappears keeps the last version until it's back.
396pub struct AssetWatch {
397    pub name: String,
398    pub path: PathBuf,
399    stamp: Option<(std::time::SystemTime, u64)>,
400}
401
402impl AssetWatch {
403    /// Watch `path`; the asset itself is added by [`AssetWatch::poll`] (or already given at start).
404    pub fn new(name: &str, path: &Path) -> AssetWatch {
405        AssetWatch { name: name.to_owned(), path: path.to_owned(), stamp: Self::stamp(path) }
406    }
407
408    fn stamp(path: &Path) -> Option<(std::time::SystemTime, u64)> {
409        let m = std::fs::metadata(path).ok()?;
410        Some((m.modified().ok()?, m.len()))
411    }
412
413    /// Re-open the file if it changed. Returns the new version, if any.
414    pub fn poll(&mut self, assets: &mut Assets) -> Option<u32> {
415        let now = Self::stamp(&self.path)?;
416        if self.stamp == Some(now) && assets.get(&self.name).is_some() {
417            return None;
418        }
419        self.stamp = Some(now);
420        match assets.set_file(&self.name, &self.path) {
421            Ok(v) => Some(v),
422            Err(e) => {
423                eprintln!("[gasm] assets: --watch-asset {e}");
424                None
425            }
426        }
427    }
428}
429
430/// Recursive, sorted walk collecting regular files (with their sizes); skips
431/// symlinks and hidden entries. Names must be valid UTF-8 (others are skipped
432/// with a warning).
433fn walk(dir: &Path, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, PathBuf, u64)>) -> Result<(), String> {
434    let mut entries: Vec<_> = std::fs::read_dir(dir)
435        .map_err(|e| format!("{}: {e}", dir.display()))?
436        .filter_map(Result::ok)
437        .collect();
438    entries.sort_by_key(|e| e.file_name());
439    for entry in entries {
440        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
441            eprintln!("[gasm] assets: skipping non-UTF-8 name {:?}", entry.path());
442            continue;
443        };
444        if name.starts_with('.') {
445            continue;
446        }
447        let Ok(ft) = entry.file_type() else { continue }; // does not follow symlinks
448        if ft.is_symlink() {
449            continue;
450        }
451        prefix.push(name);
452        if ft.is_dir() {
453            walk(&entry.path(), prefix, out)?;
454        } else if ft.is_file() {
455            match entry.metadata() {
456                Ok(m) => out.push((prefix.clone(), entry.path(), m.len())),
457                Err(e) => eprintln!("[gasm] assets: skipping {}: {e}", entry.path().display()),
458            }
459        }
460        prefix.pop();
461    }
462    Ok(())
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn mods_are_the_folders_packs_in_name_order() {
471        let dir = std::env::temp_dir().join(format!("gasm-mods-test-{}", std::process::id()));
472        let _ = std::fs::remove_dir_all(&dir);
473        std::fs::create_dir_all(dir.join("sub")).unwrap();
474        for f in ["b.ZIP", "a.pck", ".hidden.pck", "notes.txt", "sub/c.pck"] {
475            std::fs::write(dir.join(f), f).unwrap();
476        }
477        #[cfg(unix)]
478        {
479            use std::os::unix::fs::PermissionsExt;
480            std::fs::write(dir.join("locked.pck"), "x").unwrap();
481            std::fs::set_permissions(dir.join("locked.pck"), std::fs::Permissions::from_mode(0)).unwrap();
482        }
483        let mut a = Assets::new();
484        let (mounted, refused) = a.add_mods(&dir).unwrap();
485        assert_eq!(mounted, ["a.pck", "b.ZIP"]);
486        assert_eq!(a.size("mods/a.pck"), Some(5));
487        #[cfg(unix)]
488        if refused.len() == 1 {
489            // (as root everything is readable)
490            assert_eq!(refused[0].0, "locked.pck");
491            assert!(a.size("mods.refused").is_some());
492        }
493        let _ = std::fs::remove_dir_all(&dir);
494    }
495
496    fn tree() -> PathBuf {
497        let d = std::env::temp_dir().join(format!("gasm-assets-test-{}", std::process::id()));
498        let _ = std::fs::remove_dir_all(&d);
499        std::fs::create_dir_all(d.join("ART")).unwrap();
500        std::fs::create_dir_all(d.join("WORLDS/1PLAYER")).unwrap();
501        std::fs::write(d.join("ART/ART.CAR"), b"car").unwrap();
502        std::fs::write(d.join("WORLDS/1PLAYER/MAP.RFM"), b"map-data").unwrap();
503        std::fs::write(d.join(".DS_Store"), b"x").unwrap();
504        std::fs::write(d.join("dup.txt"), b"lower").unwrap();
505        std::fs::write(d.join("DUP.TXT"), b"upper").unwrap();
506        #[cfg(unix)]
507        std::os::unix::fs::symlink("/etc/hosts", d.join("escape")).unwrap();
508        d
509    }
510
511    #[test]
512    fn replace_and_watch() {
513        let read = |a: &Assets, n: &str| {
514            let mut buf = vec![0u8; 64];
515            a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
516        };
517        let mut a = Assets::new();
518        a.insert_memory("weather.json", b"{}".to_vec());
519        a.finish();
520        assert_eq!(a.version("weather.json"), Some(0));
521        assert_eq!(a.set("weather.json", b"{\"t\":1}".to_vec()), 1);
522        assert_eq!((a.version("weather.json"), read(&a, "weather.json").as_deref()), (Some(1), Some("{\"t\":1}")));
523        assert_eq!(a.set("tiles/0.png", b"png".to_vec()), 2); // a new name is listed
524        assert_eq!(a.names(), ["tiles/0.png", "weather.json"]);
525        assert!(a.remove("tiles/0.png") && !a.remove("tiles/0.png"));
526        assert_eq!((a.version("tiles/0.png"), a.names().len()), (None, 1));
527        assert_eq!(a.set("tiles/0.png", b"png".to_vec()), 3); // never a version seen before
528
529        let d = std::env::temp_dir().join(format!("gasm-assets-watch-{}", std::process::id()));
530        let _ = std::fs::remove_dir_all(&d);
531        std::fs::create_dir_all(&d).unwrap();
532        let f = d.join("w.json");
533        std::fs::write(&f, b"one").unwrap();
534        let mut w = AssetWatch::new("w.json", &f);
535        assert_eq!(w.poll(&mut a), Some(4)); // not an asset yet: added
536        assert_eq!(w.poll(&mut a), None); // unchanged
537        std::fs::write(d.join("tmp"), b"second").unwrap();
538        std::fs::rename(d.join("tmp"), &f).unwrap(); // atomic replace: a new size
539        assert_eq!(w.poll(&mut a), Some(5));
540        assert_eq!(read(&a, "w.json").as_deref(), Some("second"));
541        std::fs::remove_file(&f).unwrap();
542        assert_eq!(w.poll(&mut a), None); // gone: the last version stays
543        assert_eq!(read(&a, "w.json").as_deref(), Some("second"));
544        let _ = std::fs::remove_dir_all(&d);
545    }
546
547    #[test]
548    fn folders_case_and_precedence() {
549        let d = tree();
550        let mut a = Assets::new();
551        a.insert_memory("ART/ART.CAR", b"explicit".to_vec());
552        a.add_dir(None, &d).unwrap();
553        a.add_dir(Some("cd"), &d).unwrap();
554        a.finish();
555        let read = |a: &Assets, n: &str| {
556            let mut buf = vec![0u8; 64];
557            a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
558        };
559        assert_eq!(read(&a, "ART/ART.CAR").as_deref(), Some("explicit")); // explicit wins
560        assert_eq!(read(&a, "cd/art/art.car").as_deref(), Some("car")); // case-insensitive
561        assert_eq!(read(&a, "Worlds/1player/map.rfm").as_deref(), Some("map-data"));
562        // Case collisions only exist on case-sensitive file systems (not default macOS/Windows).
563        let case_sensitive = std::fs::read_dir(&d).unwrap().filter_map(Result::ok)
564            .filter(|e| e.file_name().to_string_lossy().eq_ignore_ascii_case("dup.txt")).count() == 2;
565        if case_sensitive {
566            assert_eq!(read(&a, "dup.txt").as_deref(), Some("lower")); // exact beats folding
567            assert_eq!(read(&a, "Dup.Txt").as_deref(), Some("upper")); // first in sorted order ("DUP.TXT" < "dup.txt")
568        }
569        assert!(a.size(".DS_Store").is_none()); // hidden skipped
570        assert!(a.size("escape").is_none()); // symlink skipped
571        assert_eq!(a.size("worlds/1player/MAP.RFM"), Some(8));
572        let mut buf = [0u8; 4];
573        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 4, &mut buf), Some(4));
574        assert_eq!(&buf, b"data");
575        assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 100, &mut buf), Some(0)); // past the end
576        assert!(a.read_at("missing", 0, &mut buf).is_none());
577        assert!(a.names().windows(2).all(|w| w[0] < w[1]));
578        let _ = std::fs::remove_dir_all(&d);
579    }
580
581    #[test]
582    fn many_folder_files_stay_under_the_open_file_limit() {
583        let d = std::env::temp_dir().join(format!("gasm-assets-many-{}", std::process::id()));
584        let _ = std::fs::remove_dir_all(&d);
585        std::fs::create_dir_all(&d).unwrap();
586        for i in 0..(OPEN_FILES * 3) {
587            std::fs::write(d.join(format!("F{i:04}")), format!("{i}")).unwrap();
588        }
589        let mut a = Assets::new();
590        a.add_dir(None, &d).unwrap();
591        a.finish();
592        let mut buf = [0u8; 8];
593        for round in 0..2 {
594            for i in 0..(OPEN_FILES * 3) {
595                let n = a.read_at(&format!("f{i:04}"), 0, &mut buf).unwrap();
596                assert_eq!(&buf[..n], format!("{i}").as_bytes(), "round {round}");
597            }
598        }
599        assert!(a.open.borrow().len() <= OPEN_FILES);
600        let _ = std::fs::remove_dir_all(&d);
601    }
602
603    #[test]
604    fn shrinking_file_never_panics() {
605        let p = std::env::temp_dir().join(format!("gasm-shrink-{}", std::process::id()));
606        std::fs::write(&p, vec![7u8; 1000]).unwrap();
607        let mut a = Assets::new();
608        a.insert_file("f", &p).unwrap();
609        a.finish();
610        std::fs::write(&p, vec![7u8; 10]).unwrap(); // truncate while "running"
611        let mut buf = vec![0u8; 100];
612        assert_eq!(a.size("f"), Some(10));
613        assert_eq!(a.read_at("f", 0, &mut buf), Some(10));
614        assert_eq!(a.read_at("f", 500, &mut buf), Some(0));
615        std::fs::remove_file(&p).unwrap(); // vanishes
616        let _ = a.read_at("f", 0, &mut buf); // must not panic (Unix keeps the inode)
617        let _ = std::fs::remove_file(&p);
618    }
619}