// 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; 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). /// /// /// When true, hidden sequence points are compared too. A hidden point has no source /// location, so it is described by the visible point it follows ("hidden after <loc>", /// or "hidden at method start"). Anchoring to a source location keeps the comparison /// independent of the exact IL offsets, which the decompiler reconstructs differently. /// public static string CompareBreakpointMaps( Dictionary> expected, Dictionary> actual, Dictionary methodNames, bool includeColumns, bool includeHidden) { var report = new StringBuilder(); foreach (int row in expected.Keys.Union(actual.Keys).OrderBy(r => r)) { var expectedPoints = Tokens(expected, row, includeColumns, includeHidden); var actualPoints = Tokens(actual, row, includeColumns, includeHidden); if (expectedPoints.SequenceEqual(actualPoints)) continue; methodNames.TryGetValue(row, out var name); report.Append($"0x{0x06000000 | row:x8} {name}").Append('\n'); foreach (var (prefix, point) in OrderedDifference(expectedPoints, actualPoints)) report.Append(prefix).Append(point).Append('\n'); } return report.ToString(); } /// /// Projects a method's sequence points onto comparable descriptors: each visible point /// becomes its source location; each hidden point (when included) is anchored to the visible /// point it follows, so the descriptor does not depend on the reconstructed IL offset. /// static List Tokens(Dictionary> map, int row, bool includeColumns, bool includeHidden) { var result = new List(); if (!map.TryGetValue(row, out var list)) return result; string lastVisible = null; foreach (var sp in list) { if (sp.IsHidden) { if (includeHidden) result.Add(lastVisible == null ? "hidden at method start" : "hidden after " + lastVisible); } else { lastVisible = sp.Format(includeColumns); result.Add(lastVisible); } } return result; } static List<(string Prefix, string Point)> OrderedDifference(List expected, List actual) { int[,] lcs = new int[expected.Count + 1, actual.Count + 1]; for (int i = expected.Count - 1; i >= 0; i--) { for (int j = actual.Count - 1; j >= 0; j--) { lcs[i, j] = expected[i] == actual[j] ? lcs[i + 1, j + 1] + 1 : Math.Max(lcs[i + 1, j], lcs[i, j + 1]); } } var result = new List<(string Prefix, string Point)>(); for (int i = 0, j = 0; i < expected.Count || j < actual.Count;) { if (i < expected.Count && j < actual.Count && expected[i] == actual[j]) { i++; j++; } else if (j < actual.Count && (i == expected.Count || lcs[i, j + 1] >= lcs[i + 1, j])) { result.Add((" + decompiler only: ", actual[j])); j++; } else { result.Add((" - compiler only: ", expected[i])); i++; } } 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(); } } }