From 68ebbaed93cefa65afe61936c74938ea86be68ea Mon Sep 17 00:00:00 2001 From: routersys Date: Tue, 15 Sep 2026 18:01:43 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=E5=86=8D=E5=B8=B0=E3=81=99=E3=82=8B?= =?UTF-8?q?=E5=91=BC=E3=81=B3=E5=87=BA=E3=81=97=E3=82=92=E8=A8=BA=E6=96=AD?= =?UTF-8?q?=E3=81=A7=E6=96=AD=E3=81=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ja.md | 4 +- README.md | 4 +- .../AnalyzerReleases.Unshipped.md | 6 ++ ...xelShaderDescriptorGenerator.HlslSource.cs | 18 +++++ .../Diagnostics/DiagnosticDescriptors.cs | 20 +++++ .../Models/TypeAliases.cs | 6 +- .../HlslDefinitionsSyntaxProcessor.cs | 77 +++++++++++++++++++ .../SyntaxRewriters/ShaderSourceRewriter.cs | 52 ++++++++++++- .../SyntaxRewriters/StaticFieldRewriter.cs | 3 + .../AnalyzerReleases.Unshipped.md | 6 ++ ...uteShaderDescriptorGenerator.HlslSource.cs | 18 +++++ .../Diagnostics/DiagnosticDescriptors.cs | 16 ++++ src/ComputeWeave/README.ja.md | 2 +- src/ComputeWeave/README.md | 2 +- 14 files changed, 226 insertions(+), 8 deletions(-) diff --git a/README.ja.md b/README.ja.md index 32c1bfead..0de01696b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -260,7 +260,7 @@ GPUが書いたバッファを、以降のシェーダーへ読み取り専用 ### コンパイル時の検証 -以上の宣言はアナライザーが検査し、接頭辞 `CMPW` の診断112種類として報告します。対象は属性の位置、ホストとパイプラインメソッドの形、スロットの宣言、資源の契約、生成されるオーバーロードの衝突です。一部にはコード修正が付きます。 +以上の宣言はアナライザーが検査し、接頭辞 `CMPW` の診断113種類として報告します。対象は属性の位置、ホストとパイプラインメソッドの形、スロットの宣言、資源の契約、生成されるオーバーロードの衝突です。一部にはコード修正が付きます。 --- @@ -297,7 +297,7 @@ ReadOnlyMemory bytecode = D2D1PixelShader.LoadBytecode() ReadOnlyMemory buffer = D2D1PixelShader.GetConstantBuffer(new DifferenceEffect(1)); ``` -これらの宣言は、`CMPWD2D` を接頭辞とする100件の診断を報告するアナライザーが検査します。シェーダーは Direct2D が受け付ける DXBC へ FXC でコンパイルします。`d3dcompiler_47.dll` は Windows に同梱されているため、このパッケージはコンパイラーを同梱しません。 +これらの宣言は、`CMPWD2D` を接頭辞とする101件の診断を報告するアナライザーが検査します。シェーダーは Direct2D が受け付ける DXBC へ FXC でコンパイルします。`d3dcompiler_47.dll` は Windows に同梱されているため、このパッケージはコンパイラーを同梱しません。 --- diff --git a/README.md b/README.md index 057e065a6..5678cc3bb 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ The budget covers the resources the device creates itself. A device using an all ### Compile-time validation -The declarations above are checked by analyzers that report 112 diagnostics with the `CMPW` prefix, covering attribute placement, host and pipeline method shape, slot declaration, resource contracts and generated overload conflicts. Some carry a code fix. +The declarations above are checked by analyzers that report 113 diagnostics with the `CMPW` prefix, covering attribute placement, host and pipeline method shape, slot declaration, resource contracts and generated overload conflicts. Some carry a code fix. --- @@ -297,7 +297,7 @@ ReadOnlyMemory bytecode = D2D1PixelShader.LoadBytecode() ReadOnlyMemory buffer = D2D1PixelShader.GetConstantBuffer(new DifferenceEffect(1)); ``` -The declarations are checked by analyzers that report 100 diagnostics with the `CMPWD2D` prefix. Shaders are compiled to DXBC with FXC, which is what Direct2D accepts; `d3dcompiler_47.dll` ships with Windows, so the package bundles no compiler of its own. +The declarations are checked by analyzers that report 101 diagnostics with the `CMPWD2D` prefix. Shaders are compiled to DXBC with FXC, which is what Direct2D accepts; `d3dcompiler_47.dll` ships with Windows, so the package bundles no compiler of its own. --- diff --git a/src/ComputeWeave.D2D1.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/ComputeWeave.D2D1.SourceGenerators/AnalyzerReleases.Unshipped.md index 6a6dd08d1..a3c8e253e 100644 --- a/src/ComputeWeave.D2D1.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/ComputeWeave.D2D1.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -1,2 +1,8 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +CMPWD2D0101 | ComputeWeave.D2D1.Shaders | Error | [Documentation](https://github.com/routersys/ComputeWeave) diff --git a/src/ComputeWeave.D2D1.SourceGenerators/D2DPixelShaderDescriptorGenerator.HlslSource.cs b/src/ComputeWeave.D2D1.SourceGenerators/D2DPixelShaderDescriptorGenerator.HlslSource.cs index 2ec1ef9e2..b59c784ff 100644 --- a/src/ComputeWeave.D2D1.SourceGenerators/D2DPixelShaderDescriptorGenerator.HlslSource.cs +++ b/src/ComputeWeave.D2D1.SourceGenerators/D2DPixelShaderDescriptorGenerator.HlslSource.cs @@ -75,6 +75,10 @@ public static string GetHlslSource( // is read here whichever declaration it was found in and however that declaration was reached HlslShaderRequirements requirements = new(); + // Every rewriter records the calls it writes out into this one collection, so a call leading back + // to the declaration it is written in is read here once every declaration is rewritten + List calls = []; + // Explore the syntax tree and extract the processed info (string entryPoint, ImmutableArray processedMethods) = GetProcessedMethods( diagnostics, @@ -88,6 +92,7 @@ public static string GetHlslSource( constantDefinitions, staticFieldDefinitions, requirements, + calls, token); token.ThrowIfCancellationRequested(); @@ -105,11 +110,18 @@ public static string GetHlslSource( constantDefinitions, staticFieldDefinitions, requirements, + calls, initializerLocalFunctions, token); token.ThrowIfCancellationRequested(); + // HLSL has no recursion, and whether a call leads back is a property of every declaration the + // generated HLSL holds, so the calls are read once every declaration is rewritten + HlslDefinitionsSyntaxProcessor.ReportRecursiveCalls(diagnostics, calls); + + token.ThrowIfCancellationRequested(); + // The static methods are gathered after the static fields, because an initializer can import one // of its own. Where they land among the methods does not matter: every forward declaration is // written before any of the definitions, and before the static fields that call them. @@ -314,6 +326,7 @@ private static ImmutableArray AppendImportedMethods( /// The collection of discovered constant definitions. /// The collection of discovered static field definitions. /// The requirements gathered for the shader being rewritten. + /// The collection of calls the generated HLSL holds, recorded from the declarations they are written in. /// The collection to receive the local functions lifted out of imported methods. /// The used to cancel the operation, if needed. /// A sequence of static constant fields in . @@ -328,6 +341,7 @@ private static ImmutableArray GetStaticFields( IDictionary constantDefinitions, IDictionary staticFieldDefinitions, HlslShaderRequirements requirements, + ICollection calls, IDictionary localFunctions, CancellationToken token) { @@ -351,6 +365,7 @@ private static ImmutableArray GetStaticFields( constantDefinitions, staticFieldDefinitions, requirements, + calls, diagnostics, token, out string? name, @@ -393,6 +408,7 @@ private static ImmutableArray GetStaticFields( /// The collection of discovered constant definitions. /// The collection of discovered static field definitions. /// The requirements gathered for the shader being rewritten. + /// The collection of calls the generated HLSL holds, recorded from the declarations they are written in. /// The used to cancel the operation, if needed. /// A sequence of processed methods in , and the entry point. private static (string EntryPoint, ImmutableArray Methods) GetProcessedMethods( @@ -407,6 +423,7 @@ private static (string EntryPoint, ImmutableArray Methods) GetProces IDictionary constantDefinitions, IDictionary staticFieldDefinitions, HlslShaderRequirements requirements, + ICollection calls, CancellationToken token) { using ImmutableArrayBuilder methods = new(); @@ -453,6 +470,7 @@ private static (string EntryPoint, ImmutableArray Methods) GetProces constantDefinitions, staticFieldDefinitions, requirements, + calls, diagnostics, token, isShaderEntryPoint); diff --git a/src/ComputeWeave.D2D1.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs b/src/ComputeWeave.D2D1.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs index 7845e0110..6995b2ccf 100644 --- a/src/ComputeWeave.D2D1.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs +++ b/src/ComputeWeave.D2D1.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs @@ -1537,4 +1537,24 @@ partial class DiagnosticDescriptors isEnabledByDefault: true, description: "HLSL needs a custom type declared ahead of every declaration naming it, and the generated HLSL declares the custom types in an order that satisfies the fields and the member method prototypes each of them holds. A field cannot name a type declared later, as C# reports a cycle through fields, so an order always exists for fields alone. Member signatures can name each other in a cycle, and then no order puts every type ahead of the prototypes naming it. The compute generator resolves that with a type forward declaration, which DXC accepts, but FXC has no such declaration, so a pixel shader with such a cycle would be written into HLSL that cannot build, and the failure would name generated code the author never wrote. The member reported is the prototype read before the type it names is declared; moving it out of its type, for instance into a static method taking both types, removes the cycle.", helpLinkUri: "https://github.com/routersys/ComputeWeave"); + + /// + /// Gets a for a call leading back to the declaration it is written in. + /// + /// Format: "The call to {0} cannot be used in a D2D1 pixel shader (it leads back to {1}, the declaration it is written in, and HLSL has no recursion)". + /// + /// + /// + /// This is the Direct2D counterpart of the compute diagnostic. The rewriting that records the calls is + /// shared, so leaving one of the two out would refuse the same call on one path and not the other. + /// + public static readonly DiagnosticDescriptor RecursiveCall = new( + id: "CMPWD2D0101", + title: "Recursive call", + messageFormat: "The call to {0} cannot be used in a D2D1 pixel shader (it leads back to {1}, the declaration it is written in, and HLSL has no recursion)", + category: "ComputeWeave.D2D1.Shaders", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "HLSL has no recursion, so the shader compiler refuses a function that reaches itself through the functions it calls. Without this the call is written out as it stands and the shader compiler answers at the shader type, naming the function in the generated HLSL, which for a local function or a method of a custom type is a name the generator gave it. What is reported is every call on a cycle, at the call as the author wrote it, so a cycle through two declarations is reported at both of the calls closing it and removing either one resolves both. The calls followed are the ones the generated HLSL holds: a method of the shader, a method or constructor of another type the shader reaches, and a local function, which is written out whether or not it is called.", + helpLinkUri: "https://github.com/routersys/ComputeWeave"); } \ No newline at end of file diff --git a/src/ComputeWeave.SourceGeneration.Hlsl/Models/TypeAliases.cs b/src/ComputeWeave.SourceGeneration.Hlsl/Models/TypeAliases.cs index 8e02989de..aabdf32e5 100644 --- a/src/ComputeWeave.SourceGeneration.Hlsl/Models/TypeAliases.cs +++ b/src/ComputeWeave.SourceGeneration.Hlsl/Models/TypeAliases.cs @@ -13,4 +13,8 @@ // after every field its initializer reads, so writing them by it puts each after the ones it names global using HlslStaticField = (string Name, string? TypeDeclaration, string? Assignment, int Order); global using HlslSharedBuffer = (string Name, string Type, int? Count); -global using HlslMethod = (string Signature, string Declaration); \ No newline at end of file +global using HlslMethod = (string Signature, string Declaration); + +// A call the generated HLSL holds, from the declaration it is written in to the one it resolves to, +// with the call as the author wrote it. Every rewriter for one shader records into the same collection +global using HlslCall = (Microsoft.CodeAnalysis.IMethodSymbol Caller, Microsoft.CodeAnalysis.IMethodSymbol Callee, Microsoft.CodeAnalysis.SyntaxNode Site); \ No newline at end of file diff --git a/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxProcessors/HlslDefinitionsSyntaxProcessor.cs b/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxProcessors/HlslDefinitionsSyntaxProcessor.cs index 3d19a534e..9863e468c 100644 --- a/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxProcessors/HlslDefinitionsSyntaxProcessor.cs +++ b/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxProcessors/HlslDefinitionsSyntaxProcessor.cs @@ -55,6 +55,7 @@ public static ImmutableArray GetDefinedConstants(IReadOnlyDictiona /// The collection of discovered constant definitions. /// The collection of discovered static field definitions. /// The requirements gathered for the shader being rewritten. + /// The collection of calls the generated HLSL holds, recorded from the declarations they are written in. /// The collection of produced instances. /// The used to cancel the operation, if needed. /// The mapped name for the field. @@ -73,6 +74,7 @@ public static bool TryGetStaticField( IDictionary constantDefinitions, IDictionary staticFieldDefinitions, HlslShaderRequirements requirements, + ICollection calls, ImmutableArrayBuilder diagnostics, CancellationToken token, [NotNullWhen(true)] out string? name, @@ -143,6 +145,7 @@ public static bool TryGetStaticField( constantDefinitions, staticFieldDefinitions, requirements, + calls, diagnostics, token); @@ -332,6 +335,80 @@ public static void DetectAndReportInvalidPropertyDeclarations(ImmutableArrayBuil } } + /// + /// Reports every call that leads back to the declaration it is written in. + /// + /// The collection of produced instances. + /// The collection of calls the generated HLSL holds, recorded from the declarations they are written in. + /// + /// + /// HLSL has no recursion, so the shader compiler refuses a function that reaches itself through the + /// functions it calls. Whether a call does is a property of every declaration the generated HLSL holds, + /// so this runs once every declaration is rewritten, over the calls each rewriting recorded: a method + /// of the shader, a method or constructor of another type the shader reaches, and a local function are + /// all written out as functions, a local function whether or not it is called. + /// + /// + /// Every call on a cycle is reported, at the call as the author wrote it, so a cycle through two + /// declarations names both of the calls closing it and either one can be the one removed. + /// + /// + public static void ReportRecursiveCalls(ImmutableArrayBuilder diagnostics, IReadOnlyCollection calls) + { + Dictionary> callees = new(SymbolEqualityComparer.Default); + + foreach ((IMethodSymbol caller, IMethodSymbol callee, _) in calls) + { + if (!callees.TryGetValue(caller, out List? targets)) + { + targets = []; + + callees.Add(caller, targets); + } + + targets.Add(callee); + } + + // The declarations a call leads to, following the calls those hold in turn + Dictionary> reachable = new(SymbolEqualityComparer.Default); + + HashSet GetReachable(IMethodSymbol callee) + { + if (!reachable.TryGetValue(callee, out HashSet? reached)) + { + reached = new(SymbolEqualityComparer.Default); + + Stack pending = new([callee]); + + while (pending.Count > 0) + { + if (callees.TryGetValue(pending.Pop(), out List? next)) + { + foreach (IMethodSymbol declaration in next) + { + if (reached.Add(declaration)) + { + pending.Push(declaration); + } + } + } + } + + reachable.Add(callee, reached); + } + + return reached; + } + + foreach ((IMethodSymbol caller, IMethodSymbol callee, SyntaxNode site) in calls) + { + if (GetReachable(callee).Contains(caller)) + { + diagnostics.Add(RecursiveCall, site, callee, caller); + } + } + } + /// /// Gets the order the next static field to finish takes, which is how many have finished before it. /// diff --git a/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxRewriters/ShaderSourceRewriter.cs b/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxRewriters/ShaderSourceRewriter.cs index fd9d6e597..f19c34f98 100644 --- a/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxRewriters/ShaderSourceRewriter.cs +++ b/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxRewriters/ShaderSourceRewriter.cs @@ -34,6 +34,7 @@ namespace ComputeWeave.SourceGeneration.SyntaxRewriters; /// The collection of discovered constant definitions. /// The collection of discovered static field definitions. /// The requirements gathered for the shader being rewritten. +/// The collection of calls the generated HLSL holds, recorded from the declarations they are written in. /// The collection of produced instances. /// The value for the current operation. /// Whether or not the current instance is processing a shader entry point. @@ -47,6 +48,7 @@ internal sealed partial class ShaderSourceRewriter( IDictionary constantDefinitions, IDictionary staticFieldDefinitions, HlslShaderRequirements requirements, + ICollection calls, ImmutableArrayBuilder diagnostics, CancellationToken token, bool isEntryPoint = false) @@ -67,6 +69,11 @@ internal sealed partial class ShaderSourceRewriter( /// private readonly IDictionary constructors = constructors; + /// + /// The collection of calls the generated HLSL holds, shared by every rewriter for the shader. + /// + private readonly ICollection calls = calls; + /// /// The collection of processed local functions in the current tree. /// @@ -93,6 +100,15 @@ internal sealed partial class ShaderSourceRewriter( /// private SyntaxToken currentMethodIdentifier; + /// + /// The symbol of the declaration whose body is being visited, which a call it holds is recorded from. + /// + /// + /// A local function is written out as a function of its own, so while its body is visited this is the + /// local function rather than the declaration holding it, the way is swapped. + /// + private IMethodSymbol? currentDeclaration; + /// /// The current depth inside local declarations. /// @@ -112,6 +128,7 @@ internal sealed partial class ShaderSourceRewriter( } this.currentMethodIdentifier = node.Identifier; + this.currentDeclaration = SemanticModel.For(node).GetDeclaredSymbol(node, CancellationToken); ReportUnmappedOperators(node); @@ -145,6 +162,7 @@ internal sealed partial class ShaderSourceRewriter( } this.currentMethodIdentifier = node.Identifier; + this.currentDeclaration = SemanticModel.For(node).GetDeclaredSymbol(node, CancellationToken); // A local function is reached through the body of the declaration that holds it, so it is // covered here and does not need a walk of its own @@ -193,6 +211,7 @@ internal sealed partial class ShaderSourceRewriter( } this.currentMethodIdentifier = node.Identifier; + this.currentDeclaration = SemanticModel.For(node).GetDeclaredSymbol(node, CancellationToken); LocalFunctionStatementSyntax? updatedNode = (LocalFunctionStatementSyntax?)base.Visit(node)!; @@ -353,10 +372,13 @@ public override SyntaxNode VisitLocalDeclarationStatement(LocalDeclarationStatem // A variable declared inside this function belongs to the function it is written in, which is // lifted out as a function of its own, so the declarations it raises are collected apart from - // the ones of the body holding it and written into its own body below + // the ones of the body holding it and written into its own body below. A call it holds is + // recorded from it for the same reason List enclosingImplicitVariables = this.implicitVariables; + IMethodSymbol? enclosingDeclaration = this.currentDeclaration; this.implicitVariables = []; + this.currentDeclaration = functionSymbol; LocalFunctionStatementSyntax updatedNode = ((LocalFunctionStatementSyntax)base.VisitLocalFunctionStatement(node)!) @@ -371,6 +393,7 @@ public override SyntaxNode VisitLocalDeclarationStatement(LocalDeclarationStatem } this.implicitVariables = enclosingImplicitVariables; + this.currentDeclaration = enclosingDeclaration; updatedNode = ReplaceAndTrackType(updatedNode, updatedNode.ReturnType, node!.ReturnType, SemanticModel.For(node)); @@ -442,10 +465,31 @@ private ShaderSourceRewriter CreateNestedRewriter() ConstantDefinitions, StaticFieldDefinitions, Requirements, + this.calls, Diagnostics, CancellationToken); } + /// + /// Records a call the generated HLSL holds, from the declaration being visited to the one it resolves to. + /// + /// The call as the author wrote it. + /// The method or constructor the call resolves to. + /// + /// A call resolving to a declaration with source is written out as a call to what that declaration is + /// written out as, whichever route imports or renames it, so it is recorded ahead of those routes. The + /// calls are read for one leading back to the declaration it is written in once every declaration is + /// rewritten, HLSL having no recursion (see ). + /// A partial declaration is recorded under its defining part, which is what a call to it resolves to. + /// + private void TrackCall(SyntaxNode node, IMethodSymbol method) + { + if (this.currentDeclaration is { } caller && method.TryGetSyntaxNode(CancellationToken, out SyntaxNode? _)) + { + this.calls.Add((caller.PartialDefinitionPart ?? caller, method.PartialDefinitionPart ?? method, node)); + } + } + /// /// Merges the local functions gathered by a nested rewriter into the current collection. /// @@ -654,6 +698,8 @@ public override SyntaxNode VisitMemberAccessExpression(MemberAccessExpressionSyn return updatedNode; } + TrackCall(node, method); + if (method.IsStatic) { string metadataName = method.GetFullyQualifiedMetadataName(); @@ -987,6 +1033,9 @@ ArgumentSyntax[] ExtractArguments() this.constructors[constructor] = (stubNode, ctorNode); } + // The creation is written out as a call to the stub, which calls the constructor + TrackCall(node, constructor); + // Rewrite the expression to invoke the rewritten constructor: // // ::__ctor(...) @@ -1044,6 +1093,7 @@ ArgumentSyntax[] ExtractArguments() ConstantDefinitions, StaticFieldDefinitions, Requirements, + this.calls, Diagnostics, CancellationToken, out _, diff --git a/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxRewriters/StaticFieldRewriter.cs b/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxRewriters/StaticFieldRewriter.cs index 83ee632c9..d866568af 100644 --- a/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxRewriters/StaticFieldRewriter.cs +++ b/src/ComputeWeave.SourceGeneration.Hlsl/SyntaxRewriters/StaticFieldRewriter.cs @@ -27,6 +27,7 @@ namespace ComputeWeave.SourceGeneration.SyntaxRewriters; /// The collection of discovered constant definitions. /// The collection of discovered static field definitions. /// The requirements gathered for the shader being rewritten. +/// The collection of calls the generated HLSL holds, recorded from the declarations they are written in. /// The collection of produced instances. /// The value for the current operation. internal sealed partial class StaticFieldRewriter( @@ -39,6 +40,7 @@ internal sealed partial class StaticFieldRewriter( IDictionary constantDefinitions, IDictionary staticFieldDefinitions, HlslShaderRequirements requirements, + ICollection calls, ImmutableArrayBuilder diagnostics, CancellationToken token) : HlslSourceRewriter(shaderType, semanticModel, discoveredTypes, constantDefinitions, staticFieldDefinitions, requirements, diagnostics, token) @@ -357,6 +359,7 @@ private ShaderSourceRewriter CreateImportRewriter() ConstantDefinitions, StaticFieldDefinitions, Requirements, + calls, Diagnostics, CancellationToken); } diff --git a/src/ComputeWeave.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/ComputeWeave.SourceGenerators/AnalyzerReleases.Unshipped.md index 17d4678ce..c78ea44d3 100644 --- a/src/ComputeWeave.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/ComputeWeave.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -1,2 +1,8 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/master/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +CMPW0129 | ComputeWeave.Shaders | Error | [Documentation](https://github.com/routersys/ComputeWeave) diff --git a/src/ComputeWeave.SourceGenerators/ComputeShaderDescriptorGenerator.HlslSource.cs b/src/ComputeWeave.SourceGenerators/ComputeShaderDescriptorGenerator.HlslSource.cs index 2060c844e..eb5db0015 100644 --- a/src/ComputeWeave.SourceGenerators/ComputeShaderDescriptorGenerator.HlslSource.cs +++ b/src/ComputeWeave.SourceGenerators/ComputeShaderDescriptorGenerator.HlslSource.cs @@ -95,6 +95,10 @@ public static void GetInfo( // is read here whichever declaration it was found in and however that declaration was reached HlslShaderRequirements requirements = new(); + // Every rewriter records the calls it writes out into this one collection, so a call leading back + // to the declaration it is written in is read here once every declaration is rewritten + List calls = []; + (string entryPoint, ImmutableArray processedMethods) = GetProcessedMethods( diagnostics, structDeclarationSymbol, @@ -107,6 +111,7 @@ public static void GetInfo( constantDefinitions, staticFieldDefinitions, requirements, + calls, isComputeShader, token); @@ -125,11 +130,18 @@ public static void GetInfo( constantDefinitions, staticFieldDefinitions, requirements, + calls, initializerLocalFunctions, token); token.ThrowIfCancellationRequested(); + // HLSL has no recursion, and whether a call leads back is a property of every declaration the + // generated HLSL holds, so the calls are read once every declaration is rewritten + HlslDefinitionsSyntaxProcessor.ReportRecursiveCalls(diagnostics, calls); + + token.ThrowIfCancellationRequested(); + // The static methods are gathered after the static fields, because an initializer can import one // of its own. Where they land among the methods does not matter: every forward declaration is // written before any of the definitions, and before the static fields that call them. @@ -346,6 +358,7 @@ private static ImmutableArray AppendImportedMethods( /// The collection of discovered constant definitions. /// The collection of discovered static field definitions. /// The requirements gathered for the shader being rewritten. + /// The collection of calls the generated HLSL holds, recorded from the declarations they are written in. /// The collection to receive the local functions lifted out of imported methods. /// The used to cancel the operation, if needed. /// A sequence of static constant fields in . @@ -360,6 +373,7 @@ private static ImmutableArray GetStaticFields( IDictionary constantDefinitions, IDictionary staticFieldDefinitions, HlslShaderRequirements requirements, + ICollection calls, IDictionary localFunctions, CancellationToken token) { @@ -390,6 +404,7 @@ private static ImmutableArray GetStaticFields( constantDefinitions, staticFieldDefinitions, requirements, + calls, diagnostics, token, out string? name, @@ -473,6 +488,7 @@ private static ImmutableArray GetSharedBuffers( /// The collection of discovered constant definitions. /// The collection of discovered static field definitions. /// The requirements gathered for the shader being rewritten. + /// The collection of calls the generated HLSL holds, recorded from the declarations they are written in. /// Indicates whether or not represents a compute shader. /// The used to cancel the operation, if needed. /// A sequence of processed methods in , and the entry point. @@ -488,6 +504,7 @@ private static (string EntryPoint, ImmutableArray Methods) GetProces IDictionary constantDefinitions, IDictionary staticFieldDefinitions, HlslShaderRequirements requirements, + ICollection calls, bool isComputeShader, CancellationToken token) { @@ -535,6 +552,7 @@ private static (string EntryPoint, ImmutableArray Methods) GetProces constantDefinitions, staticFieldDefinitions, requirements, + calls, diagnostics, token, isShaderEntryPoint); diff --git a/src/ComputeWeave.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs b/src/ComputeWeave.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs index ed0d35644..b067d515d 100644 --- a/src/ComputeWeave.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs +++ b/src/ComputeWeave.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs @@ -1703,4 +1703,20 @@ partial class DiagnosticDescriptors isEnabledByDefault: true, description: "What is written into the generated HLSL is built from the body of the declaration, so one that carries none has nothing to write. An extern declaration is that case, and C# reports only a warning for it. Without this a method or a local function is written out as a declaration with no body and the shader compiler answers by naming generated code the author never wrote, while a constructor and the entry point end the generator instead, which discards the descriptors for every shader in the compilation unit. What is reported follows what is written out: a member of an external type is written out where the shader reaches it, and one it never reaches is left alone. A declaration split into parts is unaffected, the implementing part being the one that is read.", helpLinkUri: "https://github.com/routersys/ComputeWeave"); + + /// + /// Gets a for a call leading back to the declaration it is written in. + /// + /// Format: "The call to {0} cannot be used in a compute shader (it leads back to {1}, the declaration it is written in, and HLSL has no recursion)". + /// + /// + public static readonly DiagnosticDescriptor RecursiveCall = new( + id: "CMPW0129", + title: "Recursive call", + messageFormat: "The call to {0} cannot be used in a compute shader (it leads back to {1}, the declaration it is written in, and HLSL has no recursion)", + category: "ComputeWeave.Shaders", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "HLSL has no recursion, so the shader compiler refuses a function that reaches itself through the functions it calls. Without this the call is written out as it stands and the shader compiler answers at the shader type, naming the function in the generated HLSL, which for a local function or a method of a custom type is a name the generator gave it. What is reported is every call on a cycle, at the call as the author wrote it, so a cycle through two declarations is reported at both of the calls closing it and removing either one resolves both. The calls followed are the ones the generated HLSL holds: a method of the shader, a method or constructor of another type the shader reaches, and a local function, which is written out whether or not it is called.", + helpLinkUri: "https://github.com/routersys/ComputeWeave"); } \ No newline at end of file diff --git a/src/ComputeWeave/README.ja.md b/src/ComputeWeave/README.ja.md index 1f2fead02..4ad54a45b 100644 --- a/src/ComputeWeave/README.ja.md +++ b/src/ComputeWeave/README.ja.md @@ -94,7 +94,7 @@ public sealed partial class ResourceSet ## コンパイル時の検証 -以上の宣言はアナライザーが検査し、接頭辞 `CMPW` の診断112種類として報告します。対象は属性の位置、ホストとパイプラインメソッドの形、スロットの宣言、資源の契約、生成されるオーバーロードの衝突です。一部にはコード修正が付きます。実行時に識別子を持つ拒否は `IComputeDiagnostic` を実装するもの、すなわち `ComputeDiagnosticException` と `GraphicsDeviceMismatchException` だけです。その `DiagnosticId` は同じ `CMPW` 接頭辞を専用の番号帯で使います。引数の検査は識別子を持ちません。`ArgumentOutOfRangeException` をはじめとするフレームワークの例外型を送出し、代わりに引数の名前を載せます。 +以上の宣言はアナライザーが検査し、接頭辞 `CMPW` の診断113種類として報告します。対象は属性の位置、ホストとパイプラインメソッドの形、スロットの宣言、資源の契約、生成されるオーバーロードの衝突です。一部にはコード修正が付きます。実行時に識別子を持つ拒否は `IComputeDiagnostic` を実装するもの、すなわち `ComputeDiagnosticException` と `GraphicsDeviceMismatchException` だけです。その `DiagnosticId` は同じ `CMPW` 接頭辞を専用の番号帯で使います。引数の検査は識別子を持ちません。`ArgumentOutOfRangeException` をはじめとするフレームワークの例外型を送出し、代わりに引数の名前を載せます。 ## 詳細 diff --git a/src/ComputeWeave/README.md b/src/ComputeWeave/README.md index 22ae7abe1..dac8b4397 100644 --- a/src/ComputeWeave/README.md +++ b/src/ComputeWeave/README.md @@ -94,7 +94,7 @@ Retiring a shared texture generation drains the external queue before the extern ## Compile-time validation -The declarations above are checked by analyzers that report 112 diagnostics with the `CMPW` prefix, covering attribute placement, host and pipeline method shape, slot declaration, resource contracts and generated overload conflicts. Some carry a code fix. At run time an identifier is carried only by the rejections that implement `IComputeDiagnostic`, which are `ComputeDiagnosticException` and `GraphicsDeviceMismatchException`; their `DiagnosticId` uses the same `CMPW` prefix in a number band of its own. Argument validation carries no identifier: it throws the framework exception types, `ArgumentOutOfRangeException` among them, and names the argument instead. +The declarations above are checked by analyzers that report 113 diagnostics with the `CMPW` prefix, covering attribute placement, host and pipeline method shape, slot declaration, resource contracts and generated overload conflicts. Some carry a code fix. At run time an identifier is carried only by the rejections that implement `IComputeDiagnostic`, which are `ComputeDiagnosticException` and `GraphicsDeviceMismatchException`; their `DiagnosticId` uses the same `CMPW` prefix in a number band of its own. Argument validation carries no identifier: it throws the framework exception types, `ArgumentOutOfRangeException` among them, and names the argument instead. ## More From b39e1360cf6ea0719120f4b5b704a2b48a117671 Mon Sep 17 00:00:00 2001 From: routersys Date: Tue, 15 Sep 2026 18:02:05 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=91=E3=82=A4?= =?UTF-8?q?=E3=83=A9=E3=81=8C=E6=96=AD=E3=82=8B=E5=85=A5=E5=8A=9B=E3=81=AE?= =?UTF-8?q?=E8=A9=A6=E9=A8=93=E3=82=92=E5=86=8D=E5=B8=B0=E4=BB=A5=E5=A4=96?= =?UTF-8?q?=E3=81=AE=E5=BD=A2=E3=81=B8=E7=A7=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...DescriptorGenerator_DiagnosticLocations.cs | 5 +-- ...erDescriptorGenerator_RefusedConstructs.cs | 18 +++++------ .../Shaders/RefusedConstructTests.cs | 32 +++++++++++++------ 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_DiagnosticLocations.cs b/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_DiagnosticLocations.cs index a0984635d..030f8b031 100644 --- a/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_DiagnosticLocations.cs +++ b/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_DiagnosticLocations.cs @@ -57,6 +57,7 @@ public float4 Execute() } """; + // A thread synchronization is what the pixel shader profile refuses, and the rewriter maps it like any intrinsic private const string CompilerRefusedSource = """ using ComputeWeave; using ComputeWeave.D2D1; @@ -73,9 +74,9 @@ namespace MyNamespace; public float4 Execute() { - static int Fib(int n) => n <= 1 ? n : Fib(n - 1) + Fib(n - 2); + Hlsl.GroupMemoryBarrierWithGroupSync(); - return time + Fib(3); + return time; } } """; diff --git a/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_RefusedConstructs.cs b/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_RefusedConstructs.cs index 312c56a65..a60d6e446 100644 --- a/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_RefusedConstructs.cs +++ b/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_RefusedConstructs.cs @@ -88,15 +88,15 @@ public void TheShaderTheRowsAreBuiltAroundIsNotDiagnosed() /// A pixel shader the rewriter accepts and FXC refuses. /// /// - /// Recursion is what FXC refuses, and the rewriter has nothing to say about the local function carrying - /// it, so a refusal is the only thing that keeps a shader from reaching FXC. Without this row, removing - /// the forwarding outright would leave every row above passing. + /// A thread synchronization is what the pixel shader profile refuses, and the rewriter maps the intrinsic + /// like any other, so a refusal is the only thing that keeps a shader from reaching FXC. Without this row, + /// removing the forwarding outright would leave every row above passing. /// [TestMethod] public void AnInputTheRewriterAcceptsCarriesTheCompilerFailure() { ImmutableArray reported = CSharpGeneratorTest.GetReportedDiagnostics( - Shader("static int Fib(int n) => n <= 1 ? n : Fib(n - 1) + Fib(n - 2); k += Fib(3);", isUnsafe: false)); + Shader("Hlsl.GroupMemoryBarrierWithGroupSync(); k += 1;", isUnsafe: false)); Assert.AreEqual("CMPWD2D0034", Ids(reported)); } @@ -105,9 +105,9 @@ public void AnInputTheRewriterAcceptsCarriesTheCompilerFailure() /// A pixel shader carrying syntax the accepted set does not cover. /// /// - /// The report refuses the input, so the shader never reaches FXC. The body carries recursion, which HLSL - /// cannot express under any profile, so FXC would answer for it were it handed the shader: what the row - /// reads is the refusal arriving alone, and not a body the compiler happens to accept. + /// The report refuses the input, so the shader never reaches FXC. The body carries a thread synchronization, + /// which the pixel shader profile cannot express, so FXC would answer for it were it handed the shader: what + /// the row reads is the refusal arriving alone, and not a body the compiler happens to accept. /// [TestMethod] public void AReportForSyntaxWithNoVerdictRefusesTheInput() @@ -121,9 +121,9 @@ public void AReportForSyntaxWithNoVerdictRefusesTheInput() done: v += 1; - static int Fib(int n) => n <= 1 ? n : Fib(n - 1) + Fib(n - 2); + Hlsl.GroupMemoryBarrierWithGroupSync(); - k += Fib(3) + (int)v; + k += (int)v; """, isUnsafe: false)); diff --git a/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RefusedConstructTests.cs b/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RefusedConstructTests.cs index 1e179984d..dd33c18ad 100644 --- a/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RefusedConstructTests.cs +++ b/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RefusedConstructTests.cs @@ -148,15 +148,15 @@ public void Execute() /// A shader the rewriter accepts and the HLSL compiler refuses. /// /// - /// Recursion is what the HLSL compiler refuses, and the rewriter has nothing to say about the local - /// function carrying it, so a refusal is the only thing that keeps a shader from reaching the compiler. - /// Without this row, removing the forwarding outright would leave every row above passing. + /// More group shared memory than a group may hold is what the HLSL compiler refuses, and nothing in the + /// generator reads that total, so a refusal is the only thing that keeps a shader from reaching the + /// compiler. Without this row, removing the forwarding outright would leave every row above passing. /// [TestMethod] public void AnInputTheRewriterAcceptsCarriesTheCompilerFailure() { Diagnostic[] reported = Report( - Shader("static int Fib(int n) => n <= 1 ? n : Fib(n - 1) + Fib(n - 2); k += Fib(3);", isUnsafe: false), + Shader("cache[ThreadIds.X] = 1; k += (int)cache[ThreadIds.X];", isUnsafe: false, GroupSharedOverTheLimit), "ShaderAcceptedCompilerFailureTests"); Assert.AreEqual("CMPW0046", Ids(reported)); @@ -166,8 +166,8 @@ public void AnInputTheRewriterAcceptsCarriesTheCompilerFailure() /// A shader carrying syntax the accepted set does not cover. /// /// - /// The report refuses the input, so the shader never reaches the HLSL compiler. The body carries recursion, - /// which HLSL cannot express under any profile, so the compiler would answer for it were it handed the + /// The report refuses the input, so the shader never reaches the HLSL compiler. The shader declares more + /// group shared memory than a group may hold, so the compiler would answer for it were it handed the /// shader: what the row reads is the refusal arriving alone, and not a body the compiler happens to accept. /// [TestMethod] @@ -182,11 +182,12 @@ public void AReportForSyntaxWithNoVerdictRefusesTheInput() done: v += 1; - static int Fib(int n) => n <= 1 ? n : Fib(n - 1) + Fib(n - 2); + cache[ThreadIds.X] = v; - k += Fib(3) + (int)v; + k += (int)cache[ThreadIds.X]; """, - isUnsafe: false), + isUnsafe: false, + GroupSharedOverTheLimit), "ShaderReportedRefusalTests"); Assert.AreEqual("CMPW0121", Ids(reported)); @@ -309,13 +310,22 @@ private static Diagnostic[] Report(string source, string assemblyName) return [.. result.Diagnostics]; } + /// + /// A group shared field larger than a group may hold, which the HLSL compiler refuses and nothing in the generator reads. + /// + private const string GroupSharedOverTheLimit = """ + [GroupShared(16384)] + private static readonly float[] cache; + """; + /// /// Builds a shader around a body. /// /// The statements to put in the shader body. /// Whether the entry point needs an unsafe context. + /// The members to declare beside the entry point, if any. /// The source of a shader carrying . - private static string Shader(string body, bool isUnsafe) + private static string Shader(string body, bool isUnsafe, string members = "") { return $$""" using System.Linq; @@ -329,6 +339,8 @@ namespace Shaders; { private readonly ReadWriteBuffer buffer; + {{members}} + public {{(isUnsafe ? "unsafe " : "")}}void Execute() { int k = 1; From a0f976fe233f56f4373a95c6c7662ffb6bde9219 Mon Sep 17 00:00:00 2001 From: routersys Date: Tue, 15 Sep 2026 18:02:06 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=E5=86=8D=E5=B8=B0=E3=81=99=E3=82=8B?= =?UTF-8?q?=E5=91=BC=E3=81=B3=E5=87=BA=E3=81=97=E3=81=AE=E6=96=AD=E3=82=8A?= =?UTF-8?q?=E3=82=92=E8=A9=A6=E9=A8=93=E3=81=A7=E5=9B=BA=E5=AE=9A=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...elShaderDescriptorGenerator_Diagnostics.cs | 121 ++++++ .../Shaders/RecursiveCallTests.cs | 402 ++++++++++++++++++ 2 files changed, 523 insertions(+) create mode 100644 tests/ComputeWeave.Tests.SourceGenerators/Shaders/RecursiveCallTests.cs diff --git a/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_Diagnostics.cs b/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_Diagnostics.cs index 263ea9317..c91c4f04e 100644 --- a/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_Diagnostics.cs +++ b/tests/ComputeWeave.D2D1.Tests.SourceGenerators/Test_D2DPixelShaderDescriptorGenerator_Diagnostics.cs @@ -1,5 +1,8 @@ +using System.Collections.Immutable; +using System.Linq; using ComputeWeave.D2D1.SourceGenerators; using ComputeWeave.Tests.SourceGenerators.Helpers; +using Microsoft.CodeAnalysis; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace ComputeWeave.D2D1.Tests.SourceGenerators; @@ -1477,4 +1480,122 @@ public float4 Execute() CSharpGeneratorTest.VerifyDiagnostics(source, "CMPWD2D0041"); } + + /// + /// A method calling itself, which HLSL has no way to write. Before this the shader compiler refused the + /// generated HLSL at the shader type, and for a local function or a method of a custom type it named a + /// function the generator gave its name to. + /// + /// + /// The rewriting that records the calls is shared with the compute generator, and each of the two carries + /// its own identifier, so a row on one of them says nothing about the other. The shapes a cycle can close + /// through are measured on the compute side, the rows here answering for the identifier and for the report + /// landing on the call as the author wrote it. + /// + [TestMethod] + public void ARecursiveMethodIsDiagnosed() + { + const string source = """ + using ComputeWeave; + using ComputeWeave.D2D1; + using float4 = global::ComputeWeave.Float4; + + namespace MyNamespace; + + [D2DInputCount(0)] + [D2DShaderProfile(D2D1ShaderProfile.PixelShader50)] + [D2DGeneratedPixelShaderDescriptor] + internal readonly partial struct MyShader : ID2D1PixelShader + { + private readonly float time; + + private static float Twice(float value) => Twice(value) * 2; + + public float4 Execute() + { + return Twice(this.time); + } + } + """; + + CSharpGeneratorTest.VerifyDiagnostics(source, "CMPWD2D0101"); + } + + /// + /// A local function calling itself. The generated HLSL holds it under a name the generator gave it, so + /// this is the row for the report naming what the author wrote and landing on the call. + /// + [TestMethod] + public void ARecursiveLocalFunctionIsDiagnosedAtTheCall() + { + const string source = """ + using ComputeWeave; + using ComputeWeave.D2D1; + using float4 = global::ComputeWeave.Float4; + + namespace MyNamespace; + + [D2DInputCount(0)] + [D2DShaderProfile(D2D1ShaderProfile.PixelShader50)] + [D2DGeneratedPixelShaderDescriptor] + internal readonly partial struct MyShader : ID2D1PixelShader + { + private readonly float time; + + public float4 Execute() + { + static float Twice(float value) => Twice(value) * 2; + + return Twice(this.time); + } + } + """; + + Diagnostic diagnostic = CSharpGeneratorTest.GetReportedDiagnostics(source).Single(); + string message = diagnostic.GetMessage(); + + Assert.AreEqual("CMPWD2D0101", diagnostic.Id); + Assert.AreEqual("Twice(value)", diagnostic.Location.SourceTree!.GetText().ToString(diagnostic.Location.SourceSpan)); + Assert.IsTrue(message.Contains("Twice(float)"), message); + Assert.IsFalse(message.Contains("__"), message); + } + + /// + /// Two methods calling each other, reported at both of the calls closing the cycle. + /// + [TestMethod] + public void MutuallyRecursiveMethodsAreDiagnosedAtBothCalls() + { + const string source = """ + using ComputeWeave; + using ComputeWeave.D2D1; + using float4 = global::ComputeWeave.Float4; + + namespace MyNamespace; + + [D2DInputCount(0)] + [D2DShaderProfile(D2D1ShaderProfile.PixelShader50)] + [D2DGeneratedPixelShaderDescriptor] + internal readonly partial struct MyShader : ID2D1PixelShader + { + private readonly float time; + + private static float Even(float value) => Odd(value); + + private static float Odd(float value) => Even(value); + + public float4 Execute() + { + return Even(this.time); + } + } + """; + + ImmutableArray diagnostics = CSharpGeneratorTest.GetReportedDiagnostics(source); + + Assert.IsTrue(diagnostics.All(static diagnostic => diagnostic.Id == "CMPWD2D0101"), string.Join(", ", diagnostics.Select(static diagnostic => diagnostic.Id))); + CollectionAssert.AreEqual( + new[] { "Even(value)", "Odd(value)" }, + diagnostics.Select(static diagnostic => diagnostic.Location.SourceTree!.GetText().ToString(diagnostic.Location.SourceSpan)).Order().ToArray()); + } } diff --git a/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RecursiveCallTests.cs b/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RecursiveCallTests.cs new file mode 100644 index 000000000..854ac190d --- /dev/null +++ b/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RecursiveCallTests.cs @@ -0,0 +1,402 @@ +using System.Collections.Immutable; +using System.Linq; +using ComputeWeave.SourceGenerators; +using ComputeWeave.Tests.SourceGenerators.Helpers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ComputeWeave.Tests.SourceGenerators.Shaders; + +/// +/// What a call leading back to the declaration it is written in is answered with. HLSL has no recursion, so +/// the shader compiler refuses such a function, and before this it did so at the shader type, naming a +/// function of the generated HLSL, which for a local function or a method of a custom type is a name the +/// generator gave it. +/// +/// +/// The rows are the declarations the generated HLSL holds and the ways a cycle can close through them, the +/// calls being recorded from whichever rewriting writes them out: a method of the shader, a method or a +/// constructor of another type, and a local function, whether or not it is called. Each row names the calls +/// the report has to land on, as the author wrote them, so a report at the declaration or at the shader type +/// fails the row as much as a missing one does. +/// +[TestClass] +public class RecursiveCallTests +{ + [TestMethod] + [DataRow( + "RecursiveStaticMethodOfTheShaderTests", + "", + "private static float Twice(float value) => Twice(value) * 2;", + "this.buffer[0] = Twice(2.0f);", + new[] { "Twice(value)" })] + [DataRow( + "RecursiveInstanceMethodOfTheShaderTests", + "", + "private float Twice(float value) => Twice(value) * 2;", + "this.buffer[0] = Twice(2.0f);", + new[] { "Twice(value)" })] + [DataRow( + "MutuallyRecursiveMethodsOfTheShaderTests", + "", + """ + private static float Even(float value) => Odd(value); + + private static float Odd(float value) => Even(value); + """, + "this.buffer[0] = Even(2.0f);", + new[] { "Even(value)", "Odd(value)" })] + [DataRow( + "RecursionThroughThreeMethodsOfTheShaderTests", + "", + """ + private static float First(float value) => Second(value); + + private static float Second(float value) => Third(value); + + private static float Third(float value) => First(value); + """, + "this.buffer[0] = First(2.0f);", + new[] { "First(value)", "Second(value)", "Third(value)" })] + [DataRow( + "RecursiveExternalStaticMethodTests", + """ + internal static class Helper + { + public static float Twice(float value) => Twice(value) * 2; + } + """, + "", + "this.buffer[0] = Helper.Twice(2.0f);", + new[] { "Twice(value)" })] + [DataRow( + "RecursiveExtensionMethodTests", + """ + internal static class Helper + { + public static float Twice(this float value) => value.Twice() * 2; + } + """, + "", + "this.buffer[0] = 2.0f.Twice();", + new[] { "value.Twice()" })] + [DataRow( + "RecursionThroughAnExternalStaticMethodTests", + """ + internal static class Helper + { + public static float Back(float value) => Shader.Twice(value); + } + """, + "internal static float Twice(float value) => Helper.Back(value);", + "this.buffer[0] = Twice(2.0f);", + new[] { "Helper.Back(value)", "Shader.Twice(value)" })] + [DataRow( + "RecursiveInstanceMethodOfACustomTypeTests", + """ + internal struct Helper + { + public float Amount; + + public float Doubled() => Doubled() * 2; + } + """, + "", + """ + Helper helper = default; + + this.buffer[0] = helper.Doubled(); + """, + new[] { "Doubled()" })] + [DataRow( + "RecursiveConstructorTests", + """ + internal struct Helper + { + public float Amount; + + public Helper(float amount) + { + Amount = new Helper(amount - 1).Amount; + } + } + """, + "", + "this.buffer[0] = new Helper(2.0f).Amount;", + new[] { "new Helper(amount - 1)" })] + [DataRow( + "RecursionThroughAConstructorTests", + """ + internal struct Helper + { + public float Amount; + + public Helper(float amount) + { + Amount = Make(amount); + } + + public static float Make(float amount) => new Helper(amount).Amount; + } + """, + "", + "this.buffer[0] = Helper.Make(2.0f);", + new[] { "Make(amount)", "new Helper(amount)" })] + [DataRow( + "RecursiveLocalFunctionTests", + "", + "", + """ + static float Twice(float value) => Twice(value) * 2; + + this.buffer[0] = Twice(2.0f); + """, + new[] { "Twice(value)" })] + [DataRow( + "MutuallyRecursiveLocalFunctionsTests", + "", + "", + """ + static float Even(float value) => Odd(value); + + static float Odd(float value) => Even(value); + + this.buffer[0] = Even(2.0f); + """, + new[] { "Even(value)", "Odd(value)" })] + [DataRow( + "RecursionThroughALocalFunctionTests", + "", + """ + private static float Twice(float value) + { + static float Back(float inner) => Twice(inner); + + return Back(value) * 2; + } + """, + "this.buffer[0] = Twice(2.0f);", + new[] { "Back(value)", "Twice(inner)" })] + [DataRow( + "RecursiveLocalFunctionInAnImportTests", + """ + internal static class Helper + { + public static float Outer(float value) + { + static float Twice(float inner) => Twice(inner) * 2; + + return Twice(value); + } + } + """, + "", + "this.buffer[0] = Helper.Outer(2.0f);", + new[] { "Twice(inner)" })] + [DataRow( + "RecursiveMethodReachedFromAStaticFieldInitializerTests", + """ + internal static class Helper + { + public static float Twice(float value) => Twice(value) * 2; + } + """, + "private static readonly float Scale = Helper.Twice(2.0f);", + "this.buffer[0] = Scale;", + new[] { "Twice(value)" })] + [DataRow( + "RecursivePartialMethodOfTheShaderTests", + "", + """ + private static partial float Twice(float value); + + private static partial float Twice(float value) => Twice(value) * 2; + """, + "this.buffer[0] = Twice(2.0f);", + new[] { "Twice(value)" })] + [DataRow( + "UncalledRecursiveMethodOfTheShaderTests", + "", + "private static float Unused(float value) => Unused(value);", + "this.buffer[0] = 1.0f;", + new[] { "Unused(value)" })] + [DataRow( + "UncalledRecursiveLocalFunctionTests", + "", + "", + """ + static float Unused(float value) => Unused(value); + + this.buffer[0] = 1.0f; + """, + new[] { "Unused(value)" })] + public void ARecursiveCallIsDiagnosedAtEveryCallOnTheCycle(string assemblyName, string declarations, string members, string body, string[] expectedCalls) + { + AssertReportsOnlyAt(Shader(declarations, members, body), assemblyName, expectedCalls); + } + + /// + /// The message names what the author wrote, a local function being the case where the generated HLSL + /// holds a name the author never wrote. + /// + [TestMethod] + public void TheMessageNamesTheDeclarationTheAuthorWrote() + { + const string body = """ + static float Twice(float value) => Twice(value) * 2; + + this.buffer[0] = Twice(2.0f); + """; + + Diagnostic diagnostic = Run(Shader("", "", body), "RecursiveCallMessageTests").Diagnostics.Single(); + string message = diagnostic.GetMessage(); + + Assert.IsTrue(message.Contains("Twice(float)"), message); + Assert.IsFalse(message.Contains("__"), message); + } + + /// + /// Calls off any cycle, in the shapes the rows above close one through, so that the rows answer for the + /// cycle rather than for the shape. Each is required to produce source as well, an identifier being absent + /// from a run that generated nothing saying nothing, and the shader compiler is handed the result. + /// + [TestMethod] + [DataRow( + "ChainOfMethodsOfTheShaderTests", + "", + """ + private static float First(float value) => Second(value); + + private static float Second(float value) => Third(value); + + private static float Third(float value) => value * 2; + """, + "this.buffer[0] = First(2.0f);")] + [DataRow( + "MethodReachedTwiceTests", + """ + internal static class Helper + { + public static float Twice(float value) => value * 2; + + public static float Left(float value) => Twice(value); + + public static float Right(float value) => Twice(value); + } + """, + "private static float Both(float value) => Helper.Left(value) + Helper.Right(value);", + "this.buffer[0] = Both(Helper.Twice(2.0f));")] + [DataRow( + "OverloadsCallingEachOtherTests", + "", + """ + private static float Twice(float value) => Twice((int)value); + + private static float Twice(int value) => value * 2; + """, + "this.buffer[0] = Twice(2.0f);")] + [DataRow( + "ConstructorCalledFromAMethodTests", + """ + internal struct Helper + { + public float Amount; + + public Helper(float amount) + { + Amount = amount; + } + + public static float Make(float amount) => new Helper(amount).Amount; + } + """, + "", + "this.buffer[0] = Helper.Make(2.0f);")] + [DataRow( + "LocalFunctionsCallingOneAnotherTests", + "", + "", + """ + static float Twice(float value) => value * 2; + + static float Four(float value) => Twice(Twice(value)); + + this.buffer[0] = Four(2.0f) + Twice(2.0f); + """)] + [DataRow( + "PartialMethodOfTheShaderTests", + """ + internal static class Helper + { + public static float Twice(float value) => value * 2; + } + """, + """ + private static partial float Twice(float value); + + private static partial float Twice(float value) => Helper.Twice(value); + """, + "this.buffer[0] = Twice(2.0f);")] + public void ACallOffACycleIsNotDiagnosed(string assemblyName, string declarations, string members, string body) + { + GeneratorRunResult result = Run(Shader(declarations, members, body), assemblyName); + + Assert.IsTrue(result.Diagnostics.IsEmpty, string.Join(", ", result.Diagnostics.Select(static diagnostic => diagnostic.Id))); + Assert.AreNotEqual(0, GeneratorHelper.GetGeneratedSource(result.GeneratedSources, "Shaders.Shader").Length); + } + + private static string Shader(string declarations, string members, string body) + { + return $$""" + using ComputeWeave; + + namespace Shaders; + + {{declarations}} + + [ThreadGroupSize(DefaultThreadGroupSizes.X)] + [GeneratedComputeShaderDescriptor] + internal readonly partial struct Shader : IComputeShader + { + {{members}} + + private readonly ReadWriteBuffer buffer; + + public void Execute() + { + {{body}} + } + } + """; + } + + private static void AssertReportsOnlyAt(string source, string assemblyName, string[] expectedCalls) + { + ImmutableArray diagnostics = Run(source, assemblyName).Diagnostics; + + string[] actualIds = [.. diagnostics.Select(static diagnostic => diagnostic.Id).Distinct()]; + + Assert.IsTrue(actualIds.SequenceEqual(["CMPW0129"]), $"CMPW0129 is not the only report: {string.Join(", ", actualIds)}"); + + // The location is read back through the tree, so a report bound to none fails here rather than reading as a match + string[] actualCalls = [.. diagnostics.Select(static diagnostic => diagnostic.Location.SourceTree!.GetText().ToString(diagnostic.Location.SourceSpan)).Order()]; + + Assert.IsTrue(actualCalls.SequenceEqual(expectedCalls.Order()), $"The reports are not at the expected calls: {string.Join(", ", actualCalls)}"); + } + + private static GeneratorRunResult Run(string source, string assemblyName) + { + CSharpCompilation compilation = CompilationHelper.CreateCompilation( + [source], + assemblyName, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); + GeneratorDriver driver = GeneratorHelper.CreateDriver(new ComputeShaderDescriptorGenerator()); + GeneratorRunResult result = driver.RunGenerators(compilation).GetRunResult().Results[0]; + + // A fault discards the output for the whole compilation unit, so it is read ahead of the reports + Assert.IsNull(result.Exception, result.Exception?.ToString()); + + return result; + } +} From e3f2dcfef7d6ba64cf395e48becffbe9f739bf04 Mon Sep 17 00:00:00 2001 From: routersys Date: Tue, 15 Sep 2026 18:03:11 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=E4=B9=96=E9=9B=A2=E5=8F=B0=E5=B8=B3?= =?UTF-8?q?=E3=81=B8=E5=86=8D=E5=B8=B0=E3=81=99=E3=82=8B=E5=91=BC=E3=81=B3?= =?UTF-8?q?=E5=87=BA=E3=81=97=E3=81=AE=E8=A1=8C=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3e36be4e..f2bdea2d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -756,6 +756,7 @@ The Direct2D authoring projects were removed before the first release and restor | Generated HLSL, custom type declarations | Orders the custom type definitions by their fields alone, forward declares every custom type ahead of them on the compute path, and forward declares none on the Direct2D path. DXC accepts the forward declarations, so the shipped path is unaffected, but FXC has no type forward declaration and refuses each one with `X3000`, so a caller who takes the generated HLSL of a shader using a custom type and builds it for Direct3D 11 has to delete those lines by hand. The order by fields alone leaves a member method prototype free to name a type defined after it, which the forward declarations mask on the compute path, while on the Direct2D path such a prototype reaches FXC naming an undeclared type, with nothing that names the cause at the declaration. The order is found by dequeuing a type until none of its fields waits on another, which never comes for a type in a struct layout cycle, so on source C# has already refused with `CS0523` the generator never returns, and no cancellation reaches the loop | Orders the definitions by the fields and the member method prototypes each of them holds, so a prototype naming another type finds it declared, and forward declares a type only where the prototypes name each other in a cycle that no order resolves. Such a cycle cannot run through fields, which are refused first: a type reached again through its own fields is reported as an invalid discovered type, with every other type on that cycle, because the shader compiler runs out of stack on a use of such a type rather than reporting it, and the refusal is what keeps its bytecode from being compiled. The type written first is then the first in line whose fields are declared, and the types its prototypes still name are the ones forward declared. The Direct2D generator, whose compiler has no such declaration, reports `CMPWD2D0100` at each prototype naming such a type instead of writing HLSL that cannot build, that prototype being the place to change. Of the 22 generated compute shaders in this repository that carried forward declarations, 21 lose them all and compile to byte identical DXIL under `cs_6_0`, and they build under FXC at `cs_5_0` as a result; the one whose two custom types name each other keeps the single forward declaration it needs, which DXC accepts and FXC cannot, that shape having no spelling FXC takes | `e00057fb`, `6ac88e40` | | Constant buffer layout, a captured struct in a layout cycle | Walks the captured fields into every nested unmanaged struct to lay the constant buffer out, with nothing that notices a type it is already inside. A struct whose fields lead back to itself, which C# refuses with `CS0523` but which the generator and the dispatch data size analyzers run over all the same while the author is in the middle of writing it, is followed around the cycle until the stack runs out, and the process hosting the generator ends with it | Skips a field whose type is already being walked, in the walk that lays the fields out and in the one that only sizes them alike, so the walk ends. The type is then refused by the discovered type check as one HLSL cannot lay out, which reports `CMPW0050` or `CMPWD2D0041` and keeps the shader compiler from being run over it; the C# compiler already names the field, so nothing further is reported here | `245fe8ab` | | Custom type discovery, a field of a type with no name | Casts the type of every field of a discovered custom type to a named type while exploring the nested types. A pointer field, or a fixed size buffer whose field type is a pointer as well, is valid C# in an unsafe struct and ends the generator with `InvalidCastException`, which Roslyn reports as `CS8785` while discarding the descriptors of every shader in the compilation unit | Refuses a custom type holding such a field as an invalid discovered type, reporting `CMPW0050` or `CMPWD2D0041` for the type the way a `bool` field is reported, and stops exploring it. HLSL has no pointer, and while a struct in HLSL does hold a fixed size array, the generator writes no mapping for a fixed size buffer, neither the field itself nor the constant buffer layout an array in one would need, so there is nothing to write for either as things stand; a shader whose own field is one of these was already refused by the field check, and stays so | `c8cc970f` | +| Generated HLSL, a call leading back to the declaration it is written in | Writes every call out as it stands and imports a declaration once, claiming its entry before the rewriting so that a method reaching itself ends the import rather than the stack, and nothing reads what that claim just found. HLSL has no recursion, so the shader compiler refuses the function, and the failure lands on the shader type as the forwarded compiler error, naming a function of the generated HLSL, which for a local function or a method of a custom type is a name the generator gave it. Six shapes were measured on the compute path and two on the Direct2D path, each accepted by the generator and refused by the shader compiler | Records every call the generated HLSL holds as it is rewritten, from the declaration being rewritten to the one the call resolves to, into one collection every rewriter for a shader shares the way the requirements are shared, and reads it once every declaration is rewritten for the calls whose target reaches back to the declaration holding them. Each is reported as `CMPW0129`, or `CMPWD2D0101` on the Direct2D path, at the call as the author wrote it, naming the callee and the declaration in C# terms. The read comes after the rewriting rather than during it because no rewriting passes through every edge of a cycle: a method of the shader is written out by the generator's own path rather than imported, a local function is lifted where it is declared, and an imported declaration is rewritten once. Every call on a cycle is reported rather than the one a traversal happens to close it on, so removing any of them resolves the report and the set does not depend on the order the declarations were reached in. A partial declaration is recorded under its defining part, which is what a call to it resolves to; a call to a declaration with no source is left to the report it already had; a static field initializer is not a function and is not a node, while the declarations it imports are recorded by the rewriter importing them | `68ebbaed` | The ledger is kept current by derivation, not by memory: `build/audit-upstream-divergence.ps1` lists every commit after the marker below that modifies an inherited `.cs` file under `src/` and is not cited by a row. That is the whole of what it reads. Inherited project files, documents and test sources are outside it, so a divergence in one of those reaches the ledger only because someone wrote the row by hand. Run it before a release. When the queue is not empty, either add a row or decide that the commit is not a divergence, and then move the marker. The ledger is audited through commit `c8cc970f`. From 98a18b44158c9f777cc0ade834d84db854d570d5 Mon Sep 17 00:00:00 2001 From: routersys Date: Tue, 15 Sep 2026 20:40:46 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=E5=85=A5=E5=8F=A3=E3=81=8C=E8=87=AA?= =?UTF-8?q?=E5=88=86=E3=82=92=E5=91=BC=E3=81=B6=E5=BD=A2=E3=82=92=E5=86=8D?= =?UTF-8?q?=E5=B8=B0=E3=81=AE=E8=A9=A6=E9=A8=93=E3=81=AE=E8=A1=8C=E3=81=B8?= =?UTF-8?q?=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Shaders/RecursiveCallTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RecursiveCallTests.cs b/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RecursiveCallTests.cs index 854ac190d..6eecceb0a 100644 --- a/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RecursiveCallTests.cs +++ b/tests/ComputeWeave.Tests.SourceGenerators/Shaders/RecursiveCallTests.cs @@ -37,6 +37,16 @@ public class RecursiveCallTests "private float Twice(float value) => Twice(value) * 2;", "this.buffer[0] = Twice(2.0f);", new[] { "Twice(value)" })] + [DataRow( + "RecursiveEntryPointTests", + "", + "", + """ + Execute(); + + this.buffer[0] = 1.0f; + """, + new[] { "Execute()" })] [DataRow( "MutuallyRecursiveMethodsOfTheShaderTests", "",