diff --git a/Dalamud.sln b/Dalamud.sln
index de91e7ceb..fa26a5d67 100644
--- a/Dalamud.sln
+++ b/Dalamud.sln
@@ -75,6 +75,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumina.Excel.Generator", "l
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumina.Excel", "lib\Lumina.Excel\src\Lumina.Excel\Lumina.Excel.csproj", "{88FB719B-EB41-73C5-8D25-C03E0C69904F}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Source Generators", "Source Generators", "{50BEC23B-FFFD-427B-A95D-27E1D1958FFF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dalamud.EnumGenerator", "generators\Dalamud.EnumGenerator\Dalamud.EnumGenerator\Dalamud.EnumGenerator.csproj", "{27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dalamud.EnumGenerator.Sample", "generators\Dalamud.EnumGenerator\Dalamud.EnumGenerator.Sample\Dalamud.EnumGenerator.Sample.csproj", "{8CDAEB2D-5022-450A-A97F-181C6270185F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dalamud.EnumGenerator.Tests", "generators\Dalamud.EnumGenerator\Dalamud.EnumGenerator.Tests\Dalamud.EnumGenerator.Tests.csproj", "{F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -173,6 +181,18 @@ Global
{88FB719B-EB41-73C5-8D25-C03E0C69904F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{88FB719B-EB41-73C5-8D25-C03E0C69904F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{88FB719B-EB41-73C5-8D25-C03E0C69904F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}.Debug|Any CPU.Build.0 = Debug|x64
+ {27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}.Release|Any CPU.ActiveCfg = Release|x64
+ {27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8}.Release|Any CPU.Build.0 = Release|x64
+ {8CDAEB2D-5022-450A-A97F-181C6270185F}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {8CDAEB2D-5022-450A-A97F-181C6270185F}.Debug|Any CPU.Build.0 = Debug|x64
+ {8CDAEB2D-5022-450A-A97F-181C6270185F}.Release|Any CPU.ActiveCfg = Release|x64
+ {8CDAEB2D-5022-450A-A97F-181C6270185F}.Release|Any CPU.Build.0 = Release|x64
+ {F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}.Debug|Any CPU.Build.0 = Debug|x64
+ {F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}.Release|Any CPU.ActiveCfg = Release|x64
+ {F5D92D2D-D36F-4471-B657-8B9AA6C98AD6}.Release|Any CPU.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -197,6 +217,9 @@ Global
{02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {E15BDA6D-E881-4482-94BA-BE5527E917FF}
{5A44DF0C-C9DA-940F-4D6B-4A11D13AEA3D} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{88FB719B-EB41-73C5-8D25-C03E0C69904F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {27AA9F87-D2AA-41D9-A559-0F1EBA38C5F8} = {50BEC23B-FFFD-427B-A95D-27E1D1958FFF}
+ {8CDAEB2D-5022-450A-A97F-181C6270185F} = {50BEC23B-FFFD-427B-A95D-27E1D1958FFF}
+ {F5D92D2D-D36F-4471-B657-8B9AA6C98AD6} = {50BEC23B-FFFD-427B-A95D-27E1D1958FFF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {79B65AC9-C940-410E-AB61-7EA7E12C7599}
diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj
index f5e75af63..bb8f5af7c 100644
--- a/Dalamud/Dalamud.csproj
+++ b/Dalamud/Dalamud.csproj
@@ -88,6 +88,15 @@
+
+
+
+
+
+
+
+
+
imgui-frag.hlsl.bytes
diff --git a/Dalamud/EnumCloneMap.txt b/Dalamud/EnumCloneMap.txt
new file mode 100644
index 000000000..bbc3c1eda
--- /dev/null
+++ b/Dalamud/EnumCloneMap.txt
@@ -0,0 +1,3 @@
+# Format: Target.Full.TypeName = Source.Full.EnumTypeName
+# Example: Generate a local enum MyGeneratedEnum in namespace Sample.Gen mapped to SourceEnums.SampleSourceEnum
+Dalamud.Game.Agent.AgentId = FFXIVClientStructs.FFXIV.Client.UI.Agent.AgentId
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Sample/Dalamud.EnumGenerator.Sample.csproj b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Sample/Dalamud.EnumGenerator.Sample.csproj
new file mode 100644
index 000000000..225ea5f94
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Sample/Dalamud.EnumGenerator.Sample.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net9.0
+ enable
+ Dalamud.EnumGenerator.Sample
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Sample/EnumCloneMap.txt b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Sample/EnumCloneMap.txt
new file mode 100644
index 000000000..a7db08bf3
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Sample/EnumCloneMap.txt
@@ -0,0 +1,4 @@
+# Format: Target.Full.TypeName = Source.Full.EnumTypeName
+# Example: Generate a local enum MyGeneratedEnum in namespace Sample.Gen mapped to SourceEnums.SampleSourceEnum
+Dalamud.EnumGenerator.Sample.Gen.MyGeneratedEnum = Dalamud.EnumGenerator.Sample.SourceEnums.SampleSourceEnum
+
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Sample/SourceEnums.cs b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Sample/SourceEnums.cs
new file mode 100644
index 000000000..407b4c151
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Sample/SourceEnums.cs
@@ -0,0 +1,9 @@
+namespace Dalamud.EnumGenerator.Sample.SourceEnums
+{
+ public enum SampleSourceEnum : long
+ {
+ First = 1,
+ Second = 2,
+ Third = 10000000000L
+ }
+}
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Tests/Dalamud.EnumGenerator.Tests.csproj b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Tests/Dalamud.EnumGenerator.Tests.csproj
new file mode 100644
index 000000000..50de4a7c8
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Tests/Dalamud.EnumGenerator.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net9.0
+ enable
+
+ false
+
+ Dalamud.EnumGenerator.Tests
+
+ false
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Tests/EnumCloneMapTests.cs b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Tests/EnumCloneMapTests.cs
new file mode 100644
index 000000000..f14279c53
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Tests/EnumCloneMapTests.cs
@@ -0,0 +1,47 @@
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Xunit;
+
+namespace Dalamud.EnumGenerator.Tests;
+
+public class EnumCloneMapTests
+{
+ [Fact]
+ public void ParseMappings_SimpleLines_ParsesCorrectly()
+ {
+ var text = @"# Comment line
+My.Namespace.Target = Other.Namespace.Source
+
+Another.Target = Some.Source";
+
+ var results = Dalamud.EnumGenerator.EnumCloneGenerator.ParseMappings(text);
+
+ Assert.Equal(2, results.Length);
+ Assert.Equal("My.Namespace.Target", results[0].TargetFullName);
+ Assert.Equal("Other.Namespace.Source", results[0].SourceFullName);
+ Assert.Equal("Another.Target", results[1].TargetFullName);
+ }
+
+ [Fact]
+ public void Generator_ProducesFile_WhenSourceResolved()
+ {
+ // We'll create a compilation that contains a source enum type and add an AdditionalText mapping
+ var sourceEnum = @"namespace Foo.Bar { public enum SourceEnum { A = 1, B = 2 } }";
+
+ var mapText = "GeneratedNs.TargetEnum = Foo.Bar.SourceEnum";
+
+ var generator = new EnumCloneGenerator();
+ var driver = CSharpGeneratorDriver.Create(generator)
+ .AddAdditionalTexts(ImmutableArray.Create(new Utils.TestAdditionalFile("EnumCloneMap.txt", mapText)));
+
+ var compilation = CSharpCompilation.Create("TestGen", [CSharpSyntaxTree.ParseText(sourceEnum)],
+ [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]);
+
+ driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out var diagnostics);
+
+ var generated = newCompilation.SyntaxTrees.Select(t => t.FilePath).Where(p => p.EndsWith("TargetEnum.CloneEnum.g.cs")).ToArray();
+ Assert.Single(generated);
+ }
+}
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Tests/Utils/TestAdditionalFile.cs b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Tests/Utils/TestAdditionalFile.cs
new file mode 100644
index 000000000..e5c0df848
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator.Tests/Utils/TestAdditionalFile.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Dalamud.EnumGenerator.Tests.Utils;
+
+public class TestAdditionalFile : AdditionalText
+{
+ private readonly SourceText text;
+
+ public TestAdditionalFile(string path, string text)
+ {
+ Path = path;
+ this.text = SourceText.From(text);
+ }
+
+ public override SourceText GetText(CancellationToken cancellationToken = new()) => this.text;
+
+ public override string Path { get; }
+}
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/AnalyzerReleases.Shipped.md b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/AnalyzerReleases.Shipped.md
new file mode 100644
index 000000000..60b59dd99
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/AnalyzerReleases.Shipped.md
@@ -0,0 +1,3 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/AnalyzerReleases.Unshipped.md b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/AnalyzerReleases.Unshipped.md
new file mode 100644
index 000000000..e90084796
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,9 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+ENUMGEN001 | EnumGenerator | Warning | SourceGeneratorWithAttributes
+ENUMGEN002 | EnumGenerator | Warning | SourceGeneratorWithAttributes
\ No newline at end of file
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/Dalamud.EnumGenerator.csproj b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/Dalamud.EnumGenerator.csproj
new file mode 100644
index 000000000..106b036a8
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/Dalamud.EnumGenerator.csproj
@@ -0,0 +1,33 @@
+
+
+
+ netstandard2.0
+ false
+ enable
+ latest
+
+ true
+ true
+
+ Dalamud.EnumGenerator
+ Dalamud.EnumGenerator
+
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/EnumCloneGenerator.cs b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/EnumCloneGenerator.cs
new file mode 100644
index 000000000..95af4c38b
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/EnumCloneGenerator.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Globalization;
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Dalamud.EnumGenerator;
+
+[Generator]
+public class EnumCloneGenerator : IIncrementalGenerator
+{
+ private const string NewLine = "\r\n";
+
+ private const string MappingFileName = "EnumCloneMap.txt";
+
+ private static readonly DiagnosticDescriptor MissingSourceDescriptor = new(
+ id: "ENUMGEN001",
+ title: "Source enum not found",
+ messageFormat: "Source enum '{0}' could not be resolved by the compilation",
+ category: "EnumGenerator",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
+ private static readonly DiagnosticDescriptor DuplicateTargetDescriptor = new(
+ id: "ENUMGEN002",
+ title: "Duplicate target mapping",
+ messageFormat: "Target enum '{0}' is mapped multiple times; generation skipped for this target",
+ category: "EnumGenerator",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Read mappings from additional files named EnumCloneMap.txt
+ var mappingEntries = context.AdditionalTextsProvider
+ .Where(at => Path.GetFileName(at.Path).Equals(MappingFileName, StringComparison.OrdinalIgnoreCase))
+ .SelectMany((at, _) => ParseMappings(at.GetText()?.ToString() ?? string.Empty));
+
+ // Combine with compilation so we can resolve types
+ var compilationAndMaps = context.CompilationProvider.Combine(mappingEntries.Collect());
+
+ context.RegisterSourceOutput(compilationAndMaps, (spc, pair) =>
+ {
+ var compilation = pair.Left;
+ var maps = pair.Right;
+
+ // Detect duplicate targets first and report diagnostics
+ var duplicateTargets = maps.GroupBy(m => m.TargetFullName, StringComparer.OrdinalIgnoreCase)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key)
+ .ToImmutableArray();
+ foreach (var dup in duplicateTargets)
+ {
+ var diag = Diagnostic.Create(DuplicateTargetDescriptor, Location.None, dup);
+ spc.ReportDiagnostic(diag);
+ }
+
+ foreach (var (targetFullName, sourceFullName) in maps)
+ {
+ if (string.IsNullOrWhiteSpace(targetFullName) || string.IsNullOrWhiteSpace(sourceFullName))
+ continue;
+
+ if (duplicateTargets.Contains(targetFullName, StringComparer.OrdinalIgnoreCase))
+ continue;
+
+ // Resolve the source enum type by metadata name (namespace.type)
+ var sourceSymbol = compilation.GetTypeByMetadataName(sourceFullName);
+ if (sourceSymbol is null)
+ {
+ // Report diagnostic for missing source type
+ var diag = Diagnostic.Create(MissingSourceDescriptor, Location.None, sourceFullName);
+ spc.ReportDiagnostic(diag);
+ continue;
+ }
+
+ if (sourceSymbol.TypeKind != TypeKind.Enum)
+ continue;
+
+ var sourceNamed = sourceSymbol; // GetTypeByMetadataName already returns INamedTypeSymbol
+
+ // Split target into namespace and type name
+ string? targetNamespace = null;
+ var targetName = targetFullName;
+ var lastDot = targetFullName.LastIndexOf('.');
+ if (lastDot >= 0)
+ {
+ targetNamespace = targetFullName.Substring(0, lastDot);
+ targetName = targetFullName.Substring(lastDot + 1);
+ }
+
+ var underlyingType = sourceNamed.EnumUnderlyingType;
+ var underlyingDisplay = underlyingType?.ToDisplayString() ?? "int";
+
+ var fields = sourceNamed.GetMembers()
+ .OfType()
+ .Where(f => f.IsStatic && f.HasConstantValue)
+ .ToArray();
+
+ var memberLines = fields.Select(f =>
+ {
+ var name = f.Name;
+ var constValue = f.ConstantValue;
+ string literal;
+
+ var st = underlyingType?.SpecialType ?? SpecialType.System_Int32;
+
+ if (constValue is null)
+ {
+ literal = "0";
+ }
+ else if (st == SpecialType.System_UInt64)
+ {
+ literal = Convert.ToString(constValue, CultureInfo.InvariantCulture) + "UL";
+ }
+ else if (st == SpecialType.System_UInt32)
+ {
+ literal = Convert.ToString(constValue, CultureInfo.InvariantCulture) + "U";
+ }
+ else if (st == SpecialType.System_Int64)
+ {
+ literal = Convert.ToString(constValue, CultureInfo.InvariantCulture) + "L";
+ }
+ else
+ {
+ literal = Convert.ToString(constValue, CultureInfo.InvariantCulture) ?? throw new InvalidOperationException("Unable to convert enum constant value to string.");
+ }
+
+ return $" {name} = {literal},";
+ });
+
+ var membersText = string.Join(NewLine, memberLines);
+
+ var nsPrefix = targetNamespace is null ? string.Empty : $"namespace {targetNamespace};" + NewLine + NewLine;
+
+ var code = "// " + NewLine + NewLine
+ + nsPrefix
+ + $"public enum {targetName} : {underlyingDisplay}" + NewLine
+ + "{" + NewLine
+ + membersText + NewLine
+ + "}" + NewLine;
+
+ var hintName = $"{targetName}.CloneEnum.g.cs";
+ spc.AddSource(hintName, SourceText.From(code, Encoding.UTF8));
+ }
+ });
+ }
+
+ internal static ImmutableArray<(string TargetFullName, string SourceFullName)> ParseMappings(string text)
+ {
+ var builder = ImmutableArray.CreateBuilder<(string, string)>();
+ using var reader = new StringReader(text);
+ string? line;
+ while ((line = reader.ReadLine()) != null)
+ {
+ // Remove comments starting with #
+ var commentIndex = line.IndexOf('#');
+ var content = commentIndex >= 0 ? line.Substring(0, commentIndex) : line;
+ content = content.Trim();
+ if (string.IsNullOrEmpty(content))
+ continue;
+
+ // Expected format: Target.Full.Name = Source.Full.Name
+ var idx = content.IndexOf('=');
+ if (idx <= 0)
+ continue;
+
+ var left = content.Substring(0, idx).Trim();
+ var right = content.Substring(idx + 1).Trim();
+ if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right))
+ continue;
+
+ builder.Add((left, right));
+ }
+
+ return builder.ToImmutable();
+ }
+}
diff --git a/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/Properties/AssemblyInfo.cs b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000..6eac4d12e
--- /dev/null
+++ b/generators/Dalamud.EnumGenerator/Dalamud.EnumGenerator/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Dalamud.EnumGenerator.Tests")]
+