Skip to main content

mirage_engine/assets/
mod.rs

1//! Loaded sources, and the names a game's builds read from them.
2
3use 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/// The example's asset source, loaded for every test that needs data.
22#[cfg(test)]
23pub(crate) const BEACON: &[u8] = include_bytes!("../../examples/assets/hello.glb");
24
25/// A two-second tone, the sound every test that needs one loads.
26#[cfg(test)]
27pub(crate) const SWEEP: &[u8] = include_bytes!("../../tests/assets/sweep.ogg");
28
29/// The models the tests read: a skinned rig of three joints under two clips,
30/// the same rig under a root node scaled and turned, a keyed cube with
31/// another under it no clip moves, one action written on two tracks, two
32/// animations of one name, and a model two skins and a set of shapes cover.
33#[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/// The two models the examples draw, which the tests read as well: one of
47/// sixteen joints under four clips, and one whose two meshes share a skin.
48#[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/// Four pixels, the texture source every test that needs one loads.
54#[cfg(test)]
55pub(crate) const IMP: &[u8] = include_bytes!("../../tests/assets/imp.png");
56
57/// The example's font, the source every test that needs one loads.
58#[cfg(test)]
59pub(crate) const OPERATOR: &[u8] = include_bytes!("../../examples/assets/pixel-operator.ttf");
60
61/// One source as [`Assets::load`] takes it: the file name it came in, and
62/// its bytes.
63#[cfg(test)]
64pub(crate) fn file(source: &str, bytes: &[u8]) -> (String, Vec<u8>) {
65    (source.to_owned(), bytes.to_vec())
66}
67
68/// Everything [`Config::with_assets`](crate::Config::with_assets) loaded, by
69/// name across all sources.
70///
71/// A name only one source used is read bare, `"Ship"`; a name more than
72/// one shares needs its source, `"props#Ship"`.
73#[derive(Default)]
74pub struct Assets {
75    loaded: Loaded,
76    /// `build` takes `&Assets`, not `&mut`, so what did not resolve is
77    /// recorded here.
78    unresolved: RefCell<BTreeSet<Unresolved>>,
79}
80
81impl Assets {
82    /// The mesh a source loaded under `name`, its material names resolved
83    /// to the parts `P`.
84    ///
85    /// A part names exactly one material; one that resolves to no part is
86    /// drawn as authored unless a draw repaints every slot. A missing or
87    /// ambiguous name, a part no material resolves to, and a part two
88    /// materials resolve to each return empty data and become part of the
89    /// startup error.
90    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    /// The model a source loaded under `name`: the mesh of one of its root
101    /// nodes, the joints that pose it, and one animation per clip of `C`.
102    ///
103    /// Material names resolve to the parts `P` as [`mesh`](Assets::mesh)
104    /// resolves them, and each clip resolves the one animation of the source
105    /// whose name it matches. An animation no clip matches is left alone; a
106    /// clip that matches none, a clip two animations match, and a clip whose
107    /// animation moves no joint of this model each return empty data and
108    /// become part of the startup error.
109    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    /// The mesh a name resolved to, with everything about it that did not
120    /// resolve recorded for the startup error.
121    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    /// The texture a source loaded under `name`.
135    ///
136    /// A missing name returns empty pixels and becomes part of the startup
137    /// error.
138    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    /// The texture a source loaded under `name`, read as a relief holding a
149    /// normal and a depth per texel.
150    ///
151    /// A missing name returns empty pixels and becomes part of the startup
152    /// error.
153    pub fn relief(&self, name: &str) -> ReliefData {
154        ReliefData::loaded(self.texture(name))
155    }
156
157    /// The same pixels read as the whole sky: the whole way around across
158    /// the image, and zenith to nadir down it.
159    ///
160    /// A missing name returns a black sky and becomes part of the startup
161    /// error, and so does an image that is no sky at all, under the name of
162    /// the skybox it was built for.
163    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    /// The sound a source loaded under `name`.
173    ///
174    /// A missing name returns silence and becomes part of the startup error.
175    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    /// The font a source loaded under `name`.
186    ///
187    /// A missing or ambiguous name is the error it returns, not a recorded
188    /// miss: a font is read once startup has already turned the record into
189    /// its error, so a recorded miss would reach nothing.
190    #[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    /// Builds a store by decoding `files` in load order. Each item is a file
199    /// name and its bytes.
200    ///
201    /// Fails with one error naming every file the engine cannot read, every
202    /// file whose name cannot qualify an item name, every file that names one
203    /// item of a kind more than once, and every file name two files share. A
204    /// name two files share is not an error: it is only read qualified.
205    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    /// The one startup error for everything the builds needed and did not
231    /// get; clears the record.
232    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    /// Records something that did not resolve, once, for the startup error.
245    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/// Everything the sources have loaded so far.
255#[derive(Default)]
256struct Loaded {
257    /// Each name against every source that used it, in load order.
258    items: HashMap<String, Vec<(Rc<str>, Item)>>,
259    stems: HashSet<Rc<str>>,
260}
261
262impl Loaded {
263    /// What `name` resolves to among the items `of` reads, or what to record
264    /// if it resolves to nothing.
265    ///
266    /// A name an item holds whole is matched before the name is split at
267    /// its `#`. One name reaches one item of every kind, so a source that
268    /// holds a mesh and a model of one name is read either way.
269    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    /// Takes in the items `file` decoded, under the file name it read them
309    /// by.
310    ///
311    /// Fails where a file of that name is in the store already, which nothing
312    /// could then tell apart by name; the store is left as it was.
313    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
329/// One file decoded on its own: the file name its items are read by, and the
330/// items it holds.
331struct DecodedFile {
332    stem: Rc<str>,
333    items: Vec<(String, Item)>,
334}
335
336impl DecodedFile {
337    /// Decodes the file `source` names out of `bytes`, against nothing else.
338    ///
339    /// Fails where that file name could qualify no item name, where the
340    /// engine cannot read the bytes, and where they name one item of a kind
341    /// more than once.
342    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            // A texture or a sound is one item, which its own file name is
348            // the name of.
349            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/// What a font source adds to the store under `stem`: its bytes, for the UI
374/// to draw with.
375#[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/// The same, without the UI: the source decodes the same way, and no item
382/// keeps a font nothing can draw with.
383#[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
389/// The error for two sources one file name could mean either of.
390fn 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
397/// One decoded item a source named.
398enum 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
445/// Kind of thing a source holds, from its file name.
446enum Kind {
447    Model,
448    Texture,
449    Sound,
450    Font,
451}
452
453impl Kind {
454    /// Kind a source holds, from the end of its file name.
455    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
469/// The name a source qualifies its items by, without its file extension.
470///
471/// Fails where that name holds the mark a qualified name is read through,
472/// which nothing could then read a shared name of the source by.
473fn 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}