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
Invariants that involve types (stack type of a variable against its IType,
the element type of an array access, the operand types of a comparison)
need a type system to resolve them against, and the only correct one is the
type system the instruction tree was decoded with. Until now CheckInvariant
took only the phase, so such a check had no compilation to use:
DeconstructInstruction.CheckInvariant called IsAssignment with a null type
system, which only held up because the targets it sees are ldloc, whose
InferType never touches the compilation; a ldflda-wrapped or pointer target
would have failed inside the invariant instead of reporting a violation.
Every call site already has that type system in scope: the ILReader's
compilation, the ILTransformContext of the running transform, or the
decompiler's own IDecompilerTypeSystem. It is now passed explicitly and the
base implementation asserts it is present, so a future invariant can rely
on it without re-plumbing the callers.
Assisted-by: Claude:claude-fable-5:Claude Code
A local function nested in a lambda can capture closures at two depths:
csc emits it as an instance method on the enclosing method's display
class that takes the lambda's display class as a parameter. Combining
those two capture scopes with FindCommonAncestorInstruction picked the
enclosing method, moving the function out of the lambda that owns the
deeper closure; the variables captured there were then unreachable and
the display-class parameter survived into the output as an undeclared
identifier. Nested capture scopes resolve to the innermost instead,
which a local function can always see - it reaches the outer closure
through the display class it is declared on.
Assisted-by: Claude:claude-fable-5:Claude Code
HandleConditionalOperator collapses `if (c) a = x; else a = y;` into a
conditional operator, innermost first, and keeps going for as long as the
chain does. A source else-if ladder therefore comes back as one expression,
however long it was: the sample in #2027 decompiles to a single
2095-character statement, and one NLog method to 2230 characters nested 29
brackets deep.
ExpandNestedConditionals undoes that past one level, so a statement keeps at
most a single conditional operator.
It runs at the end of the pipeline rather than inside ExpressionTransforms,
because every transform that needs its input to be a single expression has
to see the collapsed form first: object and collection initializers, `with`,
switch expressions, interpolated string handlers, and the query lambdas the
C# stage later rewrites into clauses. Cutting the chain earlier leaves an
if-else between the statements they pattern-match on and they silently stop
matching - an object initializer assigning an init-only member then does not
even compile. The same reasoning ReduceNestingTransform gives for walking
back ConditionDetection's aggressive else-inlining once the structure is
settled.
A chain already stored to a variable is expanded into that variable, so
nothing has to be decided: the variable carries its own type. A chain in any
other position - an argument, a return value, a field store - has nothing to
expand into, and ILExtraction can give it one. The temporary ILExtraction
creates is typed from the stack type though, where `I4` is `int`, `bool`,
`char` and every enum at once, so extracting on that basis turned a bool
into `int num` with `if (num == 0)` and an enum into
`dbType = (IsFixedLength ? 22 : 0)`.
InferExpectedType is the counterpart to InferType that answers this: where
InferType asks what a value is, it asks what the position the value flows
into says it should be - a parameter, a return type, a field, all of which
carry their type in metadata. Extraction is done only where that question
has an answer, and the temporary is typed from it. The receiver of a call
then reads `XPathNavigator xPathNavigator`, not `object obj` with a cast
back, and a field store keeps its enum's member names.
The Pretty fixture covers what must NOT change: an array initializer, a
query lambda, a ref local, a switch expression, an object initializer with
an init-only member, a `with` expression, a catch-when filter and both
constructor-initializer forms. The positive case is an ILPretty test,
because a Pretty fixture is its own input and expected output, and a chain
that round-trips through collapse and expansion has no fixed point there.
The PdbGen test records the cost in breakpoints: the compiler's single
sequence point for the collapsed statement becomes one per expanded
statement, which is inherent to splitting a statement in two.
#2027
Assisted-by: Claude:claude-opus-5:Claude Code
A dynamic call site that names a type passes typeof(T) as its target. When
a later argument contains control flow, the compiler stores that typeof in
a temporary. ILInlining does not undo this: it inlines a store only into
the immediately following instruction, so an intervening statement leaves
the typeof behind, and the expression builder printed the temporary instead
of the type. Substituting the typeof back into the target slot is not an
option either, because a call there has side effects and blocks inlining of
the remaining arguments.
The type is therefore stored on the instruction. Object creation already
did this, but kept the field private, so the expression builder re-derived
the type from the target argument and failed on the spilled form. Member
invocation gets the same field. Both drop the target from Arguments, so the
dead-argument handling removes the store; ArgumentInfo keeps its entry for
the target, because the invocation symbol is built from it.
The type of an object creation is not nullable: the transform returns
before constructing the instruction when it cannot match the typeof, and
substitutes the unknown type otherwise. An unresolvable type therefore
reaches the expression builder as the unresolved type it is, and prints as
`?` like any other, rather than being indistinguishable from a missing one.
Those two are also the only binder method kinds that ever see
CSharpArgumentInfoFlags.IsStaticType. Every other way of naming a type as
the receiver binds statically and leaves only a conversion of the dynamic
operand behind.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
FindRefStructParameters dropped generic instantiations, so a parameter
typed 'ref <>c__DisplayClass0_0<T>' never reached RefStructTypes. Both
consumers therefore missed local functions whose declaring type or method
is generic: the signature test for an obfuscated local function, and
LocalFunctionNeedsAccessibilityChange, which left such a function internal
while its closure struct stayed private - the recompiled output then fails
with CS0051.
Cross-module signatures still drop out, because the generic type part of an
instantiation goes through GetTypeFromReference, which returns nil.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A Roslyn local function name is "<caller>g__name|x_y", where the trailing digits
are a synthetic disambiguator that SplitName has to strip before the scope-local
renumbering can run. An obfuscated name has no such suffix, so running it through
the same path renames "smethod_1" to "smethod_" -- a needless second mangling on
top of what the obfuscator already did.
#3202
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Obfuscators strip the CompilerGeneratedAttribute and rewrite the
"<caller>g__name|x_y" name, which is all IsLocalFunctionMethod had to go on.
The method then stays an ordinary static method, its display struct escapes by
ref into a plain call, and TransformDisplayClassUsage correctly refuses to
scalar-replace it -- so the closure fields leak into the output as
"<>c__DisplayClass29_0_.iid" (issue #3202).
The one marker an obfuscator cannot remove is the signature: Roslyn emits
struct closures exclusively for local functions, and no hand-written C# can
name a "<>c__DisplayClass" type, so a by-ref parameter of one identifies the
method regardless of what it is called.
#3202
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Below C# 7 ref locals are unavailable, so CopyPropagation is allowed to copy
LdFlda/LdElema. When such a copy lands in a StObj target slot whose value is
impure, it violates the invariant checked by StObj.CheckTargetSlot: C# computes
the value to be stored before dereferencing the target, so the exception moves.
ILInlining resolves the same conflict by marking the address as delayed rather
than falling back to a ref local; copy propagation now does the same, which
keeps the generated code unchanged and only repairs the IL.
Unlike inlining, copy propagation has no third arm to fall back to: by the time
DoPropagate runs, the defining store is about to disappear, so every load has to
be replaced and refusing the copy is no longer an option. That decision can only
be made up front, which is what CanPerformCopyPropagation does when ref locals
are requested. The assertion covers the remaining hole, the public Propagate()
entry point, which bypasses that check -- AsyncAwaitDecompiler copies an ldflda
of the builder field through it irrespective of the setting.
Propagating an address also un-inlines its arguments into fresh stack slots, and
those stores are copy-propagation candidates in their own right. They are
inserted before the store being replaced, so the block scan used to step right
past them: a slot loaded more than once could never be inlined back and survived
into the output as a ref local -- for `s.ShortField >>>= 5` the copied ldflda
left behind `stloc C_0(ldloca s)`, printed as `ref CustomStruct reference = ref
s`. Rewinding the scan to the first of those stores lets them propagate too.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Both types are consumed well outside the C# output layer - DecompileRun
carries the using scope, and the IL transforms build a resolve context from
it - so living in ICSharpCode.Decompiler.CSharp.TypeSystem misrepresented
where they belong and forced a C#-specific namespace import on every
consumer.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A writable ref-struct argument can receive narrower values through regular ref/out calls, and a ref-return can expose the same storage for field mutation. Treat those paths like receiver captures so inferred declarations remain compilable.
Assisted-by: Copilot:gpt-5.6-sol:GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5d30b7a7-983d-4efa-8d99-fbface5828dc
Local scopedness is erased from IL and PDBs, so it can only be recovered
from the body. Compare each declaration initializer with later assignments,
field stores, and receiver captures using the C# 11 ref/value escape rules,
and emit scoped only when a later operation is strictly narrower.
Assisted-by: Copilot:gpt-5.6-sol:GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 86d2918e-5a24-48b4-9a86-41d331ec3720
Backing-field references inside a property's own get/set/init accessors
are emitted as the `field` keyword at IL-to-AST translation time
(ExpressionBuilder.ConvertField), so arbitrary accessor bodies become
expressible and no separate rewrite pass is needed. Compiler-generated
trivial accessors then collapse individually to `get;`/`set;`, which
also handles mixed shapes like `{ get; set { ... field ... } }`; the
backing-field declaration is removed with its remaining attributes
re-hosted as `field:` sections, and constructor stores become property
initializers (or property assignments, for setter-less properties).
Implicit zero-stores that auto-default struct constructors emit for
unassigned backing fields are dropped rather than lifted.
Recognition stays AST/metadata-based rather than mirroring the ILAst
analysis used for automatic events: events must prove compiler-generated
bodies before discarding them, while the field keyword discards nothing,
so name association plus the accessor context is sufficient.
Below C# 14 (or with the new FieldKeyword setting off), the field
declaration survives under its metadata name, so the UI keeps showing
the truth; EscapeInvalidIdentifiers - the transform the compilable-output
flows (project export, VS, tests) already add - now maps
`<P>k__BackingField` to the readable `P__BackingField` instead of the
generic character escape. A genuine field literally named "field" is
qualified as `this.field` inside accessors, and locals are not named
"field" there, since C# 14 rebinds the bare identifier. Bodiless
accessors mixed into multi-line properties get their own line in the
output.
The fixture covering the feature surface lands with the implementation rather
than as a separate xfailed commit. It is excluded from the test-assembly
compilation because its nullable annotations would trip warnings-as-errors
there.
Assisted-by: Claude:claude-fable-5:Claude Code
Under UseLambdaSyntax, anonymous functions became lambdas only when an
expression body was possible; statement-bodied ones kept C# 2 delegate
syntax. Now every anonymous function whose parameter shape a lambda can
express uses lambda syntax; delegate syntax remains for ref/out/in and
params parameters and for pre-C# 3 language profiles.
Two latent issues surfaced by the wider lambda coverage: DeclareVariables
assumed an insertion point directly under a LambdaExpression is an
expression body it must convert to a block, which block-bodied lambdas
now violate; and anonymous methods declared without a parameter list
carry compiler-generated parameter names like '<p0>' that are not valid
identifiers, so the lambda's mandatory parameter list regenerates such
names from the parameter type: (object obj, EventArgs e) => ...
A side effect visible in fixtures: an explicit parameter list can make
a delegate-creation cast redundant that bare 'delegate' syntax needed
for overload resolution, e.g. new Thread((ThreadStart)delegate { })
becomes new Thread(() => { }).
Assisted-by: Claude:claude-fable-5:Claude Code
Element 8+ of a long tuple is read through the Rest field, which Roslyn
loads by value; ILSpy turned that into an addressof over the loaded
copy, hiding the tuple field chain from every downstream matcher. Elide
the copy when the enclosing expression only reads through it - the read
then goes directly through the original address and folds into the
usual Item_N chain. The deconstruction transform's use-shape guard also
learns to walk that chain; whether each read really is a consumable
element access remains the job of MatchTupleElementRead and the escape
check.
Assisted-by: Claude:claude-fable-5:Claude Code
Deconstruction assignment copies the right-hand side into a temporary
before calling Deconstruct. When the RHS is a call, inlining folds that
temporary away, but for a local or parameter it survived into the
output as a separate assignment statement. Consume the copy into the
deconstruction pattern; rendering the copied value as the RHS
recompiles to the identical temporary. Because blocks are processed
back to front, the call-position match defers to the attempt starting
at the copy, mirroring the existing nested-deconstruction defer guard.
The new fixture also covers deconstruction assignment to locals
captured by a lambda in an async method (issue #3037's crash shape,
already fixed earlier).
Assisted-by: Claude:claude-fable-5:Claude Code
C# has no ref-returning switch expression. Skip the transform for StackType.Ref and cover the statement form in RefLocalsAndReturns.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0dd407b6-9410-48df-add5-761ca4a8dec0
A nested designation whose temporary is still read elsewhere is retried with
that variable demoted to a designator leaf. The check that the first tuple
element must be assigned ran before that retry, and every leaf of a wrongly
nested first element precedes the assigned ones, so the pattern looked like it
started mid-way and was rejected before the retry could restore it. The flat
deconstruction was lost for a shape that has one.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Deferring an inner deconstruction to its enclosing one used to be decided by
matching the enclosing pattern in full, once per inner statement of the same
pattern, discarding everything but the end position.
The same decisions are available without it. A nested Deconstruct call can only
be consumed by an enclosing one that is the immediately preceding statement,
looking through the defensive copy of a struct element; anything else in between
is a barrier that stops the enclosing from reaching this position, so it matches
here instead. That leaves the case where the enclosing call is adjacent but
cannot match anyway, which is decided by the constraint MatchDeconstructionCall
already places on its out-parameters.
The tuple-designation branch no longer needs the position the enclosing run
starts at, so the backward walk that searched for it is gone with it. The added
fixtures pin reconstruction across adjacent deconstructions, whose element
stores that walk used to step through.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Only defer to an enclosing designation that can reach this position
The temporaries and element reads of a nested tuple designation are stored back
to back, so a statement of any other kind between the temporary and a read of it
stops the enclosing pattern from consuming that read. Deferring anyway lost the
deconstruction entirely: the enclosing attempt fails and the back-to-front walk
does not return to the position that stepped aside for it, so the reads were left
as the plain element accesses they came from, which master reconstructs.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A nested designation over tuples, var (x, (a, b)) = t;, is lowered to one
temporary per nested designation - parents before children - followed by the
element reads in depth-first leaf order, and decompiled as a flat
deconstruction plus separate element statements.
The temporaries are now consumed into a tree of tuple nodes before the
conversions and assignments are matched, and the leaves get the same flat
depth-first indices the Deconstruct-call chain hands out, so conversion and
assignment matching runs unchanged. Two properties of the lowered IL shape
the matcher to it: earlier transforms rewrite non-escaping element reads
from ldloca to ldloc, and the temporaries are stack slots whose type is
imprecise, so the container's element type is authoritative and the match
variable is retyped to keep the tuple pattern's invariant.
An element that escapes the deconstruction - used after the statement, so
the pattern cannot consume all its reads - demotes back to a designator leaf
and the match is retried, which restores the flat deconstruction the
escaping read needs. The guard against consuming a pattern piecemeal extends
to the new shape: an element read whose container is stored by an earlier
element read defers to the match starting at that store.
Assisted-by: Claude:claude-opus-5:Claude Code
Element index resolution serves both pattern roots: a registered result of a
Deconstruct call, or an element read of a tuple, which it discovers on first
sight and then owns. In an attempt rooted in a Deconstruct call the tuple
branch must not engage - it overwrites the call's result bookkeeping and
rewires the element read to a fresh variable that the pattern never defines.
The shape that reaches it is a tuple whose element is custom-deconstructed
with discarded leaves, followed by an unrelated assignment: the tuple-rooted
attempt fails, the call-rooted one runs at the element's position, and, now
that an unrelated assignment ends a call pattern instead of rejecting it, the
mixed match is no longer rejected on the way out.
Assisted-by: Claude:claude-opus-5:Claude Code
A nested designation rebinds Deconstruct on the element's static type when
the output is recompiled, while the explicit call it replaces is bound at
the call site. Where a derived element type declares a Deconstruct of the
same arity as the called method, and the source deconstructs through a
base-typed view, the two bindings differ, so the sugared output calls the
wrong method - a divergence the runtime fixture demonstrates on optimized
builds, where copy propagation elides the view.
Nesting is therefore only applied when the method the call binds to is the
one a designation would rebind to; otherwise the call stays explicit, where
its receiver cast preserves the binding.
Assisted-by: Claude:claude-opus-5:Claude Code
A nested designation, var (x, (a, b)) = o;, is lowered to a chain of
Deconstruct calls - the inner call taking the outer call's out-argument as
its target, through a defensive copy where the element is a struct - and
decompiled as a flat deconstruction followed by an explicit Deconstruct
call. The IL pattern node, its invariants and the C# builders already
support nested patterns; only the transform never built them.
MatchDeconstruction now consumes the chain into a tree of match patterns.
The leaves get flat indices in depth-first order, which is the order in
which StatementBuilder and ExpressionBuilder pair pattern variables with
assignments, so the conversion and assignment matching runs unchanged on
top of a nested pattern.
Two matching rules follow from the chain being consumed: a call pattern no
longer needs a matched assignment, because single-use leaves are covered by
the forwarding fixup in MatchAssignments; and a pattern is not rooted on an
element of an enclosing deconstruction, because blocks are processed back to
front, so the inner call is visited first and would otherwise consume the
pattern piecemeal, starving the outer call. That guard runs the enclosing
match as a dry run, which is precise: a barrier statement between the calls
or an element with further uses makes it fail, and the inner deconstruction
is then still transformed on its own.
Assisted-by: Claude:claude-opus-5:Claude Code
An assignment whose value is not one of the deconstruction's elements used
to reject the whole match, so a custom deconstruction followed by any
unrelated assignment stayed an explicit Deconstruct call. For a pattern
rooted in a Deconstruct call the element list is fixed by the call's
out-arguments, so such an assignment simply ends the pattern and stays after
the deconstruct instruction.
Tuple-rooted patterns keep rejecting: their element list is discovered from
the assignments, so ending early would misread a suffix of the assignments
as the whole pattern and fabricate discards for the elements before it.
Assisted-by: Claude:claude-opus-5:Claude Code
The transform is about to be extended substantially; annotating it first
keeps the null contracts of the matcher explicit, where "no match" is
expressed by a null out-argument throughout.
The matching state fields are non-null only while a match is in progress,
which the codebase's null! idiom expresses; MatchConversion additionally
gets the null check its caller's ElementAtOrDefault already implies.
Assisted-by: Claude:claude-opus-5:Claude Code
A C# anonymous type is immutable and compares every member. VB's are neither
unless every property is declared 'Key': otherwise the properties are settable
and only the 'Key' ones take part in Equals and GetHashCode. Writing such a
type as 'new { ... }' silently gave it value equality and made any assignment
to one of its properties fail to compile, so only an anonymous type with no
settable property is treated as one; the rest keep their own declaration.
Those declarations carry the shape VB gave them, so the round-trip preserves
both mutability and 'Key' equality. Their names are the remaining obstacle,
since the VB compiler separates the parts with '$': the type, its backing
fields and any local named after it are renamed to use '_' instead, and a
comment on the declaration says why the type is spelled out.
Generated variable names are now rejected when they would not be legal C#
identifiers, which also stops a display class from lending its unspeakable
name to a local in the NoLocalFunctions output.
Assisted-by: Claude:claude-fable-5:Claude Code
HandleSimpleArrayInitializer multiplies the array dimensions to size the list
it collects elements into. The dimensions come from the input assembly and
need not multiply within int range, and ICSharpCode.Decompiler is built with
CheckForOverflowUnderflow, so an implausible pair of dimensions aborted
decompilation of the whole member. The product is only a capacity hint, so it
can saturate.
Found by fuzzing nuget.org; reproduces on obfuscated assemblies.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Optimized code stores no temporary for a deconstruction element that is
used only once after the deconstruction. MatchAssignments handled that
for trailing elements, but a nested deconstruction copies the inner
element to a temporary, so the elements preceding it are also left
without an assignment; their external load then violated the
DeconstructInstruction invariant that all pattern variable loads are
descendants of the instruction. The forwarding fixup now covers all
unassigned elements and inserts in pattern order, because the statement
and expression builders pair pattern variables with assignments
positionally. This also fixes the nested tuple deconstruction crash
reported in #3388.
Also unwrap the address of the tested operand in
VisitDeconstructInstruction: deconstructing a struct passes the
receiver by reference, which was emitted as an invalid cast,
'var (x, y) = (S)(ref s);', even without nesting.
Fixes#3388.
Assisted-by: Claude:claude-fable-5:Claude Code
Review follow-up. A display-class field initialized from a non-this
parameter is now the only shape where propagation and a later mutation
coexist; it stays sound only because ResolveVariableToPropagate accepts
a parameter with LoadCount == 1, so the mutation can be redirected to
it. Nothing covered that, so Test12 pins it, and Test13 records the
neighbouring shape where the mutation happens inside a lambda - there
capturing the display class keeps it materialized and propagation never
arises. The guard predicate is renamed to say what it matches, since
'ReadOnly' reads like the C# keyword rather than 'a plain read'.
Assisted-by: Claude:claude-fable-5:Claude Code
A field that is propagated to the variable it was initialized from is
replaced by that variable, so re-emitting its initializer assigns the
variable to itself. Where the field was initialized from 'this' the
result does not even compile ('this = this'). The store is dropped
instead, which is what VisitStObj already does for initializer stores
that are not part of an object-initializer block; the insertion position
has to be tracked separately from the loop index, because skipping a
store would otherwise push the following ones past the end of the block.
Assisted-by: Claude:claude-fable-5:Claude Code
Aggressive scalar replacement propagated a display-class field to its
source variable even when the field is mutated after initialization,
aliasing two distinct source-level variables (Test9: thisField and
this). Propagation is now cancelled when the field sees a second store
or its address escapes, but only for propagation targets that cannot
absorb the store: 'this' and variables that are themselves
scalar-replaced display classes. Parameters continue to propagate,
because their remaining uses are already restricted by
ResolveVariableToPropagate and a captured parameter mutated inside a
lambda (DelegateConstruction's Bug951) must keep mapping to the
parameter. Checking CanPropagate first also keeps the guard away from
Mono state-machine fields, whose VariableToDeclare is pre-bound to a
state-machine variable that Propagate(null) would discard.
Re-enables Test9 and adds Test10 covering the escaping-address variant
(Interlocked.Exchange(ref displayClass.thisField, ...)).
Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
Assisted-by: Claude:claude-fable-5:Claude Code
ReduceNesting walks an else-if chain to its innermost if and asks
ShouldReduceNesting whether to extract the else block, which ExtractElseBlock
does by casting the block to Block. A chain with no trailing else reaches this
with a bare Nop, yet the heuristic still approved it (its stats count a Nop as
one statement), so the cast threw InvalidCastException. Take a Block in
ShouldReduceNesting and skip the reduction at the call site when the else is
absent.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Roslyn caches a ReadOnlySpan<T> created from an array literal in a
<PrivateImplementationDetails> field on target frameworks without
RuntimeHelpers.CreateSpan (e.g. .NET Framework / netstandard2.0 + System.Memory):
object obj = <PrivateImplementationDetails>.cache;
if (obj == null) {
obj = new char[] { '\r', '\n' };
<PrivateImplementationDetails>.cache = (char[])obj;
}
... new ReadOnlySpan<char>((char[])obj) ...
The decompiled output referenced the compiler-synthesized
<PrivateImplementationDetails> type, whose escaped name is not expressible in C#
and is never declared, so the output failed to recompile (CS0400).
The modern RuntimeHelpers.CreateSpan form was already handled
(TransformRuntimeHelpersCreateSpanInitialization); this adds the analogous
handling for the legacy lazy-cache form, mirroring CachedDelegateInitialization
(which collapses the same lazy-static-field cache for anonymous-method delegates).
Once the cache is collapsed, the existing array-initializer transforms recover
the array literal, so the <PrivateImplementationDetails> reference disappears.
Test: ILPretty/CachedReadOnlySpanInitialization.
MatchSwitchOnCharBlock's case 2 and default paths guarded against a
negative character index, but case 1 (a bare switch on get_Chars) did
not. Crafted IL whose get_Chars/get_Item index is negative - a value no
compiler emits, but valid IL - therefore reached the pattern
reconstruction unchecked. For a length-1 group this silently miscompiled
the switch (it rebuilds the string switch from the char labels without
using the index), turning IL that reads s[-1] into `switch (s)`; for
longer strings it threw IndexOutOfRangeException and aborted the method.
Move the check into MatchGetChars so all three call sites reject a
negative index by construction, and drop the two now-redundant guards.
Same class of unvalidated-integer robustness issue as #3878, in a
different switch-on-string pattern.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Added Issue3877 test to PrettyTestRunner and new test case source to verify dictionary initialization with negative capacity. Updated SwitchOnStringTransform to skip processing when a negative dictionary capacity is detected.
Awaiting a dynamic value lowers GetAwaiter/IsCompleted/GetResult to
dynamic callsites, which async decompilation could not recognize:
await detection ran before DynamicCallSiteTransform, so the await was
emitted as a raw state machine (or crashed in AnalyzeAwaitBlock, #1388).
AnalyzeStateMachine now collapses the awaiter callsites per block, folds
the runtime ICriticalNotifyCompletion branch the compiler emits for an
awaiter not statically known to implement it into the canonical single
call, and re-joins the branch chains the collapse leaves so each dynamic
await sits in one block. DetectAwaitPattern matches the dynamic
GetAwaiter/IsCompleted/GetResult shape and emits `await expr`; a
synthesized dynamic GetResult method gives the await and its local the
dynamic type. DynamicCallSiteTransform also follows callsite targets
spilled into state-machine locals, so an awaited value flowing into a
dynamic callsite (e.g. d.Result = await ..., #1928) decompiles too.
Assisted-by: Claude:claude-fable-5:Claude Code
DynamicIsEventInstruction has no ExpressionBuilder visitor, so any is-event
diamond that survives the transform pipeline leaks as an "OpCode not supported"
comment. The collapse only fired for the plain statement form; every other
lowering the compiler emits for "d.Event += b" was missed:
- a copy-of-value temporary between the getmember cache and the diamond (modern
Roslyn emits it for the statement form),
- the result-used shape, where the diamond is a value-if nested inside the
consuming expression (a call argument, or a leave's value when returned),
- the result-returned shape as two leaves, "if (isevent) leave(add)" plus a
fall-through "leave(compound)" rather than an if/else, and
- the same two-leaves shape when the optimizer drops the getmember cache (legacy
csc and Roslyn <= 2.x): the is-event flag then feeds a single branch, inlining
folds it into the condition, and the compound leave is already collapsed, so
the cache-based entry never matched it and the branch leaked. These compilers
only run on Windows CI, so the gap was invisible on Linux.
The descendants walk handles the nested value-if once an over-strict
copy-of-value bounds guard (which never held for the compact return block) is
dropped. The two two-leaves shapes share a skeleton matcher and the add/remove
accessor-name check, and both validate the accessor name and arguments. Re-running
from the collapsed statement instead of the next one avoids indexing past the end
of a block that has shrunk to a single leave.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The bool-plus-out-abortTransform contract encoded three outcomes in two
flags, so false meant either 'sequence ended' or 'reject the transform'
depending on the flag. A three-value enum names each outcome at the
return site, and passing minExpectedOffset by ref makes visible that
only the binary.add path advances the expected offset (the bare ldloc
path previously echoed it back through an out parameter).
Assisted-by: Claude:claude-fable-5:Claude Code
HandleCpblkInitializer already rejects fields with a nil metadata token
before casting to FieldDefinitionHandle; the localloc prefix path did
not, so a crafted assembly could make GetFieldDefinition throw instead
of the transform being skipped.
Assisted-by: Claude:claude-fable-5:Claude Code
The jagged-array branch of DoTransform still assembled the
Block(ArrayInitializer) shape by hand; mapping its sequential values to
indexed tuples lets it use BuildSimpleArrayInitializerBlock like the
other single-dim branches, removing the last inline copy of the pattern.
Assisted-by: Claude:claude-fable-5:Claude Code
A true result meant both 'consumed a prefix' and 'no prefix present',
while false aborted the whole transform. The distinction is unnecessary:
a malformed or absent prefix leaves pos unchanged, so the per-element
stobj scan fails on the initblk/cpblk instruction and rejects the
transform with the same out-state. Aborting on 'no prefix' had also
broken plain constant-length stackalloc initializers (element stores
without any prefix), caught by CS73_StackAllocInitializers.
Assisted-by: Claude:claude-fable-5:Claude Code
HandleSequentialLocAllocInitializer mixed three concerns in one method
body: consuming an optional initblk/cpblk prefix, matching the offset of
each sequential store, and assembling the values array. Moving the first
two into TryHandleLocAllocInitializerPrefix and TryGetSequentialStoreOffset
reduces the method to the scan loop itself. Behavior-preserving; the
break-vs-abort distinction of the offset matcher is kept via an out flag.
Share the repeated block construction used by single-dimensional and multi-dimensional simple array initializers. The helper also avoids the LINQ iterator used for adding initializer stores while keeping the transformation behavior unchanged.
Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
Backfills the standard MIT X11 header on hand-written files that never
got one, attributing each to its first-commit author and year from git
history. Code vendored from dotnet/runtime and Humanizr/Humanizer gets
its origin's license lines and a provenance note instead. Generated
files (Resources.Designer.cs, the version-info template), tool-managed
suppression files, and BAML test-case fixtures intentionally stay
header-less.
Assisted-by: Claude:claude-fable-5:Claude Code
A value-type constructor chains via 'this = new TSelf(...)', an ordinary
body statement, so a hoisted argument null-guard in front of it is legal
C# output as-is; folding it back only bought lifting the chain into a
this(...) initializer. That cosmetic gain does not justify the extra
stobj shape matching, so the guard now stays in the body for structs and
the gate reduces to the ChainedConstructorCallILOffset check.
Assisted-by: Claude:claude-fable-5:Claude Code
The loop-shape decision can use ILVariable use lists before AST lowering, so avoid creating a for-loop when its iterator updates a byref local that must remain usable after the loop.
Assisted-by: OpenAI:openai/gpt-5.5:OpenCode
When a chained constructor-call argument contains a throwing null-check
(e.g. `value?.Length ?? throw ...`) and the argument is evaluated more
than once, the compiler hoists the null-check in front of the chained
call. The hoisted `if (value == null) throw ...;` then became the first
body statement, so MoveConstructorInitializer could not recognize the
chained call and left it as an illegal in-body `base..ctor(...)` /
`this..ctor(...)` (a parse error).
Fix it in the ILAst, where the `?? throw` shape already lives, rather
than re-deriving it on the C# AST: NullCoalescingTransform folds a guard
that directly precedes the chained call back into the first use of the
parameter as `if.notnull(ldloc param, throw)`. Nothing in a constructor
body can legally run before the chained call, so a statement preceding it
is necessarily compiler-hoisted; matching is by ILVariable identity, not
parameter name. The guard disappears before the AST transforms run, so
they need no change.
Reference types chain via a base/this..ctor CallInstruction; value types
chain via `this = new TSelf(...)`, i.e. stobj(ldthis, newobj TSelf(...)),
which ChainedConstructorCallILOffset does not report -- so the value-type
chain (including the case where this is spilled to a stack slot because
the guard sits between its load and the call) is matched directly.
Assisted-by: Claude:claude-opus-4-8:Claude Code