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, ICompilation) 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.