diff --git a/doc/DecompilerArchitecture.html b/doc/DecompilerArchitecture.html new file mode 100644 index 000000000..56937fb8f --- /dev/null +++ b/doc/DecompilerArchitecture.html @@ -0,0 +1,1757 @@ + + +
+ + +How ICSharpCode.Decompiler turns .NET assemblies back into C#
Describes the engine as found in this repository (July 2026). File paths are relative to
+ICSharpCode.Decompiler/ unless stated otherwise.
ICSharpCode.Decompiler is the engine behind ILSpy, ilspycmd, and the
+ICSharpCode.ILSpyX host library. Given a .NET assembly, it reconstructs C# source code
+that a developer can read — and, ideally, recompile. The engine is a plain class library with no
+UI dependencies; everything in this document lives in the ICSharpCode.Decompiler project.
Decompilation is the inverse of a lossy process. The C# compiler erases most of what makes source
+code readable: expressions are flattened onto an evaluation stack, structured control flow becomes
+conditional branches, lambdas become classes with fields, async/await and
+yield return become state machines, and syntactic sugar of every kind is expanded into
+plain calls and branches. The decompiler's job is to run each of these expansions backwards —
+recognizing the compiler's output patterns and folding them back into the constructs that
+produced them. Almost everything in the architecture follows from that framing. Four design tenets
+recur throughout the codebase:
Round-trip correctness. The output must not merely look plausible; recompiling +it should bind to the same members and produce the same behavior. This is enforced structurally: the +decompiler embeds a complete C# semantic engine (name lookup, overload resolution, conversions, type +inference) and re-resolves its own output while generating it. A cast or qualifier is emitted +only when the resolver proves that omitting it would change meaning (section 7).
Progressive raising through many small transforms. There is no single clever
+algorithm. Instead, a low-level intermediate representation (the ILAst) is raised step by step
+by roughly forty ordered IL transforms and fifteen C# AST transforms, each responsible for one
+pattern: one transform reconstructs loops, another lock statements, another string
+interpolation. Transforms are strict pattern matchers: they fire only on shapes the compiler is known
+to emit, and leave anything else untouched.
Robustness against arbitrary IL. Input assemblies may be hand-written,
+obfuscated, or invalid. The engine degrades gracefully instead of failing: unverifiable IL becomes
+InvalidBranch/InvalidExpression nodes with warnings, and a state-machine
+analysis that encounters something unexpected throws internally
+(SymbolicAnalysisFailedException) and simply leaves the method in its lower-level form.
+A method that cannot be prettified is still decompiled — just with gotos.
Trees with checked invariants. Both intermediate representations are strict
+trees whose nodes know their parents, children (in typed slots), result types, and originating
+IL offsets. In debug builds, CheckInvariant runs after every single transform, so a
+corrupting transform fails at its own doorstep rather than ten passes later.
A fifth theme is configurability: DecompilerSettings
+(DecompilerSettings.cs) exposes roughly 150 feature flags, and
+SetLanguageVersion switches them in blocks so the same pipeline can emit C# 1
+through C# 15 — a transform whose feature is disabled simply does nothing
+(section 6.6).
The engine has two intermediate representations and three major stages. The front +end reads metadata and IL bytes and produces the ILAst, a tree-shaped, typed form of IL. +The middle end runs the IL transform pipeline, which raises the ILAst from +"structured assembly" to something semantically equivalent to C#. The back +end translates the ILAst into a C# syntax tree, prettifies it with AST transforms, and +renders it to text.
+ +The orchestrator is CSharpDecompiler
+(CSharp/CSharpDecompiler.cs), "the main class of the C# decompiler
+engine." One instance wraps one assembly plus its type system and settings; instances are
+deliberately not thread-safe (parallel consumers such as the whole-project decompiler create
+one per thread). Its public surface offers several granularities:
DecompileWholeModuleAsSingleFile() / DecompileWholeModuleAsString()DecompileType(FullTypeName), DecompileTypes(...)Decompile(params EntityHandle[]) for arbitrary member setsDecompileModuleAndAssemblyAttributes()All of them funnel into the same per-member machinery. Each invocation creates a
+DecompileRun (DecompileRun.cs), a scratchpad that travels
+through the whole pipeline: the settings, the cancellation token, the namespaces referenced by the
+IL (collected up front, before any transform runs; section 8 explains how they are
+used), the documentation provider, and caches such as per-type
+RecordDecompiler instances. Both transform stages see it — the IL transforms through
+ILTransformContext, the AST transforms through TransformContext.
To keep the stages concrete, the next sections trace one small method through the pipeline:
+ +static void Greet(bool polite)
+{
+ Console.WriteLine(polite ? "Good day!" : "Hi.");
+}
+
+The C# compiler (release build) turns the conditional expression into branches. This is exactly the +kind of information loss the pipeline has to undo — by the end of +section 7 the ternary will have been reassembled:
+ +ldarg.0
+brtrue.s IL_000a
+ldstr "Hi."
+br.s IL_000f
+IL_000a: ldstr "Good day!"
+IL_000f: call void System.Console::WriteLine(string)
+ret
+
+The decompiler does not work on raw metadata handles for long. Two layers turn a file on disk into +semantic objects that the rest of the pipeline can reason about.
+ +The Metadata/ namespace wraps System.Reflection.Metadata (SRM), the BCL's
+low-level metadata reader. The central abstraction is MetadataFile
+(Metadata/MetadataFile.cs): one loaded module, exposing the SRM
+MetadataReader, method bodies by RVA (GetMethodBody returns an SRM
+MethodBodyBlock), and section data. PEFile is the ordinary
+portable-executable implementation over a PEReader; WebCilFile handles the
+WebAssembly packaging format; single-file bundles are unpacked by SingleFileBundle.
+Everything above this layer is format-agnostic.
Referenced assemblies are located by an IAssemblyResolver. The default,
+UniversalAssemblyResolver (Metadata/UniversalAssemblyResolver.cs,
+with DotNetCorePathFinder), searches the same universe the runtime would: framework
+directories, the GAC, .NET Core shared frameworks, and NuGet-style layouts, keyed off the target
+framework detected from the main module's attributes.
DecompilerTypeSystem (TypeSystem/DecompilerTypeSystem.cs)
+builds a resolved, semantic view over the main module and everything it references. Its
+initialization walks assembly references and module references, resolves each through the assembly
+resolver, follows type forwarders, and — on .NET Core and later — pulls in implicit
+references that metadata does not name explicitly. Each module is wrapped with
+TypeSystemOptions, a flags enum controlling how metadata is interpreted: whether
+dynamic, tuple names, nint, ref structs, extension methods and so on are
+surfaced as first-class types. The result is a MetadataModule per assembly — the
+object the IL reader uses to resolve every token it encounters.
Why carry a full type system instead of raw handles? Because nearly every later stage needs real +semantics: the IL transforms compare and substitute generic types, the expression builder performs +member lookup and conversions, and the resolver (section 7) runs actual C# overload +resolution. The type system is the shared vocabulary; it is the same NRefactory-lineage design that +once powered SharpDevelop's code completion, which is precisely why a complete C# resolver could be +embedded on top of it.
+ +The front end proper is two classes: ILReader
+(IL/ILReader.cs) decodes IL bytes into expression trees grouped into
+basic blocks, and BlockBuilder (IL/BlockBuilder.cs)
+arranges those blocks into the nested container structure that models control flow. The output is a
+single ILFunction per method body.
IL is a stack machine: ldarg.0; ldarg.1; add pushes two values and replaces them with
+their sum. Stack code is hostile to source-level analysis — data flow is implicit in stack
+positions. The reader's core move is therefore an abstract interpretation of the evaluation
+stack at decode time, converting stack discipline into two explicit forms: expression trees
+where possible, and named stack-slot variables where values cross statement or block
+boundaries.
Two structures are maintained while decoding (ILReader.cs):
+ +expressionStack — a transient list of not-yet-committed expression trees within
+the current block. When add is decoded, its two operands are popped from here and become
+its children; the new BinaryNumericInstruction is pushed back.currentStack — an ImmutableStack<ILVariable> of virtual
+stack slots: values that survive past the point where trees can represent them.FlushExpressionStack() converts the former into the latter. Each pending expression is
+committed as a statement that stores into a fresh variable of kind StackSlot:
IType type = compilation.FindType(inst.ResultType);
+var v = new ILVariable(VariableKind.StackSlot, type, inst.ResultType);
+currentStack = currentStack.Push(v);
+currentBlock.Block.Instructions.Add(new StLoc(v, inst).WithILRange(inst));
+
+Later consumers read the value back with ldloc S_0. A flush happens at every block
+boundary and, crucially, whenever a decoded instruction is not pushed onto the expression
+stack — otherwise the side effects of the pending expressions could be reordered past the new
+instruction. Side-effect ordering is a load-bearing invariant here: the documentation on
+Pop() spells out that popped instructions must be evaluated in reverse pop order, and
+much of the later inlining machinery exists to safely undo the conservative flushes made now.
Methods are imported block by block through a worklist. ReadInstructions seeds offset
+0 with an empty stack, precomputes branch targets (a BitSet via
+ILParser.SetBranchTargets), and dequeues blocks until done. A block ends where the next
+offset is a branch target, where an instruction may branch, or where the endpoint is unreachable;
+if execution falls through, an explicit Branch to the next offset is appended.
+Fall-through never survives — after the front end, every block ends in
+unconditional control flow, which is what lets later transforms reorder blocks freely.
When several predecessors reach the same offset, their stack states must agree. Stack heights must
+match exactly; the per-slot types are merged over the StackType lattice
+(IL/StackType.cs: I4, native I,
+I8, F4, F8, O, Ref — the CLI
+evaluation-stack types, deliberately ordered so that merging picks the larger). If a merge widens the
+input stack of a block that was already imported, that block is re-enqueued and imported again —
+a small dataflow fixpoint that terminates because the lattice is finite.
Each predecessor initially creates its own stack-slot variable for a given slot. After
+import, CheckOutgoingEdges walks every control-flow edge and merges corresponding slots
+with a union-find structure; where the types differ but are compatible (I4 vs native
+I, F4 vs F8) it inserts an explicit conversion at the end of
+the predecessor block. A final visitor rewrites all loads and stores to the union-find representative
+and names the survivors S_0, S_1, …
Exception handlers get their stack seeded rather than inherited: for each catch/filter handler the
+reader creates an ExceptionStackSlot variable named E_<offset>
+representing the exception object the runtime pushes, so a handler body decodes exactly like normal
+code.
For Greet, the two ldstr instructions in the two branch arms each push a
+string that is still on the stack when the branches converge at IL_000f. Both sides
+flush, producing two stack-slot variables that the union-find then merges into one
+S_0:
Greet after the IL reader: an explicit CFG, expression
+trees inside blocks, and the on-stack string materialized as stack slot S_0.Note what has already happened: brtrue became a structured
+if (…) br whose condition is a real expression tree,
+ret became leave of the function's main container, and all data flow is
+explicit. What has not happened yet: nothing knows this is a conditional expression —
+that is the transform pipeline's job.
BlockBuilder converts the reader's flat, offset-ordered block list into the nested
+structure described in section 5. It works in three steps:
CreateContainerStructure reads the method's exception regions and
+builds the try/handler skeleton: each catch/filter region becomes a TryCatch with
+TryCatchHandler children (a plain catch gets the constant filter ldc.i4 1;
+a real filter gets its own container that must evaluate to I4), and fault/finally
+regions become TryFault/TryFinally. Regions are sorted outermost-first so
+nesting comes out right.CreateBlocks walks the basic blocks in IL order, maintaining a stack
+of currently-open containers; when a block's offset enters a try or handler range, the corresponding
+container is pushed. This reconstructs proper lexical nesting from what is, in the file, just a table
+of offset ranges.ConnectBranches resolves the symbolic branches: every
+Branch still carrying a target offset gets its TargetBlock reference;
+leave instructions with no explicit target (i.e. endfinally) are bound to
+the innermost finally/filter container. Anything unresolvable becomes an InvalidBranch
+— the graceful-degradation policy again. As a curiosity, the builder even synthesizes a
+dispatcher variable and switch to support VB's On Error Resume Next, which branches from
+a handler back into its try block.Finally, ILReader.ReadIL wraps the main container in an ILFunction,
+registers all variables, and topologically sorts each container's blocks (deleting unreachable ones).
+The front end is done; everything from here on is tree rewriting.
The ILAst is the decompiler's central data structure — the representation on which all +analysis and most transformation happens. It deserves a close look before we walk the pipeline that +operates on it. (A two-paragraph summary also lives in doc/ILAst.txt, +repository root.)
+ +Every node derives from ILInstruction
+(IL/Instructions/ILInstruction.cs). Evaluating a node produces a value,
+void, a thrown exception, or the execution of a branch. The model's key properties:
Strict tree. A child belongs to exactly one parent
+(ValidateChild asserts "ILAst must form a tree"). This is what makes the
+stack-to-tree conversion sound and lets transforms move subtrees without aliasing surprises.
+Cross-references that would form a graph — branch targets, variables — are modeled as
+references (Branch.TargetBlock, ILVariable) rather than
+children.
Typed slots. Children are not a homogeneous list; each subclass stores them in
+named, typed slots (Block.InstructionSlot, TryCatchHandler.BodySlot,
+…). A slot's SlotInfo carries policy — notably CanInlineInto,
+which tells the inlining transform whether an expression may legally be moved into that position.
Everything knows its stack type. ResultType is abstract and
+mandatory; the reader warns whenever it would be Unknown on valid IL. Types stay explicit
+from the first minute, which is why no separate type-inference pass over the ILAst is needed.
Semantic flags. InstructionFlags
+(IL/InstructionFlags.cs) — MayThrow,
+SideEffect, MayReadLocals/MayWriteLocals,
+MayBranch, EndPointUnreachable, ControlFlow — are computed
+bottom-up, cached, and invalidated up the parent chain on mutation. The ControlFlow flag
+carries the model's central evaluation-order guarantee: if it is not set, all descendants
+evaluate exactly once, left to right, in pre-order. Transforms lean on these flags constantly
+("may I move this expression past that one?").
IL provenance. Every node carries an ILRange interval of original
+IL offsets, set at decode time and unioned as trees are rebuilt. This survives all the way to the C#
+AST and is what makes sequence-point generation (section 9) and ILSpy's
+click-to-navigate features possible.
There are roughly 200 concrete instruction classes, and nearly all of their code is
+generated: IL/Instructions.tt is a T4 template that
+declares each opcode's children, flags, and result type, and emits the constructors, child accessors,
+flag computation, visitor methods, WriteTo dumping, and structural
+Match… helpers into IL/Instructions.cs. Keeping ~200
+node classes consistent by hand would be hopeless; the template makes the slot/flag/visitor machinery
+uniform by construction.
ILVariable (IL/ILVariable.cs) represents parameters,
+locals, and everything the pipeline invents along the way. Its VariableKind records the
+provenance and is itself a small history of the pipeline:
| Kind | Created by | Meaning |
|---|---|---|
Parameter, Local, PinnedLocal | IL reader | +From the method signature and local-variable signature; PDB names recovered when available. |
StackSlot, ExceptionStackSlot | IL reader | +Materialized evaluation-stack values (S_n) and handler exception objects (E_n). |
UsingLocal, ForeachLocal, PinnedRegionLocal,
+PatternLocal | IL transforms | +Variables promoted when a using/foreach/fixed/pattern construct is recognized. |
DisplayClassLocal, InitializerTarget, NamedArgument,
+DeconstructionInitTemporary | IL transforms | +Bookkeeping for closure elimination, object initializers, argument reordering, deconstruction. |
Variables track their load, store, and address-taken counts, which many transforms use as cheap +preconditions (the inlining gate in section 6.2 is literally "one store, +one use").
+ +The ILAst does not keep a separate control-flow graph object; the CFG is embedded in the tree via
+two node types. A Block (IL/Instructions/Block.cs) is a
+list of instructions plus a FinalInstruction; a BlockContainer
+(IL/Instructions/BlockContainer.cs) owns a list of blocks and represents
+one single-entry control-flow region. The rules are strict:
Branch (goto) may target a block in its own container or in any
+enclosing container — branching outward implicitly leaves the inner container(s).
+What it can never do is jump into a container from outside, which is what keeps every
+container single-entry.Leave instruction
+naming it explicitly, the latter optionally carrying a result value. ret is simply
+leave of the outermost container; endfinally is a leave of the
+finally container.Regions nest: exception handlers are containers from the start, and the transform pipeline
+introduces more — a detected loop becomes a nested container of ContainerKind.Loop
+(where br entrypoint now means continue and
+leave means break), a detected switch a container of
+ContainerKind.Switch. Structure discovery is thus literally the act of wrapping flat
+block lists into deeper container trees:
Branch targets a block of the current or an enclosing container, Leave
+exits a named container. Loops and switches are containers introduced by transforms.This uniformity is a quiet superpower: break, continue,
+return, goto, and endfinally are all the same two node
+types, interpreted relative to the container structure. Transforms that restructure control flow
+never juggle label names or offset arithmetic — they move blocks between containers.
An ILFunction (IL/Instructions/ILFunction.cs) is itself
+an instruction, and it has a LocalFunctions child collection. The reader only ever
+produces top-level functions; transforms grow the tree downward by re-invoking an
+ILReader on compiler-generated methods and grafting the result in with a specific
+ILFunctionKind:
Delegate — lambda/anonymous-method bodies, created by
+DelegateConstruction;LocalFunction — created by LocalFunctionDecompiler;ExpressionTree — created by TransformExpressionTrees, which
+rebuilds a lambda from System.Linq.Expressions factory calls.The result mirrors the original lexical nesting: one tree of functions, each with its own variable
+collection, body container, and (once closure analysis has run) a CapturedVariables set.
+Most IL transforms iterate function.Descendants and therefore recurse into nested
+functions automatically.
Transforms recognize idioms structurally, and the ILAst gives them two tools. The generated code
+provides a Match… helper per instruction
+(inst.MatchLdcI4(out int value), MatchIfInstructionPositiveCondition(out cond, out
+trueInst, out falseInst), …) — the bread and butter of every transform. For larger
+shapes there is a small pattern facility (IL/Patterns/) with wildcard
+nodes and capture groups whose Match result is an allocation-free struct, so speculative
+matching is cheap. (See also doc/ILAst Pattern Matching.md.)
CheckInvariant(ILPhase) verifies parent/child consistency, flag correctness, and
+connectedness. The phase parameter exists because invariants tighten over time: in
+ILPhase.InILReader, branches may still point at offsets; from
+ILPhase.Normal on, the full rules apply. ILFunction.RunTransforms checks the
+invariant before and after every transform in debug builds — the practical reason the
+forty-pass pipeline stays debuggable.
After the front end, Greet is correct but ugly: stack slots, explicit gotos, no
+ternary. The middle end fixes that. CSharpDecompiler.GetILTransforms()
+(CSharp/CSharpDecompiler.cs) returns the ordered list of roughly forty
+transforms; ILFunction.RunTransforms executes them one after another on the method's
+ILFunction. Order is not incidental — the source is dotted with comments like
+"must run after inlining but before loop detection," and this section preserves them,
+because the ordering constraints are the architecture.
Three interfaces, three granularities (IL/Transforms/IILTransform.cs, +BlockTransform.cs, StatementTransform.cs):
+ +IILTransform.Run(ILFunction, ILTransformContext) — sees the whole function
+(and, via Descendants, all nested functions). Most passes are these.IBlockTransform.Run(Block, BlockTransformContext) — hosted by
+BlockILTransform, which builds a control-flow graph per container and walks the
+dominator tree in post-order. Post-order means a block is processed only after
+everything it dominates — so when loop detection or condition detection looks at a block, all
+nested structure inside it has already been built. A block transform may only modify its block and
+blocks dominated by it.IStatementTransform.Run(Block, pos, StatementTransformContext) — hosted by
+StatementTransform, which runs a set of statement transforms over a sliding
+window: starting at the end of the block and walking positions downward, at each position every child
+transform runs in turn, and each may only touch Instructions[pos..].Why the sliding window? Because sugar nests. An object initializer can appear inside a collection
+initializer inside an array initializer; running whole-pass A then whole-pass B would require A to
+handle B's output and vice versa. Interleaving them per statement means each transform can assume
+that everything later in the block is already fully reduced — the array-initializer
+transform sees one statement per element even when the element contains an object initializer. The
+comment in the pipeline says it directly: "pretty much all transforms that open up new expression
+inlining opportunities belong in this category." Coordination is via
+StatementTransformContext.RequestRerun(): a transform that changed something upstream
+asks for the position (or a higher one) to be revisited. Inlining runs first in the group precisely
+because it never needs a re-run itself — everyone else triggers it.
All tiers share ILTransformContext: the function, type system, settings, debug info,
+cancellation token, and the Stepper instrumentation (section 11). It
+can also create new ILReaders — the hook that lets transforms decompile
+other methods, which the state-machine and delegate transforms depend on.
The list below is GetILTransforms() verbatim, with the source's own ordering comments,
+grouped into six conceptual phases:
new ControlFlowSimplification(),
+// Run SplitVariables only after ControlFlowSimplification duplicates return blocks,
+// so that the return variable is split and can be inlined.
+new SplitVariables(),
+new ILInlining(),
+new InlineReturnTransform(), // must run before DetectPinnedRegions
+new RemoveInfeasiblePathTransform(),
+
+ControlFlowSimplification
+(IL/ControlFlow/ControlFlowSimplification.cs) removes nops,
+collapses branch chains, turns branches-to-return-blocks into returns, and merges blocks —
+mostly undoing debug-build codegen. SplitVariables
+(IL/Transforms/SplitVariables.cs) performs live-range splitting: a local
+that the compiler reused for several independent purposes becomes several variables, one per
+independent def-use group (it bails conservatively whenever an address-of use is not fully
+understood). Splitting matters because it manufactures the "one store, one load" property
+that inlining needs. ILInlining
+(IL/Transforms/ILInlining.cs) is then the workhorse of the entire
+pipeline: it moves the value of stloc v(expr) into the (unique) place where
+v is used, reversing the reader's conservative flushes. The gate is strict —
+exactly one store and exactly one use — and the search
+(FindLoadInNext) walks the next statement in evaluation order, answering
+Found (inline), Stop (a side effect or flag conflict blocks reordering), or
+Continue. Inlining runs again and again throughout the pipeline; nearly every other
+transform exists to unlock more of it.
new DetectPinnedRegions(), // must run after inlining but before non-critical control flow transforms
+new YieldReturnDecompiler(), // must run after inlining but before loop detection
+new AsyncAwaitDecompiler(), // must run after inlining but before loop detection
+new DetectCatchWhenConditionBlocks(), // must run after inlining but before loop detection
+new DetectExitPoints(),
+
+DetectPinnedRegions rebuilds fixed statements from
+pinned locals — it must run before any non-essential structure exists because pin lifetimes are
+a correctness matter, not cosmetics. The two state-machine decompilers get their own deep dive in
+section 6.3; the key scheduling fact is that both must run before
+loop detection: until the state machine is undone, a user loop containing yield or
+await has extra entry points — the resume paths that jump back into its middle
+after a suspension — so it is not a natural loop, and LoopDetection would not
+recognize it. DetectCatchWhenConditionBlocks folds the filter-block
+pattern back into catch … when (…), and
+DetectExitPoints rewrites branches that merely leave a container into
+explicit leave instructions, so later structure "falls out of" blocks instead
+of needing gotos.
new LdLocaDupInitObjTransform(),
+new EarlyExpressionTransforms(),
+new SplitVariables(), // split variables once again, because the stobj(ldloca V, ...) may open up new replacements
+new RemoveDeadVariableInit(), // must run after EarlyExpressionTransforms
+new ControlFlowSimplification(), // split variables may enable new branch to leave inlining
+new DynamicCallSiteTransform(),
+new SwitchDetection(),
+new SwitchOnStringTransform(),
+new SwitchOnNullableTransform(),
+new SplitVariables(), // split variables once again, because SwitchOnNullableTransform eliminates ldloca
+new IntroduceRefReadOnlyModifierOnLocals(),
+
+The repeated SplitVariables/ControlFlowSimplification entries illustrate
+the pipeline's rhythm: a structural transform eliminates an address-taken use, which lets splitting
+find more independent groups, which enables more inlining. RemoveDeadVariableInit uses
+definite-assignment analysis (section 6.6) to drop the compiler's defensive
+zero-initializations. DynamicCallSiteTransform collapses the CallSite
+caching boilerplate of dynamic back into first-class dynamic operations. The three switch
+transforms rebuild SwitchInstructions from compare chains, from string-hash dispatch
+(including the dictionary form Roslyn emits for many labels), and from Nullable<T>
+switches respectively.
new BlockILTransform { // per-block transforms
+ PostOrderTransforms = { new LoopDetection() }
+},
+new DetectExitPoints(), // re-run after loop detection
+new PatternMatchingTransform(), // must run after LoopDetection and before ConditionDetection
+
+Loop detection deliberately sits in its own BlockILTransform, before any
+if structure exists — the source comments that detecting loops after ifs
+"might make our life introducing good exit points more difficult." Details in
+section 6.4. PatternMatchingTransform reconstructs C# type and
+value patterns (x is string s) from isinst/null-check shapes, and must see
+raw conditional branches — hence "before ConditionDetection."
new BlockILTransform { // per-block transforms
+ PostOrderTransforms = {
+ new ConditionDetection(),
+ new LockTransform(),
+ new UsingTransform(),
+ // CachedDelegateInitialization must run after ConditionDetection and before/in LoopingBlockTransform
+ // and must run before NullCoalescingTransform
+ new CachedDelegateInitialization(),
+ new StatementTransform(
+ // per-block transforms that depend on each other, and thus need to
+ // run interleaved (statement by statement).
+ // Pretty much all transforms that open up new expression inlining
+ // opportunities belong in this category.
+ new ILInlining() { options = InliningOptions.AllowInliningOfLdloca },
+ // Inlining must be first, because it doesn't trigger re-runs.
+ // Any other transform that opens up new inlining opportunities should call RequestRerun().
+ new ExpressionTransforms(),
+ new DynamicIsEventAssignmentTransform(),
+ new TransformAssignment(), // inline and compound assignments
+ new NullCoalescingTransform(),
+ new NullableLiftingStatementTransform(),
+ new NullPropagationStatementTransform(),
+ new TransformArrayInitializers(),
+ new TransformCollectionAndObjectInitializers(),
+ new TransformExpressionTrees(),
+ new IndexRangeTransform(),
+ new DeconstructionTransform(),
+ new NamedArgumentTransform(),
+ new RemoveUnconstrainedGenericReferenceTypeCheck(),
+ new UserDefinedLogicTransform(),
+ new InterpolatedStringTransform()
+ ),
+ }
+},
+
+This is where most of C# reappears; section 6.5 catalogs the group.
+ +new ProxyCallReplacer(),
+new FixRemainingIncrements(),
+new CopyPropagation(),
+new DelegateConstruction(),
+new LocalFunctionDecompiler(),
+new TransformDisplayClassUsage(),
+new HighLevelLoopTransform(),
+new ReduceNestingTransform(),
+new RemoveRedundantReturn(),
+new IntroduceDynamicTypeOnLocals(),
+new IntroduceNativeIntTypeOnLocals(),
+new AssignVariableNames(),
+
+The lambda cluster comes first: DelegateConstruction turns
+new SomeDelegate(target) over a compiler-generated method into a nested
+ILFunction (reading the target's IL via the context, as described in section 5.4), and
+LocalFunctionDecompiler does the same for C# 7 local functions.
+TransformDisplayClassUsage then erases closures: a display class whose
+instance is default-constructed, never escapes, and is never an invocation target (guaranteed, since
+the lambdas over it were already rewritten) is scalar-replaced — its fields become plain locals
+of the enclosing function, recorded as captured variables of the nested ones.
+HighLevelLoopTransform classifies the loop containers built in phase 4
+into while, do…while, and for.
+ReduceNestingTransform restores source-like shape by duplicating
+keyword exits (return/break/continue) so a large
+else block can be flattened to statements following the if; and
+AssignVariableNames gives every surviving variable a readable name
+(PDB names when available, type-derived otherwise).
Nothing the C# compiler does is more destructive to structure than the state-machine rewrites for
+yield return and async/await. The user's method body is moved
+into a MoveNext() method on a compiler-generated type; locals that live across
+suspension points become fields; control flow becomes a dispatch on a state field. Undoing this is
+the job of YieldReturnDecompiler and AsyncAwaitDecompiler
+(IL/ControlFlow/), and they are the reason the transform context can
+spawn new IL readers:
MoveNext out of
+band, analyzes it symbolically, and splices the recovered body back into the user method.Both decompilers share the same skeleton. First the creation pattern in the visible
+method is matched (which generated type, which state field, which builder/current field). Then the
+generated MoveNext is read with a fresh ILReader and only
+EarlyILTransforms (simplification + splitting + inlining) — a mini-pipeline that
+normalizes the body without building structure that would get in the way. The analysis core is
+StateRangeAnalysis, which symbolically executes the dispatch code to
+compute, for each block, the set of state values that can reach it (as LongSet ranges),
+and SymbolicExecution, a small abstract interpreter over values like
+"the state field", "this", or "integer constant." For iterators this
+also recovers the mapping from states to enclosing try regions so
+yield return inside try…finally reconstructs correctly; for async,
+DetectAwaitPattern recognizes each suspension point's awaiter dance and replaces it with
+an await ILAst instruction. Finally field accesses are translated back to locals, the
+body replaces the stub, and control-flow cleanup re-runs over the newly created gotos
+(AwaitInCatchTransform/AwaitInFinallyTransform handle the especially gnarly
+C# 6 await-in-catch codegen).
The robustness policy is explicit here: every "this is not what the C# compiler emits"
+discovery throws SymbolicAnalysisFailedException, which the transform catches, leaving
+the method as an ordinary (if odd-looking) method that calls MoveNext. Obfuscated state
+machines degrade to readable-but-literal code instead of wrong code.
Loop detection (IL/ControlFlow/LoopDetection.cs) is
+classic compiler theory run in reverse. Dominance is computed by the Cooper–Harvey–Kennedy
+"simple, fast dominance" algorithm (FlowAnalysis/Dominance.cs).
+An edge t → h is a back edge iff h dominates t;
+the natural loop of that back edge is the smallest block set containing it with no external
+predecessors except the header's. Natural loops sharing a header are unioned, extended to include
+nested-loop blocks, then wrapped in a new BlockContainer of kind Loop.
+Because the driver visits the dominator tree post-order, inner loops always exist before the outer
+loop is formed. At this point every loop is still a while (true) with
+leave/br exits — classifying it as
+while/do/for happens much later
+(HighLevelLoopTransform), after conditions and sugar have cleaned up the loop's guts.
ConditionDetection
+(IL/ControlFlow/ConditionDetection.cs) then builds
+if/else: for a block ending in if (c) br A; br B, blocks
+dominated by the current one are folded into the IfInstruction's then/else children
+(post-order again guarantees they are already fully structured inside). The output intentionally
+prefers the source's IL order, so decompiled conditions usually read in the order the original code
+was written.
In the running example, ConditionDetection turns the four blocks of Figure 2 into a
+single block:
if (ldarg polite) {
+ stloc S_0(ldstr "Good day!")
+} else {
+ stloc S_0(ldstr "Hi.")
+}
+call WriteLine(ldloc S_0)
+leave IL_0000
+
+Then, inside the same phase-5 pass, the statement transforms finish the job:
+ExpressionTransforms.HandleConditionalOperator (step name "conditional
+operator") recognizes an if/else whose two arms store to the same variable and fuses them into
+stloc S_0(if (polite) … else …) — the ILAst form of a ternary —
+and ILInlining, now seeing a single store and single load, inlines it into the call:
call WriteLine(if (ldarg polite) ldstr "Good day!" else ldstr "Hi.")
+leave IL_0000
+
+The stack slot is gone, and the ILAst is now shaped exactly like the original source. Note the +division of labor this example demonstrates: a control-flow transform created the structure, +an expression transform recognized the idiom, and inlining stitched the result into +its consumer — three small transforms, each trivial in isolation.
+ +The interleaved statement group in phase 5, plus a few block transforms around it, is the map of +"which construct gets detected where":
+ +| Transform | Reconstructs | Settings gate |
|---|---|---|
LockTransform | lock (x) { } from Monitor.Enter/Exit try/finally | LockStatement |
UsingTransform | using statements from Dispose() try/finally | UsingStatement |
CachedDelegateInitialization | removes if (cache == null) cache = new D(...) | AnonymousMethods |
ExpressionTransforms | peephole cleanup; the conditional (ternary) operator; entry point into nullable lifting and null propagation | — |
TransformAssignment | compound assignment (x += y), increments (x++), inline assignment (a = b = c) | MakeAssignmentExpressions |
NullCoalescingTransform | ?? for reference types | NullCoalescing-related |
NullableLiftingStatementTransform | lifted operators over Nullable<T>, value-type ?? | LiftNullables |
NullPropagationStatementTransform | ?. from v != null ? v.M() : null | NullPropagation |
TransformArrayInitializers | array and stackalloc initializers (incl. the InitializeArray data-blob form) | ArrayInitializers |
TransformCollectionAndObjectInitializers | new T { ... } object/collection initializers | ObjectOrCollectionInitializers |
TransformExpressionTrees | lambdas from System.Linq.Expressions factory-call trees | ExpressionTrees |
IndexRangeTransform | ^ and .. (System.Index/Range access) | Ranges |
DeconstructionTransform | (a, b) = ... deconstruction | Deconstruction |
NamedArgumentTransform | named arguments (to preserve evaluation order without temps) | NamedArguments |
UserDefinedLogicTransform | user-defined &&/|| via op_True/op_BitwiseAnd | — |
InterpolatedStringTransform | $"..." from DefaultInterpolatedStringHandler calls | StringInterpolation |
Not everything is done at the IL level: notably, string.Concat calls become the
++ operator and query expressions are rebuilt only in the C# AST stage
+(section 8), where operator syntax is directly expressible; and the enumerator
+foreach idiom is reconstructed during the translation itself, by
+StatementBuilder (section 7.5). As a rule of thumb: anything that
+changes data flow or control flow is an IL transform; anything that is purely surface
+syntax comes later, in the back end or the AST transforms.
The FlowAnalysis/ namespace supplies the machinery the structural transforms lean on:
+ControlFlowNode/dominator computation (used by loop and switch detection and the block
+driver), and a generic forward dataflow framework, DataFlowVisitor<State>, whose
+state type must form a join-semilattice with finite height (there is a MeetWith for
+try/finally merging, too). Its two main instantiations are
+DefiniteAssignmentVisitor ("is there a path from the entry that does not write this
+variable?" — powering RemoveDeadVariableInit) and
+ReachingDefinitionsVisitor (which stores can reach a load — powering the
+correctness checks in inlining, copy propagation, and variable splitting via the related
+GroupStores analysis).
Finally, settings. The transform list is fixed; behavior is gated inside each transform by
+DecompilerSettings flags, and SetLanguageVersion flips those flags in
+blocks: targeting C# 4 switches off asyncAwait; targeting C# 7 leaves
+patternMatching and localFunctions off; and so on up through the current
+C# 15 features (e.g. closedHierarchies). A disabled feature does
+not merely change printing: the pattern is simply never folded, so the underlying mechanism (the
+state machine, the display class) stays visible — decompiling with old settings is the
+supported way to study the compiler's lowering of new features.
By the end of the IL pipeline, the ILAst is semantically C#-shaped but still an ILAst. The back end
+converts it into an actual C# syntax tree. Three cooperating classes do the work
+(CSharp/StatementBuilder.cs, ExpressionBuilder.cs, CallBuilder.cs):
+StatementBuilder visits statement-level instructions (blocks, loops, try/catch, switch,
+stores) and produces C# statements (section 7.5); it owns an ExpressionBuilder, which visits
+value-producing instructions and produces C# expressions; call instructions are handed to
+CallBuilder, which is complicated enough to be its own type. Both builders are
+ILVisitors with one method per ILAst opcode.
ExpressionBuilder never returns a bare syntax node. Its result type,
+TranslatedExpression (CSharp/TranslatedExpression.cs),
+pairs the expression with its ResolveResult — the semantic description of what the
+expression means: its type, its constant value if any, the member it binds to. Alongside it,
+annotations attach the originating ILInstructions. The class documentation states the
+post-condition as a contract: every translated expression carries both annotations, and evaluating
+the C# expression must produce the same side effects and a similar value as the IL instruction it
+came from. Helper structs in CSharp/Annotations.cs
+(ExpressionWithResolveResult, ExpressionWithILInstruction) form a small
+type-state machine, so forgetting an annotation is a compile error in the decompiler itself,
+not a latent bug. These annotations are not just bookkeeping: the resolve results feed every
+subsequent correctness check, and the IL instructions carry the offsets that become sequence points
+(section 9) and navigation metadata.
IL is looser than C#: the evaluation stack knows I4 where C# distinguishes
+int, short, bool, and enums; IL conversions are explicit
+opcodes where C# has implicit conversions and inference. The bridging method is
+TranslatedExpression.ConvertTo(targetType, …), whose documented post-condition is
+that the result evaluates to the same value the IL conv instruction would produce. Its
+governing principle: emit nothing unless necessary. If the current type already
+matches (ignoring nullability and tuple-name differences), the expression is returned unchanged; with
+implicit conversions allowed, it will even strip a cast that turns out to be redundant. When a
+conversion is needed, it asks the resolver (CSharpResolver.ResolveCast) what
+that cast means: constant-foldable casts are folded, impossible direct casts are routed through
+object, and checked/unchecked context is recorded as an annotation for the
+AddCheckedBlocks AST transform to place checked{} regions later. Special
+cases abound — bool/integer bridging, native integers, enum/pointer conversions, managed
+references via Unsafe.As — but they all flow through the same
+resolver-consultation pattern.
Here is the back end's headline design decision. Printing a call is easy; printing a call that
+recompiles to the same call is not, because C# will run type inference, overload resolution,
+extension-method lookup and implicit conversions over whatever the decompiler writes. The defense is
+mechanical: the decompiler contains a complete C# semantic engine
+(CSharp/Resolver/ — CSharpResolver,
+OverloadResolution implementing the C# spec's algorithm, MemberLookup,
+CSharpConversions, TypeInference; a lineage inherited from NRefactory),
+and CallBuilder uses it as an oracle: after building a candidate call syntax, it
+re-resolves that syntax and checks whether it binds to exactly the member the IL called. If not, it
+repairs the syntax incrementally — least invasive fix first — and re-checks:
CallBuilder
+(GetRequiredTransformationsForCall, with IsUnambiguousCall as the oracle).
+Parallel loops exist for property/indexer accessors and method-group references.This is why decompiled code has casts exactly where they matter: a cast to select an overload, a
+(IDisposable) before a struct's explicit interface call, an explicit type argument where
+inference would pick differently — and nowhere else. It also explains an easily-missed cost
+profile: the decompiler runs real overload resolution for essentially every call it prints.
Beyond the repair loop, CallBuilder decides the surface form of every invocation:
+collapsing accessor calls into property/indexer syntax, recognizing operator methods
+(op_Addition et al.) so they can later become operators, expanding or preserving
+params form, omitting trailing arguments that match parameter defaults (gated by
+settings.OptionalArguments), building delegate constructions and method-group
+references, and rendering tuple construction as tuple literals. IL's tail. prefix, which
+has no C# syntax, is surfaced honestly as a /*tail.*/ comment.
Translation actually starts one level above the expressions: CSharpDecompiler hands
+the function body to StatementBuilder.ConvertAsBlock, and everything in sections
+7.1–7.4 runs in service of the statements built here. Much of StatementBuilder is
+a direct mapping, because the IL transforms have already produced high-level instructions and each
+gets its C# form: TryCatch/TryFinally/TryFault become
+try statements, LockInstruction becomes lock,
+UsingInstruction becomes using, PinnedRegion becomes
+fixed, YieldReturn becomes yield return. The interesting work
+is in control flow: a BlockContainer is rendered according to its
+ContainerKind — Loop as while (true);
+While/DoWhile/For (classified by
+HighLevelLoopTransform) matched via MatchConditionBlock into the
+corresponding loop statement; a container whose entry point is a single
+SwitchInstruction as a switch. While converting, the builder tracks the
+current continue and break targets, so branches become continue/break
+keywords wherever the container structure allows; only branches no keyword can express survive as
+labels and goto.
One language construct is detected here rather than in any transform: enumerator-based
+foreach. When VisitUsingInstruction sees a using over
+a GetEnumerator() call whose body is a while (MoveNext()) loop reading
+Current, TransformToForeach rebuilds the foreach statement:
+DetectGetCurrentTransformation classifies how the Current value flows into
+the body, the iteration variable becomes a VariableKind.ForeachLocal, and the declared
+element type is checked against what foreach would infer. The pattern lives at this
+stage because it is a statement shape — a using wrapping a
+while — that only exists once statements are being assembled; if any part of the
+idiom fails to hold, the code simply stays an explicit using+while, which
+remains correct. (The index-based foreach forms — over arrays, multi-dimensional
+arrays, and inline arrays — are recognized later, at the AST level; see
+section 8.)
The finished ForeachStatement is annotated with a ForeachAnnotation
+(CSharp/Annotations.cs) recording the underlying
+GetEnumerator/MoveNext/get_Current IL calls. This annotation
+is not a hint for later folding — the statement is already in final form — it exists for
+debug-info generation: SequencePointBuilder reads it to map the foreach
+header back to the IL calls it stands for when emitting sequence points
+(section 9). (IntroduceUsingDeclarations also consults it, to import
+the namespace of an extension GetEnumerator method.)
For the running example, StatementBuilder visits the call statement,
+CallBuilder resolves Console.WriteLine with a
+ConditionalExpression argument of type string, confirms via overload
+resolution that WriteLine(string) is selected unambiguously — no casts needed
+— and the tree for Console.WriteLine(polite ? "Good day!" : "Hi."); is complete.
The C# tree (CSharp/Syntax/, rooted at SyntaxTree) uses
+the same architectural ideas as the ILAst: strict tree, typed slots, invariant checks after every
+transform. The mechanical per-node code — visitor dispatch, structural pattern matching
+(DoMatch), slot metadata, cloning — is emitted by a Roslyn source generator
+(ICSharpCode.Decompiler.Generators/DecompilerSyntaxTreeGenerator.cs)
+from attributes on partial node classes; the ILAst uses a T4 template, the C# AST a source generator,
+but the philosophy is identical. One deliberate omission defines the design: nodes do not
+know operator precedence. The tree is pure structure, so transforms can rearrange it without
+ever reasoning about parentheses — those are reconstructed at output time
+(section 9).
Semantic linkage is again by annotation: every node can carry its ResolveResult /
+ISymbol, its ILInstructions, and its ILVariable
+(CSharp/Annotations.cs; accessors like GetSymbol(),
+GetResolveResult()). Purpose-built annotations record how a construct was assembled
+— e.g. the ForeachAnnotation attached by StatementBuilder
+(section 7.5) keeps a rebuilt foreach's
+GetEnumerator/MoveNext/Current calls addressable for sequence-point generation
+(section 9). Declarations and
+signatures, as opposed to bodies, are produced from type-system entities by
+TypeSystemAstBuilder (CSharp/Syntax/TypeSystemAstBuilder.cs)
+— the same class that renders types everywhere in the output.
GetAstTransforms() (CSharp/CSharpDecompiler.cs), again
+verbatim with its ordering comments:
new PatternStatementTransform(),
+new ReplaceMethodCallsWithOperators(), // must run before DeclareVariables.EnsureExpressionStatementsAreValid
+new IntroduceUnsafeModifier(),
+new AddCheckedBlocks(),
+new DeclareVariables(), // should run after most transforms that modify statements
+new TransformFieldAndConstructorInitializers(), // must run after DeclareVariables
+new PrettifyAssignments(), // must run after DeclareVariables
+new IntroduceUsingDeclarations(),
+new IntroduceExtensionMethods(), // must run after IntroduceUsingDeclarations
+new IntroduceQueryExpressions(), // must run after IntroduceExtensionMethods
+new CombineQueryExpressions(),
+new NormalizeBlockStatements(),
+new FlattenSwitchBlocks(),
+new FixNameCollisions(),
+new AddXmlDocumentationTransform(),
+
+In reading order:
+ +PatternStatementTransform is the AST-level counterpart of the IL
+sugar transforms: it completes for loops (moving a preceding initializer statement into
+the for header, or converting a while of the right shape), rebuilds the
+index-based foreach forms — over arrays, multi-dimensional arrays, and inline
+arrays — from their for-loop lowering (the enumerator-based foreach
+was already built by StatementBuilder, section 7.5), and recognizes
+automatic properties and events — member idioms that are easier to see once real C# syntax
+exists.ReplaceMethodCallsWithOperators turns surviving operator-method
+calls into operators, string.Concat into +,
+Type.GetTypeFromHandle(ldtoken …) into typeof, and delegate
+Combine/Remove into +=/-=.IntroduceUnsafeModifier and AddCheckedBlocks
+place unsafe modifiers and minimal-scope checked/unchecked
+blocks, consuming the annotations planted by ConvertTo and the arithmetic visitors.DeclareVariables is the scope analysis: ILAst variables have no
+declaration site, so this pass computes, per variable, the narrowest insertion point covering all
+uses, merges declaration with first assignment where possible, and chooses var versus
+explicit types per settings. Several later transforms rely on declarations existing, hence its
+position.TransformFieldAndConstructorInitializers hoists field
+initializations out of constructor bodies onto the field declarations and turns
+this(…)/base(…) calls into constructor initializers;
+PrettifyAssignments contracts x = x + 1 to
+x++ and friends (only safe now that variables are declared — the two
+xs might have been different ILAst variables).IntroduceUsingDeclarations collects the namespaces used by the
+final C# AST, emits using directives for them, shortens qualified names, and leaves
+anything that would become ambiguous fully qualified. Namespaces are actually collected
+twice: a superset was already gathered from the IL at the very start of the pipeline, into
+the DecompileRun (section 2), because IL transforms need to know the
+eventual imports to answer ambiguity questions — null propagation, for example, may rewrite
+a != null ? Extensions.Method(a) : null to a?.Method() only if it can
+guarantee the extension-method form will resolve unambiguously. Since many compiler-generated calls
+have disappeared by the time the AST exists, the AST-derived set is a subset of the
+DecompileRun's, and only this subset gets directives. Only after imports exist can
+IntroduceExtensionMethods rewrite
+Enumerable.Where(xs, p) to xs.Where(p) (an ordering that predates the
+DecompileRun namespace superset), and only after that can
+IntroduceQueryExpressions rebuild from …
+select query syntax (running the C# spec's query translation backwards), with
+CombineQueryExpressions merging nested queries and dissolving the
+compiler's transparent identifiers.NormalizeBlockStatements
+(brace style, redundant blocks), FlattenSwitchBlocks,
+FixNameCollisions (renames so the output still compiles — e.g. a
+backing field colliding with an event name), and
+AddXmlDocumentationTransform, which attaches /// docs
+from the assembly's XML documentation file.After the last transform, RunTransforms runs two final visitors that prepare for
+printing: InsertParenthesesVisitor and GenericGrammarAmbiguityVisitor —
+which belong to the next section.
Rendering is a pipeline of its own, and its stages are deliberately dumb — all intelligence +was spent upstream:
+ +ITextOutput
+abstraction let plain-text and rich-UI rendering share one path.Parentheses last. Because the tree stores no precedence,
+InsertParenthesesVisitor (CSharp/OutputVisitor/)
+reconstructs the required parentheses from precedence and associativity in one pass — and, in
+its InsertParenthesesForReadability mode (on by default in the decompiler), adds a few
+beyond the minimum, e.g. around nested ternaries. GenericGrammarAmbiguityVisitor handles
+the classic F(a<b, c>(d)) ambiguity where a generic method call could parse as
+comparisons. Keeping this out of the transforms means fifteen passes never had to think about
+printing.
Tokens through decorators. CSharpOutputVisitor walks the tree and
+drives an abstract TokenWriter. Concrete writers are stacked:
+InsertRequiredSpacesDecorator guarantees token separation;
+InsertMissingTokensDecorator (optional) synthesizes punctuation tokens and records text
+locations back onto AST nodes when callers need source positions; the terminal writer either formats
+into a plain TextWriter or — via TextTokenWriter
+(Output/TextTokenWriter.cs) — into an ITextOutput
+(Output/ITextOutput.cs). ITextOutput is the UI extension
+point: its WriteReference and MarkFoldStart/End calls carry the semantic
+annotations (which member does this identifier refer to?) that ILSpy's text view turns into
+hyperlinks, tooltips, and folding, while PlainTextOutput simply discards them. The
+decompiler core never references a UI type.
Sequence points. Because every AST node still knows its
+ILInstructions, and every ILAst node its IL offset ranges,
+SequencePointBuilder (CSharp/SequencePointBuilder.cs) can
+walk the final tree and emit text-location ↔ IL-offset mappings per function. This
+feeds DebugInfo/PortablePdbWriter.cs, which writes a portable PDB for
+the decompiled source — the basis for "debug the decompiled code" scenarios. The IL
+reader's SequencePointCandidates (offsets where the stack is empty, recorded back in the
+front end) help choose good statement boundaries, and construct-level annotations fill in where one
+piece of syntax stands for several IL calls — ForeachAnnotation
+(section 7.5) tells the builder which calls the foreach header
+corresponds to. It is the payoff for threading ILRange provenance through every single
+stage.
Everything so far decompiled one method body. The surrounding machinery assembles bodies into +types, files, and projects.
+ +DoDecompileTypeDefinition (CSharp/CSharpDecompiler.cs)
+builds a type's declaration shell with TypeSystemAstBuilder.ConvertEntity, then
+decompiles each member in metadata order — running the full IL + AST pipeline per body —
+and inserts the results. Two filters decide what the reader never sees:
MemberIsHidden suppresses compiler-generated artifacts whose
+content has been folded elsewhere: state-machine types (consumed by the async/iterator decompilers),
+display classes (consumed by closure elimination), local-function methods, auto-property backing
+fields, fixed-buffer types. The checks mirror the transform settings — if
+AnonymousMethods is off, display classes stay visible, keeping the output
+self-consistent.RecordDecompiler (CSharp/RecordDecompiler.cs),
+instantiated per record type, verifies member-by-member that Equals,
+GetHashCode, ToString, Deconstruct, the copy constructor and
+EqualityContract match exactly what the compiler would synthesize — and only then
+hides them, so a concise record declaration is emitted. Hand-modified
+"records" keep their unusual members visible.WholeProjectDecompiler (CSharp/ProjectDecompiler/)
+turns an assembly into a compilable Visual Studio project: it groups top-level types into files
+(namespaces as directories), decompiles files in parallel — one CSharpDecompiler
+per worker, since instances are single-threaded — extracts resources (.resources
+back into .resx), emits AssemblyInfo, and writes an SDK-style or legacy
+project file. Types that must share a file (partial classes, WinForms designer splits) are handled
+through PartialTypeInfo, which tells each per-file decompiler which members to emit and
+marks the type partial.
The IL view in ILSpy is not this pipeline with different printing — it is a separate,
+much simpler back end: ReflectionDisassembler
+(Disassembler/ReflectionDisassembler.cs) walks metadata and writes
+ILAsm text straight to an ITextOutput, with ILStructure recovering
+try/loop nesting for code folding. Sharing only the output abstraction keeps it dependable when the
+C# pipeline would balk.
Hosts see all of this through a language layer: ICSharpCode.ILSpyX defines
+ILanguage, and the ILSpy app implements CSharpLanguage (wrapping
+CSharpDecompiler / WholeProjectDecompiler), ILLanguage
+(wrapping the disassembler), and the mixed/debug views. Every language writes to
+ITextOutput, which is what lets the same engine serve the WPF UI, headless tests, and
+ilspycmd unchanged.
Invariants as a debugging strategy. Both trees validate themselves after every +transform in debug builds. Combined with the strict-tree rule and slot typing, the common failure +mode of a forty-pass pipeline — pass 12 corrupts, pass 31 crashes — largely disappears: +the corrupting pass fails its own post-check.
+ +Watching the pipeline run. Every transform reports steps through the
+Stepper (CSharp/CSharpDecompiler.cs;
+context.Step(…) calls are compiled in only for builds with the
+STEP constant). ILSpy's DebugSteps pane consumes this to show the transform
+tree and let you re-run decompilation stopped after any step — the single most useful tool for
+understanding or debugging a transform. The textual ILAst dump (WriteTo on any
+instruction, options in IL/ILAstWritingOptions.cs) is what this
+document's intermediate listings imitate, and the UI's "ILAst" language exposes it
+directly.
Tests as the real specification. The pattern each transform matches is defined, +in practice, by the test suite: ICSharpCode.Decompiler.Tests contains +hundreds of "pretty" fixtures — C# source compiled by a matrix of compilers and +options, decompiled, and diffed against expected output — plus round-trip tests that recompile +the decompiler's output. When the C# compiler changes its codegen, these fixtures are where the new +pattern lands first (see ICSharpCode.Decompiler.Tests/CLAUDE.md for the +fixture structure). A transform PR without a fixture is architecturally incomplete: the fixture +is the pattern's definition.
+ +Where to start reading code. A good first trace mirrors this document:
+CSharpDecompiler.Decompile → ILReader.ReadIL →
+GetILTransforms() (set a breakpoint in ILFunction.RunTransforms) →
+StatementBuilder.ConvertAsBlock → GetAstTransforms() →
+CSharpOutputVisitor. For any specific construct, find its transform in the phase tables
+above, then read the transform's tests.
| Stage | Namespace / directory | Key classes |
|---|---|---|
| Metadata loading | Metadata/ |
+MetadataFile, PEFile, WebCilFile, UniversalAssemblyResolver |
| Type system | TypeSystem/ |
+DecompilerTypeSystem, MetadataModule, TypeSystemOptions |
| IL reading | IL/ |
+ILReader, BlockBuilder |
| ILAst model | IL/Instructions/ (generated from Instructions.tt) |
+ILInstruction, ILFunction, Block, BlockContainer, ILVariable |
| IL transforms | IL/Transforms/, IL/ControlFlow/ |
+ILInlining, SplitVariables, LoopDetection, ConditionDetection,
+AsyncAwaitDecompiler, YieldReturnDecompiler, TransformDisplayClassUsage |
| Flow analyses | FlowAnalysis/ |
+Dominance, ControlFlowNode, DataFlowVisitor<T>, DefiniteAssignmentVisitor |
| ILAst → C# | CSharp/ |
+StatementBuilder, ExpressionBuilder, CallBuilder, TranslatedExpression |
| C# semantics | CSharp/Resolver/, Semantics/ |
+CSharpResolver, OverloadResolution, CSharpConversions, TypeInference |
| C# syntax tree | CSharp/Syntax/ |
+AstNode, SyntaxTree, TypeSystemAstBuilder |
| AST transforms | CSharp/Transforms/ |
+PatternStatementTransform, DeclareVariables, IntroduceUsingDeclarations, IntroduceQueryExpressions |
| Rendering | CSharp/OutputVisitor/, Output/ |
+CSharpOutputVisitor, InsertParenthesesVisitor, TokenWriter, ITextOutput |
| Debug info | DebugInfo/, CSharp/SequencePointBuilder.cs |
+SequencePointBuilder, PortablePdbWriter, IDebugInfoProvider |
| Projects & types | CSharp/ProjectDecompiler/ |
+WholeProjectDecompiler, RecordDecompiler, PartialTypeInfo |
| IL disassembly | Disassembler/ |
+ReflectionDisassembler, MethodBodyDisassembler, ILStructure |
This document was derived from the source code in this +repository in July 2026. When the text and the code disagree, the code (and its tests) win — +please update this file when the architecture moves.
+ + +