// Copyright (c) 2026 Siegfried Pammer // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software // without restriction, including without limitation the rights to use, copy, modify, merge, // publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons // to whom the Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all copies or // substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR // PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE // FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; using System.Reflection.PortableExecutable; using System.Text; namespace ICSharpCode.Decompiler.Tests.Helpers { /// /// Reads the sequence points out of a portable PDB and compares two PDBs (typically the one /// the original C# compiler produced versus the one PortablePdbWriter reconstructs) /// at the granularity that actually matters to a debugging user: the breakpoint map. /// /// /// The decompiler reconstructs IL ranges, hidden sequence points and local scopes from the /// ILAst, so those never match the original compiler byte-for-byte. What it can and should /// get right is where the visible (non-hidden) breakpoints land in the source. /// The comparison therefore projects each PDB onto the ordered list of visible sequence-point /// source locations per method (keyed by method-definition row, which is shared between the /// PDB and the PE it describes) and reports the residual difference. /// static class PdbSequencePoints { internal readonly record struct RawSequencePoint(int Offset, bool IsHidden, int StartLine, int StartColumn, int EndLine, int EndColumn) { /// Visible source location, formatted with or without columns. public string Format(bool includeColumns) { return includeColumns ? $"({StartLine},{StartColumn})-({EndLine},{EndColumn})" : $"L{StartLine}-{EndLine}"; } } /// /// Reads every method's sequence points (hidden ones included, in document order) from a /// portable PDB, keyed by the row number of the owning method definition. /// public static Dictionary> Read(Stream portablePdb) { portablePdb.Position = 0; using var provider = MetadataReaderProvider.FromPortablePdbStream(portablePdb, MetadataStreamOptions.LeaveOpen); var reader = provider.GetMetadataReader(); var result = new Dictionary>(); foreach (var handle in reader.MethodDebugInformation) { var info = reader.GetMethodDebugInformation(handle); if (info.SequencePointsBlob.IsNil) continue; var list = new List(); foreach (var sp in info.GetSequencePoints()) { list.Add(new RawSequencePoint(sp.Offset, sp.IsHidden, sp.StartLine, sp.StartColumn, sp.EndLine, sp.EndColumn)); } if (list.Count > 0) { // A MethodDebugInformation row is parallel to its MethodDefinition row. result[MetadataTokens.GetRowNumber(handle)] = list; } } return result; } /// /// Resolves a readable "Namespace.Type.Method" label for each method-definition row, /// for use in comparison failure messages. /// public static Dictionary ReadMethodNames(string peFileName) { using var peStream = File.OpenRead(peFileName); using var peReader = new PEReader(peStream); var md = peReader.GetMetadataReader(); var names = new Dictionary(); foreach (var handle in md.MethodDefinitions) { var method = md.GetMethodDefinition(handle); var type = md.GetTypeDefinition(method.GetDeclaringType()); string typeName = md.GetString(type.Name); if (!type.Namespace.IsNil && md.GetString(type.Namespace) is { Length: > 0 } ns) typeName = ns + "." + typeName; names[MetadataTokens.GetRowNumber(handle)] = typeName + "." + md.GetString(method.Name); } return names; } /// /// Compares the visible breakpoint maps of two PDBs and returns the residual difference as /// a deterministic, human-readable report. An empty string means the maps are identical. /// /// PDB used as the oracle (the original compiler's PDB). /// PDB under test (the decompiler's reconstruction). /// Row-to-label map from . /// /// When true, breakpoints must match on line and column; when false, only the /// line span is compared (use for methods where the decompiler's column placement within a /// statement legitimately differs). /// public static string CompareBreakpointMaps( Dictionary> expected, Dictionary> actual, Dictionary methodNames, bool includeColumns) { var report = new StringBuilder(); foreach (int row in expected.Keys.Union(actual.Keys).OrderBy(r => r)) { var expectedPoints = Visible(expected, row, includeColumns); var actualPoints = Visible(actual, row, includeColumns); var expectedOnly = MultisetDifference(expectedPoints, actualPoints); var actualOnly = MultisetDifference(actualPoints, expectedPoints); if (expectedOnly.Count == 0 && actualOnly.Count == 0) continue; methodNames.TryGetValue(row, out var name); report.Append($"0x{0x06000000 | row:x8} {name}").Append('\n'); foreach (var p in expectedOnly.OrderBy(p => p)) report.Append(" - compiler only: ").Append(p).Append('\n'); foreach (var p in actualOnly.OrderBy(p => p)) report.Append(" + decompiler only: ").Append(p).Append('\n'); } return report.ToString(); } static List Visible(Dictionary> map, int row, bool includeColumns) { if (!map.TryGetValue(row, out var list)) return new List(); return list.Where(sp => !sp.IsHidden).Select(sp => sp.Format(includeColumns)).ToList(); } static List MultisetDifference(List a, List b) { var counts = new Dictionary(); foreach (var x in b) counts[x] = counts.GetValueOrDefault(x) + 1; var result = new List(); foreach (var x in a) { if (counts.TryGetValue(x, out int c) && c > 0) counts[x] = c - 1; else result.Add(x); } return result; } /// /// Checks that the decompiler's own PDB is structurally well-formed, independent of any /// oracle: within each method the sequence points' IL offsets must be strictly increasing, /// i.e. no duplicate or overlapping sequence points (a duplicate is exactly the failure /// that aborts PortablePdbWriter in debug builds). Returns the offending methods, or /// an empty string when well-formed. /// public static string CheckWellFormed( Dictionary> pdb, Dictionary methodNames) { var report = new StringBuilder(); foreach (int row in pdb.Keys.OrderBy(r => r)) { var list = pdb[row]; for (int i = 1; i < list.Count; i++) { if (list[i].Offset <= list[i - 1].Offset) { methodNames.TryGetValue(row, out var name); report.Append($"0x{0x06000000 | row:x8} {name}: non-increasing IL offset 0x{list[i].Offset:x} after 0x{list[i - 1].Offset:x}").Append('\n'); break; } } } return report.ToString(); } } }