The async stepping blob's first field is the compiler-generated catch
handler's IL offset plus one, and 0 when there is nothing to record. ILSpy
wrote the raw offset, so a consumer decoding it as (value - 1) resolved an
address in the middle of an instruction: Mono.Cecil throws
ArgumentNullException while reading the body, which is why the reported
assembly opened in ILSpy but killed ILLink on every MoveNext it had.
CatchHandlerOffset now holds the offset it is named after, or -1 for "none",
and BuildBlob applies the bias - the same model the compiler uses.
It is recorded only where an escaping exception is unlikely to be observed:
an async void method, and an async entry point. Recording it more widely
would be worse than recording it nowhere, because an async Task method
returns its exception through the Task and the debugger would then break on
exceptions user code catches. Measured over csc 1.3.2 to 5.10: async void is
handler+1 and a normal async Task is 0 in every version; only async Main
changed, from 0 to handler+1 between 2.10 and 3.11.
The entry point token names the synchronous '<Main>' shim that exists because
the runtime will not take .entrypoint on an async method, so the method to
record is the one the shim calls. Reading that call is exact; matching the
shim's siblings by name is not, and gets a 'Main' overload beside the real
entry point wrong.
Both tests compare against the compiler's own PDB for the same assembly, so
the blobs describe the same IL and the field compares directly.
Assisted-by: Claude:claude-opus-5:Claude Code
The PDB writer had no coverage beyond hand-written fixtures whose sequence
points are compared to the compiler's, and nothing ever asked whether a
consumer can read what it emits. Issue #2823 is the consequence: a PDB that
loads fine in ILSpy kills ILLink, and it took a reporter's own tool to find
out.
--pdb reuses the corpus, download and reference-pack machinery already in
nugetfuzz and replaces the type sweep with two checks: Mono.Cecil - the
consumer ILLink uses - has to read every method body through the generated
PDB, and a lint has to find everything the PDB claims true of the assembly.
--pdb-lint exists because a lint is only worth its findings if it is silent
on correct input. It runs the same checks against a PDB the compiler wrote,
and three of the checks written here were wrong until it said so - including
the async one, which flagged the compiler's own PDB for any executable with
an async Main, a case 190 nuget packages could not contain because a library
has no entry point.
Assisted-by: Claude:claude-opus-5:Claude Code
Switching to another assembly list replaces the whole tree, but nothing
is removed from the outgoing list - the list itself goes away - so no
collection event announces it and the selection, the open tabs and the
navigation history all kept describing a list that was no longer on
screen. The switch now says so with the same Reset that clearing a list
raises, which is the one path that already discards all of it.
Emptying a tab kept its title, because the CurrentNodes setter only
recomputes the cached base title and StartDecompile returns early with
nothing to decompile. A tab showing an empty document went on naming the
member it used to show.
A removal that took the selected node with it left nothing selected,
even with assemblies still loaded: the tree view picks the nearest
survivor for its own Delete gesture, but a removal from anywhere else
did not. The selection is handed over once the tree has caught up.
Assisted-by: Claude:claude-opus-5:Claude Code
A member ID was resolved against the loaded assemblies with the
reference assemblies filtered out entirely, so the assembly set a
project's references make up - which is what the VS add-in passes -
left every target unresolved. The lookup now prefers assemblies that
carry a body and falls back to the reference assemblies, which do
declare the member; the banner already says what the reader is looking
at.
An ID that names nothing anywhere used to leave the tree untouched and
say nothing, which is indistinguishable from a jump to the wrong place.
It now names the target and the assemblies that were searched, rather
than selecting an arbitrary one of them. The report prefers the pane the
jump would have filled and falls back to a tab of its own, because
ShowText writes to the active decompiler tab and does nothing at all
when the active content is something else - a metadata table, or nothing
yet at startup, which is exactly when this report is written.
The test fixture is this project's own reference assembly: the compiler
writes one carrying the ReferenceAssembly attribute and the same members
as the output, which is the pair a targeting pack and its runtime form,
and keeps the test off machine-specific NuGet paths.
Assisted-by: Claude:claude-opus-5:Claude Code
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
The order of a record's fields and properties has to be known, because
Equals, GetHashCode, PrintMembers and the copy constructor are recognised
by walking their bodies in lockstep with it. It was assumed to be every
property followed by every field, so a record that declares a field
before a property desynchronised all four at once: none was recognised as
generated, all of them were emitted, and the auto-properties lost their
backing fields to raw <Property>k__BackingField accesses - output that
does not compile.
The order is in the generated members themselves, but no single one has
all of it: Equals compares everything that carries state and never a
computed property, PrintMembers prints everything public and never a
private field. Both follow declaration order, so the two sequences are
merged along the members they share, which puts a private field and a
computed property back in the right places relative to each other.
Members neither of them mentions - EqualityContract, static members -
keep the position they had.
Where the two orders conflict, which can only happen for a member that
one of them never sees, the equality order wins; nothing in the metadata
says more, and the choice cannot change more than the order the members
are printed in.
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