Skip to main content

concinnity_core/ecs/
asset_ref.rs

1//! A typed reference from one asset to another, declared by name.
2//!
3//! A field of type `AssetRef<T>` points at a separately-declared asset. `T` is
4//! the reference target -- a concrete asset type or a category marker (e.g. any
5//! mesh source) -- so the target kind lives in the type system instead of a
6//! hand-maintained side table. On the wire and at runtime the reference is a name
7//! (authoring) or a dense id (resolved); the referenced asset's data is never
8//! embedded.
9//!
10//! Deserialization matches the bare-`AssetId` field: an integer is an
11//! already-resolved id; a name string is run through the installed resolver, or
12//! kept as a name if none is installed (an out-of-engine tool reading authoring
13//! JSON). Serialization is likewise blob-identical: a resolved reference is its
14//! integer id, an unresolved one its name string.
15
16use core::fmt;
17use core::marker::PhantomData;
18
19use alloc::boxed::Box;
20use alloc::string::String;
21use serde::de::{self, Visitor};
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24use crate::ecs::asset_id::AssetId;
25use crate::ecs::resolver::resolve_name;
26
27/// A by-name reference to another asset of target `T`.
28pub struct AssetRef<T> {
29    // The referenced asset's declared name (authoring / unresolved). Empty once
30    // a reference carries only its resolved id.
31    name: Box<str>,
32    // The dense id, filled once resolved. `None` until then.
33    id: Option<AssetId>,
34    // Variance-neutral tag: keeps `AssetRef<T>: Send + Sync` and imposes no
35    // trait bounds on `T`.
36    _target: PhantomData<fn() -> T>,
37}
38
39impl<T> AssetRef<T> {
40    /// An unresolved reference to the asset declared as `name`.
41    pub fn by_name(name: impl Into<Box<str>>) -> Self {
42        Self {
43            name: name.into(),
44            id: None,
45            _target: PhantomData,
46        }
47    }
48
49    /// A reference already resolved to a dense id (no name retained).
50    pub fn resolved(id: AssetId) -> Self {
51        Self {
52            name: Box::from(""),
53            id: Some(id),
54            _target: PhantomData,
55        }
56    }
57
58    /// The declared name, or `""` if this reference carries only a resolved id.
59    pub fn name(&self) -> &str {
60        &self.name
61    }
62
63    /// The resolved dense id, or `None` if still unresolved.
64    pub fn id(&self) -> Option<AssetId> {
65        self.id
66    }
67
68    /// Whether the reference has been resolved to a dense id.
69    pub fn is_resolved(&self) -> bool {
70        self.id.is_some()
71    }
72
73    /// Fill in the resolved id.
74    pub fn resolve(&mut self, id: AssetId) {
75        self.id = Some(id);
76    }
77}
78
79// Manual auto-trait-friendly impls: no `T: Trait` bounds, since `T` is a phantom
80// tag and never a value.
81impl<T> Clone for AssetRef<T> {
82    fn clone(&self) -> Self {
83        Self {
84            name: self.name.clone(),
85            id: self.id,
86            _target: PhantomData,
87        }
88    }
89}
90
91impl<T> fmt::Debug for AssetRef<T> {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.debug_struct("AssetRef")
94            .field("name", &self.name)
95            .field("id", &self.id)
96            .finish()
97    }
98}
99
100impl<T> PartialEq for AssetRef<T> {
101    fn eq(&self, other: &Self) -> bool {
102        self.name == other.name && self.id == other.id
103    }
104}
105
106impl<T> Eq for AssetRef<T> {}
107
108// An empty, unresolved reference: lets a required `AssetRef<T>` field carry
109// `#[serde(default)]` and a "not yet set" placeholder.
110impl<T> Default for AssetRef<T> {
111    fn default() -> Self {
112        Self {
113            name: Box::from(""),
114            id: None,
115            _target: PhantomData,
116        }
117    }
118}
119
120impl<T> Serialize for AssetRef<T> {
121    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
122        // A resolved reference serializes as its integer id (the compiled-args /
123        // blob form); an unresolved one serializes as its name. This matches the
124        // legacy bare-`AssetId` field, so compiled output is byte-identical.
125        match self.id {
126            Some(id) => s.serialize_u32(id.0),
127            None => s.serialize_str(&self.name),
128        }
129    }
130}
131
132struct AssetRefVisitor<T>(PhantomData<fn() -> T>);
133
134impl<T> AssetRefVisitor<T> {
135    fn from_name(name: &str) -> AssetRef<T> {
136        match resolve_name(name) {
137            Some(id) => AssetRef::resolved(AssetId(id)),
138            None => AssetRef::by_name(name),
139        }
140    }
141}
142
143impl<'de, T> Visitor<'de> for AssetRefVisitor<T> {
144    type Value = AssetRef<T>;
145
146    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.write_str("an asset reference name string or a resolved id integer")
148    }
149
150    fn visit_str<E: de::Error>(self, v: &str) -> Result<AssetRef<T>, E> {
151        Ok(Self::from_name(v))
152    }
153    fn visit_string<E: de::Error>(self, v: String) -> Result<AssetRef<T>, E> {
154        Ok(Self::from_name(&v))
155    }
156    fn visit_u64<E: de::Error>(self, v: u64) -> Result<AssetRef<T>, E> {
157        Ok(AssetRef::resolved(AssetId(v as u32)))
158    }
159    fn visit_i64<E: de::Error>(self, v: i64) -> Result<AssetRef<T>, E> {
160        Ok(AssetRef::resolved(AssetId(v as u32)))
161    }
162}
163
164impl<'de, T> Deserialize<'de> for AssetRef<T> {
165    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
166        if d.is_human_readable() {
167            d.deserialize_any(AssetRefVisitor::<T>(PhantomData))
168        } else {
169            Ok(AssetRef::resolved(AssetId(u32::deserialize(d)?)))
170        }
171    }
172}
173
174/// `serde` `deserialize_with` helper for an optional typed reference field.
175///
176/// Accepts a name string, an integer id, an empty string, or null; the latter
177/// two resolve to `None`. Apply with `#[serde(default, deserialize_with =
178/// "concinnity_core::ecs::asset_id::de_opt_asset_ref_typed")]`.
179pub fn de_opt_asset_ref_typed<'de, D, T>(d: D) -> Result<Option<AssetRef<T>>, D::Error>
180where
181    D: Deserializer<'de>,
182{
183    // A non-self-describing format (postcard, the baked blob form) carries the
184    // already-resolved id; names only appear in human-readable input.
185    if !d.is_human_readable() {
186        return Option::<AssetRef<T>>::deserialize(d);
187    }
188
189    struct OptVisitor<T>(PhantomData<fn() -> T>);
190
191    impl<'de, T> Visitor<'de> for OptVisitor<T> {
192        type Value = Option<AssetRef<T>>;
193
194        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195            f.write_str("an asset reference name string, id integer, or null")
196        }
197
198        fn visit_unit<E: de::Error>(self) -> Result<Option<AssetRef<T>>, E> {
199            Ok(None)
200        }
201        fn visit_none<E: de::Error>(self) -> Result<Option<AssetRef<T>>, E> {
202            Ok(None)
203        }
204        fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<AssetRef<T>>, E> {
205            Ok(Some(AssetRef::resolved(AssetId(v as u32))))
206        }
207        fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<AssetRef<T>>, E> {
208            Ok(Some(AssetRef::resolved(AssetId(v as u32))))
209        }
210        fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<AssetRef<T>>, E> {
211            if v.is_empty() {
212                Ok(None)
213            } else {
214                Ok(Some(AssetRefVisitor::<T>::from_name(v)))
215            }
216        }
217        fn visit_string<E: de::Error>(self, v: String) -> Result<Option<AssetRef<T>>, E> {
218            self.visit_str(&v)
219        }
220    }
221
222    d.deserialize_any(OptVisitor(PhantomData))
223}
224
225#[cfg(test)]
226mod tests {
227    // The resolver seam is process-global, so tests that need one install the
228    // shared stand-in rather than their own; the rest use integer paths and
229    // constructors, which do not consult it at all.
230    use super::*;
231    use alloc::string::ToString;
232
233    struct Texture;
234
235    #[test]
236    fn deserializes_a_resolved_id_from_compiled_args() {
237        let r: AssetRef<Texture> = serde_json::from_str("5").unwrap();
238        assert_eq!(r.id(), Some(AssetId(5)));
239        assert!(r.is_resolved());
240    }
241
242    #[test]
243    fn resolve_fills_the_id() {
244        let mut r: AssetRef<Texture> = AssetRef::by_name("wall");
245        assert_eq!(r.name(), "wall");
246        r.resolve(AssetId(3));
247        assert_eq!(r.id(), Some(AssetId(3)));
248    }
249
250    #[test]
251    fn serializes_a_resolved_reference_as_an_integer() {
252        // The compiled-args form matches the legacy AssetId field byte-for-byte.
253        let r = AssetRef::<Texture>::resolved(AssetId(7));
254        assert_eq!(serde_json::to_string(&r).unwrap(), "7");
255    }
256
257    #[test]
258    fn serializes_an_unresolved_reference_as_its_name() {
259        let r = AssetRef::<Texture>::by_name("floor");
260        assert_eq!(serde_json::to_string(&r).unwrap(), "\"floor\"");
261    }
262
263    #[derive(Debug, serde::Deserialize)]
264    struct Holder {
265        #[serde(default, deserialize_with = "de_opt_asset_ref_typed")]
266        r: Option<AssetRef<Texture>>,
267    }
268
269    #[test]
270    fn opt_ref_treats_empty_null_and_missing_as_none() {
271        assert!(
272            serde_json::from_str::<Holder>("{\"r\":\"\"}")
273                .unwrap()
274                .r
275                .is_none()
276        );
277        assert!(
278            serde_json::from_str::<Holder>("{\"r\":null}")
279                .unwrap()
280                .r
281                .is_none()
282        );
283        assert!(serde_json::from_str::<Holder>("{}").unwrap().r.is_none());
284        assert_eq!(
285            serde_json::from_str::<Holder>("{\"r\":5}")
286                .unwrap()
287                .r
288                .unwrap()
289                .id(),
290            Some(AssetId(5))
291        );
292    }
293
294    #[test]
295    fn is_send_and_sync_regardless_of_target() {
296        fn assert_send_sync<U: Send + Sync>() {}
297        assert_send_sync::<AssetRef<Texture>>();
298    }
299
300    #[test]
301    fn a_blank_reference_is_unresolved_and_nameless() {
302        let r = AssetRef::<Texture>::default();
303        assert_eq!(r.name(), "");
304        assert_eq!(r.id(), None);
305        assert!(!r.is_resolved());
306    }
307
308    #[test]
309    fn clone_and_equality_compare_name_and_id() {
310        // No `T: Trait` bounds: the target tag is phantom, so a marker type with
311        // no impls of its own still clones and compares.
312        let named = AssetRef::<Texture>::by_name("floor");
313        assert_eq!(named.clone(), named);
314        assert_ne!(named, AssetRef::<Texture>::by_name("wall"));
315        assert_ne!(named, AssetRef::<Texture>::resolved(AssetId(5)));
316        let resolved = AssetRef::<Texture>::resolved(AssetId(5));
317        assert_eq!(resolved.clone(), resolved);
318    }
319
320    #[test]
321    fn debug_shows_the_name_and_id() {
322        let r = AssetRef::<Texture>::resolved(AssetId(5));
323        let shown = alloc::format!("{r:?}");
324        assert!(shown.contains("AssetRef"), "{shown}");
325        assert!(shown.contains("id: Some(AssetId(5))"), "{shown}");
326    }
327
328    #[test]
329    fn deserializes_a_name_through_the_seam() {
330        crate::test_support::install_resolvers();
331        let r: AssetRef<Texture> = serde_json::from_str("\"floor\"").unwrap();
332        assert_eq!(r.id(), Some(AssetId(5)));
333        // An owned string, the form the serde_json::Value bridge hands over.
334        let r: AssetRef<Texture> = serde_json::from_value(serde_json::json!("wall")).unwrap();
335        assert_eq!(r.id(), Some(AssetId(4)));
336    }
337
338    #[test]
339    fn deserializes_a_signed_integer_narrowed_to_id_width() {
340        let r: AssetRef<Texture> = serde_json::from_str("-1").unwrap();
341        assert_eq!(r.id(), Some(AssetId(u32::MAX)));
342    }
343
344    #[test]
345    fn a_wrong_typed_reference_names_what_it_accepts() {
346        let err = serde_json::from_str::<AssetRef<Texture>>("true")
347            .unwrap_err()
348            .to_string();
349        assert!(
350            err.contains("an asset reference name string or a resolved id integer"),
351            "{err}"
352        );
353        let err = serde_json::from_str::<Holder>("{\"r\":true}")
354            .unwrap_err()
355            .to_string();
356        assert!(
357            err.contains("an asset reference name string, id integer, or null"),
358            "{err}"
359        );
360    }
361
362    #[test]
363    fn round_trips_through_postcard_as_a_resolved_id() {
364        // The baked blob form is not self-describing, so both the plain and the
365        // optional path read the resolved id straight through.
366        #[derive(serde::Serialize, serde::Deserialize)]
367        struct Baked {
368            plain: AssetRef<Texture>,
369            #[serde(default, deserialize_with = "de_opt_asset_ref_typed")]
370            opt: Option<AssetRef<Texture>>,
371        }
372        let baked = Baked {
373            plain: AssetRef::resolved(AssetId(7)),
374            opt: Some(AssetRef::resolved(AssetId(9))),
375        };
376        let bytes = postcard::to_allocvec(&baked).unwrap();
377        let back: Baked = postcard::from_bytes(&bytes).unwrap();
378        assert_eq!(back.plain.id(), Some(AssetId(7)));
379        assert_eq!(back.opt.unwrap().id(), Some(AssetId(9)));
380    }
381
382    #[test]
383    fn a_truncated_baked_reference_is_an_error_not_a_panic() {
384        // The baked path reads the resolved id straight through, so a blob cut
385        // short has to surface as a decode error rather than a partial value.
386        assert!(postcard::from_bytes::<AssetRef<Texture>>(&[]).is_err());
387    }
388
389    #[test]
390    fn opt_ref_accepts_names_signed_integers_and_a_reported_none() {
391        crate::test_support::install_resolvers();
392        assert_eq!(
393            serde_json::from_str::<Holder>("{\"r\":\"floor\"}")
394                .unwrap()
395                .r
396                .unwrap()
397                .id(),
398            Some(AssetId(5))
399        );
400        assert_eq!(
401            serde_json::from_str::<Holder>("{\"r\":-1}")
402                .unwrap()
403                .r
404                .unwrap()
405                .id(),
406            Some(AssetId(u32::MAX))
407        );
408        // An owned string, empty or not, through the serde_json::Value bridge.
409        assert_eq!(
410            serde_json::from_value::<Holder>(serde_json::json!({"r": "wall"}))
411                .unwrap()
412                .r
413                .unwrap()
414                .id(),
415            Some(AssetId(4))
416        );
417        assert!(
418            serde_json::from_value::<Holder>(serde_json::json!({"r": ""}))
419                .unwrap()
420                .r
421                .is_none()
422        );
423        // A `None` reported by an option-aware format, rather than a null unit.
424        assert!(
425            de_opt_asset_ref_typed::<_, Texture>(crate::test_support::NoneDeserializer)
426                .unwrap()
427                .is_none()
428        );
429    }
430}