From b1de6ccc4921a57e196a5c606fefa5b568991e2e Mon Sep 17 00:00:00 2001 From: Patedam Date: Mon, 7 Sep 2026 19:42:15 -0400 Subject: [PATCH] finishing first step of serialization : the basics --- Game/Entity/Entity.cpp | 17 +- Game/Entity/Entity.h | 6 +- .../01_Serialization_And_Text_Archive.md | 218 ++++++++------- Juliet/include/Core/Common/CoreUtils.h | 1 + Juliet/include/Core/Common/serialization.h | 7 +- Juliet/include/Engine/Class.h | 4 +- Juliet/src/UnitTest/RunUnitTests.cpp | 4 +- Juliet/src/UnitTest/serialization_test.cpp | 259 ++++++++++++++++++ 8 files changed, 406 insertions(+), 110 deletions(-) create mode 100644 Juliet/src/UnitTest/serialization_test.cpp diff --git a/Game/Entity/Entity.cpp b/Game/Entity/Entity.cpp index 320b546..11e2c11 100644 --- a/Game/Entity/Entity.cpp +++ b/Game/Entity/Entity.cpp @@ -2,7 +2,19 @@ #include -DEFINE_CLASS_VERSIONED(Entity, 1, nullptr, nullptr) +namespace +{ + void serialize_entity(Archive& ar, uint16 /*version*/, void* payload) + { + Assert(payload); + Entity* entity = static_cast(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 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) { diff --git a/Game/Entity/Entity.h b/Game/Entity/Entity.h index 8639980..1d05a94 100644 --- a/Game/Entity/Entity.h +++ b/Game/Entity/Entity.h @@ -8,8 +8,8 @@ #include #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; diff --git a/Game/Plans/01_Serialization_And_Text_Archive.md b/Game/Plans/01_Serialization_And_Text_Archive.md index 3e0c0cd..0654dce 100644 --- a/Game/Plans/01_Serialization_And_Text_Archive.md +++ b/Game/Plans/01_Serialization_And_Text_Archive.md @@ -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(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 @@ -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 #if JULIET_DEBUG @@ -1064,40 +1064,10 @@ namespace UnitTest #include #include #include -#include +#include 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(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(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(const_cast(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(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(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 diff --git a/Juliet/include/Core/Common/CoreUtils.h b/Juliet/include/Core/Common/CoreUtils.h index ff391c1..d0efea9 100644 --- a/Juliet/include/Core/Common/CoreUtils.h +++ b/Juliet/include/Core/Common/CoreUtils.h @@ -3,6 +3,7 @@ #include #define global static +#define internal static // 1. Stringify helpers #define JULIET_STR(x) #x diff --git a/Juliet/include/Core/Common/serialization.h b/Juliet/include/Core/Common/serialization.h index e7f37d1..9f65b61 100644 --- a/Juliet/include/Core/Common/serialization.h +++ b/Juliet/include/Core/Common/serialization.h @@ -38,10 +38,8 @@ struct Archive { Arena* arena; bool loading; - ParsedArchive base = {}; - IOStream* stream = nullptr; - uint32 base_version = 0; - uint16 class_version = 0; + ParsedArchive base = {}; + IOStream* stream = nullptr; // to remove void* base_ptr = nullptr; @@ -94,6 +92,7 @@ bool read_prop(Archive& /*ar*/, String value_raw, Type& value) } #define SERIALIZE(ar, name, var) serialize((ar), ConstString(#name), #name##_crc32, (var)) +#define SERIALIZE_SIMPLE(ar, var) SERIALIZE(ar, var, var) template bool serialize(Archive& ar, String property_name, uint32 property_crc, Type& value) diff --git a/Juliet/include/Engine/Class.h b/Juliet/include/Engine/Class.h index 7863748..22f9da7 100644 --- a/Juliet/include/Engine/Class.h +++ b/Juliet/include/Engine/Class.h @@ -24,6 +24,8 @@ struct Class #endif }; +#define DECLARE_CLASS() static Class* kind; + #define DEFINE_CLASS_VERSIONED(cls, version, base_class, serialize_fct) \ constexpr Class classKind##cls = \ MakeClass(ConstString(#cls), 0, (version), (base_class), sizeof(cls), alignof(cls), (serialize_fct)); \ @@ -53,7 +55,7 @@ bool IsA(const Class& query, const Class* target); template bool IsA(const Class& cls) { - return IsA(cls, type::StaticClass); + return IsA(cls, type::kind); } JULIET_API void serialize(Archive& ar, NonNullPtr cls, void* instance); diff --git a/Juliet/src/UnitTest/RunUnitTests.cpp b/Juliet/src/UnitTest/RunUnitTests.cpp index d0db95d..2eee895 100644 --- a/Juliet/src/UnitTest/RunUnitTests.cpp +++ b/Juliet/src/UnitTest/RunUnitTests.cpp @@ -1,4 +1,4 @@ -#include +#include #if JULIET_DEBUG @@ -10,6 +10,7 @@ namespace UnitTest // Forward declare the VectorUnitTest function void VectorUnitTest(); void TestMemoryArena(); + void SerializationUnitTest(); void RunUnitTests() { @@ -17,6 +18,7 @@ namespace UnitTest TestMemoryArena(); VectorUnitTest(); + SerializationUnitTest(); LogMessage(LogCategory::Core, "Unit Tests Completed Successfully."); } diff --git a/Juliet/src/UnitTest/serialization_test.cpp b/Juliet/src/UnitTest/serialization_test.cpp new file mode 100644 index 0000000..c2e6688 --- /dev/null +++ b/Juliet/src/UnitTest/serialization_test.cpp @@ -0,0 +1,259 @@ +#if JULIET_DEBUG + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UnitTest +{ + // Test 1: Parser tokenization with whitespace, comments (# and //), and CRLF / LF line endings + internal void test_parser_tokenization() + { + TempArena temp = scratch_begin(nullptr, 0); + + String test_content = ConstString("# 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 4.0\r\n"); + + ByteBuffer buffer = { .Data = (Byte*)test_content.Str, .Size = test_content.Size }; + + ParsedArchive parsed = tokenize_archive(temp.Arena, buffer); + Assert(parsed.property_count == 3); + + auto* health_node = find_property(&parsed, "Health"_crc32); + Assert(health_node != nullptr); + Assert(StringCompare(health_node->value, WrapString("100.500000")) == 0); + + auto* name_node = find_property(&parsed, "Name"_crc32); + Assert(name_node != nullptr); + Assert(StringCompare(name_node->value, WrapString("\"Paladin Hero\"")) == 0); + + auto* pos_node = find_property(&parsed, "Position"_crc32); + Assert(pos_node != nullptr); + Assert(StringCompare(pos_node->value, WrapString("1.0 2.0 3.0 4.0")) == 0); + + scratch_end(temp); + LogMessage(LogCategory::Core, "test_parser_tokenization passed."); + } + + // Test 2: Missing properties retain default struct values + 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; + }; + + internal 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) + + internal void test_default_value_retention() + { + TempArena temp = scratch_begin(nullptr, 0); + + String incomplete_content = ConstString("; version\n" + "1\n" + "; max_speed\n" + "180.000000\n"); + + ByteBuffer buffer = { .Data = (Byte*)incomplete_content.Str, .Size = incomplete_content.Size }; + + 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, "test_default_value_retention passed."); + } + + // Base test class for testing hierarchy + struct DummyWeaponBase + { + DECLARE_CLASS() + + float base_damage = 25.0f; + }; + + internal 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; + }; + + internal 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 + internal void test_deprecation_migration() + { + TempArena temp = scratch_begin(nullptr, 0); + + // Simulated v1 file containing obsolete 'speed' and class_version 1 + String v1_content = ConstString("; class_version\n" + "1\n" + "; speed\n" + "75.500000\n"); + + ByteBuffer buffer = { .Data = (Byte*)v1_content.Str, .Size = v1_content.Size }; + + 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, "test_deprecation_migration passed."); + } + + // Test 4: Hierarchical type query (IsA) + internal void test_class_inheritance() + { + Assert(IsA(*LegacyWeapon::kind)); + Assert(!IsA(*DummyWeaponBase::kind)); + Assert(!IsA(*DummyVehicle::kind)); + LogMessage(LogCategory::Core, "test_class_inheritance passed."); + } + + // Test 5: String and Vector4 read_prop serialization + internal void test_string_and_vector4() + { + TempArena temp = scratch_begin(nullptr, 0); + + String content = ConstString("; title\n" + "\"Sword of Destiny\"\n" + "; position\n" + "10.0 20.0 30.0 1.0\n"); + + ByteBuffer buffer = { .Data = (Byte*)content.Str, .Size = content.Size }; + + ParsedArchive parsed = tokenize_archive(temp.Arena, buffer); + + Archive ar = {}; + ar.arena = temp.Arena; + ar.loading = true; + ar.base = parsed; + + String title = {}; + Vector4 position = {}; + + bool read_title = SERIALIZE_SIMPLE(ar, title); + bool read_pos = SERIALIZE_SIMPLE(ar, position); + + Assert(read_title); + Assert(StringCompare(title, WrapString("Sword of Destiny")) == 0); + + Assert(read_pos); + Assert(position.x == 10.0f); + Assert(position.y == 20.0f); + Assert(position.z == 30.0f); + Assert(position.w == 1.0f); + + scratch_end(temp); + LogMessage(LogCategory::Core, "test_string_and_vector4 passed."); + } + + void SerializationUnitTest() + { + LogMessage(LogCategory::Core, "=== Running Serialization & .jasset Unit Tests (Juliet) ==="); + test_parser_tokenization(); + test_default_value_retention(); + test_deprecation_migration(); + test_class_inheritance(); + test_string_and_vector4(); + LogMessage(LogCategory::Core, "=== All Serialization Unit Tests Passed Successfully ==="); + } +} // namespace UnitTest + +#endif