From 3ab2a2b0cbac1196586d3f9e13853bdbc0b74bb8 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Sat, 11 Jul 2026 13:09:01 +0200 Subject: [PATCH] Add decompiler architecture document (LLM-generated, human-reviewed) (#3870) * Add decompiler architecture document * Add StatementBuilder section to architecture doc, fix foreach story * Correct architecture doc per review by the decompiler author --- doc/DecompilerArchitecture.html | 1757 +++++++++++++++++++++++++++++++ 1 file changed, 1757 insertions(+) create mode 100644 doc/DecompilerArchitecture.html diff --git a/doc/DecompilerArchitecture.html b/doc/DecompilerArchitecture.html new file mode 100644 index 000000000..56937fb8f --- /dev/null +++ b/doc/DecompilerArchitecture.html @@ -0,0 +1,1757 @@ + + + + + +ILSpy Decompiler Architecture + + + + +

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

  4. + +
  5. 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.

  6. + +
  7. 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.

  8. +
+ +

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. +
  3. 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.
  4. +
  5. 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.
  6. +
+ +

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 readerFrom the method signature and local-variable signature; PDB names recovered when available.
StackSlot, ExceptionStackSlotIL readerMaterialized evaluation-stack values (S_n) and handler exception objects (E_n).
UsingLocal, ForeachLocal, PinnedRegionLocal, +PatternLocalIL transformsVariables promoted when a using/foreach/fixed/pattern construct is recognized.
DisplayClassLocal, InitializerTarget, NamedArgument, +DeconstructionInitTemporaryIL transformsBookkeeping 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) 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.ReadIL → +GetILTransforms() (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.csSequencePointBuilder, 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.

+ + +