wip entity deserialization phase 1. Unit test not passing because of globals that assume only one entity manager / game state.

This commit is contained in:
2026-09-10 22:50:18 -04:00
parent 615d36b09c
commit 58b79fb499
9 changed files with 518 additions and 259 deletions
+45 -242
View File
@@ -374,28 +374,52 @@ Under the new pipeline:
5. `serialize(ar, NonNullPtr<Entity>(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*>(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<entity_template*>(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<uint8*>(derivedPtr)} - \text{reinterpret\_cast<uint8*>(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<uint8*>(derivedPtr);
auto* firstByte = reinterpret_cast<uint8*>(typedArray.array);
size_t componentIndex = static_cast<size_t>(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<entity_template*>(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<EntityID, 1024> 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<Arena> 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<EntityType>` 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<EntityID, 1024> 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> entity)` handles both base and derived class serialization.
2. In `World.cpp`:
- Implement `serialize_entity_asset(Archive& ar, NonNullPtr<Entity> 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<Inert>(manager, 1.0f, 0.0f, 0.0f);
Inert* e1 = MakeEntity<Inert>(manager, 2.0f, 0.0f, 0.0f);
Inert* e2 = MakeEntity<Inert>(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<entity_template*>(remaining0->derived);
auto* derived1 = reinterpret_cast<entity_template*>(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, "==================================================");
+273
View File
@@ -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<entity_template*>(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<uint8*>(derivedPtr)} - \text{reinterpret\_cast<uint8*>(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<uint8*>(derivedPtr);
auto* firstByte = reinterpret_cast<uint8*>(typedArray.array);
size_t componentIndex = static_cast<size_t>(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<entity_template*>(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<EntityID, 1024> 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<Arena> 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<Inert>(manager, 1.0f, 0.0f, 0.0f);
Inert* e1 = MakeEntity<Inert>(manager, 2.0f, 0.0f, 0.0f);
Inert* e2 = MakeEntity<Inert>(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<entity_template*>(remaining0->derived);
auto* derived1 = reinterpret_cast<entity_template*>(remaining1->derived);
Assert(derived0->base == remaining0);
Assert(derived1->base == remaining1);
ShutdownEntityManager();
ShutdownWorld(&testWorld);
scratch_end(tempArena);
Log(LogLevel::Message, LogCategory::Game, "[UnitTest] PASSED: TestSwapAndPopPointerFixup");
}
```