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
The BAML tests were orphaned: in no solution, no CI, and missing their
ICSharpCode.BamlDecompiler reference. Revive them split by platform,
mirroring ILSpy.Tests / ILSpy.Tests.Windows: ILSpy.BamlDecompiler.Tests
(net11.0) holds platform-agnostic tests - including a regression test that
decompiles with the WPF assemblies hidden, covering the missing-assembly
fix - and ILSpy.BamlDecompiler.Tests.Windows (net11.0-windows) holds the
WPF round-trip cases that compile XAML into BAML. Single-TFM projects keep
normal lock files; the Windows one declares all RIDs so its lock is
generatable and verifiable on any host.
Wire the cross-platform project into ILSpy.sln, ILSpy.XPlat.slnf and
ILSpy.Desktop.slnf so it builds and runs on the Linux/macOS legs; the
Windows project stays in ILSpy.sln (its tests execute in the Windows leg).
The Windows job also archives that assembly, whose embedded BAML lets the
decompiler be exercised against real BAML on a host without WPF.
Assisted-by: Claude:claude-opus-4-8:Claude Code
KnownThings hard-required PresentationFramework, PresentationCore and
WindowsBase to be resolvable, so decompiling a WPF binary on a machine
without WPF (Linux/macOS) threw "Could not resolve known assembly" and
aborted the whole BAML decompile.
Mirror MinimalCorlib: when a well-known BAML assembly cannot be resolved,
BamlDecompilerTypeSystem substitutes a synthetic stand-in module that
upholds the invariant KnownThings assumes. The stand-in only materializes
the types the decompiler explicitly seeds, so it stays bounded to the
well-known set and references to other types keep degrading to
UnknownType exactly as before. For the WPF assemblies it also reproduces
the XmlnsDefinitionAttribute mapping so known types still serialize under
the presentation xmlns instead of a clr-namespace fallback.
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.
When a null literal is the only argument a generic type argument could
be inferred from, and that type argument is an anonymous type, ILSpy
used to drop the type arguments entirely (they cannot be written
explicitly), producing 'Test(null)', which no longer compiles
(CS0411). A conditional expression whose never-taken branch creates an
instance of the anonymous type is the minimal C# expression that gives
a null value that type, so the argument now carries enough information
for type inference.
The instance expression is obtained by translating a synthesized
'newobj' with 'default.value' arguments through the existing pipeline
rather than assembling syntax by hand; the property values are typed
defaults, since the IL only contains ldnull. Anonymous types occurring
inside other constructed types (e.g. arrays) stay unexpressible and
keep the previous output.
Assisted-by: Claude:claude-fable-5:Claude Code
CS8196 also fires when the second reference sits inside a nested call
in another argument of the declaring call, e.g.
OutAndValue(out var a, UseAndReturn(out a)). The existing check already
covers this because it walks all descendants of the sibling arguments;
this fixture pins that behavior.
Assisted-by: Claude:claude-fable-5:Claude Code
Passing the same local as multiple out arguments of one call made
DeclareVariables turn the first use into an implicitly-typed declaration,
producing 'f(out var x, out x)'. Referencing an implicitly-typed out
variable in another argument of the declaring call is rejected by the
compiler (CS8196), because its type is only inferred once overload
resolution of that call has completed. The explicitly-typed form
'f(out int x, out x)' is valid, so fall back to the explicit type in
that case.
Assisted-by: Claude:claude-fable-5:Claude Code
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
* Add decompiler architecture document
* Add StatementBuilder section to architecture doc, fix foreach story
* Correct architecture doc per review by the decompiler author
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
Failed decompiler-test runs leave their generated fixtures (compiled
test assemblies, generated IL, diff inputs) on the runner where they
are lost, making CI-only failures hard to diagnose. Capture the folder
via upload-artifact's own client-side zip at compression-level 9
instead of a separate 7z step, since 7z is not reliably available on
the macOS runner.
Assisted-by: Claude:claude-fable-5:Claude Code
A stackalloc initializer whose first element is written twice must not be
folded into a 'stackalloc int[] { ... }': doing so drops the earlier store,
which is observable when its value has side effects. HandleSequentialLocAllocInitializer
already rejects this (the extra store trips the LoadCount and elementCount
guards); the sequential-store offset matcher on its own would misclassify the
second offset-0 write. The IL is derived from a C# 'stackalloc int[] { a, b, c }'
disassembly with an extra offset-0 store injected, a shape no compiler emits.
Assisted-by: Claude:claude-fable-5:Claude Code
A second store to an already-written array element must prevent
HandleSimpleArrayInitializer from folding the stores into a collection
initializer: the earlier store would be dropped (a visible change when
its value has side effects), and without the nextMinimumIndex guard the
transform crashes on the colliding index. The nextMinimumIndex check
already enforces this; pin it with a Pretty fixture so the boundary
between a foldable initializer and a duplicate-write sequence stays
covered.
Assisted-by: Claude:claude-fable-5: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
Pre-checking stream.Length before reading is racy (the stream can shrink
between check and read) and not every stream knows its length ahead of
time. Instead, catch EndOfStreamException at each read: a stream too
short for the magic is passed through as not-XALZ, and a truncated
header or payload after a confirmed magic is rejected as
InvalidDataException. stream.Length remains only a sizing hint for the
payload buffer.
Assisted-by: Claude:claude-fable-5:Claude Code
The Xamarin XALZ loader sized its buffer allocations from an attacker-controlled
header field and ignored the partial-read length, so merely opening a crafted
file (the loader is registered first and runs on any XALZ-magic input) could
crash or over-allocate. The declared uncompressed length, a raw header uint cast
to int, had no sanity bound: a tiny file claiming ~2 GB forced a giant
ArrayPool.Rent (decompression-bomb amplification), and a high-bit value became
negative and made Rent throw ArgumentOutOfRangeException. The compressed length
was taken as the whole file (header included) and ReadAsync's return value was
discarded, leaving stale pooled bytes in the tail fed to the decoder; the
output MemoryStream then exposed the entire rented buffer, so PEFile parsed past
the real decompressed data into leftover pool contents.
Bound the declared length before renting (reject zero, > int.MaxValue, or more
than an LZ4 block could expand from this payload at its 255x maximum ratio),
read the payload that actually follows the header with ReadExactlyAsync, and
slice the output to the length LZ4Codec.Decode reports. Malformed input now
fails as a catchable InvalidDataException, consistent with the bundle and .rsrc
hardening; well-formed Xamarin modules load exactly as before.
Assisted-by: Claude:claude-opus-4-8:Claude Code
WebCilFile builds raw native pointers into the memory-mapped view directly
from section-header fields read out of attacker-controlled metadata. Unlike
PEFile.GetSectionData, which delegates to the bounds-checked PEReader, this
hand-rolled path validated nothing: a crafted section header could produce a
SectionData (and hence a BlobReader) pointing far outside the view, an
out-of-bounds read reachable on normal decompilation through method-body and
field-data RVA resolution. The (int)RawDataSize narrowing cast could also
yield a negative length.
Resolve and bounds-check the raw-data range against the view length before
constructing SectionData, widening the arithmetic to long so crafted uint
fields cannot wrap the range check or narrow into an apparently valid length.
Structural parsing in FromFile now reports a crafted or truncated module as
"not a WebCIL file" (null) rather than letting EndOfStreamException,
OverflowException or BadImageFormatException escape the loader.
Assisted-by: Claude:claude-opus-4-8:Claude Code
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
New .cs files were inconsistently getting AlphaSierraPapa headers; the
convention is that new code carries the contributing human's copyright,
while existing headers (including vendored third-party ones) are never
rewritten and legacy header-less files stay as they are.
Assisted-by: Claude:claude-fable-5:Claude Code
The C# compiler lowers a foreach over an inline array into a for loop whose
body reads each element through <PrivateImplementationDetails>.InlineArrayElementRef(ref
buffer, i). That helper cannot be named in C#, so the decompiled for loop did
not compile.
Reconstruct the foreach at the AST level instead. Rewriting the unchecked
InlineArrayElementRef helper to the bounds-checked indexer buffer[i] would be
unsound for an out-of-bounds index, so the transform fires only when the loop
bound equals the inline array's length: that proves 0 <= i < length, matching
the exact shape the compiler emits and nothing else. A loop that does not match
keeps the faithful (if unnameable) helper call rather than silently gaining a
bounds check.
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
Per @siegfriedpammer: rather than rescue the hoisted for-initializer in
DeclareVariables, don't form the for-loop at all. TransformFor now bails when the
loop variable is a by-ref-like local used after the loop, leaving the while-loop
(matching source); the ref decl keeps its initializer, no CS8174. Reverts the
DeclareVariables change; test now expects the while form.
Assisted-by: Copilot:claude-opus-4.8:GitHub Copilot CLI
A ref local that is used after a for-loop has its declaration hoisted in front of
the loop, but its only initialization is the for-initializer ref-assignment. The
declaration was then emitted without an initializer (`ref T x;`), which does not
compile (CS8174).
When a by-ref-like local's matching assignment is the first for-initializer, move
the ref-assignment's value up into the declaration (`ref T x = ref expr;`) and
drop the for-initializer.
Assisted-by: Copilot:claude-opus-4.8:GitHub Copilot CLI
A type's leading field assignments are extracted to field declarations
only when every constructor that does not chain with this() agrees on
them. When they disagree, the analyzer gave up on the whole type, so the
remaining constructors' this()/base() calls were never lifted into
initializers -- a struct with two divergent constructors plus a chaining
one rendered the chain as `this = new TSelf(...)` instead of `: this(...)`.
Chain lifting does not depend on the shared-initializer extraction, so a
mismatch now just skips the extraction (the assignments stay in the
bodies) and the transform continues. Primary constructors still bail,
since their parameters drive the initializers that must be extracted.
Assisted-by: Claude:claude-opus-4-8:Claude Code
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
LocalFunctionMethod.MemberDefinition returned the instance itself, so the
declaration of a generic local function (whose base method is an identity
specialization) and its use sites (whose base methods carry the use-site
substitutions) never compared equal, and click-highlighting could not group
them. Returning a wrapper around the unspecialized base method lets the
token writer record the same definition object for the declaration and all
use sites. Method-group references to local functions additionally need
their own lookup, because GetSymbol() does not surface a
MethodGroupResolveResult.
The token-writer tests now keep the PEFile alive for the duration of each
test: recorded references are type-system entities that lazily read from
the PE image, and formatting one in an assertion message after disposal
crashed with an AccessViolationException.
Assisted-by: Claude:claude-fable-5:Claude Code
Textual assembly resources (JSON, XML, Markdown, plain text, ...) rendered
as an opaque byte count with only a Save button. Detect text vs binary from
the payload (size cap, BOM-aware decoding, strict UTF-8, rejecting control
characters) and pick a highlighting extension by resource-name extension,
falling back to content sniffing (angle-bracket for XML/HTML, an actual
JsonDocument parse for JSON) when the extension is unknown. Text renders as
the view's whole content with the matching highlighting; binary keeps the
byte-count-plus-Save presentation.
Container resources unpack their entries as raw byte arrays. Route those
through the same IResourceNodeFactory pipeline as top-level resources so a
nested .baml gets the BAML view, an image its viewer, and so on, instead of
the generic byte node; the .resources and !AvaloniaResources views also list
their entries by name. Register built-in AvaloniaEdit definitions (JSON,
Markdown, ...) with the theme manager on lookup so they follow the dark
theme like the bundled ones.
Assisted-by: Claude:claude-fable-5:Claude Code
For an async iterator with an [EnumeratorCancellation] cancellation token, the
hoisted-local cleanup (stfld <>u__N(this, null)) can be emitted before the
combined CancellationTokenSource disposal in the set-result and catch blocks.
CheckSetResultReturnBlock and ValidateCatchBlock only consumed that cleanup after
the disposal, so the `pos + 2 == count` test missed the dispose pattern and the
analysis failed, leaving the raw state machine (catch (object), goto case, ...).
Allow the cleanup to appear before the combined-tokens disposal as well.
Assisted-by: Copilot:claude-opus-4.8:GitHub Copilot CLI
UnscopedRef only appeared on a params parameter anywhere in the
suite; a ref-returning method and property on a ref struct pin the
attribute round-trip.
Assisted-by: Claude:claude-fable-5:Claude Code
Static abstract interface members had no checked operator or
unsigned-right-shift coverage, and static abstract property getters
were never read as rvalues through the constraint.
Assisted-by: Claude:claude-fable-5:Claude Code
Compound assignments selecting between op_Addition and
op_CheckedAddition across a checked block boundary were not
covered.
Assisted-by: Claude:claude-fable-5:Claude Code
Generic attributes were only closed over simple types; constructed
generic, array, and enum type arguments exercise separate paths in
the custom-attribute blob decoder.
Assisted-by: Claude:claude-fable-5:Claude Code
SetsRequiredMembers had no coverage anywhere; required members
across a base/derived pair and in a generic abstract host were also
untested.
Assisted-by: Claude:claude-fable-5:Claude Code
readonly record struct had no coverage, and no record overrode a
synthesized member; user-declared ToString and PrintMembers pin the
synthesized-vs-user member detection.
Assisted-by: Claude:claude-fable-5:Claude Code
Only two plain ASCII u8 literals were covered; the empty literal and
a literal made of escape sequences pin the recovery heuristic
boundaries.
Assisted-by: Claude:claude-fable-5:Claude Code
The C# 6 index-initializer syntax was only covered through custom
indexers; a real Dictionary<,> initialized with [key] = value pins
the choice between the index form and the Add form.
Assisted-by: Claude:claude-fable-5:Claude Code
ConfigureAwait(false) inside an async iterator and await foreach
over ConfiguredCancelableAsyncEnumerable were not covered.
Assisted-by: Claude:claude-fable-5:Claude Code
Only interface-based disposal was covered; a using declaration over
a ref struct with a pattern-based Dispose exercises the recognition
heuristic without IDisposable.
Assisted-by: Claude:claude-fable-5:Claude Code