Skip to main content

concinnity_core/ecs/
resolver.rs

1//! Name -> id resolution seam.
2//!
3//! A reference deserializes either from an already-resolved integer id (the
4//! compiled-args / runtime form) or from a name string (the authoring form).
5//! Turning a name into a dense id is engine policy -- the build assigns ids in
6//! world declaration order -- so this data crate does not own it.
7//! concinnity-host installs a resolver here, backed by its build-time interner,
8//! before it deserializes named references. A name seen with no resolver
9//! installed is a configuration error, surfaced as a deserialization failure
10//! (the resolver is always installed during a build; only an out-of-engine tool
11//! reading authoring JSON would hit the unset case).
12//!
13//! Each resolver is a plain function pointer held in an atomic, so this stays
14//! `no_std` and thread-safe: the pointer is written once (install) and only read
15//! afterward, and the installed function keeps its own (per-thread) state in
16//! concinnity-host. The two slot types below centralize the single unavoidable
17//! piece of unsafe -- `core` has no atomic function-pointer type, so reading a
18//! `fn` back out of a `usize` requires a `transmute` -- into one audited place
19//! per function-pointer shape.
20
21use core::sync::atomic::{AtomicUsize, Ordering};
22
23/// A name -> dense id resolver.
24pub(crate) type ResolveFn = fn(&str) -> u32;
25
26/// A name -> per-kind resource-handle resolver. Returns the resource's dense
27/// handle, or `None` when the name is not a known resource of that kind in the
28/// current build (or no build map is installed). Unlike the name interner a
29/// handle is not assignable on demand: it is a position in the build's
30/// declaration-ordered resource table, so a name with no matching resource has
31/// no handle.
32pub(crate) type HandleResolveFn = fn(&str) -> Option<u32>;
33
34// An atomically-installable `ResolveFn` slot. Holds the function pointer as a
35// `usize` (0 = unset): written once at install, only read afterward.
36struct NameResolverSlot(AtomicUsize);
37
38impl NameResolverSlot {
39    const fn new() -> Self {
40        Self(AtomicUsize::new(0))
41    }
42
43    fn set(&self, f: ResolveFn) {
44        self.0.store(f as usize, Ordering::Release);
45    }
46
47    fn resolve(&self, name: &str) -> Option<u32> {
48        let v = self.0.load(Ordering::Acquire);
49        if v == 0 {
50            return None;
51        }
52        // SAFETY: `v` is non-zero here, so it is a `ResolveFn` address stored by
53        // `set`; the transmute reverses that exact `fn as usize`.
54        let f: ResolveFn = unsafe { core::mem::transmute::<usize, ResolveFn>(v) };
55        Some(f(name))
56    }
57}
58
59// An atomically-installable `HandleResolveFn` slot. Same install-once /
60// read-many discipline as `NameResolverSlot`; one instance backs each per-kind
61// handle resolver.
62struct HandleResolverSlot(AtomicUsize);
63
64impl HandleResolverSlot {
65    const fn new() -> Self {
66        Self(AtomicUsize::new(0))
67    }
68
69    fn set(&self, f: HandleResolveFn) {
70        self.0.store(f as usize, Ordering::Release);
71    }
72
73    fn resolve(&self, name: &str) -> Option<u32> {
74        let v = self.0.load(Ordering::Acquire);
75        if v == 0 {
76            return None;
77        }
78        // SAFETY: `v` is non-zero here, so it is a `HandleResolveFn` address
79        // stored by `set`; the transmute reverses that exact `fn as usize`.
80        let f: HandleResolveFn = unsafe { core::mem::transmute::<usize, HandleResolveFn>(v) };
81        f(name)
82    }
83}
84
85#[cfg(not(test))]
86static RESOLVER: NameResolverSlot = NameResolverSlot::new();
87
88/// Install the name -> id resolver. Called once by concinnity-host, backed by
89/// its build-time interner. Idempotent; the last writer wins.
90#[cfg(not(test))]
91pub fn set_name_resolver(f: ResolveFn) {
92    RESOLVER.set(f);
93}
94
95/// Resolve a name to a dense id via the installed resolver, or `None` if none is
96/// installed (only expected outside a build).
97#[cfg(not(test))]
98pub(crate) fn resolve_name(name: &str) -> Option<u32> {
99    RESOLVER.resolve(name)
100}
101
102// Under test the slot is per-thread rather than process-wide. The harness runs
103// tests in parallel and the crate carries two stand-in policies -- a
104// declaration-order interner and a name-length map -- so a shared pointer lets
105// whichever installed last answer the other's tests.
106#[cfg(test)]
107std::thread_local! {
108    static RESOLVER: core::cell::Cell<Option<ResolveFn>> =
109        const { core::cell::Cell::new(None) };
110}
111
112/// Install the name -> id resolver.
113#[cfg(test)]
114pub fn set_name_resolver(f: ResolveFn) {
115    RESOLVER.with(|slot| slot.set(Some(f)));
116}
117
118/// Resolve a name to a dense id via the installed resolver.
119#[cfg(test)]
120pub(crate) fn resolve_name(name: &str) -> Option<u32> {
121    RESOLVER.with(|slot| slot.get()).map(|f| f(name))
122}
123
124// One slot / install / resolve triple per resource kind, each backed by the
125// current build's declaration-ordered handle map for that kind.
126macro_rules! handle_resolver {
127    (
128        $(#[$extra:meta])*
129        $slot:ident, $noun:literal, $set_fn:ident, $resolve_fn:ident $(,)?
130    ) => {
131        #[cfg(not(test))]
132        static $slot: HandleResolverSlot = HandleResolverSlot::new();
133
134        // Per-thread under test, for the reason given on the name slot above:
135        // some tests install a stand-in and others pin what happens with none
136        // installed, which a process-wide slot cannot serve at the same time.
137        #[cfg(test)]
138        std::thread_local! {
139            static $slot: core::cell::Cell<Option<HandleResolveFn>> =
140                const { core::cell::Cell::new(None) };
141        }
142
143        #[doc = concat!(
144            "Install the name -> ", $noun,
145            "-handle resolver. Called by concinnity-cook, backed by the current ",
146            "build's declaration-ordered ", $noun,
147            " handle map. Idempotent; the last writer wins."
148        )]
149        $(#[$extra])*
150        #[cfg(not(test))]
151        pub fn $set_fn(f: HandleResolveFn) {
152            $slot.set(f);
153        }
154
155        #[cfg(test)]
156        #[doc = concat!("Install the name -> ", $noun, "-handle resolver.")]
157        pub fn $set_fn(f: HandleResolveFn) {
158            $slot.with(|slot| slot.set(Some(f)));
159        }
160
161        #[doc = concat!(
162            "Resolve a ", $noun,
163            " reference name to its dense handle value via the installed ",
164            "resolver. `None` means either no resolver is installed or the name ",
165            "is not a declared ", $noun,
166            "; the caller decides whether to fall back (a validation context) ",
167            "or to fail (a real build)."
168        )]
169        #[cfg(not(test))]
170        pub(crate) fn $resolve_fn(name: &str) -> Option<u32> {
171            $slot.resolve(name)
172        }
173
174        #[cfg(test)]
175        pub(crate) fn $resolve_fn(name: &str) -> Option<u32> {
176            $slot.with(|slot| slot.get()).and_then(|f| f(name))
177        }
178    };
179}
180
181handle_resolver! {
182    TEXTURE_HANDLE_RESOLVER, "texture",
183    set_texture_handle_resolver, resolve_texture_handle,
184}
185handle_resolver! {
186    AUDIO_CLIP_HANDLE_RESOLVER, "audio-clip",
187    set_audio_clip_handle_resolver, resolve_audio_clip_handle,
188}
189handle_resolver! {
190    FONT_HANDLE_RESOLVER, "font",
191    set_font_handle_resolver, resolve_font_handle,
192}
193handle_resolver! {
194    /// The mesh-source handle space is shared across every geometry-producing
195    /// kind (Mesh, ProceduralMesh, VoxelChunk, and mesh-kind File), so one
196    /// resolver serves them all.
197    MESH_HANDLE_RESOLVER, "mesh",
198    set_mesh_handle_resolver, resolve_mesh_handle,
199}
200handle_resolver! {
201    MATERIAL_HANDLE_RESOLVER, "material",
202    set_material_handle_resolver, resolve_material_handle,
203}
204handle_resolver! {
205    /// A SkinnedMesh stays an ECS component, but its authored references
206    /// (`Animation.target`, `AnimationGraph.target`, `FollowController.target`)
207    /// resolve to its dense handle so they no longer carry an interned id.
208    SKINNED_MESH_HANDLE_RESOLVER, "skinned-mesh",
209    set_skinned_mesh_handle_resolver, resolve_skinned_mesh_handle,
210}
211handle_resolver! {
212    /// A Shader stays an ECS component, but a Material's authored `shader`
213    /// reference resolves to its dense handle so the runtime never scans by name.
214    SHADER_HANDLE_RESOLVER, "shader",
215    set_shader_handle_resolver, resolve_shader_handle,
216}
217
218#[cfg(test)]
219mod tests {
220    // These tests own the process-global resolver: each installs the same
221    // deterministic stand-in first, so they stay correct regardless of the order
222    // the test harness runs them in (installs are idempotent, last-writer-wins).
223    use super::*;
224    use crate::ecs::asset_id::{AssetId, AssetRef, de_opt_asset_ref, de_opt_asset_ref_typed};
225    use crate::test_support::{install_resolvers, len_handle_resolver, len_name_resolver};
226
227    struct Clip;
228
229    #[test]
230    fn a_slot_reads_back_the_function_pointer_it_was_given() {
231        // The slots hold their function pointer as a `usize` and transmute it
232        // back, the one piece of unsafe here. Exercising a fresh slot rather
233        // than the process-global statics is the only way to see the unset
234        // state, which a test cannot restore once something has installed.
235        let name_slot = NameResolverSlot::new();
236        assert_eq!(name_slot.resolve("floor"), None);
237        name_slot.set(len_name_resolver);
238        assert_eq!(name_slot.resolve("floor"), Some(5));
239
240        let handle_slot = HandleResolverSlot::new();
241        assert_eq!(handle_slot.resolve("floor"), None);
242        handle_slot.set(len_handle_resolver);
243        assert_eq!(handle_slot.resolve("floor"), Some(5));
244        // A handle resolver may also answer "no such resource of this kind",
245        // which the name interner slot has no way to express.
246        assert_eq!(handle_slot.resolve("unknown_x"), None);
247    }
248
249    #[test]
250    fn installed_resolver_is_used() {
251        set_name_resolver(len_name_resolver);
252        assert_eq!(resolve_name("abcd"), Some(4));
253    }
254
255    #[test]
256    fn asset_id_resolves_a_name_through_the_seam() {
257        set_name_resolver(len_name_resolver);
258        let id: AssetId = serde_json::from_str("\"floor\"").unwrap();
259        assert_eq!(id, AssetId(5));
260    }
261
262    #[test]
263    fn asset_ref_resolves_a_name_through_the_seam() {
264        set_name_resolver(len_name_resolver);
265        let r: AssetRef<Clip> = serde_json::from_str("\"wall\"").unwrap();
266        assert_eq!(r.id(), Some(AssetId(4)));
267        assert!(r.is_resolved());
268    }
269
270    #[test]
271    fn every_handle_seam_resolves_through_its_own_slot() {
272        // One slot per kind: a name is a position in that kind's declaration-
273        // ordered table, so the kinds never share an answer by accident.
274        install_resolvers();
275        set_texture_handle_resolver(len_handle_resolver);
276        set_audio_clip_handle_resolver(len_handle_resolver);
277        set_font_handle_resolver(len_handle_resolver);
278        set_mesh_handle_resolver(len_handle_resolver);
279        set_material_handle_resolver(len_handle_resolver);
280        set_skinned_mesh_handle_resolver(len_handle_resolver);
281        set_shader_handle_resolver(len_handle_resolver);
282
283        assert_eq!(resolve_texture_handle("floor"), Some(5));
284        assert_eq!(resolve_audio_clip_handle("floor"), Some(5));
285        assert_eq!(resolve_font_handle("floor"), Some(5));
286        assert_eq!(resolve_mesh_handle("floor"), Some(5));
287        assert_eq!(resolve_material_handle("floor"), Some(5));
288        assert_eq!(resolve_skinned_mesh_handle("floor"), Some(5));
289        assert_eq!(resolve_shader_handle("floor"), Some(5));
290
291        // A handle is not assignable on demand: a name the build declares no
292        // resource of that kind for has none, even with a resolver installed.
293        assert_eq!(resolve_texture_handle("unknown_x"), None);
294        assert_eq!(resolve_shader_handle("unknown_x"), None);
295    }
296
297    #[test]
298    fn opt_helpers_resolve_a_name_and_pass_through_an_id() {
299        set_name_resolver(len_name_resolver);
300
301        #[derive(serde::Deserialize)]
302        struct Bare {
303            #[serde(default, deserialize_with = "de_opt_asset_ref")]
304            r: Option<AssetId>,
305        }
306        #[derive(serde::Deserialize)]
307        struct Typed {
308            #[serde(default, deserialize_with = "de_opt_asset_ref_typed")]
309            r: Option<AssetRef<Clip>>,
310        }
311
312        assert_eq!(
313            serde_json::from_str::<Bare>("{\"r\":\"mesh_a\"}")
314                .unwrap()
315                .r,
316            Some(AssetId(6))
317        );
318        assert_eq!(
319            serde_json::from_str::<Typed>("{\"r\":\"abc\"}")
320                .unwrap()
321                .r
322                .unwrap()
323                .id(),
324            Some(AssetId(3))
325        );
326    }
327}