diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0a816c3da..7241ed21b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -67,6 +67,7 @@
+
diff --git a/ICSharpCode.Decompiler.Tests/packages.lock.json b/ICSharpCode.Decompiler.Tests/packages.lock.json
index da8e45fa7..3bab2374b 100644
--- a/ICSharpCode.Decompiler.Tests/packages.lock.json
+++ b/ICSharpCode.Decompiler.Tests/packages.lock.json
@@ -417,11 +417,6 @@
"System.Security.Cryptography.ProtectedData": "8.0.0"
}
},
- "NuGet.Frameworks": {
- "type": "Transitive",
- "resolved": "7.6.0",
- "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
- },
"NuGet.Packaging": {
"type": "Transitive",
"resolved": "7.6.0",
@@ -1261,6 +1256,12 @@
"resolved": "10.0.9",
"contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA=="
},
+ "NuGet.Frameworks": {
+ "type": "CentralTransitive",
+ "requested": "[7.6.0, )",
+ "resolved": "7.6.0",
+ "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
+ },
"System.Composition.AttributedModel": {
"type": "CentralTransitive",
"requested": "[10.0.9, )",
diff --git a/ICSharpCode.ILSpyCmd/packages.lock.json b/ICSharpCode.ILSpyCmd/packages.lock.json
index 39e4841fc..b571d80a5 100644
--- a/ICSharpCode.ILSpyCmd/packages.lock.json
+++ b/ICSharpCode.ILSpyCmd/packages.lock.json
@@ -311,11 +311,6 @@
"System.Security.Cryptography.ProtectedData": "8.0.0"
}
},
- "NuGet.Frameworks": {
- "type": "Transitive",
- "resolved": "7.6.0",
- "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
- },
"NuGet.Packaging": {
"type": "Transitive",
"resolved": "7.6.0",
@@ -412,6 +407,12 @@
"resolved": "0.11.6",
"contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA=="
},
+ "NuGet.Frameworks": {
+ "type": "CentralTransitive",
+ "requested": "[7.6.0, )",
+ "resolved": "7.6.0",
+ "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
+ },
"System.Collections.Immutable": {
"type": "CentralTransitive",
"requested": "[10.0.9, )",
diff --git a/ICSharpCode.ILSpyX/AssemblyList.cs b/ICSharpCode.ILSpyX/AssemblyList.cs
index a5a9bda07..86b0e1cc8 100644
--- a/ICSharpCode.ILSpyX/AssemblyList.cs
+++ b/ICSharpCode.ILSpyX/AssemblyList.cs
@@ -95,7 +95,10 @@ namespace ICSharpCode.ILSpyX
{
foreach (var asm in listElement.Elements("Assembly"))
{
- OpenAssembly((string)asm);
+ var loaded = OpenAssembly((string)asm);
+ // Lazy detection has not run yet, so seeding the override here makes it effective
+ // for the first resolution without a reload. Absent attribute -> null (auto-detect).
+ loaded.TargetFrameworkIdOverride = (string?)asm.Attribute("TargetFramework");
}
this.dirty = false; // OpenAssembly() sets dirty, so reset it afterwards
}
@@ -175,7 +178,10 @@ namespace ICSharpCode.ILSpyX
return new XElement(
"List",
new XAttribute("name", this.ListName),
- assemblies.Where(asm => !asm.IsAutoLoaded).Select(asm => new XElement("Assembly", asm.FileName))
+ assemblies.Where(asm => !asm.IsAutoLoaded).Select(asm => new XElement("Assembly", asm.FileName,
+ asm.TargetFrameworkIdOverride != null
+ ? new XAttribute("TargetFramework", asm.TargetFrameworkIdOverride)
+ : null))
);
}
@@ -387,6 +393,9 @@ namespace ICSharpCode.ILSpyX
fileLoaders: manager?.LoaderRegistry,
applyWinRTProjections: ApplyWinRTProjections, useDebugSymbols: UseDebugSymbols);
newAsm.IsAutoLoaded = target.IsAutoLoaded;
+ // Carry the framework hint across the reload (set before any resolution on the fresh
+ // instance) so the references re-resolve against the overridden target framework.
+ newAsm.TargetFrameworkIdOverride = target.TargetFrameworkIdOverride;
lock (lockObj)
{
this.assemblies.Remove(target);
diff --git a/ICSharpCode.ILSpyX/LoadedAssembly.cs b/ICSharpCode.ILSpyX/LoadedAssembly.cs
index e68a1096a..a75ce520b 100644
--- a/ICSharpCode.ILSpyX/LoadedAssembly.cs
+++ b/ICSharpCode.ILSpyX/LoadedAssembly.cs
@@ -137,13 +137,27 @@ namespace ICSharpCode.ILSpyX
string? targetFrameworkId;
///
- /// Returns a target framework identifier in the form '<framework>Version=v<version>'.
+ /// Returns the effective target framework identifier in the form '<framework>,Version=v<version>'.
+ /// An explicit override wins over the detected value.
+ ///
+ public async Task GetTargetFrameworkIdAsync()
+ {
+ // An explicit override wins over (and bypasses) detection, even once detection has
+ // already cached a value, so the user's framework hint drives reference resolution.
+ // The setter normalizes blank to null, so a non-null override is always a usable TFM.
+ if (TargetFrameworkIdOverride is { } tfmOverride)
+ return tfmOverride;
+ return await GetDetectedTargetFrameworkIdAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Returns the detected target framework identifier in the form '<framework>,Version=v<version>'.
/// Returns an empty string if no TargetFrameworkAttribute was found
/// or the file doesn't contain an assembly header, i.e., is only a module.
- ///
+ ///
/// Throws an exception if the file does not contain any .NET metadata (e.g. file of unknown format).
///
- public async Task GetTargetFrameworkIdAsync()
+ public async Task GetDetectedTargetFrameworkIdAsync()
{
var value = LazyInit.VolatileRead(ref targetFrameworkId);
if (value == null)
@@ -373,6 +387,26 @@ namespace ICSharpCode.ILSpyX
///
public string? PdbFileName { get; private set; }
+ string? targetFrameworkIdOverride;
+
+ ///
+ /// Overrides the target framework used to resolve this assembly's references, in the
+ /// '<framework>,Version=v<version>' form (e.g. ".NETFramework,Version=v4.8").
+ /// Null means the framework detected from the TargetFrameworkAttribute is used. Blank or
+ /// whitespace-only values (e.g. a hand-edited assembly-list XML with TargetFramework="")
+ /// are normalized to null and incidental whitespace is trimmed, so an override never
+ /// suppresses detection with an empty effective TFM.
+ /// Set this before the assembly's references are first resolved (initial load) or follow
+ /// a change with a reload: the resolver is built once per LoadedAssembly and frozen.
+ ///
+ public string? TargetFrameworkIdOverride {
+ get => targetFrameworkIdOverride;
+ set {
+ var trimmed = value?.Trim();
+ targetFrameworkIdOverride = string.IsNullOrEmpty(trimmed) ? null : trimmed;
+ }
+ }
+
async Task LoadAsync(Task? streamTask)
{
using var stream = await PrepareStream();
diff --git a/ILSpy.ReadyToRun/packages.lock.json b/ILSpy.ReadyToRun/packages.lock.json
index 1eaad86c7..01ccfa8f5 100644
--- a/ILSpy.ReadyToRun/packages.lock.json
+++ b/ILSpy.ReadyToRun/packages.lock.json
@@ -339,11 +339,6 @@
"System.Security.Cryptography.ProtectedData": "8.0.0"
}
},
- "NuGet.Frameworks": {
- "type": "Transitive",
- "resolved": "7.6.0",
- "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
- },
"NuGet.Packaging": {
"type": "Transitive",
"resolved": "7.6.0",
@@ -1403,6 +1398,7 @@
"Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )",
"Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )",
+ "NuGet.Frameworks": "[7.6.0, )",
"NuGet.Protocol": "[7.6.0, )",
"ProDataGrid": "[12.0.0, )",
"Svg.Controls.Skia.Avalonia": "[12.0.0.11, )",
@@ -1573,6 +1569,12 @@
"resolved": "0.11.6",
"contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA=="
},
+ "NuGet.Frameworks": {
+ "type": "CentralTransitive",
+ "requested": "[7.6.0, )",
+ "resolved": "7.6.0",
+ "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
+ },
"NuGet.Protocol": {
"type": "CentralTransitive",
"requested": "[7.6.0, )",
diff --git a/ILSpy.Tests.Windows/packages.lock.json b/ILSpy.Tests.Windows/packages.lock.json
index 4a409c983..61c7eced5 100644
--- a/ILSpy.Tests.Windows/packages.lock.json
+++ b/ILSpy.Tests.Windows/packages.lock.json
@@ -561,11 +561,6 @@
"System.Security.Cryptography.ProtectedData": "8.0.0"
}
},
- "NuGet.Frameworks": {
- "type": "Transitive",
- "resolved": "7.6.0",
- "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
- },
"NuGet.Packaging": {
"type": "Transitive",
"resolved": "7.6.0",
@@ -1623,6 +1618,7 @@
"Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )",
"Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )",
+ "NuGet.Frameworks": "[7.6.0, )",
"NuGet.Protocol": "[7.6.0, )",
"ProDataGrid": "[12.0.0, )",
"Svg.Controls.Skia.Avalonia": "[12.0.0.11, )",
@@ -1800,6 +1796,12 @@
"resolved": "0.11.6",
"contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA=="
},
+ "NuGet.Frameworks": {
+ "type": "CentralTransitive",
+ "requested": "[7.6.0, )",
+ "resolved": "7.6.0",
+ "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
+ },
"NuGet.Protocol": {
"type": "CentralTransitive",
"requested": "[7.6.0, )",
diff --git a/ILSpy.Tests/AssemblyList/TargetFrameworkOverrideTests.cs b/ILSpy.Tests/AssemblyList/TargetFrameworkOverrideTests.cs
new file mode 100644
index 000000000..71a50980a
--- /dev/null
+++ b/ILSpy.Tests/AssemblyList/TargetFrameworkOverrideTests.cs
@@ -0,0 +1,148 @@
+// 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.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpyX;
+
+using ICSharpCode.ILSpy.AppEnv;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// The target-framework override lets the user hint the TFM used to resolve an assembly's
+/// references instead of the one detected from its TargetFrameworkAttribute. These tests pin
+/// the load-bearing behaviour: the override wins over detection, survives the assembly-list
+/// XML round-trip, defaults to null for assemblies persisted without it, and is carried across
+/// a reload (which is how a runtime change re-resolves against the new framework).
+///
+[TestFixture]
+public class TargetFrameworkOverrideTests
+{
+ static AssemblyListManager Manager => AppComposition.Current.GetExport().AssemblyListManager;
+
+ [AvaloniaTest]
+ public async Task Override_Wins_Over_Detected_Tfm_Even_After_Detection_Cached()
+ {
+ var list = Manager.CreateList("tfm-override-" + Guid.NewGuid().ToString("N"));
+ var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmOverrideFixture"));
+
+ // Force detection to run and cache its result first, so the test also proves the override
+ // takes precedence over an already-cached detected value.
+ var detected = await asm.GetTargetFrameworkIdAsync();
+ asm.TargetFrameworkIdOverride = ".NETFramework,Version=v4.7.2";
+
+ (await asm.GetTargetFrameworkIdAsync()).Should().Be(".NETFramework,Version=v4.7.2");
+ detected.Should().NotBe(".NETFramework,Version=v4.7.2",
+ "the fixture is not a .NET Framework 4.7.2 assembly; the override is what changes the result");
+ }
+
+ [AvaloniaTest]
+ public void Override_Survives_SaveAsXml_Roundtrip()
+ {
+ var list = Manager.CreateList("tfm-roundtrip-" + Guid.NewGuid().ToString("N"));
+ var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmRoundtripFixture"));
+ asm.TargetFrameworkIdOverride = ".NETCoreApp,Version=v6.0";
+
+ var reloaded = new AssemblyList(Manager, list.SaveAsXml());
+
+ reloaded.GetAssemblies().Should().ContainSingle()
+ .Which.TargetFrameworkIdOverride.Should().Be(".NETCoreApp,Version=v6.0");
+ }
+
+ [AvaloniaTest]
+ public void Assembly_Without_Override_Persists_No_Attribute_And_Loads_Null()
+ {
+ var list = Manager.CreateList("tfm-noattr-" + Guid.NewGuid().ToString("N"));
+ list.OpenAssembly(FixtureAssembly.Emit("TfmNoAttrFixture"));
+
+ var xml = list.SaveAsXml();
+ xml.Elements("Assembly").Should().OnlyContain(e => e.Attribute("TargetFramework") == null,
+ "no attribute is written when there is no override (backward-compatible XML)");
+
+ var reloaded = new AssemblyList(Manager, xml);
+ reloaded.GetAssemblies().Should().ContainSingle()
+ .Which.TargetFrameworkIdOverride.Should().BeNull();
+ }
+
+ [AvaloniaTest]
+ [TestCase("")]
+ [TestCase(" ")]
+ public async Task Blank_Override_Normalizes_To_Null_And_Falls_Back_To_Detected(string blank)
+ {
+ var list = Manager.CreateList("tfm-blank-" + Guid.NewGuid().ToString("N"));
+ var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmBlankFixture"));
+ var detected = await asm.GetTargetFrameworkIdAsync();
+
+ // A blank/whitespace override must not suppress detection with an empty effective TFM.
+ asm.TargetFrameworkIdOverride = blank;
+
+ asm.TargetFrameworkIdOverride.Should().BeNull();
+ (await asm.GetTargetFrameworkIdAsync()).Should().Be(detected);
+ }
+
+ [AvaloniaTest]
+ public void Override_Is_Trimmed()
+ {
+ var list = Manager.CreateList("tfm-trim-" + Guid.NewGuid().ToString("N"));
+ var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmTrimFixture"));
+
+ asm.TargetFrameworkIdOverride = " .NETCoreApp,Version=v8.0 ";
+
+ asm.TargetFrameworkIdOverride.Should().Be(".NETCoreApp,Version=v8.0");
+ }
+
+ [AvaloniaTest]
+ public async Task Blank_TargetFramework_Attribute_In_Xml_Loads_As_Null()
+ {
+ // A hand-edited assembly-list XML may carry TargetFramework="". Loading it must behave
+ // like no override at all, not pin the effective TFM to an empty string.
+ var list = Manager.CreateList("tfm-blankxml-" + Guid.NewGuid().ToString("N"));
+ var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmBlankXmlFixture"));
+ var xml = list.SaveAsXml();
+ xml.Elements("Assembly").Single().SetAttributeValue("TargetFramework", "");
+
+ var reloaded = new AssemblyList(Manager, xml);
+ var loaded = reloaded.GetAssemblies().Should().ContainSingle().Subject;
+
+ loaded.TargetFrameworkIdOverride.Should().BeNull();
+ (await loaded.GetTargetFrameworkIdAsync()).Should().Be(await asm.GetTargetFrameworkIdAsync());
+ }
+
+ [AvaloniaTest]
+ public void ReloadAssembly_Carries_Override_To_New_Instance()
+ {
+ var list = Manager.CreateList("tfm-reload-" + Guid.NewGuid().ToString("N"));
+ var asm = list.OpenAssembly(FixtureAssembly.Emit("TfmReloadFixture"));
+ asm.TargetFrameworkIdOverride = ".NETStandard,Version=v2.0";
+
+ var reloaded = list.ReloadAssembly(asm);
+
+ ((object?)reloaded).Should().NotBeNull();
+ reloaded!.Should().NotBeSameAs(asm);
+ reloaded.TargetFrameworkIdOverride.Should().Be(".NETStandard,Version=v2.0");
+ }
+}
diff --git a/ILSpy.Tests/Editor/DecompilerViewTests.cs b/ILSpy.Tests/Editor/DecompilerViewTests.cs
index b342af0ba..82c5417cb 100644
--- a/ILSpy.Tests/Editor/DecompilerViewTests.cs
+++ b/ILSpy.Tests/Editor/DecompilerViewTests.cs
@@ -94,6 +94,7 @@ public class DecompilerViewTests
// Assert — every header and a representative referenced assembly land in the listing.
tab.Text.Should().Contain("Detected TargetFramework-Id:");
+ tab.Text.Should().Contain("Effective TargetFramework-Id:");
tab.Text.Should().Contain("Detected RuntimePack:");
tab.Text.Should().Contain("Referenced assemblies (in metadata order):");
tab.Text.Should().Contain("System.Runtime");
diff --git a/ILSpy.Tests/Views/TargetFrameworkConverterTests.cs b/ILSpy.Tests/Views/TargetFrameworkConverterTests.cs
new file mode 100644
index 000000000..9c42ec2e8
--- /dev/null
+++ b/ILSpy.Tests/Views/TargetFrameworkConverterTests.cs
@@ -0,0 +1,76 @@
+// 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 AwesomeAssertions;
+
+using ICSharpCode.ILSpy.Views;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// The Set-Target-Framework dialog accepts the short TFMs users know (net48, net6.0, ...) but
+/// ILSpy resolves with the long FrameworkName form (.NETFramework,Version=v4.8). These tests
+/// pin that conversion/validation, which is the part of the dialog worth unit-testing in
+/// isolation from the window.
+///
+[TestFixture]
+public class TargetFrameworkConverterTests
+{
+ [TestCase("net48", ".NETFramework,Version=v4.8")]
+ [TestCase("net472", ".NETFramework,Version=v4.7.2")]
+ [TestCase("net6.0", ".NETCoreApp,Version=v6.0")]
+ [TestCase("net8.0", ".NETCoreApp,Version=v8.0")]
+ [TestCase("netstandard2.0", ".NETStandard,Version=v2.0")]
+ [TestCase("netcoreapp3.1", ".NETCoreApp,Version=v3.1")]
+ public void Short_Tfm_Parses_To_FrameworkName(string input, string expected)
+ {
+ TargetFrameworkConverter.TryParseToFrameworkName(input, out var frameworkName, out _)
+ .Should().BeTrue();
+ frameworkName.Should().Be(expected);
+ }
+
+ [Test]
+ public void Long_FrameworkName_Is_Also_Accepted()
+ {
+ TargetFrameworkConverter.TryParseToFrameworkName(".NETCoreApp,Version=v6.0", out var frameworkName, out _)
+ .Should().BeTrue();
+ frameworkName.Should().Be(".NETCoreApp,Version=v6.0");
+ }
+
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase("not-a-tfm")]
+ [TestCase("garbage123")]
+ public void Invalid_Input_Is_Rejected_With_An_Error(string input)
+ {
+ TargetFrameworkConverter.TryParseToFrameworkName(input, out var frameworkName, out var error)
+ .Should().BeFalse();
+ frameworkName.Should().BeNull();
+ error.Should().NotBeNullOrEmpty();
+ }
+
+ [TestCase(".NETFramework,Version=v4.8", "net48")]
+ [TestCase(".NETCoreApp,Version=v6.0", "net6.0")]
+ [TestCase(".NETStandard,Version=v2.0", "netstandard2.0")]
+ public void FrameworkName_Converts_To_Short_Folder_Name_For_Display(string frameworkName, string expected)
+ {
+ TargetFrameworkConverter.ToShortFolderName(frameworkName).Should().Be(expected);
+ }
+}
diff --git a/ILSpy.Tests/packages.lock.json b/ILSpy.Tests/packages.lock.json
index 7d4747191..95a8ee24f 100644
--- a/ILSpy.Tests/packages.lock.json
+++ b/ILSpy.Tests/packages.lock.json
@@ -524,11 +524,6 @@
"System.Security.Cryptography.ProtectedData": "8.0.0"
}
},
- "NuGet.Frameworks": {
- "type": "Transitive",
- "resolved": "7.6.0",
- "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
- },
"NuGet.Packaging": {
"type": "Transitive",
"resolved": "7.6.0",
@@ -1586,6 +1581,7 @@
"Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )",
"Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )",
"Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )",
+ "NuGet.Frameworks": "[7.6.0, )",
"NuGet.Protocol": "[7.6.0, )",
"ProDataGrid": "[12.0.0, )",
"Svg.Controls.Skia.Avalonia": "[12.0.0.11, )",
@@ -1775,6 +1771,12 @@
"resolved": "0.11.6",
"contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA=="
},
+ "NuGet.Frameworks": {
+ "type": "CentralTransitive",
+ "requested": "[7.6.0, )",
+ "resolved": "7.6.0",
+ "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw=="
+ },
"NuGet.Protocol": {
"type": "CentralTransitive",
"requested": "[7.6.0, )",
diff --git a/ILSpy/Commands/SetTargetFrameworkContextMenuEntry.cs b/ILSpy/Commands/SetTargetFrameworkContextMenuEntry.cs
new file mode 100644
index 000000000..82bda6d7f
--- /dev/null
+++ b/ILSpy/Commands/SetTargetFrameworkContextMenuEntry.cs
@@ -0,0 +1,133 @@
+// 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.Composition;
+using System.Linq;
+using System.Threading.Tasks;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpy.Views;
+
+namespace ICSharpCode.ILSpy.Commands
+{
+ ///
+ /// Right-click an assembly -> "Set Target Framework". Overrides the framework ILSpy resolves the
+ /// assembly's references against (instead of the one detected from its TargetFrameworkAttribute),
+ /// then reloads so the references re-resolve. Leaving the dialog blank clears the override.
+ ///
+ [ExportContextMenuEntry(Header = nameof(Resources.SetTargetFramework), Category = "Debug", Order = 420)]
+ [Shared]
+ public sealed class SetTargetFrameworkContextMenuEntry : IContextMenuEntry
+ {
+ readonly AssemblyTreeModel assemblyTreeModel;
+
+ [ImportingConstructor]
+ public SetTargetFrameworkContextMenuEntry(AssemblyTreeModel assemblyTreeModel)
+ {
+ this.assemblyTreeModel = assemblyTreeModel;
+ }
+
+ public bool IsEnabled(TextViewContext context) => true;
+
+ public bool IsVisible(TextViewContext context)
+ => context.SelectedTreeNodes?.Length == 1
+ && context.SelectedTreeNodes[0] is AssemblyTreeNode asm
+ && asm.LoadedAssembly.IsLoadedAsValidAssembly;
+
+ public void Execute(TextViewContext context)
+ {
+ if (context.SelectedTreeNodes?.FirstOrDefault() is not AssemblyTreeNode node)
+ return;
+ ExecuteAsync(node).HandleExceptions();
+ }
+
+ async Task ExecuteAsync(AssemblyTreeNode node)
+ {
+ var owner = UiContext.MainWindow;
+ if (owner == null)
+ return;
+ var assembly = node.LoadedAssembly;
+ var current = await assembly.GetTargetFrameworkIdAsync();
+
+ var result = await new SetTargetFrameworkDialog(current).ShowDialog(owner);
+ if (result == null)
+ return; // cancelled
+ var frameworkName = result.Length == 0 ? null : result;
+ if (frameworkName == assembly.TargetFrameworkIdOverride
+ || (assembly.TargetFrameworkIdOverride == null && frameworkName == current))
+ return; // unchanged
+
+ ApplyOverride(assemblyTreeModel, node, frameworkName);
+ }
+
+ ///
+ /// Sets the override, reloads so references re-resolve against the new framework, and restores
+ /// the tree selection. Shared by "Set Target Framework" and "Reset Target Framework".
+ ///
+ internal static void ApplyOverride(AssemblyTreeModel assemblyTreeModel, AssemblyTreeNode node, string? frameworkName)
+ {
+ var assembly = node.LoadedAssembly;
+ // Snapshot the node path before ReloadAssembly swaps the LoadedAssembly out from under
+ // the tree, then re-select the equivalent node so the user keeps their position.
+ var path = AssemblyTreeModel.GetPathForNode(node);
+ assembly.TargetFrameworkIdOverride = frameworkName;
+ assembly.AssemblyList.ReloadAssembly(assembly.FileName);
+ var restored = assemblyTreeModel.FindNodeByPath(path, returnBestMatch: true);
+ if (restored != null)
+ assemblyTreeModel.SelectNode(restored);
+ assemblyTreeModel.RefreshDecompiledView();
+ }
+ }
+
+ ///
+ /// Right-click an assembly that carries a manual target-framework override -> "Reset Target
+ /// Framework". Clears the override and reloads so references re-resolve against the framework
+ /// detected from the TargetFrameworkAttribute again. Only shown while an override is in effect,
+ /// so the user can revert without reopening the dialog.
+ ///
+ [ExportContextMenuEntry(Header = nameof(Resources.ResetTargetFramework), Category = "Debug", Order = 421)]
+ [Shared]
+ public sealed class ResetTargetFrameworkContextMenuEntry : IContextMenuEntry
+ {
+ readonly AssemblyTreeModel assemblyTreeModel;
+
+ [ImportingConstructor]
+ public ResetTargetFrameworkContextMenuEntry(AssemblyTreeModel assemblyTreeModel)
+ {
+ this.assemblyTreeModel = assemblyTreeModel;
+ }
+
+ public bool IsEnabled(TextViewContext context) => true;
+
+ public bool IsVisible(TextViewContext context)
+ => context.SelectedTreeNodes?.Length == 1
+ && context.SelectedTreeNodes[0] is AssemblyTreeNode asm
+ && asm.LoadedAssembly.IsLoadedAsValidAssembly
+ && asm.LoadedAssembly.TargetFrameworkIdOverride != null;
+
+ public void Execute(TextViewContext context)
+ {
+ if (context.SelectedTreeNodes?.FirstOrDefault() is not AssemblyTreeNode node)
+ return;
+ SetTargetFrameworkContextMenuEntry.ApplyOverride(assemblyTreeModel, node, null);
+ }
+ }
+}
diff --git a/ILSpy/ILSpy.csproj b/ILSpy/ILSpy.csproj
index 86b8bb5ab..c9855032b 100644
--- a/ILSpy/ILSpy.csproj
+++ b/ILSpy/ILSpy.csproj
@@ -120,6 +120,9 @@
+
+
+
diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs
index 26cf42196..1ab64a05c 100644
--- a/ILSpy/Properties/Resources.Designer.cs
+++ b/ILSpy/Properties/Resources.Designer.cs
@@ -2836,6 +2836,15 @@ namespace ICSharpCode.ILSpy.Properties {
}
}
+ ///
+ /// Looks up a localized string similar to Reset Target Framework.
+ ///
+ public static string ResetTargetFramework {
+ get {
+ return ResourceManager.GetString("ResetTargetFramework", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Reset to defaults.
///
@@ -3006,7 +3015,34 @@ namespace ICSharpCode.ILSpy.Properties {
return ResourceManager.GetString("SelectPDB", resourceCulture);
}
}
-
+
+ ///
+ /// Looks up a localized string similar to Set Target Framework....
+ ///
+ public static string SetTargetFramework {
+ get {
+ return ResourceManager.GetString("SetTargetFramework", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Target framework:.
+ ///
+ public static string TargetFramework {
+ get {
+ return ResourceManager.GetString("TargetFramework", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Unknown target framework. Enter a moniker such as net48 or net6.0..
+ ///
+ public static string InvalidTargetFramework {
+ get {
+ return ResourceManager.GetString("InvalidTargetFramework", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Select target folder.
///
diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx
index 16b6051ce..423ba5286 100644
--- a/ILSpy/Properties/Resources.resx
+++ b/ILSpy/Properties/Resources.resx
@@ -780,6 +780,9 @@ Are you sure you want to continue?
Insert using declarations
+
+ Unknown target framework. Enter a moniker such as net48 or net6.0.
+
Are you sure that you want to delete the selected assembly list?
@@ -976,6 +979,9 @@ Do you want to continue?
Rename list
+
+ Reset Target Framework
+
Reset to defaults
@@ -1039,6 +1045,9 @@ Do you want to continue?
Select version of language to output (Alt+E)
+
+ Set Target Framework...
+
You must restart ILSpy for the change to take effect.
@@ -1126,6 +1135,9 @@ Do you want to continue?
Tab size:
+
+ Target framework:
+
Theme
diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs
index 47f0e3e1e..ba94e57c5 100644
--- a/ILSpy/TreeNodes/AssemblyTreeNode.cs
+++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs
@@ -27,6 +27,8 @@ using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Media;
+using AvaloniaEdit.Highlighting;
+
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.Metadata;
@@ -38,11 +40,12 @@ using ICSharpCode.ILSpyX.TreeView;
using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Controls.TreeView;
using ICSharpCode.ILSpy.Languages;
namespace ICSharpCode.ILSpy.TreeNodes
{
- public sealed class AssemblyTreeNode : ILSpyTreeNode
+ public sealed class AssemblyTreeNode : ILSpyTreeNode, IRichTextNode
{
readonly LoadedAssembly assembly;
string? loadError;
@@ -228,6 +231,29 @@ namespace ICSharpCode.ILSpy.TreeNodes
public override object Text => assembly.Text;
+ ///
+ /// The label already embeds the effective target framework in parentheses (see
+ /// , e.g. "Foo (1.0.0.0, .NETFramework, v4.8)"). When that
+ /// framework comes from a user override, this bolds just that TFM fragment so overridden
+ /// assemblies stand out without bolding the whole row. Returns null (plain )
+ /// when there is no override.
+ ///
+ public RichText? CreateRichText()
+ {
+ if (assembly.TargetFrameworkIdOverride is not { } tfmOverride)
+ return null;
+ string label = assembly.Text;
+ // LoadedAssembly.Text renders the TFM as the effective id with "Version=" replaced by a
+ // space; reproduce that and locate it so only the framework fragment is emphasized.
+ string fragment = tfmOverride.Replace("Version=", " ");
+ int start = label.LastIndexOf(fragment, StringComparison.Ordinal);
+ if (start < 0)
+ return null;
+ var model = new RichTextModel();
+ model.SetFontWeight(start, fragment.Length, FontWeight.Bold);
+ return new RichText(label, model);
+ }
+
// ToString is the stable identity used by SessionSettings.ActiveTreeViewPath — must not
// depend on the active language. The full file path uniquely identifies the assembly.
public override string ToString() => assembly.FileName;
diff --git a/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs b/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs
index 2e87742e7..a72d81bce 100644
--- a/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs
+++ b/ILSpy/TreeNodes/ReferenceFolderTreeNode.cs
@@ -17,6 +17,7 @@
// DEALINGS IN THE SOFTWARE.
using System.Linq;
+using System.Threading.Tasks;
using Avalonia.Threading;
@@ -25,6 +26,7 @@ using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpy.Languages;
+using ICSharpCode.ILSpyX;
namespace ICSharpCode.ILSpy.TreeNodes
{
@@ -61,9 +63,10 @@ namespace ICSharpCode.ILSpy.TreeNodes
public override void Decompile(Language language, ITextOutput output, DecompilationOptions options)
{
- var targetFramework = parentAssembly.LoadedAssembly.GetTargetFrameworkIdAsync().GetAwaiter().GetResult();
- var runtimePack = parentAssembly.LoadedAssembly.GetRuntimePackAsync().GetAwaiter().GetResult();
- output.WriteLine($"Detected TargetFramework-Id: {targetFramework}");
+ var loadedAssembly = parentAssembly.LoadedAssembly;
+ var (detectedTargetFramework, effectiveTargetFramework, runtimePack) = GetFrameworkInfoAsync(loadedAssembly).GetAwaiter().GetResult();
+ output.WriteLine($"Detected TargetFramework-Id: {detectedTargetFramework}");
+ output.WriteLine($"Effective TargetFramework-Id: {effectiveTargetFramework}");
output.WriteLine($"Detected RuntimePack: {runtimePack}");
// Children realise lazily on the UI thread; we may run from a background decompile.
@@ -86,5 +89,16 @@ namespace ICSharpCode.ILSpy.TreeNodes
output.WriteLine();
}
}
+
+ // One await chain instead of three separate blocking GetResult() calls: each blocked wait
+ // can park a thread-pool thread, so resolving all three framework values in a single async
+ // method and blocking once is cheaper.
+ static async Task<(string Detected, string Effective, string RuntimePack)> GetFrameworkInfoAsync(LoadedAssembly loadedAssembly)
+ {
+ var detected = await loadedAssembly.GetDetectedTargetFrameworkIdAsync().ConfigureAwait(false);
+ var effective = await loadedAssembly.GetTargetFrameworkIdAsync().ConfigureAwait(false);
+ var runtimePack = await loadedAssembly.GetRuntimePackAsync().ConfigureAwait(false);
+ return (detected, effective, runtimePack);
+ }
}
}
diff --git a/ILSpy/Views/SetTargetFrameworkDialog.axaml b/ILSpy/Views/SetTargetFrameworkDialog.axaml
new file mode 100644
index 000000000..8e15a3509
--- /dev/null
+++ b/ILSpy/Views/SetTargetFrameworkDialog.axaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ILSpy/Views/SetTargetFrameworkDialog.axaml.cs b/ILSpy/Views/SetTargetFrameworkDialog.axaml.cs
new file mode 100644
index 000000000..7d5a83d0e
--- /dev/null
+++ b/ILSpy/Views/SetTargetFrameworkDialog.axaml.cs
@@ -0,0 +1,103 @@
+// 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.Collections.Generic;
+using System.Linq;
+
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace ICSharpCode.ILSpy.Views
+{
+ ///
+ /// Prompts for a target-framework moniker used to override how an assembly's references are
+ /// resolved. A text box accepts the short TFMs users know (net48, net6.0, ...) for free-form
+ /// entry, with the common ones offered in an adjacent list to pick from; input is
+ /// validated/converted to the long FrameworkName form via .
+ /// Leaving it blank clears the override.
+ ///
+ public partial class SetTargetFrameworkDialog : Window
+ {
+ // Suggestions only; any valid TFM the user types is accepted.
+ static readonly IReadOnlyList CommonFrameworks = new[] {
+ "net11.0", "net10.0", "net9.0", "net8.0", "net7.0", "net6.0", "net5.0",
+ "netcoreapp3.1", "netcoreapp3.0", "netcoreapp2.1",
+ "netstandard2.1", "netstandard2.0",
+ "net48", "net472", "net471", "net47", "net462", "net461", "net46",
+ };
+
+ TextBox frameworkBox = null!;
+ ListBox presetList = null!;
+ TextBlock errorText = null!;
+
+ public SetTargetFrameworkDialog()
+ {
+ InitializeComponent();
+ frameworkBox = this.FindControl("FrameworkBox")!;
+ presetList = this.FindControl("PresetList")!;
+ errorText = this.FindControl("ErrorText")!;
+ this.FindControl("PromptText")!.Text = Properties.Resources.TargetFramework;
+ presetList.ItemsSource = CommonFrameworks;
+ // Picking a preset fills the box; typing stays free-form, so any TFM the user knows is
+ // accepted even when it isn't in the list.
+ presetList.SelectionChanged += (_, _) => {
+ if (presetList.SelectedItem is string preset)
+ frameworkBox.Text = preset;
+ };
+ // Double-clicking a preset is "choose and confirm", like committing a combo selection.
+ presetList.DoubleTapped += (_, _) => {
+ if (presetList.SelectedItem is string)
+ OnOk();
+ };
+ ((Button)this.FindControl