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"), ("parent", "SkyRotation")], 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: 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                SkyRotation       => $crate::components::SkyRotation { gen, external, singleton, id, validate: sky_rotation },
147            },
148
149            // Resource: declared in a world and compiled into the blob's
150            // resource stream, addressed at runtime by a per-kind handle
151            // rather than stored in a column. Each entry names the dense
152            // handle space it is assigned into.
153            resource: {
154                AudioClip => $crate::components::AudioClip { resource: AudioClip, compiled },
155                Texture => $crate::components::Texture { resource: Texture, compiled },
156                CubemapTexture => $crate::components::CubemapTexture { resource: CubemapTexture, compiled },
157                EnvironmentMap => $crate::components::EnvironmentMap { resource: EnvironmentMap, compiled, renders },
158                ColorLut => $crate::components::ColorLut { resource: ColorLut, compiled },
159                Font => $crate::components::Font { resource: Font, compiled, useful_blank },
160                Material => $crate::components::Material { resource: Material, data, useful_blank, refs: [("albedo", "Texture"), ("normal_map", "Texture"), ("emissive_map", "Texture"), ("orm_map", "Texture"), ("shader", "Shader")] },
161                Mesh => $crate::components::Mesh { resource: Mesh, compiled },
162                SkinnedMesh => $crate::components::SkinnedMesh { resource: SkinnedMesh, compiled, renders },
163            },
164        }
165    };
166}
167
168// The runtime half: the `ComponentTag` enum, the `ComponentAsset` value enum,
169// its blob loader, and the ECS storage. The authoring `RegisteredType` registry
170// is built from the same list in the build crate.
171crate::for_each_component!(define_components);
172
173// Generate the trivial `impl Component` blocks from the shared component list.
174//
175// The runtime trait is small: a NAME, a `from_baked` blob loader, and the
176// optional identity / payload injection hooks. Most components are pure data
177// whose impl is mechanical, generated here from each list entry's compact
178// `{ ... }` metadata block. Entries whose impl is bespoke mark themselves
179// `manual` and keep their impl; their trailing flags (origin, args type, refs)
180// are authoring metadata consumed only by the build-side registry in
181// concinnity-cook.
182//
183// Metadata grammar (inside the braces):
184//   manual, <flags...>          -- skip; the impl is hand-written elsewhere
185//   gen, <flags...>             -- generated impl:
186//     external | runtime        -- the authoring origin (world-side only)
187//     compiled                  -- an `inject_locator` that stores into
188//                                  `self.locator` (and marks the payload
189//                                  world-side)
190//     id                        -- an `inject_name` that stores into
191//                                  `self.asset_id`
192//     singleton                 -- at most one instance belongs to a world
193//                                  (world-side only)
194//     useful_blank              -- meaningful when declared with only default
195//                                  args, so authoring tools offer a plain add
196//                                  (world-side only)
197//     renders                   -- presence implies the world renders; drives
198//                                  the GraphicsConfig companion injection at
199//                                  build time (world-side only)
200//     live                      -- the running world re-reads this column
201//                                  every frame, so overwriting a component in
202//                                  place takes effect without reloading the
203//                                  world. Carries a second obligation: no
204//                                  build-time expansion may read the type's
205//                                  args, because an in-place write skips the
206//                                  expansion entirely (world-side only)
207//     consumed [: <Type>]       -- a load-time pass drains this column during
208//                                  `World::start`, so it holds nothing from
209//                                  the first tick; `: <Type>` names the
210//                                  runtime component that survives in its
211//                                  place (world-side only)
212//     validate: <fn>            -- the bake-time validator (world-side only)
213//     refs: [ ("field", "Type"), ... ] -- the reference fields (world-side only)
214//     args: <Asset>             -- names the asset whose authored schema
215//                                  differs from the component it bakes into;
216//                                  the schema is that asset's `cook` form
217//                                  (world-side only)
218//   runtime [, build: <fn>]     -- RuntimeOnly: never authored, never in a
219//                                  blob; the impl is NAME + the default
220//                                  (rejecting) `from_baked`.
221macro_rules! cn_impl_components {
222    // Entry point: one impl per stored entry. The resource group is skipped
223    // whole -- a resource is never loaded from a component record.
224    (
225        stored: { $( $variant:ident => $ty:path { $($meta:tt)* } ),+ $(,)? },
226        resource: { $( $rvariant:ident => $rty:path { $($rmeta:tt)* } ),+ $(,)? } $(,)?
227    ) => {
228        $( cn_impl_components!(@one $variant $ty { $($meta)* }); )+
229    };
230
231    // Bespoke impls opt out here; trailing flags are world-side metadata.
232    (@one $variant:ident $ty:path { manual $($rest:tt)* }) => {};
233
234    // Generated impls: seed an empty method accumulator, then consume the flag
235    // list one token at a time. Only `compiled` and `id` contribute runtime
236    // code; the authoring flags are consumed (and used) by the world registry.
237    (@one $variant:ident $ty:path { gen $($flags:tt)* }) => {
238        cn_impl_components!(@munch $variant $ty [] $($flags)*);
239    };
240
241    // RuntimeOnly components: never authored in a world and never stored in a
242    // blob, so the default (rejecting) `from_baked` is correct.
243    (@one $variant:ident $ty:path { runtime $($rest:tt)* }) => {
244        impl $crate::ecs::Component for $ty {
245            const NAME: &'static str = stringify!($variant);
246        }
247    };
248
249    (@munch $variant:ident $ty:path [$($body:tt)*] , compiled $($rest:tt)*) => {
250        cn_impl_components!(@munch $variant $ty
251            [$($body)*
252             fn inject_locator(&mut self, locator: $crate::ecs::PayloadLocator) {
253                 self.locator = Some(locator);
254             }]
255            $($rest)*);
256    };
257    (@munch $variant:ident $ty:path [$($body:tt)*] , id $($rest:tt)*) => {
258        cn_impl_components!(@munch $variant $ty
259            [$($body)* fn inject_name(&mut self, id: $crate::ecs::asset_id::AssetId) {
260                 self.asset_id = id;
261             }]
262            $($rest)*);
263    };
264    // Authoring-only flags: consumed here, used by the world registry.
265    (@munch $variant:ident $ty:path [$($body:tt)*] , validate: $f:ident $($rest:tt)*) => {
266        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
267    };
268    (@munch $variant:ident $ty:path [$($body:tt)*] , refs: [ $( ($fld:literal, $tgt:literal) ),+ $(,)? ] $($rest:tt)*) => {
269        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
270    };
271    (@munch $variant:ident $ty:path [$($body:tt)*] , consumed: $surviving:ident $($rest:tt)*) => {
272        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
273    };
274    (@munch $variant:ident $ty:path [$($body:tt)*] , $flag:ident $($rest:tt)*) => {
275        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
276    };
277
278    // No flags left: emit the impl. The baked blob record carries the
279    // serialized component itself.
280    (@munch $variant:ident $ty:path [$($body:tt)*]) => {
281        impl $crate::ecs::Component for $ty {
282            const NAME: &'static str = stringify!($variant);
283            $($body)*
284            fn from_baked(bytes: &[u8]) -> Result<Self, $crate::result::CnResult> {
285                Ok($crate::blob::decode_exact(bytes)?)
286            }
287        }
288    };
289}
290
291// The generated trivial `impl Component` blocks: one per list entry marked
292// `gen`, expanded from its metadata. Entries marked `manual` keep the
293// hand-written impl in their own `components` module. Emitted here (rather than
294// in `components`) so the macro is in textual scope, alongside
295// `define_components`.
296crate::for_each_component!(cn_impl_components);
297
298#[cfg(test)]
299mod tests {
300    use crate::blob::BlobAssetDef;
301    use crate::components::{Prop, Transform};
302    use crate::ecs::asset_id::AssetId;
303    use crate::ecs::{
304        AssetKind, ComponentAsset, ComponentStorage, ComponentTag, PayloadLocator, ResourceKind,
305    };
306    use crate::result::CnResult;
307    use alloc::vec::Vec;
308
309    // Both halves of the shared list, so the tests below drive the generated
310    // per-entry arms over every entry rather than a hand-picked sample that
311    // goes stale as the list grows.
312    macro_rules! registry_names {
313        (
314            stored: { $( $variant:ident => $ty:path { $($meta:tt)* } ),+ $(,)? },
315            resource: { $( $rvariant:ident => $rty:path { $($rmeta:tt)* } ),+ $(,)? } $(,)?
316        ) => {
317            const STORED: &[(ComponentTag, &str)] =
318                &[$( (ComponentTag::$variant, stringify!($variant)) ),+];
319            const RESOURCES: &[&str] = &[$( stringify!($rvariant) ),+];
320        };
321    }
322    crate::for_each_component!(registry_names);
323
324    #[test]
325    fn an_undrained_component_survives_as_itself() {
326        assert_eq!(
327            ComponentTag::Transform.surviving_tag(),
328            Some(ComponentTag::Transform)
329        );
330        assert_eq!(
331            ComponentTag::Behavior.surviving_tag(),
332            Some(ComponentTag::Behavior)
333        );
334    }
335
336    #[test]
337    fn a_consumed_component_survives_as_nothing() {
338        assert_eq!(ComponentTag::Screen.surviving_tag(), None);
339        assert_eq!(ComponentTag::PropBody.surviving_tag(), None);
340    }
341
342    // The one entry whose `consumed` flag names a replacement: the flag is what
343    // keeps "Prop" a usable behavior scope after decomposition drains it.
344    #[test]
345    fn a_consumed_component_can_name_its_replacement() {
346        assert_eq!(
347            ComponentTag::Prop.surviving_tag(),
348            Some(ComponentTag::PropInstance)
349        );
350    }
351
352    // The tag, its authored name, and the enum discriminant are one fact in
353    // three forms: the name round-trips through `parse`, and the discriminant
354    // is the entry's position in the list.
355    #[test]
356    fn every_tag_names_itself_and_parses_back() {
357        for (i, (tag, name)) in STORED.iter().enumerate() {
358            assert_eq!(tag.as_str(), *name);
359            assert_eq!(ComponentTag::parse(name), Some(*tag));
360            assert_eq!(*tag as u8, i as u8, "{name} is not at its list position");
361        }
362    }
363
364    #[test]
365    fn a_name_no_component_carries_parses_to_nothing() {
366        assert_eq!(ComponentTag::parse("NotAComponent"), None);
367        assert_eq!(ComponentTag::parse(""), None);
368    }
369
370    // Every entry resolves, and the whole list stays inside the mask ceiling
371    // that makes a tag usable as a ComponentId.
372    #[test]
373    fn every_tag_resolves_a_surviving_tag_and_fits_the_mask() {
374        for (tag, name) in STORED {
375            let surviving = tag.surviving_tag();
376            assert!(
377                surviving.is_none_or(|s| STORED.iter().any(|(t, _)| *t == s)),
378                "{name} survives as a tag that is not in the list"
379            );
380        }
381        assert!(
382            STORED.len() < 128,
383            "the list outgrew the ComponentMask ceiling"
384        );
385    }
386
387    #[test]
388    fn every_resource_name_resolves_to_a_handle_space() {
389        for name in RESOURCES {
390            assert!(
391                ResourceKind::parse(name).is_some(),
392                "{name} is a resource entry with no handle space"
393            );
394        }
395        assert_eq!(ResourceKind::parse("Transform"), None);
396        assert_eq!(ResourceKind::parse("NotAResource"), None);
397    }
398
399    #[test]
400    fn a_loaded_component_reports_the_type_it_holds() {
401        let asset = ComponentAsset::from(Transform::default());
402        assert_eq!(asset.type_name(), "Transform");
403        assert_eq!(asset.tag(), ComponentTag::Transform);
404    }
405
406    // Injection is a no-op for a type that overrides neither hook, so it lands
407    // on the value without changing what the value holds.
408    #[test]
409    fn injection_dispatches_to_the_variant_it_holds() {
410        let mut asset = ComponentAsset::from(Transform::default());
411        asset.inject_name(AssetId(3));
412        asset.inject_locator(PayloadLocator {
413            blob_index: 0,
414            offset: 0,
415            len: 0,
416        });
417        assert_eq!(asset.tag(), ComponentTag::Transform);
418    }
419
420    // A type declaring no clamp comes back unchanged; one declaring a clamp is
421    // returned through it, keeping its variant either way.
422    #[test]
423    fn validation_runs_only_where_an_entry_declares_a_clamp() {
424        use crate::components::SdfVolume;
425
426        let plain = ComponentAsset::from(Transform::default()).validated();
427        assert_eq!(plain.tag(), ComponentTag::Transform);
428
429        let clamped = ComponentAsset::from(Prop::default()).validated();
430        assert_eq!(clamped.tag(), ComponentTag::Prop);
431
432        // A medium writes no depth, so validation forces its caster off; that
433        // clamp is what proves the entry's function ran.
434        let volume = SdfVolume {
435            volumetric: true,
436            cast_shadows: true,
437            max_steps: 9999,
438            ..Default::default()
439        };
440        let ComponentAsset::SdfVolume(baked) = ComponentAsset::from(volume).validated() else {
441            panic!("the value keeps its variant through validation");
442        };
443        assert!(!baked.cast_shadows);
444        assert_eq!(
445            baked.max_steps,
446            crate::components::sdf_volume::SDF_MAX_STEPS_CEILING
447        );
448    }
449
450    fn baked(discriminant: u8, args_bytes: Vec<u8>, name: Option<AssetId>) -> BlobAssetDef {
451        BlobAssetDef {
452            name,
453            kind: AssetKind::Component,
454            discriminant,
455            args_bytes,
456            payload: None,
457        }
458    }
459
460    // The record's discriminant picks the type, the bytes rebuild the value,
461    // and a named record has its identity injected on the way out.
462    #[test]
463    fn a_baked_record_loads_as_the_component_its_discriminant_names() {
464        let prop = Prop {
465            position: [1.0, 2.0, 3.0],
466            ..Prop::default()
467        };
468        let bytes = postcard::to_allocvec(&prop).expect("a prop encodes");
469        let asset =
470            ComponentAsset::from_baked(&baked(ComponentTag::Prop as u8, bytes, Some(AssetId(7))))
471                .expect("the record loads");
472        let ComponentAsset::Prop(loaded) = asset else {
473            panic!("expected a prop");
474        };
475        assert_eq!(loaded.position, [1.0, 2.0, 3.0]);
476        assert_eq!(loaded.asset_id, AssetId(7));
477    }
478
479    #[test]
480    fn a_record_no_tag_claims_is_rejected() {
481        assert_eq!(
482            ComponentAsset::from_baked(&baked(u8::MAX, Vec::new(), None)).err(),
483            Some(CnResult::AssetInvalidType)
484        );
485    }
486
487    // Storage dispatch: pushing through the value enum lands in the column the
488    // variant names, replacing overwrites it in place, and an entity holding no
489    // component of that type reports so rather than gaining one.
490    #[test]
491    fn the_value_enum_pushes_replaces_and_counts_through_its_column() {
492        let mut storage = ComponentStorage::default();
493        let entity = storage.push(ComponentAsset::from(Transform::default()));
494        assert_eq!(
495            storage.entities_with_tag(ComponentTag::Transform as u8),
496            &[entity]
497        );
498        assert_eq!(
499            storage.component_census(),
500            alloc::vec![(ComponentTag::Transform as u8, 1)]
501        );
502
503        let moved = Transform {
504            position: [4.0, 5.0, 6.0],
505            ..Transform::default()
506        };
507        assert!(storage.replace(entity, ComponentAsset::from(moved)));
508        assert_eq!(
509            storage.get::<Transform>(entity).map(|t| t.position),
510            Some([4.0, 5.0, 6.0])
511        );
512
513        // The entity carries no Prop, so there is nothing of that type to
514        // overwrite.
515        assert!(!storage.replace(entity, ComponentAsset::from(Prop::default())));
516        assert!(storage.entities_with_tag(u8::MAX).is_empty());
517    }
518}