From 4970e225418c0f7a7aea6f12c64b936e4b31396e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:54:41 -0700 Subject: [PATCH] feat(saves): per-persistence entity type table hydrated at AddEntity Co-Authored-By: Claude Fable 5 --- .../GenericEntityPersistenceTypeTableTests.cs | 50 +++++++++++++++++++ .../Serialization/GenericEntityPersistence.cs | 30 +++++++++++ 2 files changed, 80 insertions(+) create mode 100644 Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceTypeTableTests.cs diff --git a/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceTypeTableTests.cs b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceTypeTableTests.cs new file mode 100644 index 000000000..0622411f7 --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/GenericEntityPersistenceTypeTableTests.cs @@ -0,0 +1,50 @@ +using System; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class GenericEntityPersistenceTypeTableTests +{ + private class TypeTablePersistence : GenericEntityPersistence + { + public TypeTablePersistence() : base("TypeTable", 3000, 1, 0x7FFFFFFF) + { + } + + public override void Serialize(IGenericWriter writer) + { + } + + public override void Deserialize(IGenericReader reader) + { + } + } + + [Fact] + public void RegisterTypeAssignsStableInsertionOrderedIndexes() + { + var persistence = new TypeTablePersistence(); + + try + { + persistence.RegisterType(typeof(RoundTripEntity)); + persistence.RegisterType(typeof(string)); + persistence.RegisterType(typeof(RoundTripEntity)); // duplicate is a no-op + + Assert.Equal(2, persistence.TypeTable.Count); + Assert.Same(typeof(RoundTripEntity), persistence.TypeTable[0]); + Assert.Same(typeof(string), persistence.TypeTable[1]); + + Assert.True(persistence.TryGetTypeIndex(typeof(RoundTripEntity), out var first)); + Assert.Equal(0, first); + Assert.True(persistence.TryGetTypeIndex(typeof(string), out var second)); + Assert.Equal(1, second); + Assert.False(persistence.TryGetTypeIndex(typeof(int), out _)); + } + finally + { + persistence.Unregister(); + } + } +} diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index fb98fab31..8044fe97b 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -54,6 +54,35 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer private readonly Dictionary _pendingAdd = new(); private readonly Dictionary _pendingDelete = new(); + // Insertion-ordered table of every entity type added since boot. idx v4 records + // reference types by table index, so entries are never removed — a type whose + // entities were all deleted keeps its slot until restart. Only mutated on the game + // thread (AddEntity, deserialize); only read on the background writer thread during + // WritingSave, when AddEntity diverts to the pending queues. + private readonly Dictionary _typeIndexes = new(); + private readonly List _typeTable = []; + + internal IReadOnlyList TypeTable => _typeTable; + + internal bool TryGetTypeIndex(Type type, out ushort index) => _typeIndexes.TryGetValue(type, out index); + + internal void RegisterType(Type type) + { + ref var index = ref CollectionsMarshal.GetValueRefOrAddDefault(_typeIndexes, type, out var exists); + if (!exists) + { + if (_typeTable.Count > ushort.MaxValue) + { + throw new InvalidOperationException( + $"{Name} exceeded {ushort.MaxValue + 1} distinct entity types." + ); + } + + index = (ushort)_typeTable.Count; + _typeTable.Add(type); + } + } + public Dictionary EntitiesBySerial { get; } = new(); public GenericEntityPersistence(string name, int priority, uint minSerial, uint maxSerial) : this( @@ -715,6 +744,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer case WorldState.PendingSave: case WorldState.Running: { + RegisterType(entity.GetType()); ref var entityEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(EntitiesBySerial, entity.Serial, out var exists); if (exists) {