1322 lines
43 KiB
Markdown
1322 lines
43 KiB
Markdown
# Juliet Game Engine: Serialization Core & .jasset Text Archive
|
|
## Technical Specification & Implementation Plan
|
|
|
|
**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`
|
|
|
|
---
|
|
|
|
## 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.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.
|
|
- **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.
|
|
- **Post-Serialization In-Place Migration**: Deprecated fields can be read through specialized primitives (`ReadDeprecated*`) to migrate legacy data structures in-place upon loading, producing cleaned, modern schemas 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`.
|
|
|
|
---
|
|
|
|
## 2. The `.jasset` Text Format Specification
|
|
|
|
### 2.1 Grammar & Structural Rules
|
|
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_]* ;
|
|
```
|
|
|
|
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.
|
|
|
|
### 2.2 Formatting Specifications & Examples
|
|
|
|
#### 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`)
|
|
```
|
|
# Juliet Game Engine Asset File
|
|
# Generated automatically by AssetPipeline. Do not manually corrupt keys.
|
|
|
|
; AssetType
|
|
Entity
|
|
|
|
; BaseVersion
|
|
1
|
|
|
|
; EntityID
|
|
0x0000000000000042
|
|
|
|
; Kind
|
|
Inert
|
|
|
|
; Position
|
|
100.000000 25.500000 -50.000000
|
|
|
|
; ClassVersion
|
|
2
|
|
|
|
; MeshInstance
|
|
4
|
|
|
|
; MaterialOverride
|
|
"Materials/M_Granite_Polished"
|
|
|
|
; IsVisible
|
|
true
|
|
```
|
|
|
|
---
|
|
|
|
## 3. The In-Memory Zero-Copy Parser
|
|
|
|
### 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`.
|
|
|
|
```cpp
|
|
struct TextPropertyNode
|
|
{
|
|
uint32 KeyCRC;
|
|
String Value;
|
|
bool Consumed;
|
|
};
|
|
```
|
|
|
|
- `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.
|
|
|
|
```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<uint8>(*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);
|
|
}
|
|
```
|
|
|
|
### 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.
|
|
|
|
```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> arena, ByteBuffer fileBuffer)
|
|
{
|
|
Assert(fileBuffer.Data != nullptr);
|
|
|
|
char* cursor = reinterpret_cast<char*>(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<TextPropertyNode>(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<size_t>(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<size_t>(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 };
|
|
}
|
|
```
|
|
|
|
### 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.
|
|
|
|
```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;
|
|
}
|
|
|
|
#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<int>(nodes[index].Value.Size), nodes[index].Value.Str);
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
```
|
|
|
|
---
|
|
|
|
## 4. The `archive` Struct & Mode Handling
|
|
|
|
### 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.
|
|
|
|
```cpp
|
|
enum class ArchiveMode : uint8
|
|
{
|
|
SavingText,
|
|
LoadingText,
|
|
SavingBinary,
|
|
LoadingBinary
|
|
};
|
|
|
|
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;
|
|
}
|
|
};
|
|
```
|
|
|
|
### 4.2 Output Stream Formatting via `IOPrintf`
|
|
When saving in `ArchiveMode::SavingText`, `archive` outputs directly to an open `IOStream` using `IOPrintf`.
|
|
|
|
```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);
|
|
}
|
|
```
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## 5. `SerializeProp` API & Implementation
|
|
|
|
### 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.
|
|
|
|
```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)
|
|
{
|
|
Assert(keyName != nullptr);
|
|
if (ar.IsSaving())
|
|
{
|
|
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<int32>(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)
|
|
{
|
|
IOPrintf(ar.Stream, "\"%.*s\"\n\n", static_cast<int>(value.Size), value.Str);
|
|
}
|
|
else
|
|
{
|
|
IOPrintf(ar.Stream, "%.*s\n\n", static_cast<int>(value.Size), value.Str);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
auto* node = FindProperty(ar.Properties, ar.PropertyCount, keyCRC);
|
|
if (node == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Two-Tier Versioning 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.
|
|
|
|
#### 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.
|
|
|
|
#### The Two-Tier Solution
|
|
Juliet decouples versioning into two independent tiers:
|
|
- **Tier 1: Base Engine Version (`kEntityBaseVersion`)**: Declared centrally in `Entity.h`. Governs `Entity` base fields.
|
|
- **Tier 2: Derived Class Version (`Class::Version`)**: Declared per-entity class in `Class.h` and initialized in `DEFINE_ENTITY_VERSIONED`.
|
|
|
|
```
|
|
========================================================================
|
|
.jasset Text Archive
|
|
========================================================================
|
|
; AssetType
|
|
Entity
|
|
; BaseVersion ------> Governed by kEntityBaseVersion in Entity.h
|
|
1
|
|
; EntityID
|
|
0x0000000000000001
|
|
; Kind
|
|
Inert
|
|
; Position
|
|
0.0 0.0 0.0
|
|
------------------------------------------------------------------------
|
|
; ClassVersion ------> Governed by 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.
|
|
|
|
### 6.2 Implementation Details
|
|
|
|
#### Engine Base Version (`Game/Entity/Entity.h`)
|
|
```cpp
|
|
constexpr uint32 kEntityBaseVersion = 1;
|
|
|
|
void SerializeEntityBase(archive& ar, NonNullPtr<Entity> entity);
|
|
```
|
|
|
|
#### Derived Class Version (`Juliet/include/Engine/Class.h`)
|
|
```cpp
|
|
struct Class
|
|
{
|
|
uint32 CRC;
|
|
uint8 kind;
|
|
uint16 Version; // Added derived class version
|
|
serialize_fct_type serialize_fct;
|
|
size_t size_of;
|
|
size_t alignment;
|
|
|
|
#if JULIET_DEBUG
|
|
String Name;
|
|
#endif
|
|
};
|
|
|
|
consteval Class MakeClass(String name, uint8 kind, uint16 version, 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.size_of = size;
|
|
cls.alignment = align;
|
|
cls.serialize_fct = fct;
|
|
|
|
#if JULIET_DEBUG
|
|
cls.Name = name;
|
|
#endif
|
|
|
|
return cls;
|
|
}
|
|
```
|
|
|
|
#### Class Registration Macros (`Game/Entity/Entity.h`)
|
|
```cpp
|
|
#define DEFINE_ENTITY_VERSIONED(entity, version, serialize_fct) \
|
|
Class entityKind##entity = \
|
|
MakeClass(ConstString(#entity), (uint8)Entity_Type::entity, (version), sizeof(entity), alignof(entity), serialize_fct); \
|
|
Class* entity::Kind = &entityKind##entity;
|
|
```
|
|
|
|
### 6.3 Execution Flow in `SerializeEntity`
|
|
```cpp
|
|
void Serialize(archive& ar, NonNullPtr<Entity> entity)
|
|
{
|
|
Assert(entity.Get() != nullptr);
|
|
|
|
// --- Tier 1: Base Entity Serialization ---
|
|
if (ar.IsSaving())
|
|
{
|
|
uint32 baseVer = kEntityBaseVersion;
|
|
SERIALIZE_PROP(ar, baseVer);
|
|
SERIALIZE_PROP(ar, entity->ID);
|
|
|
|
String kindStr = WrapString(kEntity_type_names[entity->Kind->kind]);
|
|
SerializeProp(ar, "Kind", "Kind"_crc32, kindStr);
|
|
|
|
SerializeProp(ar, "Position", "Position"_crc32, entity->X, entity->Y, entity->Z);
|
|
}
|
|
else
|
|
{
|
|
uint32 baseVer = 0;
|
|
if (!SerializeProp(ar, "BaseVersion", "BaseVersion"_crc32, baseVer))
|
|
{
|
|
baseVer = 1; // Default to initial schema if absent
|
|
}
|
|
ar.BaseVersion = baseVer;
|
|
|
|
SERIALIZE_PROP(ar, entity->ID);
|
|
|
|
String kindStr = {};
|
|
if (SerializeProp(ar, "Kind", "Kind"_crc32, kindStr))
|
|
{
|
|
// Resolve class pointer from name
|
|
for (uint8 i = 0; i < ToUnderlying(Entity_Type::Count); ++i)
|
|
{
|
|
if (StringCompare(kindStr, WrapString(kEntity_type_names[i])) == 0)
|
|
{
|
|
entity->Kind = kEntity_type_class_ptr[i];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Assert(entity->Kind != nullptr);
|
|
|
|
SerializeProp(ar, "Position", "Position"_crc32, entity->X, entity->Y, entity->Z);
|
|
}
|
|
|
|
// --- Tier 2: Derived Entity Serialization ---
|
|
if (entity->Kind->serialize_fct != nullptr && entity->Derived != nullptr)
|
|
{
|
|
if (ar.IsSaving())
|
|
{
|
|
uint32 classVer = entity->Kind->Version;
|
|
SerializeProp(ar, "ClassVersion", "ClassVersion"_crc32, classVer);
|
|
}
|
|
else
|
|
{
|
|
uint32 classVer = 0;
|
|
if (!SerializeProp(ar, "ClassVersion", "ClassVersion"_crc32, classVer))
|
|
{
|
|
classVer = entity->Kind->Version;
|
|
}
|
|
ar.ClassVersion = static_cast<uint16>(classVer);
|
|
}
|
|
|
|
entity->Kind->serialize_fct(&ar, entity->Derived);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Post-Serialization Deprecation & Migration
|
|
|
|
### 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.
|
|
|
|
Retaining deprecated members in active C++ structs creates code clutter, wastes memory, and invites bugs.
|
|
|
|
### 7.2 Deprecation Primitives (`ReadDeprecated*`)
|
|
Deprecation primitives allow serializers to ingest obsolete properties exclusively during loading without polluting modern structs or writing deprecated keys back to disk during saving.
|
|
|
|
```cpp
|
|
bool ReadDeprecated(archive& ar, const char* keyName, uint32 keyCRC, float& outVal)
|
|
{
|
|
Assert(keyName != nullptr);
|
|
if (ar.IsSaving())
|
|
{
|
|
return false; // Deprecated fields are never saved
|
|
}
|
|
|
|
auto* node = FindProperty(ar.Properties, ar.PropertyCount, keyCRC);
|
|
if (node == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
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)
|
|
{
|
|
outVal = parsed;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool ReadDeprecatedVec2(archive& ar, const char* keyName, uint32 keyCRC, float& outX, float& outY)
|
|
{
|
|
Assert(keyName != nullptr);
|
|
if (ar.IsSaving())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
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 x = 0.0f;
|
|
float y = 0.0f;
|
|
if (sscanf_s(buffer, "%f %f", &x, &y) == 2)
|
|
{
|
|
outX = x;
|
|
outY = y;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool ReadDeprecatedString(archive& ar, const char* keyName, uint32 keyCRC, NonNullPtr<Arena> arena, String& outVal)
|
|
{
|
|
Assert(keyName != nullptr);
|
|
if (ar.IsSaving())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
auto* node = FindProperty(ar.Properties, ar.PropertyCount, keyCRC);
|
|
if (node == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
String parsed = node->Value;
|
|
if (parsed.Size >= 2 && parsed.Str[0] == '"' && parsed.Str[parsed.Size - 1] == '"')
|
|
{
|
|
parsed.Str++;
|
|
parsed.Size -= 2;
|
|
}
|
|
outVal = StringCopy(arena, parsed);
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### 7.3 In-Place Migration Pattern
|
|
When an asset file with an older `ClassVersion` is loaded, the derived serializer detects `ar.ClassVersion < N`, calls `ReadDeprecated*` to read obsolete fields, maps the legacy data into the modern struct, and continues. On the subsequent save, the asset file is emitted using the modern schema without deprecated keys.
|
|
|
|
```cpp
|
|
struct Projectile
|
|
{
|
|
DECLARE_ENTITY()
|
|
|
|
// Modern Schema (v2)
|
|
float VelocityX = 0.0f;
|
|
float VelocityY = 0.0f;
|
|
float Damage = 50.0f;
|
|
};
|
|
|
|
void SerializeProjectile(archive* arPtr, void* payload)
|
|
{
|
|
Assert(arPtr != nullptr);
|
|
Assert(payload != nullptr);
|
|
auto& ar = *arPtr;
|
|
auto* projectile = static_cast<Projectile*>(payload);
|
|
|
|
if (ar.IsSaving())
|
|
{
|
|
SERIALIZE_PROP(ar, projectile->VelocityX);
|
|
SERIALIZE_PROP(ar, projectile->VelocityY);
|
|
SERIALIZE_PROP(ar, projectile->Damage);
|
|
}
|
|
else
|
|
{
|
|
SERIALIZE_PROP(ar, projectile->Damage);
|
|
|
|
if (ar.ClassVersion < 2)
|
|
{
|
|
// Migration from v1: scalar 'Speed' converted to 'VelocityX'
|
|
float legacySpeed = 0.0f;
|
|
if (ReadDeprecated(ar, "Speed", "Speed"_crc32, legacySpeed))
|
|
{
|
|
projectile->VelocityX = legacySpeed;
|
|
projectile->VelocityY = 0.0f;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SERIALIZE_PROP(ar, projectile->VelocityX);
|
|
SERIALIZE_PROP(ar, projectile->VelocityY);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Step-by-Step Implementation Roadmap & Unit Testing Plan
|
|
|
|
### 8.1 Implementation Roadmap
|
|
|
|
```
|
|
+-------------------------------------------------------------------------+
|
|
| 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 & SerializeProp Helpers |
|
|
| - Upgrade archive in serialization.h with ArchiveMode |
|
|
| - Implement primitive, vector, and string SerializeProp helpers |
|
|
| - Implement ReadDeprecated* primitives |
|
|
+-------------------------------------------------------------------------+
|
|
|
|
|
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 |
|
|
+-------------------------------------------------------------------------+
|
|
```
|
|
|
|
#### 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> arena, ByteBuffer buffer)`.
|
|
- Implement `FindProperty` and `AuditUnconsumedProperties`.
|
|
|
|
#### Phase 3: Archive Struct & `SerializeProp` Helpers
|
|
1. **Target**: `Juliet/include/Core/Common/serialization.h`
|
|
- Introduce `enum class ArchiveMode : uint8`.
|
|
- Upgrade `struct archive` with stream pointer, property array, mode, and versions.
|
|
- Implement overloaded `SerializeProp` for `float`, `int32`, `uint64`, `bool`, vectors, and `String`.
|
|
- Implement `ReadDeprecated`, `ReadDeprecatedVec2`, `ReadDeprecatedString`.
|
|
|
|
#### 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> 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
|
|
1. **Target**: `Game/UnitTest/SerializationUnitTest.h` and `Game/UnitTest/SerializationUnitTest.cpp`
|
|
- Add unit tests verifying parsing, round-trip serialization, defaults preservation, versioning, and deprecation.
|
|
|
|
---
|
|
|
|
### 8.2 Comprehensive Unit Testing Plan (`SerializationUnitTest.cpp`)
|
|
|
|
The unit test suite validates all architectural requirements without modifying engine framework code for test-specific cases.
|
|
|
|
```cpp
|
|
// Game/UnitTest/SerializationUnitTest.h
|
|
#pragma once
|
|
|
|
#include <Juliet.h>
|
|
|
|
#if JULIET_DEBUG
|
|
namespace UnitTest
|
|
{
|
|
void RunSerializationUnitTests();
|
|
}
|
|
#endif
|
|
```
|
|
|
|
```cpp
|
|
// Game/UnitTest/SerializationUnitTest.cpp
|
|
#include <UnitTest/SerializationUnitTest.h>
|
|
|
|
#if JULIET_DEBUG
|
|
|
|
#include <Core/Common/CRC32.h>
|
|
#include <Core/Common/String.h>
|
|
#include <Core/Common/serialization.h>
|
|
#include <Core/HAL/IO/IOStream.h>
|
|
#include <Core/Logging/LogManager.h>
|
|
#include <Core/Logging/LogTypes.h>
|
|
#include <Core/Memory/MemoryArena.h>
|
|
#include <Core/Thread/ThreadContext.h>
|
|
#include <Entity/Entity.h>
|
|
|
|
namespace UnitTest
|
|
{
|
|
// Test Struct for Derived Entity Testing
|
|
struct DummyVehicle
|
|
{
|
|
DECLARE_ENTITY()
|
|
|
|
float MaxSpeed = 120.0f;
|
|
int32 GearCount = 6;
|
|
uint64 ChassisUUID = 0xABCDEF0123456789ULL;
|
|
bool Turbo = true;
|
|
float PosX = 10.0f;
|
|
float PosY = 20.0f;
|
|
float PosZ = 30.0f;
|
|
};
|
|
|
|
void SerializeDummyVehicle(archive* arPtr, void* payload)
|
|
{
|
|
Assert(arPtr != nullptr);
|
|
Assert(payload != nullptr);
|
|
auto& ar = *arPtr;
|
|
auto* vehicle = static_cast<DummyVehicle*>(payload);
|
|
|
|
SERIALIZE_PROP(ar, vehicle->MaxSpeed);
|
|
SERIALIZE_PROP(ar, vehicle->GearCount);
|
|
SERIALIZE_PROP(ar, vehicle->ChassisUUID);
|
|
SERIALIZE_PROP(ar, vehicle->Turbo);
|
|
SerializeProp(ar, "Position", "Position"_crc32, vehicle->PosX, vehicle->PosY, vehicle->PosZ);
|
|
}
|
|
|
|
DEFINE_ENTITY_VERSIONED(DummyVehicle, 1, SerializeDummyVehicle);
|
|
|
|
// 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<Byte*>(const_cast<char*>(testContent)),
|
|
.Size = strlen(testContent)
|
|
};
|
|
|
|
ParsedTextArchive parsed = TokenizeTextArchive(temp.Arena, buffer);
|
|
Assert(parsed.PropertyCount == 3);
|
|
|
|
auto* healthNode = FindProperty(parsed.Nodes, parsed.PropertyCount, "Health"_crc32);
|
|
Assert(healthNode != nullptr);
|
|
Assert(StringCompare(healthNode->Value, WrapString("100.500000")) == 0);
|
|
|
|
auto* nameNode = FindProperty(parsed.Nodes, parsed.PropertyCount, "Name"_crc32);
|
|
Assert(nameNode != nullptr);
|
|
Assert(StringCompare(nameNode->Value, WrapString("\"Paladin Hero\"")) == 0);
|
|
|
|
auto* posNode = FindProperty(parsed.Nodes, parsed.PropertyCount, "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 2: Missing properties retain default struct values
|
|
static void TestDefaultValueRetention()
|
|
{
|
|
TempArena temp = scratch_begin(nullptr, 0);
|
|
|
|
const char* incompleteContent =
|
|
"; MaxSpeed\n"
|
|
"180.0\n";
|
|
|
|
ByteBuffer buffer = {
|
|
.Data = reinterpret_cast<Byte*>(const_cast<char*>(incompleteContent)),
|
|
.Size = strlen(incompleteContent)
|
|
};
|
|
|
|
ParsedTextArchive parsed = TokenizeTextArchive(temp.Arena, buffer);
|
|
|
|
archive ar = {};
|
|
ar.ArenaInstance = temp.Arena;
|
|
ar.Mode = ArchiveMode::LoadingText;
|
|
ar.Properties = parsed.Nodes;
|
|
ar.PropertyCount = parsed.PropertyCount;
|
|
|
|
DummyVehicle vehicle;
|
|
// Defaults: MaxSpeed=120, GearCount=6, Turbo=true
|
|
SerializeDummyVehicle(&ar, &vehicle);
|
|
|
|
Assert(vehicle.MaxSpeed == 180.0f); // Overwritten by archive
|
|
Assert(vehicle.GearCount == 6); // Preserved default
|
|
Assert(vehicle.Turbo == true); // Preserved default
|
|
Assert(vehicle.PosX == 10.0f); // Preserved default
|
|
|
|
scratch_end(temp);
|
|
LogMessage(LogCategory::Core, "TestDefaultValueRetention passed.");
|
|
}
|
|
|
|
// Test 3: Deprecation migration from v1 to v2
|
|
struct LegacyWeapon
|
|
{
|
|
DECLARE_ENTITY()
|
|
float VelocityX = 0.0f;
|
|
float VelocityY = 0.0f;
|
|
};
|
|
|
|
void SerializeLegacyWeapon(archive* arPtr, void* payload)
|
|
{
|
|
Assert(arPtr != nullptr);
|
|
Assert(payload != nullptr);
|
|
auto& ar = *arPtr;
|
|
auto* weapon = static_cast<LegacyWeapon*>(payload);
|
|
|
|
if (ar.IsSaving())
|
|
{
|
|
SERIALIZE_PROP(ar, weapon->VelocityX);
|
|
SERIALIZE_PROP(ar, weapon->VelocityY);
|
|
}
|
|
else
|
|
{
|
|
if (ar.ClassVersion < 2)
|
|
{
|
|
float oldSpeed = 0.0f;
|
|
if (ReadDeprecated(ar, "Speed", "Speed"_crc32, oldSpeed))
|
|
{
|
|
weapon->VelocityX = oldSpeed;
|
|
weapon->VelocityY = 0.0f;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SERIALIZE_PROP(ar, weapon->VelocityX);
|
|
SERIALIZE_PROP(ar, weapon->VelocityY);
|
|
}
|
|
}
|
|
}
|
|
|
|
DEFINE_ENTITY_VERSIONED(LegacyWeapon, 2, SerializeLegacyWeapon);
|
|
|
|
static void TestDeprecationMigration()
|
|
{
|
|
TempArena temp = scratch_begin(nullptr, 0);
|
|
|
|
// Simulated v1 file containing obsolete 'Speed'
|
|
const char* v1Content =
|
|
"; ClassVersion\n"
|
|
"1\n"
|
|
"; Speed\n"
|
|
"75.500000\n";
|
|
|
|
ByteBuffer buffer = {
|
|
.Data = reinterpret_cast<Byte*>(const_cast<char*>(v1Content)),
|
|
.Size = strlen(v1Content)
|
|
};
|
|
|
|
ParsedTextArchive parsed = TokenizeTextArchive(temp.Arena, buffer);
|
|
|
|
archive ar = {};
|
|
ar.ArenaInstance = temp.Arena;
|
|
ar.Mode = ArchiveMode::LoadingText;
|
|
ar.Properties = parsed.Nodes;
|
|
ar.PropertyCount = parsed.PropertyCount;
|
|
ar.ClassVersion = 1;
|
|
|
|
LegacyWeapon weapon;
|
|
SerializeLegacyWeapon(&ar, &weapon);
|
|
|
|
Assert(weapon.VelocityX == 75.5f);
|
|
Assert(weapon.VelocityY == 0.0f);
|
|
|
|
scratch_end(temp);
|
|
LogMessage(LogCategory::Core, "TestDeprecationMigration passed.");
|
|
}
|
|
|
|
void RunSerializationUnitTests()
|
|
{
|
|
LogMessage(LogCategory::Core, "=== Running Serialization & .jasset Unit Tests ===");
|
|
TestParserTokenization();
|
|
TestDefaultValueRetention();
|
|
TestDeprecationMigration();
|
|
LogMessage(LogCategory::Core, "=== All Serialization Unit Tests Passed Successfully ===");
|
|
}
|
|
} // namespace UnitTest
|
|
|
|
#endif
|