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:
hyper
2026-07-22 18:59:31 +08:00
parent a6287911aa
commit d73b7ebf37
5 changed files with 431 additions and 1 deletions
+11
View File
@@ -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);
+25
View File
@@ -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
View File
@@ -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
}
}
}
}
}