khora_data/ecs/registry.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Defines the `ComponentRegistry` and `SemanticDomain` for the CRPECS.
16
17use bincode::{Decode, Encode};
18
19use crate::ecs::{AnyVec, Component};
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::{
22 any::{self, TypeId},
23 collections::HashMap,
24};
25
26/// Type alias for the row copy function pointer.
27type RowCopyFn = unsafe fn(&dyn AnyVec, usize, &mut dyn AnyVec);
28
29/// Defines the semantic domains a component can belong to.
30///
31/// This is used by the [`ComponentRegistry`] to map a component type to its
32/// corresponding `ComponentPage` group. This grouping is the core principle that
33/// allows the CRPECS to have fast, domain-specific queries.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Encode, Decode)]
35pub enum SemanticDomain {
36 /// For components related to position, physics, and the scene graph.
37 Spatial,
38 /// For components related to rendering, such as mesh and material handles.
39 Render,
40 /// For components related to audio, such as audio sources and listeners.
41 Audio,
42 /// For components related to physics simulation.
43 Physics,
44 /// For components driving the in-world UI subsystem (UiNode, UiText, etc).
45 Ui,
46}
47
48impl SemanticDomain {
49 /// Number of semantic domains — sizes fixed per-domain tables such as the
50 /// [`World`](crate::ecs::World)'s change epochs.
51 pub const COUNT: usize = 5;
52
53 /// Dense index of this domain in `0..COUNT`, used to address fixed
54 /// per-domain arrays without a `HashMap` lookup.
55 pub const fn index(self) -> usize {
56 match self {
57 SemanticDomain::Spatial => 0,
58 SemanticDomain::Render => 1,
59 SemanticDomain::Audio => 2,
60 SemanticDomain::Physics => 3,
61 SemanticDomain::Ui => 4,
62 }
63 }
64}
65
66/// Who is allowed to write a component — the axis orthogonal to
67/// [`SemanticDomain`].
68///
69/// `SemanticDomain` answers *which subsystem consumes this data* and drives
70/// change epochs, page grouping and `Flow` gating. It deliberately says nothing
71/// about **authorship**: `Transform` and `GlobalTransform` are both `Spatial`,
72/// yet one is written by a human and the other is recomputed every frame by
73/// `transform_propagation`. `Collider` and `PhysicsDebugData` are both
74/// `Physics`, yet one is an input and the other is debug output.
75///
76/// Without this axis the same intent gets re-encoded ad hoc at every site that
77/// needs it — which component to offer in "Add Component", which to copy when
78/// duplicating an entity, which to write to a scene file. Each such list is
79/// hand-maintained, so none of them covers components defined outside this
80/// crate.
81///
82/// The four variants encode two independent bits:
83///
84/// | variant | offered to the author | copied on duplicate |
85/// |---|---|---|
86/// | [`Authored`](Self::Authored) | yes | yes |
87/// | [`ToolAuthored`](Self::ToolAuthored) | no | yes |
88/// | [`Derived`](Self::Derived) | no | no |
89/// | [`Runtime`](Self::Runtime) | no | no |
90///
91/// Persistence stays a separate question, governed by
92/// `#[component(no_serializable)]` and `#[component(skip)]` — a `ToolAuthored`
93/// component such as `Prefab` must persist even though nobody adds it by hand.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
95pub enum ComponentProvenance {
96 /// Written by a human through the editor or by game code. Belongs in a
97 /// scene file, survives duplication, and is offered in "Add Component".
98 /// This is the default for any component that does not say otherwise.
99 #[default]
100 Authored,
101
102 /// Written by a tool action rather than by hand — `Prefab`, whose `source`
103 /// is set by "instantiate prefab". It persists and must survive
104 /// duplication, but adding an empty one by hand is meaningless, so it is
105 /// not offered in the menu.
106 ToolAuthored,
107
108 /// Recomputed by the engine from `Authored` state — `GlobalTransform` from
109 /// `Transform` + `Parent`, or the `HandleComponent<Gpu*>` projections from
110 /// their asset handles. A duplicate must **not** carry a copy: the engine
111 /// regenerates it, and a stale copy would be wrong until it did.
112 Derived,
113
114 /// Per-run transient state that no one authors and nothing recomputes from
115 /// authored data — `PhysicsDebugData`. Never offered, never copied.
116 Runtime,
117}
118
119impl ComponentProvenance {
120 /// Whether the editor should offer this component in "+ Add Component".
121 ///
122 /// Only [`Authored`](Self::Authored) qualifies: everything else is written
123 /// by the engine or by a tool action.
124 pub const fn is_hand_authorable(self) -> bool {
125 matches!(self, ComponentProvenance::Authored)
126 }
127
128 /// Whether duplicating an entity should copy this component verbatim.
129 ///
130 /// `Derived` and `Runtime` are excluded because the engine produces them:
131 /// copying would install a stale value that the next tick overwrites at
132 /// best, and that reads as corrupt state at worst.
133 pub const fn is_copied_on_duplicate(self) -> bool {
134 matches!(
135 self,
136 ComponentProvenance::Authored | ComponentProvenance::ToolAuthored
137 )
138 }
139}
140
141/// How a component column is physically laid out in memory.
142///
143/// **AGDF** (adaptive data *layout*) adapts this per component as Data
144/// self-maintenance, observed by the DCC. The default is plain `Soa`, so this
145/// descriptor is **inert** until an actual repack happens — adding it changes
146/// no behaviour.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum LayoutPolicy {
149 /// Structure-of-Arrays: one contiguous `Vec<T>` (today's only layout).
150 #[default]
151 Soa,
152 /// Array-of-Structures-of-Arrays: SIMD-friendly tiles of `lanes` elements.
153 AoSoA {
154 /// Tile width in elements (e.g. 8 for AVX2 `f32`).
155 lanes: u8,
156 },
157}
158
159/// Online access-pattern counters for one component type.
160///
161/// Updated **once per query** (off the per-element hot path), read by the DCC /
162/// telemetry to decide whether a memory-layout repack would pay off. Atomics so
163/// they can be recorded through `&self`. One small entry per component *type*
164/// (not per entity): the memory cost is constant, not proportional to the world.
165#[derive(Debug, Default)]
166pub struct AccessCounters {
167 /// Number of queries that touched this component.
168 pub query_count: AtomicU64,
169 /// Cumulative rows visited across those queries.
170 pub rows_scanned: AtomicU64,
171}
172
173/// Stores the set of type-erased functions for a registered component.
174#[derive(Debug)]
175struct ComponentVTable {
176 /// The semantic domain this component belongs to.
177 domain: SemanticDomain,
178 /// Current physical layout of this component's columns (default `Soa`).
179 layout: LayoutPolicy,
180 /// `size_of::<T>()` — recorded at registration (where `T` is known) so the
181 /// layout advisor can reason about component "fatness" from a `TypeId` alone.
182 size_bytes: usize,
183 /// Creates a new, empty `Box<dyn AnyVec>` for this component type.
184 create_column: fn() -> Box<dyn AnyVec>,
185 /// Copies a single element from a source column to a destination column.
186 copy_row: RowCopyFn,
187}
188
189/// A registry that maps component types to their semantic domains.
190///
191/// This is a critical internal part of the `World`. It provides a single source
192/// of truth for determining which semantic group a component's data belongs to,
193/// enabling the `World` to correctly store and retrieve component data from pages.
194#[derive(Debug, Default)]
195pub struct ComponentRegistry {
196 /// Maps a component's `TypeId` to its VTable of operations.
197 mapping: HashMap<TypeId, ComponentVTable>,
198 /// Per-component-type online access-pattern counters (layout adaptation).
199 access: HashMap<TypeId, AccessCounters>,
200}
201
202impl ComponentRegistry {
203 /// Registers a component type with its domain and lifecycle functions.
204 pub(crate) fn register<T: Component>(&mut self, domain: SemanticDomain) {
205 self.mapping.insert(
206 TypeId::of::<T>(),
207 ComponentVTable {
208 domain,
209 layout: LayoutPolicy::Soa,
210 size_bytes: std::mem::size_of::<T>(),
211 // Column creation, row-push, and cross-page row-copy all route
212 // through the `Component` trait hooks, which default to the AoS
213 // `Vec<T>` column and are overridden by field-SoA components.
214 create_column: T::make_column,
215 copy_row: T::copy_row_between,
216 },
217 );
218 // Ensure an access-counter slot exists for this component type.
219 self.access.entry(TypeId::of::<T>()).or_default();
220 }
221
222 /// Looks up the `SemanticDomain` for a given `TypeId`.
223 pub fn get_domain(&self, type_id: TypeId) -> Option<SemanticDomain> {
224 self.mapping.get(&type_id).map(|vtable| vtable.domain)
225 }
226
227 /// Returns the [`LayoutPolicy`] a component is currently stored with.
228 pub fn layout_of(&self, type_id: TypeId) -> Option<LayoutPolicy> {
229 self.mapping.get(&type_id).map(|v| v.layout)
230 }
231
232 /// Sets the intended [`LayoutPolicy`] for a component. The actual repack of
233 /// stored columns is performed by the layout-adaptation pass; this only
234 /// records the target.
235 pub fn set_layout(&mut self, type_id: TypeId, layout: LayoutPolicy) {
236 if let Some(v) = self.mapping.get_mut(&type_id) {
237 v.layout = layout;
238 }
239 }
240
241 /// Records one access: increments the query count and adds `rows` to the
242 /// scanned total for every component in `type_ids`. Lock-free, called once
243 /// per query — never per element.
244 pub fn record_access(&self, type_ids: &[TypeId], rows: u64) {
245 for tid in type_ids {
246 if let Some(c) = self.access.get(tid) {
247 c.query_count.fetch_add(1, Ordering::Relaxed);
248 c.rows_scanned.fetch_add(rows, Ordering::Relaxed);
249 }
250 }
251 }
252
253 /// Returns `(query_count, rows_scanned)` for a component, if registered.
254 pub fn access_stats(&self, type_id: TypeId) -> Option<(u64, u64)> {
255 self.access.get(&type_id).map(|c| {
256 (
257 c.query_count.load(Ordering::Relaxed),
258 c.rows_scanned.load(Ordering::Relaxed),
259 )
260 })
261 }
262
263 /// `size_of` for a registered component type, or `None` if unregistered.
264 pub fn size_of(&self, type_id: TypeId) -> Option<usize> {
265 self.mapping.get(&type_id).map(|v| v.size_bytes)
266 }
267
268 /// Snapshot of every registered component's `(type_id, size_bytes,
269 /// query_count, rows_scanned)` — the input the DCC's layout advisor reads
270 /// (read-only; observation tunnel). Allocates a small `Vec` (one entry per
271 /// component *type*), so it is cheap enough to sample off the hot path.
272 pub fn access_snapshot(&self) -> Vec<(TypeId, usize, u64, u64)> {
273 self.access
274 .iter()
275 .filter_map(|(tid, c)| {
276 self.mapping.get(tid).map(|v| {
277 (
278 *tid,
279 v.size_bytes,
280 c.query_count.load(Ordering::Relaxed),
281 c.rows_scanned.load(Ordering::Relaxed),
282 )
283 })
284 })
285 .collect()
286 }
287
288 /// (Internal) Gets the column constructor function for a given TypeId.
289 pub(crate) fn get_column_constructor(
290 &self,
291 type_id: &TypeId,
292 ) -> Option<fn() -> Box<dyn AnyVec>> {
293 self.mapping.get(type_id).map(|vtable| vtable.create_column)
294 }
295
296 /// (Internal) Gets the row copy function for a given TypeId.
297 pub(crate) fn get_row_copier(&self, type_id: &TypeId) -> Option<RowCopyFn> {
298 self.mapping.get(type_id).map(|vtable| vtable.copy_row)
299 }
300
301 /// (Internal) Creates empty columns for a given type signature.
302 pub(crate) fn create_columns_for_signature(
303 &self,
304 signature: &[TypeId],
305 ) -> HashMap<TypeId, Box<dyn AnyVec>> {
306 let mut columns = HashMap::new();
307 for type_id in signature {
308 let constructor = self.get_column_constructor(type_id).unwrap();
309 columns.insert(*type_id, constructor());
310 }
311 columns
312 }
313}
314
315/// Inventory entry submitted by `#[derive(Component)]` when the type carries a
316/// `#[component(domain = ...)]` attribute.
317///
318/// Each entry registers one concrete component type into a [`crate::ecs::World`]
319/// with its declared [`SemanticDomain`], so domain assignment is a **property of
320/// the type** (single source of truth) instead of a hand-maintained list in
321/// `World::new`. Collected via [`inventory`] and replayed by `World::new`.
322pub struct ComponentDomainRegistration {
323 /// Registers the component into `world` (calls `World::register_component`).
324 pub register: fn(&mut crate::ecs::World),
325}
326
327inventory::collect!(ComponentDomainRegistration);
328
329/// A registry that provides reflection data, like type names.
330#[derive(Debug, Default)]
331pub struct TypeRegistry {
332 /// Maps a component's `TypeId` to its string name.
333 id_to_name: HashMap<TypeId, String>,
334 /// Maps a component's string name to its `TypeId`.
335 name_to_id: HashMap<String, TypeId>,
336}
337
338impl TypeRegistry {
339 /// Registers a component type, storing its name and TypeId.
340 pub(crate) fn register<T: Component>(&mut self) {
341 let type_id = TypeId::of::<T>();
342 let type_name = any::type_name::<T>().to_string();
343 self.id_to_name.insert(type_id, type_name.clone());
344 self.name_to_id.insert(type_name, type_id);
345 }
346
347 /// Gets the string name for a given TypeId.
348 pub(crate) fn get_name_of(&self, type_id: &TypeId) -> Option<&str> {
349 self.id_to_name.get(type_id).map(|s| s.as_str())
350 }
351
352 /// Gets the TypeId for a given string name.
353 pub(crate) fn get_id_of(&self, type_name: &str) -> Option<TypeId> {
354 self.name_to_id.get(type_name).copied()
355 }
356}
357
358#[cfg(test)]
359mod provenance_tests {
360 use super::ComponentProvenance;
361 use super::ComponentProvenance::*;
362
363 /// Only author-written components reach the "Add Component" menu — the
364 /// whole point of the axis is that engine-written types opt out by
365 /// construction rather than via a hand-maintained denylist.
366 #[test]
367 fn only_authored_is_hand_authorable() {
368 assert!(Authored.is_hand_authorable());
369 assert!(!ToolAuthored.is_hand_authorable());
370 assert!(!Derived.is_hand_authorable());
371 assert!(!Runtime.is_hand_authorable());
372 }
373
374 /// Duplication copies what a human or a tool put there, and lets the
375 /// engine rebuild the rest. `ToolAuthored` is the variant that separates
376 /// the two bits: not offered in the menu, but still copied.
377 #[test]
378 fn engine_written_components_are_not_copied() {
379 assert!(Authored.is_copied_on_duplicate());
380 assert!(ToolAuthored.is_copied_on_duplicate());
381 assert!(!Derived.is_copied_on_duplicate());
382 assert!(!Runtime.is_copied_on_duplicate());
383 }
384
385 /// A component says nothing about provenance unless it opts out, so the
386 /// default has to be the author's data.
387 #[test]
388 fn default_is_authored() {
389 assert_eq!(ComponentProvenance::default(), Authored);
390 }
391}