Skip to main content

concinnity_core/ecs/
asset_id.rs

1//! Asset identity: names declared in world.jsonl are interned to an `AssetId` in
2//! declaration order, and the blob and the runtime carry only the integer, so
3//! every cross-reference lookup is an integer compare.
4//!
5//! The interner a name resolves through keeps a per-thread table and so belongs
6//! to the std-linked crate above; concinnity-host owns it and installs it into
7//! [`super::resolver`]. At runtime references are already integers, so the seam
8//! is never consulted.
9
10use core::fmt;
11
12use alloc::format;
13use serde::de::{self, Visitor};
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16use crate::ecs::resolver::resolve_name;
17
18/// A dense integer handle for one asset, assigned at build time in world
19/// declaration order. Equality and hashing are integer ops.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
21pub struct AssetId(pub u32);
22
23impl AssetId {
24    /// First id in the range a running world mints from.
25    ///
26    /// A build interns names from zero in declaration order, so reserving the
27    /// top of the space lets a load-time pass name what it injects without
28    /// consulting an interner the shipped runtime does not carry. A world would
29    /// have to declare four billion assets to reach it.
30    pub const MINTED_BASE: u32 = u32::MAX - 0xFFFF;
31
32    /// Whether this id was minted by a running world rather than interned from
33    /// a declared name.
34    pub fn is_minted(self) -> bool {
35        self.0 >= Self::MINTED_BASE
36    }
37}
38
39/// Hands out ids in the range a running world mints from, in call order.
40///
41/// One per world, carried as a world resource: everything that mints -- a
42/// data-entry method before start, the completion pass at start -- draws from
43/// the same counter, so no two minted assets collide.
44#[derive(Debug, Clone, Default)]
45pub struct MintedIds {
46    next: u32,
47}
48
49impl MintedIds {
50    /// The next unused minted id.
51    pub fn next_id(&mut self) -> AssetId {
52        let id = AssetId(AssetId::MINTED_BASE + self.next);
53        self.next += 1;
54        id
55    }
56}
57
58impl fmt::Display for AssetId {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        write!(f, "#{}", self.0)
61    }
62}
63
64impl Serialize for AssetId {
65    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
66        s.serialize_u32(self.0)
67    }
68}
69
70struct AssetIdVisitor;
71
72impl Visitor<'_> for AssetIdVisitor {
73    type Value = AssetId;
74
75    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str("an asset id integer or a name string")
77    }
78
79    fn visit_u64<E: de::Error>(self, v: u64) -> Result<AssetId, E> {
80        Ok(AssetId(v as u32))
81    }
82    fn visit_i64<E: de::Error>(self, v: i64) -> Result<AssetId, E> {
83        Ok(AssetId(v as u32))
84    }
85    fn visit_str<E: de::Error>(self, v: &str) -> Result<AssetId, E> {
86        resolve_name(v).map(AssetId).ok_or_else(|| {
87            E::custom(format!(
88                "no asset-name resolver installed to resolve reference {v:?}"
89            ))
90        })
91    }
92    fn visit_string<E: de::Error>(self, v: alloc::string::String) -> Result<AssetId, E> {
93        self.visit_str(&v)
94    }
95}
96
97impl<'de> Deserialize<'de> for AssetId {
98    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
99        if d.is_human_readable() {
100            d.deserialize_any(AssetIdVisitor)
101        } else {
102            Ok(AssetId(u32::deserialize(d)?))
103        }
104    }
105}
106
107/// `serde` `deserialize_with` helper for an optional cross-reference field.
108///
109/// Accepts a name string (resolved), an integer id, an empty string, or null;
110/// the latter two resolve to `None`. Apply with `#[serde(default,
111/// deserialize_with = "concinnity_core::ecs::asset_id::de_opt_asset_ref")]` so a missing field
112/// is also `None`.
113pub fn de_opt_asset_ref<'de, D>(d: D) -> Result<Option<AssetId>, D::Error>
114where
115    D: Deserializer<'de>,
116{
117    // A non-self-describing format (postcard, the baked blob form) carries the
118    // already-resolved id; names only appear in human-readable input.
119    if !d.is_human_readable() {
120        return Option::<AssetId>::deserialize(d);
121    }
122
123    struct OptVisitor;
124
125    impl Visitor<'_> for OptVisitor {
126        type Value = Option<AssetId>;
127
128        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129            f.write_str("an asset reference name string, id integer, or null")
130        }
131
132        fn visit_unit<E: de::Error>(self) -> Result<Option<AssetId>, E> {
133            Ok(None)
134        }
135        fn visit_none<E: de::Error>(self) -> Result<Option<AssetId>, E> {
136            Ok(None)
137        }
138        fn visit_u64<E: de::Error>(self, v: u64) -> Result<Option<AssetId>, E> {
139            Ok(Some(AssetId(v as u32)))
140        }
141        fn visit_i64<E: de::Error>(self, v: i64) -> Result<Option<AssetId>, E> {
142            Ok(Some(AssetId(v as u32)))
143        }
144        fn visit_str<E: de::Error>(self, v: &str) -> Result<Option<AssetId>, E> {
145            if v.is_empty() {
146                Ok(None)
147            } else {
148                AssetIdVisitor.visit_str(v).map(Some)
149            }
150        }
151        fn visit_string<E: de::Error>(
152            self,
153            v: alloc::string::String,
154        ) -> Result<Option<AssetId>, E> {
155            self.visit_str(&v)
156        }
157    }
158
159    d.deserialize_any(OptVisitor)
160}
161
162pub use super::asset_ref::{AssetRef, de_opt_asset_ref_typed};
163#[cfg(test)]
164mod tests {
165    use alloc::string::ToString;
166
167    use super::*;
168
169    #[test]
170    fn round_trips_through_json_as_a_bare_integer() {
171        let bytes = serde_json::to_vec(&AssetId(7)).unwrap();
172        assert_eq!(bytes, b"7");
173        let back: AssetId = serde_json::from_slice(&bytes).unwrap();
174        assert_eq!(back, AssetId(7));
175    }
176
177    #[test]
178    fn deserializes_from_an_already_resolved_integer() {
179        // The compiled-args / runtime path: refs are ints, no resolver needed.
180        let id: AssetId = serde_json::from_str("5").unwrap();
181        assert_eq!(id, AssetId(5));
182    }
183
184    #[test]
185    fn round_trips_through_postcard() {
186        // postcard is the blob defs-table format (BlobAssetDef.name is an AssetId).
187        let bytes = postcard::to_allocvec(&AssetId(1234)).unwrap();
188        let back: AssetId = postcard::from_bytes(&bytes).unwrap();
189        assert_eq!(back, AssetId(1234));
190    }
191
192    #[test]
193    fn a_truncated_baked_id_is_an_error_not_a_panic() {
194        // A blob whose defs table is cut short must surface as a decode error:
195        // the baked path reads the id straight through with no visitor to
196        // fall back on.
197        assert!(postcard::from_bytes::<AssetId>(&[]).is_err());
198    }
199
200    #[test]
201    fn opt_ref_treats_empty_null_and_missing_as_none() {
202        #[derive(serde::Deserialize)]
203        struct Holder {
204            #[serde(default, deserialize_with = "de_opt_asset_ref")]
205            r: Option<AssetId>,
206        }
207        assert!(
208            serde_json::from_str::<Holder>("{\"r\":\"\"}")
209                .unwrap()
210                .r
211                .is_none()
212        );
213        assert!(
214            serde_json::from_str::<Holder>("{\"r\":null}")
215                .unwrap()
216                .r
217                .is_none()
218        );
219        assert!(serde_json::from_str::<Holder>("{}").unwrap().r.is_none());
220        assert_eq!(
221            serde_json::from_str::<Holder>("{\"r\":5}").unwrap().r,
222            Some(AssetId(5))
223        );
224    }
225
226    #[test]
227    fn opt_ref_round_trips_through_postcard() {
228        // The baked blob form: not self-describing, carries the resolved id.
229        #[derive(serde::Serialize, serde::Deserialize)]
230        struct Holder {
231            #[serde(default, deserialize_with = "de_opt_asset_ref")]
232            r: Option<AssetId>,
233            #[serde(default, deserialize_with = "de_opt_asset_ref")]
234            none: Option<AssetId>,
235        }
236        let h = Holder {
237            r: Some(AssetId(7)),
238            none: None,
239        };
240        let bytes = postcard::to_allocvec(&h).unwrap();
241        let back: Holder = postcard::from_bytes(&bytes).unwrap();
242        assert_eq!(back.r, Some(AssetId(7)));
243        assert_eq!(back.none, None);
244    }
245
246    #[test]
247    fn display_formats_with_a_hash_prefix() {
248        assert_eq!(AssetId(42).to_string(), "#42");
249    }
250
251    #[test]
252    fn default_is_zero() {
253        assert_eq!(AssetId::default(), AssetId(0));
254    }
255
256    #[test]
257    fn deserializes_a_name_through_the_seam() {
258        crate::test_support::install_resolvers();
259        assert_eq!(
260            serde_json::from_str::<AssetId>("\"floor\"").unwrap(),
261            AssetId(5)
262        );
263        // An owned string, the form the serde_json::Value bridge hands over.
264        assert_eq!(
265            serde_json::from_value::<AssetId>(serde_json::json!("wall")).unwrap(),
266            AssetId(4)
267        );
268    }
269
270    #[test]
271    fn deserializes_a_signed_integer_narrowed_to_id_width() {
272        assert_eq!(
273            serde_json::from_str::<AssetId>("-1").unwrap(),
274            AssetId(u32::MAX)
275        );
276    }
277
278    #[test]
279    fn a_wrong_typed_id_names_what_it_accepts() {
280        let err = serde_json::from_str::<AssetId>("true")
281            .unwrap_err()
282            .to_string();
283        assert!(
284            err.contains("an asset id integer or a name string"),
285            "{err}"
286        );
287    }
288
289    #[derive(Debug, serde::Deserialize)]
290    struct Holder {
291        #[serde(default, deserialize_with = "de_opt_asset_ref")]
292        r: Option<AssetId>,
293    }
294
295    #[test]
296    fn opt_ref_accepts_names_signed_integers_and_a_reported_none() {
297        crate::test_support::install_resolvers();
298        assert_eq!(
299            serde_json::from_str::<Holder>("{\"r\":\"floor\"}")
300                .unwrap()
301                .r,
302            Some(AssetId(5))
303        );
304        assert_eq!(
305            serde_json::from_str::<Holder>("{\"r\":-1}").unwrap().r,
306            Some(AssetId(u32::MAX))
307        );
308        // An owned string, empty or not, through the serde_json::Value bridge.
309        assert_eq!(
310            serde_json::from_value::<Holder>(serde_json::json!({"r": "wall"}))
311                .unwrap()
312                .r,
313            Some(AssetId(4))
314        );
315        assert_eq!(
316            serde_json::from_value::<Holder>(serde_json::json!({"r": ""}))
317                .unwrap()
318                .r,
319            None
320        );
321        // A `None` reported by an option-aware format, rather than a null unit.
322        assert_eq!(
323            de_opt_asset_ref(crate::test_support::NoneDeserializer).unwrap(),
324            None
325        );
326    }
327
328    #[test]
329    fn a_wrong_typed_opt_ref_names_what_it_accepts() {
330        let err = serde_json::from_str::<Holder>("{\"r\":true}")
331            .unwrap_err()
332            .to_string();
333        assert!(
334            err.contains("an asset reference name string, id integer, or null"),
335            "{err}"
336        );
337    }
338}