Removing assemblies one at a time cleaned up the tabs that showed them,
but clearing the whole list did not: Clear() raises a Reset whose
OldItems is null, and the handler pruned the history and returned before
the loop that empties the main tab and closes the orphaned ones. The
last decompiled member stayed on screen over an empty list.
Sorting had to be fixed first. It rebuilt the collection through Clear()
plus AddRange, so it raised the same Reset and dropped the whole
navigation history as a side effect - and once Reset closes tabs, it
would have closed all of those too. It now reorders in place, and a Move
is ignored where a removal would be handled, because a sort removes
nothing.
Assisted-by: Claude:claude-opus-5:Claude Code
Reusing whatever Release build a checkout happened to carry made a run
measure code that neither side is on, and the only signal was a
timestamp in the header line that is easy to read past. Building is now
what happens unless --no-build asks for the fast path, which repeated
runs against unchanged sides still want; it says which dll it reused and
when that was built.
Assisted-by: Claude:claude-opus-5:Claude Code
Whether an element is a markup extension is decided by walking its base
types, and that walk leaves the assemblies the document's own assembly
references: WPFLocalizeExtension's LocExtension, in the report, derives
from a type in XAMLMarkupExtensions, which the application never
references itself. Only the assemblies named by the module were loaded,
so the base type resolved to nothing, the extension was not recognised,
and it went out as an element tree carrying the decompiler's own
placeholder namespace - https://github.com/icsharpcode/ILSpy - into the
XAML.
The reference closure is now followed transitively. It is built once per
assembly, and the cost is bounded: for a .NET 8 WPF application the type
system grows from 87 to 158 modules and from 92 to 118 ms, for a .NET
Framework one from 9 to 24 modules and 39 to 44 ms. Over the 1158 BAML
documents of a DevExpress theme assembly the output does not change at
all - this only decides cases that used to resolve to nothing.
Assisted-by: Claude:claude-opus-5:Claude Code
Resolving one assembly resolves its whole reference closure, and every
reference in it asked the same framework directories the same questions.
The worst of it was the scan for the closest version folder of a shared
framework: a directory listing plus a recursive file search, repeated per
reference and per runtime pack - 42 scans for two distinct answers when
decompiling ICSharpCode.ILSpyX.dll.
The scan result is only safe to keep for a bounded time: a runtime can be
installed or removed while ILSpy runs, and reloading an assembly list has
to see that. So it is kept for the length of an explicitly opened scope,
which the type system opens around the closure it resolves and closes
again afterwards; outside a scope the file system is read as before. The
scope owns what was read, so two of them on one resolver do not stack -
the first to end takes it, and the other reads the file system again.
BeginSnapshot is on IAssemblyResolver rather than an interface of its
own: it is core functionality of a resolver, and one implementation is
not an abstraction. This breaks the interface for implementors outside
this repository, who opt out by returning null - which is what the three
resolvers here that hold nothing do.
The remaining probes cost nothing to fix: the preferred runtime pack was
listed among the defaults it already belongs to, so its directory was
scanned twice for every reference that is not in it, and one package
folder was probed once per assembly the package contains.
Measured over 27 references with a fresh resolver each time: 3.3 ms per
assembly before, 3.1 ms without a scope, 1.1 ms with one.
Assisted-by: Claude:claude-opus-5:Claude Code
Two things #2253 costs an exported application, both found by decompiling
one that was built from published source.
StartupUri is written in App.xaml but never reaches the BAML: the markup
compiler turns the attribute into an assignment inside
InitializeComponent, and the project decompiler deletes the generated
members. The exported application then builds and opens no window. The
assignment is read back out of the generated method and written to the
document root again. Where the compiler emits no app.baml at all -
App.xaml carrying nothing but attributes - there is no document to write
it on, and the export has no ApplicationDefinition either; that is a
larger gap of its own.
x:Name is recorded as the runtime name property of an element, which is
FrameworkElement.Name for everything WPF - a property of the framework.
Any property called "Name" on a type of the assembly being decompiled was
written back as the directive, so <local:Helper Name="theName" /> came
back as x:Name: the name gets registered and the property stays unset,
which still compiles and quietly means something else. The directive is
now written only for a name the element does not declare itself.
Assisted-by: Claude:claude-opus-5:Claude Code
.NET ships a WindowsBase facade on every platform. It resolves under the
name BAML means, so no synthetic stand-in was substituted for it, and it
carries none of the WPF types, because those live in the WindowsDesktop
runtime pack. System.Windows.Point and Size then resolved to nothing and
the whole resource was lost with a NullReferenceException - ten of the
BAML entries in one DevExpress theme assembly, on any machine without
WPF, which is every Linux and macOS user and every CI run.
A well-known assembly now counts as resolved only if it defines a type it
is expected to have, so a facade gives way to the stand-in the way an
assembly that does not resolve at all does. Nothing else changes: over
1158 documents of that assembly the output is identical, with the ten
that used to be lost added back.
Assisted-by: Claude:claude-opus-5:Claude Code
Metadata as attributes on an item element is MSBuild 15 syntax. The
non-SDK project format is what an export falls back to for toolchains
that predate the SDK, and those reject an unknown attribute on an item
element, so a Page item carrying Generator and SubType as attributes
undoes the reason to write that format at all. Every non-SDK project
written by anything else keeps metadata in child elements.
The SDK-style writer keeps attributes: there the syntax is a given and
it is what the format's own tooling produces.
Assisted-by: Claude:claude-opus-5:Claude Code
The project exporter wrote every XAML document to the project root under
a fully-qualified name while the code-behind class went into a directory
named after its namespace, so the two halves of one partial class ended
up in different places. WPF tooling pairs MainWindow.xaml with
MainWindow.xaml.cs by name and location; anything else is an unrelated
file to it, and --nested-directories made the split wider still by moving
only the C# half.
Both now go through one function that decides where a type's files live,
so the document lands where the type's own C# file would have, and the
code-behind is named after the document. The BAML writers of the UI and
of the command line had grown their own copies of the naming, which is
how they came to disagree with the C# writer in the first place.
Assisted-by: Claude:claude-opus-5:Claude Code
Two of the defects reported on issue #2253 come from the decompiler
writing text that means something else when it is read again.
A markup extension is written as a single attribute value, and its
grammar gives ',' '=' '{' '}' and the quote characters a meaning. Values
went out unquoted, so an argument carrying any of them was read back as
further name/value pairs: {DXBinding Expr='Price - Prev > 0 ? ...'}, the
reported case, no longer compiles at all (MC3042, MC3045). Values without
such a character stay unquoted, because quoting them would rewrite every
document that never needed it.
A clr-namespace declaration names the CLR namespace it maps, but nothing
read that name out of it, so no lookup by namespace could match a
declaration the document itself had made. Every type in such a namespace
then got a second prefix declared on the element that used it. The
assembly is the second half of the same lookup, and there the document
records the name it was written against while a well-known type carries
the assembly it resolves to now - "mscorlib" against
"System.Private.CoreLib" - so the two are also accepted as the same when
the recorded assembly forwards the type.
Assisted-by: Claude:claude-opus-5:Claude Code
ILSpy.xml is the only copy of everything a user puts in it - assembly
lists above all, which people build up over years and, as the report
shows, edit by hand. A file that fails to parse was replaced by defaults
on the next save, which happens for something as incidental as a window
position, so the data was gone before the user had a chance to notice
anything was wrong.
The file is now moved aside first, under a name that says what it is, and
an earlier copy is never replaced: two bad starts in a row must not cost
the file that still has the data. A typo in hand-written XML is usually
one edit away from readable, so what matters is that it still exists.
Telling the user is still not solved - that needs somewhere central to
report it from, which the settings do not have yet - but the file is
recoverable, and its name says why it is there.
Assisted-by: Claude:claude-opus-5:Claude Code
A WPF assembly keeps its windows and pages as BAML, so a project exported
without converting them back is missing the parts that make it a WPF
application - and the reader has no XAML to look at either. The CLI could
do the conversion since --decompile-baml was added, but only if asked,
which meant that everything the project exporter learned about WPF (Page
and ApplicationDefinition items, resources, generated members removed
from the code-behind) was invisible to anyone following issue #2253 from
the command line.
The flag is kept and ignored: it is documented and scripted against, and
asking for what is now the default has to keep working.
The test fixture is a real .g.resources container rather than a directly
embedded .baml stream, because only entries inside a container reach
WriteResourceToFile - a standalone .baml is copied out untouched, which
is worth its own look.
Assisted-by: Claude:claude-opus-5:Claude Code
The GAC probe only ever looked for the exact folder of the requested version.
For about a hundred assemblies the .NET Framework 4.7.2/4.8 reference assemblies
carry a higher version than the implementation ever installed in the GAC
(System.IO.Compression is 4.2.0.0 against 4.0.0.0 in the GAC, System.Runtime is
4.1.2.0, ...), because out-of-band packages shipped those versions and the ref
assemblies had to keep up. The runtime hides this behind assembly unification;
without an equivalent, every reference to one of them was reported as
unresolvable.
Matching on the major version keeps assemblies apart that share a name but are
different products, e.g. Microsoft.Build.Framework 4.0.0.0 and 15.x.
Assisted-by: Claude:claude-opus-5:Claude Code
The exporter dropped PresentationFramework, System.Xaml, System.Windows.Forms
and System.Drawing from every project it wrote, whatever the assembly used,
while a second list held the remaining WPF assemblies behind a WPF check. The
SDK draws the line elsewhere: Microsoft.NET.Sdk.WindowsDesktop.props promotes
the nine _WpfCommonNetFxReference items to _SDKImplicitReference only when
UseWPF is set, System.Windows.Forms only when UseWindowsForms is, and
WindowsFormsIntegration only when both are. A XAML-only assembly therefore lost
a reference that nothing supplied, and a WPF application that also used Windows
Forms lost the Windows Forms references while the project only said UseWPF.
Following the SDK there makes WPF and Windows Forms independent rather than
alternatives, which is what the flags enum is for: an assembly can use both,
and then both properties have to be written. Where an assembly looks like more
than one kind of project, the web SDK wins the Sdk attribute, because
Microsoft.NET.Sdk.Web imports Microsoft.NET.Sdk and so carries the desktop
targets, while Microsoft.NET.Sdk.WindowsDesktop carries no web targets.
System.Drawing stays unconditional: Microsoft.NET.Sdk.BeforeCommon.targets adds
it for every .NETFramework target rather than only for Windows Forms ones, and
on .NET Core it ships in the Microsoft.NETCore.App reference pack.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
An exported WPF project listed PresentationCore next to the implicit
Windows Desktop framework reference, which is a duplicate reference
(MSB3243) or an unresolvable one (MSB3245) once the hint path stops
pointing anywhere. The target-pack filter that should have caught it
asks the assembly resolver, which answers by probing the shared
frameworks installed on the machine running the export - so the same
assembly exported from Linux, or from a Windows box without the
desktop runtime, produced a different project file. What the SDK adds
for UseWPF is a fixed list, so match it by name instead.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Microsoft.NET.Sdk imports the Windows Desktop targets itself for .NET
Framework and for .NET 5 and later, and warns (NETSDK1137) about every
project that still names the separate SDK. Only .NET Core 3.x, where
those targets are not imported without a platform-suffixed moniker,
genuinely needs Microsoft.NET.Sdk.WindowsDesktop.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A .NET 5 or later project that sets UseWPF or UseWindowsForms is
rejected outright (NETSDK1136) unless its target framework names the
Windows platform, so an exported WPF assembly produced a project that
could not build at all. The platform belongs to the assembly rather
than to WPF - TargetPlatformAttribute records it, SupportedOSPlatform
its minimum version - so the moniker follows the attributes wherever
they are present, and falls back to plain "windows" only for a desktop
project built before those attributes existed. Monikers older than
net5.0 take no platform suffix and must not grow one.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The WPF markup compiler generates the program entry point from the
ApplicationDefinition item, so an exported project that lists App.xaml
as a Page has no Main at all and fails to build with CS5001. Both the
UI and ilspycmd already resolve the BAML root's partial class, which
makes deriving Application from System.Windows.Application the natural
signal. The module additionally has to have an entry point of its own:
a library that merely contains an Application subclass would otherwise
have MSBuild generate a Main into it.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Sanitizing a resource name is not injective: "a+b/logo.png", "a&b/logo.png"
and "a#b/logo.png" all come out as "a-b/logo.png". The writers create files
with FileMode.Create, so every colliding entry but the last was lost, and
nothing was written to the error list to say so - an assembly can be built
to make that happen to as many entries as it likes. A WPF probe assembly
with 20 resource entries exported as 13 files.
Uniquifying is enough because the exported item already pins the true name
in its LogicalName, so the file on disk has to be unique, not faithful. The
suffix search resumes where the previous collision on a name left off, so a
crafted pile of collisions stays linear rather than quadratic, and the name
is trimmed to keep the segment within the file system's limit.
Directory creation moves inside the per-entry error recovery for the same
reason: an entry named after a directory another entry needs makes it throw,
and that has to cost the one entry rather than the rest of the container.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A rebuilt WPF project only resolves its own pack URIs when every entry of
"<AssemblyName>.g.resources" comes back under the resource ID it had before.
The file on disk cannot carry that ID: it is sanitized for the file system,
and the ID itself is escaped. Verified against a WPF assembly built for this:
the WPF build tasks re-escape whatever LogicalName they are given, so the item
has to hand them the decoded name, and an entry left as EmbeddedResource
rebuilds into a manifest resource of its own instead of into ".g.resources".
The adjustment is made where the items are collected, so it covers the base
class and both hosts that plug their own BAML handling into it without
widening the WriteResourceToFile or IResourceFileHandler contracts.
The Resource build action this gives them is also what #2253 asks for.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The SDK-style writer only ever emitted EmbeddedResource items, so every other
item type the export produced never reached the project file. XAML recovered
from BAML lands in Page items: ILSpy wrote the .xaml files to disk and the
project referenced none of them, leaving an exported WPF project that cannot
rebuild.
Explicit items collide with the SDK's own globs - a UseWPF project globs
**/*.xaml into Page, and NETSDK1022 rejects the duplicate - so each include is
preceded by a remove of the same item type, the pattern the EmbeddedResource
path already uses. Setting EnableDefaultPageItems=false would work as well, but
it is WPF-specific and switches off a glob for the whole project, whereas the
remove is per item, applies to any item type, and keeps the SDK's item
definitions (XamlRuntime) in effect.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
WPF's build tasks key every Page and Resource item in
"<AssemblyName>.g.resources" by the item's relative path, lower-cased and
run through Uri.GetComponents(Path, UriEscaped) - so a folder named
"Resource Test" arrives as "resource%20test", and a folder named in any
non-ASCII script arrives as a run of UTF-8 percent escapes. Those escapes
are not part of the name; sanitizing them turned "resource%20test" into
"resource-20test" and any Chinese or umlaut folder into a line of hex.
Measured against a WPF assembly built for this: only space, '#', '{', '}'
and non-ASCII bytes are ever escaped, and Uri.UnescapeDataString is the
exact inverse - anything the escaper leaves alone contains no percent
sign, and a literal one arrives as %25. Only the WPF-generated containers
are affected, so a percent sign in any other .resources file stays part
of the name.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Roslyn passes a display class into a local function by ref, and a local
function that only forwards that parameter to a sibling has no closure
variable of its own. The closure analysis therefore found nothing to
anchor it and fell back to the root method body, which put it out of
reach of the callees it forwards to; CallBuilder then hit the assert
guarding a local function reference it cannot resolve and emitted the
raw metadata name of the target instead.
The constructor path also mixed use-site containers into a scope the
closure analysis had already determined; when the use-sites live in
separate function bodies there is no common container, and resetting to
the constructor body threw that scope away.
Assisted-by: Claude:claude-opus-5:Claude Code
Every mode loaded the input file with its own `new PEFile(fileName)` call, so a
bundle - which the UI opens fine, because LoadedAssembly goes through the file
loader registry - failed identically in all of them. The five call sites now
share one load helper that runs the same registry, which also makes archives
work and keeps one detection point for both front ends.
Which assembly inside a bundle is meant is not something to guess, so a package
without --bundle-entry lists its managed entries and exits non-zero. The entry
point is only annotated in that listing: the manifest has no marker for it, so
it is derived the way the host does it, from the runtime-config entry's name,
and stays unannotated where that does not resolve.
Assisted-by: Claude:claude-opus-5:Claude Code
The crash is a NullReferenceException in GetNodeByVisibleIndex, reached when a
background decompile realizes a node's children while the UI thread is indexing
the flattener. Eight ILSpyTreeNode.Decompile overrides call EnsureLazyChildren
from that task; two wrapped it in Dispatcher.UIThread.Invoke, six did not, and
one of the two lost its wrapper in the Avalonia port with no test noticing for a
release cycle. A rule every call site has to remember is a rule that gets broken
again, so EnsureLazyChildren marshals itself instead: SetOwner already named the
owning thread, and now also carries the host's way onto it. A call already on the
owner runs inline, so a blocking invoke cannot deadlock on itself and a nested
load costs no further hop; an unowned tree is left unmarshalled, which keeps
building a subtree on a worker and publishing it on the UI thread legal.
The affinity check stays as the regression detector, but its fail-fast throw was
worthless on its own: tree mutation happens inside callers that catch Exception,
so the throw ended up rendered into the decompiled output and the run passed. The
violation is now recorded before the throw, and an assembly-level NUnit test
action fails the test that produced one - an assembly-level teardown failure is
reported but leaves the exit code at zero.
Assisted-by: Claude:claude-opus-5:Claude Code
Issue #3290 is a NullReferenceException in GetNodeByVisibleIndex that is
provably unreachable single-threaded: TreeFlattener.Count and
GetNodeByVisibleIndex read the same totalListLength fields back to back, so a
stale index yields ArgumentOutOfRangeException, never an NRE. A stress harness
with reader threads racing an IsExpanded/Children mutator reproduces exactly
that NRE, so the crash requires a mutation from a foreign thread. The rule that
a displayed tree is only mutated from the UI thread was pure convention:
ICSharpCode.ILSpyX/TreeView contained no VerifyAccess, lock or dispatcher of any
kind, and two tree nodes already carry a Dispatcher.UIThread.Invoke workaround
for the same hazard, which means it has been hit before and fixed one site at a
time.
ICSharpCode.ILSpyX is host-agnostic and must not name a dispatcher, so ownership
is stated by the host instead of inferred: SetOwner(Thread) marks the thread
allowed to mutate a node and its subtree. Unowned means unchecked, which is what
makes the analyzer pattern legal - build a subtree on a worker, publish it on
the UI thread - without an exception carved into the rule.
The owner is resolved by walking up the model-parent chain to the nearest
explicit owner rather than stamped onto every node. That buys the propagation
rules for free: one call on the root covers the whole displayed tree, children
attached later inherit it with no bookkeeping, and a subtree built off-thread is
unchecked while it is being built yet inherits the owner the moment it is
attached - an attachment which is itself a checked mutation of the owned tree.
A subtree that already carries a different owner would otherwise leave one
displayed tree demanding two threads, so that case is reported once and the
incoming owner dropped, rather than reported on every later mutation. Re-owning
is allowed because handing a tree over is the point, but the handoff must come
from the current owner: a background thread taking a live tree away from the UI
is the race being hunted.
The check sits in SharpTreeNodeCollection.OnCollectionChanged, which every
Children mutator funnels through, and in the IsExpanded and IsHidden setters -
the three entry points that invalidate totalListLength. Checking in
OnCollectionChanged also means a violation is reported before OnChildrenChanged
rewrites the flat-list tree, so the AVL structure is left intact.
Violations are collected rather than fatal by default, with per-call-site
deduplication and a count, and the first hit of each site written straight
through to a log file so a long exploratory session can be read while it runs.
FailFast makes them throw so tests can observe one deterministically.
Everything is behind #if DEBUG plus [Conditional("DEBUG")], so the release build
has no field on SharpTreeNode and no call at any site; verified by decompiling
the release assembly.
Assisted-by: Claude:claude-opus-5:Claude Code
Issue #3290 reports a NullReferenceException inside
SharpTreeNode.GetNodeByVisibleIndex, reached from a WPF virtualizing panel's
measure pass. The frame is still live: the Avalonia SharpTreeView binds the
TreeFlattener straight to ItemsSource, so Avalonia's virtualization indexes the
same flat-list walk on every measure.
Driving the model directly shows the indexer cannot walk off the end on one
thread: TreeFlattener.Count and GetNodeByVisibleIndex read the same
totalListLength fields with nothing in between, so a stale index becomes an
ArgumentOutOfRangeException and never a null dereference. Randomized sweeps over
insert/remove/expand/collapse/hide/reparent found no state where the two
disagree. Only a mutation concurrent with the descent reproduces the reported
frame, and every tree mutation in the app is marshalled to the UI thread.
These tests pin the interleaving that the port changed: the flattened list
shrinking underneath a realized index range, and a lazy subtree loading and
reloading while scrolled. The first asserts the panel is actually virtualizing,
so it cannot quietly degrade into a non-virtualized run that proves nothing.
Assisted-by: Claude:claude-opus-5:Claude Code