Skip to main content

concinnity_core/ecs/
registry.rs

1// src/ecs/registry.rs
2//
3// Single source of truth for the renderer-free half of the engine's asset
4// registry: every Component type paired with its stable u8 discriminant.
5//
6// The list lives in one macro, `for_each_component!`, so both registries built
7// from it stay in lockstep: the runtime value enum + ECS storage
8// (`define_components!`, invoked below in this crate) and the authoring metadata
9// registry (`RegisteredType`, invoked in the build crate from the same list).
10//
11// It arrives in two groups, and which group an entry is in is the whole of what
12// separates a component from a resource:
13//
14//   stored    has a `ComponentTag`, a `ComponentAsset` variant, a column, an
15//             `impl Component`, and an `impl RuntimeComponent`, so a world can
16//             hold one. Split further by the entry's own origin flag into
17//             `external` (declared in a world, survives into a blob) and
18//             `runtime` (only ever minted by a running world).
19//   resource  declared in a world and compiled into the blob's resource stream,
20//             addressed at runtime by a per-kind handle rather than a column.
21//             `impl ResourceAsset`; the entry names its `ResourceKind`. Carries
22//             no origin flag, because being in the group is the origin.
23//
24// A third group -- the types a world declares and the cook expands away before a
25// blob is written -- is the authoring vocabulary, which this crate does not
26// name: its list lives in `concinnity_cook::authoring::registry::build_only`, and the
27// authoring registry composes the two by passing it through the `$extra` tail
28// below.
29//
30// Components are pure data, registered with one entry each. There is no system
31// registry: every system is internal code, constructed at runtime from world
32// content (see `World::start`), never declared in a world or serialized to a
33// blob. The table that gates and orders the constructed systems is the caller's:
34// this crate's headless table, or the client crate's `ecs::registry`.
35//
36// Each component's discriminant (its on-disk blob tag and in-memory
37// `ComponentId`) is assigned by its position in this list: the runtime
38// `define_components!` builds a `#[repr(u8)] ComponentTag` enum whose variants
39// are these entries in order, so the tag is the list position. Discriminants are
40// therefore not hand-written and not a stable on-disk contract: a build
41// regenerates the blob, so the blob and the engine that loads it always agree.
42// Reordering the list changes every tag, which is only safe alongside a rebuild
43// (`cn build`), which the workflow always does. The tag must stay in 0..128 (the
44// `ComponentMask` ceiling); the list is far shorter, so position keeps it there.
45
46use crate::define_components;
47use crate::ecs::{BlobAssetDef, Component, PayloadLocator};
48use crate::result::CnResult;
49
50/// The one component list. `$cb` is a macro that receives the `Variant => Type`
51/// entries and expands to whatever registry it builds from them. Type paths are
52/// absolute so the list resolves from any crate that consumes it.
53///
54/// The `$cb; $extra` form prepends arbitrary tokens to what `$cb` receives, so a
55/// consumer holding a group this crate does not name (the authoring-only
56/// vocabulary in concinnity-cook) can hand its own list to the same callback.
57#[macro_export]
58macro_rules! for_each_component {
59    ($cb:ident) => { $crate::for_each_component!($cb;); };
60    ($cb:ident; $($extra:tt)*) => {
61        $cb! {
62            $($extra)*
63            // Stored: every type with a column, a `ComponentTag`, and a
64            // `ComponentAsset` variant. `external` entries are declared in a
65            // world and survive into a blob; `runtime` entries are only ever
66            // minted by a running world.
67            stored: {
68                Window            => $crate::components::Window { gen, external, singleton, consumed },
69                GraphicsConfig    => $crate::components::GraphicsConfig { gen, external, singleton, renders, consumed },
70                Shader            => $crate::components::Shader { manual, external, compiled, consumed },
71                Camera3D          => $crate::components::Camera3D { manual, external, useful_blank, live, args: Camera3D },
72                FrameInput        => $crate::components::FrameInput { gen, runtime },
73                Prop              => $crate::components::Prop { gen, external, id, renders, validate: prop, refs: [("model", "Model"), ("material", "Material"), ("texture", "Texture"), ("scene", "Scene"), ("parent", "Prop")], consumed: PropInstance },
74                RigidBody         => $crate::components::RigidBody { gen, external, validate: rigid_body },
75                PropBody          => $crate::components::PropBody { gen, external, consumed },
76                Room              => $crate::components::Room { manual, external, compiled, useful_blank, args: Room, refs: [("texture", "Texture"), ("wall_texture", "Texture"), ("floor_texture", "Texture"), ("ceiling_texture", "Texture")], consumed },
77                DirectionalLight  => $crate::components::DirectionalLight { gen, external, useful_blank, validate: directional_light },
78                PointLight        => $crate::components::PointLight { gen, external, useful_blank, validate: point_light },
79                SpotLight         => $crate::components::SpotLight { gen, external, useful_blank, validate: spot_light },
80                RectAreaLight     => $crate::components::RectAreaLight { gen, external, useful_blank, validate: rect_area_light },
81                ProceduralMesh    => $crate::components::ProceduralMesh { gen, external, compiled, id },
82                Model             => $crate::components::Model { gen, external, id, consumed },
83                Scene             => $crate::components::Scene { gen, external, id, refs: [("camera_shot", "Camera3D")], consumed },
84                TextLabel         => $crate::components::TextLabel { gen, external, id, useful_blank, renders, live, refs: [("font", "Font"), ("screen", "Screen")] },
85                HitRegion         => $crate::components::HitRegion { gen, external, useful_blank, refs: [("label", "TextLabel"), ("screen", "Screen")], consumed },
86                File              => $crate::components::File { manual, external, compiled, args: File, consumed },
87                BlockType         => $crate::components::BlockType { gen, external, id, useful_blank, consumed },
88                VoxelChunk        => $crate::components::VoxelChunk { gen, external, compiled, id, validate: voxel_chunk, consumed },
89                InstancedProp     => $crate::components::InstancedProp { gen, external, id, renders, validate: instanced_prop, refs: [("material", "Material"), ("texture", "Texture")], consumed },
90                PostProcessConfig => $crate::components::PostProcessConfig { manual, external, singleton, consumed },
91                Animation         => $crate::components::Animation { gen, external, id, consumed },
92                SkeletonPose      => $crate::components::SkeletonPose { runtime, build: skeleton_pose },
93                StreamingConfig   => $crate::components::StreamingConfig { gen, external, singleton, consumed },
94                VoxelWorld        => $crate::components::VoxelWorld { gen, external, renders, refs: [("material", "Material")], consumed },
95                AudioEmitter      => $crate::components::AudioEmitter { gen, external, useful_blank, refs: [("clip", "AudioClip"), ("prop", "Prop")] },
96                Sprite            => $crate::components::Sprite { gen, external, id, useful_blank, renders, live, refs: [("texture", "Texture"), ("screen", "Screen")] },
97                KeyBinding        => $crate::components::KeyBinding { gen, external, useful_blank, refs: [("screen", "Screen")], consumed },
98                Screen            => $crate::components::Screen { gen, external, id, useful_blank, refs: [("focus", "TextInput")], consumed },
99                Decal             => $crate::components::Decal { gen, external, id, useful_blank, validate: decal, refs: [("texture", "Texture")], consumed },
100                VolumetricFog     => $crate::components::VolumetricFog { gen, external, useful_blank, validate: volumetric_fog, consumed },
101                PhysicsJoint             => $crate::components::PhysicsJoint { gen, external, id, validate: joint, refs: [("body_a", "Prop"), ("body_b", "Prop")], consumed },
102                ParticleEmitter   => $crate::components::ParticleEmitter { gen, external, id, useful_blank, validate: particle_emitter, refs: [("texture", "Texture")], consumed },
103                WaterSurface      => $crate::components::WaterSurface { gen, external, id, useful_blank, renders, validate: water_surface, consumed },
104                SdfVolume         => $crate::components::SdfVolume { manual, external, compiled, renders, validate_for: sdf_volume, consumed },
105                GlassPanel        => $crate::components::GlassPanel { gen, external, id, useful_blank, validate: glass_panel, consumed },
106                LayoutContainer   => $crate::components::LayoutContainer { gen, external, renders, live },
107                PhysicsConfig     => $crate::components::PhysicsConfig { gen, external, singleton },
108                FpsCounter        => $crate::components::FpsCounter { gen, external, useful_blank, refs: [("label", "TextLabel")] },
109                StatHud           => $crate::components::StatHud { gen, external, renders, refs: [("fps_label", "TextLabel"), ("vram_label", "TextLabel"), ("ram_label", "TextLabel"), ("ev_label", "TextLabel"), ("edr_label", "TextLabel")] },
110                ScrollPanel       => $crate::components::ScrollPanel { gen, external, refs: [("screen", "Screen")], consumed },
111                ReflectionProbe   => $crate::components::ReflectionProbe { gen, external, useful_blank, validate: reflection_probe },
112                Transform         => $crate::components::Transform { runtime },
113                PropInstance      => $crate::components::PropInstance { runtime },
114                MeshRenderer      => $crate::components::MeshRenderer { runtime },
115                ModelRenderer     => $crate::components::ModelRenderer { runtime },
116                Collider          => $crate::components::Collider { runtime },
117                BodyDynamics      => $crate::components::BodyDynamics { runtime },
118                Interactable      => $crate::components::Interactable { runtime },
119                Pickup            => $crate::components::Pickup { runtime },
120                Parent            => $crate::components::Parent { runtime },
121                Children          => $crate::components::Children { runtime },
122                SceneMember       => $crate::components::SceneMember { runtime },
123                GlobalTransform   => $crate::components::GlobalTransform { runtime },
124                RenderHandle      => $crate::components::RenderHandle { runtime },
125                Held              => $crate::components::Held { runtime },
126                Lifetime          => $crate::components::Lifetime { runtime },
127                Spawner           => $crate::components::Spawner { manual, external, args: Spawner },
128                DebugHud          => $crate::components::DebugHud { gen, external, renders, refs: [("passes_label", "TextLabel"), ("mouse_label", "TextLabel"), ("camera_label", "TextLabel"), ("sys_label", "TextLabel")] },
129                AudioCue          => $crate::components::AudioCue { gen, external, useful_blank, refs: [("clip", "AudioClip"), ("screen", "Screen")] },
130                Story             => $crate::components::Story { gen, external, id },
131                AppConfig         => $crate::components::AppConfig { manual, external, singleton, args: AppConfig },
132                AnimationGraph         => $crate::components::AnimationGraph { gen, external, id, consumed },
133                AnimationParams        => $crate::components::AnimationParams { runtime, build: anim_params },
134                CharacterRig      => $crate::components::CharacterRig { runtime, build: character_rig },
135                GroundProbes      => $crate::components::GroundProbes { runtime },
136                CameraProbe       => $crate::components::CameraProbe { runtime },
137                TextInput         => $crate::components::TextInput { gen, external, id, useful_blank, renders, live, refs: [("font", "Font"), ("screen", "Screen")] },
138                Behavior          => $crate::components::Behavior { gen, external, id, useful_blank, live },
139                Variables         => $crate::components::Variables { gen, external, singleton, live },
140                TriggerVolume     => $crate::components::TriggerVolume { gen, external, id, useful_blank },
141                Hidden            => $crate::components::Hidden { runtime },
142                LoadingOverlay    => $crate::components::LoadingOverlay { gen, external, singleton, renders, refs: [("screen", "Screen"), ("backdrop", "Sprite"), ("track", "Sprite"), ("fill", "Sprite"), ("label", "TextLabel")] },
143                AudioOcclusionProbe => $crate::components::AudioOcclusionProbe { runtime },
144                CharacterShape    => $crate::components::CharacterShape { gen, external, id, refs: [("target", "SkinnedMesh")] },
145                EngineDefaults    => $crate::components::EngineDefaults { gen, external, singleton, consumed },
146            },
147
148            // Resource: declared in a world and compiled into the blob's
149            // resource stream, addressed at runtime by a per-kind handle
150            // rather than stored in a column. Each entry names the dense
151            // handle space it is assigned into.
152            resource: {
153                AudioClip => $crate::components::AudioClip { resource: AudioClip, compiled },
154                Texture => $crate::components::Texture { resource: Texture, compiled },
155                CubemapTexture => $crate::components::CubemapTexture { resource: CubemapTexture, compiled },
156                EnvironmentMap => $crate::components::EnvironmentMap { resource: EnvironmentMap, compiled, renders },
157                ColorLut => $crate::components::ColorLut { resource: ColorLut, compiled },
158                Font => $crate::components::Font { resource: Font, compiled, useful_blank },
159                Material => $crate::components::Material { resource: Material, data, useful_blank, refs: [("albedo", "Texture"), ("normal_map", "Texture"), ("emissive_map", "Texture"), ("orm_map", "Texture"), ("albedo_secondary", "Texture"), ("normal_secondary", "Texture"), ("shader", "Shader")] },
160                Mesh => $crate::components::Mesh { resource: Mesh, compiled },
161                SkinnedMesh => $crate::components::SkinnedMesh { resource: SkinnedMesh, compiled, renders },
162            },
163        }
164    };
165}
166
167// The runtime half: the `ComponentTag` enum, the `ComponentAsset` value enum,
168// its blob loader, and the ECS storage. The authoring `RegisteredType` registry
169// is built from the same list in the build crate.
170crate::for_each_component!(define_components);
171
172// Generate the trivial `impl Component` blocks from the shared component list.
173//
174// The runtime trait is small: a NAME, a `from_baked` blob loader, and the
175// optional identity / payload injection hooks. Most components are pure data
176// whose impl is mechanical, generated here from each list entry's compact
177// `{ ... }` metadata block. Entries whose impl is bespoke mark themselves
178// `manual` and keep their impl; their trailing flags (origin, args type, refs)
179// are authoring metadata consumed only by the build-side registry in
180// concinnity-cook.
181//
182// Metadata grammar (inside the braces):
183//   manual, <flags...>          -- skip; the impl is hand-written elsewhere
184//   gen, <flags...>             -- generated impl:
185//     external | runtime        -- the authoring origin (world-side only)
186//     compiled                  -- an `inject_locator` that stores into
187//                                  `self.locator` (and marks the payload
188//                                  world-side)
189//     id                        -- an `inject_name` that stores into
190//                                  `self.asset_id`
191//     singleton                 -- at most one instance belongs to a world
192//                                  (world-side only)
193//     useful_blank              -- meaningful when declared with only default
194//                                  args, so authoring tools offer a plain add
195//                                  (world-side only)
196//     renders                   -- presence implies the world renders; drives
197//                                  the GraphicsConfig companion injection at
198//                                  build time (world-side only)
199//     live                      -- the running world re-reads this column
200//                                  every frame, so overwriting a component in
201//                                  place takes effect without reloading the
202//                                  world. Carries a second obligation: no
203//                                  build-time expansion may read the type's
204//                                  args, because an in-place write skips the
205//                                  expansion entirely (world-side only)
206//     consumed [: <Type>]       -- a load-time pass drains this column during
207//                                  `World::start`, so it holds nothing from
208//                                  the first tick; `: <Type>` names the
209//                                  runtime component that survives in its
210//                                  place (world-side only)
211//     validate: <fn>            -- the bake-time validator (world-side only)
212//     validate_for: <fn>        -- the bake-time validator for entries whose
213//                                  clamp depends on the shader platform the
214//                                  world is cooked for; it takes that platform
215//                                  alongside the value (world-side only)
216//     refs: [ ("field", "Type"), ... ] -- the reference fields (world-side only)
217//     args: <Asset>             -- names the asset whose authored schema
218//                                  differs from the component it bakes into;
219//                                  the schema is that asset's `cook` form
220//                                  (world-side only)
221//   runtime [, build: <fn>]     -- RuntimeOnly: never authored, never in a
222//                                  blob; the impl is NAME + the default
223//                                  (rejecting) `from_baked`.
224macro_rules! cn_impl_components {
225    // Entry point: one impl per stored entry. The resource group is skipped
226    // whole -- a resource is never loaded from a component record.
227    (
228        stored: { $( $variant:ident => $ty:path { $($meta:tt)* } ),+ $(,)? },
229        resource: { $( $rvariant:ident => $rty:path { $($rmeta:tt)* } ),+ $(,)? } $(,)?
230    ) => {
231        $( cn_impl_components!(@one $variant $ty { $($meta)* }); )+
232    };
233
234    // Bespoke impls opt out here; trailing flags are world-side metadata.
235    (@one $variant:ident $ty:path { manual $($rest:tt)* }) => {};
236
237    // Generated impls: seed an empty method accumulator, then consume the flag
238    // list one token at a time. Only `compiled` and `id` contribute runtime
239    // code; the authoring flags are consumed (and used) by the world registry.
240    (@one $variant:ident $ty:path { gen $($flags:tt)* }) => {
241        cn_impl_components!(@munch $variant $ty [] $($flags)*);
242    };
243
244    // RuntimeOnly components: never authored in a world and never stored in a
245    // blob, so the default (rejecting) `from_baked` is correct.
246    (@one $variant:ident $ty:path { runtime $($rest:tt)* }) => {
247        impl $crate::ecs::Component for $ty {
248            const NAME: &'static str = stringify!($variant);
249        }
250    };
251
252    (@munch $variant:ident $ty:path [$($body:tt)*] , compiled $($rest:tt)*) => {
253        cn_impl_components!(@munch $variant $ty
254            [$($body)*
255             fn inject_locator(&mut self, locator: $crate::ecs::PayloadLocator) {
256                 self.locator = Some(locator);
257             }]
258            $($rest)*);
259    };
260    (@munch $variant:ident $ty:path [$($body:tt)*] , id $($rest:tt)*) => {
261        cn_impl_components!(@munch $variant $ty
262            [$($body)* fn inject_name(&mut self, id: $crate::ecs::asset_id::AssetId) {
263                 self.asset_id = id;
264             }]
265            $($rest)*);
266    };
267    // Authoring-only flags: consumed here, used by the world registry.
268    (@munch $variant:ident $ty:path [$($body:tt)*] , validate: $f:ident $($rest:tt)*) => {
269        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
270    };
271    (@munch $variant:ident $ty:path [$($body:tt)*] , validate_for: $f:ident $($rest:tt)*) => {
272        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
273    };
274    (@munch $variant:ident $ty:path [$($body:tt)*] , refs: [ $( ($fld:literal, $tgt:literal) ),+ $(,)? ] $($rest:tt)*) => {
275        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
276    };
277    (@munch $variant:ident $ty:path [$($body:tt)*] , consumed: $surviving:ident $($rest:tt)*) => {
278        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
279    };
280    (@munch $variant:ident $ty:path [$($body:tt)*] , $flag:ident $($rest:tt)*) => {
281        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
282    };
283
284    // No flags left: emit the impl. The baked blob record carries the
285    // serialized component itself.
286    (@munch $variant:ident $ty:path [$($body:tt)*]) => {
287        impl $crate::ecs::Component for $ty {
288            const NAME: &'static str = stringify!($variant);
289            $($body)*
290            fn from_baked(bytes: &[u8]) -> Result<Self, $crate::result::CnResult> {
291                Ok($crate::blob::decode_exact(bytes)?)
292            }
293        }
294    };
295}
296
297// The generated trivial `impl Component` blocks: one per list entry marked
298// `gen`, expanded from its metadata. Entries marked `manual` keep the
299// hand-written impl in their own `components` module. Emitted here (rather than
300// in `components`) so the macro is in textual scope, alongside
301// `define_components`.
302crate::for_each_component!(cn_impl_components);
303
304#[cfg(test)]
305mod tests {
306    use crate::blob::BlobAssetDef;
307    use crate::components::{Prop, Transform};
308    use crate::ecs::asset_id::AssetId;
309    use crate::ecs::{
310        AssetKind, ComponentAsset, ComponentStorage, ComponentTag, PayloadLocator, ResourceKind,
311    };
312    use crate::result::CnResult;
313    use alloc::vec::Vec;
314
315    // Both halves of the shared list, so the tests below drive the generated
316    // per-entry arms over every entry rather than a hand-picked sample that
317    // goes stale as the list grows.
318    macro_rules! registry_names {
319        (
320            stored: { $( $variant:ident => $ty:path { $($meta:tt)* } ),+ $(,)? },
321            resource: { $( $rvariant:ident => $rty:path { $($rmeta:tt)* } ),+ $(,)? } $(,)?
322        ) => {
323            const STORED: &[(ComponentTag, &str)] =
324                &[$( (ComponentTag::$variant, stringify!($variant)) ),+];
325            const RESOURCES: &[&str] = &[$( stringify!($rvariant) ),+];
326        };
327    }
328    crate::for_each_component!(registry_names);
329
330    #[test]
331    fn an_undrained_component_survives_as_itself() {
332        assert_eq!(
333            ComponentTag::Transform.surviving_tag(),
334            Some(ComponentTag::Transform)
335        );
336        assert_eq!(
337            ComponentTag::Behavior.surviving_tag(),
338            Some(ComponentTag::Behavior)
339        );
340    }
341
342    #[test]
343    fn a_consumed_component_survives_as_nothing() {
344        assert_eq!(ComponentTag::Screen.surviving_tag(), None);
345        assert_eq!(ComponentTag::PropBody.surviving_tag(), None);
346    }
347
348    // The one entry whose `consumed` flag names a replacement: the flag is what
349    // keeps "Prop" a usable behavior scope after decomposition drains it.
350    #[test]
351    fn a_consumed_component_can_name_its_replacement() {
352        assert_eq!(
353            ComponentTag::Prop.surviving_tag(),
354            Some(ComponentTag::PropInstance)
355        );
356    }
357
358    // The tag, its authored name, and the enum discriminant are one fact in
359    // three forms: the name round-trips through `parse`, and the discriminant
360    // is the entry's position in the list.
361    #[test]
362    fn every_tag_names_itself_and_parses_back() {
363        for (i, (tag, name)) in STORED.iter().enumerate() {
364            assert_eq!(tag.as_str(), *name);
365            assert_eq!(ComponentTag::parse(name), Some(*tag));
366            assert_eq!(*tag as u8, i as u8, "{name} is not at its list position");
367        }
368    }
369
370    #[test]
371    fn a_name_no_component_carries_parses_to_nothing() {
372        assert_eq!(ComponentTag::parse("NotAComponent"), None);
373        assert_eq!(ComponentTag::parse(""), None);
374    }
375
376    // Every entry resolves, and the whole list stays inside the mask ceiling
377    // that makes a tag usable as a ComponentId.
378    #[test]
379    fn every_tag_resolves_a_surviving_tag_and_fits_the_mask() {
380        for (tag, name) in STORED {
381            let surviving = tag.surviving_tag();
382            assert!(
383                surviving.is_none_or(|s| STORED.iter().any(|(t, _)| *t == s)),
384                "{name} survives as a tag that is not in the list"
385            );
386        }
387        assert!(
388            STORED.len() < 128,
389            "the list outgrew the ComponentMask ceiling"
390        );
391    }
392
393    #[test]
394    fn every_resource_name_resolves_to_a_handle_space() {
395        for name in RESOURCES {
396            assert!(
397                ResourceKind::parse(name).is_some(),
398                "{name} is a resource entry with no handle space"
399            );
400        }
401        assert_eq!(ResourceKind::parse("Transform"), None);
402        assert_eq!(ResourceKind::parse("NotAResource"), None);
403    }
404
405    #[test]
406    fn a_loaded_component_reports_the_type_it_holds() {
407        let asset = ComponentAsset::from(Transform::default());
408        assert_eq!(asset.type_name(), "Transform");
409        assert_eq!(asset.tag(), ComponentTag::Transform);
410    }
411
412    // Injection is a no-op for a type that overrides neither hook, so it lands
413    // on the value without changing what the value holds.
414    #[test]
415    fn injection_dispatches_to_the_variant_it_holds() {
416        let mut asset = ComponentAsset::from(Transform::default());
417        asset.inject_name(AssetId(3));
418        asset.inject_locator(PayloadLocator {
419            blob_index: 0,
420            offset: 0,
421            len: 0,
422        });
423        assert_eq!(asset.tag(), ComponentTag::Transform);
424    }
425
426    // A type declaring no clamp comes back unchanged; one declaring a clamp is
427    // returned through it, and a `validate_for` entry reads the platform.
428    #[test]
429    fn validation_runs_only_where_an_entry_declares_a_clamp() {
430        use crate::components::SdfVolume;
431        use crate::platform::Platform;
432
433        let plain = ComponentAsset::from(Transform::default()).validated(Platform::Metal);
434        assert_eq!(plain.tag(), ComponentTag::Transform);
435
436        let clamped = ComponentAsset::from(Prop::default()).validated(Platform::Metal);
437        assert_eq!(clamped.tag(), ComponentTag::Prop);
438
439        let volume = SdfVolume {
440            fragment_shaders: Some(
441                [
442                    ("metal".into(), "blob.metal".into()),
443                    ("hlsl".into(), "blob.hlsl".into()),
444                ]
445                .into_iter()
446                .collect(),
447            ),
448            ..Default::default()
449        };
450        let ComponentAsset::SdfVolume(baked) =
451            ComponentAsset::from(volume).validated(Platform::Hlsl)
452        else {
453            panic!("the value keeps its variant through validation");
454        };
455        assert_eq!(baked.fragment_shader, "blob.hlsl");
456    }
457
458    fn baked(discriminant: u8, args_bytes: Vec<u8>, name: Option<AssetId>) -> BlobAssetDef {
459        BlobAssetDef {
460            name,
461            kind: AssetKind::Component,
462            discriminant,
463            args_bytes,
464            payload: None,
465        }
466    }
467
468    // The record's discriminant picks the type, the bytes rebuild the value,
469    // and a named record has its identity injected on the way out.
470    #[test]
471    fn a_baked_record_loads_as_the_component_its_discriminant_names() {
472        let prop = Prop {
473            position: [1.0, 2.0, 3.0],
474            ..Prop::default()
475        };
476        let bytes = postcard::to_allocvec(&prop).expect("a prop encodes");
477        let asset =
478            ComponentAsset::from_baked(&baked(ComponentTag::Prop as u8, bytes, Some(AssetId(7))))
479                .expect("the record loads");
480        let ComponentAsset::Prop(loaded) = asset else {
481            panic!("expected a prop");
482        };
483        assert_eq!(loaded.position, [1.0, 2.0, 3.0]);
484        assert_eq!(loaded.asset_id, AssetId(7));
485    }
486
487    #[test]
488    fn a_record_no_tag_claims_is_rejected() {
489        assert_eq!(
490            ComponentAsset::from_baked(&baked(u8::MAX, Vec::new(), None)).err(),
491            Some(CnResult::AssetInvalidType)
492        );
493    }
494
495    // Storage dispatch: pushing through the value enum lands in the column the
496    // variant names, replacing overwrites it in place, and an entity holding no
497    // component of that type reports so rather than gaining one.
498    #[test]
499    fn the_value_enum_pushes_replaces_and_counts_through_its_column() {
500        let mut storage = ComponentStorage::default();
501        let entity = storage.push(ComponentAsset::from(Transform::default()));
502        assert_eq!(
503            storage.entities_with_tag(ComponentTag::Transform as u8),
504            &[entity]
505        );
506        assert_eq!(
507            storage.component_census(),
508            alloc::vec![(ComponentTag::Transform as u8, 1)]
509        );
510
511        let moved = Transform {
512            position: [4.0, 5.0, 6.0],
513            ..Transform::default()
514        };
515        assert!(storage.replace(entity, ComponentAsset::from(moved)));
516        assert_eq!(
517            storage.get::<Transform>(entity).map(|t| t.position),
518            Some([4.0, 5.0, 6.0])
519        );
520
521        // The entity carries no Prop, so there is nothing of that type to
522        // overwrite.
523        assert!(!storage.replace(entity, ComponentAsset::from(Prop::default())));
524        assert!(storage.entities_with_tag(u8::MAX).is_empty());
525    }
526}