Load_Dependencies_Resolves_References_And_Keeps_Them_In_The_List timed out on a loaded
CI agent. The idle predicate it waited on also covers the dispatcher queue, and
LoadDependenciesAsync ends with RefreshDecompiledView, so the test was waiting for a
decompilation to finish - work whose duration is a property of the machine, not of the
condition being asserted. Instrumenting the wait shows every assembly already loaded on
the first poll while dispatcher jobs stay queued for seconds, so the loads were never the
holdup.
Waiting for the list to show the resolved dependencies drops the dependency on machine
speed: under a deliberately shortened one-second deadline the previous wait failed every
run and this one passed every run.
Assisted-by: Claude:claude-opus-5:Claude Code
Obfuscators put arbitrary characters into BAML strings, and XML 1.0 has no
representation for most control characters - a numeric character reference is invalid
for them too. Writing such a document threw ArgumentException from XmlWriter, which
loses the resource on project export and shows an exception instead of the page in the
UI. The escapes are spelled the way the C# output spells them, so one convention covers
both languages. Namespace URIs have to be escaped where the XNamespace is created rather
than in the final pass: the URI is baked into every element name built from it, so
patching only the xmlns declaration would desync the two. Characters XML can carry stay
untouched, so ordinary documents decompile byte-identically.
Every BAML stream of an assembly lives in one .resources container, and the recovery
around resource writing sat outside the loop over its entries, so a single page that
could not be written discarded every other page sharing the container with it.
Assisted-by: Claude:claude-opus-5:Claude Code
An assembly can map one CLR namespace to several XML namespaces: PresentationFramework
maps its namespaces to both the winfx/2006 and the netfx/2007 presentation namespace. The
fallback used when the BAML xmlns records name no namespace for a type always preferred the
winfx/2006 one, so a document that binds the default prefix to netfx/2007 ended up with a
root start tag that declares one presentation namespace and needs the other for its own
name, which XmlWriter rejects. Only the root carries the xmlns declaration, which is why
skipping XClassRewritePass worked around it.
Assisted-by: Claude:claude-opus-5:Claude Code
The naming rules have been warnings since they were introduced; they are
advisory conventions rather than build gates, and the noise they add to
every file listing outweighs their value.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The nugetfuzz package cache is usually where the corpus itself came from,
so it holds exactly the dependency closure a corpus assembly references;
probing it resolves references the machine-wide NuGet cache does not have.
Reference assemblies name GAC-only internals such as SMDiagnostics and
System.ServiceModel.Internals, which ship in no pack and no package.
Reporting them as missing suggested --refs could supply them, so the
staging walk now tracks whether a name was reached through a reference
pack and drops those from the report.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
C# 12 allows both on the explicitly typed parameter list of a lambda, and
nowhere else: an anonymous method cannot declare either, and neither can a
lambda whose parameter list is about to be dropped. Guarded by a setting so
the output stays valid for earlier language versions.
Only what the anonymous function's own metadata declares is written. A lambda
may state a default the delegate does not have, a different one, or none where
the delegate has one, and reflection over the lambda's method reports what the
lambda declared - so filling either in from the delegate's Invoke would make
the recompiled assembly describe itself differently from the original. Call
sites are unaffected either way, because they bind against the delegate, which
still declares both.
Roslyn writes ParamArrayAttribute on the anonymous function's own method only
from version 5 on; before that it stands on the delegate type alone, where it
is not the lambda's to restate, so the fixture guards those cases on ROSLYN5.
The correctness test reads the metadata back through reflection, which is the
only place the difference is observable.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The C# 10 grammar only allows attributes on a lambda or its parameters
when the parameter list is parenthesized, but LambdaNeedsParenthesis
predates attribute support and only considered the single parameter's
type and modifiers. An attributed lambda whose parameter type is erased
for being anonymous therefore printed as '[My] a => a.X', which does not
parse. Latent since attributed-lambda decompilation was added: every
other attributed lambda has explicitly typed parameters, which already
force the parenthesized form.
Assisted-by: Claude:claude-fable-5:Claude Code
Assigning through a ref-conditional, (cond ? ref a : ref b) = value, was
emitted without the parentheses, so it re-parsed as
cond ? ref a : (ref b = value) and failed to compile (CS8156 / CS0201).
The target was only parenthesized above assignment precedence, but a
conditional binds tighter than assignment, so the check let it through.
Require the assignment target to have precedence above the conditional
operator. Ordinary lvalues (locals, fields, indexers) are primary
expressions and are unaffected; the postfix ++ form already parenthesized
correctly via unary precedence. Covers plain and compound assignments alike.
Assisted-by: Claude:claude-opus-4-8: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
This way, we don't need the MapToMergedBounds logic to split the merged list back into lower/upper.
Also, this commit avoids the quadratic merge-everything-with-everything else -- instead we use a dictionary to compare only types that are equivalent to begin with.
This is the same approach as Roslyn MethodTypeInference.Fix/AddAllCandidates.
NullPropagationTransform only rewrites "x != null ? x.Chain : fallback"
into "x?.Chain ?? fallback" when the chain's inferred type is a
non-nullable value type, and InferType had no case for ldlen. Array length
therefore came back as UnknownType, so "arr?.Length ?? 0" was left as a
ternary.
The inferred type mirrors ExpressionBuilder.VisitLdLen, which decides
between Array.Length and Array.LongLength from the result type alone.
Found while investigating #3704, where the surviving ternary also keeps the
tested array in a stack slot and strands the typeof of a dynamic call's
static target. That issue is fixed separately in #4072, whose DynamicTests
cases pinned the ternary as expected output; those blocks round-trip
exactly now, so they are gone.
Also carries a review follow-up that missed #4072: the static-target test
in VisitDynamicInvokeMemberInstruction is a plain null check, the way
DynamicInvokeMemberInstruction itself tests the field, rather than a
pattern match binding a name it does not need.
Assisted-by: Claude:claude-opus-5[1m]: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
Three places decided independently whether a backing field would still be
declared, and they disagreed. ReplaceBackingFieldUsage rewrote a constructor
store into a property assignment whenever the property looked collapsible,
without asking whether PatternStatementTransform would actually remove the
declaration; ConvertField printed the "field" keyword on the FieldKeyword
setting alone, ignoring the GetterOnlyAutomaticProperties veto that
MemberIsHidden applies to the same field.
Two consequences, both silent. A setter-less property under
GetterOnlyAutomaticProperties = false kept its declaration and got "field" in
the getter anyway, so the keyword bound to a second synthesized field and the
declared one went unwritten. A settable property under AutomaticProperties =
false had its initializing store turned into a property assignment that
TransformFieldAndConstructorInitializers could no longer lift, leaving the
constructor in the output with an unconverted base-constructor call.
BackingFieldWillBeRemoved is now the single verdict every branch consults, and
it mirrors the transform's own entry gate. A property that keeps explicit
accessors is no longer addressed by name: the store stays a field reference and
becomes the property initializer, which is what field-backed storage means. The
one exception is a setter-less property's constructor store, which C# allows to
be written as an assignment and which has no other expressible form.
IsBackingFieldOfAutomaticProperty now answers through TryGetBackingField instead
of its own name check, so the two directions of "is this field that property's
storage" cannot diverge on staticness or field type. ReplaceBackingFieldUsage
dispatches on the resolve result rather than the identifier's spelling; after
ConvertField the same field appears both as "field" and under its metadata name
carrying the same annotation, so matching the name was matching the wrong thing.
The keyword's own precondition moves into a CanUseFieldKeyword local function.
Seven clauses with comment blocks wedged between them had to be read as one
expression; as one early return per rule the list reads in order.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
ExpressionBuilder.ConvertField prints the C# 14 "field" keyword based on the
FieldKeyword setting alone, but PatternStatementTransform only entered the
property transform when AutomaticProperties was on. With FieldKeyword on and
AutomaticProperties off the backing-field declaration was therefore never
removed, and the output declared the backing field next to accessors already
written in terms of "field".
That output still compiles, which is what makes it dangerous: the keyword binds
to a second, freshly synthesized backing field while the declared one stays
unwritten, so the recompiled assembly has different storage than the input.
Only a CS0169 "field is never used" warning hints at it.
AutomaticProperties governs only whether trivial accessors collapse to
"get;"/"set;"; the declaration removal inside the transform is a separate step
that FieldKeyword alone is enough to justify. The entry gate now mirrors
CSharpDecompiler.MemberIsHidden, which already made the field's visibility
depend on either setting, with GetterOnlyAutomaticProperties vetoing the
getter-only case for both.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
ICSharpCode.Decompiler.Generators pins a stable Microsoft.CodeAnalysis.CSharp
(a source generator must not reference a Roslyn newer than the host compiler)
and ILSpy.AddIn.VS2022 pins a stable 4.0.1. Stable Roslyn lives only on
nuget.org, but the repo-root NuGet.config mapped Microsoft.CodeAnalysis.*
exclusively to the dotnet-tools feed, which carries only prerelease builds.
Both projects worked around that with a per-project NuGet.config. dotnet
restore honors it, since settings are computed per project directory, but
Visual Studio starts NuGet.config discovery at the solution directory and
never sees it: the first restore in VS on a machine with a cold NuGet cache
fails with NU1103 exactly as reported. Once any successful restore has put
the stable package into the global packages folder, source mapping is
satisfied from the cache and the problem never reappears on that machine,
which is why #3835 was closed as unreproducible.
NuGet consults every source that declares the longest pattern matching a
package id, so mapping Microsoft.CodeAnalysis.* to nuget.org as well as to
dotnet-tools makes both feeds available for the whole family: the stable
versions resolve from nuget.org and the prerelease $(RoslynVersion) from
dotnet-tools. Both per-project configs are then redundant and removed.
Verified with an empty NUGET_PACKAGES and the root config forced as the only
config (simulating VS's discovery): the old config reproduces the reported
NU1103; with the new config the generator, the decompiler tests (prerelease
Roslyn) and the VS add-in (stable 4.0.1 with its Workspaces dependencies)
all restore; restore.ps1 over ILSpy.sln leaves the lock files unchanged.
Assisted-by: Claude:claude-fable-5:Claude Code
The headless UI tests synchronized with the application by pumping a fixed
number of frames (39 loops of RunJobs/Delay across 19 files) and by pressing
at a point computed once from a control's bounds. Both encode how fast the
machine that wrote the test was: on the loaded Windows Debug CI agent the
frame count comes up short and the point goes stale, which is the recurring
timeout in the tree context-menu tests and the reason each such failure was
repaired one test at a time.
Waiters.WaitForIdleAsync replaces the frame loops. It observes the actual
precondition - no dispatcher job queued at Background priority or above, no
assembly still loading in the background sweep, a frame rendered - and
requires it on two consecutive polls so a thread-pool continuation about to
post back is caught as well.
Window.ClickAsync replaces element-targeted MouseDown/MouseUp pairs. It
re-resolves the target on every poll and presses only once the window's hit
test at the click point answers with that target, reporting the point and
what was hit instead on timeout. That diagnostic exposed one vacuous test:
User_Click_On_Visible_Row_Does_Not_Recentre_Viewport clicked the centre of a
row wider than the tree viewport, which lies under the decompiler text view,
so its assertion held without the row ever being clicked. It now clamps the
point to the viewport like the other tree-row clicks.
Clicks at text positions and press-only gutter clicks stay raw; they do not
target an element.
Assisted-by: Claude:claude-fable-5:Claude Code
Right_Clicking_A_Second_Row_Moves_The_Context_Highlight_To_It still timed
out on the Windows Debug CI job, now in the hit-test wait added for the
second right-click: for the full 60s no hit at the precomputed point matched
the captured row container. A light-dismiss overlay that survives one frame
cannot explain that many rendered frames; a container that is no longer the
one on screen can. The test only waits for three assemblies, so the rest of
the list keeps loading on the slow agent while the test runs, and every
insertion reshuffles the rows - re-realising containers and moving them -
after the row and point were captured.
The wait now resolves the row container and the click point on every poll
and matches the hit by node instead of by container identity, and a timeout
reports the point and what was hit instead so a further failure is
diagnosable from the log.
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
Handing the corpus over as `$(cat list)` is a bourne shell construct, so the
one invocation the tools print and the README documents did not work on
Windows. nugetfuzz already reads its package list from an @file for the same
reason; decompdiff now takes its corpus entries the same way, leaving the
shell out of it entirely.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Both tools need a corpus of real assemblies and neither had a way to get
one: nugetfuzz-all.ps1 walks the catalog in publish order, which is fine for
a crash sweep but makes a poor readability corpus, and the alternative was
picking package ids by hand.
The ids come from an empty search query, which orders by download count.
Downloading them reuses nugetfuzz, which already resolves versions, matches
target frameworks and walks the dependency closure into the same cache
decompdiff reads; --download-only stops it before it decompiles, since the
sweep is the expensive part and a corpus only needs the files.
The result is a list of lib directories rather than a single root, because a
package already restored on this machine is used from the machine-wide NuGet
cache instead of being copied into ours, and a corpus that silently omitted
those would misrepresent what was tested.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code