updating serialization tdd with latest changes
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -82,10 +82,10 @@ This is the classic **chicken-and-egg memory problem**:
|
||||
### 1.4 Architectural Objectives
|
||||
This specification establishes a robust in-place lifecycle pipeline that completely eliminates stack temporaries and dynamic heap allocations:
|
||||
1. **Direct In-Place Allocation:** Introduce `AllocateEntity(EntityManager& manager, Class* classPtr)` which allocates both the base `Entity` and the derived struct directly within their respective engine memory arenas.
|
||||
2. **Bidirectional Pointer Integrity:** Wire mutual pointers (`base->Derived` and `derived->Base`) at allocation time before any field deserialization begins.
|
||||
2. **Bidirectional Pointer Integrity:** Wire mutual pointers (`base->derived` and `derived->base`) at allocation time before any field deserialization begins.
|
||||
3. **In-Place Stream Deserialization:** Read class reflection metadata first, invoke `AllocateEntity`, and stream base and derived properties directly into arena-resident memory.
|
||||
4. **Isolated Entity Assets (`.jasset`):** Transition from a monolithic `world.bin` to a modular one-file-per-entity architecture (`Assets/Entities/{ID}.jasset`).
|
||||
5. **Dirty Tracking & Optimal Saves:** Introduce an `IsDirty` flag on `Entity` to avoid rewriting unchanged entity files, minimizing disk I/O and eliminating spurious Git repository modifications.
|
||||
5. **Dirty Tracking & Optimal Saves:** Introduce an `is_dirty` flag on `Entity` to avoid rewriting unchanged entity files, minimizing disk I/O and eliminating spurious Git repository modifications.
|
||||
6. **Robust Deletion Lifecycle:** Decouple in-memory removal (`RemoveAtFast` with pointer fixup) from disk synchronization using `World::PendingDeletions`.
|
||||
|
||||
---
|
||||
@@ -129,8 +129,8 @@ VectorArena<Entity, 100'000> Entities;
|
||||
```
|
||||
- **Capacity:** Fixed reserve of 100,000 entities allocated from the `WorldArena`.
|
||||
- **Memory Footprint:**
|
||||
$$\text{sizeof(Entity)} = 8\text{ (ID)} + 8\text{ (Kind)} + 8\text{ (Derived)} + 12\text{ (X, Y, Z)} + 1\text{ (IsDirty)} + 3\text{ (Padding)} = 40\text{ bytes}$$
|
||||
Total reserved space: $100{,}000 \times 40\text{ bytes} \approx 4.0\text{ MB}$.
|
||||
$$\text{sizeof(Entity)} = 8\text{ (ID)} + 8\text{ (derived\_kind)} + 8\text{ (derived)} + 16\text{ (position)} + 1\text{ (is\_dirty)} + 7\text{ (Padding)} = 48\text{ bytes}$$
|
||||
Total reserved space: $100{,}000 \times 48\text{ bytes} \approx 4.8\text{ MB}$.
|
||||
- **Access Speed:** O(1) random access by index; sequential streaming utilizes L1/L2 hardware prefetchers with zero cache line waste.
|
||||
|
||||
### 2.3 `manager.by_type[kind].arena`: Typed Component Arenas
|
||||
@@ -193,11 +193,11 @@ The canonical allocation function is defined in `EntityManager.h`:
|
||||
- A new `Entity` record is appended to `manager.Entities`.
|
||||
- A new typed block of `classPtr->size_of` bytes is allocated in `manager.by_type[classPtr->kind].arena`.
|
||||
- The derived memory is zeroed.
|
||||
- `base->Derived` points to the derived struct.
|
||||
- `derived->Base` points to the base `Entity`.
|
||||
- `base->Kind` is assigned to `classPtr`.
|
||||
- `base->derived` points to the derived struct.
|
||||
- `derived->base` points to the base `Entity`.
|
||||
- `base->derived_kind` is assigned to `class_ptr`.
|
||||
- `base->ID` is assigned the next unique `EntityManager::ID`.
|
||||
- `base->IsDirty` is initialized to `true`.
|
||||
- `base->is_dirty` is initialized to `true`.
|
||||
- `typed_entity_array::count` is incremented.
|
||||
- `typed_entity_array::array` is initialized if this is the first entity of this type.
|
||||
|
||||
@@ -215,12 +215,10 @@ The implementation replaces the flawed `RegisterEntity` routine in [`Game/Entity
|
||||
// 1. Allocate uninitialized Base Entity in the contiguous VectorArena
|
||||
Entity baseTemplate{};
|
||||
baseTemplate.ID = EntityManager::ID++;
|
||||
baseTemplate.DerivedKind = derivedClassPtr;
|
||||
baseTemplate.Derived = nullptr;
|
||||
baseTemplate.X = 0.0f;
|
||||
baseTemplate.Y = 0.0f;
|
||||
baseTemplate.Z = 0.0f;
|
||||
baseTemplate.IsDirty = true;
|
||||
baseTemplate.derived_kind = derivedClassPtr;
|
||||
baseTemplate.derived = nullptr;
|
||||
baseTemplate.position = {};
|
||||
baseTemplate.is_dirty = true;
|
||||
|
||||
manager.Entities.PushBack(baseTemplate);
|
||||
Entity* basePtr = manager.Entities.Back();
|
||||
@@ -343,16 +341,15 @@ Inert
|
||||
#### Important: No Header Structs for Derived Types
|
||||
- **Derived types NEVER require their own file header**: You do **not** write an `InertHeader`, `DoorHeader`, or `PlayerHeader`. Derived types only serialize their own member variables.
|
||||
- **Universal `; version` + optional `; class_version`**: Every `.jasset` file has a universal `; version` tag. For entity assets, `; version` governs base entity properties (`kEntityBaseVersion`), while an optional `; class_version` governs derived class properties (`Class::Version`). Non-entity assets like `WorldSettings.jasset` only have `; version`.
|
||||
- **No binary `EntityFileHeader` struct is needed**: Under the `; variable_name\nvalues` text format, there is no packed binary C-struct header at all. The common properties (`; id`, `; class`, `; version`, `; class_version`, `; position`) are standard text Key-Value nodes read by the exact same `archive` parser.
|
||||
- **No binary `EntityFileHeader` struct is needed**: Under the `; variable_name\nvalues` text format, there is no packed binary C-struct header at all. The common properties (`; id`, `; class`, `; version`, `; class_version`, `; position`) are standard text Key-Value nodes read by the exact same `Archive` parser.
|
||||
|
||||
### 4.2 Eliminating Intermediate Stack Allocations
|
||||
Under the new pipeline:
|
||||
1. The `.jasset` text file is read into memory onto a `TempArena` via `LoadFile`.
|
||||
2. The lines are tokenized into a `TextPropertyNode` scratch table.
|
||||
3. The engine reads `; class` (e.g. `"Inert"`) and resolves `Class* classPtr = FindClassByName(className)`.
|
||||
4. `AllocateEntity(manager, classPtr)` is called immediately. Memory for both base and derived components is allocated in-place in their permanent engine arenas.
|
||||
5. Base properties (`id`, `position`, etc.) are read directly into `*basePtr` via `SerializeEntityBase`.
|
||||
6. `classPtr->serialize_fct(ar, basePtr->Derived, classVersion)` is called. Derived fields stream **directly into the typed arena** without temporary staging buffers or stack copies.
|
||||
2. The property nodes are parsed into a `ParsedArchive` via `tokenize_archive(tempArena.Arena, fileBuffer, &ar.base)`.
|
||||
3. The engine reads `; class` (e.g. `"Inert"`) and resolves `Class* class_ptr = find_class_by_name(class_name)`.
|
||||
4. `AllocateEntity(manager, class_ptr)` is called immediately. Memory for both base and derived components is allocated in-place in their permanent engine arenas.
|
||||
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:
|
||||
@@ -387,73 +384,58 @@ To ensure fast and safe type lookup during file deserialization:
|
||||
| In-Place Deserialization Flowchart |
|
||||
+---------------------------------------------------------------------------------------+
|
||||
| |
|
||||
| 1. Read Header from IOStream/ByteBuffer |
|
||||
| 1. LoadFile(scratch.Arena, filepath) into ByteBuffer |
|
||||
| | |
|
||||
| v |
|
||||
| 2. Validate Magic ('JAST') and Version (1) |
|
||||
| 2. tokenize_archive(scratch.Arena, file_buffer, &ar.base) |
|
||||
| | |
|
||||
| v |
|
||||
| 3. Resolve Class* from header.Kind & header.ClassCRC |
|
||||
| 3. Read "; class" & Resolve Class* via find_class_by_name(class_name) |
|
||||
| | |
|
||||
| v |
|
||||
| 4. basePtr = AllocateEntity(manager, classPtr) |
|
||||
| 4. base_ptr = AllocateEntity(manager, class_ptr) |
|
||||
| | |
|
||||
| +--> [manager.Entities]: Allocates base Entity |
|
||||
| +--> [manager.by_type[kind].arena]: Allocates derived struct |
|
||||
| +--> Mutual Back-Pointers Wired In-Place |
|
||||
| | |
|
||||
| v |
|
||||
| 5. Direct Copy Base Properties (ID, X, Y, Z) |
|
||||
| 5. serialize(ar, NonNullPtr<Entity>(base_ptr)) |
|
||||
| | |
|
||||
| +--> Streams Base Entity (Entity::kind, version, ID, position) |
|
||||
| +--> Streams Derived Component (base_ptr->derived_kind, class_version) |
|
||||
| | |
|
||||
| v |
|
||||
| 6. Does classPtr->serialize_fct exist? |
|
||||
| | | |
|
||||
| Yes No |
|
||||
| | | |
|
||||
| v | |
|
||||
| Invoke: | |
|
||||
| serialize_fct(&ar, | |
|
||||
| Derived) | |
|
||||
| | | |
|
||||
| +---------------------+ |
|
||||
| | |
|
||||
| v |
|
||||
| 7. Clear Dirty Flag: basePtr->IsDirty = false |
|
||||
| 6. Clear Dirty Flag: base_ptr->is_dirty = false |
|
||||
| |
|
||||
+---------------------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
```cpp
|
||||
[[nodiscard]] Entity* DeserializeEntityInPlace(EntityManager& manager, archive& ar)
|
||||
[[nodiscard]] Entity* deserialize_entity_in_place(EntityManager& manager, Archive& ar)
|
||||
{
|
||||
Assert(ar.loading);
|
||||
|
||||
// 1. Read class name and resolve Class*
|
||||
String className = {};
|
||||
SerializeProp(ar, "class", className, ar.arena);
|
||||
Class* classPtr = FindClassByName(className);
|
||||
if (!classPtr)
|
||||
String class_name = {};
|
||||
SERIALIZE(ar, class, class_name);
|
||||
Class* class_ptr = find_class_by_name(class_name);
|
||||
if (!class_ptr)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// 2. Allocate persistent memory for base and derived components in their respective arenas
|
||||
Entity* basePtr = AllocateEntity(manager, classPtr);
|
||||
Assert(basePtr != nullptr);
|
||||
Entity* base_ptr = AllocateEntity(manager, class_ptr);
|
||||
Assert(base_ptr != nullptr);
|
||||
|
||||
// 3. Serialize Base Entity in-place using Entity::Kind
|
||||
SerializeClassInstance(ar, Entity::Kind, basePtr);
|
||||
|
||||
// 4. Stream derived properties in-place using basePtr->DerivedKind
|
||||
if (basePtr->DerivedKind != nullptr && basePtr->Derived != nullptr)
|
||||
{
|
||||
SerializeClassInstance(ar, basePtr->DerivedKind, basePtr->Derived);
|
||||
}
|
||||
// 3. Serialize Base Entity and Derived in-place
|
||||
serialize(ar, NonNullPtr<Entity>(base_ptr));
|
||||
|
||||
// Freshly loaded entity matches disk state exactly
|
||||
basePtr->IsDirty = false;
|
||||
base_ptr->is_dirty = false;
|
||||
|
||||
return basePtr;
|
||||
return base_ptr;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -653,26 +635,24 @@ In a level containing $10{,}000$ entities:
|
||||
- **Monolithic `world.bin` Save:** Modifying a single entity's $X$ coordinate requires re-serializing all $10{,}000$ entities and overwriting a multi-megabyte binary file. This introduces a huge Git diff and constant merge conflicts.
|
||||
- **Full-Directory `.jasset` Save:** Iterating through all $10{,}000$ entities and unconditionally writing $10{,}000$ `.jasset` files incurs massive OS file-system overhead (directory table locks, I/O bandwidth) and changes the file timestamps of every asset. Git reports thousands of modified files even when only one entity changed!
|
||||
|
||||
### 6.2 The `IsDirty` Flag on `Entity`
|
||||
### 6.2 The `is_dirty` Flag on `Entity`
|
||||
To solve this, `Entity` in [`Game/Entity/Entity.h`](file:///w:/Classified/Juliet/Game/Entity/Entity.h#L28-L36) is augmented with an explicit dirty flag:
|
||||
|
||||
```cpp
|
||||
struct Entity final
|
||||
{
|
||||
DECLARE_ENTITY() // static Class* Kind; (Entity's own Class descriptor)
|
||||
DECLARE_CLASS() // static Class* kind; (Entity's own Class descriptor)
|
||||
|
||||
EntityID ID = 0;
|
||||
Class* DerivedKind = nullptr; // Pointer to derived class descriptor (e.g. Inert::Kind)
|
||||
DerivedType Derived = nullptr; // Pointer to derived component memory
|
||||
float X = 0.0f;
|
||||
float Y = 0.0f;
|
||||
float Z = 0.0f;
|
||||
bool IsDirty = false;
|
||||
Class* derived_kind = nullptr; // Pointer to derived class descriptor (e.g. Inert::kind)
|
||||
DerivedType derived = nullptr; // Pointer to derived component memory
|
||||
Vector4 position = {};
|
||||
bool is_dirty = false;
|
||||
};
|
||||
```
|
||||
|
||||
### 6.3 Granular State Transitions
|
||||
The `IsDirty` flag obeys a strict lifecycle state machine:
|
||||
The `is_dirty` flag obeys a strict lifecycle state machine:
|
||||
|
||||
```
|
||||
+-----------------------------------+
|
||||
@@ -682,14 +662,14 @@ The `IsDirty` flag obeys a strict lifecycle state machine:
|
||||
|
|
||||
v
|
||||
+---------------+
|
||||
+------->| IsDirty: TRUE |<-------+
|
||||
+------->|is_dirty: TRUE |<-------+
|
||||
| +-------+-------+ |
|
||||
| | |
|
||||
Entity Mutated | SaveWorld
|
||||
(Position, Component) | Completed
|
||||
| v |
|
||||
| +---------------+ |
|
||||
+--------+ IsDirty: FALSE+--------+
|
||||
+--------+is_dirty: FALSE+--------+
|
||||
+-------+-------+
|
||||
^
|
||||
|
|
||||
@@ -697,10 +677,10 @@ The `IsDirty` flag obeys a strict lifecycle state machine:
|
||||
(LoadWorld / Asset)
|
||||
```
|
||||
|
||||
1. **Entity Creation:** Newly spawned entities in the editor have `IsDirty = true`.
|
||||
2. **Property Mutation:** Any modification to `X, Y, Z` or derived component payload sets `entity->IsDirty = true`.
|
||||
3. **Successful Deserialization:** Entities loaded from disk initialize with `IsDirty = false`.
|
||||
4. **Successful Save:** Upon successfully writing an entity to its `.jasset` file, the engine resets `entity->IsDirty = false`.
|
||||
1. **Entity Creation:** Newly spawned entities in the editor have `is_dirty = true`.
|
||||
2. **Property Mutation:** Any modification to `position` or derived component payload sets `entity->is_dirty = true`.
|
||||
3. **Successful Deserialization:** Entities loaded from disk initialize with `is_dirty = false`.
|
||||
4. **Successful Save:** Upon successfully writing an entity to its `.jasset` file, the engine resets `entity->is_dirty = false`.
|
||||
|
||||
### 6.4 Version Control Benefits (Git Friendly Assets)
|
||||
By coupling the one-file-per-entity `.jasset` format with dirty tracking:
|
||||
@@ -712,13 +692,13 @@ By coupling the one-file-per-entity `.jasset` format with dirty tracking:
|
||||
In [`Game/Data/World.cpp`](file:///w:/Classified/Juliet/Game/Data/World.cpp#L291-L311), editor UI widgets automatically set the dirty flag upon receiving user input:
|
||||
|
||||
```cpp
|
||||
float pos[3] = { ent.X, ent.Y, ent.Z };
|
||||
float pos[4] = { ent.position.x, ent.position.y, ent.position.z, ent.position.w };
|
||||
if (ImGui::DragFloat3("Position", pos, 0.1f))
|
||||
{
|
||||
ent.X = pos[0];
|
||||
ent.Y = pos[1];
|
||||
ent.Z = pos[2];
|
||||
ent.IsDirty = true; // Mark dirty for persistence
|
||||
ent.position.x = pos[0];
|
||||
ent.position.y = pos[1];
|
||||
ent.position.z = pos[2];
|
||||
ent.is_dirty = true; // Mark dirty for persistence
|
||||
UpdateWorld(world);
|
||||
}
|
||||
```
|
||||
@@ -729,13 +709,12 @@ if (ImGui::DragFloat3("Position", pos, 0.1f))
|
||||
|
||||
### Phase 1: Data Structures & Header Definitions
|
||||
1. **Update `Entity.h`:**
|
||||
- Add `bool IsDirty = false;` to `struct Entity`.
|
||||
- Add `bool is_dirty = false;` to `struct Entity`.
|
||||
- Update `MakeEntity<EntityType>` to delegate to `AllocateEntity`.
|
||||
- Define `EntityFileHeader`, `kEntityAssetMagic`, and `kEntityAssetVersion`.
|
||||
2. **Update `EntityManager.h`:**
|
||||
- Declare `[[nodiscard]] Entity* AllocateEntity(EntityManager& manager, Class* classPtr);`.
|
||||
- Declare `[[nodiscard]] Entity* AllocateEntity(EntityManager& manager, Class* class_ptr);`.
|
||||
- Declare `void DestroyEntity(EntityManager& manager, EntityID id);`.
|
||||
- Declare `void RemoveDerivedComponent(EntityManager& manager, Class* classPtr, DerivedType derivedPtr);`.
|
||||
- Declare `void RemoveDerivedComponent(EntityManager& manager, Class* class_ptr, DerivedType derived_ptr);`.
|
||||
3. **Update `World.h`:**
|
||||
- Add `VectorArena<EntityID, 1024> PendingDeletions;` to `struct World`.
|
||||
- Update `SaveWorld` and `LoadWorld` signatures to take directory paths.
|
||||
@@ -745,7 +724,7 @@ if (ImGui::DragFloat3("Position", pos, 0.1f))
|
||||
- Enforce parameter assertions.
|
||||
- Push to `manager.Entities`.
|
||||
- Allocate zeroed block in `manager.by_type[kind].arena`.
|
||||
- Wire mutual pointers (`base->Derived` and `derived->Base`).
|
||||
- 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.
|
||||
@@ -753,27 +732,25 @@ if (ImGui::DragFloat3("Position", pos, 0.1f))
|
||||
|
||||
### Phase 3: In-Place Deserialization & Serialization Pipeline
|
||||
1. In `Entity.cpp`:
|
||||
- Implement `EntitySerialize(archive& ar, Entity* entity)`.
|
||||
- Fix the existing reversed `if (ar.loading)` branch.
|
||||
- `serialize(Archive& ar, NonNullPtr<Entity> entity)` handles both base and derived class serialization.
|
||||
2. In `World.cpp`:
|
||||
- Implement `SerializeEntityAsset(archive& ar, Entity* entity, String filepath)`.
|
||||
- Implement `DeserializeEntityAsset(EntityManager& manager, archive& ar, String filepath)`.
|
||||
- Stream derived properties directly using `classPtr->serialize_fct(&ar, entity->Derived)`.
|
||||
- Implement `serialize_entity_asset(Archive& ar, NonNullPtr<Entity> entity, String filepath)`.
|
||||
- Implement `deserialize_entity_asset(EntityManager& manager, Archive& ar, String filepath)`.
|
||||
|
||||
### Phase 4: World Save/Load Pipeline & Disk Deletion
|
||||
1. In `World.cpp`:
|
||||
- Implement `ProcessPendingDeletions(World& world, NonNullPtr<Arena> scratchArena)`.
|
||||
- Implement `SaveWorld(World& world, String worldDirectory)`:
|
||||
- Process pending deletions.
|
||||
- Iterate `manager.Entities`, skipping entities where `!entity.IsDirty`.
|
||||
- Write dirty entities to `.jasset` files and clear `IsDirty`.
|
||||
- Iterate `manager.Entities`, skipping entities where `!entity.is_dirty`.
|
||||
- Write dirty entities to `.jasset` files and clear `is_dirty`.
|
||||
- Implement `LoadWorld(World& world, String worldDirectory)`:
|
||||
- Enumerate `.jasset` files in directory.
|
||||
- Call `DeserializeEntityAsset` for each file.
|
||||
- Call `deserialize_entity_asset` for each file.
|
||||
|
||||
### Phase 5: Editor Integration
|
||||
1. In `RenderWorldEditorUI`:
|
||||
- Hook `ImGui::DragFloat3` and property inspectors to set `IsDirty = true`.
|
||||
- Hook `ImGui::DragFloat3` and property inspectors to set `is_dirty = true`.
|
||||
- Hook "Add Entity" button to call `MakeEntity<Inert>(*world.EntityManager, 0.0f, 0.0f, 0.0f)`.
|
||||
- Hook "Delete Entity" button to call `RemoveWorldEntity(world, selectedEntityId)`.
|
||||
|
||||
@@ -824,26 +801,26 @@ namespace UnitTest
|
||||
EntityManager& manager = *testWorld.EntityManager;
|
||||
|
||||
// 1. Allocate Inert Entity via AllocateEntity
|
||||
Entity* baseEntity = AllocateEntity(manager, Inert::Kind);
|
||||
Assert(baseEntity != nullptr);
|
||||
Assert(baseEntity->ID > 0);
|
||||
Assert(baseEntity->Kind == Inert::Kind);
|
||||
Assert(baseEntity->Derived != nullptr);
|
||||
Assert(baseEntity->IsDirty == true);
|
||||
Entity* base_entity = AllocateEntity(manager, Inert::kind);
|
||||
Assert(base_entity != nullptr);
|
||||
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<entity_template*>(baseEntity->Derived);
|
||||
Assert(derived->Base == baseEntity);
|
||||
auto* derived = reinterpret_cast<entity_template*>(base_entity->derived);
|
||||
Assert(derived->base == base_entity);
|
||||
|
||||
// 3. DownCast verification
|
||||
Inert* inert = DownCast<Inert>(baseEntity);
|
||||
Inert* inert = DownCast<Inert>(base_entity);
|
||||
Assert(inert != nullptr);
|
||||
Assert(inert->Base == baseEntity);
|
||||
Assert(inert->base == base_entity);
|
||||
|
||||
// 4. Validate typed array tracking
|
||||
typed_entity_array& inertArray = manager.by_type[ENTITY(Inert)];
|
||||
Assert(inertArray.count == 1);
|
||||
Assert(inertArray.array == derived);
|
||||
typed_entity_array& inert_array = manager.by_type[ENTITY(Inert)];
|
||||
Assert(inert_array.count == 1);
|
||||
Assert(inert_array.array == derived);
|
||||
|
||||
ShutdownEntityManager();
|
||||
ShutdownWorld(&testWorld);
|
||||
@@ -864,50 +841,39 @@ namespace UnitTest
|
||||
EntityManager& manager = *testWorld.EntityManager;
|
||||
|
||||
// 1. Create and populate entity
|
||||
Inert* createdInert = MakeEntity<Inert>(manager, 12.5f, -44.0f, 108.2f);
|
||||
Assert(createdInert != nullptr);
|
||||
createdInert->MeshInstance = 42;
|
||||
Inert* created_inert = MakeEntity<Inert>(manager, 12.5f, -44.0f, 108.2f);
|
||||
Assert(created_inert != nullptr);
|
||||
created_inert->MeshInstance = 42;
|
||||
|
||||
Entity* originalBase = createdInert->Base;
|
||||
EntityID originalID = originalBase->ID;
|
||||
Entity* original_base = created_inert->base;
|
||||
EntityID original_id = original_base->ID;
|
||||
|
||||
// 2. Serialize to memory archive
|
||||
archive saveAr{ .arena = tempArena.Arena, .base_ptr = nullptr, .offset = 0, .loading = false };
|
||||
saveAr.base_ptr = ArenaPushArray<uint8>(tempArena.Arena, Kilobytes(16));
|
||||
|
||||
EntityFileHeader header{
|
||||
.Magic = kEntityAssetMagic,
|
||||
.Version = kEntityAssetVersion,
|
||||
.ClassCRC = originalBase->Kind->CRC,
|
||||
.Kind = originalBase->Kind->kind,
|
||||
.EntityID = originalBase->ID,
|
||||
.PositionX = originalBase->X,
|
||||
.PositionY = originalBase->Y,
|
||||
.PositionZ = originalBase->Z,
|
||||
.PayloadSize = sizeof(index_t)
|
||||
};
|
||||
serialize_elem(saveAr, header);
|
||||
serialize_elem(saveAr, createdInert->MeshInstance);
|
||||
// 2. Serialize to text archive memory stream
|
||||
MemoryStream mem_stream = MakeMemoryStream(tempArena.Arena);
|
||||
Archive save_ar{ .arena = tempArena.Arena, .loading = false, .stream = &mem_stream };
|
||||
serialize(save_ar, NonNullPtr<Entity>(original_base));
|
||||
|
||||
// 3. Clear manager to simulate fresh load
|
||||
ShutdownEntityManager();
|
||||
InitEntityManager(&testWorld);
|
||||
EntityManager& freshManager = *testWorld.EntityManager;
|
||||
EntityManager& fresh_manager = *testWorld.EntityManager;
|
||||
|
||||
// 4. Deserialize in-place
|
||||
archive loadAr{ .arena = tempArena.Arena, .base_ptr = saveAr.base_ptr, .offset = 0, .loading = true };
|
||||
Entity* loadedBase = DeserializeEntityInPlace(freshManager, loadAr);
|
||||
// 4. Tokenize and deserialize in-place
|
||||
Archive load_ar{ .arena = tempArena.Arena, .loading = true };
|
||||
tokenize_archive(tempArena.Arena, mem_stream.buffer, &load_ar.base);
|
||||
Entity* loaded_base = deserialize_entity_in_place(fresh_manager, load_ar);
|
||||
|
||||
Assert(loadedBase != nullptr);
|
||||
Assert(loadedBase->ID == originalID);
|
||||
Assert(loadedBase->X == 12.5f);
|
||||
Assert(loadedBase->Y == -44.0f);
|
||||
Assert(loadedBase->Z == 108.2f);
|
||||
Assert(loadedBase->IsDirty == false);
|
||||
Assert(loaded_base != nullptr);
|
||||
Assert(loaded_base->ID == original_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* loadedInert = DownCast<Inert>(loadedBase);
|
||||
Assert(loadedInert != nullptr);
|
||||
Assert(loadedInert->Base == loadedBase);
|
||||
Inert* loaded_inert = DownCast<Inert>(loaded_base);
|
||||
Assert(loaded_inert != nullptr);
|
||||
Assert(loaded_inert->base == loaded_base);
|
||||
Assert(loaded_inert->MeshInstance == 42);
|
||||
|
||||
ShutdownEntityManager();
|
||||
ShutdownWorld(&testWorld);
|
||||
@@ -932,9 +898,9 @@ namespace UnitTest
|
||||
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;
|
||||
EntityID id0 = e0->base->ID;
|
||||
EntityID id1 = e1->base->ID;
|
||||
EntityID id2 = e2->base->ID;
|
||||
|
||||
Assert(manager.Entities.Size() == 3);
|
||||
|
||||
@@ -950,11 +916,11 @@ namespace UnitTest
|
||||
Assert(remaining0->ID == id0);
|
||||
Assert(remaining1->ID == id2);
|
||||
|
||||
auto* derived0 = reinterpret_cast<entity_template*>(remaining0->Derived);
|
||||
auto* derived1 = reinterpret_cast<entity_template*>(remaining1->Derived);
|
||||
auto* derived0 = reinterpret_cast<entity_template*>(remaining0->derived);
|
||||
auto* derived1 = reinterpret_cast<entity_template*>(remaining1->derived);
|
||||
|
||||
Assert(derived0->Base == remaining0);
|
||||
Assert(derived1->Base == remaining1);
|
||||
Assert(derived0->base == remaining0);
|
||||
Assert(derived1->base == remaining1);
|
||||
|
||||
ShutdownEntityManager();
|
||||
ShutdownWorld(&testWorld);
|
||||
@@ -974,17 +940,17 @@ namespace UnitTest
|
||||
InitEntityManager(&testWorld);
|
||||
EntityManager& manager = *testWorld.EntityManager;
|
||||
|
||||
Entity* entity = AllocateEntity(manager, Inert::Kind);
|
||||
Assert(entity->IsDirty == true);
|
||||
Entity* entity = AllocateEntity(manager, Inert::kind);
|
||||
Assert(entity->is_dirty == true);
|
||||
|
||||
// Simulate save
|
||||
entity->IsDirty = false;
|
||||
Assert(entity->IsDirty == false);
|
||||
entity->is_dirty = false;
|
||||
Assert(entity->is_dirty == false);
|
||||
|
||||
// Simulate mutation
|
||||
entity->X += 1.0f;
|
||||
entity->IsDirty = true;
|
||||
Assert(entity->IsDirty == true);
|
||||
entity->position.x += 1.0f;
|
||||
entity->is_dirty = true;
|
||||
Assert(entity->is_dirty == true);
|
||||
|
||||
ShutdownEntityManager();
|
||||
ShutdownWorld(&testWorld);
|
||||
|
||||
@@ -654,10 +654,8 @@ To guarantee robust loading regardless of traversal order:
|
||||
#### Phase 1: Allocation, Deserialization, and ID Registration
|
||||
1. For each `.jasset` file found in `Entities/`:
|
||||
- Read the file buffer via `LoadFile`.
|
||||
- Validate `EntityFileHeader` (Magic `kEntityMagic`, Version `kEntityVersion`).
|
||||
- Push an `Entity` record into `EntityManager::Entities`.
|
||||
- Allocate the type-specific memory block in `EntityManager::by_type[Kind].arena`.
|
||||
- Copy base transform values (`X, Y, Z`) and invoke `Class::serialize_fct`.
|
||||
- Tokenize text properties via `tokenize_archive`.
|
||||
- Call `deserialize_entity_in_place(entityManager, ar)` which reads `; class`, invokes `AllocateEntity`, and streams base and derived fields in-place.
|
||||
- All entity references are left as raw `EntityID` values (or embedded within components).
|
||||
- Register the entity into a temporary lookup table: `EntityID -> Entity*`.
|
||||
- Call `ObserveEntityIDForCounterContinuity` to maintain the high-water mark.
|
||||
@@ -699,72 +697,38 @@ void EntityFileDiscoveryCallback(String filename, String fullPath, bool isDirect
|
||||
TempArena scratch = scratch_begin(0, 0);
|
||||
|
||||
ByteBuffer fileBuffer = LoadFile(scratch.Arena, fullPath);
|
||||
if (fileBuffer.Size < sizeof(EntityFileHeader))
|
||||
if (fileBuffer.Size == 0)
|
||||
{
|
||||
Log(LogLevel::Error, LogCategory::Game, "LoadWorld: Entity file '%s' corrupted (too small)", CStr(filename));
|
||||
Log(LogLevel::Error, LogCategory::Game, "LoadWorld: Entity file '%s' is empty or missing", CStr(filename));
|
||||
scratch_end(scratch);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto* header = reinterpret_cast<const EntityFileHeader*>(fileBuffer.Data);
|
||||
if (header->Magic != 0x544E454A || header->Version != 1)
|
||||
{
|
||||
Log(LogLevel::Error, LogCategory::Game, "LoadWorld: File '%s' has invalid header", CStr(filename));
|
||||
scratch_end(scratch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 1: Allocate & deserialize
|
||||
Entity baseEntity = {};
|
||||
baseEntity.ID = header->ID;
|
||||
baseEntity.Kind = kEntity_type_class_ptr[header->Kind];
|
||||
Assert(baseEntity.Kind != nullptr);
|
||||
|
||||
archive ar = {
|
||||
Archive ar = {
|
||||
.arena = scratch.Arena,
|
||||
.base_ptr = fileBuffer.Data + sizeof(EntityFileHeader),
|
||||
.offset = 0,
|
||||
.loading = true
|
||||
};
|
||||
|
||||
// Deserialize base entity properties
|
||||
serialize_elem(ar, baseEntity.X);
|
||||
serialize_elem(ar, baseEntity.Y);
|
||||
serialize_elem(ar, baseEntity.Z);
|
||||
|
||||
// Register into EntityManager
|
||||
entityManager.Entities.PushBack(baseEntity);
|
||||
Entity* registeredBase = entityManager.Entities.Back();
|
||||
|
||||
// Allocate derived type payload
|
||||
auto* derivedPtr = static_cast<entity_template*>(ArenaPushSize(
|
||||
entityManager.ByType[header->Kind].arena,
|
||||
baseEntity.Kind->size_of,
|
||||
baseEntity.Kind->alignment,
|
||||
false JULIET_DEBUG_PARAM(kEntity_type_names[header->Kind])
|
||||
));
|
||||
Assert(derivedPtr != nullptr);
|
||||
|
||||
// Deserialize derived data if class registered a serialize function
|
||||
if (baseEntity.Kind->serialize_fct != nullptr)
|
||||
if (!tokenize_archive(scratch.Arena, fileBuffer, &ar.base))
|
||||
{
|
||||
baseEntity.Kind->serialize_fct(ar, derivedPtr);
|
||||
Log(LogLevel::Error, LogCategory::Game, "LoadWorld: Entity file '%s' failed to tokenize", CStr(filename));
|
||||
scratch_end(scratch);
|
||||
return;
|
||||
}
|
||||
|
||||
derivedPtr->Base = registeredBase;
|
||||
registeredBase->Derived = derivedPtr;
|
||||
|
||||
if (entityManager.ByType[header->Kind].array == nullptr)
|
||||
// Phase 1: Allocate & deserialize in-place
|
||||
Entity* registeredBase = deserialize_entity_in_place(entityManager, ar);
|
||||
if (registeredBase == nullptr)
|
||||
{
|
||||
entityManager.ByType[header->Kind].array = derivedPtr;
|
||||
Log(LogLevel::Error, LogCategory::Game, "LoadWorld: Entity file '%s' failed to deserialize", CStr(filename));
|
||||
scratch_end(scratch);
|
||||
return;
|
||||
}
|
||||
entityManager.ByType[header->Kind].count += 1;
|
||||
|
||||
// Track in temporary lookup table for Phase 2
|
||||
context->LookupTable.PushBack({ .ID = header->ID, .EntityPtr = registeredBase });
|
||||
context->LookupTable.PushBack({ .ID = registeredBase->ID, .EntityPtr = registeredBase });
|
||||
|
||||
// Update session counter continuity
|
||||
ObserveEntityIDForCounterContinuity(entityManager, header->ID);
|
||||
ObserveEntityIDForCounterContinuity(entityManager, registeredBase->ID);
|
||||
|
||||
scratch_end(scratch);
|
||||
}
|
||||
@@ -838,15 +802,15 @@ void PostLoadWorld(World& world, const WorldLoadContext& context)
|
||||
String settingsPath = { settingsPathBuf, settingsPathLen - 1 };
|
||||
|
||||
ByteBuffer settingsBuffer = LoadFile(scratch.Arena, settingsPath);
|
||||
if (settingsBuffer.Size >= sizeof(WorldSettingsFileHeader))
|
||||
if (settingsBuffer.Size > 0)
|
||||
{
|
||||
const auto* header = reinterpret_cast<const WorldSettingsFileHeader*>(settingsBuffer.Data);
|
||||
if (header->Magic == 0x5453574A && header->Version == 1)
|
||||
Archive settingsAr = {
|
||||
.arena = scratch.Arena,
|
||||
.loading = true
|
||||
};
|
||||
if (tokenize_archive(scratch.Arena, settingsBuffer, &settingsAr.base))
|
||||
{
|
||||
const auto* settings = reinterpret_cast<const WorldEnvironmentSettings*>(
|
||||
settingsBuffer.Data + sizeof(WorldSettingsFileHeader)
|
||||
);
|
||||
world.Environment = *settings;
|
||||
serialize(settingsAr, world.Environment);
|
||||
Log(LogLevel::Message, LogCategory::Game, "LoadWorld: Loaded WorldSettings from %s", CStr(settingsPath));
|
||||
}
|
||||
}
|
||||
@@ -912,9 +876,8 @@ graph TD
|
||||
- `[NEW]` [EntityFileIO.h](file:///w:/Classified/Juliet/Game/Entity/EntityFileIO.h)
|
||||
- `[NEW]` [EntityFileIO.cpp](file:///w:/Classified/Juliet/Game/Entity/EntityFileIO.cpp)
|
||||
- **Deliverables**:
|
||||
- Define `WorldSettingsFileHeader` ('JWST') and `EntityFileHeader` ('JENT').
|
||||
- Implement `AtomicWriteEntityFile` using `.tmp` and Win32 `MoveFileExA`.
|
||||
- Implement serialization routines for `WorldEnvironmentSettings`.
|
||||
- Implement text serialization routines for `WorldEnvironmentSettings` and entity `.jasset` files.
|
||||
|
||||
### Phase 4: Two-Phase Load Pipeline & EntityManager Refactoring
|
||||
- **Files**:
|
||||
|
||||
@@ -206,13 +206,13 @@ When `GetOrLoadTemplate` is invoked with a relative path:
|
||||
6. **Default Base Setup**: Initialize a local `Entity defaultBase`:
|
||||
```cpp
|
||||
Entity defaultBase = {};
|
||||
defaultBase.Kind = entityClass;
|
||||
defaultBase.Derived = archetypeMem;
|
||||
defaultBase.derived_kind = entityClass;
|
||||
defaultBase.derived = archetypeMem;
|
||||
```
|
||||
7. **Back-Pointer Linking**: Set `entity_template::Base` in the archetype memory:
|
||||
7. **Back-Pointer Linking**: Set `entity_template::base` in the archetype memory:
|
||||
```cpp
|
||||
auto* archetypeTemplate = reinterpret_cast<entity_template*>(archetypeMem);
|
||||
archetypeTemplate->Base = &cachedEntry->DefaultBase;
|
||||
archetypeTemplate->base = &cachedEntry->DefaultBase;
|
||||
```
|
||||
8. **KV Property Parsing**: Parse all key-value pairs in the template `.jasset` file and write their deserialized values directly into `archetypeMem` and `defaultBase`.
|
||||
9. **Cache Insertion**: Store the fully baked `CachedTemplate` record in `cache->Templates`.
|
||||
@@ -228,21 +228,22 @@ In Juliet, an entity is split into two tightly coupled structures:
|
||||
```cpp
|
||||
struct Entity final
|
||||
{
|
||||
DECLARE_CLASS()
|
||||
|
||||
EntityID ID = 0;
|
||||
Class* Kind = nullptr;
|
||||
DerivedType Derived = nullptr; // Points to the specialized struct
|
||||
float X = 0.0f;
|
||||
float Y = 0.0f;
|
||||
float Z = 0.0f;
|
||||
Class* derived_kind = nullptr;
|
||||
DerivedType derived = nullptr; // Points to the specialized struct
|
||||
Vector4 position = {};
|
||||
bool is_dirty = false;
|
||||
};
|
||||
```
|
||||
2. **`Derived` (Specialized Type)**: e.g., `Inert`, `Collectible`, `Player`. The first member is always `DECLARE_ENTITY()`, which expands to:
|
||||
```cpp
|
||||
Entity* Base; // Back-pointer to the base Entity
|
||||
static Class* Kind;
|
||||
Entity* base; // Back-pointer to the base Entity
|
||||
DECLARE_CLASS() // static Class* kind;
|
||||
```
|
||||
|
||||
Because `DerivedType` stores a back-pointer (`Base`) to `Entity`, **a shallow memory copy of an archetype invalidates this pointer**! The loading pipeline must explicitly restore this invariant.
|
||||
Because `DerivedType` stores a back-pointer (`base`) to `Entity`, **a shallow memory copy of an archetype invalidates this pointer**! The loading pipeline must explicitly restore this invariant.
|
||||
|
||||
### 4.2 The 5-Step Instantiation Pipeline
|
||||
When an entity instance is spawned or deserialized from a `.jasset` file, the engine executes this strict 5-step sequence:
|
||||
@@ -400,12 +401,12 @@ void ApplyKvDeltaOverrides(Entity* base, void* derived, Class* cls, String kvCon
|
||||
}
|
||||
|
||||
// Check base properties first
|
||||
if (StringCompare(pair.Key, ConstString("Position")) == 0)
|
||||
if (StringCompare(pair.Key, ConstString("Position")) == 0 || StringCompare(pair.Key, ConstString("position")) == 0)
|
||||
{
|
||||
Vector3 pos = ParseVector3(pair.Value);
|
||||
base->X = pos.X;
|
||||
base->Y = pos.Y;
|
||||
base->Z = pos.Z;
|
||||
base->position.x = pos.x;
|
||||
base->position.y = pos.y;
|
||||
base->position.z = pos.z;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -483,8 +484,8 @@ bool CreateTemplateFromEntity(World& world,
|
||||
Assert(entityIndex < manager.Entities.Size());
|
||||
|
||||
Entity* sourceEntity = &manager.Entities[entityIndex];
|
||||
Class* entityKind = sourceEntity->Kind;
|
||||
void* derivedMem = sourceEntity->Derived;
|
||||
Class* entityKind = sourceEntity->derived_kind;
|
||||
void* derivedMem = sourceEntity->derived;
|
||||
|
||||
// Format destination template path
|
||||
String templatePath = Format(scratchArena, "Assets/Templates/{}.jasset", CStr(templateName));
|
||||
@@ -561,11 +562,11 @@ void RenderEntityPropertyInspector(Entity* entity, CachedTemplate* archetype)
|
||||
}
|
||||
|
||||
// Iterate through properties
|
||||
Class* cls = entity->Kind;
|
||||
Class* cls = entity->derived_kind;
|
||||
for (size_t i = 0; i < cls->PropertyCount; ++i)
|
||||
{
|
||||
const PropertyDescriptor& prop = cls->Properties[i];
|
||||
void* instanceField = static_cast<uint8*>(entity->Derived) + prop.Offset;
|
||||
void* instanceField = static_cast<uint8*>(entity->derived) + prop.Offset;
|
||||
void* templateField = isTemplated ? (static_cast<uint8*>(archetype->DefaultDerivedMemory) + prop.Offset) : nullptr;
|
||||
|
||||
const bool isOverridden = isTemplated && (MemCompare(instanceField, templateField, GetPropertySize(prop.Type)) != 0);
|
||||
@@ -605,10 +606,10 @@ Reverting an entire entity to its template archetype restores all properties whi
|
||||
```cpp
|
||||
void RevertEntityToTemplate(NonNullPtr<Entity> instance, NonNullPtr<const CachedTemplate> archetype)
|
||||
{
|
||||
Assert(instance->Kind == archetype->EntityKind);
|
||||
Assert(instance->derived_kind == archetype->EntityKind);
|
||||
Assert(archetype->DefaultDerivedMemory != nullptr);
|
||||
|
||||
void* derivedMem = instance->Derived;
|
||||
void* derivedMem = instance->derived;
|
||||
const size_t derivedSize = archetype->EntityKind->size_of;
|
||||
|
||||
// Preserve the current Base pointer
|
||||
@@ -619,16 +620,16 @@ void RevertEntityToTemplate(NonNullPtr<Entity> instance, NonNullPtr<const Cached
|
||||
|
||||
// 2. Re-establish the Base back-pointer
|
||||
auto* templateDerived = reinterpret_cast<entity_template*>(derivedMem);
|
||||
templateDerived->Base = basePtr;
|
||||
templateDerived->base = basePtr;
|
||||
|
||||
// 3. Mark visual / physics state as updated
|
||||
if (instance->Kind->kind == ENTITY(Inert))
|
||||
if (instance->derived_kind->kind == ENTITY(Inert))
|
||||
{
|
||||
auto* inert = reinterpret_cast<Inert*>(derivedMem);
|
||||
if (inert->MeshInstance != indexMax)
|
||||
{
|
||||
SetMeshInstanceTransform(inert->MeshInstance,
|
||||
MatrixTranslation(basePtr->X, basePtr->Y, basePtr->Z));
|
||||
MatrixTranslation(basePtr->position.x, basePtr->position.y, basePtr->position.z));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -844,17 +845,17 @@ namespace UnitTest
|
||||
Assert(instance != nullptr);
|
||||
|
||||
// Verify Step 1 & 2: Base spatial delta applied, non-overridden derived property retained
|
||||
Assert(instance->X == 100.0f);
|
||||
Assert(instance->Y == 200.0f);
|
||||
Assert(instance->Z == 300.0f);
|
||||
Assert(instance->position.x == 100.0f);
|
||||
Assert(instance->position.y == 200.0f);
|
||||
Assert(instance->position.z == 300.0f);
|
||||
|
||||
auto* inertDerived = DownCast<Inert>(instance);
|
||||
Assert(inertDerived != nullptr);
|
||||
Assert(inertDerived->MeshInstance == 42); // Retained from template!
|
||||
|
||||
// Verify Step 3: CRITICAL back-pointer fixup check
|
||||
Assert(inertDerived->Base == instance);
|
||||
Assert(instance->Derived == inertDerived);
|
||||
Assert(inertDerived->base == instance);
|
||||
Assert(instance->derived == inertDerived);
|
||||
|
||||
ShutdownEntityManager();
|
||||
ShutdownWorld(&world);
|
||||
@@ -900,12 +901,12 @@ namespace UnitTest
|
||||
Assert(inertDerived->MeshInstance == 100);
|
||||
|
||||
// Verify world position is preserved across revert
|
||||
Assert(instance->X == 5.0f);
|
||||
Assert(instance->Y == 5.0f);
|
||||
Assert(instance->Z == 5.0f);
|
||||
Assert(instance->position.x == 5.0f);
|
||||
Assert(instance->position.y == 5.0f);
|
||||
Assert(instance->position.z == 5.0f);
|
||||
|
||||
// Verify back-pointer invariant preserved after revert
|
||||
Assert(inertDerived->Base == instance);
|
||||
Assert(inertDerived->base == instance);
|
||||
|
||||
ShutdownEntityManager();
|
||||
ShutdownWorld(&world);
|
||||
|
||||
Reference in New Issue
Block a user