Skip to main content

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}