Skip to content

Add decompiler architecture document#3870

Open
christophwille wants to merge 3 commits into
masterfrom
christophwille/decompilerdocs
Open

Add decompiler architecture document#3870
christophwille wants to merge 3 commits into
masterfrom
christophwille/decompilerdocs

Conversation

@christophwille

Copy link
Copy Markdown
Member

Adds doc/DecompilerArchitecture.html — a self-contained architecture/design document for the ICSharpCode.Decompiler pipeline, from IL bytes to rendered C#. This PR is about how the document was put together; open the file in a browser for the content itself.

Why this document exists

The repo's existing architecture notes are fragmentary (doc/ILAst.txt is 17 lines; doc/ILAst Pattern Matching.md and doc/Pattern Matching.html cover single topics). There was no one place a new contributor could read to understand how a method body travels through the engine. This document is meant to be that place.

How it is structured, and why

High-level first, then one deep dive per stage, in pipeline order. The document's table of contents is the pipeline: inputs → IL reader → ILAst → IL transforms → C# translation → AST transforms → rendering → project-level drivers. Reading it front to back mirrors what happens to a method at runtime, so the narrative order doubles as a mental model. A "pipeline at a glance" section with a full-width diagram comes before any detail, so readers always know where they are.

The ILAst gets its own full section. It is the data structure everything else operates on, and its design decisions (strict tree, typed slots, BlockContainer regions instead of a separate CFG, generated instruction classes) are what make the forty-pass transform pipeline tractable. Understanding it is the prerequisite for understanding any transform, so it sits between the front end and the pipeline rather than being folded into either.

Ordering constraints are treated as first-class architecture. The GetILTransforms() / GetAstTransforms() lists are quoted verbatim including the source's own ordering comments ("must run after inlining but before loop detection", ...). The rationale: in this codebase the ordering constraints are not incidental detail — they encode most of the design knowledge, and they are exactly what a contributor adding a new transform needs.

Three deep dives were chosen deliberately: async/iterator state machines (the most destructive compiler lowering, and the part of the pipeline that reads other methods' IL), loop/condition detection (the compiler-theory core), and the resolver round-trip repair loop in CallBuilder (the back end's headline correctness idea — casts and qualifiers are emitted only when overload resolution proves them necessary). These three are where the engine is most surprising; the remaining ~50 transforms get one-to-three sentences each, many in tables, to stay within the page budget.

A single running example threads through the stages. Console.WriteLine(polite ? "Good day!" : "Hi.") was picked because it is tiny yet exercises the full story: stack-slot materialization across a branch join, union-find slot merging, condition detection, ternary reconstruction, inlining, and an overload-resolution check on output. Each stage section shows what that method looks like at that point.

Construct-to-transform mapping as tables. A recurring reader question is "where does feature X get detected?" — so the sugar transforms are presented as a table (transform → construct → settings gate), plus the rule of thumb that data/control-flow changes happen at the IL level and surface syntax at the AST level.

Format decisions

  • Single self-contained HTML file, inline SVG diagrams, no external assets or JS — renders offline, from a local checkout, with nothing to build; matches the doc/Pattern Matching.html precedent.
  • 8 hand-drawn SVG diagrams (pipeline overview, stack-simulation example, container nesting, transform driver tiers, phase column, state-machine splicing, repair-loop flowchart, output stack) — one per concept where a picture beats prose, styled via CSS variables so they follow light/dark mode.
  • ASCII-only source with HTML entities, CRLF — per repo conventions.
  • ~30 printed pages with print CSS (page breaks per section), per the original size target.
  • Every claim is anchored to a file path/class/method so the document stays checkable against the source; it closes with a stage-to-code quick-reference table and an explicit "when text and code disagree, the code wins" note.

How it was built

  1. Three parallel code-exploration passes mapped the front end (ILReader/BlockBuilder/ILAst model), the IL transform pipeline (driver model + full ordered list), and the back end (builders/resolver/output/project decompiler), each producing a citable report with file paths and class names.
  2. The transform lists and key claims (e.g. that ExpressionTransforms.HandleConditionalOperator is the ternary-folding step) were then verified directly against the source rather than taken from the reports.
  3. After writing, mechanical verification: all internal anchors resolve, tags balance, all 8 SVGs parse as standalone XML, the file is ASCII-only CRLF, and every cited class/method/settings name was grep-confirmed to exist (this caught one error: ILParser lives in Disassembler/, not IL/).

Draft because docs of this size deserve a human read-through for tone and accuracy before merging.

🤖 Generated with Claude Code

The repo's architecture notes were fragmentary (doc/ILAst.txt is 17
lines); there was no single place that explains how a method travels
from IL bytes to C# text. This document covers the whole pipeline:
front end (ILReader/BlockBuilder), the ILAst model, the ordered IL
transform pipeline, the resolver-checked C# translation, AST
transforms, and output rendering. Self-contained HTML with inline SVG
diagrams so it renders offline and needs no toolchain; placed in doc/
next to the existing notes.

Assisted-by: Claude:claude-fable-5:Claude Code
The document underplayed StatementBuilder (one intro sentence) and
misattributed foreach: it claimed PatternStatementTransform rebuilds
enumerator-based foreach guided by ForeachAnnotation. In the code,
StatementBuilder.TransformToForeach builds the foreach statement during
ILAst-to-AST translation, and ForeachAnnotation exists for debug-info
generation (SequencePointBuilder maps the foreach header back to the
GetEnumerator/MoveNext/get_Current calls). New section 7.5 covers the
statement layer; the section 6 rule-of-thumb paragraph, the section 8
annotation example, the PatternStatementTransform bullet (which handles
the index-based array/inline-array foreach forms and for-loop
completion), and the sequence-point paragraph were corrected to match.

Assisted-by: Claude:claude-fable-5:Claude Code
@dgrunwald

dgrunwald commented Jul 7, 2026

Copy link
Copy Markdown
Member

Do my ILSpy decompiler engine blog posts still exist anywhere? Those were on the SharpDevelop blog...
Though that was from the ILSpy 1.x days and the whole engine was rewritten in ILSpy 3.0, so there's probably not anything of use in there.

Some minor mistakes in the .html:
5.3.

A Branch (goto) may only target a block within the same container. The only way out of a container is a Leave instruction.

A branch instruction can also jump to a block within an outer enclosing container, thus implicitly leaving the inner container.

6.2

a state machine's dispatch loop must be dissolved before anyone mistakes it for a user loop.

There's no such thing as a "state machine dispatch loop" (the compiler generates a switch, not a loop). Rather, a user loop containing a yield/await can only be detected after transforming the state machine -- before that, the loop has multiple entry points (due to the resume after yield/await) which makes the loop non-natural and thus LoopDetection wouldn't be able to handle it.

6.6.

which is how the UI can tell you what compiler a decompiled assembly needs

GetMinimumRequiredVersion() is just a function from settings to language version. It doesn't inspect a decompiled assembly for used features. Not worth mentioning in an architecture document.

8.2

IntroduceUsingDeclarations collects the namespaces recorded in the DecompileRun

The DecompileRun namespaces were collected from the IL code at the start of the decompiler pipeline, before the ILAst transforms. ILAst transforms use those namespaces to check whether extension method calls might be ambiguous (e.g. a != null ? Namespace1.Extensions.Method(a) : null can only turn to a?.Method() if we can guarantee the latter will be unambiguous.).
IntroduceUsingDeclarations collects namespaces a second time, now from the C# AST. As many compiler-generated calls have disappeared in the meantime, this new namespace set is merely a subset of those recorded in the DecompileRun. Only this subset gets using directives.
IntroduceExtensionMethods happens after IntroduceUsingDeclarations because this code predates the namespace superset in DecompilerRun (introduced for the ?. support).

Review feedback on the PR identified four factual errors:

- Branch instructions may also target blocks in enclosing containers,
  implicitly leaving the inner container; Leave is not the only exit.
  Fixed the section 5.3 invariants and the figure 3 caption.
- There is no state-machine "dispatch loop" (the compiler generates a
  switch). The real reason YieldReturn/AsyncAwaitDecompiler run before
  LoopDetection is that resume paths give user loops extra entry
  points, making them non-natural until the state machine is undone.
- GetMinimumRequiredVersion() is a pure settings-to-version mapping and
  does not inspect assemblies for used features; dropped the claim.
- Namespaces are collected twice: a superset from the IL up front into
  DecompileRun (used by IL transforms for ambiguity checks, e.g. the
  ?. rewrite), then again from the final C# AST by
  IntroduceUsingDeclarations - only that subset gets using directives.
  The IntroduceExtensionMethods ordering predates the superset.

Assisted-by: Claude:claude-fable-5:Claude Code
@christophwille christophwille marked this pull request as ready for review July 7, 2026 18:54
@siegfriedpammer

Copy link
Copy Markdown
Member

IntroduceExtensionMethods happens after IntroduceUsingDeclarations because this code predates the namespace superset in DecompilerRun

That's because those transforms are still almost verbatim from ILSpy 2.x times... not sure if we should move some of the transforms to the ILAst...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants