From e28952b36006087171ce5ebc8508eb96ae3d9e1e Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Thu, 18 Jun 2026 06:23:10 -0700 Subject: [PATCH 1/2] Add IlProjection: new-pipeline IL views with resolved operands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the ground-truth IL projections (Raw, Structured) from the replacement pipeline's materialized method data off a MetadataSource — the new-pipeline backing for the annotated-IL view, replacing the old MethodBodyContext -> MethodAnalysis -> AnnotatedILEmitter contract. Operands resolve through the importer's own token resolvers (ResolveMethod / ResolveField / ResolveTypeToken) so callvirt/ldfld/ldstr render as names, not raw tokens; block structure reuses the importer's FindLeaders, so the views share one analysis with the IR import rather than running a parallel CFG/stack pass. Exception-safe by construction (DecompilerResult.Run): a malformed read or a resolver failure surfaces as a diagnostic, never a throw; operand resolution falls back to raw token hex per-instruction. Exposes three IrImporter helpers as internal (FindLeaders, CallerScope, ResolveTypeToken). Non-disruptive: no consumers migrated yet, so it dual-runs beside the old emitter. The per-instruction stack-typed depth and the consumer migration (MemberCodeProvider, harness DumpStages) + deletion of the old infra are follow-ups. Tests: 4 new (offsets/opcodes, resolved call operand, block labels, .try/catch markers); ILInspector.Decompiler.Tests 430 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../IlProjectionTests.cs | 52 ++++ .../Pipeline/Ir/IlProjection.cs | 254 ++++++++++++++++++ .../Pipeline/Ir/IrImporter.cs | 6 +- 3 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 src/ILInspector.Decompiler.Tests/IlProjectionTests.cs create mode 100644 src/ILInspector.Decompiler/Pipeline/Ir/IlProjection.cs diff --git a/src/ILInspector.Decompiler.Tests/IlProjectionTests.cs b/src/ILInspector.Decompiler.Tests/IlProjectionTests.cs new file mode 100644 index 00000000..b9340098 --- /dev/null +++ b/src/ILInspector.Decompiler.Tests/IlProjectionTests.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; + +using ILInspector.Decompiler.Pipeline; + +namespace ILInspector.Decompiler.Tests; + +public class IlProjectionTests +{ + static string Project(string method, IlProjectionDepth depth) + { + using var source = MetadataSource.Open(typeof(CfgSampleClass).Assembly.Location); + var result = IlProjection.Project(source, typeof(CfgSampleClass).FullName!, method, depth); + Assert.NotNull(result.Output); + return result.Output!; + } + + [Fact] + public void Raw_RendersOffsetsAndOpcodes() + { + var output = Project(nameof(CfgSampleClass.Add), IlProjectionDepth.Raw); + Assert.Contains("IL_0000:", output); + Assert.Contains("add", output); + Assert.Contains("ret", output); + } + + [Fact] + public void Raw_ResolvesCallOperandToName() + { + // LengthOf is `s.Length` — the method token must render resolved (the + // importer's ResolveMethod), not as a raw `0x06......` token. + var output = Project(nameof(CfgSampleClass.LengthOf), IlProjectionDepth.Raw); + Assert.Contains("get_Length", output); + Assert.DoesNotContain("0x", output); + } + + [Fact] + public void Structured_LabelsBranchBlocks() + { + // A conditional splits into multiple basic blocks, each leader labeled. + var output = Project(nameof(CfgSampleClass.Pick), IlProjectionDepth.Structured); + int labels = Regex.Matches(output, @"^IL_[0-9A-F]{4}:$", RegexOptions.Multiline).Count; + Assert.True(labels >= 2, $"expected >= 2 block labels, got {labels}"); + } + + [Fact] + public void Structured_MarksExceptionRegions() + { + var output = Project(nameof(CfgSampleClass.CatchLogs), IlProjectionDepth.Structured); + Assert.Contains("// .try", output); + Assert.Contains("// catch", output); + } +} diff --git a/src/ILInspector.Decompiler/Pipeline/Ir/IlProjection.cs b/src/ILInspector.Decompiler/Pipeline/Ir/IlProjection.cs new file mode 100644 index 00000000..b5d2f675 --- /dev/null +++ b/src/ILInspector.Decompiler/Pipeline/Ir/IlProjection.cs @@ -0,0 +1,254 @@ +using System.Buffers.Binary; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Text; + +namespace ILInspector.Decompiler.Pipeline; + +/// Depth of an rendering. +public enum IlProjectionDepth +{ + /// Flat instruction list with resolved operands. + Raw, + + /// Block-structured output with labels, indentation, and exception-region markers. + Structured, +} + +/// +/// Renders ground-truth IL views from the replacement pipeline's materialized +/// method data (off a ) — the new-pipeline backing +/// for the annotated-IL view, replacing the old +/// MethodBodyContextMethodAnalysisAnnotatedILEmitter +/// contract. Operands are resolved through the importer's own token resolvers, +/// and block structure reuses the importer's FindLeaders, so the views +/// share one analysis with the IR import rather than a parallel one. +/// +/// The per-instruction stack-typed depth (the old Typed view) is added +/// separately: it sources stack types from the importer's typed operand stack +/// and so rides on a per-instruction trace, not on this token-and-structure +/// projection. +/// +/// Exception-safe by construction: any malformed-metadata read or resolver +/// failure surfaces as a diagnosed , never a throw. +/// +public static class IlProjection +{ + public static DecompilerResult Project( + MetadataSource source, string typeFullName, string methodName, + IlProjectionDepth depth, int overloadIndex = 0, bool publicOnly = false) + => DecompilerResult.Run( + () => Render(source, typeFullName, methodName, depth, overloadIndex, publicOnly), + emptyOutputIsFailure: true); + + static string Render(MetadataSource source, string type, string method, IlProjectionDepth depth, int overloadIndex, bool publicOnly) + { + var (typeDef, methodDef, methodHandle) = Locate(source.Reader, type, method, overloadIndex, publicOnly); + var imported = MethodImporter.Import(source, (TypeDefinitionHandle)methodDef.GetDeclaringType(), methodHandle); + var scope = IrImporter.CallerScope(source.Reader, typeDef, methodDef); + var instructions = Decode(source.Reader, scope, imported.Body.IL.AsSpan()); + return depth == IlProjectionDepth.Structured + ? RenderStructured(instructions, imported.Body) + : RenderRaw(instructions); + } + + static (TypeDefinition Type, MethodDefinition Method, MethodDefinitionHandle Handle) Locate( + MetadataReader reader, string typeFullName, string methodName, int overloadIndex, bool publicOnly) + { + foreach (var typeHandle in reader.TypeDefinitions) + { + var typeDef = reader.GetTypeDefinition(typeHandle); + string ns = reader.GetString(typeDef.Namespace); + string name = reader.GetString(typeDef.Name); + if ((ns.Length == 0 ? name : $"{ns}.{name}") != typeFullName) + continue; + int seen = 0; + foreach (var methodHandle in typeDef.GetMethods()) + { + var method = reader.GetMethodDefinition(methodHandle); + if (reader.GetString(method.Name) != methodName) + continue; + if (publicOnly && (method.Attributes & System.Reflection.MethodAttributes.Public) == 0) + continue; + if (seen++ != overloadIndex) + continue; + if (method.RelativeVirtualAddress == 0) + throw new InvalidOperationException($"{typeFullName}::{methodName} has no IL body"); + return (typeDef, method, methodHandle); + } + } + throw new InvalidOperationException($"{typeFullName}::{methodName} not found"); + } + + readonly record struct Instr(int Offset, string Name, string Operand); + + static List Decode(MetadataReader reader, GenericScope scope, ReadOnlySpan il) + { + var result = new List(); + int pos = 0; + while (pos < il.Length) + { + int offset = pos; + int b = il[pos++]; + var op = b == 0xFE ? (ILOpCode)(0xFE00 | il[pos++]) : (ILOpCode)b; + string name = op.ToString().ToLowerInvariant().Replace('_', '.'); + result.Add(new Instr(offset, name, ReadOperand(reader, scope, il, op, ref pos))); + } + return result; + } + + static string ReadOperand(MetadataReader reader, GenericScope scope, ReadOnlySpan il, ILOpCode op, ref int pos) + { + if (op == ILOpCode.Switch) + { + int count = BinaryPrimitives.ReadInt32LittleEndian(il[pos..]); pos += 4; + int origin = pos + count * 4; + var targets = new string[count]; + for (int i = 0; i < count; i++) + { + targets[i] = $"IL_{origin + BinaryPrimitives.ReadInt32LittleEndian(il[pos..]):X4}"; pos += 4; + } + return $"({string.Join(", ", targets)})"; + } + + int length = OperandLength(op); + if (length == 0) + return ""; + var bytes = il.Slice(pos, length); + pos += length; + if (IsBranch(op)) + return $"IL_{pos + (length == 1 ? (sbyte)bytes[0] : BinaryPrimitives.ReadInt32LittleEndian(bytes)):X4}"; + return op switch + { + ILOpCode.Ldc_i4 => BinaryPrimitives.ReadInt32LittleEndian(bytes).ToString(), + ILOpCode.Ldc_i4_s => ((sbyte)bytes[0]).ToString(), + ILOpCode.Ldc_i8 => BinaryPrimitives.ReadInt64LittleEndian(bytes).ToString(), + ILOpCode.Ldc_r4 => BinaryPrimitives.ReadSingleLittleEndian(bytes).ToString(), + ILOpCode.Ldc_r8 => BinaryPrimitives.ReadDoubleLittleEndian(bytes).ToString(), + _ when IsMetadataToken(op) => ResolveToken(reader, scope, op, BinaryPrimitives.ReadInt32LittleEndian(bytes)), + _ when length == 1 => bytes[0].ToString(), // short var/arg index + _ when length == 2 => BinaryPrimitives.ReadUInt16LittleEndian(bytes).ToString(), // var/arg index + _ => $"0x{BinaryPrimitives.ReadUInt32LittleEndian(bytes):X8}", + }; + } + + /// Resolves a metadata-token operand to its display form, falling back to raw token hex if resolution fails. + static string ResolveToken(MetadataReader reader, GenericScope scope, ILOpCode op, int token) + { + try + { + if (op == ILOpCode.Ldstr) + return $"\"{reader.GetUserString(MetadataTokens.UserStringHandle(token))}\""; + var handle = MetadataTokens.EntityHandle(token); + return op switch + { + ILOpCode.Ldfld or ILOpCode.Ldflda or ILOpCode.Stfld + or ILOpCode.Ldsfld or ILOpCode.Ldsflda or ILOpCode.Stsfld => Field(reader, scope, handle), + ILOpCode.Call or ILOpCode.Callvirt or ILOpCode.Newobj + or ILOpCode.Ldftn or ILOpCode.Ldvirtftn or ILOpCode.Jmp => Method(reader, scope, handle), + ILOpCode.Castclass or ILOpCode.Isinst or ILOpCode.Box or ILOpCode.Unbox or ILOpCode.Unbox_any + or ILOpCode.Newarr or ILOpCode.Ldobj or ILOpCode.Stobj or ILOpCode.Cpobj or ILOpCode.Initobj + or ILOpCode.Constrained or ILOpCode.Sizeof or ILOpCode.Mkrefany or ILOpCode.Refanyval + or ILOpCode.Ldelem or ILOpCode.Ldelema or ILOpCode.Stelem + => IrImporter.ResolveTypeToken(reader, handle, scope).ToDisplayString(), + ILOpCode.Ldtoken => AnyToken(reader, scope, handle), + _ => $"0x{token:X8}", + }; + } + catch + { + return $"0x{token:X8}"; // resolution is best-effort; the view stays ground truth on the structure + } + } + + static string AnyToken(MetadataReader reader, GenericScope scope, EntityHandle handle) => handle.Kind switch + { + HandleKind.FieldDefinition => Field(reader, scope, handle), + HandleKind.MethodDefinition or HandleKind.MethodSpecification => Method(reader, scope, handle), + HandleKind.MemberReference when reader.GetMemberReference((MemberReferenceHandle)handle).GetKind() == MemberReferenceKind.Field + => Field(reader, scope, handle), + HandleKind.MemberReference => Method(reader, scope, handle), + _ => IrImporter.ResolveTypeToken(reader, handle, scope).ToDisplayString(), + }; + + static string Method(MetadataReader reader, GenericScope scope, EntityHandle handle) + { + var m = IrImporter.ResolveMethod(reader, handle, scope); + return $"{m.DeclaringType.ToDisplayString()}::{m.Name}({string.Join(", ", m.ParameterTypes.Select(p => p.ToDisplayString()))})"; + } + + static string Field(MetadataReader reader, GenericScope scope, EntityHandle handle) + { + var f = IrImporter.ResolveField(reader, handle, scope); + return $"{f.Type.ToDisplayString()} {f.DeclaringType.ToDisplayString()}::{f.Name}"; + } + + static bool IsMetadataToken(ILOpCode op) => op is + ILOpCode.Call or ILOpCode.Callvirt or ILOpCode.Newobj or ILOpCode.Ldftn or ILOpCode.Ldvirtftn or ILOpCode.Jmp + or ILOpCode.Ldfld or ILOpCode.Ldflda or ILOpCode.Stfld or ILOpCode.Ldsfld or ILOpCode.Ldsflda or ILOpCode.Stsfld + or ILOpCode.Castclass or ILOpCode.Isinst or ILOpCode.Box or ILOpCode.Unbox or ILOpCode.Unbox_any + or ILOpCode.Newarr or ILOpCode.Ldobj or ILOpCode.Stobj or ILOpCode.Cpobj or ILOpCode.Initobj + or ILOpCode.Constrained or ILOpCode.Sizeof or ILOpCode.Mkrefany or ILOpCode.Refanyval + or ILOpCode.Ldelem or ILOpCode.Ldelema or ILOpCode.Stelem or ILOpCode.Ldstr or ILOpCode.Ldtoken; + + static bool IsBranch(ILOpCode op) => op is + ILOpCode.Br or ILOpCode.Br_s or ILOpCode.Brfalse or ILOpCode.Brfalse_s or ILOpCode.Brtrue or ILOpCode.Brtrue_s + or ILOpCode.Beq or ILOpCode.Beq_s or ILOpCode.Bge or ILOpCode.Bge_s or ILOpCode.Bgt or ILOpCode.Bgt_s + or ILOpCode.Ble or ILOpCode.Ble_s or ILOpCode.Blt or ILOpCode.Blt_s or ILOpCode.Bne_un or ILOpCode.Bne_un_s + or ILOpCode.Bge_un or ILOpCode.Bge_un_s or ILOpCode.Bgt_un or ILOpCode.Bgt_un_s or ILOpCode.Ble_un or ILOpCode.Ble_un_s + or ILOpCode.Blt_un or ILOpCode.Blt_un_s or ILOpCode.Leave or ILOpCode.Leave_s; + + static int OperandLength(ILOpCode op) => op switch + { + ILOpCode.Ldc_i8 or ILOpCode.Ldc_r8 => 8, + ILOpCode.Ldarg or ILOpCode.Ldarga or ILOpCode.Starg or ILOpCode.Ldloc or ILOpCode.Ldloca or ILOpCode.Stloc => 2, + ILOpCode.Br_s or ILOpCode.Brfalse_s or ILOpCode.Brtrue_s or ILOpCode.Beq_s or ILOpCode.Bge_s or ILOpCode.Bgt_s + or ILOpCode.Ble_s or ILOpCode.Blt_s or ILOpCode.Bne_un_s or ILOpCode.Bge_un_s or ILOpCode.Bgt_un_s + or ILOpCode.Ble_un_s or ILOpCode.Blt_un_s or ILOpCode.Leave_s + or ILOpCode.Ldc_i4_s or ILOpCode.Ldarg_s or ILOpCode.Ldarga_s or ILOpCode.Starg_s or ILOpCode.Ldloc_s + or ILOpCode.Ldloca_s or ILOpCode.Stloc_s or ILOpCode.Unaligned => 1, + ILOpCode.Br or ILOpCode.Brfalse or ILOpCode.Brtrue or ILOpCode.Beq or ILOpCode.Bge or ILOpCode.Bgt + or ILOpCode.Ble or ILOpCode.Blt or ILOpCode.Bne_un or ILOpCode.Bge_un or ILOpCode.Bgt_un + or ILOpCode.Ble_un or ILOpCode.Blt_un or ILOpCode.Leave + or ILOpCode.Ldc_i4 or ILOpCode.Ldc_r4 or ILOpCode.Jmp + or ILOpCode.Call or ILOpCode.Calli or ILOpCode.Callvirt or ILOpCode.Newobj or ILOpCode.Ldftn + or ILOpCode.Ldvirtftn or ILOpCode.Ldfld or ILOpCode.Ldflda or ILOpCode.Stfld or ILOpCode.Ldsfld + or ILOpCode.Ldsflda or ILOpCode.Stsfld or ILOpCode.Castclass or ILOpCode.Isinst or ILOpCode.Box + or ILOpCode.Unbox or ILOpCode.Unbox_any or ILOpCode.Newarr or ILOpCode.Ldelem or ILOpCode.Ldelema + or ILOpCode.Stelem or ILOpCode.Ldobj or ILOpCode.Stobj or ILOpCode.Cpobj or ILOpCode.Initobj + or ILOpCode.Constrained or ILOpCode.Sizeof or ILOpCode.Ldtoken or ILOpCode.Ldstr + or ILOpCode.Mkrefany or ILOpCode.Refanyval => 4, + _ => 0, + }; + + static string RenderRaw(List instructions) + { + var sb = new StringBuilder(); + foreach (var i in instructions) + sb.AppendLine(Format(i)); + return sb.ToString(); + } + + static string RenderStructured(List instructions, MethodBody body) + { + var leaders = IrImporter.FindLeaders([.. body.IL], body.Handlers); + var tryStarts = body.Handlers.Select(h => h.TryOffset).ToHashSet(); + var handlerStarts = body.Handlers + .GroupBy(h => h.HandlerOffset) + .ToDictionary(g => g.Key, g => g.First().Kind); + var sb = new StringBuilder(); + foreach (var i in instructions) + { + if (tryStarts.Contains(i.Offset)) + sb.AppendLine(" // .try"); + if (handlerStarts.TryGetValue(i.Offset, out var kind)) + sb.AppendLine($" // {kind.ToString().ToLowerInvariant()}"); + if (leaders.Contains(i.Offset)) + sb.AppendLine($"IL_{i.Offset:X4}:"); + sb.AppendLine(" " + Format(i)); + } + return sb.ToString(); + } + + static string Format(Instr i) => $"IL_{i.Offset:X4}: {i.Name}{(i.Operand.Length > 0 ? " " + i.Operand : "")}"; +} diff --git a/src/ILInspector.Decompiler/Pipeline/Ir/IrImporter.cs b/src/ILInspector.Decompiler/Pipeline/Ir/IrImporter.cs index b0781ad7..c9dc5db6 100644 --- a/src/ILInspector.Decompiler/Pipeline/Ir/IrImporter.cs +++ b/src/ILInspector.Decompiler/Pipeline/Ir/IrImporter.cs @@ -123,7 +123,7 @@ static IrFunction CrashFunction(string methodName, string typeName, Exception ex return function; } - static GenericScope CallerScope(MetadataReader reader, TypeDefinition typeDef, MethodDefinition method) + internal static GenericScope CallerScope(MetadataReader reader, TypeDefinition typeDef, MethodDefinition method) => new(GenericParameterNames(reader, typeDef.GetGenericParameters()), GenericParameterNames(reader, method.GetGenericParameters())); @@ -256,7 +256,7 @@ void Consider(TypeRef? type) } /// Block leaders: entry, branch and leave targets, instructions following a terminator, and every exception-region boundary. - static SortedSet FindLeaders(ReadOnlySpan il, System.Collections.Immutable.ImmutableArray handlers) + internal static SortedSet FindLeaders(ReadOnlySpan il, System.Collections.Immutable.ImmutableArray handlers) { var leaders = new SortedSet { 0 }; foreach (var region in handlers) @@ -1359,7 +1359,7 @@ internal static FieldRef ResolveField(MetadataReader reader, EntityHandle handle } } - static TypeRef ResolveTypeToken(MetadataReader reader, EntityHandle handle, GenericScope callerScope) => handle.Kind switch + internal static TypeRef ResolveTypeToken(MetadataReader reader, EntityHandle handle, GenericScope callerScope) => handle.Kind switch { HandleKind.TypeDefinition => TypeRefDecoder.Instance.GetTypeFromDefinition(reader, (TypeDefinitionHandle)handle, 0), HandleKind.TypeReference => TypeRefDecoder.Instance.GetTypeFromReference(reader, (TypeReferenceHandle)handle, 0), From 83eaf8c31c4c4405015d7c14d999cccc3439751b Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Thu, 18 Jun 2026 06:34:46 -0700 Subject: [PATCH 2/2] Normalize CRLF in IlProjection tests (fix Windows CI) Structured_LabelsBranchBlocks anchored a regex with $ over output rendered with Environment.NewLine; on Windows the trailing \r before the line break made the $ anchor miss (0 matches). Normalize with ReplaceLineEndings("\n") in the test helper, the repo's convention for line-anchored assertions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ILInspector.Decompiler.Tests/IlProjectionTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ILInspector.Decompiler.Tests/IlProjectionTests.cs b/src/ILInspector.Decompiler.Tests/IlProjectionTests.cs index b9340098..61f00044 100644 --- a/src/ILInspector.Decompiler.Tests/IlProjectionTests.cs +++ b/src/ILInspector.Decompiler.Tests/IlProjectionTests.cs @@ -11,7 +11,9 @@ static string Project(string method, IlProjectionDepth depth) using var source = MetadataSource.Open(typeof(CfgSampleClass).Assembly.Location); var result = IlProjection.Project(source, typeof(CfgSampleClass).FullName!, method, depth); Assert.NotNull(result.Output); - return result.Output!; + // Normalize CRLF so line-anchored assertions are platform-agnostic (the + // projection renders with Environment.NewLine). + return result.Output!.ReplaceLineEndings("\n"); } [Fact]