Skip to main content

concinnity_asset/
handle.rs

1// Per-kind resource handles.
2//
3// A resource (a mesh, texture, material, ...) is shared, compiled data the
4// runtime addresses by a dense integer index into a per-kind resource table.
5// Each kind has its own `0..N` index space, assigned by cook in declaration
6// order. The handle is a newtype per kind so a `TextureHandle` cannot be passed
7// where a `MeshHandle` is expected. Like `AssetId`, a handle serializes as a
8// bare `u32`.
9//
10// These are the runtime replacement for the per-reference `AssetId` a component
11// carries today: cook resolves the name to the resource's handle at build time,
12// so the runtime never scans to resolve a reference.
13
14use core::fmt;
15
16use alloc::format;
17use serde::de::{self, Visitor};
18use serde::{Deserialize, Deserializer, Serialize};
19
20use alloc::vec::Vec;
21
22use crate::resolver::{
23    resolve_audio_clip_handle, resolve_font_handle, resolve_material_handle, resolve_mesh_handle,
24    resolve_name, resolve_shader_handle, resolve_skinned_mesh_handle, resolve_texture_handle,
25};
26
27macro_rules! resource_handles {
28    ( $( $(#[$m:meta])* $name:ident ),+ $(,)? ) => {
29        $(
30            $(#[$m])*
31            #[derive(
32                Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default,
33                Serialize, Deserialize,
34            )]
35            #[serde(transparent)]
36            pub struct $name(
37                /// The handle's index into its per-kind resource table.
38                pub u32,
39            );
40
41            impl $name {
42                /// The handle's index into its per-kind resource table.
43                pub fn index(self) -> usize {
44                    self.0 as usize
45                }
46            }
47        )+
48    };
49}
50
51resource_handles! {
52    /// Index into the runtime mesh table.
53    MeshHandle,
54    /// Index into the runtime texture table.
55    TextureHandle,
56    /// Index into the runtime material table.
57    MaterialHandle,
58    /// Index into the runtime font table.
59    FontHandle,
60    /// Index into the runtime audio-clip table.
61    AudioClipHandle,
62    /// Index into the runtime cubemap-texture table.
63    CubemapTextureHandle,
64    /// Index into the runtime environment-map table.
65    EnvironmentMapHandle,
66    /// Index into the runtime colour-LUT table.
67    ColorLutHandle,
68    /// Index into the runtime skinned-mesh table.
69    SkinnedMeshHandle,
70    /// Index into the runtime shader table.
71    ShaderHandle,
72}
73
74// One reference-resolution seam and `deserialize_with` helper per resource
75// kind. A real build has the declaration-ordered handle map installed, so a
76// name resolves to the resource's handle. Outside a build (single-asset
77// validation, the editor's add form) the map is absent; fall back to the name
78// interner so the reference still parses to *a* handle value -- one that is
79// never used to index a resource table in those contexts. `None` only when
80// neither resolver is installed at all.
81macro_rules! handle_ref_de {
82    (
83        $(#[$extra:meta])*
84        $handle:ident, $article:literal, $noun:literal,
85        $resolve:ident, $ref_fn:ident, $opt_fn:ident $(,)?
86    ) => {
87        fn $ref_fn(name: &str) -> Option<u32> {
88            $resolve(name).or_else(|| resolve_name(name))
89        }
90
91        #[doc = concat!(
92            "`serde` `deserialize_with` helper for an optional ", $noun,
93            " reference field."
94        )]
95        ///
96        #[doc = concat!(
97            "Mirrors [`crate::de_opt_asset_ref`] but resolves to a [`",
98            stringify!($handle),
99            "`]: an integer is an already-resolved handle (the compiled-args / ",
100            "runtime form); a name string is resolved through the installed ",
101            $noun,
102            "-handle resolver; an empty string or null is `None`. Apply with ",
103            "`#[serde(default, deserialize_with = \"concinnity_asset::",
104            stringify!($opt_fn),
105            "\")]`."
106        )]
107        $(#[$extra])*
108        pub fn $opt_fn<'de, D>(d: D) -> Result<Option<$handle>, D::Error>
109        where
110            D: Deserializer<'de>,
111        {
112            // A non-self-describing format (postcard, the baked blob form)
113            // carries the already-resolved handle; names only appear in
114            // human-readable input.
115            if !d.is_human_readable() {
116                return Option::<$handle>::deserialize(d);
117            }
118
119            struct OptVisitor;
120
121            impl Visitor<'_> for OptVisitor {
122                type Value = Option<$handle>;
123
124                fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125                    f.write_str(concat!(
126                        $article, " ", $noun,
127                        " handle integer, reference name string, or null"
128                    ))
129                }
130
131                fn visit_unit<E: de::Error>(self) -> Result<Option<$handle>, E> {
132                    Ok(None)
133                }
134                fn visit_none<E: de::Error>(self) -> Result<Option<$handle>, E> {
135                    Ok(None)
136                }
137                fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<$handle>, E> {
138                    Ok(Some($handle(v as u32)))
139                }
140                fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<$handle>, E> {
141                    Ok(Some($handle(v as u32)))
142                }
143                fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<$handle>, E> {
144                    if v.is_empty() {
145                        return Ok(None);
146                    }
147                    $ref_fn(v).map(|h| Some($handle(h))).ok_or_else(|| {
148                        E::custom(format!(
149                            concat!(
150                                "no ", $noun,
151                                "-handle resolver installed to resolve reference {:?}"
152                            ),
153                            v
154                        ))
155                    })
156                }
157                fn visit_string<E: de::Error>(
158                    self,
159                    v: alloc::string::String,
160                ) -> Result<Option<$handle>, E> {
161                    self.visit_str(&v)
162                }
163            }
164
165            d.deserialize_any(OptVisitor)
166        }
167    };
168}
169
170handle_ref_de! {
171    TextureHandle, "a", "texture",
172    resolve_texture_handle, resolve_texture_ref, de_opt_texture_handle,
173}
174handle_ref_de! {
175    ShaderHandle, "a", "shader",
176    resolve_shader_handle, resolve_shader_ref, de_opt_shader_handle,
177}
178handle_ref_de! {
179    MaterialHandle, "a", "material",
180    resolve_material_handle, resolve_material_ref, de_opt_material_handle,
181}
182handle_ref_de! {
183    /// The handle addresses the shared mesh-source space (Mesh / ProceduralMesh /
184    /// VoxelChunk / mesh-kind File).
185    MeshHandle, "a", "mesh",
186    resolve_mesh_handle, resolve_mesh_ref, de_opt_mesh_handle,
187}
188handle_ref_de! {
189    /// Used by the SkinnedMesh correlation references (`Animation.target`,
190    /// `AnimationGraph.target`, `FollowController.target`): a SkinnedMesh stays an ECS
191    /// component, but its authored references bake to its dense handle instead of
192    /// an interned id.
193    SkinnedMeshHandle, "a", "skinned-mesh",
194    resolve_skinned_mesh_handle, resolve_skinned_mesh_ref, de_opt_skinned_mesh_handle,
195}
196handle_ref_de! {
197    FontHandle, "a", "font",
198    resolve_font_handle, resolve_font_ref, de_opt_font_handle,
199}
200handle_ref_de! {
201    AudioClipHandle, "an", "audio-clip",
202    resolve_audio_clip_handle, resolve_audio_clip_ref, de_opt_audio_clip_handle,
203}
204
205/// `serde` `deserialize_with` helper for a required texture reference field.
206///
207/// Like [`de_opt_texture_handle`] but for a non-optional [`TextureHandle`]: an
208/// integer is an already-resolved handle (the compiled-args / runtime form); a
209/// name string is resolved through the installed texture-handle resolver. Used
210/// by the compiled `StoryImage.texture`, which always names a texture. Apply
211/// with `#[serde(deserialize_with = "concinnity_asset::de_texture_handle")]`.
212pub fn de_texture_handle<'de, D>(d: D) -> Result<TextureHandle, D::Error>
213where
214    D: Deserializer<'de>,
215{
216    if !d.is_human_readable() {
217        return TextureHandle::deserialize(d);
218    }
219
220    struct HandleVisitor;
221
222    impl Visitor<'_> for HandleVisitor {
223        type Value = TextureHandle;
224
225        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226            f.write_str("a texture handle integer or reference name string")
227        }
228
229        fn visit_u64<E: de::Error>(self, v: u64) -> Result<TextureHandle, E> {
230            Ok(TextureHandle(v as u32))
231        }
232        fn visit_i64<E: de::Error>(self, v: i64) -> Result<TextureHandle, E> {
233            Ok(TextureHandle(v as u32))
234        }
235        fn visit_str<E: de::Error>(self, v: &str) -> Result<TextureHandle, E> {
236            resolve_texture_ref(v).map(TextureHandle).ok_or_else(|| {
237                E::custom(format!(
238                    "no texture-handle resolver installed to resolve reference {v:?}"
239                ))
240            })
241        }
242        fn visit_string<E: de::Error>(self, v: alloc::string::String) -> Result<TextureHandle, E> {
243            self.visit_str(&v)
244        }
245    }
246
247    d.deserialize_any(HandleVisitor)
248}
249
250/// `serde` `deserialize_with` helper for a list of audio-clip reference fields.
251///
252/// Each element is either an already-resolved handle integer or a name string
253/// resolved through the installed audio-clip-handle resolver, so the compiled /
254/// runtime form (integers) and the authoring form (names) both parse. Apply with
255/// `#[serde(default, deserialize_with =
256/// "concinnity_asset::de_audio_clip_handle_vec")]`.
257pub fn de_audio_clip_handle_vec<'de, D>(d: D) -> Result<Vec<AudioClipHandle>, D::Error>
258where
259    D: Deserializer<'de>,
260{
261    if !d.is_human_readable() {
262        return Vec::<AudioClipHandle>::deserialize(d);
263    }
264
265    struct VecVisitor;
266
267    impl<'de> Visitor<'de> for VecVisitor {
268        type Value = Vec<AudioClipHandle>;
269
270        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271            f.write_str("a list of audio-clip handle integers or reference name strings")
272        }
273
274        fn visit_seq<A>(self, mut seq: A) -> Result<Vec<AudioClipHandle>, A::Error>
275        where
276            A: de::SeqAccess<'de>,
277        {
278            let mut out = Vec::new();
279            // Each element is one optional audio-clip reference; drop the `None`
280            // (empty / null) entries so a list never carries a dangling handle.
281            while let Some(handle) = seq.next_element_seed(OneRef)? {
282                if let Some(handle) = handle {
283                    out.push(handle);
284                }
285            }
286            Ok(out)
287        }
288    }
289
290    // Deserialize one list element via the same integer-or-name path as
291    // `de_opt_audio_clip_handle`.
292    struct OneRef;
293    impl<'de> de::DeserializeSeed<'de> for OneRef {
294        type Value = Option<AudioClipHandle>;
295        fn deserialize<D2>(self, d: D2) -> Result<Option<AudioClipHandle>, D2::Error>
296        where
297            D2: Deserializer<'de>,
298        {
299            de_opt_audio_clip_handle(d)
300        }
301    }
302
303    d.deserialize_seq(VecVisitor)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use alloc::string::ToString;
310    use alloc::vec;
311
312    #[test]
313    fn handle_serializes_as_a_bare_u32() {
314        // Same wire form as AssetId: a bare integer, not a one-tuple.
315        let json = serde_json::to_string(&TextureHandle(7)).unwrap();
316        assert_eq!(json, "7");
317        let back: TextureHandle = serde_json::from_str("7").unwrap();
318        assert_eq!(back, TextureHandle(7));
319    }
320
321    #[test]
322    fn index_is_the_inner_value() {
323        assert_eq!(MeshHandle(0).index(), 0);
324        assert_eq!(MeshHandle(42).index(), 42);
325    }
326
327    #[test]
328    fn per_kind_handles_are_distinct_types_with_independent_values() {
329        // A round-trip through a small table keyed by the raw index works the
330        // same for each kind; the types just keep the spaces from mixing.
331        let table = ["a", "b", "c"];
332        assert_eq!(table[TextureHandle(1).index()], "b");
333        assert_eq!(table[MeshHandle(2).index()], "c");
334    }
335
336    use crate::test_support::{NoneDeserializer, install_resolvers, len_handle_resolver};
337
338    #[derive(serde::Deserialize)]
339    struct Holder {
340        #[serde(default, deserialize_with = "de_opt_texture_handle")]
341        tex: Option<TextureHandle>,
342    }
343
344    #[test]
345    fn de_opt_texture_handle_reads_an_already_resolved_integer() {
346        // The compiled-args / runtime path: refs are integers, no resolver
347        // needed (mirrors an already-resolved AssetId reference).
348        let h: Holder = serde_json::from_str("{\"tex\":7}").unwrap();
349        assert_eq!(h.tex, Some(TextureHandle(7)));
350    }
351
352    #[test]
353    fn de_opt_texture_handle_resolves_a_name_through_the_seam() {
354        install_resolvers();
355        let h: Holder = serde_json::from_str("{\"tex\":\"floor\"}").unwrap();
356        assert_eq!(h.tex, Some(TextureHandle(5)));
357    }
358
359    #[test]
360    fn de_opt_texture_handle_treats_empty_null_and_missing_as_none() {
361        assert!(
362            serde_json::from_str::<Holder>("{\"tex\":\"\"}")
363                .unwrap()
364                .tex
365                .is_none()
366        );
367        assert!(
368            serde_json::from_str::<Holder>("{\"tex\":null}")
369                .unwrap()
370                .tex
371                .is_none()
372        );
373        assert!(serde_json::from_str::<Holder>("{}").unwrap().tex.is_none());
374    }
375
376    #[derive(Debug, serde::Deserialize)]
377    struct AudioHolder {
378        #[serde(default, deserialize_with = "de_opt_audio_clip_handle")]
379        clip: Option<AudioClipHandle>,
380        #[serde(default, deserialize_with = "de_audio_clip_handle_vec")]
381        sounds: Vec<AudioClipHandle>,
382    }
383
384    #[test]
385    fn de_opt_audio_clip_handle_reads_integers_and_resolves_names() {
386        install_resolvers();
387        // Already-resolved integer passes through; a name resolves via the seam.
388        let h: AudioHolder = serde_json::from_str("{\"clip\":7}").unwrap();
389        assert_eq!(h.clip, Some(AudioClipHandle(7)));
390        let h: AudioHolder = serde_json::from_str("{\"clip\":\"theme\"}").unwrap();
391        assert_eq!(h.clip, Some(AudioClipHandle(5)));
392        // Empty, null, and missing are None.
393        assert!(
394            serde_json::from_str::<AudioHolder>("{\"clip\":\"\"}")
395                .unwrap()
396                .clip
397                .is_none()
398        );
399        assert!(
400            serde_json::from_str::<AudioHolder>("{}")
401                .unwrap()
402                .clip
403                .is_none()
404        );
405    }
406
407    #[test]
408    fn de_audio_clip_handle_vec_resolves_mixed_and_drops_empties() {
409        install_resolvers();
410        // A mix of integers and names; empty entries drop out.
411        let h: AudioHolder = serde_json::from_str("{\"sounds\":[3,\"door\",\"\"]}").unwrap();
412        assert_eq!(h.sounds, vec![AudioClipHandle(3), AudioClipHandle(4)]);
413        // Missing defaults to an empty list.
414        assert!(
415            serde_json::from_str::<AudioHolder>("{}")
416                .unwrap()
417                .sounds
418                .is_empty()
419        );
420    }
421
422    #[derive(serde::Deserialize)]
423    struct TargetHolder {
424        #[serde(default, deserialize_with = "de_opt_skinned_mesh_handle")]
425        target: Option<SkinnedMeshHandle>,
426    }
427
428    // The baked blob form: postcard is not self-describing, so every helper
429    // must read the plain resolved value instead of probing with a visitor.
430    #[test]
431    fn handle_helpers_round_trip_through_postcard() {
432        #[derive(serde::Serialize, serde::Deserialize)]
433        struct BakedHolder {
434            #[serde(default, deserialize_with = "de_opt_texture_handle")]
435            tex: Option<TextureHandle>,
436            #[serde(deserialize_with = "de_texture_handle")]
437            stage: TextureHandle,
438            #[serde(default, deserialize_with = "de_opt_mesh_handle")]
439            mesh: Option<MeshHandle>,
440            #[serde(default, deserialize_with = "de_opt_material_handle")]
441            material: Option<MaterialHandle>,
442            #[serde(default, deserialize_with = "de_opt_skinned_mesh_handle")]
443            target: Option<SkinnedMeshHandle>,
444            #[serde(default, deserialize_with = "de_opt_font_handle")]
445            font: Option<FontHandle>,
446            #[serde(default, deserialize_with = "de_opt_audio_clip_handle")]
447            clip: Option<AudioClipHandle>,
448            #[serde(default, deserialize_with = "de_audio_clip_handle_vec")]
449            sounds: Vec<AudioClipHandle>,
450            #[serde(default, deserialize_with = "de_opt_shader_handle")]
451            shader: Option<ShaderHandle>,
452        }
453        let holder = BakedHolder {
454            tex: Some(TextureHandle(3)),
455            stage: TextureHandle(9),
456            mesh: None,
457            material: Some(MaterialHandle(1)),
458            target: Some(SkinnedMeshHandle(2)),
459            font: None,
460            clip: Some(AudioClipHandle(4)),
461            sounds: alloc::vec![AudioClipHandle(5), AudioClipHandle(6)],
462            shader: Some(ShaderHandle(8)),
463        };
464        let bytes = postcard::to_allocvec(&holder).unwrap();
465        let back: BakedHolder = postcard::from_bytes(&bytes).unwrap();
466        assert_eq!(back.tex, holder.tex);
467        assert_eq!(back.stage, holder.stage);
468        assert_eq!(back.mesh, holder.mesh);
469        assert_eq!(back.material, holder.material);
470        assert_eq!(back.target, holder.target);
471        assert_eq!(back.font, holder.font);
472        assert_eq!(back.clip, holder.clip);
473        assert_eq!(back.sounds, holder.sounds);
474        assert_eq!(back.shader, holder.shader);
475    }
476
477    #[test]
478    fn de_opt_skinned_mesh_handle_reads_integers_and_resolves_names() {
479        // The correlation-reference seam (Animation/AnimationGraph/FollowController
480        // `target`): an already-resolved integer passes through, a name resolves
481        // through the installed skinned-mesh resolver, empty/null/missing are None.
482        install_resolvers();
483        let h: TargetHolder = serde_json::from_str("{\"target\":3}").unwrap();
484        assert_eq!(h.target, Some(SkinnedMeshHandle(3)));
485        let h: TargetHolder = serde_json::from_str("{\"target\":\"hero\"}").unwrap();
486        assert_eq!(h.target, Some(SkinnedMeshHandle(4)));
487        assert!(
488            serde_json::from_str::<TargetHolder>("{\"target\":\"\"}")
489                .unwrap()
490                .target
491                .is_none()
492        );
493        assert!(
494            serde_json::from_str::<TargetHolder>("{}")
495                .unwrap()
496                .target
497                .is_none()
498        );
499    }
500
501    // Every optional-handle helper accepts the same set of input forms. One
502    // generated case set per kind keeps them from drifting apart as kinds are
503    // added, and pins the diagnostic each one produces for a wrong-typed field.
504    macro_rules! opt_handle_cases {
505        ($name:ident, $helper:literal, $helper_fn:path, $handle:ident, $expected:literal) => {
506            #[test]
507            fn $name() {
508                install_resolvers();
509
510                #[derive(Debug, serde::Deserialize)]
511                struct Holder {
512                    #[serde(default, deserialize_with = $helper)]
513                    r: Option<$handle>,
514                }
515                let parse = |s: &str| serde_json::from_str::<Holder>(s).unwrap().r;
516
517                // The compiled-args / runtime form: an already-resolved integer.
518                assert_eq!(parse(r#"{"r":6}"#), Some($handle(6)));
519                // A signed integer takes the same path, narrowed to handle width.
520                assert_eq!(parse(r#"{"r":-1}"#), Some($handle(u32::MAX)));
521                // The authoring form: a name resolved through the installed seam.
522                assert_eq!(parse(r#"{"r":"floor"}"#), Some($handle(5)));
523                // A name the build declares no resource of this kind for falls
524                // back to the interner, so single-asset validation still parses.
525                assert_eq!(parse(r#"{"r":"unknown_x"}"#), Some($handle(9)));
526                // An owned string, the form the serde_json::Value bridge hands over.
527                assert_eq!(
528                    serde_json::from_value::<Holder>(serde_json::json!({"r": "wall"}))
529                        .unwrap()
530                        .r,
531                    Some($handle(4))
532                );
533                // Empty, null, and missing are all absent.
534                assert_eq!(parse(r#"{"r":""}"#), None);
535                assert_eq!(parse(r#"{"r":null}"#), None);
536                assert_eq!(parse("{}"), None);
537                // As is a `None` reported by an option-aware format.
538                assert_eq!($helper_fn(NoneDeserializer).unwrap(), None);
539                // A wrong-typed field names what the field accepts.
540                let err = serde_json::from_str::<Holder>(r#"{"r":true}"#)
541                    .unwrap_err()
542                    .to_string();
543                assert!(err.contains($expected), "{err}");
544            }
545        };
546    }
547
548    opt_handle_cases!(
549        opt_texture_handle_accepts_every_form,
550        "de_opt_texture_handle",
551        de_opt_texture_handle,
552        TextureHandle,
553        "a texture handle integer, reference name string, or null"
554    );
555    opt_handle_cases!(
556        opt_shader_handle_accepts_every_form,
557        "de_opt_shader_handle",
558        de_opt_shader_handle,
559        ShaderHandle,
560        "a shader handle integer, reference name string, or null"
561    );
562    opt_handle_cases!(
563        opt_material_handle_accepts_every_form,
564        "de_opt_material_handle",
565        de_opt_material_handle,
566        MaterialHandle,
567        "a material handle integer, reference name string, or null"
568    );
569    opt_handle_cases!(
570        opt_mesh_handle_accepts_every_form,
571        "de_opt_mesh_handle",
572        de_opt_mesh_handle,
573        MeshHandle,
574        "a mesh handle integer, reference name string, or null"
575    );
576    opt_handle_cases!(
577        opt_skinned_mesh_handle_accepts_every_form,
578        "de_opt_skinned_mesh_handle",
579        de_opt_skinned_mesh_handle,
580        SkinnedMeshHandle,
581        "a skinned-mesh handle integer, reference name string, or null"
582    );
583    opt_handle_cases!(
584        opt_font_handle_accepts_every_form,
585        "de_opt_font_handle",
586        de_opt_font_handle,
587        FontHandle,
588        "a font handle integer, reference name string, or null"
589    );
590    opt_handle_cases!(
591        opt_audio_clip_handle_accepts_every_form,
592        "de_opt_audio_clip_handle",
593        de_opt_audio_clip_handle,
594        AudioClipHandle,
595        "an audio-clip handle integer, reference name string, or null"
596    );
597
598    #[derive(Debug, serde::Deserialize)]
599    struct StageHolder {
600        #[serde(deserialize_with = "de_texture_handle")]
601        stage: TextureHandle,
602    }
603
604    #[test]
605    fn required_texture_handle_accepts_integers_and_names() {
606        install_resolvers();
607        let parse = |s: &str| serde_json::from_str::<StageHolder>(s).unwrap().stage;
608
609        assert_eq!(parse(r#"{"stage":6}"#), TextureHandle(6));
610        assert_eq!(parse(r#"{"stage":-1}"#), TextureHandle(u32::MAX));
611        assert_eq!(parse(r#"{"stage":"floor"}"#), TextureHandle(5));
612        // A name with no declared texture falls back to the interner.
613        assert_eq!(parse(r#"{"stage":"unknown_x"}"#), TextureHandle(9));
614        assert_eq!(
615            serde_json::from_value::<StageHolder>(serde_json::json!({"stage": "wall"}))
616                .unwrap()
617                .stage,
618            TextureHandle(4)
619        );
620    }
621
622    #[test]
623    fn required_texture_handle_rejects_a_missing_or_wrong_typed_field() {
624        // Unlike the optional helper there is no `None` to fall back to, so an
625        // absent or wrong-typed reference is an error naming what it accepts.
626        let err = serde_json::from_str::<StageHolder>("{}")
627            .unwrap_err()
628            .to_string();
629        assert!(err.contains("missing field `stage`"), "{err}");
630        let err = serde_json::from_str::<StageHolder>(r#"{"stage":true}"#)
631            .unwrap_err()
632            .to_string();
633        assert!(
634            err.contains("a texture handle integer or reference name string"),
635            "{err}"
636        );
637    }
638
639    #[test]
640    fn audio_clip_handle_vec_rejects_a_non_list() {
641        let err = serde_json::from_str::<AudioHolder>(r#"{"sounds":5}"#)
642            .unwrap_err()
643            .to_string();
644        assert!(
645            err.contains("a list of audio-clip handle integers or reference name strings"),
646            "{err}"
647        );
648    }
649
650    #[test]
651    fn audio_clip_handle_vec_resolves_owned_strings() {
652        install_resolvers();
653        // The serde_json::Value bridge hands each element over as an owned string.
654        let h: AudioHolder =
655            serde_json::from_value(serde_json::json!({"sounds": ["door", "unknown_x"]})).unwrap();
656        assert_eq!(h.sounds, vec![AudioClipHandle(4), AudioClipHandle(9)]);
657    }
658
659    #[test]
660    fn handles_default_to_zero_and_order_by_index() {
661        // The derived ordering is the index ordering the resource tables use.
662        assert_eq!(TextureHandle::default(), TextureHandle(0));
663        assert!(MeshHandle(1) < MeshHandle(2));
664        assert_eq!(len_handle_resolver("unknown_floor"), None);
665    }
666}