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); } }