finishing first step of serialization : the basics

This commit is contained in:
2026-09-07 19:42:15 -04:00
parent a462575af4
commit b1de6ccc49
8 changed files with 406 additions and 110 deletions
+13 -4
View File
@@ -2,7 +2,19 @@
#include <Core/Common/serialization.h>
DEFINE_CLASS_VERSIONED(Entity, 1, nullptr, nullptr)
namespace
{
void serialize_entity(Archive& ar, uint16 /*version*/, void* payload)
{
Assert(payload);
Entity* entity = static_cast<Entity*>(payload);
SERIALIZE(ar, id, entity->ID);
SERIALIZE(ar, position, entity->position);
}
} // namespace
DEFINE_CLASS_VERSIONED(Entity, 1, nullptr, serialize_entity)
DEFINE_ENTITY_VERSIONED(Inert, 1, nullptr)
@@ -11,9 +23,6 @@ void serialize(Archive& ar, NonNullPtr<Entity> entity)
// Entity fields
serialize(ar, Entity::kind, entity.Get());
SERIALIZE(ar, id, entity->ID);
SERIALIZE(ar, position, entity->position);
// Derived fields
if (entity->derived_kind != nullptr && entity->derived != nullptr)
{
+3 -3
View File
@@ -8,8 +8,8 @@
#include <Engine/Class.h>
#define DECLARE_ENTITY() \
Entity* base; \
static Class* kind;
Entity* base; \
DECLARE_CLASS()
#define DEFINE_ENTITY_VERSIONED(entity, version, serialize_fct) \
constexpr Class entityKind##entity = MakeClass(ConstString(#entity), (uint8)Entity_Type::entity, (version), \
@@ -22,7 +22,7 @@ using EntityID = uint64_t;
struct Entity final
{
static Class* kind;
DECLARE_CLASS()
EntityID ID = 0;
Class* derived_kind = nullptr;
+121 -97
View File
@@ -24,7 +24,7 @@ The `.jasset` text archive framework is engineered to replace legacy binary blob
- **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.
- **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 version checks (`if (ar.loading && ar.class_version < N)`), seamlessly transforming legacy values without struct pollution or persisting obsolete keys on subsequent saves.
- **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`.
---
@@ -899,14 +899,14 @@ Rather than maintaining dedicated deprecation primitives or polluting C++ struct
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, an `if (ar.loading && ar.class_version < N)` block is added.
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 `class_version` is loaded, the serializer detects `ar.class_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.
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.
```cpp
struct Projectile
@@ -919,18 +919,16 @@ struct Projectile
float Damage = 50.0f;
};
void SerializeProjectile(Archive* arPtr, void* payload)
void SerializeProjectile(Archive& ar, uint16 version, void* payload)
{
Assert(arPtr != nullptr);
Assert(payload != nullptr);
auto& ar = *arPtr;
auto* projectile = static_cast<Projectile*>(payload);
if (ar.loading)
{
SERIALIZE(ar, Damage, projectile->Damage);
if (ar.class_version < 2)
if (version < 2)
{
// Migration from v1: scalar 'Speed' converted to 'VelocityX' on the stack
float deprecated_speed = 0.0f;
@@ -1014,9 +1012,9 @@ void SerializeProjectile(Archive* arPtr, void* payload)
#### Phase 3: Archive Struct & `serialize` Helpers
1. **Target**: `Juliet/include/Core/Common/serialization.h`
- Upgrade `struct Archive` with stream pointer, property array, `loading` flag, and versions (`base_version`, `class_version`).
- 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 && ar.class_version < N)`.
- 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`
@@ -1026,18 +1024,20 @@ void SerializeProjectile(Archive* arPtr, void* payload)
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.
#### 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()`.
---
### 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.
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
// Game/UnitTest/SerializationUnitTest.h
// Juliet/src/UnitTest/SerializationUnitTest.h
#pragma once
#include <Juliet.h>
@@ -1045,13 +1045,13 @@ The unit test suite validates all architectural requirements without modifying e
#if JULIET_DEBUG
namespace UnitTest
{
void RunSerializationUnitTests();
void SerializationUnitTest();
}
#endif
```
```cpp
// Game/UnitTest/SerializationUnitTest.cpp
// Juliet/src/UnitTest/SerializationUnitTest.cpp
#include <UnitTest/SerializationUnitTest.h>
#if JULIET_DEBUG
@@ -1064,40 +1064,10 @@ namespace UnitTest
#include <Core/Logging/LogTypes.h>
#include <Core/Memory/MemoryArena.h>
#include <Core/Thread/ThreadContext.h>
#include <Entity/Entity.h>
#include <Engine/Class.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()
{
@@ -1121,110 +1091,156 @@ namespace UnitTest
.Size = strlen(testContent)
};
ParsedTextArchive parsed = TokenizeTextArchive(temp.Arena, buffer);
Assert(parsed.PropertyCount == 3);
ParsedArchive parsed = tokenize_archive(temp.Arena, buffer);
Assert(parsed.property_count == 3);
auto* healthNode = FindProperty(parsed.Nodes, parsed.PropertyCount, "Health"_crc32);
auto* healthNode = find_property(&parsed, "Health"_crc32);
Assert(healthNode != nullptr);
Assert(StringCompare(healthNode->Value, WrapString("100.500000")) == 0);
Assert(StringCompare(healthNode->value, WrapString("100.500000")) == 0);
auto* nameNode = FindProperty(parsed.Nodes, parsed.PropertyCount, "Name"_crc32);
auto* nameNode = find_property(&parsed, "Name"_crc32);
Assert(nameNode != nullptr);
Assert(StringCompare(nameNode->Value, WrapString("\"Paladin Hero\"")) == 0);
Assert(StringCompare(nameNode->value, WrapString("\"Paladin Hero\"")) == 0);
auto* posNode = FindProperty(parsed.Nodes, parsed.PropertyCount, "Position"_crc32);
auto* posNode = find_property(&parsed, "Position"_crc32);
Assert(posNode != nullptr);
Assert(StringCompare(posNode->Value, WrapString("1.0 2.0 3.0")) == 0);
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<DummyVehicle*>(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 =
"; MaxSpeed\n"
"180.0\n";
"; version\n"
"1\n"
"; max_speed\n"
"180.000000\n";
ByteBuffer buffer = {
.Data = reinterpret_cast<Byte*>(const_cast<char*>(incompleteContent)),
.Size = strlen(incompleteContent)
};
ParsedTextArchive parsed = TokenizeTextArchive(temp.Arena, buffer);
ParsedArchive parsed = tokenize_archive(temp.Arena, buffer);
archive ar = {};
ar.ArenaInstance = temp.Arena;
ar.Mode = ArchiveMode::LoadingText;
ar.Properties = parsed.Nodes;
ar.PropertyCount = parsed.PropertyCount;
Archive ar = {};
ar.arena = temp.Arena;
ar.loading = true;
ar.base = parsed;
DummyVehicle vehicle;
// Defaults: MaxSpeed=120, GearCount=6, Turbo=true
SerializeDummyVehicle(&ar, &vehicle);
// Defaults: max_speed=120, gear_count=6, turbo=true, pos_x=10
serialize(ar, DummyVehicle::kind, &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
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.");
}
// Test 3: Deprecation migration from v1 to v2
struct LegacyWeapon
// Base test class for testing class hierarchy
struct DummyWeaponBase
{
DECLARE_ENTITY()
float VelocityX = 0.0f;
float VelocityY = 0.0f;
DECLARE_CLASS()
float base_damage = 25.0f;
};
void SerializeLegacyWeapon(Archive* arPtr, void* payload)
void serialize_dummy_weapon_base(Archive& ar, uint16 /*version*/, void* payload)
{
Assert(payload != nullptr);
auto* base_weapon = static_cast<DummyWeaponBase*>(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(arPtr != nullptr);
Assert(payload != nullptr);
auto& ar = *arPtr;
auto* weapon = static_cast<LegacyWeapon*>(payload);
if (ar.loading)
{
if (ar.class_version < 2)
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))
if (SERIALIZE(ar, speed, deprecated_speed))
{
weapon->VelocityX = deprecated_speed;
weapon->VelocityY = 0.0f;
weapon->velocity_x = deprecated_speed;
weapon->velocity_y = 0.0f;
}
}
else
{
SERIALIZE(ar, VelocityX, weapon->VelocityX);
SERIALIZE(ar, VelocityY, weapon->VelocityY);
SERIALIZE(ar, velocity_x, weapon->velocity_x);
SERIALIZE(ar, velocity_y, weapon->velocity_y);
}
}
else
{
SERIALIZE(ar, VelocityX, weapon->VelocityX);
SERIALIZE(ar, VelocityY, weapon->VelocityY);
SERIALIZE(ar, velocity_x, weapon->velocity_x);
SERIALIZE(ar, velocity_y, weapon->velocity_y);
}
}
DEFINE_ENTITY_VERSIONED(LegacyWeapon, 2, SerializeLegacyWeapon);
// 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'
// Simulated v1 file containing obsolete 'speed' and class_version 1
const char* v1Content =
"; class_version\n"
"1\n"
"; Speed\n"
"; speed\n"
"75.500000\n";
ByteBuffer buffer = {
@@ -1235,27 +1251,35 @@ namespace UnitTest
ParsedArchive parsed = tokenize_archive(temp.Arena, buffer);
Archive ar = {};
ar.arena = temp.Arena;
ar.loading = true;
ar.base = parsed;
ar.class_version = 1;
ar.arena = temp.Arena;
ar.loading = true;
ar.base = parsed;
LegacyWeapon weapon;
SerializeLegacyWeapon(&ar, &weapon);
serialize(ar, LegacyWeapon::kind, &weapon);
Assert(weapon.VelocityX == 75.5f);
Assert(weapon.VelocityY == 0.0f);
Assert(weapon.velocity_x == 75.5f);
Assert(weapon.velocity_y == 0.0f);
scratch_end(temp);
LogMessage(LogCategory::Core, "TestDeprecationMigration passed.");
}
void RunSerializationUnitTests()
// Test 4: Hierarchical type query (IsA)
static void TestClassInheritance()
{
LogMessage(LogCategory::Core, "=== Running Serialization & .jasset Unit Tests ===");
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