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