diff --git a/Game/Plans/01_Serialization_And_Text_Archive.md b/Game/Plans/01_Serialization_And_Text_Archive.md index 0654dce..f611e05 100644 --- a/Game/Plans/01_Serialization_And_Text_Archive.md +++ b/Game/Plans/01_Serialization_And_Text_Archive.md @@ -1,31 +1,27 @@ -# Juliet Game Engine: Serialization Core & .jasset Text Archive -## Technical Specification & Implementation Plan +# Juliet Engine: Serialization & Text Archive Architecture -**Document ID**: SPEC-001-SERIALIZATION-CORE -**Component**: Juliet Engine Core / Asset Pipeline -**Target Architecture**: Juliet Game Engine (C++20, x64, D3D12) -**File Location**: `Game/Plans/01_Serialization_And_Text_Archive.md` +## Technical Specification & Architectural Design --- ## 1. Executive Summary & Architecture Goals -### 1.1 Context & Current Limitations -The Juliet game engine previously utilized packed binary records for level and entity serialization (`Assets/world.bin`, `WorldFileHeader`, `WorldEntityDiskRecord`). While packed binary formats are compact, they present severe architectural roadblocks during collaborative development: -1. **Merge Incompatibility**: Binary assets cannot be merged or diffed across version control systems (Git / Perforce). Concurrent edits by multiple designers or engineers result in unresolvable binary conflicts and data loss. -2. **Schema Rigidity**: Adding, removing, or reordering a single struct field invalidates all previously serialized binary blobs unless complex, manual byte-offset mapping tables are maintained. -3. **Opacity**: Programmers and technical artists cannot inspect, debug, or patch asset properties in a standard text editor. +### 1.1 Context & Motivation +Juliet historically relied on monolithic, packed binary blobs for world and entity persistence. While fast to read as raw byte offsets, binary serialization suffers from three major flaws: +1. **Merge Incompatibility**: Binary assets cannot be merged or diffed in version control systems (Git / Perforce), causing unresolvable binary conflicts and data loss. +2. **Schema Rigidity**: Adding, removing, or reordering a single struct field invalidates all existing binary files unless complex manual byte-offset mapping tables are maintained. +3. **Opacity**: Designers and engineers cannot inspect, debug, or patch asset properties in a standard text editor. ### 1.2 Architectural Goals -The `.jasset` text archive framework is engineered to replace legacy binary blobs with a robust, human-readable, diff-friendly property serialization pipeline while strictly adhering to Juliet's performance and memory constraints: -- **Zero Dynamic Heap Allocations**: All parsing, tokenization, formatting, and buffer transformations execute entirely within Juliet memory arenas (`Arena`, `TempArena`, `scratch_begin` / `scratch_end`). Standard library containers (`std::string`, `std::vector`, `std::map`) and raw heap allocators (`malloc`, `new`) are forbidden. -- **Zero-Copy In-Memory Tokenization**: Files are loaded into arena memory once via `LoadFile`. The tokenizer parses properties into lightweight slices represented by Juliet's `String` (`char* Str; size_t Size;`), referencing existing file buffer bytes without string replication or null-termination overhead. -- **$O(1)$ Property Lookup via Compile-Time & Runtime CRC32**: Property keys in text files are hashed once during tokenization into 32-bit CRC values. In code, lookups utilize compile-time hashed literals (`operator""_crc32`). Property resolution reduces to a single 32-bit integer comparison, avoiding string comparison overhead in serialization loops. -- **Symmetric Single-Function Serialization**: A single `Serialize` implementation per entity or data structure handles both Save and Load paths, guaranteeing that write and read schemas never diverge. +The `.jasset` text archive framework replaces legacy binary blobs with a human-readable, diff-friendly property serialization pipeline adhering to Juliet's systems programming principles: +- **Zero Dynamic Heap Allocations**: Parsing, tokenization, formatting, and buffer transformations execute entirely within Juliet memory arenas (`Arena`, `TempArena`, `scratch_begin` / `scratch_end`). Standard library containers (`std::string`, `std::vector`) and raw heap allocators (`malloc`, `new`) are forbidden. +- **Zero-Copy In-Memory Tokenization**: Files are loaded into arena memory once via `LoadFile`. The tokenizer parses properties into lightweight slices represented by Juliet's `String` (`char* Str; size_t Size;`), referencing existing file buffer bytes without string duplication. +- **$O(1)$ Property Lookup via Compile-Time & Runtime CRC32**: Property keys in text files are hashed once during tokenization into 32-bit CRC values. In code, lookups utilize compile-time hashed literals (`operator""_crc32`). Property resolution is a single 32-bit integer comparison. +- **Symmetric Single-Function Serialization**: A single `serialize` implementation per entity or data structure handles both Save and Load paths, guaranteeing read and write schemas never diverge. - **Graceful Forward/Backward Compatibility**: Missing keys during load automatically preserve struct default values. Unknown keys present in newer asset files are safely ignored without parse failure. -- **Two-Tier Decoupled Versioning**: Core engine entity properties (`kEntityBaseVersion`) and derived gameplay class properties (`Class::Version`) are versioned independently. Engine-level updates never bump derived entity class versions. -- **In-Place Schema Migration**: Deprecated properties no longer present in modern structs can be read during loading into local stack variables using standard serialization (`serialize` / `SERIALIZE`) guarded by the class serializer's version parameter (`if (ar.loading && version < N)`), seamlessly transforming legacy values without struct pollution or persisting obsolete keys on subsequent saves. -- **Zero Exceptions & Total Warning Cleanliness**: Fully conforming to Juliet coding guidelines: strict assertions (`Assert`), `[[nodiscard]]`, `auto*`/`auto&`, mandatory braces, and `static_cast`/`reinterpret_cast`. +- **Two-Tier Decoupled Versioning via Generalized `Class`**: Core engine entity properties (`; version`) and derived gameplay class properties (`; class_version`) are versioned independently through their respective `Class` descriptors. +- **In-Place Schema Migration**: Deprecated fields no longer present in C++ structs are read into temporary stack variables during load using standard `SERIALIZE` calls guarded by `if (ar.loading && version < N)`, seamlessly converting legacy data without struct pollution or persisting obsolete keys on subsequent saves. +- **Clean Warning-Free C++**: Fully conforming to Juliet coding guidelines: strict assertions (`Assert`), `[[nodiscard]]`, `auto*`/`auto&`, mandatory braces, and `static_cast`/`reinterpret_cast`. --- @@ -35,1253 +31,403 @@ The `.jasset` text archive framework is engineered to replace legacy binary blob The `.jasset` format uses a line-oriented, key-value property hierarchy designed for visual clarity and clean Git diffs. ```ebnf -AssetFile ::= { CommentLine | EmptyLine | PropertyDeclaration } ; -CommentLine ::= ( "#" | "//" ) { Character } LineEnding ; -EmptyLine ::= { Whitespace } LineEnding ; -PropertyDeclaration ::= KeyHeader LineEnding ValueBlock ; -KeyHeader ::= ";" { Whitespace } Identifier ; -ValueBlock ::= { ValueLine LineEnding } ; -ValueLine ::= { Whitespace } ValueString { Whitespace } ; -LineEnding ::= "\r\n" | "\n" ; -Identifier ::= [a-zA-Z_][a-zA-Z0-9_]* ; +AssetFile ::= { CommentLine | EmptyLine | PropertyDeclaration } ; +CommentLine ::= ( "#" | "//" ) { Character } LineEnding ; +EmptyLine ::= { Whitespace } LineEnding ; +PropertyDeclaration ::= KeyHeader LineEnding ValueBlock ; +KeyHeader ::= ";" { Whitespace } Identifier ; +ValueBlock ::= { ValueLine LineEnding } ; +ValueLine ::= { Whitespace } ValueString { Whitespace } ; +LineEnding ::= "\r\n" | "\n" ; +Identifier ::= [a-zA-Z_][a-zA-Z0-9_]* ; ``` -1. **Property Key Header (`;\n`)**: - - Every property declaration begins with a semicolon `;` as the first non-whitespace character. - - The semicolon is followed by optional whitespace and a CamelCase identifier: `; PropertyName`. - - Keys are case-sensitive and must be valid C++ identifier tokens. -2. **Value Block**: - - The line immediately following the property header contains the property's serialized payload. - - For multi-line values or arrays, lines continue until the next property header `;` or end-of-file. -3. **Comments & Ignored Tokens**: - - Any line starting with `#` or `//` (after optional leading whitespace) is treated as a comment. - - Empty lines and lines containing only whitespace are ignored. -4. **Line Termination Handling**: - - The parser natively accepts both Windows (`\r\n`) and POSIX (`\n`) line endings. - - Trailing carriage returns (`\r`) are automatically stripped during tokenization. -5. **Whitespace Tolerance**: - - Leading and trailing spaces and horizontal tabs (`\t`) on both key and value lines are stripped during tokenization. +#### Formatting Rules: +1. **Key Declarations**: A property begins with a semicolon `;` followed by optional whitespace and a case-sensitive identifier (e.g. `; position`). +2. **Value Blocks**: The line(s) immediately following a key header contain its value payload. +3. **Comments**: Any line whose first non-whitespace character is `#` or `//` is treated as a comment and ignored. Inline comments on property lines are forbidden. +4. **Whitespace**: Leading and trailing spaces or tabs on both keys and values are stripped during tokenization. +5. **Line Endings**: Both Windows CRLF (`\r\n`) and Linux LF (`\n`) are transparently accepted. -### 2.2 Formatting Specifications & Examples +### 2.2 Formatting Specifications #### Scalar Types -- **Floating Point (`float`, `float32`)**: Formatted via `%f` (default 6 decimal digits) or `%.9g` for full single-precision round-trip fidelity. - ``` - ; Health - 100.000000 - - ; Mass - 14.250000 - ``` -- **32-Bit Signed Integer (`int32`)**: Formatted as decimal integer `%d`. - ``` - ; AmmoCount - 45 - - ; TeamIndex - -1 - ``` -- **64-Bit Unsigned Integer (`uint64`, `EntityID`)**: Formatted as a 16-character padded hexadecimal integer prefixed with `0x`. Hexadecimal ensures 64-bit handle readability and exact bit-pattern preservation. - ``` - ; EntityID - 0x00000000DEADBEEF - - ; LayerMask - 0x0000000000000001 - ``` -- **Boolean (`bool`)**: Formatted as `true` or `false`. For backwards tolerance, the parser also accepts `1` and `0`. - ``` - ; IsStatic - true - - ; CastShadows - false - ``` - -#### Vector Types -- **3D Vector (`Vector3` / `float x, y, z`)**: Formatted as three space-delimited floating-point values on a single line. - ``` - ; Position - 10.500000 0.000000 -25.250000 - - ; Scale - 1.000000 1.000000 1.000000 - ``` -- **2D Vector (`Vector2` / `float x, y`)**: Formatted as two space-delimited floating-point values. - ``` - ; UVOffset - 0.000000 0.500000 - ``` - -#### String Types -- **String (`String` / `String8`)**: - - Strings without spaces can be serialized as raw text tokens. - - Strings containing spaces or symbols are enclosed in double quotes `"..."`. - ``` - ; AssetName - Character_Mesh_Hero - - ; DisplayName - "Grand Citadel Knight" - ``` - -#### Comprehensive Entity Asset File Example (`Entity_01.jasset`) +Scalars are formatted as decimal representations on a single line: ``` -# Juliet Game Engine Asset File -# Generated automatically by AssetPipeline. Do not manually corrupt keys. +; max_speed +180.500000 -; AssetType -Entity +; gear_count +6 -; Version -1 - -; EntityID -0x0000000000000042 - -; Kind -Inert - -; Position -100.000000 25.500000 -50.000000 - -; ClassVersion -2 - -; MeshInstance -4 - -; MaterialOverride -"Materials/M_Granite_Polished" - -; IsVisible +; turbo true ``` +- Floats: Output via `%.9g` or `%f`. +- Integers: Signed (`%d`, `%lld`) and unsigned (`%u`, `%llu`). +- Booleans: Case-insensitive `true` / `false` or `1` / `0`. + +#### Vector Types (`Vector4`) +Multi-component vectors are space-delimited on a single value line: +``` +; position +10.0 20.0 30.0 1.0 +``` + +#### String Types (`String`) +Strings containing spaces are wrapped in double quotes `"..."`. Quotes are automatically stripped upon loading and emitted during saving when spaces are present: +``` +; name +"Paladin Hero" +``` + +#### Asset File Example (`Entity_01.jasset`) +``` +# Juliet Entity Asset File +; version +1 + +; id +1001 + +; position +12.500000 0.000000 45.200000 1.000000 + +; class +Inert + +; class_version +1 + +; mesh_instance +42 +``` --- -## 3. The In-Memory Zero-Copy Parser +## 3. Zero-Copy Tokenization & Fast Property Lookup -### 3.1 Memory Layout & Property Nodes -To eliminate heap fragmentation and per-object allocation overhead during level loading, the parser loads the entire `.jasset` file into contiguous arena memory via `LoadFile`. The tokenizer then constructs a flat array of `TextPropertyNode` structures on a `TempArena`. +### 3.1 Data Structures (`Core/Common/serialization.h`) + +To eliminate heap fragmentation, the parser loads the entire `.jasset` file into contiguous arena memory and parses it into a flat array of lightweight slices. ```cpp -struct TextPropertyNode +struct ArchivePropertyNode { - uint32 KeyCRC; - String Value; - bool Consumed; + String key; + String value; + uint32 key_crc; + bool consumed; +}; + +struct ParsedArchive +{ + ArchivePropertyNode* nodes = nullptr; + uint32 property_count = 0; }; ``` -- `KeyCRC`: The 32-bit CRC hash of the trimmed property name. -- `Value`: A `String` (`String8`) struct containing a `char* Str` pointer directly into the file buffer and `size_t Size`. No new string allocations are performed. -- `Consumed`: A boolean flag initialized to `false`. When a property is queried and read via `SerializeProp`, `Consumed` is set to `true`. - -``` -File Buffer in Arena: -+-------------------------------------------------------------------------------+ -| ; Position\n10.0 20.0 30.0\n; Health\n100.0\n | -+-------------------------------------------------------------------------------+ - ^ ^ ^ ^ - | | | | -TextPropertyNode[0]: | | | - KeyCRC: CRC("Position") | | - Value: { Str --------+ , Size: 14 } | - Consumed: true | - | -TextPropertyNode[1]: | - KeyCRC: CRC("Health") | - Value: { Str -------------------------------+ , Size: 5 } - Consumed: true -``` - -### 3.2 Dual-Mode CRC32: Compile-Time & Runtime -Juliet's `CRC32.h` defines `consteval uint32 crc32(const char* str, size_t length)`. However, `consteval` guarantees compilation failure when called on runtime input (such as keys extracted from a text file during parsing). - -The engine requires a unified `Crc32` implementation that functions at runtime for parsed text tokens while retaining `constexpr` / `consteval` capability for compile-time string literals. +- `key`: Sliced `String` referencing the key name. +- `value`: Sliced `String` directly referencing file buffer bytes (zero-copy). +- `key_crc`: 32-bit CRC hash computed once during tokenization. +- `consumed`: Initialized to `false`. Set to `true` whenever queried by `find_property`. +### 3.2 Dual-Mode CRC32 (`Core/Common/CRC32.h`) +Lookups rely on compile-time string hashing via `constexpr` / `consteval`: ```cpp -// Core/Common/CRC32.h additions -[[nodiscard]] constexpr uint32 Crc32(const char* str, size_t length) -{ - Assert(str != nullptr || length == 0); - const char* p = str; - uint32 crc = ~0U; - while (length--) - { - crc = details::crc32_tab[(crc ^ static_cast(*p++)) & 0xFF] ^ (crc >> 8); - } - return crc ^ ~0U; -} - -[[nodiscard]] constexpr uint32 Crc32(String str) -{ - return Crc32(str.Str, str.Size); -} - -[[nodiscard]] consteval uint32 operator""_crc32(const char* str, size_t length) -{ - return Crc32(str, length); -} +[[nodiscard]] constexpr uint32 crc32(const char* str, size_t length); +[[nodiscard]] constexpr uint32 crc32(String str); +[[nodiscard]] consteval uint32 operator""_crc32(const char* str, size_t length); ``` -### 3.3 Zero-Copy Tokenization Algorithm -The tokenization algorithm scans the memory buffer in a single pass. It first counts property keys to allocate the exact array size on the arena, then populates the `TextPropertyNode` array. - +### 3.3 Tokenization API (`tokenize_archive`) ```cpp -[[nodiscard]] inline String TrimWhitespace(String str) -{ - while (str.Size > 0 && (*str.Str == ' ' || *str.Str == '\t' || *str.Str == '\r' || *str.Str == '\n')) - { - str.Str++; - str.Size--; - } - while (str.Size > 0 && (str.Str[str.Size - 1] == ' ' || str.Str[str.Size - 1] == '\t' || - str.Str[str.Size - 1] == '\r' || str.Str[str.Size - 1] == '\n')) - { - str.Size--; - } - return str; -} - -struct ParsedTextArchive -{ - TextPropertyNode* Nodes = nullptr; - uint32 PropertyCount = 0; -}; - -[[nodiscard]] ParsedTextArchive TokenizeTextArchive(NonNullPtr arena, ByteBuffer fileBuffer) -{ - Assert(fileBuffer.Data != nullptr); - - char* cursor = reinterpret_cast(fileBuffer.Data); - char* end = cursor + fileBuffer.Size; - - // Pass 1: Count properties to allocate exactly on arena - uint32 propertyCount = 0; - char* scan = cursor; - while (scan < end) - { - if (*scan == ';') - { - if (scan == cursor || *(scan - 1) == '\n') - { - propertyCount++; - } - } - scan++; - } - - if (propertyCount == 0) - { - return { nullptr, 0 }; - } - - auto* nodes = ArenaPushArray(arena, propertyCount); - Assert(nodes != nullptr); - - // Pass 2: Extract keys and value slices - uint32 nodeIndex = 0; - scan = cursor; - - while (scan < end && nodeIndex < propertyCount) - { - // Skip leading whitespace / empty lines / comments - while (scan < end && (*scan == '\r' || *scan == '\n' || *scan == ' ' || *scan == '\t')) - { - scan++; - } - - if (scan >= end) - { - break; - } - - // Check for comment line - if (*scan == '#' || (*scan == '/' && scan + 1 < end && *(scan + 1) == '/')) - { - while (scan < end && *scan != '\n') - { - scan++; - } - continue; - } - - // Check for property declaration ';' - if (*scan == ';') - { - scan++; // skip ';' - - // Extract key name - char* keyStart = scan; - while (scan < end && *scan != '\r' && *scan != '\n') - { - scan++; - } - - String rawKey = { .Str = keyStart, .Size = static_cast(scan - keyStart) }; - String key = TrimWhitespace(rawKey); - - // Skip newline after key - while (scan < end && (*scan == '\r' || *scan == '\n')) - { - scan++; - } - - // Extract value block (everything until next ';' at start of line or EOF) - char* valStart = scan; - char* valEnd = scan; - - while (scan < end) - { - if (*scan == ';' && (scan == cursor || *(scan - 1) == '\n')) - { - break; - } - scan++; - valEnd = scan; - } - - String rawVal = { .Str = valStart, .Size = static_cast(valEnd - valStart) }; - String val = TrimWhitespace(rawVal); - - nodes[nodeIndex].KeyCRC = Crc32(key); - nodes[nodeIndex].Value = val; - nodes[nodeIndex].Consumed = false; - nodeIndex++; - } - else - { - // Advance unexpected character - scan++; - } - } - - return { nodes, nodeIndex }; -} +JULIET_API ParsedArchive tokenize_archive(NonNullPtr arena, ByteBuffer file_buffer); ``` +**Algorithm Invariants**: +1. **Pass 1 (Count)**: Scans the buffer to count `;` key headers at line starts, allocating the exact node array in `arena`. +2. **Pass 2 (Extract)**: Slices key and value `String`s, trims whitespace, computes `key_crc = crc32(key)`, and populates nodes. Skips `#` and `//` comments. -### 3.4 Key Lookup & Unconsumed Key Audit -Lookup performs a fast linear scan over the contiguous `TextPropertyNode` array. Because typical game entities possess between 5 and 50 properties, a cache-coherent linear scan over contiguous 16-byte structs executes in single-digit nanoseconds, comfortably fitting within CPU L1/L2 data cache. - +### 3.4 Property Lookup & Audit API ```cpp -[[nodiscard]] inline TextPropertyNode* FindProperty(TextPropertyNode* nodes, uint32 count, uint32 keyCRC) -{ - Assert(nodes != nullptr || count == 0); - for (uint32 index = 0; index < count; ++index) - { - if (nodes[index].KeyCRC == keyCRC) - { - nodes[index].Consumed = true; - return &nodes[index]; - } - } - return nullptr; -} +JULIET_API ArchivePropertyNode* find_property(NonNullPtr archive, uint32 property_crc); #if JULIET_DEBUG -inline void AuditUnconsumedProperties(const TextPropertyNode* nodes, uint32 count, const char* contextName) -{ - Assert(nodes != nullptr || count == 0); - for (uint32 index = 0; index < count; ++index) - { - if (!nodes[index].Consumed) - { - LogWarning(LogCategory::Core, - "[%s] Unconsumed or obsolete property detected: CRC 0x%08X (Value: '%.*s')", - contextName, nodes[index].KeyCRC, - static_cast(nodes[index].Value.Size), nodes[index].Value.Str); - } - } -} +JULIET_API void audit_unconsumed_properties(NonNullPtr archive, String context_name); #endif ``` +- `find_property`: Performs an $O(1)$ integer comparison against `key_crc`. When found, marks `node->consumed = true`. +- `audit_unconsumed_properties`: Iterates through all nodes in debug builds and logs warnings for any property with `consumed == false`, catching typos or abandoned schema fields. --- -## 4. The `archive` Struct & Mode Handling +## 4. The `Archive` Context Struct & Streaming I/O -### 4.1 Struct Definition & Mode Flags -Juliet's existing `archive` struct in `Core/Common/serialization.h` is restricted to binary offsets and raw arena pointers. We upgrade `archive` into a unified serialization context supporting both text `.jasset` and binary streams. +### 4.1 Struct Definition (`Core/Common/serialization.h`) +The `Archive` struct unifies loading and saving state into a single decoupled context: ```cpp -enum class ArchiveMode : uint8 +struct Archive { - SavingText, - LoadingText, - SavingBinary, - LoadingBinary -}; + Arena* arena; + bool loading; + ParsedArchive base = {}; + IOStream* stream = nullptr; -struct TextPropertyNode; - -struct archive -{ - Arena* ArenaInstance = nullptr; - IOStream* Stream = nullptr; - TextPropertyNode* Properties = nullptr; - uint32 PropertyCount = 0; - ArchiveMode Mode = ArchiveMode::LoadingText; - uint32 BaseVersion = 0; - uint16 ClassVersion = 0; - - // Legacy binary support fields - void* BasePtr = nullptr; - index_t Offset = 0; - - [[nodiscard]] bool IsLoading() const - { - return Mode == ArchiveMode::LoadingText || Mode == ArchiveMode::LoadingBinary; - } - - [[nodiscard]] bool IsSaving() const - { - return Mode == ArchiveMode::SavingText || Mode == ArchiveMode::SavingBinary; - } - - [[nodiscard]] bool IsText() const - { - return Mode == ArchiveMode::LoadingText || Mode == ArchiveMode::SavingText; - } - - [[nodiscard]] bool IsBinary() const - { - return Mode == ArchiveMode::LoadingBinary || Mode == ArchiveMode::SavingBinary; - } + // Legacy binary support fields (to be deprecated) + void* base_ptr = nullptr; + index_t offset = 0; }; ``` -### 4.2 Output Stream Formatting via `IOPrintf` -When saving in `ArchiveMode::SavingText`, `archive` outputs directly to an open `IOStream` using `IOPrintf`. +- When `loading == true`: Reads properties from `base.nodes` via `find_property`. +- When `loading == false`: Writes formatted key-value pairs directly to `stream`. +### 4.2 Property Header Formatting ```cpp -inline void WritePropertyHeader(archive& ar, const char* keyName) -{ - Assert(ar.IsSaving()); - Assert(ar.Stream != nullptr); - Assert(keyName != nullptr); - IOPrintf(ar.Stream, "; %s\n", keyName); -} +JULIET_API void write_property_header(Archive& archive, String property_name); ``` - -This design provides: -1. Direct stream output with zero heap buffer allocations. -2. Canonical spacing and formatting across all entity serializers. -3. Formatted outputs immediately flushed or buffered according to `IOStreamInterface` configuration. +Emits `; \n` directly to `ar.stream` with zero intermediate heap buffers. --- -## 5. `SerializeProp` API & Implementation +## 5. Property Serialization API & Helpers ### 5.1 Unified Serialization Idiom -The `SerializeProp` function family encapsulates both loading and saving behind a single call. If an asset file lacks a given property (e.g. an older asset file loaded by newer code), `SerializeProp` returns `false` during loading, and the destination variable retains its existing default value. +All property serialization uses a single template function: ```cpp -#define SERIALIZE_PROP(ar, var) SerializeProp((ar), #var, #var##_crc32, (var)) -``` - -### 5.2 Primitive Type Helpers - -#### Float (`float`) -```cpp -bool SerializeProp(archive& ar, const char* keyName, uint32 keyCRC, float& value) +template +bool serialize(Archive& ar, String property_name, uint32 property_crc, Type& value) { - Assert(keyName != nullptr); - if (ar.IsSaving()) + Assert(IsValid(property_name)); + + bool result = false; + if (ar.loading) { - WritePropertyHeader(ar, keyName); - IOPrintf(ar.Stream, "%f\n\n", value); - return true; - } - - auto* node = FindProperty(ar.Properties, ar.PropertyCount, keyCRC); - if (node == nullptr) - { - return false; - } - - // Fast float conversion from String slice - char buffer[64]; - size_t copySize = Min(node->Value.Size, sizeof(buffer) - 1); - MemCopy(buffer, node->Value.Str, copySize); - buffer[copySize] = '\0'; - - char* endPtr = nullptr; - float parsed = strtof(buffer, &endPtr); - if (endPtr != buffer) - { - value = parsed; - return true; - } - return false; -} -``` - -#### 32-Bit Signed Integer (`int32`) -```cpp -bool SerializeProp(archive& ar, const char* keyName, uint32 keyCRC, int32& value) -{ - Assert(keyName != nullptr); - if (ar.IsSaving()) - { - WritePropertyHeader(ar, keyName); - IOPrintf(ar.Stream, "%d\n\n", value); - return true; - } - - auto* node = FindProperty(ar.Properties, ar.PropertyCount, keyCRC); - if (node == nullptr) - { - return false; - } - - char buffer[32]; - size_t copySize = Min(node->Value.Size, sizeof(buffer) - 1); - MemCopy(buffer, node->Value.Str, copySize); - buffer[copySize] = '\0'; - - char* endPtr = nullptr; - int32 parsed = static_cast(strtol(buffer, &endPtr, 10)); - if (endPtr != buffer) - { - value = parsed; - return true; - } - return false; -} -``` - -#### 64-Bit Unsigned Integer (`uint64`, `EntityID`) -```cpp -bool SerializeProp(archive& ar, const char* keyName, uint32 keyCRC, uint64& value) -{ - Assert(keyName != nullptr); - if (ar.IsSaving()) - { - WritePropertyHeader(ar, keyName); - IOPrintf(ar.Stream, "0x%016llX\n\n", value); - return true; - } - - auto* node = FindProperty(ar.Properties, ar.PropertyCount, keyCRC); - if (node == nullptr) - { - return false; - } - - char buffer[32]; - size_t copySize = Min(node->Value.Size, sizeof(buffer) - 1); - MemCopy(buffer, node->Value.Str, copySize); - buffer[copySize] = '\0'; - - char* endPtr = nullptr; - int base = (buffer[0] == '0' && (buffer[1] == 'x' || buffer[1] == 'X')) ? 16 : 10; - uint64 parsed = strtoull(buffer, &endPtr, base); - if (endPtr != buffer) - { - value = parsed; - return true; - } - return false; -} -``` - -#### Boolean (`bool`) -```cpp -bool SerializeProp(archive& ar, const char* keyName, uint32 keyCRC, bool& value) -{ - Assert(keyName != nullptr); - if (ar.IsSaving()) - { - WritePropertyHeader(ar, keyName); - IOPrintf(ar.Stream, "%s\n\n", value ? "true" : "false"); - return true; - } - - auto* node = FindProperty(ar.Properties, ar.PropertyCount, keyCRC); - if (node == nullptr) - { - return false; - } - - if (StringCompare(node->Value, WrapString("true")) == 0 || - StringCompare(node->Value, WrapString("1")) == 0) - { - value = true; - return true; - } - if (StringCompare(node->Value, WrapString("false")) == 0 || - StringCompare(node->Value, WrapString("0")) == 0) - { - value = false; - return true; - } - return false; -} -``` - -### 5.3 Vector Helpers (`float x, y, z`) -```cpp -bool SerializeProp(archive& ar, const char* keyName, uint32 keyCRC, float& x, float& y, float& z) -{ - Assert(keyName != nullptr); - if (ar.IsSaving()) - { - WritePropertyHeader(ar, keyName); - IOPrintf(ar.Stream, "%f %f %f\n\n", x, y, z); - return true; - } - - auto* node = FindProperty(ar.Properties, ar.PropertyCount, keyCRC); - if (node == nullptr) - { - return false; - } - - char buffer[128]; - size_t copySize = Min(node->Value.Size, sizeof(buffer) - 1); - MemCopy(buffer, node->Value.Str, copySize); - buffer[copySize] = '\0'; - - float parsedX = 0.0f; - float parsedY = 0.0f; - float parsedZ = 0.0f; - int matches = sscanf_s(buffer, "%f %f %f", &parsedX, &parsedY, &parsedZ); - if (matches == 3) - { - x = parsedX; - y = parsedY; - z = parsedZ; - return true; - } - return false; -} -``` - -### 5.4 String Helpers -```cpp -bool SerializeProp(archive& ar, const char* keyName, uint32 keyCRC, String& value) -{ - Assert(keyName != nullptr); - if (ar.IsSaving()) - { - WritePropertyHeader(ar, keyName); - bool hasSpace = ContainsChar(value, ' '); - if (hasSpace) + if (auto* prop = find_property(&ar.base, property_crc)) { - IOPrintf(ar.Stream, "\"%.*s\"\n\n", static_cast(value.Size), value.Str); + if (read_prop(ar, prop->value, value)) + { + result = true; + } } - else - { - IOPrintf(ar.Stream, "%.*s\n\n", static_cast(value.Size), value.Str); - } - return true; } - - auto* node = FindProperty(ar.Properties, ar.PropertyCount, keyCRC); - if (node == nullptr) + else { - return false; + write_property_header(ar, property_name); + write(ar.stream, value); + result = true; } - String parsed = node->Value; - // Strip optional surrounding double quotes - if (parsed.Size >= 2 && parsed.Str[0] == '"' && parsed.Str[parsed.Size - 1] == '"') - { - parsed.Str++; - parsed.Size -= 2; - } - - // Allocate persistent string copy on the archive arena - Assert(ar.ArenaInstance != nullptr); - value = StringCopy(ar.ArenaInstance, parsed); - return true; + return result; } ``` +### 5.2 Convenience Macros +```cpp +#define SERIALIZE(ar, name, var) serialize((ar), ConstString(#name), #name##_crc32, (var)) +#define SERIALIZE_SIMPLE(ar, var) SERIALIZE(ar, var, var) +``` +- `SERIALIZE(ar, id, entity->ID)`: Serializes property named `"id"` with `"id"_crc32`. +- `SERIALIZE_SIMPLE(ar, position)`: Uses variable identifier as property name. + +### 5.3 Supported Type Conversions +Conversion between text and memory is handled by overloaded `read` and `write` primitives: + +| C++ Type | Text Format | Conversion Primitive | +| :--- | :--- | :--- | +| `float` | `180.500000` | `strtof` / `IOPrintf("%.9g")` | +| `int8`, `int16`, `int32`, `int64` | `42` / `-100` | `strtol`, `strtoll` / `IOPrintf("%d")` | +| `uint8`, `uint16`, `uint32`, `uint64` | `1001` / `0x...` | `strtoul`, `strtoull` / `IOPrintf("%u")` | +| `bool` | `true` / `false` | `true/false/1/0` string compare / `IOPrintf` | +| `Vector4` | `10.0 20.0 30.0 1.0` | Space-delimited float parse / `IOPrintf` | +| `String` | `"Paladin Hero"` | Arena-allocated copy, quote strip / `IOPrintf` | + --- -## 6. Two-Tier Versioning Architecture +## 6. Two-Tier Versioning & Generalized `Class` Architecture -### 6.1 Architectural Rationale: Base vs Derived Decoupling -In entity-component systems or object-oriented engine hierarchies, entities consist of two distinct domains: -1. **Core Engine Identity (Base Entity)**: Position, Rotation, Scale, EntityID, Class Kind, Render Flags, Layer Masks. Managed by engine architects. -2. **Gameplay Specialization (Derived Class)**: Ammo, Health, AI State, Mesh Instance ID, Patrol Paths. Managed by gameplay programmers. +### 6.1 Architectural Principle +To prevent monolithic engine updates from forcing all gameplay assets to re-version, schema versions are decoupled into two tiers: +1. **Base Version (`; version`)**: Managed by root classes (e.g. `Entity::kind->version`). Governs core engine properties (`id`, `position`). +2. **Derived Version (`; class_version`)**: Managed by derived classes (e.g. `Inert::kind->version`). Governs gameplay-specific component properties. -#### The Fragility of Monolithic Versioning -In naive serialization architectures, a single `uint32 Version` governs the entire file. When an engine programmer updates the base `Entity` struct (e.g. adding a `uint32 LayerMask`), bumping the global version invalidates or forces schema changes across every single derived entity type in the project. +### 6.2 The `Class` Descriptor (`Engine/Class.h`) +Every serializable entity or component is described by an immutable `Class` instance: -#### The Two-Tier Solution: Universal `; Version` + Optional `; ClassVersion` -To keep `.jasset` files clean and unified across all asset types: -- **All `.jasset` files declare a universal `; Version` tag**: - - In simple standalone assets (e.g., `WorldSettings.jasset`, `Material.jasset`), `; Version` is the single asset schema version. - - In entity assets, `; Version` maps to the Base Engine Version (`kEntityBaseVersion` in `Entity.h`), governing core engine fields (`Position`, `Rotation`, `Scale`, etc.). -- **Entity classes with derived versioning add an optional `; ClassVersion`**: - - Governed by `Class::Version` in `Class.h`. - - Only written/read for entity classes that define custom versions. If omitted in the file, it defaults to `1`. - - Non-entity assets never see or use `; ClassVersion`. - -``` -======================================================================== - .jasset Text Archive -======================================================================== - ; AssetType - Entity - ; Version ------> Universal asset version (kEntityBaseVersion for Entity) - 1 - ; EntityID - 0x0000000000000001 - ; Kind - Inert - ; Position - 0.0 0.0 0.0 ------------------------------------------------------------------------- - ; ClassVersion ------> Optional derived version (Class::Version in Class.h) - 2 - ; MeshInstance - 42 -======================================================================== -``` - -When an engine programmer bumps `kEntityBaseVersion` from `1` to `2` to add `LayerMask`, no derived game classes (`Inert`, `Monster`, `Vehicle`) need version increments or code modifications. Conversely, non-entity assets (like `WorldSettings.jasset`) simply use `; Version \n 1` without carrying entity-specific terminology. - -### 6.2 Implementation Details: The Generalized `Class` Model - -In this architecture, **every serializable struct in Juliet has a `Class` descriptor**: - -#### Generalized `Class` Struct (`Juliet/include/Engine/Class.h`) ```cpp -using serialize_fct_type = void (*)(archive& ar, void* payload, uint16 version); +using serialize_fct_type = void (*)(Archive& ar, uint16 version, void* payload); struct Class { uint32 CRC; uint8 kind; - uint16 Version; // Struct schema version - Class* BaseClass; // Pointer to parent class (or nullptr if root) - serialize_fct_type serialize_fct; // Type-specific serialization callback + uint16 version; + const Class* base_class; + serialize_fct_type serialize_fct; size_t size_of; size_t alignment; #if JULIET_DEBUG - String Name; + String Name; #endif }; - -consteval Class MakeClass(String name, uint8 kind, uint16 version, Class* baseClass, - size_t size, size_t align, serialize_fct_type fct) -{ - Class cls = {}; - cls.CRC = crc32(name.Str, name.Size); - cls.kind = kind; - cls.Version = version; - cls.BaseClass = baseClass; - cls.size_of = size; - cls.alignment = align; - cls.serialize_fct = fct; - -#if JULIET_DEBUG - cls.Name = name; -#endif - - return cls; -} ``` -#### Registration Macros +### 6.3 Class Registration Macros ```cpp -// General class registration (e.g. for Entity, WorldSettings, Materials) +#define DECLARE_CLASS() \ + static Class* kind; + #define DEFINE_CLASS_VERSIONED(cls, version, base_class, serialize_fct) \ - Class classKind##cls = \ + constexpr Class classKind##cls = \ MakeClass(ConstString(#cls), 0, (version), (base_class), sizeof(cls), alignof(cls), (serialize_fct)); \ - Class* cls::Kind = &classKind##cls; - -// Entity derived class registration (automatically sets BaseClass = Entity::Kind) -#define DEFINE_ENTITY_VERSIONED(entity, version, serialize_fct) \ - Class entityKind##entity = \ - MakeClass(ConstString(#entity), (uint8)Entity_Type::entity, (version), Entity::Kind, sizeof(entity), \ - alignof(entity), (serialize_fct)); \ - Class* entity::Kind = &entityKind##entity; + Class* cls::kind = const_cast(&classKind##cls); ``` -#### Universal Class Instance Serializer -Because every type is a `Class`, serializing **any** struct in the engine is unified into a single function: - +For derived entity types, `DECLARE_ENTITY()` and `DEFINE_ENTITY_VERSIONED` compose cleanly: ```cpp -void SerializeClassInstance(archive& ar, Class* cls, void* instance) +#define DECLARE_ENTITY() \ + Entity* base; \ + DECLARE_CLASS() + +#define DEFINE_ENTITY_VERSIONED(entity, version, serialize_fct) \ + constexpr Class entityKind##entity = MakeClass(ConstString(#entity), (uint8)Entity_Type::entity, (version), \ + &classKindEntity, sizeof(entity), alignof(entity), (serialize_fct)); \ + Class* entity::kind = const_cast(&entityKind##entity); +``` + +### 6.4 Universal Class Serializer (`Engine/class.cpp`) +```cpp +void serialize(Archive& ar, NonNullPtr cls, void* instance) { - Assert(cls != nullptr); Assert(instance != nullptr); - uint16 version = cls->Version; - if (ar.IsSaving()) + uint16 version = cls->version; + if (cls->base_class) { - // If this is a derived entity class, write as class_version; otherwise write universal version - if (cls->BaseClass != nullptr) - { - SerializeProp(ar, "class_version", "class_version"_crc32, version); - } - else - { - SerializeProp(ar, "version", "version"_crc32, version); - } + serialize(ar, ConstString("class_version"), "class_version"_crc32, version); } else { - if (cls->BaseClass != nullptr) - { - (void)SerializeProp(ar, "class_version", "class_version"_crc32, version); - } - else - { - (void)SerializeProp(ar, "version", "version"_crc32, version); - } + serialize(ar, ConstString("version"), "version"_crc32, version); } - if (cls->serialize_fct != nullptr) + if (cls->serialize_fct) { - cls->serialize_fct(ar, instance, version); + cls->serialize_fct(ar, version, instance); } } ``` -#### Hierarchical Type Queries via `BaseClass` +### 6.5 Runtime Type Queries (`IsA`) +Polymorphic type safety is resolved without virtual tables or RTTI: ```cpp -[[nodiscard]] inline bool IsA(const Class* queryClass, const Class* targetClass) +bool IsA(const Class& query, const Class* target); + +template +bool IsA(const Class& cls) { - const Class* current = queryClass; - while (current != nullptr) - { - if (current == targetClass) - { - return true; - } - current = current->BaseClass; - } - return false; + return IsA(cls, TargetType::kind); } ``` -### 6.3 Entity Serialization with Generalized Classes -An entity instance is cleanly composed of its base `Entity` class and its derived `DerivedKind` class: - +### 6.6 Entity Serialization Composition +An entity instance composes base `Entity` properties and derived component properties: ```cpp -void Serialize(archive& ar, NonNullPtr entity) +void serialize_entity(Archive& ar, uint16 /*version*/, void* payload) { - Assert(entity.Get() != nullptr); + Assert(payload != nullptr); + auto* entity = static_cast(payload); - // 1. Serialize Base Entity using Entity's own Class descriptor (Entity::Kind) - SerializeClassInstance(ar, Entity::Kind, entity.Get()); + SERIALIZE(ar, id, entity->ID); + SERIALIZE(ar, position, entity->position); +} - // 2. Serialize Derived Entity using its Class descriptor (entity->DerivedKind) - if (entity->DerivedKind != nullptr && entity->Derived != nullptr) +DEFINE_CLASS_VERSIONED(Entity, 1, nullptr, serialize_entity) + +void serialize(Archive& ar, NonNullPtr entity) +{ + // 1. Serialize base Entity properties (reads/writes '; version') + serialize(ar, Entity::kind, entity.Get()); + + // 2. Serialize derived component properties (reads/writes '; class_version') + if (entity->derived_kind != nullptr && entity->derived != nullptr) { - SerializeClassInstance(ar, entity->DerivedKind, entity->Derived); + serialize(ar, entity->derived_kind, entity->derived); } } ``` --- -## 7. Post-Serialization Deprecation & Migration +## 7. In-Place Schema Migration & Deprecation -### 7.1 Schema Evolution Challenge -Over the lifecycle of a game, gameplay mechanics evolve: -- A scalar float `Speed` is replaced by a directional 2D vector `Velocity`. -- A single texture index `TextureID` is replaced by an asset path string `DiffuseTexture`. -- Obsolete properties are deleted entirely. +### 7.1 Deprecation Principle +When gameplay code evolves, obsolete member variables are deleted from active C++ structs to avoid memory waste and code clutter. Obsolete properties are migrated exclusively during loading using temporary local stack variables. -Retaining deprecated members in active C++ structs creates code clutter, wastes memory, and invites bugs. - -### 7.2 Stack-Allocated Migration via Standard Serialization -Rather than maintaining dedicated deprecation primitives or polluting C++ structs with obsolete members, deprecated properties are migrated using the standard `serialize` / `SERIALIZE` function with temporary local variables allocated on the stack. - -When an asset property is deprecated, restructured, or renamed: -1. The obsolete field is completely removed from the modern C++ struct definition. -2. In the entity/component serializer `serialize_fct(Archive& ar, uint16 version, void* payload)`, an `if (ar.loading && version < N)` block is added. -3. A local variable of the legacy type is declared on the stack. -4. The standard `SERIALIZE(ar, OldPropName, deprecated_val)` (or `serialize(ar, ConstString("OldPropName"), "OldPropName"_crc32, deprecated_val)`) is called. If the property exists in the loaded asset, it parses into `deprecated_val` and returns `true`. -5. The serializer performs whatever mapping or transformation is required into the modern struct fields. -6. Because the migration block is strictly guarded by `ar.loading`, it never executes during save operations (`ar.loading == false`). The serializer writes only modern struct fields, automatically purging obsolete keys on subsequent saves without requiring dedicated cleanup routines. - -### 7.3 In-Place Migration Pattern -When an asset file with an older schema is loaded, `serialize(ar, cls, instance)` parses the version from the file (e.g. `; version` or `; class_version`) and passes it directly into the callback `serialize_fct(ar, version, payload)`. The serializer detects `version < N`, reads obsolete fields into stack variables using standard serialization, maps the legacy data into the modern struct, and completes loading. On the subsequent save, the asset file is emitted using the modern schema without deprecated keys. +### 7.2 The Stack-Allocated Migration Idiom +In the class's `serialize_fct(Archive& ar, uint16 version, void* payload)`: +1. When `ar.loading == true` and `version < N`: + - Declare a temporary variable on the stack matching the legacy type. + - Call `SERIALIZE(ar, old_field_name, deprecated_var)`. + - If present, transform the legacy data into the modern struct field(s). +2. When saving (`ar.loading == false`): + - The migration block is skipped. Only modern struct properties are written. + - On the next save, obsolete keys are automatically purged from disk. ```cpp -struct Projectile -{ - DECLARE_ENTITY() - - // Modern Schema (v2) - float VelocityX = 0.0f; - float VelocityY = 0.0f; - float Damage = 50.0f; -}; - -void SerializeProjectile(Archive& ar, uint16 version, void* payload) +void serialize_projectile(Archive& ar, uint16 version, void* payload) { Assert(payload != nullptr); auto* projectile = static_cast(payload); - if (ar.loading) - { - SERIALIZE(ar, Damage, projectile->Damage); + SERIALIZE(ar, damage, projectile->damage); - if (version < 2) + if (ar.loading && version < 2) + { + // Migrating v1 scalar 'speed' into modern Vector4 'velocity' + float deprecated_speed = 0.0f; + if (SERIALIZE(ar, speed, deprecated_speed)) { - // Migration from v1: scalar 'Speed' converted to 'VelocityX' on the stack - float deprecated_speed = 0.0f; - if (SERIALIZE(ar, Speed, deprecated_speed)) - { - projectile->VelocityX = deprecated_speed; - projectile->VelocityY = 0.0f; - } - } - else - { - SERIALIZE(ar, VelocityX, projectile->VelocityX); - SERIALIZE(ar, VelocityY, projectile->VelocityY); + projectile->velocity = Vector4{ deprecated_speed, 0.0f, 0.0f, 0.0f }; } } else { - // Save modern schema - SERIALIZE(ar, VelocityX, projectile->VelocityX); - SERIALIZE(ar, VelocityY, projectile->VelocityY); - SERIALIZE(ar, Damage, projectile->Damage); + SERIALIZE(ar, velocity, projectile->velocity); } } ``` --- -## 8. Step-by-Step Implementation Roadmap & Unit Testing Plan +## 8. Verification & Unit Testing Framework -### 8.1 Implementation Roadmap +### 8.1 Engine-Level Test Runner (`Juliet/src/UnitTest/`) +Unit testing lives inside the Juliet engine layer (`Juliet/src/UnitTest/serialization_test.cpp`) and executes during engine startup in debug builds via `UnitTest::RunUnitTests()` in `RunUnitTests.cpp`. -``` -+-------------------------------------------------------------------------+ -| Phase 1: Core Utilities & Dual-Mode CRC32 | -| - Update CRC32.h with constexpr Crc32(String) and Crc32(char*, len) | -| - Extend struct Class in Class.h with uint16 Version | -+-------------------------------------------------------------------------+ - | - v -+-------------------------------------------------------------------------+ -| Phase 2: In-Memory Zero-Copy Parser | -| - Implement TextArchiveParser.h / .cpp | -| - TokenizeTextArchive, TrimWhitespace, FindProperty | -+-------------------------------------------------------------------------+ - | - v -+-------------------------------------------------------------------------+ -| Phase 3: Archive Struct & Serialize Helpers | -| - Upgrade Archive in serialization.h with loading / version state | -| - Implement primitive, vector, and string serialize helpers | -| - Support stack-allocated schema migration | -+-------------------------------------------------------------------------+ - | - v -+-------------------------------------------------------------------------+ -| Phase 4: Entity & World Integration | -| - Update Entity.h / Entity.cpp with two-tier serialization | -| - Refactor World.cpp to load/save .jasset files | -+-------------------------------------------------------------------------+ - | - v -+-------------------------------------------------------------------------+ -| Phase 5: Verification & Unit Testing Suite | -| - Build and run SerializationUnitTest.cpp | -+-------------------------------------------------------------------------+ -``` +### 8.2 Test Coverage Matrix -#### Phase 1: Core Utilities & Dual-Mode CRC32 -1. **Target**: `Juliet/include/Core/Common/CRC32.h` - - Add `constexpr uint32 Crc32(const char* str, size_t length)` and `constexpr uint32 Crc32(String str)`. - - Ensure existing `operator""_crc32` calls `Crc32`. -2. **Target**: `Juliet/include/Engine/Class.h` - - Add `uint16 Version` to `struct Class`. - - Update `MakeClass` to accept `uint16 version = 1`. - -#### Phase 2: In-Memory Zero-Copy Parser -1. **Target**: `Juliet/include/Core/Common/TextArchiveParser.h` (and `src/Core/Common/TextArchiveParser.cpp`) - - Define `struct TextPropertyNode { uint32 KeyCRC; String Value; bool Consumed; }`. - - Implement `TokenizeTextArchive(NonNullPtr arena, ByteBuffer buffer)`. - - Implement `FindProperty` and `AuditUnconsumedProperties`. - -#### Phase 3: Archive Struct & `serialize` Helpers -1. **Target**: `Juliet/include/Core/Common/serialization.h` - - Upgrade `struct Archive` with stream pointer, property array, and `loading` flag (pure I/O container decoupled from domain versions). - - Implement overloaded `serialize`, `read_prop`, and `write` for `float`, `int32`, `uint64`, `bool`, vectors, and `String`. - - Support deprecation migration using standard `serialize` / `SERIALIZE` with local stack variables under `if (ar.loading && version < N)` in `serialize_fct`. - -#### Phase 4: Entity & World Integration -1. **Target**: `Game/Entity/Entity.h` and `Game/Entity/Entity.cpp` - - Define `constexpr uint32 kEntityBaseVersion = 1;`. - - Update `DEFINE_ENTITY` and add `DEFINE_ENTITY_VERSIONED`. - - Implement `Serialize(archive& ar, NonNullPtr entity)` supporting `.jasset` text format. -2. **Target**: `Game/Data/World.h` and `Game/Data/World.cpp` - - Implement text-based world saving and loading using `.jasset` formatting. - -#### Phase 5: Comprehensive Unit Testing (Juliet Engine Layer) -1. **Target**: `Juliet/src/UnitTest/SerializationUnitTest.h` and `Juliet/src/UnitTest/SerializationUnitTest.cpp` - - Add self-contained unit tests in Juliet verifying parsing, round-trip serialization, defaults preservation, versioning, and deprecation. - - Use `DECLARE_CLASS()` and `DEFINE_CLASS_VERSIONED` (no dependency on Game/Entity headers). - - Hook into `Juliet/src/UnitTest/RunUnitTests.cpp` via `UnitTest::SerializationUnitTest()`. +| Test Function | Target Feature | Validation Criteria | +| :--- | :--- | :--- | +| `test_parser_tokenization` | Zero-copy text parser | Validates handling of `#` and `//` comments, whitespace trimming, mixed CRLF/LF, and fast CRC property lookups. | +| `test_default_value_retention` | Partial schema loading | Validates that missing properties in partial files preserve existing struct default values without corruption. | +| `test_deprecation_migration` | Stack-based schema migration | Loads a v1 asset containing obsolete `; speed`, verifies `version = 1` is received, and validates migration into modern fields. | +| `test_class_inheritance` | Runtime `IsA` queries | Validates polymorphic inheritance checks across base and derived `Class` instances. | +| `test_string_and_vector4` | Primitives & text quoting | Verifies parsing and quote handling of `String` and multi-component `Vector4`. | --- -### 8.2 Comprehensive Unit Testing Plan (`SerializationUnitTest.cpp`) +## 9. Deliverables & File Summary -The unit test suite validates all architectural requirements directly within the Juliet engine layer without modifying framework code or depending on Game-layer entity headers. It uses `DECLARE_CLASS()` and `DEFINE_CLASS_VERSIONED` directly to test base and derived class serialization, versioning, and deprecation. - -```cpp -// Juliet/src/UnitTest/SerializationUnitTest.h -#pragma once - -#include - -#if JULIET_DEBUG -namespace UnitTest -{ - void SerializationUnitTest(); -} -#endif -``` - -```cpp -// Juliet/src/UnitTest/SerializationUnitTest.cpp -#include - -#if JULIET_DEBUG - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace UnitTest -{ - // Test 1: Parser tokenization with whitespace, comments, and mixed line endings - static void TestParserTokenization() - { - TempArena temp = scratch_begin(nullptr, 0); - - const char* testContent = - "# Header Comment\r\n" - "// Secondary comment\n" - "\n" - "; Health\r\n" - " 100.500000 \r\n" - "\n" - "; Name\n" - "\"Paladin Hero\"\n" - "\n" - "; Position\r\n" - "1.0 2.0 3.0\r\n"; - - ByteBuffer buffer = { - .Data = reinterpret_cast(const_cast(testContent)), - .Size = strlen(testContent) - }; - - ParsedArchive parsed = tokenize_archive(temp.Arena, buffer); - Assert(parsed.property_count == 3); - - auto* healthNode = find_property(&parsed, "Health"_crc32); - Assert(healthNode != nullptr); - Assert(StringCompare(healthNode->value, WrapString("100.500000")) == 0); - - auto* nameNode = find_property(&parsed, "Name"_crc32); - Assert(nameNode != nullptr); - Assert(StringCompare(nameNode->value, WrapString("\"Paladin Hero\"")) == 0); - - auto* posNode = find_property(&parsed, "Position"_crc32); - Assert(posNode != nullptr); - Assert(StringCompare(posNode->value, WrapString("1.0 2.0 3.0")) == 0); - - scratch_end(temp); - LogMessage(LogCategory::Core, "TestParserTokenization passed."); - } - - // Test Struct for Default Value Testing in Juliet - struct DummyVehicle - { - DECLARE_CLASS() - - float max_speed = 120.0f; - int32 gear_count = 6; - uint64 chassis_uuid = 0xABCDEF0123456789ULL; - bool turbo = true; - float pos_x = 10.0f; - }; - - void serialize_dummy_vehicle(Archive& ar, uint16 /*version*/, void* payload) - { - Assert(payload != nullptr); - auto* vehicle = static_cast(payload); - - SERIALIZE(ar, max_speed, vehicle->max_speed); - SERIALIZE(ar, gear_count, vehicle->gear_count); - SERIALIZE(ar, chassis_uuid, vehicle->chassis_uuid); - SERIALIZE(ar, turbo, vehicle->turbo); - SERIALIZE(ar, pos_x, vehicle->pos_x); - } - - DEFINE_CLASS_VERSIONED(DummyVehicle, 1, nullptr, serialize_dummy_vehicle) - - // Test 2: Missing properties retain default struct values - static void TestDefaultValueRetention() - { - TempArena temp = scratch_begin(nullptr, 0); - - const char* incompleteContent = - "; version\n" - "1\n" - "; max_speed\n" - "180.000000\n"; - - ByteBuffer buffer = { - .Data = reinterpret_cast(const_cast(incompleteContent)), - .Size = strlen(incompleteContent) - }; - - ParsedArchive parsed = tokenize_archive(temp.Arena, buffer); - - Archive ar = {}; - ar.arena = temp.Arena; - ar.loading = true; - ar.base = parsed; - - DummyVehicle vehicle; - // Defaults: max_speed=120, gear_count=6, turbo=true, pos_x=10 - serialize(ar, DummyVehicle::kind, &vehicle); - - Assert(vehicle.max_speed == 180.0f); // Overwritten by archive - Assert(vehicle.gear_count == 6); // Preserved default - Assert(vehicle.turbo == true); // Preserved default - Assert(vehicle.pos_x == 10.0f); // Preserved default - - scratch_end(temp); - LogMessage(LogCategory::Core, "TestDefaultValueRetention passed."); - } - - // Base test class for testing class hierarchy - struct DummyWeaponBase - { - DECLARE_CLASS() - - float base_damage = 25.0f; - }; - - void serialize_dummy_weapon_base(Archive& ar, uint16 /*version*/, void* payload) - { - Assert(payload != nullptr); - auto* base_weapon = static_cast(payload); - SERIALIZE(ar, base_damage, base_weapon->base_damage); - } - - DEFINE_CLASS_VERSIONED(DummyWeaponBase, 1, nullptr, serialize_dummy_weapon_base) - - // Derived test class for testing version migration (v1 -> v2) - struct LegacyWeapon - { - DECLARE_CLASS() - - float velocity_x = 0.0f; - float velocity_y = 0.0f; - }; - - void serialize_legacy_weapon(Archive& ar, uint16 version, void* payload) - { - Assert(payload != nullptr); - auto* weapon = static_cast(payload); - - if (ar.loading) - { - if (version < 2) - { - // Migration from v1: scalar 'speed' converted to 'velocity_x' on the stack - float deprecated_speed = 0.0f; - if (SERIALIZE(ar, speed, deprecated_speed)) - { - weapon->velocity_x = deprecated_speed; - weapon->velocity_y = 0.0f; - } - } - else - { - SERIALIZE(ar, velocity_x, weapon->velocity_x); - SERIALIZE(ar, velocity_y, weapon->velocity_y); - } - } - else - { - SERIALIZE(ar, velocity_x, weapon->velocity_x); - SERIALIZE(ar, velocity_y, weapon->velocity_y); - } - } - - // Inherits from DummyWeaponBase (base_class != nullptr) to verify derived class_version handling - DEFINE_CLASS_VERSIONED(LegacyWeapon, 2, &classKindDummyWeaponBase, serialize_legacy_weapon) - - // Test 3: Deprecation migration from v1 to v2 via stack variable - static void TestDeprecationMigration() - { - TempArena temp = scratch_begin(nullptr, 0); - - // Simulated v1 file containing obsolete 'speed' and class_version 1 - const char* v1Content = - "; class_version\n" - "1\n" - "; speed\n" - "75.500000\n"; - - ByteBuffer buffer = { - .Data = reinterpret_cast(const_cast(v1Content)), - .Size = strlen(v1Content) - }; - - ParsedArchive parsed = tokenize_archive(temp.Arena, buffer); - - Archive ar = {}; - ar.arena = temp.Arena; - ar.loading = true; - ar.base = parsed; - - LegacyWeapon weapon; - serialize(ar, LegacyWeapon::kind, &weapon); - - Assert(weapon.velocity_x == 75.5f); - Assert(weapon.velocity_y == 0.0f); - - scratch_end(temp); - LogMessage(LogCategory::Core, "TestDeprecationMigration passed."); - } - - // Test 4: Hierarchical type query (IsA) - static void TestClassInheritance() - { - Assert(IsA(*LegacyWeapon::kind, DummyWeaponBase::kind)); - Assert(!IsA(*DummyWeaponBase::kind, LegacyWeapon::kind)); - LogMessage(LogCategory::Core, "TestClassInheritance passed."); - } - - void SerializationUnitTest() - { - LogMessage(LogCategory::Core, "=== Running Serialization & .jasset Unit Tests (Juliet) ==="); - TestParserTokenization(); - TestDefaultValueRetention(); - TestDeprecationMigration(); - TestClassInheritance(); - LogMessage(LogCategory::Core, "=== All Serialization Unit Tests Passed Successfully ==="); - } -} // namespace UnitTest - -#endif +| File | Responsibilities | +| :--- | :--- | +| [`Juliet/include/Core/Common/serialization.h`](file:///w:/Classified/Juliet/Juliet/include/Core/Common/serialization.h) | `ArchivePropertyNode`, `ParsedArchive`, `Archive` struct, `serialize` template, and `SERIALIZE` macros. | +| [`Juliet/src/Core/Common/serialization.cpp`](file:///w:/Classified/Juliet/Juliet/src/Core/Common/serialization.cpp) | `tokenize_archive`, `find_property`, `audit_unconsumed_properties`, `read_prop`, `read`, and `write` primitives. | +| [`Juliet/include/Engine/Class.h`](file:///w:/Classified/Juliet/Juliet/include/Engine/Class.h) | `Class` struct, `MakeClass`, `DECLARE_CLASS()`, `DEFINE_CLASS_VERSIONED`, and `IsA` declarations. | +| [`Juliet/src/Engine/class.cpp`](file:///w:/Classified/Juliet/Juliet/src/Engine/class.cpp) | Universal `serialize(Archive&, NonNullPtr, void*)` and runtime `IsA` traversal. | +| [`Juliet/src/UnitTest/serialization_test.cpp`](file:///w:/Classified/Juliet/Juliet/src/UnitTest/serialization_test.cpp) | Exhaustive unit tests for tokenization, defaults retention, version migration, and type queries. | +| [`Game/Entity/Entity.h`](file:///w:/Classified/Juliet/Game/Entity/Entity.h) | `DECLARE_ENTITY()`, `DEFINE_ENTITY_VERSIONED`, and `Entity` struct definition. | +| [`Game/Entity/Entity.cpp`](file:///w:/Classified/Juliet/Game/Entity/Entity.cpp) | `serialize_entity` registration and two-tier `serialize(Archive&, NonNullPtr)` composition. | diff --git a/Game/Plans/02_Entity_Allocation_And_Lifecycle.md b/Game/Plans/02_Entity_Allocation_And_Lifecycle.md index 28e002c..e1f3b64 100644 --- a/Game/Plans/02_Entity_Allocation_And_Lifecycle.md +++ b/Game/Plans/02_Entity_Allocation_And_Lifecycle.md @@ -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 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. @@ -214,13 +214,11 @@ 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.ID = EntityManager::ID++; + 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(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(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(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; + EntityID ID = 0; + 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,25 +662,25 @@ 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+--------+ +-------+-------+ ^ | - Deserialization + Deserialization (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` 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 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)` 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, 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 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(*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(baseEntity->Derived); - Assert(derived->Base == baseEntity); + auto* derived = reinterpret_cast(base_entity->derived); + Assert(derived->base == base_entity); // 3. DownCast verification - Inert* inert = DownCast(baseEntity); + Inert* inert = DownCast(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(manager, 12.5f, -44.0f, 108.2f); - Assert(createdInert != nullptr); - createdInert->MeshInstance = 42; + Inert* created_inert = MakeEntity(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(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(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(loadedBase); - Assert(loadedInert != nullptr); - Assert(loadedInert->Base == loadedBase); + Inert* loaded_inert = DownCast(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(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; + 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(remaining0->Derived); - auto* derived1 = reinterpret_cast(remaining1->Derived); + auto* derived0 = reinterpret_cast(remaining0->derived); + auto* derived1 = reinterpret_cast(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); diff --git a/Game/Plans/03_Entity_ID_And_World_Directory.md b/Game/Plans/03_Entity_ID_And_World_Directory.md index a476181..cb138bc 100644 --- a/Game/Plans/03_Entity_ID_And_World_Directory.md +++ b/Game/Plans/03_Entity_ID_And_World_Directory.md @@ -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. @@ -680,8 +678,8 @@ struct EntityLookupEntry struct WorldLoadContext { - World* TargetWorld; - Arena* TempMapArena; + World* TargetWorld; + Arena* TempMapArena; VectorArena LookupTable; }; @@ -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(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 = { - .arena = scratch.Arena, - .base_ptr = fileBuffer.Data + sizeof(EntityFileHeader), - .offset = 0, - .loading = true + Archive ar = { + .arena = scratch.Arena, + .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(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(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( - 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**: diff --git a/Game/Plans/04_Entity_Templates_And_Inheritance.md b/Game/Plans/04_Entity_Templates_And_Inheritance.md index f2e7235..f550d2b 100644 --- a/Game/Plans/04_Entity_Templates_And_Inheritance.md +++ b/Game/Plans/04_Entity_Templates_And_Inheritance.md @@ -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(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 { - 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; + DECLARE_CLASS() + + EntityID ID = 0; + 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(entity->Derived) + prop.Offset; + void* instanceField = static_cast(entity->derived) + prop.Offset; void* templateField = isTemplated ? (static_cast(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 instance, NonNullPtr 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 instance, NonNullPtr(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(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(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);