From 565e1a3442fde63c0815bc14fd1a5edcf7b00173 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 8 Oct 2026 17:52:13 +0200 Subject: [PATCH] Handle legacy VB delegate wrappers VB can lower event-handler closures through generated wrapper methods, closure copy constructors, and immediately invoked VB anonymous delegate helpers. Recognizing and unwrapping those shapes leaves the expected lambda form and lets captured locals be scalar-replaced instead of exposing generated closure artifacts. Assisted-by: OpenCode:openai/gpt-5.5:OpenCode --- .../TestCases/VBPretty/Issue2199.cs | 35 ++++++++ .../TestCases/VBPretty/Issue2199.vb | 17 ++++ .../VBPrettyTestRunner.cs | 8 +- .../CSharp/CSharpDecompiler.cs | 2 + .../IL/Transforms/DelegateConstruction.cs | 80 +++++++++++++++++++ .../Transforms/TransformDisplayClassUsage.cs | 77 ++++++++++++++---- 6 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/VBPretty/Issue2199.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/VBPretty/Issue2199.vb diff --git a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/Issue2199.cs b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/Issue2199.cs new file mode 100644 index 000000000..8a5e19425 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/Issue2199.cs @@ -0,0 +1,35 @@ +using System; +using System.Runtime.CompilerServices; + +public class Issue2199 +{ + private class Item + { + public string Text; + } + + public event EventHandler Click; + + public event EventHandler DoubleClick; + + public void Register(string value) + { + Item item = new Item(); + item.Text = value.Trim(); +#if EXPECTED_OUTPUT + Click += [SpecialName] () => { + Console.WriteLine(item.Text); + }; + DoubleClick += [SpecialName] () => { + Console.WriteLine(item.Text); + }; +#else + Click += delegate { + Console.WriteLine(item.Text); + }; + DoubleClick += delegate { + Console.WriteLine(item.Text); + }; +#endif + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/Issue2199.vb b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/Issue2199.vb new file mode 100644 index 000000000..d57fc34fe --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/VBPretty/Issue2199.vb @@ -0,0 +1,17 @@ +Imports System + +Public Class Issue2199 + Public Event Click As EventHandler + Public Event DoubleClick As EventHandler + + Private Class Item + Public Text As String + End Class + + Public Sub Register(value As String) + Dim item As New Item() + item.Text = value.Trim() + AddHandler Click, Sub() Console.WriteLine(item.Text) + AddHandler DoubleClick, Sub() Console.WriteLine(item.Text) + End Sub +End Class diff --git a/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs b/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs index 3b3bcd78b..23f00f8aa 100644 --- a/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/VBPrettyTestRunner.cs @@ -151,6 +151,12 @@ namespace ICSharpCode.Decompiler.Tests await Run(options: options | CompilerOptions.Library); } + [Test] + public async Task Issue2199([ValueSource(nameof(defaultOptions))] CompilerOptions options) + { + await Run(options: options | CompilerOptions.Library); + } + [Test] public async Task VBOnError([ValueSource(nameof(defaultOptions))] CompilerOptions options) { @@ -216,7 +222,7 @@ namespace ICSharpCode.Decompiler.Tests settings ??= new DecompilerSettings { FileScopedNamespaces = false, SortCustomAttributes = true }; var decompiled = await Tester.DecompileCSharp(executable.PathToAssembly, settings).ConfigureAwait(false); - CodeAssert.FilesAreEqual(csFile, decompiled, Tester.GetPreprocessorSymbols(options).ToArray()); + CodeAssert.FilesAreEqual(csFile, decompiled, Tester.GetPreprocessorSymbols(options).Append("EXPECTED_OUTPUT").ToArray()); Tester.RepeatOnIOError(() => File.Delete(decompiled)); } } diff --git a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs index 3c5b14d09..7525e4f8c 100644 --- a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs @@ -478,6 +478,8 @@ namespace ICSharpCode.Decompiler.CSharp return true; if (settings.AnonymousTypes && type.IsAnonymousType(metadata)) return true; + if (settings.AnonymousMethods && type.IsDelegate(metadata) && name.StartsWith("VB$AnonymousDelegate_", StringComparison.Ordinal)) + return true; if (settings.Dynamic && type.IsDelegate(metadata) && (name.StartsWith("<>A", StringComparison.Ordinal) || name.StartsWith("<>F", StringComparison.Ordinal))) return true; } diff --git a/ICSharpCode.Decompiler/IL/Transforms/DelegateConstruction.cs b/ICSharpCode.Decompiler/IL/Transforms/DelegateConstruction.cs index 7971c68e6..d449e5417 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/DelegateConstruction.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/DelegateConstruction.cs @@ -16,6 +16,7 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System; using System.Collections.Generic; using System.Linq; using System.Reflection.Metadata; @@ -195,6 +196,18 @@ namespace ICSharpCode.Decompiler.IL.Transforms var ilReader = context.CreateILReader(); var body = context.PEFile.GetMethodBody(methodDefinition.RelativeVirtualAddress); var function = ilReader.ReadIL((MethodDefinitionHandle)targetMethod.MetadataToken, body, genericContext.Value, ILFunctionKind.Delegate, context.CancellationToken); + if (TryUnwrapVisualBasicDelegateWrapper(function, targetMethod, out var unwrappedMethod)) + { + targetMethod = unwrappedMethod; + methodDefinition = context.PEFile.Metadata.GetMethodDefinition((MethodDefinitionHandle)targetMethod.MetadataToken); + if (!methodDefinition.HasBody()) + return null; + genericContext = GenericContextFromTypeArguments(targetMethod.Substitution); + if (genericContext == null) + return null; + body = context.PEFile.GetMethodBody(methodDefinition.RelativeVirtualAddress); + function = ilReader.ReadIL((MethodDefinitionHandle)targetMethod.MetadataToken, body, genericContext.Value, ILFunctionKind.Delegate, context.CancellationToken); + } function.DelegateType = delegateType; // Embed the lambda into the parent function's ILAst, so that "Show steps" can show // how the lambda body is being transformed. @@ -215,6 +228,11 @@ namespace ICSharpCode.Decompiler.IL.Transforms nestedContext.StepStartGroup("DelegateConstruction (nested lambdas)", function); ((IILTransform)this).Run(function, nestedContext); nestedContext.StepEndGroup(); + if (TryUnwrapTransformedVisualBasicDelegateWrapper(function, delegateType, out var unwrappedFunction)) + { + function.ReplaceWith(unwrappedFunction); + function = unwrappedFunction; + } function.AddILRange(target); function.AddILRange(value); if (value is Call call) @@ -222,6 +240,64 @@ namespace ICSharpCode.Decompiler.IL.Transforms return function; } + static bool TryUnwrapTransformedVisualBasicDelegateWrapper(ILFunction function, IType delegateType, out ILFunction unwrappedFunction) + { + unwrappedFunction = null; + if (function.Body is not BlockContainer container) + return false; + var block = container.EntryPoint; + if (block.Instructions.Count != 2 || !block.Instructions[1].MatchLeave(container, out _)) + return false; + if (block.Instructions[0] is not CallVirt callVirt) + return false; + if (callVirt.Method.Name != "Invoke" || callVirt.Method.DeclaringType.Kind != TypeKind.Delegate) + return false; + if (callVirt.Arguments.Count != 1 || callVirt.Arguments[0] is not ILFunction innerFunction) + return false; + if (innerFunction.DelegateType?.Name.StartsWith("VB$AnonymousDelegate_", StringComparison.Ordinal) != true) + return false; + innerFunction.DelegateType = delegateType; + unwrappedFunction = innerFunction; + return true; + } + + static bool TryUnwrapVisualBasicDelegateWrapper(ILFunction function, IMethod wrapperMethod, out IMethod unwrappedMethod) + { + unwrappedMethod = null; + if (!wrapperMethod.Name.Contains("$__R", StringComparison.Ordinal)) + return false; + var thisVariable = function.Variables.SingleOrDefault(VariableKindExtensions.IsThis); + if (thisVariable == null || function.Body is not BlockContainer container) + return false; + var block = container.EntryPoint; + if (block.Instructions.Count != 2 || !block.Instructions[1].MatchLeave(container, out _)) + return false; + if (block.Instructions[0] is Call call) + { + if (call.Arguments.Count != 1 || !call.Arguments[0].MatchLdLoc(thisVariable)) + return false; + unwrappedMethod = call.Method; + } + else if (block.Instructions[0] is CallVirt callVirt) + { + if (callVirt.Method.Name != "Invoke" || callVirt.Method.DeclaringType.Kind != TypeKind.Delegate) + return false; + if (callVirt.Arguments.Count != 1 || !MatchDelegateConstruction(callVirt.Arguments[0], out unwrappedMethod, out var target, out _)) + return false; + if (!target.MatchLdLoc(thisVariable)) + return false; + } + else + { + return false; + } + if (!object.Equals(unwrappedMethod.DeclaringTypeDefinition, wrapperMethod.DeclaringTypeDefinition)) + return false; + if (!IsAnonymousMethod(wrapperMethod.DeclaringTypeDefinition, unwrappedMethod)) + return false; + return true; + } + private static bool ValidateDelegateTarget(ILInstruction inst) { switch (inst) @@ -238,6 +314,8 @@ namespace ICSharpCode.Decompiler.IL.Transforms // TODO : ldfld chains must be validated more thoroughly, i.e., we should make sure // that the value of the field is never changed. ILInstruction target = ldobj; + // Match delegate targets stored in nested display classes, e.g. + // ldobj(ldflda field(ldobj(ldflda field(ldloc displayClass)))). while (target is LdObj || target is LdFlda) { if (target is LdObj o) @@ -297,6 +375,8 @@ namespace ICSharpCode.Decompiler.IL.Transforms break; case LdObj lo: ILInstruction inner = lo.Target; + // Match the same display-class field chain after ValidateDelegateTarget + // accepted it: ldobj(ldflda field(ldflda field(ldloc displayClass))). while (inner is LdFlda ldf) { inner = ldf.Target; diff --git a/ICSharpCode.Decompiler/IL/Transforms/TransformDisplayClassUsage.cs b/ICSharpCode.Decompiler/IL/Transforms/TransformDisplayClassUsage.cs index ff6bede9a..837349303 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/TransformDisplayClassUsage.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/TransformDisplayClassUsage.cs @@ -466,7 +466,7 @@ namespace ICSharpCode.Decompiler.IL.Transforms { try { - if (method.Parameters.Count != 0) + if (method.Parameters.Count > 1) return false; var handle = (MethodDefinitionHandle)method.MetadataToken; var module = (MetadataModule)method.ParentModule; @@ -489,20 +489,8 @@ namespace ICSharpCode.Decompiler.IL.Transforms // IL_0000: ldarg.0 // IL_0001: call instance void [mscorlib]System.Object::.ctor() // IL_0006: ret - var opCode = DecodeOpCodeSkipNop(ref reader); - switch (opCode) - { - case ILOpCode.Ldarg: - case ILOpCode.Ldarg_s: - if (reader.DecodeIndex(opCode) != 0) - return false; - break; - case ILOpCode.Ldarg_0: - // OK - break; - default: - return false; - } + if (!DecodeLdArg(ref reader, 0)) + return false; if (DecodeOpCodeSkipNop(ref reader) != ILOpCode.Call) return false; var baseCtorHandle = MetadataTokenHelpers.EntityHandleOrNil(reader.ReadInt32()); @@ -513,7 +501,43 @@ namespace ICSharpCode.Decompiler.IL.Transforms return false; if (!objectCtor.IsConstructor || objectCtor.Parameters.Count != 0) return false; - return DecodeOpCodeSkipNop(ref reader) == ILOpCode.Ret; + if (method.Parameters.Count == 0) + return DecodeOpCodeSkipNop(ref reader) == ILOpCode.Ret; + if (!object.Equals(method.Parameters[0].Type.GetDefinition(), method.DeclaringTypeDefinition)) + return false; + if (!DecodeLdArg(ref reader, 1)) + return false; + switch (DecodeOpCodeSkipNop(ref reader)) + { + case ILOpCode.Brfalse: + reader.ReadInt32(); + break; + case ILOpCode.Brfalse_s: + reader.ReadSByte(); + break; + default: + return false; + } + bool copiedField = false; + while (true) + { + var opCode = DecodeOpCodeSkipNop(ref reader); + if (opCode == ILOpCode.Ret) + return copiedField; + if (!DecodeLdArg(opCode, ref reader, 0)) + return false; + if (!DecodeLdArg(ref reader, 1)) + return false; + if (DecodeOpCodeSkipNop(ref reader) != ILOpCode.Ldfld) + return false; + var sourceField = MetadataTokenHelpers.EntityHandleOrNil(reader.ReadInt32()); + if (DecodeOpCodeSkipNop(ref reader) != ILOpCode.Stfld) + return false; + var targetField = MetadataTokenHelpers.EntityHandleOrNil(reader.ReadInt32()); + if (sourceField.IsNil || sourceField != targetField) + return false; + copiedField = true; + } } catch (BadImageFormatException) { @@ -521,6 +545,27 @@ namespace ICSharpCode.Decompiler.IL.Transforms } } + static bool DecodeLdArg(ref BlobReader reader, int expectedIndex) + { + return DecodeLdArg(DecodeOpCodeSkipNop(ref reader), ref reader, expectedIndex); + } + + static bool DecodeLdArg(ILOpCode opCode, ref BlobReader reader, int expectedIndex) + { + switch (opCode) + { + case ILOpCode.Ldarg: + case ILOpCode.Ldarg_s: + return reader.DecodeIndex(opCode) == expectedIndex; + case ILOpCode.Ldarg_0: + return expectedIndex == 0; + case ILOpCode.Ldarg_1: + return expectedIndex == 1; + default: + return false; + } + } + static ILOpCode DecodeOpCodeSkipNop(ref BlobReader reader) { ILOpCode code;