doc/DecompilerArchitecture.html
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.
INPUTAssembly filePE / WebCIL / bundleMetadata layerPEFile : MetadataFileSystem.Reflection.MetadataType systemDecompilerTypeSystemresolved types & membersReferenced assembliesUniversalAssemblyResolverGAC / shared FX / NuGetper method: IL bytes + generic contextFRONT END + MIDDLE ENDILReaderdecodes IL, simulates theevaluation stackBlockBuilderblocks, containers,try/catch nestingILAstILFunctiontyped instruction treeIL transforms~40 ordered passes:state machines, loops, sugarraised ILAst (semantically C#-shaped)BACK ENDStatement/Expression/CallBuilderresolver-checked translationC# ASTSyntaxTreeAST transforms15 prettification passesOutputVisitorparentheses, tokens,formattingITextOutputplain text, or rich text with hyperlinks (UI)ReflectionDisassemblerIL view: metadata to text directly Figure 1 — The decompilation pipeline. The IL disassembler is a parallel back end that shares only the output abstraction.
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:
Block IL_0000if (ldarg polite) br IL_000abr IL_0003Block IL_0003stloc S_0(ldstr "Hi.")br IL_000fBlock IL_000astloc S_0(ldstr "Good day!")br IL_000fBlock IL_000fcall WriteLine(ldloc S_0)leave IL_0000 (nop)false (fall-throughmade explicit)truestack: [S_0 : O]stack: [S_0 : O]two slots, merged by union-find Figure 2 — 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:
ILFunction Greet(polite)BlockContainer (main body)Block IL_0000entry pointBlockContainer (Kind = Loop)Block (head)Block (body)br head= continueTryFinallytry:BlockContainerBlock ...finally:BlockContainerBlock ...leave (null) = endfinallyleave loop = breakdashed = Leave (exits a container); solid = Branch (goto) Figure 3 — The CFG lives in the tree: containers are single-entry regions; 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..].Tier 1: IILTransform — whole ILFunctionruns once per function; iterates Descendants (including nested ILFunctions)SplitVariables, ILInlining, AsyncAwaitDecompiler, DelegateConstruction, ...BlockILTransform hosts tier 2Tier 2: IBlockTransform — per block, dominator tree post-orderCFG built per BlockContainer; children of the dominator tree are finished first,so inner loops / nested ifs exist before the enclosing block is processedLoopDetection, ConditionDetection, LockTransform, UsingTransform3211visit orderStatementTransform hosts tier 3Tier 3: IStatementTransform — sliding window inside one blockpos walks from the last instruction to the first; at each pos all child transforms run;a transform may only modify Instructions[pos..]; RequestRerun() repeats a positionposwindow moves leftILInlining, ExpressionTransforms, TransformAssignment, NullCoalescingTransform, ... Figure 4 — The three-tier driver. Each tier is hosted by a transform of the tier above it.
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:
async Task M() (the stub)sm.<>t__builder = Create();sm.<>1__state = -1;sm.<>t__builder.Start(ref sm);return sm.<>t__builder.Task;struct <M>d__0 (compiler-generated)int <>1__state;AsyncTaskMethodBuilder <>t__builder;TaskAwaiter <>u__1; + hoisted localsvoid MoveNext() {switch (state) ... awaiter.GetResult() ...}(1) creation patternmatchedAsyncAwaitDecompiler analysis(2) ILReader on MoveNext + EarlyILTransforms only(3) StateRangeAnalysis: which blocks belong to which state(4) DetectAwaitPattern: awaiter save / IsCompleted /AwaitUnsafeOnCompleted / GetResult per suspension point(5) TranslateFieldsToLocalAccess: hoisted fields become localsM's ILFunction, body spliced back inawait expressions in place; EarlyILTransforms re-run; AwaitInCatch/FinallyTransform(6) InlineBodyOfMoveNextstub replaced Figure 6 — The async decompiler reads the generated 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 &&/` | |
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:
Build minimal call syntaxarguments ConvertTo parameter typesRe-resolve the syntaxOverloadResolution (spec 7.5)binds to the originalmember & form?DoneyesApply the next least-invasive fix1. drop cosmetic named/optional-argument forms2. insert explicit casts on arguments3. qualify the target (this. / TypeName.)4. cast the target to the declaring type5. add explicit type arguments6. enforce explicit "in" modifiersexhausted: emit as-is (rare, pathological IL)noretry Figure 7 — The repair loop in 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:
SyntaxTree (annotated)structure only; no parentheses, no tokensInsertParenthesesVisitorprecedence-driven parens (+ readability mode); GenericGrammarAmbiguityVisitorCSharpOutputVisitortree walk, formatting policy (CSharpFormattingOptions), emits tokensTokenWriter decorator chainInsertRequiredSpacesDecorator -> [InsertMissingTokensDecorator] ->TextWriterTokenWriter | TextTokenWriterPlainTextOutputCLI, tests, files (WholeProjectDecompiler)rich ITextOutput (ILSpy UI)syntax colors, hyperlinks, code foldingITextOutput: WriteReference / MarkFoldStart hooks Figure 8 — From tree to text. The decorator chain and the 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.