feat(serialization): Implement world save/load with MessagePack
Add EntityFormatter to handle readonly fields, fix relationship target serialization, and add edge case tests for ReorderSources, RemoveSingleton, and ReadComponent/ReadSingleton.
This commit is contained in:
@@ -8,6 +8,7 @@ namespace OECS;
|
||||
/// use-after-free bugs when entity IDs are recycled.
|
||||
/// </summary>
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
[MessagePackFormatter(typeof(EntityFormatter))]
|
||||
public readonly partial struct Entity : IEquatable<Entity>
|
||||
{
|
||||
private const uint IdMask = 0x00FF_FFFF; // lower 24 bits
|
||||
@@ -57,6 +58,16 @@ public readonly partial struct Entity : IEquatable<Entity>
|
||||
/// </summary>
|
||||
internal Entity WithVersion(uint version) => new(Id, version);
|
||||
|
||||
/// <summary>
|
||||
/// Converts the entity to its raw 32-bit packed representation.
|
||||
/// </summary>
|
||||
public static explicit operator uint(Entity entity) => entity._value;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an entity from its raw 32-bit packed representation.
|
||||
/// </summary>
|
||||
public static explicit operator Entity(uint value) => new(value);
|
||||
|
||||
public bool Equals(Entity other) => _value == other._value;
|
||||
|
||||
public override bool Equals(object? obj) => obj is Entity other && Equals(other);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using MessagePack;
|
||||
using MessagePack.Formatters;
|
||||
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Custom MessagePack formatter for <see cref="Entity"/>.
|
||||
///
|
||||
/// Reads and writes the raw 32-bit packed value as a single uint.
|
||||
/// This avoids the readonly-field issue where MessagePack's built-in
|
||||
/// deserialization path (reflection-based field assignment) cannot
|
||||
/// write to <c>readonly</c> fields on value types.
|
||||
/// </summary>
|
||||
public class EntityFormatter : IMessagePackFormatter<Entity>
|
||||
{
|
||||
public void Serialize(ref MessagePackWriter writer, Entity value, MessagePackSerializerOptions options)
|
||||
{
|
||||
writer.Write((uint)value);
|
||||
}
|
||||
|
||||
public Entity Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
|
||||
{
|
||||
return (Entity)reader.ReadUInt32();
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -19,6 +19,12 @@ public static class WorldSerializer
|
||||
{
|
||||
var entityComponents = new Dictionary<Entity, List<ComponentEntry>>();
|
||||
|
||||
// Collect relationship target entities that may have no components.
|
||||
// They must be serialized so that Relationship.Target references remain
|
||||
// valid after deserialization (bare entity handles round-trip correctly
|
||||
// thanks to the EntityFormatter which reads/writes the raw uint).
|
||||
var relationshipTargets = new HashSet<Entity>();
|
||||
|
||||
foreach (var desc in ComponentRegistry.Descriptors)
|
||||
{
|
||||
var set = world.Components.GetSet(desc.Type);
|
||||
@@ -41,9 +47,20 @@ public static class WorldSerializer
|
||||
TypeName = desc.TypeName,
|
||||
Data = desc.Serialize(component)
|
||||
});
|
||||
|
||||
// Track relationship targets.
|
||||
if (component is IRelationship rel && rel.Target != Entity.Null)
|
||||
relationshipTargets.Add(rel.Target);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure bare relationship target entities appear in the snapshot.
|
||||
foreach (var target in relationshipTargets)
|
||||
{
|
||||
if (!entityComponents.ContainsKey(target))
|
||||
entityComponents[target] = new List<ComponentEntry>();
|
||||
}
|
||||
|
||||
var snapshot = new WorldSnapshot
|
||||
{
|
||||
Entities = entityComponents
|
||||
@@ -116,4 +133,4 @@ public static class WorldSerializer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user