Skip to main content

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