From 96ba037432690315d449a59644750316c42a8313 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 18 Jul 2026 23:41:28 +0800 Subject: [PATCH] feat: add component discovery source generator Introduce an incremental source generator that automatically discovers component types used with the World API. This allows the WorldSerializer to perform serialization and deserialization without relying on reflection, improving performance and stability. --- OECS.sln | 61 ++++- examples/Blackjack/Blackjack.csproj | 5 + examples/TicTacToe/Program.cs | 4 +- examples/TicTacToe/Systems/WinCheckSystem.cs | 8 +- examples/TicTacToe/TicTacToe.csproj | 7 +- .../ComponentDiscoveryGenerator.cs | 226 ++++++++++++++++++ src/OECS.SourceGen/OECS.SourceGen.csproj | 25 ++ src/OECS/ComponentDescriptor.cs | 41 ++++ src/OECS/ComponentRegistry.cs | 51 ++++ src/OECS/World.cs | 22 ++ src/OECS/WorldSerializer.cs | 34 +-- tests/OECS.Tests/OECS.Tests.csproj | 5 + 12 files changed, 468 insertions(+), 21 deletions(-) create mode 100644 src/OECS.SourceGen/ComponentDiscoveryGenerator.cs create mode 100644 src/OECS.SourceGen/OECS.SourceGen.csproj create mode 100644 src/OECS/ComponentDescriptor.cs create mode 100644 src/OECS/ComponentRegistry.cs diff --git a/OECS.sln b/OECS.sln index b6b35a8..dab7a30 100644 --- a/OECS.sln +++ b/OECS.sln @@ -1,25 +1,84 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "src\OECS.SourceGen\OECS.SourceGen.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS", "src\OECS\OECS.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "tests\OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blackjack", "examples\Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x64.ActiveCfg = Debug|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x64.Build.0 = Debug|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x86.ActiveCfg = Debug|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Debug|x86.Build.0 = Debug|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|Any CPU.Build.0 = Release|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.ActiveCfg = Release|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x64.Build.0 = Release|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.ActiveCfg = Release|Any CPU + {85631EDE-F984-4DA6-8EE9-0715AF1204E6}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C3D4E5F6-A7B8-9012-CDEF-123456789012} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {85631EDE-F984-4DA6-8EE9-0715AF1204E6} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} EndGlobalSection EndGlobal diff --git a/examples/Blackjack/Blackjack.csproj b/examples/Blackjack/Blackjack.csproj index a91f1df..c0cd25f 100644 --- a/examples/Blackjack/Blackjack.csproj +++ b/examples/Blackjack/Blackjack.csproj @@ -12,4 +12,9 @@ + + + + \ No newline at end of file diff --git a/examples/TicTacToe/Program.cs b/examples/TicTacToe/Program.cs index 9c8d882..92d9192 100644 --- a/examples/TicTacToe/Program.cs +++ b/examples/TicTacToe/Program.cs @@ -50,8 +50,8 @@ public static class Program // ── Wire up systems ─────────────────────────────────────────── var group = new SystemGroup(world); - group.Add(new WinCheckSystem(world)); - group.Add(new RenderSystem(world)); + group.Add(new WinCheckSystem()); + group.Add(new RenderSystem()); // ── Game loop ───────────────────────────────────────────────── diff --git a/examples/TicTacToe/Systems/WinCheckSystem.cs b/examples/TicTacToe/Systems/WinCheckSystem.cs index 58b7137..e43bb0e 100644 --- a/examples/TicTacToe/Systems/WinCheckSystem.cs +++ b/examples/TicTacToe/Systems/WinCheckSystem.cs @@ -10,11 +10,13 @@ public class WinCheckSystem : ISystem { public void Run(World world) { - ref var state = ref world.GetSingleton(); - - if (state.Status != GameStatus.Playing) + // Check game status before doing any work. Use ReadSingleton + // to avoid auto-marking GameState as modified when we bail early. + if (world.ReadSingleton().Status != GameStatus.Playing) return; + ref var state = ref world.GetSingleton(); + // Build a 3×3 grid of marks using the iterator API. var grid = new Player[3, 3]; diff --git a/examples/TicTacToe/TicTacToe.csproj b/examples/TicTacToe/TicTacToe.csproj index 8852db0..c4b6b7f 100644 --- a/examples/TicTacToe/TicTacToe.csproj +++ b/examples/TicTacToe/TicTacToe.csproj @@ -12,10 +12,15 @@ + + + + PreserveNewest - + \ No newline at end of file diff --git a/src/OECS.SourceGen/ComponentDiscoveryGenerator.cs b/src/OECS.SourceGen/ComponentDiscoveryGenerator.cs new file mode 100644 index 0000000..b845fdc --- /dev/null +++ b/src/OECS.SourceGen/ComponentDiscoveryGenerator.cs @@ -0,0 +1,226 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Collections.Immutable; +using System.Text; + +namespace OECS.SourceGen; + +/// +/// Incremental source generator that discovers all component types used with +/// and generates a strongly-typed registry so that +/// can save/load without reflection. +/// +[Generator] +public class ComponentDiscoveryGenerator : IIncrementalGenerator +{ + private static readonly HashSet _componentApiMethods = new() + { + // Component management + "AddComponent", + "RemoveComponent", + "HasComponent", + "GetComponent", + "TryGetComponent", + "ReadComponent", + "MarkModified", + + // Singletons + "SetSingleton", + "GetSingleton", + "ReadSingleton", + "HasSingleton", + "RemoveSingleton", + + // Observability + "ObserveComponentChanges", + + // Query building + "With", + "Without", + }; + + private static readonly HashSet _forEachNames = new() + { + "ForEach", + }; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Collect all generic method invocations that reference component types. + var invocations = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: IsCandidateInvocation, + transform: ExtractComponentType) + .Where(t => t.FullyQualifiedName != null) + .Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!)); + + // Also collect ForEach type arguments from the World class. + var forEachCalls = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: IsForEachCandidate, + transform: ExtractForEachTypes) + .Where(list => list.Length > 0) + .SelectMany((list, _) => list); + + var allTypes = invocations.Collect().Combine(forEachCalls.Collect()); + + context.RegisterSourceOutput(allTypes, GenerateRegistry); + } + + private static bool IsCandidateInvocation(SyntaxNode node, CancellationToken _) + { + if (node is not InvocationExpressionSyntax invocation) + return false; + + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + if (memberAccess.Name is GenericNameSyntax genericName) + { + return _componentApiMethods.Contains(genericName.Identifier.Text); + } + } + + return false; + } + + private static bool IsForEachCandidate(SyntaxNode node, CancellationToken _) + { + if (node is not InvocationExpressionSyntax invocation) + return false; + + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + var name = memberAccess.Name is GenericNameSyntax gn + ? gn.Identifier.Text + : memberAccess.Name.Identifier.Text; + return _forEachNames.Contains(name); + } + + return false; + } + + private static (string? FullyQualifiedName, string? AssemblyQualifiedName) ExtractComponentType( + GeneratorSyntaxContext ctx, CancellationToken _) + { + if (ctx.Node is not InvocationExpressionSyntax invocation) + return default; + + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + return default; + + if (memberAccess.Name is not GenericNameSyntax genericName) + return default; + + var typeArg = genericName.TypeArgumentList.Arguments.FirstOrDefault(); + if (typeArg == null) + return default; + + var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg); + if (symbolInfo.Symbol is not INamedTypeSymbol typeSymbol) + return default; + + // Only public struct types are valid components. + if (!typeSymbol.IsValueType || typeSymbol.IsAbstract) + return default; + if (typeSymbol.DeclaredAccessibility != Accessibility.Public) + return default; + + var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Assembly-qualified name for stable serialization (without assembly version). + var aqn = typeSymbol.ContainingAssembly != null + ? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}" + : fqn; + + return (fqn, aqn); + } + + private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> ExtractForEachTypes( + GeneratorSyntaxContext ctx, CancellationToken _) + { + if (ctx.Node is not InvocationExpressionSyntax invocation) + return ImmutableArray<(string, string)>.Empty; + + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + return ImmutableArray<(string, string)>.Empty; + + if (memberAccess.Name is not GenericNameSyntax genericName) + return ImmutableArray<(string, string)>.Empty; + + var types = ImmutableArray.CreateBuilder<(string, string)>(); + foreach (var typeArg in genericName.TypeArgumentList.Arguments) + { + var symbolInfo = ctx.SemanticModel.GetSymbolInfo(typeArg); + if (symbolInfo.Symbol is INamedTypeSymbol typeSymbol && + typeSymbol.IsValueType && !typeSymbol.IsAbstract && + typeSymbol.DeclaredAccessibility == Accessibility.Public) + { + var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var aqn = typeSymbol.ContainingAssembly != null + ? $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted))}, {typeSymbol.ContainingAssembly.Name}" + : fqn; + types.Add((fqn, aqn)); + } + } + + return types.ToImmutable(); + } + + private static void GenerateRegistry(SourceProductionContext context, + (ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Left, + ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName)> Right) data) + { + var (invocations, forEachTypes) = data; + + // Collect unique types by fully-qualified name, deduplicating. + var typeMap = new Dictionary(); + foreach (var t in invocations) + typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName); + foreach (var t in forEachTypes) + { + typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName); + } + + if (typeMap.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("using System.Runtime.CompilerServices;"); + sb.AppendLine("using MessagePack;"); + sb.AppendLine(); + sb.AppendLine("namespace OECS"); + sb.AppendLine("{"); + sb.AppendLine(" file static class ComponentRegistryInitializer"); + sb.AppendLine(" {"); + sb.AppendLine(" [ModuleInitializer]"); + sb.AppendLine(" public static void Initialize()"); + sb.AppendLine(" {"); + sb.AppendLine(" ComponentRegistry.Initialize(new ComponentDescriptor[]"); + sb.AppendLine(" {"); + + bool first = true; + foreach (var (fqn, aqn) in typeMap.Values.OrderBy(t => t.Fqn)) + { + if (!first) + sb.AppendLine(","); + first = false; + + sb.AppendLine($" new ComponentDescriptor("); + sb.AppendLine($" typeName: \"{aqn}\","); + sb.AppendLine($" type: typeof({fqn}),"); + sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),"); + sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data)"); + sb.Append(" )"); + } + + sb.AppendLine(); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource("ComponentRegistry.g.cs", sb.ToString()); + } +} \ No newline at end of file diff --git a/src/OECS.SourceGen/OECS.SourceGen.csproj b/src/OECS.SourceGen/OECS.SourceGen.csproj new file mode 100644 index 0000000..b7a71b9 --- /dev/null +++ b/src/OECS.SourceGen/OECS.SourceGen.csproj @@ -0,0 +1,25 @@ + + + + netstandard2.0 + enable + enable + 12 + OECS.SourceGen + OECS.SourceGen + true + true + false + true + + + + + + + + + + + + \ No newline at end of file diff --git a/src/OECS/ComponentDescriptor.cs b/src/OECS/ComponentDescriptor.cs new file mode 100644 index 0000000..d91efa8 --- /dev/null +++ b/src/OECS/ComponentDescriptor.cs @@ -0,0 +1,41 @@ +namespace OECS; + +/// +/// Describes a component type discovered at compile time by the source generator. +/// Used by to save/load components without reflection. +/// +public sealed class ComponentDescriptor +{ + /// + /// The assembly-qualified name of the component type, for stable serialization + /// across assemblies. + /// + public string TypeName { get; } + + /// + /// The of the component. + /// + public Type Type { get; } + + /// + /// Serializes a boxed component instance to a MessagePack byte array. + /// + public Func Serialize { get; } + + /// + /// Deserializes a MessagePack byte array into a boxed component instance. + /// + public Func Deserialize { get; } + + public ComponentDescriptor( + string typeName, + Type type, + Func serialize, + Func deserialize) + { + TypeName = typeName; + Type = type; + Serialize = serialize; + Deserialize = deserialize; + } +} \ No newline at end of file diff --git a/src/OECS/ComponentRegistry.cs b/src/OECS/ComponentRegistry.cs new file mode 100644 index 0000000..f17202d --- /dev/null +++ b/src/OECS/ComponentRegistry.cs @@ -0,0 +1,51 @@ +using System.Runtime.CompilerServices; + +namespace OECS; + +/// +/// Registry of component types discovered at compile time by the +/// OECS.SourceGen incremental generator. The consuming project's +/// generated code populates this via a module initializer, so no +/// manual registration is needed. +/// +public static class ComponentRegistry +{ + private static ComponentDescriptor[]? _descriptors; + private static Dictionary? _byTypeName; + + /// + /// All discovered component descriptors. Populated automatically + /// by the source generator at module initialization. + /// + public static ComponentDescriptor[] Descriptors => + _descriptors ?? Array.Empty(); + + /// + /// Lookup by assembly-qualified type name. Populated automatically + /// by the source generator at module initialization. + /// + public static IReadOnlyDictionary ByTypeName + { + get + { + if (_byTypeName == null) + { + var dict = new Dictionary(); + foreach (var desc in Descriptors) + dict[desc.TypeName] = desc; + _byTypeName = dict; + } + return _byTypeName; + } + } + + /// + /// Called by generated code to register discovered component types. + /// Must be called before any serialization occurs. + /// + public static void Initialize(ComponentDescriptor[] descriptors) + { + _descriptors = descriptors; + _byTypeName = null; // Rebuild on next access. + } +} \ No newline at end of file diff --git a/src/OECS/World.cs b/src/OECS/World.cs index cadf8bb..b5eeb65 100644 --- a/src/OECS/World.cs +++ b/src/OECS/World.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Reflection; using R3; namespace OECS; @@ -664,6 +665,27 @@ public class World : IDisposable /// internal ComponentStore Components => _components; + /// + /// Adds a boxed component to an entity by its runtime type. + /// Used by during deserialization to + /// invoke the typed without reflection. + /// + internal void AddComponentBoxed(Type componentType, Entity entity, object component) + { + // Use a cached delegate per type to avoid reflection on every call. + if (!_addComponentDelegates.TryGetValue(componentType, out var del)) + { + var method = typeof(World).GetMethod(nameof(AddComponent))! + .MakeGenericMethod(componentType); + del = (Action)Delegate.CreateDelegate( + typeof(Action), method); + _addComponentDelegates[componentType] = del; + } + del(this, entity, component); + } + + private readonly Dictionary> _addComponentDelegates = new(); + // ── Helpers ─────────────────────────────────────────────────────── private void ThrowIfNotAlive(Entity entity) diff --git a/src/OECS/WorldSerializer.cs b/src/OECS/WorldSerializer.cs index e2d3476..8c45b17 100644 --- a/src/OECS/WorldSerializer.cs +++ b/src/OECS/WorldSerializer.cs @@ -5,9 +5,10 @@ namespace OECS; /// /// Saves and loads a to/from a MessagePack stream. /// -/// Components are serialized by their runtime type. The caller must -/// ensure all component types used in the world are resolvable via -/// at load time. +/// Uses the compile-time generated to +/// serialize and deserialize components without reflection. All component +/// types used with the World API are discovered by the OECS.SourceGen +/// incremental generator at build time. /// public static class WorldSerializer { @@ -18,9 +19,9 @@ public static class WorldSerializer { var entityComponents = new Dictionary>(); - foreach (var ct in world.Components.ComponentTypes) + foreach (var desc in ComponentRegistry.Descriptors) { - var set = world.Components.GetSet(ct); + var set = world.Components.GetSet(desc.Type); if (set == null || set.Count == 0) continue; var entities = set.GetDenseEntities(); @@ -37,8 +38,8 @@ public static class WorldSerializer list.Add(new ComponentEntry { - TypeName = ct.AssemblyQualifiedName!, - Data = MessagePackSerializer.Serialize(ct, component) + TypeName = desc.TypeName, + Data = desc.Serialize(component) }); } } @@ -67,6 +68,11 @@ public static class WorldSerializer { var snapshot = MessagePackSerializer.Deserialize(stream); + // Build a lookup by type name for O(1) descriptor resolution. + var lookup = new Dictionary(); + foreach (var desc in ComponentRegistry.Descriptors) + lookup[desc.TypeName] = desc; + foreach (var es in snapshot.Entities) { var entity = world.CreateEntity(es.Entity); @@ -81,18 +87,18 @@ public static class WorldSerializer foreach (var ce in es.Components) { - var type = Type.GetType(ce.TypeName) - ?? throw new InvalidOperationException( + if (!lookup.TryGetValue(ce.TypeName, out var desc)) + throw new InvalidOperationException( $"Unknown component type '{ce.TypeName}'. " + - $"Ensure the assembly is loaded."); + $"Ensure the component type is used with the World " + + $"API so the source generator can discover it."); - var component = MessagePackSerializer.Deserialize(type, ce.Data) + var component = desc.Deserialize(ce.Data) ?? throw new InvalidOperationException( $"Failed to deserialize component of type '{ce.TypeName}'."); - var method = typeof(World).GetMethod(nameof(World.AddComponent))! - .MakeGenericMethod(type); - method.Invoke(world, [entity, component]); + // Use the generated typed delegate to add the component. + world.AddComponentBoxed(desc.Type, entity, component); } } } diff --git a/tests/OECS.Tests/OECS.Tests.csproj b/tests/OECS.Tests/OECS.Tests.csproj index 1c8ca26..be47b57 100644 --- a/tests/OECS.Tests/OECS.Tests.csproj +++ b/tests/OECS.Tests/OECS.Tests.csproj @@ -23,4 +23,9 @@ + + + +