diff --git a/Game/Entity/Entity.cpp b/Game/Entity/Entity.cpp index ab65f7f..c9a87a6 100644 --- a/Game/Entity/Entity.cpp +++ b/Game/Entity/Entity.cpp @@ -15,9 +15,36 @@ void serialize(Archive& ar, NonNullPtr entity) } DEFINE_CLASS_VERSIONED(Entity, 1, nullptr) - internal void serialize(Archive& ar, uint16 /*version*/, Entity& entity) { SERIALIZE(ar, id, entity.ID); SERIALIZE(ar, position, entity.position); } + +const Class* resolve_entity_class(uint8 kind, uint32 crc) +{ + Assert(kind <= ENTITY(Count)); + + NonNullPtr class_ptr = kEntity_type_class_ptr[kind]; + Assert(class_ptr->CRC == crc); + + return class_ptr.Get(); +} + +const Class* find_class_by_name(String name) +{ + Assert(IsValid(name)); + + const Class* result = nullptr; + const uint32 name_crc = crc32(name.Str, name.Size); + for (uint8 kind = 0; kind < ENTITY(Count); ++kind) + { + const Class* class_ptr = resolve_entity_class(kind, name_crc); + if (class_ptr != nullptr) + { + result = class_ptr; + break; + } + } + return result; +} diff --git a/Game/Entity/Entity.h b/Game/Entity/Entity.h index fbc81e4..ab1326b 100644 --- a/Game/Entity/Entity.h +++ b/Game/Entity/Entity.h @@ -10,10 +10,11 @@ struct Entity final { DECLARE_CLASS() - EntityID ID = 0; - Class* derived_kind = nullptr; - DerivedType derived = nullptr; - Vector4 position = {}; + EntityID ID = 0; + const Class* derived_kind = nullptr; + DerivedType derived = nullptr; + Vector4 position = {}; + bool is_dirty = false; }; // Can reinterpret cast to this to have the offset of Base and Kind for any entity @@ -64,3 +65,7 @@ template } void serialize(Archive& ar, NonNullPtr entity); + +[[nodiscard]] const Class* find_class_by_name(String name); + +[[nodiscard]] const Class* resolve_entity_class(uint8 kind, uint32 crc); diff --git a/Game/Entity/EntityManager.cpp b/Game/Entity/EntityManager.cpp index 5047427..92e4314 100644 --- a/Game/Entity/EntityManager.cpp +++ b/Game/Entity/EntityManager.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -67,7 +68,7 @@ EntityTemplate* RegisterEntity(EntityManager& manager, Entity* base, DerivedType return ptr; } -Entity* allocate_entity(EntityManager& manager, NonNullPtr derived_type_class) +Entity* allocate_entity(EntityManager& manager, NonNullPtr derived_type_class) { Assert(derived_type_class->kind < ENTITY(Count)); Assert(derived_type_class->size_of >= sizeof(EntityTemplate)); @@ -76,6 +77,7 @@ Entity* allocate_entity(EntityManager& manager, NonNullPtr derived_type_c Entity base_template = {}; base_template.derived_kind = derived_type_class; + base_template.is_dirty = true; manager.Entities.PushBack(base_template); Entity* base_ptr = manager.Entities.Back(); @@ -118,3 +120,24 @@ void UpdateEntityManager(EntityManager& manager) } } } + +Entity* deserialize_entity(Archive& ar, EntityManager& manager) +{ + Assert(ar.loading); + + String class_name; + SERIALIZE(ar, Class, class_name); + + NonNullPtr class_ptr = find_class_by_name(class_name); + + NonNullPtr entity_base = allocate_entity(manager, class_ptr); + + serialize(ar, entity_base); + + if (entity_base->ID >= EntityManager::ID) + { + EntityManager::ID = entity_base->ID + 1; + } + + entity_base->is_dirty = false; +} diff --git a/Game/Entity/EntityManager.h b/Game/Entity/EntityManager.h index e1439a9..11a8368 100644 --- a/Game/Entity/EntityManager.h +++ b/Game/Entity/EntityManager.h @@ -29,5 +29,6 @@ void InitEntityManager(NonNullPtr world); void ShutdownEntityManager(); EntityManager& GetEntityManager(); EntityTemplate* RegisterEntity(EntityManager& manager, Entity* base, DerivedType entity); -[[nodiscard]] Entity* allocate_entity(EntityManager& manager, NonNullPtr derived_type_class); +[[nodiscard]] Entity* allocate_entity(EntityManager& manager, NonNullPtr derived_type_class); void UpdateEntityManager(EntityManager& manager); +[[nodisacrd]] Entity* deserialize_entity(Archive& archive, EntityManager& manager); diff --git a/Game/Plans/02_Entity_Allocation_And_Lifecycle.md b/Game/Plans/02_Entity_Allocation_And_Lifecycle.md index d844b3e..780eb6c 100644 --- a/Game/Plans/02_Entity_Allocation_And_Lifecycle.md +++ b/Game/Plans/02_Entity_Allocation_And_Lifecycle.md @@ -374,28 +374,52 @@ Under the new pipeline: 5. `serialize(ar, NonNullPtr(base_ptr))` is called. Base (`Entity::kind`) and derived (`base_ptr->derived_kind`) fields stream **directly into their permanent memory arenas** without temporary staging buffers or stack copies. ### 4.3 Runtime Class Resolution -To ensure fast and safe type lookup during file deserialization: +To ensure fast and safe type lookup during file deserialization, Juliet provides two tiers of type resolution: + +1. **Low-Level Indexed Validation (`ResolveEntityClass`)**: An $O(1)$ array lookup into `kEntity_type_class_ptr[kind]` that validates the class pointer and verifies schema compatibility against `classPtr->CRC`. This is the core validation primitive used by binary streaming, network replication, and internal lookups. +2. **High-Level Name Resolution (`find_class_by_name`)**: Bridges the human-readable text archive format (`; class\nInert`) to the entity class registry. It computes the `crc32` of the parsed class name and queries `ResolveEntityClass` across registered entity kinds. ```cpp -[[nodiscard]] Class* ResolveEntityClass(uint8 kind, uint32 crc) +[[nodiscard]] const Class* ResolveEntityClass(uint8 kind, uint32 crc) { if (kind >= ENTITY(Count)) { return nullptr; } - Class* classPtr = kEntity_type_class_ptr[kind]; - if (!classPtr) + const Class* class_ptr = kEntity_type_class_ptr[kind]; + if (class_ptr == nullptr) { return nullptr; } - if (classPtr->CRC != crc) + if (class_ptr->CRC != crc) { return nullptr; } - return classPtr; + return class_ptr; +} + +[[nodiscard]] Class* find_class_by_name(String name) +{ + if (!IsValid(name)) + { + return nullptr; + } + + const uint32 name_crc = crc32(name.Str, name.Size); + + for (uint8 kind = 0; kind < ENTITY(Count); ++kind) + { + const Class* class_ptr = ResolveEntityClass(kind, name_crc); + if (class_ptr != nullptr) + { + return const_cast(class_ptr); + } + } + + return nullptr; } ``` @@ -469,190 +493,17 @@ To ensure fast and safe type lookup during file deserialization: --- -## 5. Entity Deletion Lifecycle & Disk Synchronization +## 5. Entity Deletion Lifecycle & Disk Synchronization (Extracted for Rework) -### 5.1 In-Memory Removal vs. Immediate Disk Deletion Hazards -In game development, deleting an entity in the editor or during gameplay must **never synchronously invoke disk deletion**: -1. **Frame Rate Stutters:** Blocking on synchronous OS filesystem APIs (`DeleteFileA`) introduces multisecond frame freezes. -2. **Transactional Safety:** If the editor crashes or the user exits without saving, disk modifications cannot be rolled back. -3. **Undo/Redo Support:** An editor action stack must allow recovering deleted entities before changes are permanently committed to disk. - -Therefore, Juliet enforces a strict separation: -- **Immediate in-memory destruction:** Releases the entity from active simulation and registers its identifier in `World::PendingDeletions`. -- **Deferred disk deletion:** Executed strictly during explicit `SaveWorld` operations. - -### 5.2 Fast In-Memory Removal (`RemoveAtFast`) & Mutual Pointer Fixup -`VectorArena::RemoveAtFast` utilizes swap-and-pop: the element at the target index is replaced by the last element in the vector, and `Count` is decremented. - -```cpp -void RemoveAtFast(index_t index) -{ - Assert(Arena); - Assert(index < Count); - Assert(Count > 0); - - Type* elementAdr = DataFirst + index; - - if (DataLast != elementAdr) - { - Swap(DataLast, elementAdr); - } - - --DataLast; - --Count; -} -``` - -#### The Pointer Invalidation Problem: -When `Entity A` (at `index`) is swapped with `Entity Z` (at `DataLast`), the physical address of `Entity Z` changes from `DataLast` to `elementAdr`. -If `Entity Z` has a derived struct `derivedZ`, `derivedZ->Base` previously pointed to `DataLast`. After `RemoveAtFast`, `derivedZ->Base` points to garbage or the freed slot! - -#### The Pointer Fixup Protocol: -To preserve the mutual back-pointer invariant, `DestroyEntity` explicitly fixes up the swapped entity's derived back-pointer: - -```cpp -void DestroyEntity(EntityManager& manager, EntityID id) -{ - Entity* baseArray = manager.Entities.DataPtr(); - size_t count = manager.Entities.Size(); - - size_t targetIndex = indexMax; - for (size_t i = 0; i < count; ++i) - { - if (baseArray[i].ID == id) - { - targetIndex = i; - break; - } - } - - if (targetIndex == indexMax) - { - return; - } - - Entity* targetEntity = &baseArray[targetIndex]; - Class* classPtr = targetEntity->Kind; - Assert(classPtr != nullptr); - - // 1. Remove derived component from typed array via swap-and-pop - RemoveDerivedComponent(manager, classPtr, targetEntity->Derived); - - // 2. Remove base entity via swap-and-pop in VectorArena - bool wasLast = (targetIndex == count - 1); - manager.Entities.RemoveAtFast(targetIndex); - - // 3. Pointer fixup: If an element was swapped into targetIndex, fix its back-pointer! - if (!wasLast && targetIndex < manager.Entities.Size()) - { - Entity* movedEntity = &manager.Entities[targetIndex]; - auto* derivedTemp = reinterpret_cast(movedEntity->Derived); - Assert(derivedTemp != nullptr); - derivedTemp->Base = movedEntity; - } -} -``` - -### 5.3 O(1) Component Removal in `typed_entity_array` via Swap-and-Pop -To keep derived components packed contiguously for SIMD/cache iteration: -1. Locate the component's index within `by_type[kind].arena`. Because components have uniform stride `classPtr->size_of`: - $$\text{componentIndex} = \frac{\text{reinterpret\_cast(derivedPtr)} - \text{reinterpret\_cast(typedArray.array)}}{\text{classPtr->size\_of}}$$ -2. If the component is not the last one in the typed arena: - - Copy the last component into the slot occupied by the deleted component. - - Update the moved component's `Base->Derived` pointer to point to its new slot. -3. Decrement `typedArray.count`. -4. Pop the arena allocation if it was the top of the stack, or decrement count to mark slot reclamation. - -```cpp -void RemoveDerivedComponent(EntityManager& manager, Class* classPtr, DerivedType derivedPtr) -{ - Assert(classPtr != nullptr); - Assert(derivedPtr != nullptr); - - typed_entity_array& typedArray = manager.by_type[classPtr->kind]; - Assert(typedArray.count > 0); - Assert(typedArray.array != nullptr); - - size_t stride = classPtr->size_of; - auto* targetByte = reinterpret_cast(derivedPtr); - auto* firstByte = reinterpret_cast(typedArray.array); - - size_t componentIndex = static_cast(targetByte - firstByte) / stride; - Assert(componentIndex < typedArray.count); - - size_t lastIndex = typedArray.count - 1; - if (componentIndex != lastIndex) - { - uint8* lastByte = firstByte + (lastIndex * stride); - - // Copy last component data into target slot - MemCopy(targetByte, lastByte, stride); - - // Fixup the base pointer of the moved component - auto* movedDerived = reinterpret_cast(targetByte); - Assert(movedDerived->Base != nullptr); - movedDerived->Base->Derived = targetByte; - } - - typedArray.count -= 1; - if (typedArray.count == 0) - { - typedArray.array = nullptr; - } -} -``` - -### 5.4 Tracking Deletions in `World::PendingDeletions` -In `World.h`, the `World` struct is extended with a pending deletions container: - -```cpp -struct World -{ - Arena* WorldArena = nullptr; - EntityManager* EntityManager = nullptr; - VectorArena PendingDeletions; -}; -``` - -When an entity is deleted in the world: -```cpp -void RemoveWorldEntity(World& world, EntityID id) -{ - Assert(world.EntityManager != nullptr); - - // Record pending disk deletion - world.PendingDeletions.PushBack(id); - - // Destroy in memory immediately - DestroyEntity(*world.EntityManager, id); -} -``` - -### 5.5 Disk File Cleanup during `SaveWorld` -During `SaveWorld`, before saving modified entities, the engine iterates over `world.PendingDeletions` and removes their associated `.jasset` files: - -```cpp -void ProcessPendingDeletions(World& world, NonNullPtr scratchArena) -{ - for (size_t i = 0; i < world.PendingDeletions.Size(); ++i) - { - EntityID id = world.PendingDeletions[i]; - - // Format relative asset path: Assets/Entities/{ID}.jasset - char filenameBuffer[64]; - juliet_snprintf(filenameBuffer, sizeof(filenameBuffer), "Entities/%llu.jasset", id); - - String assetPath = GetAssetPath(scratchArena, WrapString(filenameBuffer)); - - if (PlatformDeleteFile(assetPath)) - { - Log(LogLevel::Message, LogCategory::Game, "Deleted entity asset: %s", CStr(assetPath)); - } - } - - world.PendingDeletions.Clear(); -} -``` +> [!WARNING] +> **Status: Extracted for Rework** +> The original swap-and-pop in-memory removal logic (`DestroyEntity`, `RemoveDerivedComponent`, and mutual back-pointer fixups) was determined to be overly complex and has been extracted to [`Game/Plans/Entity_Removal_Brainstorm.md`](file:///w:/Classified/Juliet/Game/Plans/Entity_Removal_Brainstorm.md) for further brainstorming and redesign. +> +> Simpler alternative architectures under consideration include: +> - **Active / Tombstone Flag (`is_active` bool):** Retaining entities in-place without moving memory during frame simulation, eliminating pointer invalidation entirely. +> - **Intrusive Free List:** Linking inactive slots via an intrusive linked list to find the first free slot in $O(1)$ without memory shifting. +> - **Generational Handles / Slot Map:** Enabling safe, non-dangling entity references across systems. +> - **Deferred Compaction:** Batch-compacting memory during level loads or scene transitions rather than per-frame swap-and-pop. --- @@ -741,10 +592,10 @@ if (ImGui::DragFloat3("Position", pos, 0.1f)) - Update `DEFINE_ENTITY_VERSIONED` and `DEFINE_CLASS_VERSIONED` to define `default_init_##entity` and pass it to `MakeClass`. - Add `bool is_dirty = false;` to `struct Entity`. - Update `MakeEntity` to assign `base_ptr->ID = EntityManager::ID++;` and delegate allocation and defaults cleanly to `AllocateEntity`. + - Declare `[[nodiscard]] const Class* ResolveEntityClass(uint8 kind, uint32 crc);` and `[[nodiscard]] Class* find_class_by_name(String name);` in `Entity.h`. 2. **Update `EntityManager.h`:** - Declare `[[nodiscard]] Entity* AllocateEntity(EntityManager& manager, Class* class_ptr);`. - - Declare `void DestroyEntity(EntityManager& manager, EntityID id);`. - - Declare `void RemoveDerivedComponent(EntityManager& manager, Class* class_ptr, DerivedType derived_ptr);`. + - *(Note: `DestroyEntity` and `RemoveDerivedComponent` deferred to `Entity_Removal_Brainstorm.md`)* 3. **Update `World.h`:** - Add `VectorArena PendingDeletions;` to `struct World`. - Update `SaveWorld` and `LoadWorld` signatures to take directory paths. @@ -757,12 +608,11 @@ if (ImGui::DragFloat3("Position", pos, 0.1f)) - Call `derivedClassPtr->default_init_fct(rawMemory)` (or `MemZero` if null) to initialize struct defaults. - Wire mutual pointers (`base->derived` and `derived->base`). - Increment `typedArray.count` and initialize `typedArray.array`. -2. Implement `DestroyEntity` & `RemoveDerivedComponent`: - - Implement swap-and-pop for derived components with base pointer update. - - Implement `manager.Entities.RemoveAtFast` with mutual back-pointer fixup. +2. *(Deferred for rework)* `DestroyEntity` & `RemoveDerivedComponent` (See [`Game/Plans/Entity_Removal_Brainstorm.md`](file:///w:/Classified/Juliet/Game/Plans/Entity_Removal_Brainstorm.md)). ### Phase 3: In-Place Deserialization & Serialization Pipeline 1. In `Entity.cpp`: + - Implement `ResolveEntityClass` and `find_class_by_name`. - `serialize(Archive& ar, NonNullPtr entity)` handles both base and derived class serialization. 2. In `World.cpp`: - Implement `serialize_entity_asset(Archive& ar, NonNullPtr entity, String filepath)`. @@ -917,53 +767,6 @@ namespace UnitTest Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestInPlaceDeserialization"); } - void TestSwapAndPopPointerFixup() - { - Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestSwapAndPopPointerFixup..."); - - TempArena tempArena = scratch_begin(nullptr, 0); - - World testWorld{}; - InitWorld(&testWorld, tempArena.Arena); - InitEntityManager(&testWorld); - EntityManager& manager = *testWorld.EntityManager; - - // Allocate 3 entities: E0, E1, E2 - Inert* e0 = MakeEntity(manager, 1.0f, 0.0f, 0.0f); - Inert* e1 = MakeEntity(manager, 2.0f, 0.0f, 0.0f); - Inert* e2 = MakeEntity(manager, 3.0f, 0.0f, 0.0f); - - EntityID id0 = e0->base->ID; - EntityID id1 = e1->base->ID; - EntityID id2 = e2->base->ID; - - Assert(manager.Entities.Size() == 3); - - // Delete middle entity E1 (triggers swap with E2) - DestroyEntity(manager, id1); - - Assert(manager.Entities.Size() == 2); - - // Verify E2's mutual back-pointers are still completely intact - Entity* remaining0 = &manager.Entities[0]; - Entity* remaining1 = &manager.Entities[1]; - - Assert(remaining0->ID == id0); - Assert(remaining1->ID == id2); - - auto* derived0 = reinterpret_cast(remaining0->derived); - auto* derived1 = reinterpret_cast(remaining1->derived); - - Assert(derived0->base == remaining0); - Assert(derived1->base == remaining1); - - ShutdownEntityManager(); - ShutdownWorld(&testWorld); - - scratch_end(tempArena); - Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestSwapAndPopPointerFixup"); - } - void TestDirtyTrackingLifecycle() { Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestDirtyTrackingLifecycle..."); @@ -1003,7 +806,7 @@ namespace UnitTest TestEntityAllocationAndWiring(); TestInPlaceDeserialization(); - TestSwapAndPopPointerFixup(); + // TestSwapAndPopPointerFixup(); // Deferred to Entity_Removal_Brainstorm.md TestDirtyTrackingLifecycle(); Log(LogLevel::Message, LogCategory::Game, "=================================================="); diff --git a/Game/Plans/Entity_Removal_Brainstorm.md b/Game/Plans/Entity_Removal_Brainstorm.md new file mode 100644 index 0000000..96fb3b5 --- /dev/null +++ b/Game/Plans/Entity_Removal_Brainstorm.md @@ -0,0 +1,273 @@ +# Entity Removal & Deletion Lifecycle (Brainstorm & Rework) + +> [!WARNING] +> **Status: Needs Rework** +> The entity deletion and removal logic below was extracted as-is from [`Game/Plans/02_Entity_Allocation_And_Lifecycle.md`](file:///w:/Classified/Juliet/Game/Plans/02_Entity_Allocation_And_Lifecycle.md) for future brainstorming. +> The original swap-and-pop approach required complex mutual back-pointer fixups across both base and derived memory buffers. + +--- + +## 1. Brainstorming Notes & Alternative Approaches + +The original design relied on `VectorArena::RemoveAtFast` (swap-and-pop) for both `Entities` and `by_type[kind].arena`. While this maintained dense contiguous memory, swapping arbitrary elements in memory invalidated pointers in both directions, requiring runtime pointer patching (`derived->Base = movedEntity; base->Derived = movedComponent`). + +### Ideas for Simpler & More Robust Alternatives: + +1. **Active Flag / Tombstone (`is_active` bool)**: + - Keep deleted entities in place rather than moving or swapping them. + - Simply toggle `entity->is_active = false;`. + - Iterators and simulation loops skip inactive entities (`if (!entity->is_active) continue;`). + - Pointers to entities and derived components remain stable for their entire lifetime. + +2. **Intrusive Free List for Slot Recycling**: + - Instead of shifting memory on deletion, vacant slots are linked into an intrusive free list (`first_free_index`). + - When an entity is destroyed: + - `entity->is_active = false;` + - Overwrite unused slot memory with `next_free_index`. + - When allocating a new entity: + - Check if `first_free_index != indexMax`. + - Pop from the free list in $O(1)$; otherwise push back to the end of the arena. + - Zero pointer invalidation, zero memmove/memcpy overhead during deletion. + +3. **Stable Generational Handles / Slot Map**: + - If external systems need references to entities that might be deleted, combine the free list with a generational counter to detect stale lookups safely. + +4. **Deferred Compaction / Garbage Collection**: + - If contiguous packing is strictly necessary for SIMD/cache optimization, defer compaction to a designated level load or scene transition rather than doing it per-frame on individual deletes. + +--- + +## 2. Extracted Original Removal Logic (As-Is from Plan 2) + +### 2.1 In-Memory Removal vs. Immediate Disk Deletion Hazards +In game development, deleting an entity in the editor or during gameplay must **never synchronously invoke disk deletion**: +1. **Frame Rate Stutters:** Blocking on synchronous OS filesystem APIs (`DeleteFileA`) introduces multisecond frame freezes. +2. **Transactional Safety:** If the editor crashes or the user exits without saving, disk modifications cannot be rolled back. +3. **Undo/Redo Support:** An editor action stack must allow recovering deleted entities before changes are permanently committed to disk. + +Therefore, Juliet enforces a strict separation: +- **Immediate in-memory destruction:** Releases the entity from active simulation and registers its identifier in `World::PendingDeletions`. +- **Deferred disk deletion:** Executed strictly during explicit `SaveWorld` operations. + +### 2.2 Fast In-Memory Removal (`RemoveAtFast`) & Mutual Pointer Fixup +`VectorArena::RemoveAtFast` utilizes swap-and-pop: the element at the target index is replaced by the last element in the vector, and `Count` is decremented. + +```cpp +void RemoveAtFast(index_t index) +{ + Assert(Arena); + Assert(index < Count); + Assert(Count > 0); + + Type* elementAdr = DataFirst + index; + + if (DataLast != elementAdr) + { + Swap(DataLast, elementAdr); + } + + --DataLast; + --Count; +} +``` + +#### The Pointer Invalidation Problem: +When `Entity A` (at `index`) is swapped with `Entity Z` (at `DataLast`), the physical address of `Entity Z` changes from `DataLast` to `elementAdr`. +If `Entity Z` has a derived struct `derivedZ`, `derivedZ->Base` previously pointed to `DataLast`. After `RemoveAtFast`, `derivedZ->Base` points to garbage or the freed slot! + +#### The Pointer Fixup Protocol: +To preserve the mutual back-pointer invariant, `DestroyEntity` explicitly fixes up the swapped entity's derived back-pointer: + +```cpp +void DestroyEntity(EntityManager& manager, EntityID id) +{ + Entity* baseArray = manager.Entities.DataPtr(); + size_t count = manager.Entities.Size(); + + size_t targetIndex = indexMax; + for (size_t i = 0; i < count; ++i) + { + if (baseArray[i].ID == id) + { + targetIndex = i; + break; + } + } + + if (targetIndex == indexMax) + { + return; + } + + Entity* targetEntity = &baseArray[targetIndex]; + Class* classPtr = targetEntity->derived_kind; + Assert(classPtr != nullptr); + + // 1. Remove derived component from typed array via swap-and-pop + RemoveDerivedComponent(manager, classPtr, targetEntity->derived); + + // 2. Remove base entity via swap-and-pop in VectorArena + bool wasLast = (targetIndex == count - 1); + manager.Entities.RemoveAtFast(targetIndex); + + // 3. Pointer fixup: If an element was swapped into targetIndex, fix its back-pointer! + if (!wasLast && targetIndex < manager.Entities.Size()) + { + Entity* movedEntity = &manager.Entities[targetIndex]; + auto* derivedTemp = reinterpret_cast(movedEntity->derived); + Assert(derivedTemp != nullptr); + derivedTemp->base = movedEntity; + } +} +``` + +### 2.3 O(1) Component Removal in `typed_entity_array` via Swap-and-Pop +To keep derived components packed contiguously for SIMD/cache iteration: +1. Locate the component's index within `by_type[kind].arena`. Because components have uniform stride `classPtr->size_of`: + $$\text{componentIndex} = \frac{\text{reinterpret\_cast(derivedPtr)} - \text{reinterpret\_cast(typedArray.array)}}{\text{classPtr->size\_of}}$$ +2. If the component is not the last one in the typed arena: + - Copy the last component into the slot occupied by the deleted component. + - Update the moved component's `Base->Derived` pointer to point to its new slot. +3. Decrement `typedArray.count`. +4. Pop the arena allocation if it was the top of the stack, or decrement count to mark slot reclamation. + +```cpp +void RemoveDerivedComponent(EntityManager& manager, Class* classPtr, DerivedType derivedPtr) +{ + Assert(classPtr != nullptr); + Assert(derivedPtr != nullptr); + + typed_entity_array& typedArray = manager.by_type[classPtr->kind]; + Assert(typedArray.count > 0); + Assert(typedArray.array != nullptr); + + size_t stride = classPtr->size_of; + auto* targetByte = reinterpret_cast(derivedPtr); + auto* firstByte = reinterpret_cast(typedArray.array); + + size_t componentIndex = static_cast(targetByte - firstByte) / stride; + Assert(componentIndex < typedArray.count); + + size_t lastIndex = typedArray.count - 1; + if (componentIndex != lastIndex) + { + uint8* lastByte = firstByte + (lastIndex * stride); + + // Copy last component data into target slot + MemCopy(targetByte, lastByte, stride); + + // Fixup the base pointer of the moved component + auto* movedDerived = reinterpret_cast(targetByte); + Assert(movedDerived->base != nullptr); + movedDerived->base->derived = targetByte; + } + + typedArray.count -= 1; + if (typedArray.count == 0) + { + typedArray.array = nullptr; + } +} +``` + +### 2.4 Tracking Deletions in `World::PendingDeletions` +In `World.h`, the `World` struct is extended with a pending deletions container: + +```cpp +struct World +{ + Arena* WorldArena = nullptr; + EntityManager* EntityManager = nullptr; + VectorArena PendingDeletions; +}; +``` + +When an entity is deleted in the world: +```cpp +void RemoveWorldEntity(World& world, EntityID id) +{ + Assert(world.EntityManager != nullptr); + + // Record pending disk deletion + world.PendingDeletions.PushBack(id); + + // Destroy in memory immediately + DestroyEntity(*world.EntityManager, id); +} +``` + +### 2.5 Disk File Cleanup during `SaveWorld` +During `SaveWorld`, before saving modified entities, the engine iterates over `world.PendingDeletions` and removes their associated `.jasset` files: + +```cpp +void ProcessPendingDeletions(World& world, NonNullPtr scratchArena) +{ + for (size_t i = 0; i < world.PendingDeletions.Size(); ++i) + { + EntityID id = world.PendingDeletions[i]; + + // Format relative asset path: Assets/Entities/{ID}.jasset + char filenameBuffer[64]; + juliet_snprintf(filenameBuffer, sizeof(filenameBuffer), "Entities/%llu.jasset", id); + + String assetPath = GetAssetPath(scratchArena, WrapString(filenameBuffer)); + + if (PlatformDeleteFile(assetPath)) + { + Log(LogLevel::Message, LogCategory::Game, "Deleted entity asset: %s", CStr(assetPath)); + } + } + + world.PendingDeletions.Clear(); +} +``` + +### 2.6 Associated Swap-and-Pop Unit Test +```cpp +void TestSwapAndPopPointerFixup() +{ + Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestSwapAndPopPointerFixup..."); + + TempArena tempArena = scratch_begin(nullptr, 0); + + World testWorld{}; + InitWorld(&testWorld, tempArena.Arena); + InitEntityManager(&testWorld); + EntityManager& manager = *testWorld.EntityManager; + + // Allocate 3 entities: E0, E1, E2 + Inert* e0 = MakeEntity(manager, 1.0f, 0.0f, 0.0f); + Inert* e1 = MakeEntity(manager, 2.0f, 0.0f, 0.0f); + Inert* e2 = MakeEntity(manager, 3.0f, 0.0f, 0.0f); + + EntityID id0 = e0->base->ID; + EntityID id1 = e1->base->ID; + EntityID id2 = e2->base->ID; + + Assert(manager.Entities.Size() == 3); + + // Delete middle entity E1 (triggers swap with E2) + DestroyEntity(manager, id1); + + Assert(manager.Entities.Size() == 2); + + // Verify E2's mutual back-pointers are still completely intact + Entity* remaining0 = &manager.Entities[0]; + Entity* remaining1 = &manager.Entities[1]; + + Assert(remaining0->ID == id0); + Assert(remaining1->ID == id2); + + auto* derived0 = reinterpret_cast(remaining0->derived); + auto* derived1 = reinterpret_cast(remaining1->derived); + + Assert(derived0->base == remaining0); + Assert(derived1->base == remaining1); + + ShutdownEntityManager(); + ShutdownWorld(&testWorld); + + scratch_end(tempArena); + Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestSwapAndPopPointerFixup"); +} +``` diff --git a/Game/UnitTest/WorldUnitTest.cpp b/Game/UnitTest/WorldUnitTest.cpp index 95a3fe4..446c1e1 100644 --- a/Game/UnitTest/WorldUnitTest.cpp +++ b/Game/UnitTest/WorldUnitTest.cpp @@ -3,24 +3,151 @@ #if JULIET_DEBUG #include +#include #include #include #include #include +#include #include - -#ifdef _WIN32 -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#endif +#include namespace UnitTest { + internal void TestEntityAllocationAndWiring() + { + Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestEntityAllocationAndWiring..."); + + TempArena tempArena = scratch_begin(nullptr, 0); + + World testWorld{}; + InitWorld(&testWorld, tempArena.Arena); + + InitEntityManager(&testWorld); + EntityManager& manager = *testWorld.EntityManager; + + // 1. Allocate Inert Entity via AllocateEntity + Entity* base_entity = allocate_entity(manager, Inert::kind); + Assert(base_entity != nullptr); + Assert(base_entity->ID == 0); // Pure memory allocator leaves ID unassigned (0) until MakeEntity or deserialization + base_entity->ID = ++EntityManager::ID; + Assert(base_entity->ID > 0); + Assert(base_entity->derived_kind == Inert::kind); + Assert(base_entity->derived != nullptr); + Assert(base_entity->is_dirty == true); + + // 2. Validate mutual back-pointer wiring + auto* derived = reinterpret_cast(base_entity->derived); + Assert(derived->base == base_entity); + + // 3. DownCast verification + Inert* inert = DownCast(base_entity); + Assert(inert != nullptr); + Assert(inert->base == base_entity); + + // 4. Validate typed array tracking + typed_entity_array& inert_array = manager.by_type[ENTITY(Inert)]; + Assert(inert_array.count == 1); + Assert(inert_array.array == derived); + + ShutdownEntityManager(); + ShutdownWorld(&testWorld); + + scratch_end(tempArena); + Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestEntityAllocationAndWiring"); + } + + internal void TestInPlaceDeserialization() + { + Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestInPlaceDeserialization..."); + TempArena tempArena = scratch_begin(nullptr, 0); + World testWorld{}; + InitWorld(&testWorld, tempArena.Arena); + InitEntityManager(&testWorld); + EntityManager& manager = *testWorld.EntityManager; + // 1. In-memory .jasset text fixture (no streams or files needed) + String asset_content = ConstString("; class\n" + "Inert\n" + "; version\n" + "1\n" + "; class_version\n" + "1\n" + "; id\n" + "42\n" + "; position\n" + "12.5 -44.0 108.2 1.0\n" + "; MeshInstance\n" + "42\n"); + ByteBuffer buffer = { .Data = reinterpret_cast(asset_content.Str), .Size = asset_content.Size }; + // 2. Tokenize into Archive + Archive load_ar = {}; + load_ar.arena = tempArena.Arena; + load_ar.loading = true; + load_ar.base = tokenize_archive(tempArena.Arena, buffer); + // 3. Deserialize in-place + Entity* loaded_base = deserialize_entity(load_ar, manager); + // 4. Verify in-place allocations and values + Assert(loaded_base != nullptr); + Assert(loaded_base->ID == 42); + Assert(EntityManager::ID > 42); // Generator counter advanced past loaded ID + Assert(loaded_base->position.x == 12.5f); + Assert(loaded_base->position.y == -44.0f); + Assert(loaded_base->position.z == 108.2f); + Assert(loaded_base->is_dirty == false); + Inert* loaded_inert = DownCast(loaded_base); + Assert(loaded_inert != nullptr); + Assert(loaded_inert->base == loaded_base); + Assert(loaded_inert->MeshInstance == 42); + ShutdownEntityManager(); + ShutdownWorld(&testWorld); + scratch_end(tempArena); + Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestInPlaceDeserialization"); + } + + internal void TestDirtyTrackingLifecycle() + { + Log(LogLevel::Message, LogCategory::Game, "[UnitTest] Running TestDirtyTrackingLifecycle..."); + + TempArena tempArena = scratch_begin(nullptr, 0); + + World testWorld{}; + InitWorld(&testWorld, tempArena.Arena); + InitEntityManager(&testWorld); + EntityManager& manager = *testWorld.EntityManager; + + Entity* entity = allocate_entity(manager, Inert::kind); + Assert(entity->is_dirty == true); + + // Simulate save + entity->is_dirty = false; + Assert(entity->is_dirty == false); + + // Simulate mutation + entity->position.x += 1.0f; + entity->is_dirty = true; + Assert(entity->is_dirty == true); + + ShutdownEntityManager(); + ShutdownWorld(&testWorld); + + scratch_end(tempArena); + Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestDirtyTrackingLifecycle"); + } + void WorldUnitTest() { - LogMessage(LogCategory::Game, "Running World Unit Tests..."); + Log(LogLevel::Message, LogCategory::Game, "=================================================="); + Log(LogLevel::Message, LogCategory::Game, "Starting Entity Allocation & Lifecycle Unit Tests"); + Log(LogLevel::Message, LogCategory::Game, "=================================================="); + + TestEntityAllocationAndWiring(); + TestInPlaceDeserialization(); + // TestSwapAndPopPointerFixup(); // Deferred to Entity_Removal_Brainstorm.md + TestDirtyTrackingLifecycle(); + + Log(LogLevel::Message, LogCategory::Game, "=================================================="); + Log(LogLevel::Message, LogCategory::Game, "All Entity Lifecycle Unit Tests PASSED Successfully"); + Log(LogLevel::Message, LogCategory::Game, "=================================================="); } } // namespace UnitTest diff --git a/Juliet/include/Engine/Class.h b/Juliet/include/Engine/Class.h index 36deb45..e7ae7ba 100644 --- a/Juliet/include/Engine/Class.h +++ b/Juliet/include/Engine/Class.h @@ -78,4 +78,4 @@ bool IsA(const Class& cls) return IsA(cls, type::kind); } -JULIET_API void serialize(Archive& ar, NonNullPtr cls, void* instance); +JULIET_API void serialize(Archive& ar, NonNullPtr cls, void* instance); diff --git a/Juliet/src/Engine/class.cpp b/Juliet/src/Engine/class.cpp index 043e9ab..ba09268 100644 --- a/Juliet/src/Engine/class.cpp +++ b/Juliet/src/Engine/class.cpp @@ -17,7 +17,7 @@ bool IsA(const Class& query, const Class* target) return result; } -void serialize(Archive& ar, NonNullPtr cls, void* instance) +void serialize(Archive& ar, NonNullPtr cls, void* instance) { Assert(instance != nullptr);