The C# 5 compiler loads the awaiter into a fresh local before every
dynamic call site and before the ICriticalNotifyCompletion type test, and
the early non-aggressive inlining refuses to fold those copies. Every
await matcher identifies an await by the identity of its awaiter
variable, so each copy hid the awaiter from the matcher and the whole
await stayed undetected.
Each such copy has one store and one load, and after the dynamic call
sites are collapsed that load sits in the next instruction, which is the
case ILInlining.InlineOne already handles, including the check that the
source is not overwritten first. Folding is restricted to the IsCompleted
and GetResult call sites and the completion-interface type tests, so
dynamic calls in user code keep their locals: a blanket fold retypes
unrelated locals of the surrounding method (an enum local decompiled as
int plus a cast).
Two smaller divergences from the Roslyn shape sat behind that one: the
merge block of the AwaitOnCompleted/AwaitUnsafeOnCompleted diamond
clears doFinallyBodies before its leave, and the awaiter is restored
from its object-typed field with unbox.any rather than castclass.
The ILPretty fixture is the state machine from the issue's assembly with
its external types stubbed out, so the fix stays guarded where the legacy
compiler is unavailable. The Pretty fixture for dynamic await now runs
the pre-Roslyn configurations as well, which is the end-to-end guard on
Windows; its expected output records that the compiler copies an awaited
dynamic value into a local first and emits no debug name for it. The
dead stores an optimized build leaves before a try block containing an
await are not specific to dynamic - a plain await on a Task produces the
same three - so they sit behind an EXPECTED_OUTPUT-only block.
Checked against real legacy csc output (/o- and /o+): dynamic await in
plain code, loops, try/catch, try/finally and using.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
When an IfInstruction was created with resultType=bool, because only
values 0 or 1 are possible, then we need to preserve this property in
transforms -- otherwise the added correctness test would fail.
Declining every type-parameter operand was too broad. The reason a type
parameter has no lambda spelling is that `v == other` is CS0019 and boxing
both operands compares box identity where the tree compares values - and both
only apply while the parameter may be a value type. A parameter constrained to
a reference type compares as a reference, which is what the tree asks for and
what `t == null` spells, so it converts like any other reference comparison.
IsReferenceType is the distinction the type system already makes here:
TypeUtils.GetStackType maps a type parameter to Obj or VT by the same
question. An unconstrained parameter answers null and keeps declining.
Assisted-by: Claude:claude-opus-5:Claude Code
The decompiler emits comments - //IL_ warnings, "Could not convert
BlockContainer", "try-fault", a Nop's comment - as an EmptyStatement in the
middle of a statement sequence. Every transform that walks such a sequence
then stops recognizing its pattern the moment one of those lands in it:
constructor initializers stay in the body, `using var` and `for` are not
introduced, and a finalizer keeps its `override Finalize` shape, which does
not compile at all.
The destructor matcher moves the placeholders it skipped into the body that
replaces the old one, so the warning that caused the problem is not dropped
along with the statement carrying it.
Assisted-by: Claude:claude-opus-5:Claude Code
A constructor store to an auto-property's backing field is expressible
after the field declaration is gone in one of two ways: ReplaceBackingFieldUsage
rewrites it to an assignment of a setter-less property, or
TransformFieldAndConstructorInitializers lifts it into a property initializer.
A deconstruction target assigns several members at once, so it can never take
the second route, and a property that kept a setter would invoke that setter
instead of storing the field. Without the restriction the declaration is
removed while the store keeps referencing it.
Assisted-by: Claude:claude-opus-5:Claude Code
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
A constant narrower than its stack type builds as a plain ldc.i4, which
infers as int, so a conditional whose other branch really is a char saw
two different types and the whole tree was left as the Expression calls
that built it - EF Core's StringCharConverter.ToChar is one. Bool and
enum constants were already wrapped for this reason; the wrap now
applies wherever the built value does not infer as the declared type.
Assisted-by: Claude:claude-opus-5:Claude Code
The builder returned by ConvertLambda hands back null when a nested
conversion declines, and the result was cast and dereferenced before
anything checked it, so a tree the transform cannot handle took down the
whole method with a NullReferenceException instead of being left alone.
EF Core's StringCharConverter.ToChar is such a tree: the conditional
spills the Expression.Call arguments into stack slots, which
MatchGetMethodFromHandle does not see through.
Assisted-by: Claude:claude-opus-5:Claude Code
A hand-built Expression.Equal whose operands are a type parameter has no
lambda to decompile to: `v == other` is CS0019 for a type parameter, and
boxing both operands compiles but compares box identity where the tree
compares values once the parameter is a value type. The conversion now
declines, leaving the Expression calls that built the tree.
Found in EF Core's BoolToTwoValuesConverter<TProvider>.ToBool, which
decompiled to code that does not compile.
Assisted-by: Claude:claude-opus-5:Claude Code
The parameters standing in for the expanded arguments were built from
the element type of the array the compiler had built, not from the
element type the params collection declares. The two can only differ
where the collection is covariant in its element type, and no compiler
emits that shape today - it materializes the array into a local of the
target type first, which the pattern no longer matches - so this only
removes the dependency on that.
Where the params collection is one overload resolution cannot unpack,
the expanded form is now abandoned instead of being built from a type
that resolution would never have used.
Assisted-by: Claude:claude-opus-5:Claude Code
Resolving a method reference and resolving one by name and signature
differed in three ways that had no reason to differ: only the metadata
path found a static constructor, only it restricted the candidates to
the declared members, and only it matched a vararg signature against
its required parameters plus __arglist.
Assisted-by: Claude:claude-opus-5:Claude Code
MatchArrayInitializerFinal selects the operator by its return type, so
the block's result type is that return type. The declaring type only
happens to be the same one for the two array-to-span operators; Span<T>
also declares the conversion to ReadOnlySpan<T>.
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
An array initializer standing in for a Span<T>/ReadOnlySpan<T> reaches the
call builder as an implicit span conversion over the array creation, a shape
the params expansion did not know, so a params span argument came out as the
array the compiler had built rather than as the argument list that was
written.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The block addresses its allocation through the pointer localloc returns, and
only its result is a Span<T>. Retyping the initializer variable to the span
made every element store ask for a pointer it no longer had, which was papered
over with a conv from the span; once Obj and VT became distinct stack types
that conv had no conversion kind left and the whole method failed to decompile.
The span constructor becomes the block's final instruction instead, so the
element stores keep the pointer they were written against and the block still
evaluates to the span.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The Span<T>/ReadOnlySpan<T> initializer patterns replaced their call with an
array initializer block, so an array stood where a span was expected: the
enclosing leave and any call taking the result saw StackType.Obj against the
StackType.VT the span type demands. The block now ends in the implicit
conversion the C# compiler applies, which is the one shape besides a bare
ldloc that an array initializer may take; the expression builder keeps the
conversion out of the output but not out of the expression's type.
Naming that operator wants a method looked up by signature rather than by a
predicate over the type's members, so MetadataModule grows a ResolveMethod
overload for it, sharing its signature matching with the metadata path.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The Span<T>/ReadOnlySpan<T> initializer patterns replaced their call with an
array initializer block, so an array stood where a span was expected: the
enclosing leave and any call taking the result saw StackType.Obj against the
StackType.VT the span type demands. The conversion the C# compiler applies is
the implicit operator, and it has to wrap the block rather than sit inside it,
because an ArrayInitializer block must keep ldloc as its final instruction.
Without the operator the conversion cannot be expressed at all, so the
original call is left untransformed instead.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A conversion of a small integer type to Int32 returns its operand unchanged,
because such values already occupy an I4 stack slot. The two operands of
`(short a, int b) => a + b` are therefore Int16 and Int32, and requiring them
to be equal rejected the conversion; the whole expression tree was then left
untransformed, or worse, aborted the enclosing method. What
BinaryNumericInstruction requires of its operands is a common stack type.
TryConvertExpressionTree also has to cope with a builder that fails, rather
than dereferencing the lambda it did not get.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
An unconstrained type parameter has no known IsReferenceType, and since the
stack types were split it takes StackType.VT, so a call on it reached
CallInstruction with a VT 'this' argument where Obj was expected. It might be
a value type at runtime, so it needs the same box a known value type gets.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
An expression tree leaves the boxing of a value-type receiver implicit: it
carries no Convert node for it, because the boxing follows from the method
being declared on a reference type. Enum.HasFlag invoked on an enum value is
the common case, and it reached CallInstruction with an I4 'this' argument
where Obj was expected, which aborts a debug build outright.
Deciding by the target's own type also retires the StackType.VT arm, which
ExpectedTypeForThisPointer never returns. Since box records the type of what
it boxes, an expression-tree cast to that same type in front of it is dropped.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A conversion from a value type to a reference type produced an opaque cast, so
no box instruction appeared anywhere in the converted tree, while the same C#
compiled as a plain lambda yields box T. The operand type is what box takes,
and it is available from the converted operand.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Compiling the same C# twice, once as Expression<Func<...>> and once as a plain
Func<...>, and diffing the two ILFunction bodies exposes where the conversion
reconstructs something the IL reader would never build. Three such cases:
The sign is part of the opcode only for the checked add/sub/mul and for
div/rem/shr; ILReader leaves it at Sign.None elsewhere, while the conversion
took it from the operand type unconditionally.
Expression.MemberInit is an object initializer, not a collection initializer.
Expression.Convert's three-argument overload carries the user-defined
conversion operator - which is how the decimal conversions are encoded - and
that argument was read by nothing, so every such conversion collapsed into an
opaque cast that dropped the method. Emitting the call matches what the
transform already does for decimal arithmetic.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Every converter now states the Expression.* call it matches and the ILAst it
produces, and the argument-count switches label the factory overload each case
stands for. The shapes were read off ILAst dumps of compiled expression trees
rather than from the factory signatures; two branches are documented as
unreachable, since no arithmetic or logical factory declares the four-argument
(left, right, liftToNull, method) overload their case matches.
Also drops the result-type local left in ConvertField, which BuildField
re-derives from the field and the type hint.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
ConvertConstant is its only caller, and with the result type gone the
out parameter that reconstructed it - a switch over LdNull/LdStr/Ldc* -
has no consumer. What remains is a match condition: the two-argument
Expression.Constant overload must pass its type as typeof(T), while the
one-argument overload legacy csc emits for display-class instances has
nothing to check.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Every converter handed back a (Func<ILInstruction>, IType) pair, and the
IType was consumed by matchers that ran before the builder. Now that the
ILAst instructions carry their own types, InferType() on the built operand
answers the same questions, so the type-dependent decisions move into the
builders and the pair collapses to the builder alone. Builders that could
already fail (ConvertArrayIndex) set the precedent for returning null from
inside one; ConvertInstruction propagates that.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
ConvertBind is the only expression-tree converter whose IType nobody reads:
ConvertMemberInit, its sole caller, takes Item1 and discards the rest, and
the member type is recoverable from the Call/StObj it builds anyway.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
System.Linq.Expressions resolves the user-defined operator behind a binary
factory by metadata name (Expression.Add looks up op_Addition), and the
checked factories reuse the unchecked names: AddChecked also looks up
op_Addition, never op_CheckedAddition. Recording that name at the call site
keeps the mapping next to the factory it belongs to.
Assisted-by: Claude:claude-opus-5[1m]: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
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
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