1use std::cell::RefCell;
32use std::collections::{BTreeMap, HashMap, VecDeque};
33use std::fs::File;
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36
37const OPEN_FILES: usize = 64;
39
40enum Source {
41 Memory(Vec<u8>),
42 File { file: File, path: PathBuf },
44 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 folded: BTreeMap<String, Vec<String>>,
59 sorted: Vec<String>,
61 open: RefCell<VecDeque<(PathBuf, Arc<File>)>>,
63 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
88fn 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
105pub struct Asset<'a> {
107 assets: &'a Assets,
108 source: &'a Source,
109 version: u32,
110}
111
112impl Asset<'_> {
113 pub fn version(&self) -> u32 {
115 self.version
116 }
117
118 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 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 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 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 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 pub fn add_mods(&mut self, dir: &Path) -> Result<(Vec<String>, Vec<(String, String)>), String> {
208 let entries = std::fs::read_dir(dir).map_err(|e| format!("--mods {}: {e}", dir.display()))?;
209 let mut names: Vec<(String, PathBuf)> = entries
210 .filter_map(|e| e.ok())
211 .filter_map(|e| Some((e.file_name().into_string().ok()?, e.path())))
212 .filter(|(n, _)| !n.starts_with('.'))
213 .filter(|(n, _)| {
214 let l = n.to_ascii_lowercase();
215 l.ends_with(".pck") || l.ends_with(".zip")
216 })
217 .collect();
218 names.sort();
219 let (mut mounted, mut refused) = (Vec::new(), Vec::new());
220 for (name, path) in names {
221 let ok = std::fs::symlink_metadata(&path)
222 .map_err(|e| e.to_string())
223 .and_then(|m| if m.is_file() { Ok(m.len()) } else { Err("not a regular file".into()) })
224 .and_then(|len| File::open(&path).map(|_| len).map_err(|e| e.to_string()));
225 match ok {
226 Ok(len) => {
227 self.exact.insert(format!("mods/{name}"), Entry { source: Source::Lazy { path, len }, from_dir: true, version: 0 });
228 mounted.push(name);
229 }
230 Err(why) => refused.push((name, why)),
231 }
232 }
233 if !refused.is_empty() {
234 let text: String = refused.iter().map(|(n, w)| format!("{n}\t{w}\n")).collect();
235 self.exact.insert("mods.refused".into(), Entry { source: Source::Memory(text.into_bytes()), from_dir: false, version: 0 });
236 }
237 Ok((mounted, refused))
238 }
239
240 pub fn refuse_mod(&mut self, file: &str, why: &str) {
243 self.exact.remove(&format!("mods/{file}"));
244 let mut text = match self.exact.get("mods.refused").map(|e| &e.source) {
245 Some(Source::Memory(b)) => String::from_utf8_lossy(b).into_owned(),
246 _ => String::new(),
247 };
248 text += &format!("{file}\t{why}\n");
249 self.exact.insert("mods.refused".into(), Entry { source: Source::Memory(text.into_bytes()), from_dir: false, version: 0 });
250 self.index();
251 }
252
253 pub fn set(&mut self, name: &str, bytes: Vec<u8>) -> u32 {
256 self.replace(name, Source::Memory(bytes))
257 }
258
259 pub fn set_file(&mut self, name: &str, path: &Path) -> Result<u32, String> {
261 let file = File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
262 if !file.metadata().map_err(|e| format!("{}: {e}", path.display()))?.is_file() {
263 return Err(format!("{}: not a regular file", path.display()));
264 }
265 Ok(self.replace(name, Source::File { file, path: path.to_owned() }))
266 }
267
268 pub fn remove(&mut self, name: &str) -> bool {
270 let gone = self.exact.remove(name).is_some();
271 if gone {
272 self.index();
273 }
274 gone
275 }
276
277 fn replace(&mut self, name: &str, source: Source) -> u32 {
278 self.last_version += 1;
279 self.exact.insert(name.to_owned(), Entry { source, from_dir: false, version: self.last_version });
280 self.index();
281 self.last_version
282 }
283
284 pub fn finish(&mut self) {
286 self.index();
287 for names in self.folded.values() {
288 if names.len() > 1 {
289 eprintln!(
290 "[gasm] assets: {} differ only in case; case-insensitive lookups use {:?}",
291 names.join(", "),
292 names[0]
293 );
294 }
295 }
296 }
297
298 fn index(&mut self) {
299 self.folded.clear();
300 for (name, entry) in &self.exact {
301 if entry.from_dir {
302 self.folded.entry(name.to_ascii_lowercase()).or_default().push(name.clone());
303 }
304 }
305 for names in self.folded.values_mut() {
306 names.sort();
307 }
308 self.sorted = self.exact.keys().cloned().collect();
309 self.sorted.sort();
310 }
311
312 pub fn get(&self, name: &str) -> Option<Asset<'_>> {
314 let entry = match self.exact.get(name) {
315 Some(e) => e,
316 None => self.exact.get(self.folded.get(&name.to_ascii_lowercase())?.first()?)?,
317 };
318 Some(Asset { assets: self, source: &entry.source, version: entry.version })
319 }
320
321 pub fn size(&self, name: &str) -> Option<u64> {
323 self.get(name).map(|a| a.size())
324 }
325
326 pub fn read_at(&self, name: &str, offset: u64, dst: &mut [u8]) -> Option<usize> {
329 self.get(name).map(|a| a.read_at(offset, dst))
330 }
331
332 pub fn version(&self, name: &str) -> Option<u32> {
334 self.get(name).map(|a| a.version())
335 }
336
337 pub fn names(&self) -> &[String] {
339 &self.sorted
340 }
341
342 fn open_lazy(&self, path: &Path) -> Option<Arc<File>> {
345 let mut open = self.open.borrow_mut();
346 if let Some(i) = open.iter().position(|(p, _)| p == path) {
347 let hit = open.remove(i)?;
348 let file = hit.1.clone();
349 open.push_back(hit);
350 return Some(file);
351 }
352 let ok = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file());
353 let file = match ok.then(|| File::open(path)) {
354 Some(Ok(f)) => Arc::new(f),
355 Some(Err(e)) => {
356 eprintln!("[gasm] assets: {}: {e}", path.display());
357 return None;
358 }
359 None => {
360 eprintln!("[gasm] assets: {}: no longer a regular file", path.display());
361 return None;
362 }
363 };
364 if open.len() >= OPEN_FILES {
365 open.pop_front();
366 }
367 open.push_back((path.to_owned(), file.clone()));
368 Some(file)
369 }
370}
371
372pub struct AssetWatch {
376 pub name: String,
377 pub path: PathBuf,
378 stamp: Option<(std::time::SystemTime, u64)>,
379}
380
381impl AssetWatch {
382 pub fn new(name: &str, path: &Path) -> AssetWatch {
384 AssetWatch { name: name.to_owned(), path: path.to_owned(), stamp: Self::stamp(path) }
385 }
386
387 fn stamp(path: &Path) -> Option<(std::time::SystemTime, u64)> {
388 let m = std::fs::metadata(path).ok()?;
389 Some((m.modified().ok()?, m.len()))
390 }
391
392 pub fn poll(&mut self, assets: &mut Assets) -> Option<u32> {
394 let now = Self::stamp(&self.path)?;
395 if self.stamp == Some(now) && assets.get(&self.name).is_some() {
396 return None;
397 }
398 self.stamp = Some(now);
399 match assets.set_file(&self.name, &self.path) {
400 Ok(v) => Some(v),
401 Err(e) => {
402 eprintln!("[gasm] assets: --watch-asset {e}");
403 None
404 }
405 }
406 }
407}
408
409fn walk(dir: &Path, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, PathBuf, u64)>) -> Result<(), String> {
413 let mut entries: Vec<_> = std::fs::read_dir(dir)
414 .map_err(|e| format!("{}: {e}", dir.display()))?
415 .filter_map(Result::ok)
416 .collect();
417 entries.sort_by_key(|e| e.file_name());
418 for entry in entries {
419 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
420 eprintln!("[gasm] assets: skipping non-UTF-8 name {:?}", entry.path());
421 continue;
422 };
423 if name.starts_with('.') {
424 continue;
425 }
426 let Ok(ft) = entry.file_type() else { continue }; if ft.is_symlink() {
428 continue;
429 }
430 prefix.push(name);
431 if ft.is_dir() {
432 walk(&entry.path(), prefix, out)?;
433 } else if ft.is_file() {
434 match entry.metadata() {
435 Ok(m) => out.push((prefix.clone(), entry.path(), m.len())),
436 Err(e) => eprintln!("[gasm] assets: skipping {}: {e}", entry.path().display()),
437 }
438 }
439 prefix.pop();
440 }
441 Ok(())
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn mods_are_the_folders_packs_in_name_order() {
450 let dir = std::env::temp_dir().join(format!("gasm-mods-test-{}", std::process::id()));
451 let _ = std::fs::remove_dir_all(&dir);
452 std::fs::create_dir_all(dir.join("sub")).unwrap();
453 for f in ["b.ZIP", "a.pck", ".hidden.pck", "notes.txt", "sub/c.pck"] {
454 std::fs::write(dir.join(f), f).unwrap();
455 }
456 #[cfg(unix)]
457 {
458 use std::os::unix::fs::PermissionsExt;
459 std::fs::write(dir.join("locked.pck"), "x").unwrap();
460 std::fs::set_permissions(dir.join("locked.pck"), std::fs::Permissions::from_mode(0)).unwrap();
461 }
462 let mut a = Assets::new();
463 let (mounted, refused) = a.add_mods(&dir).unwrap();
464 assert_eq!(mounted, ["a.pck", "b.ZIP"]);
465 assert_eq!(a.size("mods/a.pck"), Some(5));
466 #[cfg(unix)]
467 if refused.len() == 1 {
468 assert_eq!(refused[0].0, "locked.pck");
470 assert!(a.size("mods.refused").is_some());
471 }
472 let _ = std::fs::remove_dir_all(&dir);
473 }
474
475 fn tree() -> PathBuf {
476 let d = std::env::temp_dir().join(format!("gasm-assets-test-{}", std::process::id()));
477 let _ = std::fs::remove_dir_all(&d);
478 std::fs::create_dir_all(d.join("ART")).unwrap();
479 std::fs::create_dir_all(d.join("WORLDS/1PLAYER")).unwrap();
480 std::fs::write(d.join("ART/ART.CAR"), b"car").unwrap();
481 std::fs::write(d.join("WORLDS/1PLAYER/MAP.RFM"), b"map-data").unwrap();
482 std::fs::write(d.join(".DS_Store"), b"x").unwrap();
483 std::fs::write(d.join("dup.txt"), b"lower").unwrap();
484 std::fs::write(d.join("DUP.TXT"), b"upper").unwrap();
485 #[cfg(unix)]
486 std::os::unix::fs::symlink("/etc/hosts", d.join("escape")).unwrap();
487 d
488 }
489
490 #[test]
491 fn replace_and_watch() {
492 let read = |a: &Assets, n: &str| {
493 let mut buf = vec![0u8; 64];
494 a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
495 };
496 let mut a = Assets::new();
497 a.insert_memory("weather.json", b"{}".to_vec());
498 a.finish();
499 assert_eq!(a.version("weather.json"), Some(0));
500 assert_eq!(a.set("weather.json", b"{\"t\":1}".to_vec()), 1);
501 assert_eq!((a.version("weather.json"), read(&a, "weather.json").as_deref()), (Some(1), Some("{\"t\":1}")));
502 assert_eq!(a.set("tiles/0.png", b"png".to_vec()), 2); assert_eq!(a.names(), ["tiles/0.png", "weather.json"]);
504 assert!(a.remove("tiles/0.png") && !a.remove("tiles/0.png"));
505 assert_eq!((a.version("tiles/0.png"), a.names().len()), (None, 1));
506 assert_eq!(a.set("tiles/0.png", b"png".to_vec()), 3); let d = std::env::temp_dir().join(format!("gasm-assets-watch-{}", std::process::id()));
509 let _ = std::fs::remove_dir_all(&d);
510 std::fs::create_dir_all(&d).unwrap();
511 let f = d.join("w.json");
512 std::fs::write(&f, b"one").unwrap();
513 let mut w = AssetWatch::new("w.json", &f);
514 assert_eq!(w.poll(&mut a), Some(4)); assert_eq!(w.poll(&mut a), None); std::fs::write(d.join("tmp"), b"second").unwrap();
517 std::fs::rename(d.join("tmp"), &f).unwrap(); assert_eq!(w.poll(&mut a), Some(5));
519 assert_eq!(read(&a, "w.json").as_deref(), Some("second"));
520 std::fs::remove_file(&f).unwrap();
521 assert_eq!(w.poll(&mut a), None); assert_eq!(read(&a, "w.json").as_deref(), Some("second"));
523 let _ = std::fs::remove_dir_all(&d);
524 }
525
526 #[test]
527 fn folders_case_and_precedence() {
528 let d = tree();
529 let mut a = Assets::new();
530 a.insert_memory("ART/ART.CAR", b"explicit".to_vec());
531 a.add_dir(None, &d).unwrap();
532 a.add_dir(Some("cd"), &d).unwrap();
533 a.finish();
534 let read = |a: &Assets, n: &str| {
535 let mut buf = vec![0u8; 64];
536 a.read_at(n, 0, &mut buf).map(|k| String::from_utf8_lossy(&buf[..k]).into_owned())
537 };
538 assert_eq!(read(&a, "ART/ART.CAR").as_deref(), Some("explicit")); assert_eq!(read(&a, "cd/art/art.car").as_deref(), Some("car")); assert_eq!(read(&a, "Worlds/1player/map.rfm").as_deref(), Some("map-data"));
541 let case_sensitive = std::fs::read_dir(&d).unwrap().filter_map(Result::ok)
543 .filter(|e| e.file_name().to_string_lossy().eq_ignore_ascii_case("dup.txt")).count() == 2;
544 if case_sensitive {
545 assert_eq!(read(&a, "dup.txt").as_deref(), Some("lower")); assert_eq!(read(&a, "Dup.Txt").as_deref(), Some("upper")); }
548 assert!(a.size(".DS_Store").is_none()); assert!(a.size("escape").is_none()); assert_eq!(a.size("worlds/1player/MAP.RFM"), Some(8));
551 let mut buf = [0u8; 4];
552 assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 4, &mut buf), Some(4));
553 assert_eq!(&buf, b"data");
554 assert_eq!(a.read_at("WORLDS/1PLAYER/MAP.RFM", 100, &mut buf), Some(0)); assert!(a.read_at("missing", 0, &mut buf).is_none());
556 assert!(a.names().windows(2).all(|w| w[0] < w[1]));
557 let _ = std::fs::remove_dir_all(&d);
558 }
559
560 #[test]
561 fn many_folder_files_stay_under_the_open_file_limit() {
562 let d = std::env::temp_dir().join(format!("gasm-assets-many-{}", std::process::id()));
563 let _ = std::fs::remove_dir_all(&d);
564 std::fs::create_dir_all(&d).unwrap();
565 for i in 0..(OPEN_FILES * 3) {
566 std::fs::write(d.join(format!("F{i:04}")), format!("{i}")).unwrap();
567 }
568 let mut a = Assets::new();
569 a.add_dir(None, &d).unwrap();
570 a.finish();
571 let mut buf = [0u8; 8];
572 for round in 0..2 {
573 for i in 0..(OPEN_FILES * 3) {
574 let n = a.read_at(&format!("f{i:04}"), 0, &mut buf).unwrap();
575 assert_eq!(&buf[..n], format!("{i}").as_bytes(), "round {round}");
576 }
577 }
578 assert!(a.open.borrow().len() <= OPEN_FILES);
579 let _ = std::fs::remove_dir_all(&d);
580 }
581
582 #[test]
583 fn shrinking_file_never_panics() {
584 let p = std::env::temp_dir().join(format!("gasm-shrink-{}", std::process::id()));
585 std::fs::write(&p, vec![7u8; 1000]).unwrap();
586 let mut a = Assets::new();
587 a.insert_file("f", &p).unwrap();
588 a.finish();
589 std::fs::write(&p, vec![7u8; 10]).unwrap(); let mut buf = vec![0u8; 100];
591 assert_eq!(a.size("f"), Some(10));
592 assert_eq!(a.read_at("f", 0, &mut buf), Some(10));
593 assert_eq!(a.read_at("f", 500, &mut buf), Some(0));
594 std::fs::remove_file(&p).unwrap(); let _ = a.read_at("f", 0, &mut buf); let _ = std::fs::remove_file(&p);
597 }
598}