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: 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//     refs: [ ("field", "Type"), ... ] -- the reference fields (world-side only)
213//     args: <Asset>             -- names the asset whose authored schema
214//                                  differs from the component it bakes into;
215//                                  the schema is that asset's `cook` form
216//                                  (world-side only)
217//   runtime [, build: <fn>]     -- RuntimeOnly: never authored, never in a
218//                                  blob; the impl is NAME + the default
219//                                  (rejecting) `from_baked`.
220macro_rules! cn_impl_components {
221    // Entry point: one impl per stored entry. The resource group is skipped
222    // whole -- a resource is never loaded from a component record.
223    (
224        stored: { $( $variant:ident => $ty:path { $($meta:tt)* } ),+ $(,)? },
225        resource: { $( $rvariant:ident => $rty:path { $($rmeta:tt)* } ),+ $(,)? } $(,)?
226    ) => {
227        $( cn_impl_components!(@one $variant $ty { $($meta)* }); )+
228    };
229
230    // Bespoke impls opt out here; trailing flags are world-side metadata.
231    (@one $variant:ident $ty:path { manual $($rest:tt)* }) => {};
232
233    // Generated impls: seed an empty method accumulator, then consume the flag
234    // list one token at a time. Only `compiled` and `id` contribute runtime
235    // code; the authoring flags are consumed (and used) by the world registry.
236    (@one $variant:ident $ty:path { gen $($flags:tt)* }) => {
237        cn_impl_components!(@munch $variant $ty [] $($flags)*);
238    };
239
240    // RuntimeOnly components: never authored in a world and never stored in a
241    // blob, so the default (rejecting) `from_baked` is correct.
242    (@one $variant:ident $ty:path { runtime $($rest:tt)* }) => {
243        impl $crate::ecs::Component for $ty {
244            const NAME: &'static str = stringify!($variant);
245        }
246    };
247
248    (@munch $variant:ident $ty:path [$($body:tt)*] , compiled $($rest:tt)*) => {
249        cn_impl_components!(@munch $variant $ty
250            [$($body)*
251             fn inject_locator(&mut self, locator: $crate::ecs::PayloadLocator) {
252                 self.locator = Some(locator);
253             }]
254            $($rest)*);
255    };
256    (@munch $variant:ident $ty:path [$($body:tt)*] , id $($rest:tt)*) => {
257        cn_impl_components!(@munch $variant $ty
258            [$($body)* fn inject_name(&mut self, id: $crate::ecs::asset_id::AssetId) {
259                 self.asset_id = id;
260             }]
261            $($rest)*);
262    };
263    // Authoring-only flags: consumed here, used by the world registry.
264    (@munch $variant:ident $ty:path [$($body:tt)*] , validate: $f:ident $($rest:tt)*) => {
265        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
266    };
267    (@munch $variant:ident $ty:path [$($body:tt)*] , refs: [ $( ($fld:literal, $tgt:literal) ),+ $(,)? ] $($rest:tt)*) => {
268        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
269    };
270    (@munch $variant:ident $ty:path [$($body:tt)*] , consumed: $surviving:ident $($rest:tt)*) => {
271        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
272    };
273    (@munch $variant:ident $ty:path [$($body:tt)*] , $flag:ident $($rest:tt)*) => {
274        cn_impl_components!(@munch $variant $ty [$($body)*] $($rest)*);
275    };
276
277    // No flags left: emit the impl. The baked blob record carries the
278    // serialized component itself.
279    (@munch $variant:ident $ty:path [$($body:tt)*]) => {
280        impl $crate::ecs::Component for $ty {
281            const NAME: &'static str = stringify!($variant);
282            $($body)*
283            fn from_baked(bytes: &[u8]) -> Result<Self, $crate::result::CnResult> {
284                Ok($crate::blob::decode_exact(bytes)?)
285            }
286        }
287    };
288}
289
290// The generated trivial `impl Component` blocks: one per list entry marked
291// `gen`, expanded from its metadata. Entries marked `manual` keep the
292// hand-written impl in their own `components` module. Emitted here (rather than
293// in `components`) so the macro is in textual scope, alongside
294// `define_components`.
295crate::for_each_component!(cn_impl_components);
296
297#[cfg(test)]
298mod tests {
299    use crate::blob::BlobAssetDef;
300    use crate::components::{Prop, Transform};
301    use crate::ecs::asset_id::AssetId;
302    use crate::ecs::{
303        AssetKind, ComponentAsset, ComponentStorage, ComponentTag, PayloadLocator, ResourceKind,
304    };
305    use crate::result::CnResult;
306    use alloc::vec::Vec;
307
308    // Both halves of the shared list, so the tests below drive the generated
309    // per-entry arms over every entry rather than a hand-picked sample that
310    // goes stale as the list grows.
311    macro_rules! registry_names {
312        (
313            stored: { $( $variant:ident => $ty:path { $($meta:tt)* } ),+ $(,)? },
314            resource: { $( $rvariant:ident => $rty:path { $($rmeta:tt)* } ),+ $(,)? } $(,)?
315        ) => {
316            const STORED: &[(ComponentTag, &str)] =
317                &[$( (ComponentTag::$variant, stringify!($variant)) ),+];
318            const RESOURCES: &[&str] = &[$( stringify!($rvariant) ),+];
319        };
320    }
321    crate::for_each_component!(registry_names);
322
323    #[test]
324    fn an_undrained_component_survives_as_itself() {
325        assert_eq!(
326            ComponentTag::Transform.surviving_tag(),
327            Some(ComponentTag::Transform)
328        );
329        assert_eq!(
330            ComponentTag::Behavior.surviving_tag(),
331            Some(ComponentTag::Behavior)
332        );
333    }
334
335    #[test]
336    fn a_consumed_component_survives_as_nothing() {
337        assert_eq!(ComponentTag::Screen.surviving_tag(), None);
338        assert_eq!(ComponentTag::PropBody.surviving_tag(), None);
339    }
340
341    // The one entry whose `consumed` flag names a replacement: the flag is what
342    // keeps "Prop" a usable behavior scope after decomposition drains it.
343    #[test]
344    fn a_consumed_component_can_name_its_replacement() {
345        assert_eq!(
346            ComponentTag::Prop.surviving_tag(),
347            Some(ComponentTag::PropInstance)
348        );
349    }
350
351    // The tag, its authored name, and the enum discriminant are one fact in
352    // three forms: the name round-trips through `parse`, and the discriminant
353    // is the entry's position in the list.
354    #[test]
355    fn every_tag_names_itself_and_parses_back() {
356        for (i, (tag, name)) in STORED.iter().enumerate() {
357            assert_eq!(tag.as_str(), *name);
358            assert_eq!(ComponentTag::parse(name), Some(*tag));
359            assert_eq!(*tag as u8, i as u8, "{name} is not at its list position");
360        }
361    }
362
363    #[test]
364    fn a_name_no_component_carries_parses_to_nothing() {
365        assert_eq!(ComponentTag::parse("NotAComponent"), None);
366        assert_eq!(ComponentTag::parse(""), None);
367    }
368
369    // Every entry resolves, and the whole list stays inside the mask ceiling
370    // that makes a tag usable as a ComponentId.
371    #[test]
372    fn every_tag_resolves_a_surviving_tag_and_fits_the_mask() {
373        for (tag, name) in STORED {
374            let surviving = tag.surviving_tag();
375            assert!(
376                surviving.is_none_or(|s| STORED.iter().any(|(t, _)| *t == s)),
377                "{name} survives as a tag that is not in the list"
378            );
379        }
380        assert!(
381            STORED.len() < 128,
382            "the list outgrew the ComponentMask ceiling"
383        );
384    }
385
386    #[test]
387    fn every_resource_name_resolves_to_a_handle_space() {
388        for name in RESOURCES {
389            assert!(
390                ResourceKind::parse(name).is_some(),
391                "{name} is a resource entry with no handle space"
392            );
393        }
394        assert_eq!(ResourceKind::parse("Transform"), None);
395        assert_eq!(ResourceKind::parse("NotAResource"), None);
396    }
397
398    #[test]
399    fn a_loaded_component_reports_the_type_it_holds() {
400        let asset = ComponentAsset::from(Transform::default());
401        assert_eq!(asset.type_name(), "Transform");
402        assert_eq!(asset.tag(), ComponentTag::Transform);
403    }
404
405    // Injection is a no-op for a type that overrides neither hook, so it lands
406    // on the value without changing what the value holds.
407    #[test]
408    fn injection_dispatches_to_the_variant_it_holds() {
409        let mut asset = ComponentAsset::from(Transform::default());
410        asset.inject_name(AssetId(3));
411        asset.inject_locator(PayloadLocator {
412            blob_index: 0,
413            offset: 0,
414            len: 0,
415        });
416        assert_eq!(asset.tag(), ComponentTag::Transform);
417    }
418
419    // A type declaring no clamp comes back unchanged; one declaring a clamp is
420    // returned through it.
421    #[test]
422    fn validation_runs_only_where_an_entry_declares_a_clamp() {
423        let plain = ComponentAsset::from(Transform::default()).validated();
424        assert_eq!(plain.tag(), ComponentTag::Transform);
425
426        let clamped = ComponentAsset::from(Prop::default()).validated();
427        assert_eq!(clamped.tag(), ComponentTag::Prop);
428    }
429
430    fn baked(discriminant: u8, args_bytes: Vec<u8>, name: Option<AssetId>) -> BlobAssetDef {
431        BlobAssetDef {
432            name,
433            kind: AssetKind::Component,
434            discriminant,
435            args_bytes,
436            payload: None,
437        }
438    }
439
440    // The record's discriminant picks the type, the bytes rebuild the value,
441    // and a named record has its identity injected on the way out.
442    #[test]
443    fn a_baked_record_loads_as_the_component_its_discriminant_names() {
444        let prop = Prop {
445            position: [1.0, 2.0, 3.0],
446            ..Prop::default()
447        };
448        let bytes = postcard::to_allocvec(&prop).expect("a prop encodes");
449        let asset =
450            ComponentAsset::from_baked(&baked(ComponentTag::Prop as u8, bytes, Some(AssetId(7))))
451                .expect("the record loads");
452        let ComponentAsset::Prop(loaded) = asset else {
453            panic!("expected a prop");
454        };
455        assert_eq!(loaded.position, [1.0, 2.0, 3.0]);
456        assert_eq!(loaded.asset_id, AssetId(7));
457    }
458
459    #[test]
460    fn a_record_no_tag_claims_is_rejected() {
461        assert_eq!(
462            ComponentAsset::from_baked(&baked(u8::MAX, Vec::new(), None)).err(),
463            Some(CnResult::AssetInvalidType)
464        );
465    }
466
467    // Storage dispatch: pushing through the value enum lands in the column the
468    // variant names, replacing overwrites it in place, and an entity holding no
469    // component of that type reports so rather than gaining one.
470    #[test]
471    fn the_value_enum_pushes_replaces_and_counts_through_its_column() {
472        let mut storage = ComponentStorage::default();
473        let entity = storage.push(ComponentAsset::from(Transform::default()));
474        assert_eq!(
475            storage.entities_with_tag(ComponentTag::Transform as u8),
476            &[entity]
477        );
478        assert_eq!(
479            storage.component_census(),
480            alloc::vec![(ComponentTag::Transform as u8, 1)]
481        );
482
483        let moved = Transform {
484            position: [4.0, 5.0, 6.0],
485            ..Transform::default()
486        };
487        assert!(storage.replace(entity, ComponentAsset::from(moved)));
488        assert_eq!(
489            storage.get::<Transform>(entity).map(|t| t.position),
490            Some([4.0, 5.0, 6.0])
491        );
492
493        // The entity carries no Prop, so there is nothing of that type to
494        // overwrite.
495        assert!(!storage.replace(entity, ComponentAsset::from(Prop::default())));
496        assert!(storage.entities_with_tag(u8::MAX).is_empty());
497    }
498}