diff --git a/ModernUO.Serialization.Generator.Benchmarks/GeneratorBenchmarks.cs b/ModernUO.Serialization.Generator.Benchmarks/GeneratorBenchmarks.cs
index 4cb5ec8..df8ebf3 100644
--- a/ModernUO.Serialization.Generator.Benchmarks/GeneratorBenchmarks.cs
+++ b/ModernUO.Serialization.Generator.Benchmarks/GeneratorBenchmarks.cs
@@ -86,6 +86,16 @@ public void Setup()
.RunGenerators(_compilation);
_warmCompilation = _compilation;
_editTarget = _warmCompilation.SyntaxTrees.Last();
+
+ // Guard against measuring a silently failing generator.
+ var result = _warmDriver.GetRunResult().Results[0];
+ if (result.GeneratedSources.Length != ClassCount)
+ {
+ throw new InvalidOperationException(
+ $"Expected {ClassCount} generated sources but got {result.GeneratedSources.Length}. " +
+ $"Diagnostics: {string.Join("; ", result.Diagnostics.Take(3))}"
+ );
+ }
}
[Benchmark]
@@ -95,6 +105,13 @@ public GeneratorDriver ColdFullRun() =>
.AddAdditionalTexts(_additionalTexts)
.RunGenerators(_compilation);
+ [Benchmark]
+ public GeneratorDriver WarmRerunNoChange()
+ {
+ _warmDriver = _warmDriver.RunGenerators(_warmCompilation);
+ return _warmDriver;
+ }
+
[Benchmark]
public GeneratorDriver WarmRerunAfterSingleEdit()
{
diff --git a/ModernUO.Serialization.Generator.Benchmarks/ModernUO.Serialization.Generator.Benchmarks.csproj b/ModernUO.Serialization.Generator.Benchmarks/ModernUO.Serialization.Generator.Benchmarks.csproj
index 514db43..57b8fb4 100644
--- a/ModernUO.Serialization.Generator.Benchmarks/ModernUO.Serialization.Generator.Benchmarks.csproj
+++ b/ModernUO.Serialization.Generator.Benchmarks/ModernUO.Serialization.Generator.Benchmarks.csproj
@@ -11,6 +11,8 @@
+
+
diff --git a/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs b/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs
new file mode 100644
index 0000000..2023d61
--- /dev/null
+++ b/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs
@@ -0,0 +1,290 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using ModernUO.Serialization.Generator.Tests.Helpers;
+using Xunit;
+
+namespace ModernUO.Serialization.Generator.Tests;
+
+///
+/// Proves the pipeline actually caches: after an edit, only affected classes may re-run the
+/// source-output stage, and untouched migration files are not re-parsed. Uses Roslyn's
+/// incremental step tracking, so "cached" means the callback did not execute at all.
+///
+public class IncrementalityTests
+{
+ private const string ClassA = """
+ using System;
+ using ModernUO.Serialization;
+ using Server;
+
+ namespace Server.TestContent
+ {
+ [SerializationGenerator(1)]
+ public partial class AlphaItem : ISerializable
+ {
+ [SerializableField(0)]
+ private string _name;
+
+ public DateTime Created { get; set; }
+ public Serial Serial { get; }
+ public bool Deleted => false;
+ public void Delete() { }
+
+ private void MigrateFrom(V0Content content)
+ {
+ _name = content.Name;
+ }
+ }
+ }
+ """;
+
+ private const string ClassB = """
+ using System;
+ using ModernUO.Serialization;
+ using Server;
+
+ namespace Server.TestContent
+ {
+ [SerializationGenerator(0)]
+ public partial class BravoItem : ISerializable
+ {
+ [SerializableField(0)]
+ private int _charges;
+
+ public DateTime Created { get; set; }
+ public Serial Serial { get; }
+ public bool Deleted => false;
+ public void Delete() { }
+ }
+ }
+ """;
+
+ private const string UnrelatedClass = """
+ namespace Server.TestContent
+ {
+ public class Bystander
+ {
+ public int Value { get; set; }
+ }
+ }
+ """;
+
+ private const string AlphaMigrationJson = """
+ {
+ "version": 0,
+ "type": "Server.TestContent.AlphaItem",
+ "properties": [
+ {
+ "name": "Name",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule"
+ }
+ ]
+ }
+ """;
+
+ private static (GeneratorDriver Driver, CSharpCompilation Compilation, Dictionary Texts)
+ CreateTrackedRun(
+ Dictionary sources,
+ IEnumerable<(string fileName, string content)> additionalTexts
+ )
+ {
+ var trees = new List { CSharpSyntaxTree.ParseText(SourceGeneratorTestHelper.ServerStubs) };
+ foreach (var (path, text) in sources)
+ {
+ trees.Add(CSharpSyntaxTree.ParseText(text, path: path));
+ }
+
+ var trustedAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
+ .Split(Path.PathSeparator);
+
+ var references = trustedAssemblies
+ .Where(p => !string.IsNullOrEmpty(p))
+ .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))
+ .Concat([MetadataReference.CreateFromFile(typeof(SerializationGeneratorAttribute).Assembly.Location)])
+ .ToList();
+
+ var compilation = CSharpCompilation.Create(
+ "IncrementalityAssembly",
+ trees,
+ references,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ );
+
+ var texts = additionalTexts.ToDictionary(
+ t => t.fileName,
+ t => (AdditionalText)new InMemoryAdditionalText(t.fileName, t.content)
+ );
+
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(
+ [new EntitySerializationGenerator().AsSourceGenerator()],
+ additionalTexts: texts.Values,
+ driverOptions: new GeneratorDriverOptions(
+ IncrementalGeneratorOutputKind.None,
+ trackIncrementalGeneratorSteps: true
+ )
+ );
+
+ driver = driver.RunGenerators(compilation);
+ return (driver, compilation, texts);
+ }
+
+ private static CSharpCompilation ReplaceTree(CSharpCompilation compilation, string path, string newText)
+ {
+ var oldTree = compilation.SyntaxTrees.Single(t => t.FilePath == path);
+ return compilation.ReplaceSyntaxTree(oldTree, CSharpSyntaxTree.ParseText(newText, path: path));
+ }
+
+ private static (int Executed, int Cached) CountSourceOutputRuns(GeneratorDriver driver)
+ {
+ var executed = 0;
+ var cached = 0;
+
+ foreach (var (_, steps) in driver.GetRunResult().Results[0].TrackedOutputSteps)
+ {
+ foreach (var step in steps)
+ {
+ foreach (var (_, reason) in step.Outputs)
+ {
+ if (reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)
+ {
+ cached++;
+ }
+ else
+ {
+ executed++;
+ }
+ }
+ }
+ }
+
+ return (executed, cached);
+ }
+
+ [Fact]
+ public void EditingOneClass_DoesNotRegenerateOthers()
+ {
+ var sources = new Dictionary { ["A.cs"] = ClassA, ["B.cs"] = ClassB };
+ var (driver, compilation, _) = CreateTrackedRun(
+ sources,
+ [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)]
+ );
+
+ var edited = ReplaceTree(compilation, "B.cs", ClassB + "\n// edit\n");
+ driver = driver.RunGenerators(edited);
+
+ var (executed, cached) = CountSourceOutputRuns(driver);
+
+ Assert.True(cached >= 1, "The untouched class must be served from cache.");
+ Assert.True(
+ executed <= 1,
+ $"Only the edited class may re-run the output stage, but {executed} outputs ran."
+ );
+ }
+
+ [Fact]
+ public void EditingAnUnrelatedFile_RegeneratesNothing()
+ {
+ var sources = new Dictionary
+ {
+ ["A.cs"] = ClassA, ["B.cs"] = ClassB, ["C.cs"] = UnrelatedClass
+ };
+ var (driver, compilation, _) = CreateTrackedRun(
+ sources,
+ [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)]
+ );
+
+ var edited = ReplaceTree(compilation, "C.cs", UnrelatedClass + "\n// edit\n");
+ driver = driver.RunGenerators(edited);
+
+ var (executed, _) = CountSourceOutputRuns(driver);
+
+ Assert.True(
+ executed == 0,
+ $"An edit to a non-serializable file must not re-run any output stage, but {executed} outputs ran."
+ );
+ }
+
+ [Fact]
+ public void EditingACodeFile_DoesNotReparseMigrationFiles()
+ {
+ var sources = new Dictionary { ["A.cs"] = ClassA, ["B.cs"] = ClassB };
+ var (driver, compilation, _) = CreateTrackedRun(
+ sources,
+ [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)]
+ );
+
+ var edited = ReplaceTree(compilation, "B.cs", ClassB + "\n// edit\n");
+ driver = driver.RunGenerators(edited);
+
+ var result = driver.GetRunResult().Results[0];
+ Assert.True(
+ result.TrackedSteps.TryGetValue("migrationFiles", out var parseSteps),
+ "The migration parse node must be tracked as 'migrationFiles'."
+ );
+
+ var reparsed = parseSteps
+ .SelectMany(s => s.Outputs)
+ .Count(o => o.Reason is not (IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged));
+
+ Assert.True(reparsed == 0, $"No migration file changed, but {reparsed} parse outputs ran.");
+ }
+
+ // The payoff case: editing logic inside a serializable class - a method body, a comment -
+ // produces an equal model, so nothing regenerates even though the class itself changed.
+ [Fact]
+ public void EditingNonSerializationCode_InASerializableClass_RegeneratesNothing()
+ {
+ var sources = new Dictionary { ["A.cs"] = ClassA, ["B.cs"] = ClassB };
+ var (driver, compilation, _) = CreateTrackedRun(
+ sources,
+ [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)]
+ );
+
+ // Add a method that has nothing to do with serialization.
+ var edited = ReplaceTree(
+ compilation,
+ "B.cs",
+ ClassB.Replace(
+ "public void Delete() { }",
+ "public void Delete() { }\n\n public int ComputeDamage(int roll) => roll * 2 + _charges;"
+ )
+ );
+ driver = driver.RunGenerators(edited);
+
+ var (executed, _) = CountSourceOutputRuns(driver);
+
+ Assert.True(
+ executed == 0,
+ $"A non-serialization edit inside a serializable class must not regenerate, but {executed} outputs ran."
+ );
+ }
+
+ [Fact]
+ public void EditingAMigrationFile_OnlyAffectsItsClass()
+ {
+ var sources = new Dictionary { ["A.cs"] = ClassA, ["B.cs"] = ClassB };
+ var (driver, compilation, texts) = CreateTrackedRun(
+ sources,
+ [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)]
+ );
+
+ // Same compilation; only the additional text changes.
+ var newJson = AlphaMigrationJson.Replace("\"Name\"", "\"Name\" ");
+ driver = driver
+ .ReplaceAdditionalText(
+ texts["Server.TestContent.AlphaItem.v0.json"],
+ new InMemoryAdditionalText("Server.TestContent.AlphaItem.v0.json", newJson)
+ )
+ .RunGenerators(compilation);
+
+ var (executed, cached) = CountSourceOutputRuns(driver);
+
+ Assert.True(cached >= 1, "The class without migrations must be served from cache.");
+ Assert.True(
+ executed <= 1,
+ $"Only the class owning the edited migration may re-run, but {executed} outputs ran."
+ );
+ }
+}
diff --git a/ModernUO.Serialization.Generator.Tests/PipelineProbeTests.cs b/ModernUO.Serialization.Generator.Tests/PipelineProbeTests.cs
new file mode 100644
index 0000000..aba93f0
--- /dev/null
+++ b/ModernUO.Serialization.Generator.Tests/PipelineProbeTests.cs
@@ -0,0 +1,171 @@
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using ModernUO.Serialization.Generator.Tests.Helpers;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace ModernUO.Serialization.Generator.Tests;
+
+///
+/// Step-level probe over a benchmark-sized corpus: reports per-node executed/cached counts
+/// after a single-file edit, so a caching regression shows up as numbers, not vibes.
+///
+public class PipelineProbeTests(ITestOutputHelper output)
+{
+ private const int ClassCount = 150;
+
+ private static string BuildClassSource(int index)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("using System;");
+ sb.AppendLine("using ModernUO.Serialization;");
+ sb.AppendLine("using Server;");
+ sb.AppendLine();
+ sb.AppendLine("namespace Server.TestContent");
+ sb.AppendLine("{");
+ sb.AppendLine(" [SerializationGenerator(1)]");
+ sb.AppendLine($" public partial class BenchItem{index} : ISerializable");
+ sb.AppendLine(" {");
+
+ for (var f = 0; f < 6; f++)
+ {
+ sb.AppendLine($" [SerializableField({f})]");
+ sb.AppendLine($" private {(f % 2 == 0 ? "int" : "string")} _field{f};");
+ sb.AppendLine();
+ }
+
+ sb.AppendLine(" public DateTime Created { get; set; }");
+ sb.AppendLine(" public Serial Serial { get; }");
+ sb.AppendLine(" public bool Deleted => false;");
+ sb.AppendLine(" public void Delete() { }");
+ sb.AppendLine();
+ sb.AppendLine(" private void MigrateFrom(V0Content content)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" _field0 = content.Field0;");
+ sb.AppendLine(" }");
+ sb.AppendLine(" }");
+ sb.AppendLine("}");
+
+ return sb.ToString();
+ }
+
+ private static string BuildMigrationJson(int index) =>
+ $$"""
+ {
+ "version": 0,
+ "type": "Server.TestContent.BenchItem{{index}}",
+ "properties": [
+ {
+ "name": "Field0",
+ "type": "int",
+ "rule": "PrimitiveTypeMigrationRule"
+ }
+ ]
+ }
+ """;
+
+ [Fact]
+ public void WarmRerun_OnlyEditedClassExecutes()
+ {
+ var trees = new List { CSharpSyntaxTree.ParseText(SourceGeneratorTestHelper.ServerStubs) };
+ for (var i = 0; i < ClassCount; i++)
+ {
+ trees.Add(CSharpSyntaxTree.ParseText(BuildClassSource(i), path: $"BenchItem{i}.cs"));
+ }
+
+ var trustedAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
+ .Split(Path.PathSeparator);
+
+ var references = trustedAssemblies
+ .Where(p => !string.IsNullOrEmpty(p))
+ .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))
+ .Concat([MetadataReference.CreateFromFile(typeof(SerializationGeneratorAttribute).Assembly.Location)])
+ .ToList();
+
+ var compilation = CSharpCompilation.Create(
+ "ProbeAssembly",
+ trees,
+ references,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ );
+
+ var additionalTexts = new List();
+ for (var i = 0; i < ClassCount; i++)
+ {
+ additionalTexts.Add(
+ new InMemoryAdditionalText($"Server.TestContent.BenchItem{i}.v0.json", BuildMigrationJson(i))
+ );
+ }
+
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(
+ [new EntitySerializationGenerator().AsSourceGenerator()],
+ additionalTexts: additionalTexts,
+ driverOptions: new GeneratorDriverOptions(
+ IncrementalGeneratorOutputKind.None,
+ trackIncrementalGeneratorSteps: true
+ )
+ );
+
+ driver = driver.RunGenerators(compilation);
+ Assert.Equal(ClassCount, driver.GetRunResult().Results[0].GeneratedSources.Length);
+
+ // Edit one file.
+ var target = compilation.SyntaxTrees.Single(t => t.FilePath == "BenchItem7.cs");
+ var edited = compilation.ReplaceSyntaxTree(
+ target,
+ CSharpSyntaxTree.ParseText(BuildClassSource(7) + "\n// edit\n", path: "BenchItem7.cs")
+ );
+
+ driver = driver.RunGenerators(edited);
+ var result = driver.GetRunResult().Results[0];
+
+ var executedOutputs = 0;
+ foreach (var (name, steps) in result.TrackedSteps)
+ {
+ var executed = 0;
+ var cached = 0;
+ foreach (var step in steps)
+ {
+ foreach (var (_, reason) in step.Outputs)
+ {
+ if (reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)
+ {
+ cached++;
+ }
+ else
+ {
+ executed++;
+ }
+ }
+ }
+
+ output.WriteLine($"{name}: executed={executed} cached={cached}");
+ }
+
+ foreach (var (name, steps) in result.TrackedOutputSteps)
+ {
+ var executed = 0;
+ var cached = 0;
+ foreach (var step in steps)
+ {
+ foreach (var (_, reason) in step.Outputs)
+ {
+ if (reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)
+ {
+ cached++;
+ }
+ else
+ {
+ executed++;
+ }
+ }
+ }
+
+ executedOutputs += executed;
+ output.WriteLine($"OUTPUT {name}: executed={executed} cached={cached}");
+ }
+
+ Assert.True(executedOutputs <= 1, $"Expected at most 1 executed output, got {executedOutputs}.");
+ }
+}
diff --git a/ModernUO.Serialization.Generator/Diagnostics/DiagnosticDescriptors.cs b/ModernUO.Serialization.Generator/Diagnostics/DiagnosticDescriptors.cs
index 30c7e22..70ff82b 100644
--- a/ModernUO.Serialization.Generator/Diagnostics/DiagnosticDescriptors.cs
+++ b/ModernUO.Serialization.Generator/Diagnostics/DiagnosticDescriptors.cs
@@ -130,6 +130,15 @@ public static class DiagnosticDescriptors
true
);
+ public static readonly DiagnosticDescriptor SG3013 = new(
+ "SG3013",
+ "Invalid migration file",
+ "Migration file '{0}' could not be parsed: {1}",
+ "ModernUO.Serialization.Generator",
+ DiagnosticSeverity.Error,
+ true
+ );
+
public static DiagnosticDescriptor GeneratorCrashedDiagnostic(Exception e) =>
new(
"SG0001",
diff --git a/ModernUO.Serialization.Generator/Diagnostics/DiagnosticInfo.cs b/ModernUO.Serialization.Generator/Diagnostics/DiagnosticInfo.cs
new file mode 100644
index 0000000..a4d1c65
--- /dev/null
+++ b/ModernUO.Serialization.Generator/Diagnostics/DiagnosticInfo.cs
@@ -0,0 +1,71 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: DiagnosticInfo.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace ModernUO.Serialization.Generator;
+
+///
+/// A value-equatable stand-in for so pipeline models stay cacheable;
+/// materialized back into a real diagnostic at output time.
+///
+public sealed record DiagnosticInfo(
+ string Id,
+ string FilePath,
+ TextSpan Span,
+ LinePositionSpan LineSpan,
+ EquatableArray Args
+)
+{
+ private static readonly Dictionary _descriptors = new()
+ {
+ ["SG3001"] = DiagnosticDescriptors.SG3001,
+ ["SG3002"] = DiagnosticDescriptors.SG3002,
+ ["SG3003"] = DiagnosticDescriptors.SG3003,
+ ["SG3004"] = DiagnosticDescriptors.SG3004,
+ ["SG3005"] = DiagnosticDescriptors.SG3005,
+ ["SG3006"] = DiagnosticDescriptors.SG3006,
+ ["SG3007"] = DiagnosticDescriptors.SG3007,
+ ["SG3008"] = DiagnosticDescriptors.SG3008,
+ ["SG3009"] = DiagnosticDescriptors.SG3009,
+ ["SG3010"] = DiagnosticDescriptors.SG3010,
+ ["SG3011"] = DiagnosticDescriptors.SG3011,
+ ["SG3012"] = DiagnosticDescriptors.SG3012,
+ ["SG3013"] = DiagnosticDescriptors.SG3013
+ };
+
+ public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, Location location, params object[] args)
+ {
+ var lineSpan = location.GetLineSpan();
+ return new DiagnosticInfo(
+ descriptor.Id,
+ location.SourceTree?.FilePath ?? lineSpan.Path ?? "",
+ location.SourceSpan,
+ lineSpan.Span,
+ args.Select(a => a?.ToString() ?? "").ToEquatableArray()
+ );
+ }
+
+ public Diagnostic ToDiagnostic() =>
+ Diagnostic.Create(
+ _descriptors[Id],
+ Location.Create(FilePath, Span, LineSpan),
+ Args.Cast