1use core::mem::discriminant;
4use std::collections::{HashMap, HashSet};
5use std::sync::Arc;
6
7use crate::Error;
8use crate::mesh::{Clip, MeshData, NoClips, Part};
9use crate::skybox::SkyboxData;
10use crate::sound::{Encoded, SoundData};
11
12pub(crate) use model::{NamedAnimation, NamedModel, Readable};
13pub(crate) use named::{NamedMesh, NamedSlot};
14pub(crate) use ogg::Stream;
15pub(crate) use texture::Textures;
16pub use texture::{ReliefData, ShadingData, TextureData};
17pub(crate) use unresolved::{Missing, QUALIFIER, Unresolved};
18
19#[cfg(test)]
21pub(crate) const BEACON: &[u8] = include_bytes!("../../examples/assets/hello.glb");
22
23#[cfg(test)]
25pub(crate) const SWEEP: &[u8] = include_bytes!("../../tests/assets/sweep.ogg");
26
27#[cfg(test)]
32pub(crate) const RIG: &[u8] = include_bytes!("../../tests/assets/a_rig.glb");
33#[cfg(test)]
34pub(crate) const SCALED: &[u8] = include_bytes!("../../tests/assets/a_rig_scaled.glb");
35#[cfg(test)]
36pub(crate) const PROP: &[u8] = include_bytes!("../../tests/assets/b_prop.glb");
37#[cfg(test)]
38pub(crate) const TRACKED: &[u8] = include_bytes!("../../tests/assets/two_tracks_same_action.glb");
39#[cfg(test)]
40pub(crate) const MERGED: &[u8] = include_bytes!("../../tests/assets/d_merge.glb");
41#[cfg(test)]
42pub(crate) const ROBOT: &[u8] = include_bytes!("../../tests/assets/robot-expressive.glb");
43
44#[cfg(test)]
47pub(crate) const CORGI: &[u8] = include_bytes!("../../examples/assets/corgi.glb");
48#[cfg(test)]
49pub(crate) const CHEST: &[u8] = include_bytes!("../../examples/assets/chest.glb");
50
51#[cfg(test)]
53pub(crate) const IMP: &[u8] = include_bytes!("../../tests/assets/imp.png");
54
55#[cfg(test)]
57pub(crate) const OPERATOR: &[u8] = include_bytes!("../../examples/assets/pixel-operator.ttf");
58
59#[cfg(test)]
62pub(crate) fn file(source: &str, bytes: &[u8]) -> (String, Vec<u8>) {
63 (source.to_owned(), bytes.to_vec())
64}
65
66#[derive(Default)]
72pub struct Assets {
73 items: HashMap<String, Vec<(Arc<str>, Item)>>,
75 stems: HashSet<Arc<str>>,
76}
77
78impl Assets {
79 pub fn mesh<P: Part>(&self, name: &str) -> MeshData<P, NoClips> {
88 match self.find(name, Item::mesh) {
89 Ok(mesh) => mesh.resolved(name),
90 Err(unaddressable) => MeshData::missing(Unresolved::of(unaddressable)),
91 }
92 }
93
94 pub fn model<P: Part, C: Clip>(&self, name: &str) -> MeshData<P, C> {
104 match self.find(name, Item::model) {
105 Ok(model) => model.resolved(name),
106 Err(unaddressable) => MeshData::missing(Unresolved::of(unaddressable)),
107 }
108 }
109
110 pub fn texture(&self, name: &str) -> TextureData {
115 match self.find(name, Item::texture) {
116 Ok(texture) => texture.clone(),
117 Err(unaddressable) => TextureData::missing(Unresolved::of(unaddressable)),
118 }
119 }
120
121 pub fn relief(&self, name: &str) -> ReliefData {
127 ReliefData::loaded(self.texture(name))
128 }
129
130 pub fn skybox(&self, name: &str) -> SkyboxData {
137 let mut image = self.texture(name);
138
139 match image.drawn() {
140 true => SkyboxData::equirect(image),
141 false => SkyboxData::missing(image.take_unresolved()),
142 }
143 }
144
145 pub fn sound(&self, name: &str) -> SoundData {
150 match self.find(name, Item::sound) {
151 Ok(clip) => SoundData::loaded(Arc::clone(clip)),
152 Err(unaddressable) => SoundData::missing(Unresolved::of(unaddressable)),
153 }
154 }
155
156 #[cfg(feature = "ui")]
162 pub(crate) fn font(&self, name: &str) -> Result<Arc<[u8]>, Error> {
163 match self.find(name, Item::font) {
164 Ok(bytes) => Ok(Arc::clone(bytes)),
165 Err(unaddressable) => Err(Error::msg(unaddressable.to_string())),
166 }
167 }
168
169 pub(crate) fn load(files: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self, Error> {
177 let decoded = files
178 .into_iter()
179 .map(|(source, bytes)| DecodedFile::decode(&source, &bytes));
180
181 let mut loaded = Self::default();
182 let mut failures = Vec::new();
183 for file in decoded {
184 if let Err(error) = file.and_then(|file| loaded.place(file)) {
185 failures.push(error.to_string());
186 }
187 }
188
189 match failures.is_empty() {
190 true => Ok(loaded),
191 false => Err(Error::msg(format!(
192 "the game's asset sources did not load: {}",
193 failures.join("; ")
194 ))),
195 }
196 }
197
198 fn find<'a, T>(&'a self, name: &str, of: impl Fn(&'a Item) -> Option<T>) -> Result<T, Missing> {
205 let held = |bare: &str| -> Vec<(&'a Arc<str>, T)> {
206 self.items
207 .get(bare)
208 .into_iter()
209 .flatten()
210 .filter_map(|(stem, item)| of(item).map(|held| (stem, held)))
211 .collect()
212 };
213
214 let mut shared = held(name);
215 match shared.len() {
216 1 => return Ok(shared.remove(0).1),
217 0 => {}
218 _ => {
219 return Err(Missing::Ambiguous {
220 name: name.to_owned(),
221 sources: shared
222 .into_iter()
223 .map(|(stem, _)| stem.to_string())
224 .collect(),
225 });
226 }
227 }
228
229 let Some((stem, bare)) = name.split_once(QUALIFIER) else {
230 return Err(Missing::absent(name));
231 };
232 held(bare)
233 .into_iter()
234 .find(|(other, _)| other.as_ref() == stem)
235 .map(|(_, item)| item)
236 .ok_or_else(|| Missing::absent(name))
237 }
238
239 fn place(&mut self, file: DecodedFile) -> Result<(), Error> {
245 let DecodedFile { stem, items } = file;
246 if !self.stems.insert(Arc::clone(&stem)) {
247 return Err(shared_stem(&stem));
248 }
249
250 for (name, item) in items {
251 self.items
252 .entry(name)
253 .or_default()
254 .push((Arc::clone(&stem), item));
255 }
256 Ok(())
257 }
258}
259
260struct DecodedFile {
263 stem: Arc<str>,
264 items: Vec<(String, Item)>,
265}
266
267impl DecodedFile {
268 fn decode(source: &str, bytes: &[u8]) -> Result<Self, Error> {
274 let stem: Arc<str> = Arc::from(stem(source)?);
275 let named = |error| Error::msg(format!("the asset source `{source}` {error}"));
276 let items = match Kind::of(source) {
277 Kind::Model => glb::decode(&stem, bytes).map_err(named)?,
278 Kind::Texture => vec![(
281 stem.to_string(),
282 Item::Texture(texture::decode(bytes).map_err(named)?),
283 )],
284 Kind::Sound => vec![(
285 stem.to_string(),
286 Item::Sound(Arc::new(ogg::decode(bytes).map_err(named)?)),
287 )],
288 Kind::Font => font_items(&stem, bytes).map_err(named)?,
289 };
290
291 let mut names = HashSet::with_capacity(items.len());
292 for (name, item) in &items {
293 if !names.insert((name.as_str(), discriminant(item))) {
294 return Err(Error::msg(format!(
295 "the asset source `{source}` calls two things `{name}`"
296 )));
297 }
298 }
299
300 Ok(Self { stem, items })
301 }
302}
303
304#[cfg(feature = "ui")]
307fn font_items(stem: &str, bytes: &[u8]) -> Result<Vec<(String, Item)>, Error> {
308 let font = font::decode(bytes)?;
309 Ok(vec![(stem.to_owned(), Item::Font(font))])
310}
311
312#[cfg(not(feature = "ui"))]
315fn font_items(_stem: &str, bytes: &[u8]) -> Result<Vec<(String, Item)>, Error> {
316 font::decode(bytes)?;
317 Ok(Vec::new())
318}
319
320fn shared_stem(stem: &str) -> Error {
322 Error::msg(format!(
323 "two asset sources are both called `{stem}`, and a shared item name is reached by \
324 file name, so rename one of them"
325 ))
326}
327
328enum Item {
330 Mesh(NamedMesh),
331 Model(NamedModel),
332 Texture(TextureData),
333 Sound(Arc<Encoded>),
334 #[cfg(feature = "ui")]
335 Font(Arc<[u8]>),
336}
337
338impl Item {
339 fn mesh(&self) -> Option<&NamedMesh> {
340 match self {
341 Self::Mesh(mesh) => Some(mesh),
342 _ => None,
343 }
344 }
345
346 fn model(&self) -> Option<&NamedModel> {
347 match self {
348 Self::Model(model) => Some(model),
349 _ => None,
350 }
351 }
352
353 fn texture(&self) -> Option<&TextureData> {
354 match self {
355 Self::Texture(texture) => Some(texture),
356 _ => None,
357 }
358 }
359
360 fn sound(&self) -> Option<&Arc<Encoded>> {
361 match self {
362 Self::Sound(clip) => Some(clip),
363 _ => None,
364 }
365 }
366
367 #[cfg(feature = "ui")]
368 fn font(&self) -> Option<&Arc<[u8]>> {
369 match self {
370 Self::Font(bytes) => Some(bytes),
371 _ => None,
372 }
373 }
374}
375
376enum Kind {
378 Model,
379 Texture,
380 Sound,
381 Font,
382}
383
384impl Kind {
385 fn of(source: &str) -> Self {
387 match source.rsplit_once('.') {
388 Some((_, kind)) if kind.eq_ignore_ascii_case("ogg") => Self::Sound,
389 Some((_, kind)) if kind.eq_ignore_ascii_case("png") => Self::Texture,
390 Some((_, kind))
391 if kind.eq_ignore_ascii_case("ttf") || kind.eq_ignore_ascii_case("otf") =>
392 {
393 Self::Font
394 }
395 _ => Self::Model,
396 }
397 }
398}
399
400fn stem(source: &str) -> Result<&str, Error> {
405 let file = source.rsplit(['/', '\\']).next().unwrap_or(source);
406 let stem = file.rsplit_once('.').map_or(file, |(stem, _)| stem);
407 if stem.contains(QUALIFIER) {
408 return Err(Error::msg(format!(
409 "the asset source `{stem}` holds a `{QUALIFIER}` in its file name, which is the \
410 mark a shared item name is read through, so rename it"
411 )));
412 }
413 Ok(stem)
414}
415
416pub(crate) mod font;
417mod glb;
418mod model;
419mod named;
420pub(crate) mod ogg;
421#[cfg(test)]
422mod testing;
423mod texture;
424mod unresolved;
425
426#[cfg(test)]
427mod tests {
428 use core::time::Duration;
429
430 use super::*;
431 use crate::assets::testing::unresolved;
432 use crate::math::UVec2;
433 use crate::mesh::NoParts;
434
435 fn loaded(sources: &[&str]) -> Assets {
436 Assets::load(sources.iter().map(|source| file(source, BEACON)))
437 .expect("the example's model decodes")
438 }
439
440 #[test]
441 fn the_store_is_shared_across_threads() {
442 fn assert_send_sync<T: Send + Sync>() {}
443 assert_send_sync::<Assets>();
444 }
445
446 #[test]
447 fn a_miss_returns_empty_data_carrying_the_name_into_one_error() {
448 let assets = Assets::default();
449 let hull = assets.mesh::<NoParts>("hull");
450 let mut paint = assets.texture("paint");
451
452 assert_eq!(hull.slots().len(), 0, "a miss draws nothing");
453 assert_eq!(paint.size(), UVec2::ZERO, "and reads no pixels");
454
455 let mut carried = unresolved(hull);
456 carried.record(unresolved(assets.mesh::<NoParts>("hull")));
457 carried.record(paint.take_unresolved());
458 assert_eq!(
459 carried
460 .error()
461 .expect("both names came back on the data the pulls returned")
462 .to_string(),
463 "the game's assets did not resolve: no asset is named `hull`; \
464 no asset is named `paint`",
465 "one line each, however many builds pulled the name"
466 );
467 }
468
469 #[test]
470 fn a_name_loaded_as_one_kind_is_a_miss_as_the_other() {
471 let assets = loaded(&["hello.glb"]);
472 let mut beacon = assets.texture("beacon");
473
474 assert!(beacon.pixels().is_empty());
475 assert!(beacon.take_unresolved().error().is_some());
476 }
477
478 #[test]
479 fn a_name_two_sources_share_is_reachable_only_by_their_file_names() {
480 let assets = loaded(&["art/props.glb", "art/scene.glb"]);
481 let shared = assets.mesh::<NoParts>("beacon");
482
483 assert_eq!(shared.slots().len(), 0, "bare, it reaches neither of them");
484 assert_eq!(
485 unresolved(shared)
486 .error()
487 .expect("the ambiguity came back on the empty data")
488 .to_string(),
489 "the game's assets did not resolve: several sources call something `beacon`; \
490 ask for `props#beacon` or `scene#beacon`"
491 );
492
493 let qualified = assets.mesh::<NoParts>("props#beacon");
494 let mut panels = assets.texture("scene#beacon_panels");
495 assert_eq!(qualified.slots().len(), 2);
496 assert_eq!(panels.size(), UVec2::splat(16));
497 assert!(
498 unresolved(qualified).is_empty() && panels.take_unresolved().is_empty(),
499 "qualified, both resolve"
500 );
501 }
502
503 #[test]
504 fn a_name_only_one_source_uses_stays_bare() {
505 let assets = loaded(&["props.glb"]);
506 let bare = assets.mesh::<NoParts>("beacon");
507 let qualified = assets.mesh::<NoParts>("props#beacon");
508
509 assert_eq!(bare.slots().len(), 2);
510 assert_eq!(qualified.slots().len(), 2);
511 assert!(
512 unresolved(bare).is_empty() && unresolved(qualified).is_empty(),
513 "either way of pulling it works"
514 );
515 }
516
517 #[test]
518 fn a_sound_source_is_named_by_its_own_file_name() {
519 let assets = Assets::load([file("audio/theme.ogg", SWEEP)]).expect("the fixture decodes");
520 let theme = assets.sound("theme");
521 let as_a_mesh = assets.mesh::<NoParts>("theme");
522
523 assert_eq!(
524 theme.duration(),
525 Duration::from_secs(2),
526 "one source, one sound, named by the file it came in"
527 );
528 assert!(as_a_mesh.indices().is_empty());
529 assert_eq!(
530 unresolved(as_a_mesh)
531 .error()
532 .expect("pulling it as a mesh reaches nothing")
533 .to_string(),
534 "the game's assets did not resolve: no asset is named `theme`"
535 );
536 }
537
538 #[test]
539 fn a_texture_source_is_named_by_its_own_file_name() {
540 let assets = Assets::load([file("art/imp.png", IMP)]).expect("the fixture decodes");
541
542 let mut imp = assets.texture("imp");
543 assert_eq!(imp.size(), UVec2::splat(2), "one source, one texture");
544 assert_eq!(
545 imp.pixels()[..4],
546 [u8::MAX, 0, 0, u8::MAX],
547 "read row by row from the top left"
548 );
549 assert_eq!(assets.texture("imp#imp").size(), UVec2::splat(2));
550 assert!(imp.take_unresolved().is_empty());
551
552 let mut goblin = assets.texture("goblin");
553 assert_eq!(goblin.size(), UVec2::ZERO);
554 assert_eq!(
555 goblin
556 .take_unresolved()
557 .error()
558 .expect("only the second pull missed")
559 .to_string(),
560 "the game's assets did not resolve: no asset is named `goblin`"
561 );
562 }
563
564 #[cfg(feature = "ui")]
565 #[test]
566 fn a_font_source_is_named_by_its_own_file_name() {
567 let assets =
568 Assets::load([file("art/pixel-operator.ttf", OPERATOR)]).expect("the fixture decodes");
569
570 assert_eq!(
571 assets
572 .font("pixel-operator")
573 .expect("one source, one font, named by the file it came in")
574 .len(),
575 OPERATOR.len(),
576 "the bytes the source held"
577 );
578 assert_eq!(
579 assets
580 .font("nothing")
581 .expect_err("no source holds that name")
582 .to_string(),
583 "no asset is named `nothing`",
584 "a font that missed is the error it returned, and carries nothing"
585 );
586
587 let assets = Assets::load([
588 file("art/pixel-operator.ttf", OPERATOR),
589 file("art/OTHER.OTF", OPERATOR),
590 ])
591 .expect("either extension, in either case");
592 assert!(assets.font("OTHER").is_ok());
593 }
594
595 #[test]
596 fn a_font_source_that_does_not_decode_cannot_start() {
597 let Err(error) = Assets::load([file("junk.ttf", b"not a font at all")]) else {
598 panic!("nothing decodes that");
599 };
600
601 assert!(
602 error.to_string().starts_with(
603 "the game's asset sources did not load: the asset source `junk.ttf` did not decode"
604 ),
605 "got {error}"
606 );
607 }
608
609 #[test]
610 fn a_texture_source_that_does_not_decode_cannot_start() {
611 let Err(error) = Assets::load([file("junk.png", b"not a texture at all")]) else {
612 panic!("nothing decodes that");
613 };
614
615 assert!(
616 error.to_string().starts_with(
617 "the game's asset sources did not load: the asset source `junk.png` did not decode"
618 ),
619 "got {error}"
620 );
621 }
622
623 #[test]
624 fn a_source_whose_file_name_holds_the_qualifier_cannot_start() {
625 let Err(error) = Assets::load([file("art/art#hello.glb", BEACON)]) else {
626 panic!("nothing could reach a name it shared");
627 };
628
629 assert_eq!(
630 error.to_string(),
631 "the game's asset sources did not load: the asset source `art#hello` holds a `#` in \
632 its file name, which is the mark a shared item name is read through, so rename it"
633 );
634 }
635
636 #[test]
637 fn two_sources_with_one_file_name_cannot_start() {
638 let Err(error) = Assets::load([
639 file("art/hello.glb", BEACON),
640 file("other/hello.glb", BEACON),
641 ]) else {
642 panic!("nothing could tell the two apart");
643 };
644
645 assert_eq!(
646 error.to_string(),
647 "the game's asset sources did not load: two asset sources are both called `hello`, \
648 and a shared item name is reached by file name, so rename one of them"
649 );
650 }
651
652 #[test]
653 fn every_source_that_did_not_load_is_named_in_one_error() {
654 let Err(error) = Assets::load([
655 file("art/hello.glb", BEACON),
656 file("other/hello.glb", BEACON),
657 file("art#props.glb", BEACON),
658 ]) else {
659 panic!("two file names are one, and the third holds the qualifier");
660 };
661
662 assert_eq!(
663 error.to_string(),
664 "the game's asset sources did not load: two asset sources are both called `hello`, \
665 and a shared item name is reached by file name, so rename one of them; the asset \
666 source `art#props` holds a `#` in its file name, which is the mark a shared item \
667 name is read through, so rename it",
668 "one error, in load order, naming both"
669 );
670
671 let Err(error) = Assets::load([
672 file("junk/beacon.glb", b"not a container at all"),
673 file("art/beacon.glb", BEACON),
674 ]) else {
675 panic!("the first of these two does not decode");
676 };
677
678 let text = error.to_string();
679 assert!(
680 text.starts_with(
681 "the game's asset sources did not load: the asset source `junk/beacon.glb` did \
682 not decode"
683 ),
684 "got {text}"
685 );
686 assert!(
687 !text.contains("both called"),
688 "and the file that did not load claims no file name: {text}"
689 );
690 }
691}