This commit also changes the ILAst representation of with-initializers to preserve the `<Clone>$()` call, so that BlockKind.WithInitializer only forces the block into a certain shape, without assigning its special semantics.
Non-UI decompilation runs do not consume debug steps, but debug builds still allocated steppers and step nodes unless each context opted out manually. Keep step contexts empty unless the caller enables RecordSteps, while preserving the shared stepper path for the UI debug-step view.
Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
CleanUpVariableName sits on the per-variable naming path of every
decompiled method and allocated up to three intermediates (backtick
cut, m_/_ prefix strip, lowercase-first concat) before producing its
result, and its callers added further throwaway substrings when
stripping get_/set_/Get/Set and interface-I prefixes. The cuts are now
slices over the original name: ContainsNonPrintableIdentifierChar and
IsValidName gained span overloads, and only the final lowered name is
materialized, in a single allocation via char[] (netstandard2.0 has no
string(span) constructor). IsKeyword keeps its string parameter - it
is called on that final string anyway, and the keyword HashSet has no
span lookup on netstandard2.0.
Assisted-by: Claude:claude-fable-5:Claude Code
The loop preceding the parse has already proven that the tail consists
solely of ASCII digits, so the Substring+int.TryParse pair only
re-validated them at the cost of a throwaway string allocation.
SplitName runs per variable and per reserved-name registration for
every decompiled method, making this one of the hottest Substring call
sites in the decompiler. Accumulating the digits inline keeps the
TryParse overflow semantics (fall back to number=1 and the unchanged
name) without allocating.
Assisted-by: Claude:claude-fable-5:Claude Code
Inlining may extend a named-argument block: CanExtendNamedArgument moves an
expression into the first named-argument store (or the second one for an
instance call) and checks reordering against the stored values. The reverse
move was refused: Block.CanInlineIntoSlot rejected every slot of a
CallWithNamedArgs block, and CanMoveInto checked reordering against the whole
store instruction, which counts as a write to a local. The foreach pattern
relies on CanUninline to pull the Current getter back out of the loop body's
first statement, so once the getter had been inlined into a named-argument
block the loop was emitted as using + while (MoveNext()). Both checks now
mirror what inlining allows, so an expression inlined into a named-argument
block can always be un-inlined again.
Assisted-by: Claude:claude-fable-5-1:Claude Code
VB can lower event-handler closures through generated wrapper methods, closure copy constructors, and immediately invoked VB anonymous delegate helpers. Recognizing and unwrapping those shapes leaves the expected lambda form and lets captured locals be scalar-replaced instead of exposing generated closure artifacts.
Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
Avoid reusing a temporary receiver's stored value for extension-method lookup when its inferred source type no longer matches the local type.
Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
Roslyn can lower mostly constant arrays with a static data blob and leave non-constant elements as explicit stores. Merge those trailing stores into the decoded initializer so the decompiler keeps a single array initializer instead of splitting the expression.
Assisted-by: OpenCode:openai/gpt-5.5:opencode
Captured locals can first be named while visiting a nested local function even though their declaring ILFunction is the parent. Reserving such generated names only in the nested scope lets sibling captured locals reuse the same name and later collide when declarations are hoisted.
Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
Null propagation must not render an extension-method conditional access when normal member lookup would bind to a non-method member, because that produces invalid C# for ambiguous Count-style cases.
Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
Recognize Roslyn's null-terminated backing-store shape for u8 literals so escaped control bytes decompile back to UTF-8 string literal syntax, fix LdStrUtf8 to infer ReadOnlySpan<byte> in both the template and generated code, and cover overload-resolution cases where the explicit span conversion must be preserved.
Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
Expression-tree binary operators can infer nullable results even when one operand type is unknown. Preserve that result type explicitly and avoid rendering Nullable<?> as nullable syntax.
Assisted-by: OpenAI:gpt-5.5:OpenCode
Fix#4174 by bailing out of expression-tree call reconstruction when a converted argument cannot be built, and allow conditional operands that unify through an implicit reference conversion.
Assisted-by: OpenAI:gpt-5.5:OpenCode
Enable nullable analysis for the expression-tree transform independently from the behavior fixes, so later commits can show the decompilation changes without annotation noise.
Assisted-by: OpenAI:gpt-5.5:OpenCode
An index initializer is written as an element access even for a
parameterized property, which has no access syntax of its own, so
BuildDictionaryInitializerExpression routes one through the accessor
setter path regardless of symbol kind. The guard that stops an access
from dropping every argument only recognised indexers, so an index whose
argument repeats its declared default was left out, the element access
collapsed to a member assignment, and the placeholder target leaked into
the output. VB emits exactly this shape.
Which parameters carry the names, and which names can be written, were
decided in two places: NamedArgumentTransform admitted a reordering when
the accessor's parameters all had some name, while CallBuilder wrote the
names from the indexer's parameters and only when they were valid
identifiers. Where the two disagreed the reordering was kept and the
names dropped, leaving the arguments positional in the reordered order -
a silent change of meaning. Both now read one list under one predicate,
and a call whose names cannot be written is not reordered at all. The
value parameter is no longer consulted, since it is never named; COM
interop declares indexers that leave it unnamed.
The argument-list narrowing in Apply(CastArguments) is dropped again:
every step list runs NoOptionalArgumentAllowed first, so nothing is ever
left out by the time the casts go on.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
C# allows named arguments in an element access, but NamedArgumentTransform
refused to introduce one for any accessor, so an access whose arguments the
compiler reordered came out as a temporary. Only indexers gain this: a
property access has no argument list, an operator cannot take names, and a
setter's value stays unnamed on the right-hand side, which also keeps it last
in argument order for everything that identifies it by position.
Introducing a name replaces the call with a block, so it is refused where the
surrounding instruction requires the call itself - a call-inline-assign block,
or the target of a compound assignment.
A name in an element access names a parameter of the indexer, which the type
system takes from the getter; the accessor being called may name the same
parameters differently. C# cannot declare that, but other languages can, so
the names are read off the indexer. The names also have to stop where the
written arguments do, which is now done once where they are built rather than
again inside the ambiguity check.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Cover the method body shapes from the PR review and remove the extra field-load guard so discarded conversions are kept as allocations without preserving the cache check.
Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
CachedDelegateInitializationWithField only fired when the instruction
after the caching `if` read the cache field exactly once. A discarded
conversion -- `_ = (Action<int>)M;` -- reads it zero times: Roslyn emits
the null check and the store, and nothing else. The transform bailed out,
so the `<>O` cache class and its `<0>__M` field survived into the output
under names no C# compiler will accept.
Zero usages is now handled: the `if` is replaced by the delegate
construction it guarded, which is what the source expressed, and the
caching disappears with the field. The rest of the method is checked for
loads of the same field first, since only then is this `if` the sole
initialization.
Closes#3965
Only vbc's On Error lowering produces this filter; structured
Catch...When filters call SetProjectError and never reach it. Roslyn
emits it from a single code path with System.Exception as the type, and
legacy vbc output has the same shape, so there is no chain of unknown
conjuncts to walk. The transform runs before the expression transforms
canonicalize comparisons, so the filter still compares with cgt against
ldnull and cgt.un against zero: the ldnull comparison is fixed up first,
and MatchCompUnsignedZero accepts both the unsigned and canonical form.
Assisted-by: Claude:claude-opus-5:Claude Code
vbc builds an exception filter as a single non-short-circuiting
expression: `isinst`, then the user's `when` conditions, combined with
`and`. DetectCatchWhenConditionBlocks only knew the branch chain csc
emits, so the type test stayed inside the filter and the handler kept the
`object` variable it has in IL:
catch (object obj) when ((obj is Exception) & (num2 != 0) & (num == 0))
{
ProjectData.SetProjectError((Exception)obj);
A catch type has to derive from Exception, so that does not compile.
The conjunction form is matched too now, lifting the test to the catch
type as the block form already does. The other conjuncts stop running for
a non-matching exception once the test moves, so the filter has to be
pure for this to be invisible; PropagateExceptionVariable then drops the
castclass in the handler:
catch (Exception ex) when ((num2 != 0) & (num == 0))
{
ProjectData.SetProjectError(ex);
Closes#3659
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
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 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