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 Assets {
68 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
109fn 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
126pub struct Asset<'a> {
128 assets: &'a Assets,
129 source: &'a Source,
130 version: u32,
131}
132
133impl Asset<'_> {
134 pub fn version(&self) -> u32 {
136 self.version
137 }
138
139 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 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 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 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 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 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 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 pub fn set(&mut self, name: &str, bytes: Vec<u8>) -> u32 {
277 self.replace(name, Source::Memory(bytes))
278 }
279
280 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 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 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 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 pub fn size(&self, name: &str) -> Option<u64> {
344 self.get(name).map(|a| a.size())
345 }
346
347 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 pub fn version(&self, name: &str) -> Option<u32> {
355 self.get(name).map(|a| a.version())
356 }
357
358 pub fn names(&self) -> &[String] {
360 &self.sorted
361 }
362
363 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
393pub struct AssetWatch {
397 pub name: String,
398 pub path: PathBuf,
399 stamp: Option<(std::time::SystemTime, u64)>,
400}
401
402impl AssetWatch {
403 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 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
430fn 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 }; 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 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); 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); 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)); assert_eq!(w.poll(&mut a), None); std::fs::write(d.join("tmp"), b"second").unwrap();
538 std::fs::rename(d.join("tmp"), &f).unwrap(); 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); 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")); 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"));
562 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")); assert_eq!(read(&a, "Dup.Txt").as_deref(), Some("upper")); }
569 assert!(a.size(".DS_Store").is_none()); assert!(a.size("escape").is_none()); 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)); 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(); 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(); let _ = a.read_at("f", 0, &mut buf); let _ = std::fs::remove_file(&p);
618 }
619}