diff --git a/ICSharpCode.Decompiler.Tests/UniversalAssemblyResolverTests.cs b/ICSharpCode.Decompiler.Tests/UniversalAssemblyResolverTests.cs
new file mode 100644
index 000000000..2630cfdfd
--- /dev/null
+++ b/ICSharpCode.Decompiler.Tests/UniversalAssemblyResolverTests.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.IO;
+
+using ICSharpCode.Decompiler.Metadata;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.Decompiler.Tests
+{
+ ///
+ /// The GAC layout is the same on every platform ILSpy runs on, so these tests build one in a
+ /// temporary directory instead of requiring a .NET Framework installation.
+ ///
+ [TestFixture]
+ public class UniversalAssemblyResolverTests
+ {
+ string gacDirectory;
+
+ [SetUp]
+ public void SetUp()
+ {
+ gacDirectory = Path.Combine(Path.GetTempPath(), "ILSpyGacTest_" + Path.GetRandomFileName());
+ Directory.CreateDirectory(gacDirectory);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(gacDirectory))
+ Directory.Delete(gacDirectory, recursive: true);
+ }
+
+ string AddAssembly(string name, string version, string publicKeyToken, string culture = "")
+ {
+ string directory = Path.Combine(gacDirectory, name, $"v4.0_{version}_{culture}_{publicKeyToken}");
+ Directory.CreateDirectory(directory);
+ string file = Path.Combine(directory, name + ".dll");
+ File.WriteAllBytes(file, new byte[0]);
+ return file;
+ }
+
+ static IAssemblyReference Reference(string name, string version, string publicKeyToken)
+ {
+ return AssemblyNameReference.Parse($"{name}, Version={version}, Culture=neutral, PublicKeyToken={publicKeyToken}");
+ }
+
+ [Test]
+ public void HigherReferenceVersionResolvesToTheVersionInstalledInTheGac()
+ {
+ // The 4.8 reference assembly of System.IO.Compression is 4.2.0.0, but the GAC only
+ // ever holds 4.0.0.0; the runtime unifies the reference onto the installed version.
+ string file = AddAssembly("System.IO.Compression", "4.0.0.0", "b77a5c561934e089");
+
+ var reference = Reference("System.IO.Compression", "4.2.0.0", "b77a5c561934e089");
+
+ Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory),
+ Is.EqualTo(file));
+ }
+
+ [Test]
+ public void UnificationPicksTheHighestInstalledVersion()
+ {
+ AddAssembly("Microsoft.Build.Framework", "15.1.0.0", "b03f5f7f11d50a3a");
+ string expected = AddAssembly("Microsoft.Build.Framework", "15.2.0.0", "b03f5f7f11d50a3a");
+
+ var reference = Reference("Microsoft.Build.Framework", "15.0.0.0", "b03f5f7f11d50a3a");
+
+ Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory),
+ Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void UnificationDoesNotCrossMajorVersions()
+ {
+ // Microsoft.Build.Framework 4.0.0.0 and 15.x are different products sharing a name.
+ AddAssembly("Microsoft.Build.Framework", "4.0.0.0", "b03f5f7f11d50a3a");
+
+ var reference = Reference("Microsoft.Build.Framework", "15.0.0.0", "b03f5f7f11d50a3a");
+
+ Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory),
+ Is.Null);
+ }
+
+ [Test]
+ public void UnificationRequiresAMatchingPublicKeyToken()
+ {
+ AddAssembly("System.IO.Compression", "4.0.0.0", "31bf3856ad364e35");
+
+ var reference = Reference("System.IO.Compression", "4.2.0.0", "b77a5c561934e089");
+
+ Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory),
+ Is.Null);
+ }
+
+ [Test]
+ public void SatelliteAssembliesAreNotUsedForUnification()
+ {
+ AddAssembly("System.IO.Compression", "4.0.0.0", "b77a5c561934e089", culture: "de");
+
+ var reference = Reference("System.IO.Compression", "4.2.0.0", "b77a5c561934e089");
+
+ Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory),
+ Is.Null);
+ }
+ }
+}
diff --git a/ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs b/ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs
index b4a1f0bc9..afee1ed2b 100644
--- a/ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs
+++ b/ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs
@@ -788,9 +788,67 @@ namespace ICSharpCode.Decompiler.Metadata
}
}
+ // An exact version match is always preferred, so unification is a separate pass over
+ // the whole GAC rather than a fallback within one folder.
+ for (int i = 0; i < gac_paths.Count; i++)
+ {
+ for (int j = 0; j < gacs.Length; j++)
+ {
+ var gac = Path.Combine(gac_paths[i], gacs[j]);
+ var file = FindUnifiedAssemblyInGacFolder(reference, prefixes[i], gac);
+ if (file != null)
+ return file;
+ }
+ }
+
return null;
}
+ ///
+ /// Finds an assembly whose version differs from the requested one, because the runtime
+ /// unifies references to in-box assemblies onto whatever version the installed framework
+ /// carries. The versions genuinely differ: the .NET Framework 4.7.2/4.8 reference
+ /// assemblies of about a hundred assemblies (System.IO.Compression is 4.2.0.0, System.Runtime
+ /// is 4.1.2.0, ...) are higher than the 4.0.0.0 implementations in the GAC, which is the
+ /// only version ever installed there (issue #2080).
+ ///
+ /// Unification is approximated by the highest installed version that shares the major
+ /// version and the public key token of the reference. The major version is what separates
+ /// assemblies that share a name but are different products, e.g. Microsoft.Build.Framework
+ /// 4.0.0.0 and 15.x.
+ ///
+ internal static string? FindUnifiedAssemblyInGacFolder(IAssemblyReference reference, string prefix, string gac)
+ {
+ var requestedVersion = reference.Version;
+ if (requestedVersion == null || reference.PublicKeyToken == null)
+ return null;
+ string assemblyDirectory = Path.Combine(gac, reference.Name);
+ if (!Directory.Exists(assemblyDirectory))
+ return null;
+ // The folder name is "{prefix}{version}_{culture}_{publicKeyToken}"; the culture of a
+ // non-satellite assembly is empty, which leaves the two underscores adjacent.
+ string suffix = "__" + reference.PublicKeyToken.ToHexString(8);
+ string? bestFile = null;
+ Version? bestVersion = null;
+ foreach (var candidate in Directory.EnumerateDirectories(assemblyDirectory, prefix + "*" + suffix))
+ {
+ string folderName = Path.GetFileName(candidate);
+ string versionText = folderName.Substring(prefix.Length, folderName.Length - prefix.Length - suffix.Length);
+ if (!Version.TryParse(versionText, out var version))
+ continue;
+ if (version.Major != requestedVersion.Major)
+ continue;
+ if (bestVersion != null && version <= bestVersion)
+ continue;
+ string file = Path.Combine(candidate, reference.Name + ".dll");
+ if (!File.Exists(file))
+ continue;
+ bestFile = file;
+ bestVersion = version;
+ }
+ return bestFile;
+ }
+
static string GetAssemblyFile(IAssemblyReference reference, string prefix, string gac)
{
var gac_folder = new StringBuilder()