The ILSpy Decompiler: Architecture and Design

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.

1. Introduction and design goals

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:

  1. 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).

  2. 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.

  3. 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.

  4. 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).

2. The pipeline at a glance

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.

INPUT Assembly file PE / WebCIL / bundle Metadata layer PEFile : MetadataFile System.Reflection.Metadata Type system DecompilerTypeSystem resolved types & members Referenced assemblies UniversalAssemblyResolver GAC / shared FX / NuGet per method: IL bytes + generic context FRONT END + MIDDLE END ILReader decodes IL, simulates the evaluation stack BlockBuilder blocks, containers, try/catch nesting ILAst ILFunction typed instruction tree IL transforms ~40 ordered passes: state machines, loops, sugar raised ILAst (semantically C#-shaped) BACK END Statement/Expression/ CallBuilder resolver-checked translation C# AST SyntaxTree AST transforms 15 prettification passes OutputVisitor parentheses, tokens, formatting ITextOutput plain text, or rich text with hyperlinks (UI) ReflectionDisassembler IL 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:

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.

A running example

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

3. Inputs: metadata and the type system

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.

3.1 The metadata layer

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.

3.2 The type system

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.

4. Front end: from IL bytes to ILAst

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.

4.1 Simulating the evaluation stack

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):

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.

4.2 Worklist import and stack-type merging

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.

4.3 The running example through the reader

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_0000 if (ldarg polite) br IL_000a br IL_0003 Block IL_0003 stloc S_0(ldstr "Hi.") br IL_000f Block IL_000a stloc S_0(ldstr "Good day!") br IL_000f Block IL_000f call WriteLine(ldloc S_0) leave IL_0000 (nop) false (fall-through made explicit) true stack: [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.

4.4 BlockBuilder: containers and exception handlers

BlockBuilder converts the reader's flat, offset-ordered block list into the nested structure described in section 5. It works in three steps:

  1. 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.
  2. 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.
  3. 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.

5. The ILAst

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

5.1 The instruction model

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:

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.

5.2 Variables

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:

KindCreated byMeaning
Parameter, Local, PinnedLocalIL reader From the method signature and local-variable signature; PDB names recovered when available.
StackSlot, ExceptionStackSlotIL reader Materialized evaluation-stack values (S_n) and handler exception objects (E_n).
UsingLocal, ForeachLocal, PinnedRegionLocal, PatternLocalIL transforms Variables promoted when a using/foreach/fixed/pattern construct is recognized.
DisplayClassLocal, InitializerTarget, NamedArgument, DeconstructionInitTemporaryIL 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").

5.3 Control flow as nested containers

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:

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_0000 entry point BlockContainer (Kind = Loop) Block (head) Block (body) br head = continue TryFinally try: BlockContainer Block ... finally: BlockContainer Block ... leave (null) = endfinally leave loop = break dashed = 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.

5.4 Nested functions

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:

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.

5.5 Pattern matching on the ILAst

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

5.6 Invariants

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.

6. The IL transform pipeline

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.

6.1 How transforms are driven

Three interfaces, three granularities (IL/Transforms/IILTransform.cs, BlockTransform.cs, StatementTransform.cs):

Tier 1: IILTransform — whole ILFunction runs once per function; iterates Descendants (including nested ILFunctions) SplitVariables, ILInlining, AsyncAwaitDecompiler, DelegateConstruction, ... BlockILTransform hosts tier 2 Tier 2: IBlockTransform — per block, dominator tree post-order CFG built per BlockContainer; children of the dominator tree are finished first, so inner loops / nested ifs exist before the enclosing block is processed LoopDetection, ConditionDetection, LockTransform, UsingTransform 3 2 1 1 visit order StatementTransform hosts tier 3 Tier 3: IStatementTransform — sliding window inside one block pos 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 position pos window moves left ILInlining, 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.

6.2 The pipeline, phase by phase

The list below is GetILTransforms() verbatim, with the source's own ordering comments, grouped into six conceptual phases:

1. Dataflow normalization simplify CFG, split variables, inline single-use stack slots 2. Regions & state machines fixed regions; async/iterator state machines undone 3. Early expression cleanup & switches dead init removal, dynamic call sites, switch detection 4. Loop detection natural loops via dominance; exit points; pattern matching 5. Conditions & statement-level sugar if/else, lock, using; the interleaved sugar transforms 6. Final structuring lambdas, closures, for/while/do, nesting reduction, names Greet: S_0 still present (two stores block inlining) Greet: no state machine; transforms no-op robustness: analysis failure = skip, keep gotos post-order: inner loops first Greet: if/else built, then folded into a ternary, then inlined into the WriteLine call Greet: nothing left to do but naming
Figure 5 — The six phases of the IL pipeline, with the running example's progress on the right.

Phase 1 — dataflow normalization

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.

Phase 2 — regions and state machines

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.

Phase 3 — early expression cleanup and switches

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.

Phase 4 — loop detection

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."

Phase 5 — conditions and statement-level sugar

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.

Phase 6 — final structuring

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

6.3 Deep dive: async and iterator state machines

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 locals void MoveNext() { switch (state) ... awaiter.GetResult() ... } (1) creation pattern matched AsyncAwaitDecompiler 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 locals M's ILFunction, body spliced back in await expressions in place; EarlyILTransforms re-run; AwaitInCatch/FinallyTransform (6) InlineBodyOfMoveNext stub 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.

6.4 Deep dive: loops and conditions

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.

6.5 Deep dive: where C# sugar is recognized

The interleaved statement group in phase 5, plus a few block transforms around it, is the map of "which construct gets detected where":

TransformReconstructsSettings gate
LockTransformlock (x) { } from Monitor.Enter/Exit try/finallyLockStatement
UsingTransformusing statements from Dispose() try/finallyUsingStatement
CachedDelegateInitializationremoves if (cache == null) cache = new D(...)AnonymousMethods
ExpressionTransformspeephole cleanup; the conditional (ternary) operator; entry point into nullable lifting and null propagation
TransformAssignmentcompound assignment (x += y), increments (x++), inline assignment (a = b = c)MakeAssignmentExpressions
NullCoalescingTransform?? for reference typesNullCoalescing-related
NullableLiftingStatementTransformlifted operators over Nullable<T>, value-type ??LiftNullables
NullPropagationStatementTransform?. from v != null ? v.M() : nullNullPropagation
TransformArrayInitializersarray and stackalloc initializers (incl. the InitializeArray data-blob form)ArrayInitializers
TransformCollectionAndObjectInitializersnew T { ... } object/collection initializersObjectOrCollectionInitializers
TransformExpressionTreeslambdas from System.Linq.Expressions factory-call treesExpressionTrees
IndexRangeTransform^ and .. (System.Index/Range access)Ranges
DeconstructionTransform(a, b) = ... deconstructionDeconstruction
NamedArgumentTransformnamed arguments (to preserve evaluation order without temps)NamedArguments
UserDefinedLogicTransformuser-defined &&/|| via op_True/op_BitwiseAnd
InterpolatedStringTransform$"..." from DefaultInterpolatedStringHandler callsStringInterpolation

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.

6.6 Supporting analyses and settings

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.

7. Back end: translating ILAst to C#

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.

7.1 TranslatedExpression and the dual-annotation invariant

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.

7.2 ConvertTo: casts only when needed

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.

7.3 The round-trip correctness model

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 syntax arguments ConvertTo parameter types Re-resolve the syntax OverloadResolution (spec 7.5) binds to the original member & form? Done yes Apply the next least-invasive fix 1. drop cosmetic named/optional-argument forms 2. insert explicit casts on arguments 3. qualify the target (this. / TypeName.) 4. cast the target to the declaring type 5. add explicit type arguments 6. enforce explicit "in" modifiers exhausted: emit as-is (rare, pathological IL) no retry
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.

7.4 CallBuilder's other duties

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.

7.5 StatementBuilder: the statement layer

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 ContainerKindLoop 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.

8. The C# AST and its transforms

8.1 The syntax tree

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.

8.2 The AST transform pipeline

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:

After the last transform, RunTransforms runs two final visitors that prepare for printing: InsertParenthesesVisitor and GenericGrammarAmbiguityVisitor — which belong to the next section.

9. Output rendering

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 tokens InsertParenthesesVisitor precedence-driven parens (+ readability mode); GenericGrammarAmbiguityVisitor CSharpOutputVisitor tree walk, formatting policy (CSharpFormattingOptions), emits tokens TokenWriter decorator chain InsertRequiredSpacesDecorator -> [InsertMissingTokensDecorator] -> TextWriterTokenWriter | TextTokenWriter PlainTextOutput CLI, tests, files (WholeProjectDecompiler) rich ITextOutput (ILSpy UI) syntax colors, hyperlinks, code folding ITextOutput: 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.

10. Above a single method

Everything so far decompiled one method body. The surrounding machinery assembles bodies into types, files, and projects.

10.1 Assembling type declarations

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:

10.2 Whole projects

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.

10.3 The other back end, and the hosts

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.

11. Cross-cutting themes and further exploration

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.DecompileILReader.ReadILGetILTransforms() (set a breakpoint in ILFunction.RunTransforms) → StatementBuilder.ConvertAsBlockGetAstTransforms()CSharpOutputVisitor. For any specific construct, find its transform in the phase tables above, then read the transform's tests.

Appendix: stage-to-code quick reference

StageNamespace / directoryKey classes
Metadata loadingMetadata/ MetadataFile, PEFile, WebCilFile, UniversalAssemblyResolver
Type systemTypeSystem/ DecompilerTypeSystem, MetadataModule, TypeSystemOptions
IL readingIL/ ILReader, BlockBuilder
ILAst modelIL/Instructions/ (generated from Instructions.tt) ILInstruction, ILFunction, Block, BlockContainer, ILVariable
IL transformsIL/Transforms/, IL/ControlFlow/ ILInlining, SplitVariables, LoopDetection, ConditionDetection, AsyncAwaitDecompiler, YieldReturnDecompiler, TransformDisplayClassUsage
Flow analysesFlowAnalysis/ Dominance, ControlFlowNode, DataFlowVisitor<T>, DefiniteAssignmentVisitor
ILAst → C#CSharp/ StatementBuilder, ExpressionBuilder, CallBuilder, TranslatedExpression
C# semanticsCSharp/Resolver/, Semantics/ CSharpResolver, OverloadResolution, CSharpConversions, TypeInference
C# syntax treeCSharp/Syntax/ AstNode, SyntaxTree, TypeSystemAstBuilder
AST transformsCSharp/Transforms/ PatternStatementTransform, DeclareVariables, IntroduceUsingDeclarations, IntroduceQueryExpressions
RenderingCSharp/OutputVisitor/, Output/ CSharpOutputVisitor, InsertParenthesesVisitor, TokenWriter, ITextOutput
Debug infoDebugInfo/, CSharp/SequencePointBuilder.cs SequencePointBuilder, PortablePdbWriter, IDebugInfoProvider
Projects & typesCSharp/ProjectDecompiler/ WholeProjectDecompiler, RecordDecompiler, PartialTypeInfo
IL disassemblyDisassembler/ 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.