khora_data/scene/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//! Open component registration system for scene serialization.
16//!
17//! Components register their serialization/deserialization functions
18//! at link time via `inventory`. The Definition and Recipe strategies
19//! iterate these registrations to handle all component types.
20
21use crate::ecs::{ComponentProvenance, World};
22use khora_core::ecs::entity::EntityId;
23use std::any::TypeId;
24
25/// Registration entry for a serializable component type.
26///
27/// Each component that derives `Component` submits an entry via
28/// `inventory::submit!` (generated by the derive macro). The serialization
29/// strategies use these registrations to handle all component types automatically.
30///
31/// The two `*_recipe` functions are the bincode round-trip used by the scene
32/// file format ([`super::recipe_strategy`]). The two `*_json` functions are
33/// the parallel serde-JSON round-trip used by the editor inspector to
34/// display and (eventually) edit components without per-type code. Both
35/// pairs go through the same auto-generated `Serializable<Type>` mirror
36/// struct; the JSON path simply picks a different encoding.
37pub struct ComponentRegistration {
38 /// The `TypeId` of the component type.
39 pub type_id: TypeId,
40
41 /// A human-readable name for the component (e.g., "Camera", "Light").
42 pub type_name: &'static str,
43
44 /// Who writes this component — see [`ComponentProvenance`].
45 ///
46 /// Lets any consumer ask "is this the author's data, or the engine's?"
47 /// instead of keeping its own hand-maintained list of type names. The
48 /// editor's "Add Component" menu and `duplicate_entity` both read it, so
49 /// components declared outside this crate are handled correctly too.
50 pub provenance: ComponentProvenance,
51
52 /// Serializes the component from the world into a Recipe command's
53 /// component_data bytes. Returns `None` if the entity doesn't have
54 /// this component.
55 pub serialize_recipe: fn(&World, EntityId) -> Option<Vec<u8>>,
56
57 /// Deserializes a Recipe command's component_data bytes and adds
58 /// the component to the entity.
59 pub deserialize_recipe: fn(&mut World, EntityId, &[u8]) -> Result<(), String>,
60
61 /// Creates a default instance of this component and adds it to the entity.
62 /// Used by the editor's "Add Component" UI.
63 pub create_default: fn(&mut World, EntityId) -> Result<(), String>,
64
65 /// Returns a JSON view of the component on `entity`, or `None` if the
66 /// entity doesn't have it. The conversion goes through the generated
67 /// `Serializable<Type>` mirror, so any field tagged `#[component(skip)]`
68 /// is omitted (same as the scene-recipe path).
69 pub to_json: fn(&World, EntityId) -> Option<serde_json::Value>,
70
71 /// Applies a JSON value back to the component on `entity`. Used by the
72 /// editor inspector to commit field edits without per-type code.
73 /// Implementations should be tolerant of partial updates by deserialising
74 /// against the full mirror struct first.
75 pub from_json: fn(&mut World, EntityId, &serde_json::Value) -> Result<(), String>,
76
77 /// Removes the component (and any siblings in the same `SemanticDomain`)
78 /// from `entity`. Used by the editor inspector's per-card delete button
79 /// to drop a component by `type_name` lookup.
80 pub remove: fn(&mut World, EntityId) -> Result<(), String>,
81}
82
83inventory::collect!(ComponentRegistration);
84
85/// Serializes every component of `entity` that belongs to the author.
86///
87/// Skips [`ComponentProvenance::Derived`] and [`ComponentProvenance::Runtime`]
88/// components, because every caller — scene files, `.kprefab` extraction and
89/// entity duplication — wants the data a human or a tool put there, not what
90/// the engine computed from it.
91///
92/// Emitting them would be actively wrong, not merely wasteful: `Children`
93/// holds the *source* entity's `EntityId`s, so a copy would claim the
94/// original's children, and `GlobalTransform` would land stale until the next
95/// `transform_propagation` tick. Hierarchy is rebuilt from the recipe's
96/// `SetParent` commands instead, which remap ids properly.
97pub fn serialize_all_components(world: &World, entity: EntityId) -> Vec<(String, Vec<u8>)> {
98 let mut results = Vec::new();
99 for reg in inventory::iter::<ComponentRegistration> {
100 if !reg.provenance.is_copied_on_duplicate() {
101 continue;
102 }
103 if let Some(data) = (reg.serialize_recipe)(world, entity) {
104 results.push((reg.type_name.to_string(), data));
105 }
106 }
107 results
108}
109
110/// Links `child` under `parent`, maintaining **both** halves of the hierarchy
111/// edge — the `Parent` back-reference and the parent's `Children` list.
112///
113/// Every `SceneCommand::SetParent` handler goes through this. They used to add
114/// only `Parent` and rely on a serialized `Children` component to supply the
115/// forward list, which quietly loaded the *source* world's entity ids; now that
116/// `Children` is `Derived` and no longer persisted, the inverse index has to be
117/// rebuilt here instead. Mirrors the invariant `GameWorld::set_parent` enforces
118/// for live edits.
119pub fn link_parent_child(world: &mut World, child: EntityId, parent: EntityId) {
120 if let Some(existing) = world.get_mut::<crate::ecs::Parent>(child) {
121 *existing = crate::ecs::Parent(parent);
122 } else {
123 world.add_component(child, crate::ecs::Parent(parent)).ok();
124 }
125
126 if let Some(children) = world.get_mut::<crate::ecs::Children>(parent) {
127 if !children.0.contains(&child) {
128 children.0.push(child);
129 }
130 } else {
131 world
132 .add_component(parent, crate::ecs::Children(vec![child]))
133 .ok();
134 }
135}
136
137/// Looks up the registered provenance of a component by its `type_name`.
138///
139/// Returns `None` for a type that never registered — the generic
140/// `HandleComponent<T>` instantiations and anything tagged
141/// `#[component(no_serializable)]`, neither of which participates in
142/// serialization, duplication or the "Add Component" menu.
143pub fn provenance_of(type_name: &str) -> Option<ComponentProvenance> {
144 inventory::iter::<ComponentRegistration>
145 .into_iter()
146 .find(|reg| reg.type_name == type_name)
147 .map(|reg| reg.provenance)
148}
149
150#[cfg(test)]
151mod provenance_tests {
152 use super::*;
153
154 /// Locks the classification of the components whose provenance is not the
155 /// default. Each of these was previously encoded in a hand-maintained list
156 /// somewhere else in the workspace; if one silently reverts to `Authored`
157 /// it would reappear in "Add Component" and be copied on duplicate.
158 #[test]
159 fn engine_written_components_are_classified() {
160 // Recomputed by `transform_propagation` from Transform + Parent.
161 assert_eq!(
162 provenance_of("GlobalTransform"),
163 Some(ComponentProvenance::Derived)
164 );
165 // Inverse index of `Parent`, maintained by `GameWorld::set_parent`.
166 // Copying it would make a duplicate claim the original's children.
167 assert_eq!(
168 provenance_of("Children"),
169 Some(ComponentProvenance::Derived)
170 );
171 // Written by "instantiate prefab"; persists and must survive a
172 // duplicate, but adding an empty one by hand is meaningless.
173 assert_eq!(
174 provenance_of("Prefab"),
175 Some(ComponentProvenance::ToolAuthored)
176 );
177 // Debug output of the physics writeback.
178 assert_eq!(
179 provenance_of("PhysicsDebugData"),
180 Some(ComponentProvenance::Runtime)
181 );
182 }
183
184 /// The components a user actually authors keep the default, including the
185 /// two whose registration is hand-written rather than derive-generated.
186 #[test]
187 fn authored_components_keep_the_default() {
188 for name in [
189 "Transform",
190 "Camera",
191 "Light",
192 "Tag",
193 "MeshRef",
194 "MaterialRef",
195 ] {
196 assert_eq!(
197 provenance_of(name),
198 Some(ComponentProvenance::Authored),
199 "{name} should be author-written"
200 );
201 }
202 }
203
204 /// The two halves of the hierarchy edge are classified differently, and
205 /// the asymmetry is the point.
206 ///
207 /// `Parent` is written by the reparent action, so it persists and a
208 /// duplicate keeps it — but nobody adds one from a menu, hence
209 /// `ToolAuthored`. `Children` is merely the inverse index rebuilt from it,
210 /// so it is `Derived` and must never be copied verbatim.
211 #[test]
212 fn parent_and_children_are_classified_asymmetrically() {
213 let parent = provenance_of("Parent").expect("Parent is registered");
214 let children = provenance_of("Children").expect("Children is registered");
215
216 assert!(!parent.is_hand_authorable());
217 assert!(parent.is_copied_on_duplicate());
218
219 assert!(!children.is_hand_authorable());
220 assert!(!children.is_copied_on_duplicate());
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::ecs::component::Component;
228 use crate::ecs::{
229 AudioSource, Camera, Children, GlobalTransform, Light, Name, Parent, SemanticDomain, Tag,
230 Transform,
231 };
232 use khora_core::math::Vec3;
233
234 /// A test-only anchor component. A fresh round-trip target entity is
235 /// spawned carrying only this, so it exists in the world without already
236 /// owning any component the round-trip is about to add — every
237 /// `from_json` / `deserialize_recipe` exercises the real "add" path.
238 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
239 struct Anchor;
240 impl Component for Anchor {}
241
242 /// Spawns the round-trip target: a live entity carrying only [`Anchor`],
243 /// in a world where `Anchor` is registered (its own page domain) so adding
244 /// any real component triggers a genuine migration.
245 fn spawn_anchor_target(world: &mut World) -> EntityId {
246 world.register_component::<Anchor>(SemanticDomain::Ui);
247 world.spawn(Anchor)
248 }
249
250 /// Spawns one entity carrying a broad, representative instance of every
251 /// commonly-authored component, with non-default field values where the
252 /// type allows it. The returned entity is the JSON/recipe round-trip
253 /// fixture for [`json_roundtrip_covers_every_present_component`] and
254 /// [`recipe_roundtrip_covers_every_present_component`].
255 ///
256 /// Components reached here are exercised end-to-end: the entity *has* them,
257 /// so `to_json` / `serialize_recipe` return `Some` and the parallel
258 /// `from_json` / `deserialize_recipe` paths are driven on a fresh entity.
259 fn spawn_representative_entity(world: &mut World) -> EntityId {
260 // A second entity so `Parent`/`Children` carry real ids.
261 let other = world.spawn((Transform::identity(), GlobalTransform::identity()));
262
263 let mut tags = Tag::new();
264 tags.insert("enemy");
265 tags.insert("boss");
266
267 world.spawn((
268 Transform::from_translation(Vec3::new(1.5, -2.0, 3.25)),
269 GlobalTransform::at_position(Vec3::new(1.5, -2.0, 3.25)),
270 Name::new("Fixture"),
271 Camera::new_perspective(std::f32::consts::FRAC_PI_3, 1.5, 0.05, 500.0),
272 Light::point(),
273 AudioSource::default(),
274 tags,
275 Parent(other),
276 Children(vec![other]),
277 ))
278 }
279
280 /// The editor inspector round-trip: for every registered component the
281 /// fixture entity carries, `to_json` must yield a value, and feeding that
282 /// value to `from_json` on a fresh entity in a fresh world must re-create
283 /// the component with the same JSON shape. This one test catches macro
284 /// `to_json`/`from_json` generation bugs, registry wiring gaps, and serde
285 /// breakage across the whole component set in a single pass.
286 #[test]
287 fn json_roundtrip_covers_every_present_component() {
288 let mut src = World::new();
289 let entity = spawn_representative_entity(&mut src);
290
291 let mut covered = 0usize;
292 for reg in inventory::iter::<ComponentRegistration> {
293 let Some(json) = (reg.to_json)(&src, entity) else {
294 // The fixture entity doesn't carry this component — nothing to
295 // assert for it here.
296 continue;
297 };
298 covered += 1;
299
300 // Fresh world + fresh entity: `from_json` must reconstruct the
301 // component from the captured JSON alone.
302 let mut dst = World::new();
303 let target = spawn_anchor_target(&mut dst);
304 (reg.from_json)(&mut dst, target, &json).unwrap_or_else(|e| {
305 panic!("from_json failed for {}: {}", reg.type_name, e);
306 });
307
308 let restored = (reg.to_json)(&dst, target).unwrap_or_else(|| {
309 panic!(
310 "{} missing after from_json — component was not added",
311 reg.type_name
312 )
313 });
314 assert_eq!(
315 restored, json,
316 "{} JSON did not round-trip through from_json",
317 reg.type_name
318 );
319 }
320
321 // Sanity: the fixture must reach a meaningful slice of the registry,
322 // otherwise a silent registration regression could make this test
323 // pass vacuously.
324 assert!(
325 covered >= 6,
326 "expected the fixture to cover at least 6 components, got {covered}"
327 );
328 }
329
330 /// The scene-file round-trip: the bincode recipe path mirrors the JSON
331 /// path through the same `Serializable<Type>` mirror. For every component
332 /// the fixture carries, `serialize_recipe` must yield bytes and
333 /// `deserialize_recipe` must re-add the component on a fresh entity, with
334 /// the JSON view matching afterwards.
335 #[test]
336 fn recipe_roundtrip_covers_every_present_component() {
337 let mut src = World::new();
338 let entity = spawn_representative_entity(&mut src);
339
340 for reg in inventory::iter::<ComponentRegistration> {
341 let Some(bytes) = (reg.serialize_recipe)(&src, entity) else {
342 continue;
343 };
344 let before = (reg.to_json)(&src, entity);
345
346 let mut dst = World::new();
347 let target = spawn_anchor_target(&mut dst);
348 (reg.deserialize_recipe)(&mut dst, target, &bytes).unwrap_or_else(|e| {
349 panic!("deserialize_recipe failed for {}: {}", reg.type_name, e);
350 });
351
352 let after = (reg.to_json)(&dst, target);
353 assert!(
354 after.is_some(),
355 "{} missing after deserialize_recipe",
356 reg.type_name
357 );
358 assert_eq!(
359 after, before,
360 "{} did not round-trip through the recipe path",
361 reg.type_name
362 );
363 }
364 }
365}