From bf3beea96a67272ed329c938fda4706d8b055541 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 28 Jul 2026 18:14:55 +0200 Subject: [PATCH] Fix #2879: show stored member-list values for empty ranges The TypeDef table view derived FieldList/MethodList from the computed member ranges (TypeDefinition.GetFields/GetMethods), and the MethodDef view derived ParamList the same way; their FirstOrDefault is a nil handle for an empty range, so memberless types and parameterless methods displayed row 0. The stored column value is never 0: it is the running list position, i.e. the next row's first member or one past the member table's end. Read the raw columns instead, relative to the row end so the widths of the preceding string-heap, blob and coded-index columns need not be re-derived, and sized by the table the column actually indexes (the FieldPtr/MethodPtr/ParamPtr indirection when present). EventMap and PropertyMap already read their list columns from the raw rows. Tooltips explain the empty-list-start semantics; rows with members behave as before. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Metadata/TypeDefMemberListColumnTests.cs | 123 ++++++++++++++++++ .../Metadata/CorTables/MethodTableTreeNode.cs | 29 ++++- .../CorTables/TypeDefTableTreeNode.cs | 43 +++++- 3 files changed, 183 insertions(+), 12 deletions(-) create mode 100644 ILSpy.Tests/Metadata/TypeDefMemberListColumnTests.cs diff --git a/ILSpy.Tests/Metadata/TypeDefMemberListColumnTests.cs b/ILSpy.Tests/Metadata/TypeDefMemberListColumnTests.cs new file mode 100644 index 000000000..3cf2fe019 --- /dev/null +++ b/ILSpy.Tests/Metadata/TypeDefMemberListColumnTests.cs @@ -0,0 +1,123 @@ +// 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.Linq; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; + +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.ILSpy.Metadata; +using ICSharpCode.ILSpy.Metadata.CorTables; +using ICSharpCode.ILSpy.ViewModels; + +using Avalonia.Headless.NUnit; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Metadata; + +/// +/// A type that owns no rows in the Field or MethodDef tables: its stored FieldList and +/// MethodList column values are empty-list start positions, not references to own +/// members. An interface cannot acquire an implicit constructor, so memberlessness is +/// structurally guaranteed. +/// +public interface IMemberlessSampleType +{ +} + +[TestFixture] +public class TypeDefMemberListColumnTests +{ + static PEFile? testAssembly; + static List? entries; + + static PEFile LoadAssembly() + { + return testAssembly ??= new PEFile(typeof(TypeDefMemberListColumnTests).Assembly.Location); + } + + static List LoadEntries() + { + if (entries != null) + return entries; + var page = (MetadataTablePageModel)new TypeDefTableTreeNode(LoadAssembly()).CreateTab(); + return entries = page.Items.Cast().ToList(); + } + + [OneTimeTearDown] + public void Unload() + { + testAssembly?.Dispose(); + } + + static int Row(int token) => token & 0x00ffffff; + + [AvaloniaTest] + public void MemberlessTypeShowsStoredListValues() + { + var entries = LoadEntries(); + var entry = entries.Single(e => e.Name == nameof(IMemberlessSampleType)); + + // The stored FieldList/MethodList of a memberless type is the running list + // position (the next type's first member row, or one past the table end) - + // never row 0. + Assert.That(Row(entry.FieldList), Is.Not.Zero); + Assert.That(Row(entry.MethodList), Is.Not.Zero); + } + + [AvaloniaTest] + public void ParameterlessMethodShowsStoredParamListValue() + { + var page = (MetadataTablePageModel)new MethodTableTreeNode(LoadAssembly()).CreateTab(); + var methods = page.Items.Cast().ToList(); + int paramRows = testAssembly!.Metadata.GetTableRowCount(TableIndex.Param); + + var parameterless = methods.Single(m => m.Name == nameof(ParameterlessMethodShowsStoredParamListValue)); + Assert.That(Row(parameterless.ParamList), Is.Not.Zero); + + for (int i = 0; i < methods.Count; i++) + { + Assert.That(Row(methods[i].ParamList), Is.InRange(1, paramRows + 1), $"ParamList of row {i + 1}"); + if (i > 0) + { + Assert.That(Row(methods[i].ParamList), Is.GreaterThanOrEqualTo(Row(methods[i - 1].ParamList)), $"ParamList of row {i + 1}"); + } + } + } + + [AvaloniaTest] + public void ListColumnsAreMonotonicallyNonDecreasing() + { + var entries = LoadEntries(); + int fieldRows = testAssembly!.Metadata.GetTableRowCount(TableIndex.Field); + int methodRows = testAssembly.Metadata.GetTableRowCount(TableIndex.MethodDef); + + for (int i = 0; i < entries.Count; i++) + { + Assert.That(Row(entries[i].FieldList), Is.InRange(1, fieldRows + 1), $"FieldList of row {i + 1}"); + Assert.That(Row(entries[i].MethodList), Is.InRange(1, methodRows + 1), $"MethodList of row {i + 1}"); + if (i > 0) + { + Assert.That(Row(entries[i].FieldList), Is.GreaterThanOrEqualTo(Row(entries[i - 1].FieldList)), $"FieldList of row {i + 1}"); + Assert.That(Row(entries[i].MethodList), Is.GreaterThanOrEqualTo(Row(entries[i - 1].MethodList)), $"MethodList of row {i + 1}"); + } + } + } +} diff --git a/ILSpy/Metadata/CorTables/MethodTableTreeNode.cs b/ILSpy/Metadata/CorTables/MethodTableTreeNode.cs index 3ce4c9904..08e7cc035 100644 --- a/ILSpy/Metadata/CorTables/MethodTableTreeNode.cs +++ b/ILSpy/Metadata/CorTables/MethodTableTreeNode.cs @@ -41,8 +41,24 @@ namespace ICSharpCode.ILSpy.Metadata.CorTables protected override IReadOnlyList LoadTable() { var list = new List(); - foreach (var row in metadataFile.Metadata.MethodDefinitions) - list.Add(new MethodDefEntry(metadataFile, row)); + var metadata = metadataFile.Metadata; + // ParamList is read from the raw row: the computed range (GetParameters) is empty + // for a parameterless method, but the stored value is the running list position + // (the next method's first Param row, or one past the Param table's end), never 0. + // It is the last column, so it sits at the end of the row; with a ParamPtr + // indirection present, the column indexes the pointer table, whose row count also + // governs the column width. + var indexedTable = metadata.GetTableRowCount(TableIndex.ParamPtr) > 0 ? TableIndex.ParamPtr : TableIndex.Param; + int paramListWidth = metadata.GetTableRowCount(indexedTable) <= ushort.MaxValue ? 2 : 4; + int rowSize = metadata.GetTableRowSize(TableIndex.MethodDef); + int tableOffset = metadata.GetTableMetadataOffset(TableIndex.MethodDef); + var reader = metadata.AsBlobReader(); + foreach (var row in metadata.MethodDefinitions) + { + reader.Offset = tableOffset + rowSize * MetadataTokens.GetRowNumber(row) - paramListWidth; + int paramList = paramListWidth == 2 ? reader.ReadUInt16() : reader.ReadInt32(); + list.Add(new MethodDefEntry(metadataFile, row, paramList)); + } return list; } @@ -94,20 +110,23 @@ namespace ICSharpCode.ILSpy.Metadata.CorTables public string? SignatureTooltip => GenerateTooltip(ref signatureTooltip, metadataFile, handle); [ColumnInfo("X8", Kind = ColumnKind.Token)] - public int ParamList => MetadataTokens.GetToken(methodDef.GetParameters().FirstOrDefault()); + public int ParamList => 0x08000000 | paramList; string? paramListTooltip; public string? ParamListTooltip { get { var param = methodDef.GetParameters().FirstOrDefault(); if (param.IsNil) - return null; + return "(method has no Param rows; the stored value is the start of its empty parameter list: the next method's first Param row, or one past the end of the Param table)"; return GenerateTooltip(ref paramListTooltip, metadataFile, param); } } - public MethodDefEntry(MetadataFile metadataFile, MethodDefinitionHandle handle) + readonly int paramList; + + public MethodDefEntry(MetadataFile metadataFile, MethodDefinitionHandle handle, int paramList) { + this.paramList = paramList; this.metadataFile = metadataFile; this.handle = handle; methodDef = metadataFile.Metadata.GetMethodDefinition(handle); diff --git a/ILSpy/Metadata/CorTables/TypeDefTableTreeNode.cs b/ILSpy/Metadata/CorTables/TypeDefTableTreeNode.cs index 6695c6584..d58bcc0a8 100644 --- a/ILSpy/Metadata/CorTables/TypeDefTableTreeNode.cs +++ b/ILSpy/Metadata/CorTables/TypeDefTableTreeNode.cs @@ -44,9 +44,33 @@ namespace ICSharpCode.ILSpy.Metadata.CorTables protected override IReadOnlyList LoadTable() { var list = new List(); - foreach (var row in metadataFile.Metadata.TypeDefinitions) - list.Add(new TypeDefEntry(metadataFile, row)); + var metadata = metadataFile.Metadata; + // FieldList/MethodList are read from the raw rows: the computed member ranges + // (TypeDefinition.GetFields/GetMethods) are empty for a memberless type, but the + // stored column value is the running list position (the next type's first member + // row, or one past the member table's end), never 0. Reading relative to the row + // end avoids re-deriving the widths of the preceding string-heap and coded-index + // columns. With a FieldPtr/MethodPtr indirection present, the list columns index + // the pointer table, whose row count also governs the column width. + int fieldListWidth = ListColumnWidth(metadata, TableIndex.FieldPtr, TableIndex.Field); + int methodListWidth = ListColumnWidth(metadata, TableIndex.MethodPtr, TableIndex.MethodDef); + int rowSize = metadata.GetTableRowSize(TableIndex.TypeDef); + int tableOffset = metadata.GetTableMetadataOffset(TableIndex.TypeDef); + var reader = metadata.AsBlobReader(); + foreach (var row in metadata.TypeDefinitions) + { + reader.Offset = tableOffset + rowSize * MetadataTokens.GetRowNumber(row) - fieldListWidth - methodListWidth; + int fieldList = fieldListWidth == 2 ? reader.ReadUInt16() : reader.ReadInt32(); + int methodList = methodListWidth == 2 ? reader.ReadUInt16() : reader.ReadInt32(); + list.Add(new TypeDefEntry(metadataFile, row, fieldList, methodList)); + } return list; + + static int ListColumnWidth(MetadataReader metadata, TableIndex ptrTable, TableIndex memberTable) + { + var indexed = metadata.GetTableRowCount(ptrTable) > 0 ? ptrTable : memberTable; + return metadata.GetTableRowCount(indexed) <= ushort.MaxValue ? 2 : 4; + } } public sealed class TypeDefEntry @@ -103,35 +127,40 @@ namespace ICSharpCode.ILSpy.Metadata.CorTables } [ColumnInfo("X8", Kind = ColumnKind.Token)] - public int FieldList => MetadataTokens.GetToken(typeDef.GetFields().FirstOrDefault()); + public int FieldList => 0x04000000 | fieldList; string? fieldListTooltip; public string? FieldListTooltip { get { var @field = typeDef.GetFields().FirstOrDefault(); if (@field.IsNil) - return null; + return "(type has no fields; the stored value is the start of its empty field list: the next type's first field row, or one past the end of the Field table)"; return GenerateTooltip(ref fieldListTooltip, metadataFile, @field); } } [ColumnInfo("X8", Kind = ColumnKind.Token)] - public int MethodList => MetadataTokens.GetToken(typeDef.GetMethods().FirstOrDefault()); + public int MethodList => 0x06000000 | methodList; string? methodListTooltip; public string? MethodListTooltip { get { var method = typeDef.GetMethods().FirstOrDefault(); if (method.IsNil) - return null; + return "(type has no methods; the stored value is the start of its empty method list: the next type's first method row, or one past the end of the MethodDef table)"; return GenerateTooltip(ref methodListTooltip, metadataFile, method); } } - public TypeDefEntry(MetadataFile metadataFile, TypeDefinitionHandle handle) + readonly int fieldList; + readonly int methodList; + + public TypeDefEntry(MetadataFile metadataFile, TypeDefinitionHandle handle, int fieldList, int methodList) { this.metadataFile = metadataFile; this.handle = handle; + this.fieldList = fieldList; + this.methodList = methodList; typeDef = metadataFile.Metadata.GetTypeDefinition(handle); } }