1use core::mem::discriminant;
4use std::cell::RefCell;
5use std::collections::{BTreeSet, HashMap, HashSet};
6use std::rc::Rc;
7use std::sync::Arc;
8
9use crate::Error;
10use crate::mesh::{Clip, MeshData, NoClips, Part};
11use crate::skybox::SkyboxData;
12use crate::sound::{Encoded, SoundData};
13
14pub(crate) use model::{NamedAnimation, NamedModel, Readable};
15pub(crate) use named::{NamedMesh, NamedSlot};
16pub(crate) use ogg::Stream;
17pub(crate) use texture::Textures;
18pub use texture::{ReliefData, ShadingData, TextureData};
19pub(crate) use unresolved::{QUALIFIER, Unresolved};
20
21#[cfg(test)]
23pub(crate) const BEACON: &[u8] = include_bytes!("../../examples/assets/hello.glb");
24
25#[cfg(test)]
27pub(crate) const SWEEP: &[u8] = include_bytes!("../../tests/assets/sweep.ogg");
28
29#[cfg(test)]
34pub(crate) const RIG: &[u8] = include_bytes!("../../tests/assets/a_rig.glb");
35#[cfg(test)]
36pub(crate) const SCALED: &[u8] = include_bytes!("../../tests/assets/a_rig_scaled.glb");
37#[cfg(test)]
38pub(crate) const PROP: &[u8] = include_bytes!("../../tests/assets/b_prop.glb");
39#[cfg(test)]
40pub(crate) const TRACKED: &[u8] = include_bytes!("../../tests/assets/two_tracks_same_action.glb");
41#[cfg(test)]
42pub(crate) const MERGED: &[u8] = include_bytes!("../../tests/assets/d_merge.glb");
43#[cfg(test)]
44pub(crate) const ROBOT: &[u8] = include_bytes!("../../tests/assets/robot-expressive.glb");
45
46#[cfg(test)]
49pub(crate) const CORGI: &[u8] = include_bytes!("../../examples/assets/corgi.glb");
50#[cfg(test)]
51pub(crate) const CHEST: &[u8] = include_bytes!("../../examples/assets/chest.glb");
52
53#[cfg(test)]
55pub(crate) const IMP: &[u8] = include_bytes!("../../tests/assets/imp.png");
56
57#[cfg(test)]
59pub(crate) const OPERATOR: &[u8] = include_bytes!("../../examples/assets/pixel-operator.ttf");
60
61#[cfg(test)]
64pub(crate) fn file(source: &str, bytes: &[u8]) -> (String, Vec<u8>) {
65 (source.to_owned(), bytes.to_vec())
66}
67
68#[derive(Default)]
74pub struct Assets {
75 loaded: Loaded,
76 unresolved: RefCell<BTreeSet<Unresolved>>,
79}
80
81impl Assets {
82 pub fn mesh<P: Part>(&self, name: &str) -> MeshData<P, NoClips> {
91 match self.loaded.find(name, Item::mesh) {
92 Ok(mesh) => self.built(mesh.resolved(name)),
93 Err(unaddressable) => {
94 self.record(unaddressable);
95 MeshData::empty()
96 }
97 }
98 }
99
100 pub fn model<P: Part, C: Clip>(&self, name: &str) -> MeshData<P, C> {
110 match self.loaded.find(name, Item::model) {
111 Ok(model) => self.built(model.resolved(name)),
112 Err(unaddressable) => {
113 self.record(unaddressable);
114 MeshData::empty()
115 }
116 }
117 }
118
119 fn built<P: Part, C: Clip>(
122 &self,
123 resolved: Result<MeshData<P, C>, Vec<Unresolved>>,
124 ) -> MeshData<P, C> {
125 match resolved {
126 Ok(mesh) => mesh,
127 Err(unresolved) => {
128 unresolved.into_iter().for_each(|what| self.record(what));
129 MeshData::empty()
130 }
131 }
132 }
133
134 pub fn texture(&self, name: &str) -> TextureData {
139 match self.loaded.find(name, Item::texture) {
140 Ok(texture) => texture.clone(),
141 Err(unaddressable) => {
142 self.record(unaddressable);
143 TextureData::default()
144 }
145 }
146 }
147
148 pub fn relief(&self, name: &str) -> ReliefData {
154 ReliefData::loaded(self.texture(name))
155 }
156
157 pub fn skybox(&self, name: &str) -> SkyboxData {
164 let image = self.texture(name);
165
166 match image.drawn() {
167 true => SkyboxData::equirect(image),
168 false => SkyboxData::default(),
169 }
170 }
171
172 pub fn sound(&self, name: &str) -> SoundData {
176 match self.loaded.find(name, Item::sound) {
177 Ok(clip) => SoundData::loaded(Arc::clone(clip)),
178 Err(unaddressable) => {
179 self.record(unaddressable);
180 SoundData::empty()
181 }
182 }
183 }
184
185 #[cfg(feature = "ui")]
191 pub(crate) fn font(&self, name: &str) -> Result<Arc<[u8]>, Error> {
192 match self.loaded.find(name, Item::font) {
193 Ok(bytes) => Ok(Arc::clone(bytes)),
194 Err(unaddressable) => Err(Error::msg(unaddressable.to_string())),
195 }
196 }
197
198 pub(crate) fn load(files: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self, Error> {
206 let decoded = files
207 .into_iter()
208 .map(|(source, bytes)| DecodedFile::decode(&source, &bytes));
209
210 let mut loaded = Loaded::default();
211 let mut failures = Vec::new();
212 for file in decoded {
213 if let Err(error) = file.and_then(|file| loaded.place(file)) {
214 failures.push(error.to_string());
215 }
216 }
217
218 match failures.is_empty() {
219 true => Ok(Self {
220 loaded,
221 unresolved: RefCell::default(),
222 }),
223 false => Err(Error::msg(format!(
224 "the game's asset sources did not load: {}",
225 failures.join("; ")
226 ))),
227 }
228 }
229
230 pub(crate) fn unresolved(&self) -> Option<Error> {
233 let unresolved = self.unresolved.take();
234 (!unresolved.is_empty()).then(|| {
235 let mut lines: Vec<String> = unresolved.iter().map(Unresolved::to_string).collect();
236 lines.sort();
237 Error::msg(format!(
238 "the game's assets did not resolve: {}",
239 lines.join("; ")
240 ))
241 })
242 }
243
244 pub(crate) fn record(&self, what: Unresolved) {
246 let mut unresolved = self.unresolved.borrow_mut();
247 if !unresolved.contains(&what) {
248 log::debug!("{what}");
249 unresolved.insert(what);
250 }
251 }
252}
253
254#[derive(Default)]
256struct Loaded {
257 items: HashMap<String, Vec<(Rc<str>, Item)>>,
259 stems: HashSet<Rc<str>>,
260}
261
262impl Loaded {
263 fn find<'a, T>(
270 &'a self,
271 name: &str,
272 of: impl Fn(&'a Item) -> Option<T>,
273 ) -> Result<T, Unresolved> {
274 let held = |bare: &str| -> Vec<(&'a Rc<str>, T)> {
275 self.items
276 .get(bare)
277 .into_iter()
278 .flatten()
279 .filter_map(|(stem, item)| of(item).map(|held| (stem, held)))
280 .collect()
281 };
282
283 let mut shared = held(name);
284 match shared.len() {
285 1 => return Ok(shared.remove(0).1),
286 0 => {}
287 _ => {
288 return Err(Unresolved::Ambiguous {
289 name: name.to_owned(),
290 sources: shared
291 .into_iter()
292 .map(|(stem, _)| stem.to_string())
293 .collect(),
294 });
295 }
296 }
297
298 let Some((stem, bare)) = name.split_once(QUALIFIER) else {
299 return Err(Unresolved::absent(name));
300 };
301 held(bare)
302 .into_iter()
303 .find(|(other, _)| other.as_ref() == stem)
304 .map(|(_, item)| item)
305 .ok_or_else(|| Unresolved::absent(name))
306 }
307
308 fn place(&mut self, file: DecodedFile) -> Result<(), Error> {
314 let DecodedFile { stem, items } = file;
315 if !self.stems.insert(Rc::clone(&stem)) {
316 return Err(shared_stem(&stem));
317 }
318
319 for (name, item) in items {
320 self.items
321 .entry(name)
322 .or_default()
323 .push((Rc::clone(&stem), item));
324 }
325 Ok(())
326 }
327}
328
329struct DecodedFile {
332 stem: Rc<str>,
333 items: Vec<(String, Item)>,
334}
335
336impl DecodedFile {
337 fn decode(source: &str, bytes: &[u8]) -> Result<Self, Error> {
343 let stem: Rc<str> = Rc::from(stem(source)?);
344 let named = |error| Error::msg(format!("the asset source `{source}` {error}"));
345 let items = match Kind::of(source) {
346 Kind::Model => glb::decode(&stem, bytes).map_err(named)?,
347 Kind::Texture => vec![(
350 stem.to_string(),
351 Item::Texture(texture::decode(bytes).map_err(named)?),
352 )],
353 Kind::Sound => vec![(
354 stem.to_string(),
355 Item::Sound(Arc::new(ogg::decode(bytes).map_err(named)?)),
356 )],
357 Kind::Font => font_items(&stem, bytes).map_err(named)?,
358 };
359
360 let mut names = HashSet::with_capacity(items.len());
361 for (name, item) in &items {
362 if !names.insert((name.as_str(), discriminant(item))) {
363 return Err(Error::msg(format!(
364 "the asset source `{source}` calls two things `{name}`"
365 )));
366 }
367 }
368
369 Ok(Self { stem, items })
370 }
371}
372
373#[cfg(feature = "ui")]
376fn font_items(stem: &str, bytes: &[u8]) -> Result<Vec<(String, Item)>, Error> {
377 let font = font::decode(bytes)?;
378 Ok(vec![(stem.to_owned(), Item::Font(font))])
379}
380
381#[cfg(not(feature = "ui"))]
384fn font_items(_stem: &str, bytes: &[u8]) -> Result<Vec<(String, Item)>, Error> {
385 font::decode(bytes)?;
386 Ok(Vec::new())
387}
388
389fn shared_stem(stem: &str) -> Error {
391 Error::msg(format!(
392 "two asset sources are both called `{stem}`, and a shared item name is reached by \
393 file name, so rename one of them"
394 ))
395}
396
397enum Item {
399 Mesh(NamedMesh),
400 Model(NamedModel),
401 Texture(TextureData),
402 Sound(Arc<Encoded>),
403 #[cfg(feature = "ui")]
404 Font(Arc<[u8]>),
405}
406
407impl Item {
408 fn mesh(&self) -> Option<&NamedMesh> {
409 match self {
410 Self::Mesh(mesh) => Some(mesh),
411 _ => None,
412 }
413 }
414
415 fn model(&self) -> Option<&NamedModel> {
416 match self {
417 Self::Model(model) => Some(model),
418 _ => None,
419 }
420 }
421
422 fn texture(&self) -> Option<&TextureData> {
423 match self {
424 Self::Texture(texture) => Some(texture),
425 _ => None,
426 }
427 }
428
429 fn sound(&self) -> Option<&Arc<Encoded>> {
430 match self {
431 Self::Sound(clip) => Some(clip),
432 _ => None,
433 }
434 }
435
436 #[cfg(feature = "ui")]
437 fn font(&self) -> Option<&Arc<[u8]>> {
438 match self {
439 Self::Font(bytes) => Some(bytes),
440 _ => None,
441 }
442 }
443}
444
445enum Kind {
447 Model,
448 Texture,
449 Sound,
450 Font,
451}
452
453impl Kind {
454 fn of(source: &str) -> Self {
456 match source.rsplit_once('.') {
457 Some((_, kind)) if kind.eq_ignore_ascii_case("ogg") => Self::Sound,
458 Some((_, kind)) if kind.eq_ignore_ascii_case("png") => Self::Texture,
459 Some((_, kind))
460 if kind.eq_ignore_ascii_case("ttf") || kind.eq_ignore_ascii_case("otf") =>
461 {
462 Self::Font
463 }
464 _ => Self::Model,
465 }
466 }
467}
468
469fn stem(source: &str) -> Result<&str, Error> {
474 let file = source.rsplit(['/', '\\']).next().unwrap_or(source);
475 let stem = file.rsplit_once('.').map_or(file, |(stem, _)| stem);
476 if stem.contains(QUALIFIER) {
477 return Err(Error::msg(format!(
478 "the asset source `{stem}` holds a `{QUALIFIER}` in its file name, which is the \
479 mark a shared item name is read through, so rename it"
480 )));
481 }
482 Ok(stem)
483}
484
485pub(crate) mod font;
486mod glb;
487mod model;
488mod named;
489pub(crate) mod ogg;
490mod texture;
491mod unresolved;
492
493#[cfg(test)]
494mod tests {
495 use core::time::Duration;
496
497 use super::*;
498 use crate::math::UVec2;
499 use crate::mesh::NoParts;
500
501 fn loaded(sources: &[&str]) -> Assets {
502 Assets::load(sources.iter().map(|source| file(source, BEACON)))
503 .expect("the example's model decodes")
504 }
505
506 #[test]
507 fn a_miss_yields_empty_data_and_becomes_one_error() {
508 let assets = Assets::default();
509
510 assert_eq!(assets.mesh::<NoParts>("hull").slots().len(), 0);
511 assert_eq!(assets.texture("paint").size(), UVec2::ZERO);
512 assert_eq!(assets.mesh::<NoParts>("hull").slots().len(), 0);
513
514 let error = assets.unresolved().expect("the misses were recorded");
515 assert_eq!(
516 error.to_string(),
517 "the game's assets did not resolve: no asset is named `hull`; \
518 no asset is named `paint`"
519 );
520 assert!(assets.unresolved().is_none(), "the record is cleared");
521 }
522
523 #[test]
524 fn a_name_loaded_as_one_kind_is_a_miss_as_the_other() {
525 let assets = loaded(&["hello.glb"]);
526
527 assert!(assets.texture("beacon").pixels().is_empty());
528 assert!(assets.unresolved().is_some());
529 }
530
531 #[test]
532 fn a_name_two_sources_share_is_reachable_only_by_their_file_names() {
533 let assets = loaded(&["art/props.glb", "art/scene.glb"]);
534
535 assert_eq!(
536 assets.mesh::<NoParts>("beacon").slots().len(),
537 0,
538 "bare, it reaches neither of them"
539 );
540 let error = assets.unresolved().expect("the ambiguity was recorded");
541 assert_eq!(
542 error.to_string(),
543 "the game's assets did not resolve: several sources call something `beacon`; \
544 ask for `props#beacon` or `scene#beacon`"
545 );
546
547 assert_eq!(assets.mesh::<NoParts>("props#beacon").slots().len(), 2);
548 assert_eq!(
549 assets.texture("scene#beacon_panels").size(),
550 UVec2::splat(16)
551 );
552 assert!(assets.unresolved().is_none(), "qualified, both resolve");
553 }
554
555 #[test]
556 fn a_name_only_one_source_uses_stays_bare() {
557 let assets = loaded(&["props.glb"]);
558
559 assert_eq!(assets.mesh::<NoParts>("beacon").slots().len(), 2);
560 assert_eq!(assets.mesh::<NoParts>("props#beacon").slots().len(), 2);
561 assert!(assets.unresolved().is_none(), "either way of asking works");
562 }
563
564 #[test]
565 fn a_sound_source_is_named_by_its_own_file_name() {
566 let assets = Assets::load([file("audio/theme.ogg", SWEEP)]).expect("the fixture decodes");
567
568 assert_eq!(
569 assets.sound("theme").duration(),
570 Duration::from_secs(2),
571 "one source, one sound, named by the file it came in"
572 );
573 assert!(assets.mesh::<NoParts>("theme").indices().is_empty());
574 let error = assets.unresolved().expect("asking for it as a mesh missed");
575 assert_eq!(
576 error.to_string(),
577 "the game's assets did not resolve: no asset is named `theme`"
578 );
579 }
580
581 #[test]
582 fn a_texture_source_is_named_by_its_own_file_name() {
583 let assets = Assets::load([file("art/imp.png", IMP)]).expect("the fixture decodes");
584
585 let imp = assets.texture("imp");
586 assert_eq!(imp.size(), UVec2::splat(2), "one source, one texture");
587 assert_eq!(
588 imp.pixels()[..4],
589 [u8::MAX, 0, 0, u8::MAX],
590 "read row by row from the top left"
591 );
592 assert_eq!(assets.texture("imp#imp").size(), UVec2::splat(2));
593
594 assert_eq!(assets.texture("goblin").size(), UVec2::ZERO);
595 let error = assets.unresolved().expect("only the second pull missed");
596 assert_eq!(
597 error.to_string(),
598 "the game's assets did not resolve: no asset is named `goblin`"
599 );
600 }
601
602 #[cfg(feature = "ui")]
603 #[test]
604 fn a_font_source_is_named_by_its_own_file_name() {
605 let assets =
606 Assets::load([file("art/pixel-operator.ttf", OPERATOR)]).expect("the fixture decodes");
607
608 assert_eq!(
609 assets
610 .font("pixel-operator")
611 .expect("one source, one font, named by the file it came in")
612 .len(),
613 OPERATOR.len(),
614 "the bytes the source held"
615 );
616 assert_eq!(
617 assets
618 .font("nothing")
619 .expect_err("no source holds that name")
620 .to_string(),
621 "no asset is named `nothing`"
622 );
623 assert!(
624 assets.unresolved().is_none(),
625 "a font that missed is the error it returned, never a recorded miss"
626 );
627
628 let assets = Assets::load([
629 file("art/pixel-operator.ttf", OPERATOR),
630 file("art/OTHER.OTF", OPERATOR),
631 ])
632 .expect("either extension, in either case");
633 assert!(assets.font("OTHER").is_ok());
634 }
635
636 #[test]
637 fn a_font_source_that_does_not_decode_cannot_start() {
638 let Err(error) = Assets::load([file("junk.ttf", b"not a font at all")]) else {
639 panic!("nothing decodes that");
640 };
641
642 assert!(
643 error.to_string().starts_with(
644 "the game's asset sources did not load: the asset source `junk.ttf` did not decode"
645 ),
646 "got {error}"
647 );
648 }
649
650 #[test]
651 fn a_texture_source_that_does_not_decode_cannot_start() {
652 let Err(error) = Assets::load([file("junk.png", b"not a texture at all")]) else {
653 panic!("nothing decodes that");
654 };
655
656 assert!(
657 error.to_string().starts_with(
658 "the game's asset sources did not load: the asset source `junk.png` did not decode"
659 ),
660 "got {error}"
661 );
662 }
663
664 #[test]
665 fn a_source_whose_file_name_holds_the_qualifier_cannot_start() {
666 let Err(error) = Assets::load([file("art/art#hello.glb", BEACON)]) else {
667 panic!("nothing could reach a name it shared");
668 };
669
670 assert_eq!(
671 error.to_string(),
672 "the game's asset sources did not load: the asset source `art#hello` holds a `#` in \
673 its file name, which is the mark a shared item name is read through, so rename it"
674 );
675 }
676
677 #[test]
678 fn two_sources_with_one_file_name_cannot_start() {
679 let Err(error) = Assets::load([
680 file("art/hello.glb", BEACON),
681 file("other/hello.glb", BEACON),
682 ]) else {
683 panic!("nothing could tell the two apart");
684 };
685
686 assert_eq!(
687 error.to_string(),
688 "the game's asset sources did not load: two asset sources are both called `hello`, \
689 and a shared item name is reached by file name, so rename one of them"
690 );
691 }
692
693 #[test]
694 fn every_source_that_did_not_load_is_named_in_one_error() {
695 let Err(error) = Assets::load([
696 file("art/hello.glb", BEACON),
697 file("other/hello.glb", BEACON),
698 file("art#props.glb", BEACON),
699 ]) else {
700 panic!("two file names are one, and the third holds the qualifier");
701 };
702
703 assert_eq!(
704 error.to_string(),
705 "the game's asset sources did not load: two asset sources are both called `hello`, \
706 and a shared item name is reached by file name, so rename one of them; the asset \
707 source `art#props` holds a `#` in its file name, which is the mark a shared item \
708 name is read through, so rename it",
709 "one error, in load order, naming both"
710 );
711
712 let Err(error) = Assets::load([
713 file("junk/beacon.glb", b"not a container at all"),
714 file("art/beacon.glb", BEACON),
715 ]) else {
716 panic!("the first of these two does not decode");
717 };
718
719 let text = error.to_string();
720 assert!(
721 text.starts_with(
722 "the game's asset sources did not load: the asset source `junk/beacon.glb` did \
723 not decode"
724 ),
725 "got {text}"
726 );
727 assert!(
728 !text.contains("both called"),
729 "and the file that did not load claims no file name: {text}"
730 );
731 }
732}