diff --git a/ILSpy.Tests/Editor/XmlDocumentationTests.cs b/ILSpy.Tests/Editor/XmlDocumentationTests.cs
new file mode 100644
index 000000000..db1c83fb3
--- /dev/null
+++ b/ILSpy.Tests/Editor/XmlDocumentationTests.cs
@@ -0,0 +1,84 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// 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.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler.Documentation;
+using ICSharpCode.Decompiler.TypeSystem;
+
+using ILSpy.AppEnv;
+using ILSpy.TextView;
+using ILSpy.TreeNodes;
+using ILSpy.ViewModels;
+using ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.TextView;
+
+///
+/// Diagnoses whether the XML-documentation lookup that backs the decompiler-view hover
+/// tooltip actually surfaces non-empty docs for a well-documented system method. The
+/// renderer + wiring (DocumentationRenderer,
+/// DecompilerTextView.BuildHoverContent, AppendXmlDocumentation) are
+/// already in place; this test verifies the underlying
+///
+/// path produces a real doc string for at least one ubiquitous CoreLib method.
+///
+[TestFixture]
+public class XmlDocumentationTests
+{
+ [AvaloniaTest]
+ public async Task XmlDocLoader_Surfaces_Documentation_For_CoreLib_String_Concat()
+ {
+ var window = AppComposition.Current.GetExport();
+ window.Show();
+ var vm = (MainWindowViewModel)window.DataContext!;
+ await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
+
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String");
+ stringNode.IsExpanded = true;
+ var concatNode = stringNode.Children.OfType()
+ .First(m => m.MethodDefinition.Name == "Concat");
+
+ var concat = concatNode.MethodDefinition;
+ Assert.That(concat, Is.Not.Null);
+ Assert.That(concat!.ParentModule, Is.Not.Null);
+ Assert.That(concat.ParentModule!.MetadataFile, Is.Not.Null);
+
+ // Modern .NET runtime DLLs don't ship .xml beside them — XmlDocLoader can't find
+ // CoreLib's docs at runtime. The Avalonia ModernXmlDocLookup walks the parallel
+ // ref pack (/packs/Microsoft.NETCore.App.Ref//ref//*.xml)
+ // and aggregates every XML there into a single provider.
+ var provider = ModernXmlDocLookup.TryGetProvider(concat.ParentModule.MetadataFile!);
+ ((object?)provider).Should().NotBeNull(
+ "ModernXmlDocLookup must locate the ref-pack XML for the test-host runtime layout");
+
+ var documentation = provider!.GetDocumentation(concat.GetIdString());
+ documentation.Should().NotBeNullOrEmpty(
+ "System.String.Concat is one of the most-documented methods in CoreLib — the hover tooltip would be empty without this");
+ documentation.Should().Contain(" tag the renderer parses");
+ }
+}
diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs
index 4836543a9..940eb3f06 100644
--- a/ILSpy/TextView/DecompilerTextView.axaml.cs
+++ b/ILSpy/TextView/DecompilerTextView.axaml.cs
@@ -653,10 +653,13 @@ namespace ILSpy.TextView
{
if (entity.ParentModule?.MetadataFile is not { } metadata)
return;
- var docProvider = XmlDocLoader.LoadDocumentation(metadata);
- if (docProvider == null)
- return;
- var documentation = docProvider.GetDocumentation(entity.GetIdString());
+ var idString = entity.GetIdString();
+ // First-cut: try the shared XmlDocLoader (handles "xml beside dll" + .NET
+ // Framework reference pack paths). When that returns null — typical for
+ // modern .NET runtime DLLs whose XML lives in the parallel ref pack — fall
+ // back to ModernXmlDocLookup which walks dotnet/packs/...
+ var documentation = XmlDocLoader.LoadDocumentation(metadata)?.GetDocumentation(idString)
+ ?? ModernXmlDocLookup.TryGetProvider(metadata)?.GetDocumentation(idString);
if (documentation == null)
return;
// First-cut: no cref resolver, so falls back to printing the
diff --git a/ILSpy/TextView/ModernXmlDocLookup.cs b/ILSpy/TextView/ModernXmlDocLookup.cs
new file mode 100644
index 000000000..c4ba3c5bd
--- /dev/null
+++ b/ILSpy/TextView/ModernXmlDocLookup.cs
@@ -0,0 +1,191 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// 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.Runtime.CompilerServices;
+
+using ICSharpCode.Decompiler.Documentation;
+using ICSharpCode.Decompiler.Metadata;
+
+namespace ILSpy.TextView
+{
+ ///
+ /// Fallback XML-documentation locator for modern .NET (.NET 5+). The shared
+ /// only knows about the .NET Framework reference-assemblies
+ /// paths (v1.0 – v4.8.1) and the ".xml beside the .dll" convention. Modern
+ /// runtime DLLs (System.Private.CoreLib.dll, System.Linq.dll, …) live
+ /// under dotnet/shared/Microsoft.NETCore.App/<version>/ with no
+ /// matching XML; the actual XML files ship in the parallel reference pack under
+ /// dotnet/packs/Microsoft.NETCore.App.Ref/<version>/ref/<tfm>/.
+ /// This class walks that structure for a loaded and exposes
+ /// an aggregate provider that searches every .xml in the matching ref-pack tfm
+ /// folder. Cached per so the directory scan only happens
+ /// once per loaded assembly.
+ ///
+ public static class ModernXmlDocLookup
+ {
+ // Keep one cache per loaded MetadataFile so re-hovering the same assembly doesn't
+ // re-scan disk. The ConditionalWeakTable lets entries drop when the assembly is
+ // unloaded — matches XmlDocLoader's caching strategy.
+ static readonly ConditionalWeakTable cache = new();
+
+ ///
+ /// Returns an aggregate XML-doc provider that walks every XML in the modern .NET
+ /// ref pack matching 's runtime, or null when the
+ /// assembly isn't part of a modern .NET shared-framework layout (e.g. the user
+ /// opened a NuGet-only assembly with its XML already next to it — that path is
+ /// handled by ).
+ ///
+ public static Provider? TryGetProvider(MetadataFile module)
+ {
+ if (module == null)
+ return null;
+ lock (cache)
+ {
+ if (cache.TryGetValue(module, out var cached))
+ return cached.IsEmpty ? null : cached;
+ var built = Build(module);
+ cache.Add(module, built ?? Provider.Empty);
+ return built;
+ }
+ }
+
+ static Provider? Build(MetadataFile module)
+ {
+ var refPackDir = ResolveRefPackTfmDirectory(module.FileName);
+ if (refPackDir == null)
+ return null;
+ var xmls = SafeGetXmlFiles(refPackDir);
+ if (xmls.Count == 0)
+ return null;
+ var providers = new List(xmls.Count);
+ foreach (var x in xmls)
+ {
+ try
+ {
+ providers.Add(new XmlDocumentationProvider(x));
+ }
+ catch
+ {
+ // One bad XML doesn't disable the rest — skip and continue.
+ }
+ }
+ return providers.Count > 0 ? new Provider(providers) : null;
+ }
+
+ ///
+ /// Maps a runtime-DLL path like X:\Program Files\dotnet\shared\Microsoft.NETCore.App\10.0.0\System.Private.CoreLib.dll
+ /// to the matching ref-pack tfm directory X:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\10.0.0\ref\net10.0.
+ /// Returns null when the assembly isn't laid out under
+ /// shared/Microsoft.NETCore.App/<version>/.
+ ///
+ internal static string? ResolveRefPackTfmDirectory(string? assemblyFileName)
+ {
+ if (string.IsNullOrEmpty(assemblyFileName))
+ return null;
+ var versionDir = Path.GetDirectoryName(assemblyFileName);
+ if (versionDir == null)
+ return null;
+ var sharedFramework = Path.GetDirectoryName(versionDir);
+ if (sharedFramework == null || !string.Equals(Path.GetFileName(sharedFramework), "Microsoft.NETCore.App", StringComparison.OrdinalIgnoreCase))
+ return null;
+ var sharedDir = Path.GetDirectoryName(sharedFramework);
+ if (sharedDir == null || !string.Equals(Path.GetFileName(sharedDir), "shared", StringComparison.OrdinalIgnoreCase))
+ return null;
+ var dotnetRoot = Path.GetDirectoryName(sharedDir);
+ if (dotnetRoot == null)
+ return null;
+ var version = Path.GetFileName(versionDir);
+ var refPackRoot = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref", version, "ref");
+ if (!Directory.Exists(refPackRoot))
+ return null;
+ // One tfm folder per pack version (e.g. "net10.0"). Pick the lexicographically
+ // latest — multiple tfms only show up across versions, not within one ref-pack
+ // version. Lexicographic order matches version order for "netN.M" formats up
+ // to N=9 (and net10.0 beats net9.0 by string compare too because "1" < "9" but
+ // length wins via the "0." prefix... actually no, this fails for net10.0).
+ // Sort by parsed version-number triple to be safe.
+ var tfmDir = Directory.GetDirectories(refPackRoot)
+ .OrderByDescending(d => ParseTfm(Path.GetFileName(d)))
+ .FirstOrDefault();
+ return tfmDir;
+ }
+
+ static (int Major, int Minor) ParseTfm(string tfm)
+ {
+ // "net10.0" → (10, 0). Anything we don't recognise sorts last (negative).
+ if (!tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
+ return (-1, -1);
+ var rest = tfm.Substring(3);
+ var dot = rest.IndexOf('.');
+ if (dot < 0)
+ return (-1, -1);
+ if (!int.TryParse(rest.AsSpan(0, dot), out int major))
+ return (-1, -1);
+ if (!int.TryParse(rest.AsSpan(dot + 1), out int minor))
+ return (-1, -1);
+ return (major, minor);
+ }
+
+ static IReadOnlyList SafeGetXmlFiles(string directory)
+ {
+ try
+ {
+ return Directory.GetFiles(directory, "*.xml");
+ }
+ catch
+ {
+ return Array.Empty();
+ }
+ }
+
+ ///
+ /// Aggregate XML-doc provider that probes each contributing
+ /// in turn. First match wins — id strings are
+ /// unique across the ref pack (a type forwarded into one assembly is documented in
+ /// exactly one ref-pack XML).
+ ///
+ public sealed class Provider
+ {
+ internal static readonly Provider Empty = new(Array.Empty());
+
+ readonly IReadOnlyList providers;
+
+ internal Provider(IReadOnlyList providers)
+ {
+ this.providers = providers;
+ }
+
+ internal bool IsEmpty => providers.Count == 0;
+
+ public string? GetDocumentation(string idString)
+ {
+ foreach (var p in providers)
+ {
+ var doc = p.GetDocumentation(idString);
+ if (!string.IsNullOrEmpty(doc))
+ return doc;
+ }
+ return null;
+ }
+ }
+ }
+}