diff --git a/CLAUDE.md b/CLAUDE.md index d9451114b4..d12627646f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ When you finish a task that touched code or assets: 3. **Cross-binding check** — the pre-commit hook does **not** catch this. If you added or changed an ECS component, you must update the remaining hand-maintained touch-points or scripting / scene saves / save-games / runtime copies will silently drop it (and the build will fail to link): - `Scene/Components.h` — declare the component struct itself. The `AllComponents` tuple is **no longer hand-edited**: OloHeaderTool now generates it from every `struct *Component` *definition* under `OloEngine/src` into `Scene/Generated/AllComponents.Generated.inl`, which `Components.h` `#include`s at the bottom — declare the struct and the next build registers it for scene copy / prefab / `HasComponent()` automatically. The exceptions are **runtime-only / hand-copied** components (entity-identity `IDComponent` / `TagComponent`, and per-tick derived `*StateComponent` / `UIResolvedRectComponent`): those must be excluded in **both** the generator's `kComponentsNotInTuple` set (`tools/OloHeaderTool/main.cpp`) **and** `ComponentTupleCoverageTest`'s `kNotInTuple` set — keep the two in sync. Still **guarded** by `OloEngine/tests/ComponentTupleCoverageTest.cpp` (both directions, now reading the generated file): it catches a stale generated tuple (rebuild `GenerateBindings`) and drift between the generator's recursive scan and the test's curated header-root list (a component in a header the test doesn't list surfaces in the inverse test → add the header to `ComponentHeaderRoots()`). - `Scene/Scene.cpp` — the `Scene::OnComponentAdded` / `OnComponentRemoved` **no-op specializations are no longer hand-edited** (issue #380, third slice): OloHeaderTool generates them from the same `struct *Component` scan into `Scene/Generated/OnComponent{Added,Removed}.Generated.inl`, which `Scene.cpp` `#include`s inside the `OLO_ON_COMPONENT_{ADDED,REMOVED}_NOOP` macros — so a new component that needs **no** init/teardown is registered automatically and you touch nothing here. You only edit this file when the component needs a **real body**: hand-write the `template<>` specialization (alongside the existing custom ones — `CameraComponent`, the physics group, audio/video, etc.) **and** add the component to the matching exclusion set in `tools/OloHeaderTool/main.cpp` (`kComponentsCustomOnAdd` and/or `kComponentsCustomOnRemove` — **two deliberately different sets**: e.g. `CameraComponent`/`CinematicComponent`/`LocalizedTextComponent` do real work on add but no-op on remove, while `Rigidbody2DComponent`/`SpringBoneComponent`/`NoiseAnimationComponent` are the reverse). Forgetting the exclusion-set edit ⇒ the generated no-op collides with your body (**duplicate-definition** compile error); forgetting the body while leaving it in the set ⇒ a **link error** (engine for add via `AddComponent`, OloEditor for remove via the remove-component button). Both are loud, not silent. The non-`struct *Component` `Skeleton` is the one type the scan can't see, so its no-ops stay hand-written. Guarded by `OloEngine/tests/ComponentHandlerCoverageTest.cpp` (verifies each generated list == every component minus its custom set; mirrors both `kComponentsCustomOn{Add,Remove}` sets — keep them in sync, same contract as `ComponentTupleCoverageTest`). - - `Scene/SceneSerializer.cpp` — `SerializeEntity` (writes) and `DeserializeEntityComponents` (reads). Validate every float with `std::isfinite`. **Trivial components are no longer hand-written** (issue #380, fourth slice): OloHeaderTool runs a full data-member scan (`CollectComponentFields` — *not* the `OLO_PROPERTY` scan, since the serializer persists every field, not just script-exposed ones) and, for every `struct *Component` whose **every** data member is a primitive / `glm::vec*` / `std::string`, generates a serialize block + a deserialize block into `Scene/Generated/Scene{Serialize,Deserialize}Components.Generated.inl`, which `SceneSerializer.cpp` `#include`s at the end of `SerializeEntity` / `DeserializeEntityComponents`. So a new **all-trivial** component round-trips through scene YAML with **zero** edits here. You only hand-write a block when the component has a **non-trivial field** (enum, `AssetHandle`, `Ref`, `std::vector`, nested struct, `glm::quat`/`mat`/`ivec`, …) — the parser classifies it non-trivial and skips it automatically — **or** when an all-trivial component needs custom deserialize logic the plain round-trip would drop (range clamps / `Sanitize*` / a runtime-only field the serializer omits / a non-`m_`-stripped key); in that case keep the hand-written block **and** add the component to `kComponentsCustomSerialize` (`tools/OloHeaderTool/main.cpp`) so the generator skips it. Forgetting that exclusion ⇒ the component is emitted **and** hand-written ⇒ a **double-emit** (duplicate YAML key on write, `AddComponent` twice on read) — caught loudly by `ComponentSerializerCoverageTest`'s `NoComponentIsBothHandWrittenAndGenerated` disjointness test, never silent. The same test's existence check now reads `SceneSerializer.cpp` **plus** the two generated `.inl` as one corpus, so a component handled by either path counts as plumbed-through. `kComponentsCustomSerialize` is a **fourth** distinct exclusion set (unrelated to the tuple / save-game / on-add / on-remove sets). The generated deserialize validates floats via `TryReadFiniteF32` (scalars) / the glm `Decode` helpers (vectors); a missing key keeps the constructor default. + - `Scene/SceneSerializer.cpp` — `SerializeEntity` (writes) and `DeserializeEntityComponents` (reads). Validate every float with `std::isfinite`. **Trivial components are no longer hand-written** (issue #380, fourth slice): OloHeaderTool runs a full data-member scan (`CollectComponentFields` — *not* the `OLO_PROPERTY` scan, since the serializer persists every field, not just script-exposed ones) and, for every `struct *Component` whose **every** data member is a primitive / `glm::vec*` / `std::string` / `AssetHandle` / `UUID`, generates a serialize block + a deserialize block into `Scene/Generated/Scene{Serialize,Deserialize}Components.Generated.inl`, which `SceneSerializer.cpp` `#include`s at the end of `SerializeEntity` / `DeserializeEntityComponents`. `AssetHandle` / `UUID` (a `u64` wrapper) joined the trivial set in **issue #451's first slice** — they round-trip as a `u64` (`static_cast` on write, `.as` + the implicit `UUID(u64)` ctor on read; matches every existing hand-written handle block). So a new **all-trivial** component round-trips through scene YAML with **zero** edits here. You only hand-write a block when the component has a **still-unhandled non-trivial field** (enum, `Ref`, `std::vector`, map, nested struct, `glm::quat`/`mat`/`ivec`, … — the remaining #451 follow-up slices) — the parser classifies it non-trivial and skips it automatically — **or** when an all-trivial component needs custom deserialize logic the plain round-trip would drop (range clamps / `Sanitize*` / a runtime-only field the serializer omits / a non-`m_`-stripped key); in that case keep the hand-written block **and** add the component to `kComponentsCustomSerialize` (`tools/OloHeaderTool/main.cpp`) so the generator skips it. Forgetting that exclusion ⇒ the component is emitted **and** hand-written ⇒ a **double-emit** (duplicate YAML key on write, `AddComponent` twice on read) — caught loudly by `ComponentSerializerCoverageTest`'s `NoComponentIsBothHandWrittenAndGenerated` disjointness test, never silent. **One blind spot the tests do *not* cover:** a component that is entity-identity or runtime-only and is persisted some *other* way than a sub-map (e.g. `IDComponent` — its `UUID` is the top-level `Entity: ` line, re-applied via `CreateEntityWithUUID`) is invisible to both the existence check (it's in the test's `kRuntimeOnly` set) and the disjointness check (it has no hand-written `out << YAML::Key` block). If a classifier-widening makes such a component all-trivial it gets auto-generated into a bogus sub-map + a double-`AddComponent` on load with **no** test failure — so it **must** be added to `kComponentsCustomSerialize` by hand (as `IDComponent` now is). When you widen the classifier, rebuild `GenerateBindings` and diff the generated `.inl` to catch every newly-flipped component. The same test's existence check now reads `SceneSerializer.cpp` **plus** the two generated `.inl` as one corpus, so a component handled by either path counts as plumbed-through. `kComponentsCustomSerialize` is a **fourth** distinct exclusion set (unrelated to the tuple / save-game / on-add / on-remove sets). The generated deserialize validates floats via `TryReadFiniteF32` (scalars) / the glm `Decode` helpers (vectors); a missing key keeps the constructor default. - **SaveGame — two edits in one file:** (a) `SaveGame/SaveGameComponentSerializer.h` — forward decl + `Serialize()` overload declaration; (b) `SaveGame/SaveGameComponentSerializer.cpp` — `Serialize()` overload definition + `RegisterAll()` registration. The `SAVE_COMPONENT(...)` capture list and `TRY_LOAD_COMPONENT(...)` restore list in `SaveGame/SaveGameSerializer.cpp` are **no longer hand-edited** (issue #380, second slice): OloHeaderTool generates them from the same `struct *Component` scan as the `AllComponents` tuple, minus a save-game-specific exclusion set, into `SaveGame/Generated/SaveGameComponent{Capture,Restore}.Generated.inl`, which `SaveGameSerializer.cpp` `#include`s — so giving a component a `Serialize()` overload makes the next build capture/restore it automatically. The exclusion set is `kComponentsNotInSaveGame` (`tools/OloHeaderTool/main.cpp`) and is **deliberately different** from `kComponentsNotInTuple`: save-games *keep* `IDComponent`/`TagComponent` but *drop* every component lacking a `Serialize()` overload (the four per-tick `*StateComponent` / `UIResolvedRectComponent`, **plus** `AudioSoundGraphComponent` / `LocalizedTextComponent`, which are in the scene-copy tuple but have no save serializer yet). A component with a serializer but wrongly listed in that set — or vice versa — is caught by `SaveGameComponentSerializerCoverageTest` (which now parses the generated `.inl` files; rebuild `GenerateBindings` after editing `RegisterAll`). A component that needs saving but lacks a `Serialize()` overload is **still** silently dropped through save-games while round-tripping through scene YAML — so the `Serialize()` overload + `RegisterAll()` registration remain the two edits you must not forget. - `Scripting/C#/Generated/` is auto-generated from `OLO_PROPERTY` annotations by OloHeaderTool (see below) — add the annotations on the component fields. Don't hand-edit the generated `.inl` / `.cs`. - `Scripting/Lua/LuaScriptGlue.cpp::RegisterAllTypes()` — Sol2 usertype registration. (`OloEngine-LuaScriptCore/` is the Mono-equivalent project target but the actual bindings live in `Scripting/Lua/`.) **Intentionally NOT completeness-guarded** — many components legitimately aren't Lua-exposed, so only per-component *functional* round-trips exist (`tests/Lua/LuaBindingTest.cpp`); a completeness test here would be noise. This is the one cross-binding touch-point with no completeness guard, by design. @@ -144,7 +144,7 @@ Cross-cutting patterns: - the ECS `AllComponents` type list (from every `struct *Component` *definition*, minus a small runtime-only exclusion set) → `OloEngine/src/OloEngine/Scene/Generated/AllComponents.Generated.inl`, `#include`d by `Scene/Components.h`. This collapses one of the formerly-hand-maintained ECS component touch-points into codegen (issue #380, first slice). - the SaveGame `SAVE_COMPONENT(...)` / `TRY_LOAD_COMPONENT(...)` enumeration lists (same `struct *Component` scan, minus a save-game-specific exclusion set `kComponentsNotInSaveGame` — components with no save serializer) → `OloEngine/src/OloEngine/SaveGame/Generated/SaveGameComponent{Capture,Restore}.Generated.inl`, `#include`d by `SaveGame/SaveGameSerializer.cpp`. This collapses the two most dangerous unguarded ECS touch-points — a component missing from either list was *silently* dropped from every save-game (issue #380, second slice). - the Scene `OnComponentAdded` / `OnComponentRemoved` no-op specialization lists (same `struct *Component` scan, minus two *distinct* custom-handler exclusion sets `kComponentsCustomOnAdd` / `kComponentsCustomOnRemove` — the components whose add/remove callback is hand-written because it does real init/teardown) → `OloEngine/src/OloEngine/Scene/Generated/OnComponent{Added,Removed}.Generated.inl` (same `Scene/Generated` dir as the tuple, no extra CLI arg), `#include`d by `Scene/Scene.cpp` inside the `OLO_ON_COMPONENT_{ADDED,REMOVED}_NOOP` macros. This collapses the `OnComponentAdded`/`OnComponentRemoved` touch-point (issue #380, third slice); the declaration-only primary templates mean a mis-excluded handler is a loud build error (duplicate-definition or unresolved-symbol), never silent. -- the Scene serializer per-component serialize/deserialize blocks → `OloEngine/src/OloEngine/Scene/Generated/Scene{Serialize,Deserialize}Components.Generated.inl` (same `Scene/Generated` dir, no extra CLI arg), `#include`d by `Scene/SceneSerializer.cpp`. Driven by a **separate full data-member scan** (`CollectComponentFields`), **not** the `OLO_PROPERTY` scan — the serializer persists *every* field, not just script-exposed ones (e.g. `DirectionalLightComponent` serializes 9 fields but annotates only 3). A component is emitted iff **every** data member is a primitive / `glm::vec*` / `std::string` (`SceneSerType`) **and** it is not in `kComponentsCustomSerialize` (the fourth, distinct exclusion set — trivial components kept hand-written for range-clamp/`Sanitize`/runtime-field/non-`m_` reasons). Anything with a non-trivial field is classified non-trivial and skipped automatically (no exclusion entry needed). This collapses the last big *unguarded* ECS touch-point (issue #380, fourth slice) — a forgotten field was **silent scene-data loss**, which the old name-only coverage test couldn't catch. Guarded by `ComponentSerializerCoverageTest`: existence (every component handled by `SceneSerializer.cpp` ∪ the generated `.inl`) **and** disjointness (`NoComponentIsBothHandWrittenAndGenerated` — no double-emit). +- the Scene serializer per-component serialize/deserialize blocks → `OloEngine/src/OloEngine/Scene/Generated/Scene{Serialize,Deserialize}Components.Generated.inl` (same `Scene/Generated` dir, no extra CLI arg), `#include`d by `Scene/SceneSerializer.cpp`. Driven by a **separate full data-member scan** (`CollectComponentFields`), **not** the `OLO_PROPERTY` scan — the serializer persists *every* field, not just script-exposed ones (e.g. `DirectionalLightComponent` serializes 9 fields but annotates only 3). A component is emitted iff **every** data member is a primitive / `glm::vec*` / `std::string` / `AssetHandle` / `UUID` (`SceneSerType`; the last two `u64`-wrapper types added by issue #451's first slice — `static_cast` on write, `.as` + implicit `UUID(u64)` on read) **and** it is not in `kComponentsCustomSerialize` (the fourth, distinct exclusion set — trivial components kept hand-written for range-clamp/`Sanitize`/runtime-field/non-`m_`/entity-identity reasons; e.g. `IDComponent` is excluded because the test suite can't catch its mis-generation — see the cross-binding section). Anything with a still-unhandled non-trivial field (enum, `Ref`, `std::vector`, nested struct, …) is classified non-trivial and skipped automatically (no exclusion entry needed). This collapses the last big *unguarded* ECS touch-point (issue #380, fourth slice) — a forgotten field was **silent scene-data loss**, which the old name-only coverage test couldn't catch. Guarded by `ComponentSerializerCoverageTest`: existence (every component handled by `SceneSerializer.cpp` ∪ the generated `.inl`) **and** disjointness (`NoComponentIsBothHandWrittenAndGenerated` — no double-emit). Wired as the `GenerateBindings` custom target; `OloEngine` and `OloEngine-ScriptCore` depend on it, so it runs automatically on configure/build. If you change an annotated property, add/rename a component, and the generated `.inl` / `.cs` look stale, build `GenerateBindings` directly (then re-stage the regenerated files — they're tracked, not git-ignored). **Gotcha for future codegen slices:** the coverage tests that guard these touch-points parse generated/source files as *text* (e.g. `ComponentTupleCoverageTest::CollectTupleMembers` reads the `AllComponents = ComponentGroup<…>` marker) — when you move a touch-point from a hand-written location into a generated file, repoint the test's parser at the generated path or it fails on an empty parse. diff --git a/OloEngine/src/OloEngine/Scene/Generated/SceneDeserializeComponents.Generated.inl b/OloEngine/src/OloEngine/Scene/Generated/SceneDeserializeComponents.Generated.inl index 85f4de94e8..fbaf55333e 100644 --- a/OloEngine/src/OloEngine/Scene/Generated/SceneDeserializeComponents.Generated.inl +++ b/OloEngine/src/OloEngine/Scene/Generated/SceneDeserializeComponents.Generated.inl @@ -174,3 +174,10 @@ if (auto node = entity["UIToggleComponent"]; node) comp.m_KnobColor = node["KnobColor"].as(comp.m_KnobColor); comp.m_Interactable = node["Interactable"].as(comp.m_Interactable); } + +if (auto node = entity["UIWorldAnchorComponent"]; node) +{ + auto& comp = deserializedEntity.AddComponent(); + comp.m_TargetEntity = node["TargetEntity"].as(static_cast(comp.m_TargetEntity)); + comp.m_WorldOffset = node["WorldOffset"].as(comp.m_WorldOffset); +} diff --git a/OloEngine/src/OloEngine/Scene/Generated/SceneSerializeComponents.Generated.inl b/OloEngine/src/OloEngine/Scene/Generated/SceneSerializeComponents.Generated.inl index 5293329626..eb6408f0a5 100644 --- a/OloEngine/src/OloEngine/Scene/Generated/SceneSerializeComponents.Generated.inl +++ b/OloEngine/src/OloEngine/Scene/Generated/SceneSerializeComponents.Generated.inl @@ -188,3 +188,13 @@ if (entity.HasComponent()) out << YAML::Key << "Interactable" << YAML::Value << comp.m_Interactable; out << YAML::EndMap; // UIToggleComponent } + +if (entity.HasComponent()) +{ + out << YAML::Key << "UIWorldAnchorComponent"; + out << YAML::BeginMap; // UIWorldAnchorComponent + auto const& comp = entity.GetComponent(); + out << YAML::Key << "TargetEntity" << YAML::Value << static_cast(comp.m_TargetEntity); + out << YAML::Key << "WorldOffset" << YAML::Value << comp.m_WorldOffset; + out << YAML::EndMap; // UIWorldAnchorComponent +} diff --git a/OloEngine/src/OloEngine/Scene/SceneSerializer.cpp b/OloEngine/src/OloEngine/Scene/SceneSerializer.cpp index 4999ec9b6a..3655d3bc07 100644 --- a/OloEngine/src/OloEngine/Scene/SceneSerializer.cpp +++ b/OloEngine/src/OloEngine/Scene/SceneSerializer.cpp @@ -2761,12 +2761,9 @@ namespace OloEngine TrySet(progress.m_FillColor, uiProgressBarComponent["FillColor"]); } - if (auto uiWorldAnchorComponent = entity["UIWorldAnchorComponent"]; uiWorldAnchorComponent) - { - auto& anchor = deserializedEntity.AddComponent(); - TrySet(anchor.m_TargetEntity, uiWorldAnchorComponent["TargetEntity"]); - TrySet(anchor.m_WorldOffset, uiWorldAnchorComponent["WorldOffset"]); - } + // UIWorldAnchorComponent — auto-generated (all-trivial: a UUID entity + // reference + a glm::vec3 offset). Its serialize/deserialize blocks live in + // Scene{Serialize,Deserialize}Components.Generated.inl (issue #451). if (auto uiInputFieldComponent = entity["UIInputFieldComponent"]; uiInputFieldComponent) { @@ -4725,17 +4722,8 @@ namespace OloEngine out << YAML::EndMap; // UIProgressBarComponent } - if (entity.HasComponent()) - { - out << YAML::Key << "UIWorldAnchorComponent"; - out << YAML::BeginMap; // UIWorldAnchorComponent - - auto const& anchor = entity.GetComponent(); - out << YAML::Key << "TargetEntity" << YAML::Value << anchor.m_TargetEntity; - out << YAML::Key << "WorldOffset" << YAML::Value << anchor.m_WorldOffset; - - out << YAML::EndMap; // UIWorldAnchorComponent - } + // UIWorldAnchorComponent — auto-generated (see the matching note in + // DeserializeEntityComponents); block lives in the generated .inl (issue #451). if (entity.HasComponent()) { diff --git a/OloEngine/tests/ComponentRoundTripTest.cpp b/OloEngine/tests/ComponentRoundTripTest.cpp index c4035bd8a6..35998e90b7 100644 --- a/OloEngine/tests/ComponentRoundTripTest.cpp +++ b/OloEngine/tests/ComponentRoundTripTest.cpp @@ -1648,7 +1648,12 @@ namespace OloEngine::Tests } // ------------------------------------------------------------------------- - // UIWorldAnchorComponent — UUID target + world-offset. + // UIWorldAnchorComponent — UUID target + world-offset. As of issue #451 this + // component is fully AUTO-GENERATED by OloHeaderTool (its only non-primitive + // field is the UUID m_TargetEntity, now a SceneSerType), so this round-trip is + // the end-to-end guard that the generated AssetHandle/UUID serialize/deserialize + // blocks (static_cast on write, .as + implicit UUID(u64) on read) + // preserve a non-trivial handle value — not just the previous hand-written path. // ------------------------------------------------------------------------- TEST(ComponentRoundTrip, UIWorldAnchorComponentSurvivesYAMLRoundTrip) { diff --git a/tools/OloHeaderTool/main.cpp b/tools/OloHeaderTool/main.cpp index 9bc0c3c9c7..02ec01ebd3 100644 --- a/tools/OloHeaderTool/main.cpp +++ b/tools/OloHeaderTool/main.cpp @@ -71,6 +71,12 @@ enum class PropType Int, UInt, U64, + AssetHandle, // AssetHandle / UUID — a u64 wrapper. Scene-serializer-only: + // round-trips as a u64 (static_cast on write, .as on + // read) but is emitted distinctly from a plain u64 so the + // serializer codegen knows to bridge the UUID<->u64 conversion. + // The scripting path never produces this (CppTypeToPropType maps + // AssetHandle -> U64), so only SceneSerType emits it. Vec2, Vec3, Vec4, @@ -765,19 +771,22 @@ static const std::set kComponentsCustomOnRemove = { }; // Components that ARE all-trivial-fields (every data member is a primitive / -// glm::vec* / std::string — see SceneSerType) yet are deliberately kept -// HAND-WRITTEN in SceneSerializer.cpp rather than auto-generated. The scene -// serialize/deserialize codegen (issue #380, this slice) emits a block for every -// all-trivial component EXCEPT these; anything with a non-trivial field (enum, -// AssetHandle, Ref, std::vector, nested struct, glm::quat/mat/ivec, …) is -// classified non-trivial by the parser and skipped automatically without needing -// an entry here. +// glm::vec* / std::string / AssetHandle / UUID — see SceneSerType) yet are +// deliberately kept HAND-WRITTEN in SceneSerializer.cpp rather than auto-generated. +// The scene serialize/deserialize codegen (issue #380; AssetHandle/UUID added by +// the #451 first slice) emits a block for every all-trivial component EXCEPT these; +// anything with a still-unhandled non-trivial field (enum, Ref, std::vector, +// nested struct, glm::quat/mat/ivec, …) is classified non-trivial by the parser and +// skipped automatically without needing an entry here. // // Each entry is a trivial component whose hand-written block does something the // plain round-trip generator must NOT silently drop: // * BuoyancyComponent / SphereAreaLightComponent / SpringBoneComponent / -// NoiseAnimationComponent — deserialize clamps / Sanitize()s float ranges; -// auto-generating would relax those guards. +// NoiseAnimationComponent / DialogueComponent / PerceptionComponent / +// IKTargetComponent — deserialize clamps / Sanitize()s float (or u32 chain- +// length / vector) ranges; auto-generating would relax those guards. +// (PerceptionComponent also intentionally does NOT restore its runtime-derived +// fields — HasVisibleTarget / VisibleTarget / LastKnownPosition / … — on load.) // * SnowDeformerComponent — serialize/deserialize delegate to a hand-written // helper (Serialize/DeserializeSnowDeformerComponent), not a flat field list. // * ScriptComponent — serializes the C# ScriptField map owned by ScriptEngine, @@ -785,6 +794,14 @@ static const std::set kComponentsCustomOnRemove = { // * VehicleComponent — has a runtime-only RuntimeVehicleToken field the // hand-written serializer deliberately omits (auto-gen would persist it). // * TagComponent — entity identity, hand-copied; not a normal sub-map. +// * IDComponent — entity identity: its UUID is serialized once as the top-level +// `Entity: ` line and re-applied via CreateEntityWithUUID on load, never +// as a component sub-map. Now that UUID is a SceneSerType the parser sees it as +// all-trivial, so it MUST be excluded here — auto-generating a block would emit +// a bogus IDComponent sub-map AND call AddComponent on an entity +// that already has one (an EnTT double-add). The coverage test's kRuntimeOnly +// set hides it from the existence check but does NOT guard this, so the +// exclusion is the only safety net. // * PhaseComponent — animation phase runtime state (kRuntimeOnly in the // coverage test); its persistence is intentionally hand-managed. // * UIResolvedRectComponent — per-tick layout-resolved rect, never serialized. @@ -795,7 +812,11 @@ static const std::set kComponentsCustomOnRemove = { // loud test failure, never a silent double-emit / drop. static const std::set kComponentsCustomSerialize = { "BuoyancyComponent", + "DialogueComponent", + "IDComponent", + "IKTargetComponent", "NoiseAnimationComponent", + "PerceptionComponent", "PhaseComponent", "ScriptComponent", "SnowDeformerComponent", @@ -900,12 +921,14 @@ struct ComponentSerInfo }; // The serialize-codegen-trivial field types. A component is auto-serializable iff -// EVERY data member maps to one of these. Anything else — enum, AssetHandle, UUID, +// EVERY data member maps to one of these. AssetHandle / UUID (a u64 wrapper with an +// implicit operator u64() / implicit ctor(u64)) round-trips as a u64 — issue #451's +// first slice brought it in scope, since "missing AssetHandle block ⇒ silent +// scene-data loss" was the single most pervasive instance of the footgun (materials, +// meshes, colliders, dialogue, streaming, …). Anything still unhandled — enum, // Ref, std::vector, nested struct, glm::quat/mat/ivec, raw pointer, u8/u16, … — // returns Unknown, marking the component non-trivial so it stays hand-written in -// SceneSerializer.cpp (handled by a future #380 slice). DELIBERATELY narrower than -// the scripting CppTypeToPropType (which maps AssetHandle→U64): asset-handle / -// Ref / nested serialization is the epic's hard 40%, explicitly out of this slice. +// SceneSerializer.cpp (handled by a future #451 slice). static PropType SceneSerType(const std::string& t) { if (t == "f32" || t == "float") @@ -918,6 +941,8 @@ static PropType SceneSerType(const std::string& t) return PropType::UInt; if (t == "u64") return PropType::U64; + if (t == "AssetHandle" || t == "UUID") + return PropType::AssetHandle; if (t == "glm::vec2") return PropType::Vec2; if (t == "glm::vec3") @@ -1448,7 +1473,15 @@ static void EmitSceneSerializeBlocks(std::ostream& out, const std::map();\n"; for (auto const& f : info.fields) - out << " out << YAML::Key << \"" << f.key << "\" << YAML::Value << comp." << f.member << ";\n"; + { + // AssetHandle / UUID has an implicit operator u64() but YAML::Emitter has + // no UUID overload, so emit the explicit u64 cast (matches every existing + // hand-written AssetHandle block, e.g. SoundConfigHandle / ColliderAsset). + if (f.type == PropType::AssetHandle) + out << " out << YAML::Key << \"" << f.key << "\" << YAML::Value << static_cast(comp." << f.member << ");\n"; + else + out << " out << YAML::Key << \"" << f.key << "\" << YAML::Value << comp." << f.member << ";\n"; + } out << " out << YAML::EndMap; // " << name << "\n"; out << "}\n\n"; } @@ -1498,6 +1531,12 @@ static void EmitSceneDeserializeBlocks(std::ostream& out, const std::map(" << lhs << ");\n"; break; + case PropType::AssetHandle: + // Read as u64, bridge back through the implicit UUID(u64) ctor; a + // missing key keeps the constructor default (cast it to u64 for the + // yaml-cpp fallback overload). Matches the hand-written blocks. + out << " " << lhs << " = " << key << ".as(static_cast(" << lhs << "));\n"; + break; case PropType::String: out << " " << lhs << " = " << key << ".as(" << lhs << ");\n"; break;