Research · Reverse Engineering

Agile.NET Devirtualizer — Reversing a .NET Code-Virtualization VM From Its Own Runtime

Target: AgileDotNet.VMRuntime.dll  ·  Versions observed: 6.6.0.35 / 6.6.0.42  ·  Tools: dnSpy/ILSpy, AsmResolver, PEVerify

agile.net secureteam codeveil devirtualization _CSVM cil ssa sccp control-flow-graph exception-handling asmresolver dotnet
</> dawwinci/agile-net-devirtualizer Generic method-body devirtualizer for Agile.NET code virtualization. MIT. Built on AsmResolver. Open repository →
7independently generated VM runtimes in the corpus
131protected methods decoded and fully lifted
6–574registered handlers per runtime
0hardcoded opcode values or handler names
ℹ️
Provenance markers. Because this article mixes what Agile.NET could do, what was observed, and what the tool currently supports, every non-obvious claim carries one of: measured reproduced directly against the public fixtures while writing this article; repo stated in the project's own DESIGN.md/VALIDATION.md; private-corpus from the non-redistributable samples, not reproducible from the public repository; conceptual illustrative, not byte-for-byte real output.

00 Introduction — What Code Virtualization Is

Agile.NET (previously marketed as CodeVeil, by SecureTeam) is a commercial .NET protector. Among its features is code virtualization: selected methods stop being CIL entirely. Their logic is translated into a proprietary bytecode, stored in a manifest resource, and executed at run time by an interpreter that ships alongside the protected assembly as AgileDotNet.VMRuntime.dll.

This is categorically different from ordinary .NET obfuscation. Renaming, string encryption, and control-flow flattening all leave CIL behind — ugly CIL, but CIL that dnSpy and ILSpy can still decompile because the CLR itself must be able to execute it. Virtualization removes the CIL. What remains in the method body is a stub, and the actual program is data in a resource that only the protector's own interpreter understands.

The transformation, conceptually

Take the smallest possible method:

C#
static int Add(int a, int b)
{
    return a + b;
}

Its CIL is four instructions:

CILunprotected
ldarg.0
ldarg.1
add
ret

Virtualization replaces that with an indirection:

pipelineconceptual
Normal CIL
    ↓
Agile.NET protector
    ↓
VM bytecode stored in the _CSVM manifest resource
    ↓
MethodDef body replaced by a VM dispatcher call
    ↓
AgileDotNet.VMRuntime interprets the proprietary instructions at run time

The result is that a decompiler sees one opaque call and nothing else. There is no partial information: not the branch structure, not the exception handlers, not the constants, not the arity of the original expression tree. All of it is in the resource.

💡
The central asymmetry this project exploits: the protector must ship the interpreter. The runtime DLL is not a hint about the format — it is the format, expressed as executable code. Every question you would otherwise answer by manual reverse engineering ("how wide is this operand?", "what does opcode 21 do?") is already answered, mechanically, by IL that sits on disk next to the protected binary.

What this project is, and is not

agile-net-devirtualizer is a method-body devirtualizer. It reverses exactly one transformation: VM bytecode back into ordinary, verifiable CIL. It does not undo Agile.NET's separate identifier-renaming pass, does not target any particular application, and ships no part of Agile.NET's binaries — you supply the protected assembly and the matching runtime DLL. It is security-research and interoperability tooling in the same category as de4dot.

It is also, deliberately, generic: there are no hardcoded opcode tables, no per-sample patches, and no name-based matching. Everything is derived from whichever AgileDotNet.VMRuntime.dll is on disk, every run. Sections 03 through 11 are about how that is possible; sections 14 through 27 are about the much harder half — turning recovered semantics back into CIL a verifier will accept.


01 What Agile.NET Virtualization Actually Changes

Consider a real fixture from the repository — TestCases/Program.cs, written specifically to exercise arithmetic paths that had previously leaked VM helper calls:

TestCases/Program.csoriginal source
public static int ComputeI4Arithmetic(byte value, int divisor)
{
    if (divisor == 0)
    {
        return -1;
    }

    int remainder = value % divisor;
    int mixed = (value + divisor) * 2;
    return mixed - remainder;
}

Protecting this with Agile.NET 6.6.0.42 and decompiling the output gives measured:

ILSpy — protected TestCases.exe0x06000006
[MethodImpl(MethodImplOptions.NoInlining)]
public static int ComputeI4Arithmetic(byte value, int divisor)
{
    return (int)CSVMRuntime.RunMethod("ca6cbf7e-8a4c-4377-b62e-24a1f9bd41df",
                                      new object[2] { value, divisor });
}

Every method the protector virtualized has this exact shape. The transformation is:

PreservedDestroyed
Method token, name, signature, visibility, custom attributesThe entire method body
Declaring type and its field layoutLocal variable table (moved into the resource)
The MethodImplOptions.NoInlining marker the protector addsException handler table (moved into the resource)
Everything the CLR needs to call the methodAll branch structure, constants, and call sites

The stub is: load a GUID string, box every argument into an object[], call CSVMRuntime.RunMethod, unbox the result to the declared return type. Conceptually:

stub shapeconceptual
Before protection:              After protection:

IL_0000: ldarg.0                ldstr    "<guid>"
IL_0001: ldc.i4.1               ldc.i4.N                 ; argument count
IL_0002: add                    newarr   [mscorlib]System.Object
IL_0003: ret                    ...  dup / ldc.i4 i / ldarg i / box / stelem.ref  (per argument)
                                call     object CSVMRuntime::RunMethod(string, object[])
                                unbox.any <return type>   ; omitted for void
                                ret
⚠️
The right-hand column above is conceptual — the exact instruction sequence the protector emits is not something this project needs or parses. The devirtualizer never reads the stub at all: it finds virtualized methods by walking the _CSVM table and resolving its tokens. The C# on the previous listing, however, is real decompiler output.

Why decompilers cannot help

dnSpy and ILSpy are faithful. They show what is there, and what is there is one call. There is nothing to "de-obfuscate": no dead branches to fold, no opaque predicates to solve, no encrypted strings to decrypt in place. The information is not obscured — it has been relocated, into a byte array whose grammar is defined by a separate assembly.

That reframes the problem. It is not a decompilation problem; it is a compiler problem. You must read a bytecode you have to reverse first, and then re-emit correct CIL for it — including a valid exception-handler table, a valid stack layout, and types the CLR verifier accepts.


02 The Agile.NET VM Architecture

The protected program after virtualization is a two-part system:

system view
Protected Assembly (e.g. TestCases.exe)
     │
     ├── virtualized MethodDef stubs  ──── call ────┐
     │                                             │
     └── _CSVM manifest resource                   │
                │                                  │
                ▼                                  ▼
        per-method record:                AgileDotNet.VMRuntime.dll
          · GUID                               │
          · MethodDef token                    ├── CSVMRuntime.RunMethod(guid, args)
          · locals blob                        ├── VM context
          · code blob (opcodes + operands)     │     ├── evaluation stack of boxed-value wrappers
          · EH blob                            │     ├── locals[]  / args[]  (same wrapper type)
                                               │     ├── instruction pointer (int)
                                               │     └── return slot
                                               ├── handler base type (2 abstract slots)
                                               ├── opcode registry (static ctor, ordered)
                                               └── N handler classes

The interpreter loop

This one does not have to be inferred — it is a dozen lines of ordinary C# in the shipped runtime. Decompiling CSVMRuntime and the abstract handler base gives the whole machine measured:

VMRuntime.Libraries.CSVMRuntimeILSpy, annotated
internal static void /3U=(o3Y= vmMethod, AHY= ctx)
{
    3XU=[] array = vmMethod.qXY=();              // one handler per VM instruction
    for (int num = 0; num < array.Length; num = ctx.GHY=())  // num = ctx.getIp()
    {
        array[num].4HU=(ctx);
    }
}

Note there is no num++: the loop re-reads the instruction pointer from the context on every iteration. Advancing is entirely the handler's job. That job is set up by a non-virtual wrapper on the handler base, which is also where exception dispatch lives:

lXU=.3XU= — the abstract handler baseILSpy, abridged + annotated
public abstract class 3XU=
{
    internal abstract void 3nU=(BinaryReader br);   // ← the READ slot   (operand decoding)
    internal abstract void 33U=(AHY= ctx);          // ← the EXECUTE slot (semantics)

    public void 4HU=(AHY= ctx)
    {
        try
        {
            ctx.GXY=(ctx.GHY=() + 1);               // setIp(getIp() + 1)  — pre-increment
            33U=(ctx);                              // run the derived handler
        }
        catch (CSVMRuntimeException exception) { 4XU=(exception); throw; }
        catch (Exception ex)
        {
            int instructionIndex = ctx.GHY=() - 1;             // the faulting VM index
            mHU= clause = ctx.HXY=(instructionIndex, ex);      // the EH clause covering it
            if (clause != null)
            {
                if (clause.mXU= == tXU=.tnU=)                  // catch-kind clause
                {
                    ctx.G3Y=(ex);                              // record the current exception
                    ctx.DHY=().7nU=(ex);                       // push it onto the VM eval stack
                    ctx.GXY=(clause.nHU=);                     // jump to the handler
                }
                else { /* push a frame marker, then */ ctx.GXY=(clause.nHU=); }
                return;
            }
            4XU=(ex); throw;
        }
    }
}

Three things fall out of those twenty lines, and each is load-bearing later:

  • The two abstract slots are right therevoid f(BinaryReader) and void f(ctx). That is the entire contract §03 searches for structurally, and it is not a guess about what the base type looks like; it is what it is.
  • The IP is pre-incremented before every handler runs. So inside an execute method, getIp() already equals index + 1. This is exactly why the branch handler in §09 computes setIp(getIp() + delta - 1) — that resolves to index + delta — and why the lifter models a target as _instr.Index + 1 + offset (§21). A handler that never touches the IP simply falls through to index + 1.
  • Exception dispatch is CLR-native. The VM does not simulate exceptions: it lets a real exception propagate out of the handler, catches it in this wrapper, finds the VM clause covering the faulting instruction index, pushes the exception object onto the VM evaluation stack, and jumps. That is precisely the CLI catch-entry contract — exactly one value on the stack, the exception — which §16 and §23 have to model when reconstructing SSA.

Two properties of that contract are the entire foundation of this project:

  1. Operands are decoded by the handler itself, from a BinaryReader positioned in a shared operand blob. So the operand grammar is a program, not a table.
  2. Execution is expressed against a small, fixed vocabulary — push, pop, peek, read/write a local or argument slot, read/set the IP, set the return value. So handler semantics can be recovered by abstract interpretation over that vocabulary.

What the handlers actually do

This is the part that surprises people coming from native-code VM protectors like VMProtect or Themida. Agile.NET's handlers do not emit machine code and do not contain a hand-written implementation of add. They use .NET reflection. A call in the original method becomes, inside the handler:

handler body — patternconceptual C#
MethodBase target = someModule.ResolveMethod(this.tokenOperand);
object receiver  = ctx.Stack.Pop().Value;
object[] args    = /* popped in reverse order */;
object result    = target.Invoke(receiver, args);
ctx.Stack.Push(new ValueWrapper(result));

Field access becomes FieldInfo.GetValue/SetValue. Object construction becomes ConstructorInfo.Invoke. Type references become Module.ResolveType. This is why the metadata tokens of the original program are still in the operand blob in raw form — the VM needs them to call ResolveMethod. It is also why RuntimeHelpers (§11) can classify runtime helper methods by which BCL reflection API they are anchored on, with no dependence on their randomized names.

💡
Recognising the reflection anchoring converts a hard problem into an easy one. MethodBase.Invoke on a token that resolves to System.String::Concat is not an ambiguous "some call happens here" — it is exactly call string [mscorlib]System.String::Concat(string, string), with the receiver and arguments already identified by the reflection call's own parameter positions.

03 Discovering the Handler Architecture Structurally

Every identifier in the shipped runtime is randomized. Here is what the type names actually look like in the fixture used throughout this article measured:

handler baselXU=.3XU=
VM contextlXU=.AHY=
boxed-value wrapperlXU=.jHY=
evaluation stacklXU=.4nU=
handler for opcode 21lXU=.J3c=
its read methodK3c=

A different build of the same protector version, protecting the same program, will produce different strings. So the entire discovery pass must be structural.

Step 1 — find the handler base by its shape

RuntimeModel.TryGetHandlerSlots looks for a type declaring exactly the two abstract slots every handler must override: one taking a System.IO.BinaryReader and returning void, and one taking some other single type and returning void. The parameter type of the second slot defines the VM context type — it is not searched for, it is derived.

Runtime/RuntimeModel.cs
var abstractSlots = baseType.Methods
    .Where(m => m.IsAbstract && m.Signature is { } s && s.ParameterTypes.Count == 1
                && (s.ReturnType?.IsTypeOf("System", "Void") ?? false))
    .ToList();
readSlot = abstractSlots.FirstOrDefault(m => IsBinaryReader(m.Signature!.ParameterTypes[0]));
execSlot = abstractSlots.FirstOrDefault(m => !IsBinaryReader(m.Signature!.ParameterTypes[0]));
if (readSlot is null || execSlot is null)
    return false;
ctx = SafeResolve.Type(execSlot.Signature!.ParameterTypes[0]);

Step 2 — bind each handler's two overrides

Agile renames overrides, so name-based binding is impossible. FindOverride prefers the explicit MethodImpl table (which records declaration→body pairs regardless of names) and falls back to a unique signature match:

Runtime/RuntimeModel.cs
foreach (var impl in type.MethodImplementations)
{
    if (SafeResolve.Method(impl.Declaration) == wantedSlot && SafeResolve.Method(impl.Body) is { } body)
        return body;
}

return type.Methods.FirstOrDefault(m =>
    m.CilMethodBody != null
    && m.Signature is { } sig && sig.HasThis
    && sig.ParameterTypes.Count == 1
    && (sig.ReturnType?.IsTypeOf("System", "Void") ?? false)
    && (wantReader ? IsBinaryReader(sig.ParameterTypes[0])
                   : SafeResolve.Type(sig.ParameterTypes[0]) == ctxType));

Why names cannot be trusted, concretely

Two builds of the same protector produce structurally identical, textually unrelated runtimes:

the same handler, two buildsconceptual
Build A:                       Build B:
class a : base {              class  : base {
    void b(BinaryReader r)        void (BinaryReader r)
    void c(Ctx ctx)               void (Ctx ctx)
}                             }

what survives:   the inheritance edge, the two signatures,
                 the MethodImpl binding, and the behaviour of the bodies.

Everything the devirtualizer keys on is in the second list. The corpus test RuntimeFamilyCorpusTests enforces this by asserting that for every one of the seven checked-in runtimes, every handler resolves both a read method and an execute method — with no name in the assertion.

ℹ️
Robustness detail. SafeResolve wraps AsmResolver's Resolve because it throws — rather than returning null — for some cross-assembly reference shapes. Every probe here runs against a DLL the user pointed at, which may not be an Agile runtime at all, so a resolution failure must mean "not a match", never a crash that prints a local file path.

04 Recovering the Opcode → Handler Map

The runtime registers its handlers in a static constructor. Conceptually it is:

registryconceptual C#
static Registry()
{
    Register(typeof(HandlerA));   // becomes VM opcode 0
    Register(typeof(HandlerB));   // becomes VM opcode 1
    Register(typeof(HandlerC));   // becomes VM opcode 2
    ...
}

In CIL, each typeof(X) is a ldtoken X. So the opcode map is simply the order of the ldtoken instructions in that method. The difficulty is identifying which static constructor is the right one — a runtime contains many, and some contain incidental ldtokens.

RuntimeModel.FindHandlerRegistry resolves this without magic counts or names: it scans every static constructor, collects the resolved ldtoken types in order, takes the most common base type among them, and keeps the candidate only if that base satisfies the handler contract from §03. Ties are broken by handler count.

Runtime/RuntimeModel.cs
var commonBase = ldtokenTypes
    .Select(t => SafeResolve.Type(t.BaseType))
    .Where(b => b is not null)
    .GroupBy(b => b!)
    .MaxBy(g => g.Count())?.Key;
if (commonBase is null || !TryGetHandlerSlots(commonBase, out var readSlot, out var execSlot, out var ctx))
    continue;

// Restrict to the run that actually derives from it (ignore incidental ldtokens), preserving order.
var handlers = ldtokenTypes.Where(t => SafeResolve.Type(t.BaseType) == commonBase).ToList();
if (best is null || handlers.Count > best.Value.Handlers.Count)
    best = new RegistryMatch(handlers, commonBase, readSlot!, execSlot!, ctx!);

The list index becomes the opcode. That is the whole map: Handlers[op] is a HandlerInfo { Opcode, Type, ReadMethod, ExecuteMethod }.

Real data

Running the tool against the repository's advanced control-flow fixture measured:

$ dotnet run --project AgileDevirtualizer -- Secured-advanced-controlflow/TestCases.exe Secured-advanced-controlflow/AgileDotNet.VMRuntime.dll
[*] Loading VM runtime: .../AgileDotNet.VMRuntime.dll
    handler base : lXU=.3XU=
    context type : lXU=.AHY=
    opcodes      : 66  (read 66, exec 66)
[*] Loading input assembly: .../TestCases.exe
[*] _CSVM resource '_CSVM': 11 virtualized method(s).

[*] Decoded 11/11 method(s), 84 instruction(s) total.

An extract of the recovered map for that build:

VM opcodeHandler typeOperand bytesRecovered semantics
0x0001lXU=.tHY=9ldc.i4 <k>; ret
0x0004lXU=.x3Y=9ldc.i4 <k>; ret
0x0015 (21)lXU=.J3c=6ldarg <n>; ldc.i4.0; ceq; ldc.i4 0; ceq; brtrue <t>
0x0016 (22)lXU=.LHc=9ldc.i4 <k>; ret
0x0017 (23)lXU=.MXc=25a 12-instruction arithmetic block ending in ret
0x0022 (34)lXU=.dXc=10 / 11 / 25ldstr <s>; newobj <ctor>; throw
0x0027 (39)lXU=.lXc=0nothing (a true no-op)

† measured operand consumption for the instances present in this assembly. It is not a constant per opcode — see §06.

Why this cannot be hardcoded

  • The map is per build. The same protector version, protecting different programs, produces different handler counts and different orders.
  • The handler count tracks the protected program. Across the four staged TestCases builds — all Agile.NET 6.6.0.42 — the counts are 7, 24, 38 and 66 registered handlers for 9, 30, 53 and 84 VM instructions respectively measured. The protector is emitting specialised handler classes for the program it is protecting.
  • Handlers are duplicated on purpose. In the 66-handler build, opcodes 1, 4 and 22 are three distinct handler classes implementing the identical semantics ldc.i4 <k>; ret. Only the field that carries the constant differs. Any signature- or hash-based matcher has to solve this problem; a behavioural one never sees it.
⚠️
The numbers above describe observed builds. "Agile.NET emits one handler per fused block" would be an overreach — what is measurable is that in the six expanded-layout runtimes examined, handler count scales with the size of the virtualized program, and that a compact layout with a fixed small handler count also exists (§13).

05 The _CSVM Resource

Agile stores the virtualized method table in an embedded manifest resource. The project does not find it by name. VMResource.Find attempts a full parse of every embedded resource and accepts the first one that parses self-consistently:

Resource/VMResource.cs
foreach (var resource in module.Resources)
{
    if (!resource.IsEmbedded) continue;
    var data = resource.GetData();
    if (data is null || data.Length < 4) continue;
    if (TryParse(module, data, out var methods))
        return (resource, methods);
}

Three conditions make the parse self-validating:

  1. the record count must be in (0, 500_000];
  2. every method token must resolve to a real MethodDefinition in this module;
  3. the stream must be consumed exactly — a single leftover byte means a mis-parse and the candidate is rejected.

Table layout

_CSVMverified byte-exact
int32                        methodCount

repeat methodCount times:
    byte[16]                 Guid              ; matches the string in the stub's RunMethod call
    uint32                   token             ; MethodDef token, must resolve
    int32 len, byte[len]     LocalVarStream
    int32 len, byte[len]     CodeStream
    int32 len, byte[len]     EhStream

end-of-stream must be exactly here

Parsing the real advanced-control-flow fixture's resource by hand measured — payload length 2,298 bytes, consumed exactly:

_CSVM table — TestCases.exe (Secured-advanced-controlflow)
methods: 11

0x06000001 guid=e373f60f-aad6-411f-acb7-5ce18f760095 locals=16B code=109B ( 7 instr) eh= 0B
0x06000002 guid=1260aeca-4efa-4f51-bdd6-53edf3d5ef74 locals=32B code= 95B ( 1 instr) eh= 0B
0x06000003 guid=da02aab0-fda6-40d3-9222-09e9d2f5ddf1 locals=32B code= 60B ( 1 instr) eh= 0B
0x06000004 guid=0592a115-96a3-40dd-89fb-ad54c57aedb1 locals=20B code=137B (11 instr) eh= 0B
0x06000005 guid=b815f718-01dd-494d-97f7-39c2f5918640 locals=16B code= 79B ( 7 instr) eh= 0B
0x06000006 guid=ca6cbf7e-8a4c-4377-b62e-24a1f9bd41df locals=16B code= 50B ( 3 instr) eh= 0B
0x06000007 guid=de46dc97-fd5a-4d63-ab25-0c3c98d0a3b1 locals=20B code=185B (11 instr) eh=24B
0x06000008 guid=d2324f78-133d-4280-8ec9-e1b6281e1e0f locals=20B code=202B (12 instr) eh=48B
0x06000009 guid=f593245c-9b78-4b9a-84f7-438ff907c22e locals=20B code=195B (11 instr) eh=24B
0x0600000A guid=c533adb6-60da-4737-9cf7-98cd1a91e501 locals=24B code=197B (10 instr) eh= 0B
0x0600000D guid=909aa5c1-5cd2-4983-9e2e-b6e55e5498f8 locals=20B code=229B (10 instr) eh=72B

end offset 0x21CA   payload len 2298   ✓ exact

Note the GUID for 0x06000006: ca6cbf7e-8a4c-4377-b62e-24a1f9bd41df — the same string the decompiled stub in §01 passes to RunMethod.

The code stream

CodeStream
int32                        instructionCount
uint16 × instructionCount    opcodes            ; dense array, no operands interleaved
byte[...]                    operand blob       ; consumed in instruction order

This split is important. Opcodes are a fixed-width array, so the instruction count and the opcode of every instruction are known before any operand is read. Operands live in one contiguous tail whose internal structure is entirely defined by the handlers.

Here is ComputeI4Arithmetic's complete 98-byte record, annotated:

record for 0x06000006file offset 0x1BC8
7E BF 6C CA 4C 8A 77 43 B6 2E 24 A1 F9 BD 41 DF   Guid  ca6cbf7e-8a4c-4377-b62e-24a1f9bd41df
06 00 00 06                                       token 0x06000006
10 00 00 00                                       locals blob = 16 bytes
  03 00 00 00                                       count = 3
  08 00 00 00                                       ELEMENT_TYPE_I4       -> Int32
  1D 00 00 00                                       ELEMENT_TYPE_SZARRAY  -> (default) Object
  08 00 00 00                                       ELEMENT_TYPE_I4       -> Int32
32 00 00 00                                       code blob = 50 bytes
  03 00 00 00                                       instructionCount = 3
  15 00  16 00  17 00                               opcodes 21, 22, 23
  <40 bytes of operands>                            see §06
00 00 00 00                                       EH blob = 0 bytes

The tool independently reports locals (3): Int32, Object, Int32 for this method — matching the hand decode exactly.

The blob-consumption invariant

MethodDecoder enforces the same "consumed exactly" rule at the instruction level. This is the project's single most valuable early-warning signal: if any handler's operand layout were misunderstood by even one byte, every subsequent instruction in that method would decode from a shifted position and the blob would not end where it should.

Decode/MethodDecoder.cs
for (int i = 0; i < count; i++)
{
    ushort op = opcodes[i];
    if (op >= runtime.Handlers.Count)
        throw new InvalidDataException($"instruction {i}: opcode {op} out of range ...");

    var handler = runtime.Handlers[op];
    long before = stream.Position;
    try { operands = OperandDecoder.Decode(module, handler, reader); }
    catch (Exception ex)
    {
        throw new InvalidDataException(
            $"instruction {i} (opcode {op}, handler {handler.Type.Name}) failed decoding operands " +
            $"at blob offset {before}: {ex.Message}", ex);
    }
    decoded.Instructions.Add(new VmInstruction { Index = i, Opcode = op, Operands = operands });
}

if (stream.Position != stream.Length)
    throw new InvalidDataException(
        $"operand blob not fully consumed ({stream.Position}/{stream.Length}) — decoder is out of sync");
💡
LocalsDecoder and EhDecoder apply the identical rule to their own blobs. Three independent length-prefixed streams, each of which must be consumed to the byte, is a strong structural test: it is essentially impossible to pass all three with a wrong format hypothesis.

06 The Key Trick — Let Agile.NET Describe Its Own Operand Format

This is the section that matters most. Everything downstream depends on decoding operands correctly, and operands are where a hardcoded approach dies.

The naive plan is: reverse each handler's Read method by hand, write down "opcode 21 = uint16 then int32", build a table, ship it. That plan fails for three independent reasons, all of which are directly observable in the fixtures.

Failure 1 — the table would be per build

Opcode 21 in the 66-handler build is a conditional branch on an argument. Opcode 21 in the 38-handler build of the same protector version is something else entirely. The table is not a property of Agile.NET; it is a property of one output file.

Failure 2 — operand width is not constant per opcode

Handler lXU=.dXc= (opcode 34 in the advanced fixture) contains a length-prefixed BinaryReader.ReadString. Its three instances in that one assembly consume 25, 10 and 11 bytes measured — because the strings are "control-flow fixture", "inner" and "escape". An opcode→width table cannot express this.

Failure 3 — some read methods branch on the data they just read

This is the decisive one. Here is the actual read method of handler lXU=.LHc= (opcode 22), as it ships inside AgileDotNet.VMRuntime.dll measured:

lXU=.LHc=::MHc=(BinaryReader)real IL, abridged + annotated
IL_0000: ldarg.0
IL_0001: ldc.i4.1
IL_0002: newarr    System.Object
IL_0007: stfld     System.Object[] lXU=.LHc=::LXc=
IL_000C: ldarg.0
IL_000D: ldc.i4.1
IL_000E: newarr    System.UInt32
IL_0013: stfld     System.UInt32[] lXU=.LHc=::Lnc=
IL_0018: ldarg.1
IL_0019: callvirt  System.Byte System.IO.BinaryReader::ReadByte()      ; ← a type tag
IL_001E: stloc.0
   ...
IL_0023: ldloc.0
IL_0024: ldc.i4.8
IL_0025: sub                                                            ; tag - ELEMENT_TYPE_I4
IL_0026: switch    (IL_004C, IL_00AB, IL_0061, IL_00AB, IL_0076, IL_008B)
IL_0045: ldloc.0
IL_0046: ldc.i4.s  28
IL_0048: beq.s     IL_00A0

IL_004C: ... callvirt System.Int32  BinaryReader::ReadInt32()  ; box; stelem.ref   → 4 bytes
IL_0061: ... callvirt System.Int64  BinaryReader::ReadInt64()  ; box; stelem.ref   → 8 bytes
IL_0076: ... callvirt System.Single BinaryReader::ReadSingle() ; box; stelem.ref   → 4 bytes
IL_008B: ... callvirt System.Double BinaryReader::ReadDouble() ; box; stelem.ref   → 8 bytes
IL_00A0: ... ldnull; stelem.ref                                                     → 0 bytes
IL_00AB: throw new CSVMRuntimeException(Messages.InternalError)

IL_00B6: ldarg.0
IL_00B7: ldfld     System.UInt32[] lXU=.LHc=::Lnc=
IL_00BC: ldc.i4.0
IL_00BD: ldarg.1
IL_00BE: callvirt  System.UInt32 System.IO.BinaryReader::ReadUInt32()   ; a metadata token
IL_00C3: stelem.i4
IL_00C4: ret

The operand length of opcode 22 is 1 + {0|4|8} + 4 bytes, chosen at decode time by a CorElementType tag embedded in the blob itself. There is no static description of that. The only correct decoder for this method is this method.

💡
So: don't describe the format — execute the description. OperandDecoder is a small, concrete CIL interpreter that runs the handler's real Read(BinaryReader) IL against the real operand blob, with the real BinaryReader. Because the actual ReadXxx calls are performed, the blob advances byte-for-byte exactly as it would at run time, including through data-dependent branches. Every stfld the method performs is captured, keyed by the field's name.

How OperandDecoder works

It is a straightforward stack machine over AsmResolver's CilInstructionCollection, with two special values on the stack: ThisRef (the handler instance, i.e. ldarg.0) and ReaderRef (ldarg.1). Field stores land in a dictionary; field loads read back from it. Arithmetic, comparisons, branches, arrays, switch, locals and conversions are all modelled concretely.

Decode/OperandDecoder.csthe two important cases
case CilCode.Stfld:
{
    var value = Pop(stack);
    Pop(stack);                     // the target object (ThisRef)
    if (instr.Operand is IFieldDescriptor field)
        fields[field.Name!] = value;
    break;
}
Decode/OperandDecoder.csthe reader bridge
if ((method.DeclaringType?.Name?.ToString() ?? "") == "BinaryReader"
    && (method.DeclaringType?.Namespace?.ToString() ?? "") == "System.IO")
{
    switch (method.Name?.ToString())
    {
        case "ReadBoolean": Pop(stack); stack.Push(reader.ReadBoolean()); return;
        case "ReadByte":    Pop(stack); stack.Push((int)reader.ReadByte()); return;
        case "ReadInt16":   Pop(stack); stack.Push((int)reader.ReadInt16()); return;
        case "ReadUInt16":  Pop(stack); stack.Push((int)reader.ReadUInt16()); return;
        case "ReadInt32":   Pop(stack); stack.Push(reader.ReadInt32()); return;
        case "ReadUInt32":  Pop(stack); stack.Push(reader.ReadUInt32()); return;
        case "ReadInt64":   Pop(stack); stack.Push(reader.ReadInt64()); return;
        case "ReadSingle":  Pop(stack); stack.Push(reader.ReadSingle()); return;
        case "ReadDouble":  Pop(stack); stack.Push(reader.ReadDouble()); return;
        case "ReadChar":    Pop(stack); stack.Push(reader.ReadChar()); return;
        case "ReadString":  Pop(stack); stack.Push(ReadStringOperand(module, reader)); return;
        case "ReadBytes":   { int n = ToInt(Pop(stack)); Pop(stack); stack.Push(reader.ReadBytes(n)); return; }
    }
}

// Any other call: balance the stack per its signature so decoding stays in sync.
if (method.Signature is MethodSignature sig)
{
    int pops = sig.ParameterTypes.Count + (sig.HasThis ? 1 : 0);
    for (int i = 0; i < pops; i++) Pop(stack);
    if (!(sig.ReturnType?.IsTypeOf("System", "Void") ?? false))
        stack.Push(null);
}

Two design details worth pointing out. First, anything the decoder does not model as a reader call is still stack-balanced from its signature, so an unknown helper cannot desynchronise the interpreter. Second, an unmodelled opcode throws NotSupportedException — it never guesses, and the failure names the handler and the blob offset.

A complete worked example, byte-exact

Handler lXU=.J3c= (opcode 21) has two fields and this read method measured:

lXU=.J3c=real IL, condensed
fields:  KHc= : System.UInt16[]      KXc= : System.Int32[]

K3c=(BinaryReader):
  IL_0000: ldarg.0 ; ldc.i4.1 ; newarr System.UInt16 ; stfld  KHc=
  IL_000C: ldarg.0 ; ldc.i4.1 ; newarr System.Int32  ; stfld  KXc=
  IL_0018: ldarg.0 ; ldfld KHc= ; ldc.i4.0 ; ldarg.1
  IL_0020: callvirt System.UInt16 System.IO.BinaryReader::ReadUInt16()
  IL_0025: stelem.i2
  IL_0026: ldarg.0 ; ldfld KXc= ; ldc.i4.0 ; ldarg.1
  IL_002E: callvirt System.Int32  System.IO.BinaryReader::ReadInt32()
  IL_0033: stelem.i4
  IL_0034: ret

Interpreting that against the blob teaches the decoder, without being told:

derived operand layout — opcode 21
uint16   →  field KHc=[0]
int32    →  field KXc=[0]
total: 6 bytes

The real operand bytes for the first instruction of ComputeI4Arithmetic are 01 00 02 00 00 00, and the decoder produces:

decodemeasured
ReadUInt16()  →  0x0001  →  KHc=[0] = 1
ReadInt32()   →  0x00000002  →  KXc=[0] = 2

tool output:  [000] op21(KHc==[1], KXc==[2])

The same, for a 25-byte instruction

Handler lXU=.MXc= (opcode 23) declares four fields — Mnc= : UInt16[6], M3c= : <enum>[1], NHc= : Object[1], NXc= : UInt32[1] — and its read method interleaves them in a non-obvious order, including the same data-dependent constant tag as opcode 22. Interpreting it consumes exactly 25 bytes:

opcode 23 — operand bytes and derived decodemeasured
00 00 | 01 00 | 00 00 | 08 00 00 00 | 00 00 | 01 00 | 08 | 02 00 00 00 | 00 00 | 06 00 00 06

  u16  Mnc=[0] = 0
  u16  Mnc=[1] = 1
  u16  Mnc=[2] = 0
  i32  M3c=[0] = 8
  u16  Mnc=[3] = 0
  u16  Mnc=[4] = 1
  u8   tag     = 0x08 (I4)  →  i32  NHc=[0] = 2
  u16  Mnc=[5] = 0
  u32  NXc=[0] = 0x06000006

tool output:  [002] op23(Mnc==[0,1,0,0,1,0], M3c==[8], NHc==[2], NXc==[100663302])
                                                                  ^ 0x06000006, this method's own token

Adding up: 6 + 9 + 25 = 40 bytes of operands, and the code blob is 50 bytes with a 10-byte header (4-byte count + 3 × 2-byte opcodes). The invariant holds exactly.

Why this generalises

The devirtualizer has learned the operand grammar of a handler it has never seen, from a build that did not exist when the code was written, with no signature database and no heuristics. It works for the data-dependent widths, for the length-prefixed strings, for the reordered field writes, and for however many operand slots a generated handler happens to have — because the array lengths are baked into the handler's own newarr constants rather than encoded in the stream.

ℹ️
One real complication. Strings read via ReadString are wrapped in a DecodedStringLiteral(Value, RawToken) rather than a plain string. Agile accepts #US heap offsets larger than the 24 bits a native CIL ldstr token can encode. Keeping the raw token lets emission detect that case and fall back to reconstructing the string through char[] + String(char[]) instead of writing an invalid 0x71xxxxxx token. That logic lives in OperandDecoder.UserStrings.cs, which mirrors the preserving #US builder's offset assignment so the projection is exact.

07 Decoding Locals

The locals blob is simple, and exactly one detail in it is a trap.

LocalVarStream
int32          count
repeat count times:
    int32      CorElementType code
    [int32     type token]        ← only for VALUETYPE (0x11), VAR (0x13), MVAR (0x1E)
    [int32     flag (+token)]     ← only for GENERICINST (0x15); token only when flag == 0x11
CodeCorElementTypeExtra bytesDecoded signature
0x020x0DBOOLEANR80the matching corlib primitive
0x0ESTRING0System.String
0x11VALUETYPE4resolved from the token
0x13VAR4resolved from the token
0x15GENERICINST4 (+4)token when flag is 0x11, else System.Object
0x18/0x19I/U0IntPtr/UIntPtr
0x1EMVAR4resolved from the token
anything elseincl. CLASS 0x12, SZARRAY 0x1D, OBJECT 0x1C0System.Object
⚠️
The quirk. CLASS (0x12) does not carry a token, unlike VALUETYPE. The obvious assumption — "a class local names its class" — is wrong: the runtime just seeds a null/default value in that slot, so no token is present. This was a real bug in the project (recorded in DESIGN.md's M2 entry) and it is exactly the kind of error the exact-consumption invariant catches: reading four phantom bytes shifts every subsequent local and the blob no longer ends where it should.

A consequence worth internalising for §10: declared local types carry almost no information. A Dictionary<string,string> local in the original method comes back as System.Object. The devirtualizer must recover real types from data flow, not from this table.

Real example

0x06000006 — locals blobmeasured
03 00 00 00     count = 3
08 00 00 00     0x08  I4       -> System.Int32
1D 00 00 00     0x1D  SZARRAY  -> (no payload, default) System.Object
08 00 00 00     0x08  I4       -> System.Int32

emitted as:  .locals init ( [0] int32, [1] object, [2] int32 )

And for a method whose original source declared a Guid, a string and an objectGuidRoundTrip, token 0x06000003 — the tool reports locals (5): Guid, String, Guid, Object, Boolean, with the two Guid slots arriving through VALUETYPE + token.


08 Exception-Handler Decoding

Agile stores exception regions as a flat table of instruction-index ranges. It is the closest thing in the format to the CLI's own EH table, but with two crucial differences: the bounds are inclusive, and they are in VM instruction index space, not byte offsets.

EhStream
int32       count
repeat count times:
    int32   clauseType      ; 0 = catch, 1 = filter, 2 = finally, 4 = fault
    int32   tryStart        ; inclusive VM instruction index
    int32   tryEnd          ; inclusive
    int32   handlerStart    ; inclusive
    int32   handlerEnd      ; inclusive
    [int32  extraToken]     ; present only when clauseType is 0 (catch type) or 1 (filter)

The EhDecoder comment records why the bounds are read as inclusive: the runtime's own membership test is idx >= start && idx <= end. This matters at emission time, where CIL wants a half-open end (§22).

A real, non-trivial table

The fixture RethrowWithoutFilter — nested try/ catch/throw; wrapped in a finally — produces a 72-byte EH blob. Decoded by hand from the file measured:

0x0600000D — EH blob (72 bytes)
03 00 00 00                          count = 3

00 00 00 00  clauseType  = 0 (catch)
01 00 00 00  tryStart    = 1
05 00 00 00  tryEnd      = 5      (inclusive)
06 00 00 00  handlerStart= 6
06 00 00 00  handlerEnd  = 6
11 00 00 01  extraToken  = 0x01000011   → TypeRef System.ArgumentException

00 00 00 00  clauseType  = 0 (catch)
01 00 00 00  tryStart    = 1
06 00 00 00  tryEnd      = 6
07 00 00 00  handlerStart= 7
07 00 00 00  handlerEnd  = 7
11 00 00 01  extraToken  = 0x01000011

02 00 00 00  clauseType  = 2 (finally)
01 00 00 00  tryStart    = 1
07 00 00 00  tryEnd      = 7
08 00 00 00  handlerStart= 8
08 00 00 00  handlerEnd  = 8
             (no extraToken for finally)

4 + 24 + 24 + 20 = 72 ✓

The tool prints exactly that:

$ ... --lift RethrowWithoutFilter
EH type=0 try[1..5] handler[6..6] extra=0x01000011
EH type=0 try[1..6] handler[7..7] extra=0x01000011
EH type=2 try[1..7] handler[8..8] extra=0x00000000

Note the nesting is expressed purely by containment of the index ranges: the inner catch guards [1..5], the outer catch guards [1..6] (so it also covers the inner handler), and the finally guards [1..7]. That recovers the source's structure exactly:

TestCases/Program.csoriginal
try {
    try { ... }
    catch (ArgumentException) { ...; throw; }        // VM handler at index 6
}
catch (ArgumentException ex) { ... }                 // VM handler at index 7
finally { ... }                                      // VM handler at index 8

From indices to labels

Every VM instruction index becomes a CIL label. Two things follow: ControlFlowGraphBuilder.FindLeaders makes tryStart, tryEnd + 1, handlerStart, handlerEnd + 1 (and the filter start, when present) block leaders, so region boundaries can never fall inside a basic block; and the emitters translate inclusive→exclusive when building the real handler:

Emit/CilBuilder.cs
var handler = new CilExceptionHandler
{
    HandlerType  = (CilExceptionHandlerType)eh.ClauseType,
    TryStart     = At(eh.TryStart),
    TryEnd       = At(eh.TryEnd + 1),          // VM end index is inclusive; CIL end is exclusive
    HandlerStart = At(eh.HandlerStart),
    HandlerEnd   = At(eh.HandlerEnd + 1),
};
⚠️
Filter support is asymmetric. The decoder handles clauseType == 1 and reads its trailing token, and the analysis layer models filters fully (RegionZone.Filter, a distinct ExceptionFilterHandler edge, separate entry contracts). But repo Agile.NET 6.6.0.42 refuses to virtualize a method containing endfilter — the project's FilterAndRethrow fixture had to stay a source-only oracle for exactly this reason. So filter and fault handling in the strict EH emission tier remains shadow-only, validated against synthetic fixtures rather than a real protected sample. fault is covered by a hand-written ILAsm fixture (FaultCases/FaultCases.il) because C# cannot emit a fault clause at all.

09 Understanding Handler Semantics

Knowing that VM opcode 21 dispatches to lXU=.J3c= is worth nothing on its own. The real question is what J3c=.Knc=(ctx) means.

This is where naming fails hardest. The method is called Knc=. Its declaring type is called J3c=. There is no docstring, no attribute, no string constant. And a semantically identical handler in the next build will be called something else and will sit at a different opcode.

The only stable identity a handler has is its effect on the VM context. So the project recovers semantics by symbolic execution of the execute method's IL over a model of that context. The class is ExecuteInterpreter (18 partial files under Lift/).

⚠️
Naming note. DESIGN.md and README.md still refer to a HandlerClassifier and a separate CilInterpreter; those types do not exist in the current source. The execute-side analysis is Lift/ExecuteInterpreter*.cs; the operand-side interpreter lives inside Decode/OperandDecoder.cs. Documentation drift in a fast-moving research codebase — worth flagging so readers reading the repo aren't sent looking for the wrong file.

The shape of the problem

Here is the real execute method of opcode 21, annotated:

lXU=.J3c=::Knc=(lXU=.AHY=)real IL, annotated
IL_0000: ldarg.1
IL_0001: callvirt  lXU=.4nU= lXU=.AHY=::DHY=()        ; ctx.getStack()
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldarg.1
IL_0009: callvirt  lXU=.jHY=[] lXU=.AHY=::EnY=()      ; ctx.getArgs()
IL_000E: ldarg.0 ; ldfld KHc= ; ldc.i4.0 ; ldelem.u2  ; operand: argument index
IL_0016: ldelem.ref                                    ; args[idx]
IL_0017: callvirt  System.Void lXU=.4nU=::7nU=(...)   ; stack.Push(value)

IL_001D: ldc.i4.1 ; stloc.1                            ; delta = 1  (fall through)
IL_0021: ldarg.1
IL_0022: callvirt  ctx.getStack()
IL_0027: callvirt  lXU=.jHY= lXU=.4nU=::7XU=()        ; stack.Pop()
IL_002C: stloc.2
IL_002D: brfalse.s IL_0038
IL_0031: callvirt  System.Object lXU=.jHY=::get_Value()
IL_0039: stloc.3                                       ; raw = wrapper?.Value

IL_003B: callvirt  System.Object lXU=.jHY=::kXY=()
IL_0040: isinst    lXU=.NnY=                           ; storage-kind discriminator
IL_0045: brtrue.s  IL_0052
IL_0048: call      System.Boolean lXU=.LHY=::NXY=(System.Object)   ; ← the "Falsy" primitive
IL_004D: ldc.i4.0 ; ceq                                ;   negated
IL_0050: br.s      IL_0056
IL_0052: ldloc.3 ; ldnull ; cgt.un                     ;   reference form: raw != null
IL_0056: stloc.s   V_4

IL_0058: brtrue.s  IL_005F
IL_005C: ldc.i4.1 ; br.s IL_0067                       ; not taken → delta 1
IL_005F: ldarg.0 ; ldfld KXc= ; ldc.i4.0 ; ldelem.i4   ; taken → delta = operand
IL_0067: stloc.1

IL_0068: ldarg.1 ; ldarg.1
IL_006A: callvirt  System.Int32 lXU=.AHY=::GHY=()      ; ctx.getIp()
IL_006F: ldloc.1 ; add ; ldc.i4.1 ; sub                ; ip + delta - 1
IL_0073: callvirt  System.Void lXU=.AHY=::GXY=(int)    ; ctx.setIp(...)
IL_0079: ret

Read that as a data-flow statement rather than as code and it says: push argument slot KHc=[0]; pop it; test truthiness; branch to index + KXc=[0] if true, otherwise fall through. Which is:

recovered semantics
ldarg <KHc=[0]>
ldc.i4.0
ceq                     ; the Falsy primitive, materialised natively
ldc.i4 0
ceq                     ; its negation
brtrue <index + KXc=[0]>

And that is verbatim what the tool emits for that instruction measured: ldarg 1; ldc.i4.0; ceq; ldc.i4 0; ceq; brtrue →#2.

The three recognition problems, and how each is solved

ProblemMechanismWhere
Which methods are stack/context primitives? Structural identification by shape and usage — §11 RuntimeVocabulary
Which methods are comparisons, and which comparison? Concrete execution on boxed integer inputs; classify from the truth table ConditionClassifier
Which methods are the reflective "do the real work" helpers? Anchor on the BCL reflection API they forward to RuntimeHelpers / HelperRole

Comparison recognition by probing

ConditionClassifier is a nice example of behaviour-over-names taken to its logical end. Every static, bool-returning method with one or two comparable leading parameters is a candidate. It is interpreted with concrete boxed integers, three times, and classified from the resulting truth table:

Lift/ConditionClassifier.cs
if (operands == 1)
{
    var t0 = Probe(def, Box(0));
    var t1 = Probe(def, Box(1));
    return t0 == true && t1 == false ? Relation.Falsy : null;
}
if (operands >= 2)
{
    var lt = Probe(def, Box(0), Box(1));   // a < b
    var gt = Probe(def, Box(1), Box(0));   // a > b
    var eq = Probe(def, Box(1), Box(1));   // a == b
    if (lt is null || gt is null || eq is null) return null;
    return (lt, gt, eq) switch
    {
        (true,  false, false) => Relation.Lt,
        (false, true,  false) => Relation.Gt,
        (true,  false, true ) => Relation.Le,
        (false, true,  true ) => Relation.Ge,
        (false, false, true ) => Relation.Eq,
        (true,  true,  false) => Relation.Ne,
        _ => null,
    };
}

Running that discovery pass on the advanced fixture's runtime measured:

$ ... --classify
[*] Comparison primitives recognized by probing (not by name):
    Lt     LHY=.MHY=
    Le     LHY=.MXY=
    Gt     LHY=.MnY=
    Ge     LHY=.M3Y=
    Eq     LHY=.NHY=
    Falsy  LHY=.NXY=

Six relations, zero names consulted. (Relation.Ne exists in the enum but this particular runtime does not expose a distinct Ne primitive measured — the protector expresses inequality as a negated Eq.)

Reflection helpers by BCL anchor

A thin runtime wrapper that only forwards to Module.ResolveMethod is recognised as "resolve a method token" regardless of its name. RuntimeHelpers.RoleOfDefinition deliberately inspects only the method's own call sites — no transitive chasing, which would tag every method that can eventually reach an anchor:

Lift/RuntimeHelpers.cs
case "Module"          when name == "ResolveMethod": return HelperRole.ResolveMethod;
case "Module"          when name == "ResolveField":  return HelperRole.ResolveField;
case "Module"          when name == "ResolveType":   return HelperRole.ResolveType;
case "Module"          when name == "ResolveString": return HelperRole.ResolveString;
case "Module"          when name == "ResolveMember": return HelperRole.ResolveMember;
case "ConstructorInfo" when name == "Invoke":        return HelperRole.NewObj;
case "MethodBase"      when name == "Invoke":        return HelperRole.Invoke;
case "MethodInfo"      when name == "Invoke":        return HelperRole.Invoke;
case "FieldInfo"       when name == "SetValue":      return HelperRole.FieldSet;
case "FieldInfo"       when name == "GetValue":      return HelperRole.FieldGet;

These names are System.Reflection's, not Agile's — they are as stable as the .NET BCL. Running the discovery pass on the same runtime measured:

$ ... --helpers
[*] Runtime helpers recognized by BCL anchor (not by name):
    ResolveMethod : 13 method(s)  e.g. CSVMRuntime./nU=, fnY=.gnY=, tHY=.t3Y=, x3Y=.ynY=
    ResolveField  : 11 method(s)  e.g. OHc=.PXc=, Y3c=.Z3c=, h3c=.i3c=, jXc=.k3c=
    ResolveType   :  3 method(s)  e.g. AHY=.HXY=, YXY=.aHY=, 03Y=.13Y=
    Invoke        :  3 method(s)  e.g. 3XU=.4XU=, fnY=.iXY=, fnY=.inY=
    NewObj        :  2 method(s)  e.g. fnY=.hnY=, fnY=.iHY=
    FieldSet      :  1 method(s)  e.g. lHU=.set_Value
    FieldGet      :  1 method(s)  e.g. lHU=.get_Value
    CoerceByRef   :  2 method(s)  e.g. 4nU=.8HU=, 4nU=.73U=

Note the duplication here too: thirteen distinct wrapper methods all mean "ResolveMethod". This is the same polymorphism defence as handler duplication, and it dissolves the same way.

ResolveMember is worth a sentence because it is genuinely ambiguous — the token could be a field or a method. The lifter does what the CLR does: it resolves the token and classifies on the resolved member's own signature.

Lift/ExecuteInterpreter.Calls.cs
private static HelperRole ClassifyMember(IMetadataMember? member) => member switch
{
    FieldDefinition                             => HelperRole.ResolveField,
    MethodDefinition                            => HelperRole.ResolveMethod,
    MemberReference { Signature: FieldSignature }  => HelperRole.ResolveField,
    MemberReference { Signature: MethodSignature } => HelperRole.ResolveMethod,
    TypeDefinition or TypeReference or TypeSpecification => HelperRole.ResolveType,
    _ => HelperRole.None,
};

10 Symbolically Interpreting the VM

ExecuteInterpreter walks a handler's execute IL with a symbolic stack. Its central design decision is stated at the top of the file and is what makes the whole thing tractable:

💡
The VM's evaluation stack is the reconstructed CIL evaluation stack. When a handler pushes a source value (a local, an argument, a constant), the interpreter emits the matching CIL load. When a handler performs a consuming operation (a store, a call, a field access, a branch, a return), the interpreter emits that. There is no separate materialisation phase and no stack simulation to reconcile — the two stacks stay in lockstep by construction.

The symbolic value model

SymValue is a 22-case discriminated union. The ones that carry the design:

SymValue caseMeaningMaterialises as
Ctxthe execute method's ctx parameternothing (VM plumbing)
StackRefresult of ctx.getStack()nothing
SlotArray(IsArgs)result of ctx.getLocals()/getArgs()nothing
SlotRead(IsArgs, Index)a pending slot readldloc / ldarg
SlotAddr(IsArgs, Index)the runtime's by-ref wrapper over a slotldloca / ldarga
ArrayElemAddr(Array, Index)the same wrapper over a real array elementldelema
Operand(Value)a decoded operand fieldldc.* / token load
ResolvedString(Value, RawToken)a user string plus its original heap tokenldstr, or char[] reconstruction
Resolved(Kind, Member)a metadata member from a reflection resolvethe call/field/newobj it feeds
OnStack(KnownType, Peeked, ManagedPointer, KnownNull)a value that is already on the real CIL stacknothing — or dup if Peeked
Cond(Rel, Negate)a genuine runtime-computed booleanalready emitted; only the negation remains
Ip(Offset)ctx.getIp() + ka branch target index
SwitchTable(Deltas, Base)an operand jump table indexed by a stack valueswitch
CurrentExceptionthe VM context's in-flight exceptionrethrow (at a catch terminator)
FnPtr(Method)method.MethodHandle.GetFunctionPointer()ldftn
DefaultValue(Type)the Activator-backed default(T) factory resultinitobj into a scratch local
Unknown(Reason)anything unmodelledthrows LiftUnsupported if asked to become CIL

The Unknown row is the fail-closed rule made concrete: an unmodelled value is safe as long as nobody asks it to become an instruction. The moment something does, the whole method is rejected — with the reason string attached.

Walking a handler

Take the annotated opcode-21 body from §09 and step it. The interpreter's state is its own symbolic stack (_eval), the handler's locals (_locals), and a shadow of the VM's stack types (_vmValueTypes):

step traceopcode 21
IL                              _eval (top first)                  emitted
────────────────────────────────────────────────────────────────────────────────
ldarg.1                         [Ctx]
callvirt ctx.getStack()         [StackRef]
stloc.0                         []                                  (local0 = StackRef)
ldloc.0                         [StackRef]
ldarg.1                         [Ctx, StackRef]
callvirt ctx.getArgs()          [SlotArray(args), StackRef]
ldfld KHc= / ldelem.u2          [Operand(1), SlotArray, StackRef]
ldelem.ref                      [SlotRead(args,1), StackRef]
callvirt stack.Push(v)          []                                  ldarg 1        ←
                                                                    (VM stack type shadow ← Int32)
...
callvirt stack.Pop()            [OnStack(Int32)]
call LHY=.NXY=(object)          [Cond(Falsy)]                       ldc.i4.0; ceq  ←
ldc.i4.0 ; ceq                  [Cond(Falsy, Negate)]
brtrue → (two arms)             — trial-executed, see below —       ldc.i4 0; ceq  ←
                                                                    brtrue →#2     ←

The Push/Pop round trip is the crux. The handler pushes args[1] onto the VM stack, then pops it back to test it. The interpreter emits ldarg 1 at the push, and the pop simply hands back an OnStack marker — no second load, no spill. The reconstructed CIL stack already holds the value.

Comparisons are re-emitted, not re-implemented

When the interpreter sees a call whose target ConditionClassifier recognised, it does not hand-roll ceq/clt. It first tries to emit the native equivalent for values already on the stack (TryEmitNativeStackComparison); if that does not apply, it materialises the arguments and re-emits a real call to the runtime's own comparison method. That way every relation — including Le, Ge, Ne, Falsy — and any type coercion the helper performs is reproduced exactly as the runtime would, rather than approximated:

Lift/ExecuteInterpreter.Calls.cs
if (_conditions.Relation(m) is { } rel)
{
    int pc = ParamCount(m) + (HasThis(m) ? 1 : 0);
    var args = new SymValue[pc];
    for (int i = pc - 1; i >= 0; i--) args[i] = Pop();

    if (TryEmitNativeStackComparison(rel, args))
    {
        _eval.Push(new SymValue.Cond(rel));
        return;
    }
    EmitArgsBoxed(args, m.Signature!.ParameterTypes);
    Emit(CilOpCodes.Call, m);
    _eval.Push(new SymValue.Cond(rel));
    return;
}

Branches inside a handler: trial execution

A handler's own IL branches for two very different reasons, and telling them apart is essential.

  • Branches on reflection facts — "is the resolved method static?", "does it return void?", "is this value type X?". These are concretely resolvable at lift time, because the token is known. The interpreter evaluates them and follows the single real path.
  • Branches on a genuine VM comparison result (a SymValue.Cond). These are the original program's control flow and must survive into the output.

For the second kind, ExecuteInterpreter.CondBranch.cs resolves each branch site locally, by trial execution: snapshot the entire interpreter state, explore both arms, restore, and commit only if one of two recognised shapes matches.

Lift/ExecuteInterpreter.CondBranch.cs
//   - "Materialise as a value" (a closed C# ternary, e.g. `cond ? 1 : 0` compiled unoptimised):
//     both arms are pure (emit no real CIL) and reconverge at the same instruction, differing only
//     in an int constant of {0,1}/{1,0} — replace with the ORIGINAL comparison call (+ negate if
//     needed) and keep interpreting from the convergence point.
//   - "Open branch" (the real VM instruction boundary): both arms run straight to a terminator
//     (SetIp) with no other emitted ops, landing on two different VM-instruction targets — emit a
//     call to the original comparison + brtrue/brfalse(/br) to those targets, and stop.
// Anything else is reported as unsupported rather than guessed at.

Why per site rather than a whole-handler two-pass? Because a fused handler can contain several independent comparisons — one whose result is merely stored to a scratch local, and a separate, later one that drives the real VM branch. A force-true/force-false pass over the whole handler couples them together and corrupts everything unrelated to the real branch. Local resolution keeps them independent.

Type recovery: the priming pass

Recall from §07 that declared VM local types are useless. ExecuteInterpreter therefore tracks, per slot, the type of whatever was last written there. But a state-machine-shaped method can read a slot at a lower bytecode index than the instruction that establishes its type. So BeginMethod runs a throwaway pass over every instruction first, purely for the type side effects:

Lift/ExecuteInterpreter.cs
foreach (var instr in instructions)
    try { Lift(runtime[instr.Opcode], instr); } catch { /* type side effects only; CIL discarded */ }

_vmValueTypes.Clear();
_tempLocals.Clear();

This is sound rather than a guess, and the comment says why: a VM local holds one consistent CLR type for its whole lifetime, because it is a single slot from the original pre-virtualization method. There is one subtlety the code handles explicitly — a branch storing literal null carries no type information and must not clobber a more specific type learned from a sibling branch:

Lift/ExecuteInterpreter.Calls.cs — DoSetLocal
if (narrowObjectToDeclared)
    _vmLocalKnownTypes[index] = declared;
else if (known is { } kt)
    _vmLocalKnownTypes[index] = kt;
else if (!_vmLocalKnownTypes.ContainsKey(index))
    _vmLocalKnownTypes[index] = null;

Receiver narrowing — a verifier problem, not a semantics problem

Reflection dispatches dynamically, so the runtime passes a call receiver as System.Object. A direct callvirt replacement needs the declaring type on the stack or the verifier rejects it. Because reflection already invoked that method on that value, the value is provably an instance of the declaring type, so inserting a castclass is always runtime-safe. The hard part is placement — the receiver is buried under the call's arguments. NarrowReceiver walks backwards over the just-emitted straight-line tail, netting out stack deltas, and aborts silently if the boundary cannot be pinned down exactly:

Lift/ExecuteInterpreter.Calls.cs
int args = ParamCount(target);
int pos = _out.Count, net = 0;
while (pos > 0 && net < args)
{
    var op = _out[pos - 1].OpCode.Code;
    if (op is CilCode.Br or CilCode.Brtrue or CilCode.Brfalse or CilCode.Switch or CilCode.Ret)
        return;                              // crossed control flow — can't trust the walk
    int d = NetDelta(_out[pos - 1]);
    if (d == int.MinValue) return;           // effect unknown — abort rather than mis-place
    net += d;
    pos--;
}
if (net != args) return;                     // didn't land exactly on the arg/receiver boundary
_out.Insert(pos, new LiftedOp(CilOpCodes.Castclass, dt));

There is one more restriction that only shows up on real code: the narrowing is applied only to public targets. Re-typing a receiver to the declaring type is verifiable for a public method from anywhere, but for a protected/internal one the legality depends on the receiver's original, often more derived, static type — a form may legally call the protected Control.set_DoubleBuffered on this, but the same call on a receiver re-typed to Control is an access violation.


11 RuntimeVocabulary — Finding the VM's Primitives

Everything in §10 assumes the interpreter can recognise ctx.getStack(), stack.Push, ctx.getLocals(), ctx.setIp() and the rest. Those are randomized names on randomized types. RuntimeVocabulary recovers each role from shape and usage.

The boxed-value wrapper

Start from the observation that locals and arguments are arrays of the same wrapper type. So: collect every parameterless instance getter on the context that returns an SzArray, resolve the element types, and take the most common one.

Runtime/RuntimeVocabulary.cs
var arrayGetters = getters
    .Where(g => g.Signature!.ReturnType is SzArrayTypeSignature)
    .Select(g => (getter: g, elem: Res(((SzArrayTypeSignature)g.Signature!.ReturnType).BaseType)))
    .Where(x => x.elem is not null)
    .ToList();
var valueType = arrayGetters
    .GroupBy(x => x.elem!)
    .MaxBy(grp => grp.Count())?.Key
    ?? throw new InvalidOperationException("Could not identify the VM boxed-value type (no value[] accessors).");

The evaluation stack, push, pop and peek

The stack is the context getter returning a type that has (a) a parameterless method returning the wrapper, and (b) at least one void method taking a single wrapper-or-object value. Pop and Peek have the same signature, so they cannot be told apart by shape — they are separated by mutation:

Runtime/RuntimeVocabulary.cs
// The parameterless value-returning methods are pop (mutates the stack — has a stfld)
// and peek (pure read). Distinguish by mutation rather than by an arbitrary order.
var popCand = valueReturners.FirstOrDefault(m => Mutates(m)) ?? valueReturners.FirstOrDefault();
...
peek = valueReturners.FirstOrDefault(m => m != pop && !Mutates(m));

private static bool Mutates(MethodDefinition m) =>
    m.CilMethodBody?.Instructions.Any(i => i.OpCode.Code == CilCode.Stfld) ?? false;

Getting this backwards would be catastrophic and silent — a Peek mistaken for a Pop desynchronises the reconstructed stack for the rest of the method. It is also why SymValue.OnStack carries a Peeked flag: re-emitting a genuinely peeked value must materialise a real dup, because the VM's logical stack still owns the original.

Locals vs arguments

Both are wrapper[] getters — identical signatures. They are separated by finding the setter: a void(int, wrapper) method whose body calls one of the array getters. Whichever array that setter indexes is getLocals; the other is getArgs. (Arguments are never written by the VM, so no such setter exists for them.)

Runtime/RuntimeVocabulary.cs
var used = body.Instructions
    .Where(i => i.OpCode.Code is CilCode.Call or CilCode.Callvirt)
    .Select(i => ResM(i.Operand as IMethodDescriptor))
    .FirstOrDefault(md => md is not null && valueArrayGetters.Contains(md));
if (used is not null) { setLocal = m; getLocals = used; break; }
...
getLocals ??= valueArrayGetters.FirstOrDefault();
var getArgs = valueArrayGetters.FirstOrDefault(g => g != getLocals) ?? getLocals;

The instruction pointer

A void(int) setter that stores to a field F, paired with a parameterless int getter that loads the same field F. Field identity is the whole proof.

Result on a real runtime

$ ... --vocabmeasured
[*] VM vocabulary (identified structurally, not by name):
    value/box type : jHY=
    stack type     : 4nU=
      push         : 7nU=, 7nU=, 7nU=
      pop / peek   : 7XU= / 7HU=
    ctx.getStack   : DHY=
    ctx.getLocals  : EHY=
    ctx.getArgs    : EnY=
    ctx.setLocal   : HHY=
    ctx.getIP/setIP: GHY= / GXY=
    ctx.setReturn  : C3Y=

Cross-check this against the annotated execute IL in §09: DHY= is indeed the getStack call at IL_0001, EnY= the getArgs at IL_0009, 7nU= the Push at IL_0017, 7XU= the Pop at IL_0027, GHY=/GXY= the getIp/setIp pair at IL_006A/IL_0073. Every role recovered without reading a single name.

ℹ️
Three Push overloads are reported because the wrapper's push takes either one value or a value plus a storage-kind enum. DoVmPush models the value argument and explicitly discards any genuinely materialised extra argument with a real pop — otherwise it leaks through as an orphaned, unbalancing value.

12 One VM Opcode Is Not One CIL Opcode

Every VM-devirtualizer tutorial you have read assumes a 1:1 map: VM opcode → CIL instruction. For Agile.NET that assumption is simply false, and building on it produces a tool that fails on the second sample.

Look again at the whole of ComputeI4Arithmetic. Eleven lines of C#, sixteen CIL instructions when compiled normally — and three VM instructions measured:

$ ... --lift ComputeI4Arithmetic
=== System.Int32 TestCases.TestPatterns::ComputeI4Arithmetic(System.Byte, System.Int32)  (token 0x06000006) ===
    locals (3): Int32, Object, Int32
    [000] op21(KHc==[1], KXc==[2])
         => ldarg 1; ldc.i4.0; ceq; ldc.i4 0; ceq; brtrue →#2
    [001] op22(LXc==[-1], Lnc==[100663302])
         => ldc.i4 -1; ret
    [002] op23(Mnc==[0,1,0,0,1,0], M3c==[8], NHc==[2], NXc==[100663302])
         => ldarg 0; ldarg 1; rem; stloc 0; ldarg 0; ldarg 1; add; ldc.i4 2; mul; ldloc 0; sub; ret

Opcode 23 alone is twelve CIL instructions. It is not "the rem handler" — it is an entire basic block, fused into one dispatch.

the correct mental model
WRONG:   VM opcode  ──►  one CIL opcode

RIGHT:   VM opcode  ──►  an ordered CIL sequence: a straight-line basic block,
                         optionally ending in a (conditional) branch,
                         with operand slots bound to the handler's decoded fields

Where the operands go

Look at Mnc==[0,1,0,0,1,0] — six uint16 values — next to the six slot references in the lifted block:

operand bindingobservation
ldarg  0   ←  Mnc=[0] = 0
ldarg  1   ←  Mnc=[1] = 1
rem
stloc  0   ←  Mnc=[2] = 0
ldarg  0   ←  Mnc=[3] = 0
ldarg  1   ←  Mnc=[4] = 1
add
ldc.i4 2   ←  NHc=[0] = 2
mul
ldloc  0   ←  Mnc=[5] = 0
sub
ret            NXc=[0] = 0x06000006
⚠️
That alignment is an observation made while writing this article, not a format claim. The devirtualizer never assigns a meaning to a field by name or position — the binding is produced by interpreting the execute IL, which reads Mnc=[k] at the exact points where it pushes a slot. The correlation is a consequence of correct decoding, not an input to it.

Fused blocks with side effects and control flow

More elaborate examples from the same fixture measured:

fused handlers in the wild
op34  ldstr "inner"; newobj System.Void System.ArgumentException::.ctor(System.String); throw

op45  ldsfld  int TestPatterns::LastLoopFinallyState; ldc.i4 31; mul;
      ldloc 1; add; ldloc 0; add;
      stsfld  int TestPatterns::LastLoopFinallyState; endfinally

op25  ldarg 0; switch (→#2, →#3, →#4, →#5)

op39  (nothing — 0 operand bytes, empty lifted sequence)

op63  stloc 1; ldsfld LastRethrowTrace; ldc.i4 10; mul; ldc.i4 3; add; stsfld LastRethrowTrace;
      ldloc 1; castclass System.ArgumentException; castclass System.Exception;
      callvirt System.Type System.Exception::GetType(); castclass System.Type;
      callvirt System.String System.Type::get_FullName(); ldstr ":";
      ldloc 1; castclass System.ArgumentException; castclass System.Exception;
      callvirt System.String System.Exception::get_Message();
      call System.String System.String::Concat(String, String, String);
      stsfld  LastRethrowException;
      ldc.i4 100; ldloc 1; ...::get_Message(); castclass System.String;
      callvirt System.Int32 System.String::get_Length(); add; stloc 0; br →#9

op63 is a single VM opcode carrying thirty CIL instructions, including three calls, a string concat, a static field store and a branch. A 1:1 lifter has nothing to say about it.

Consequences for the architecture

  • ExecuteInterpreter.Lift returns List<LiftedOp>, not a single op. Everything downstream is built around per-instruction sequences.
  • Branch targets are VM instruction indices, so the emitter creates one label per VM instruction, not per CIL instruction (§21).
  • A handler can contain several sub-operations each ending in its own setIp. Only the last one executed before the handler's own ret is real; earlier ones are dead. EmitTerminator fires once, at the end, with whatever _termKind survived.
  • ControlFlowGraphBuilder.BuildOperations must strip the trailing terminator out of a block's operation list and hoist it into the block's SemanticTerminator — but only when it is genuinely the block's last instruction.

13 Two Observed Handler Architectures

Across the seven runtimes in the project's corpus, two structurally distinct handler layouts appear. The project calls them expanded and compact. Both are handled by the same code, because both satisfy the same structural contract.

RuntimeStructureFingerprint exists specifically to describe a generated runtime without consulting any name — handler count, how many execute bodies contain a switch, how many are "large" (≥ 96 IL instructions), and the total/median/max execute-IL size, hashed into a shape identifier. Here is every runtime in the corpus measured for the five public fixtures sample1/sample2 from the project's private corpus:

RuntimeVersionMethodsVM instrs Handlersexec switchlarge exec total exec ILmedianmax
sample1 private6.6.0.3510113885742499338,4173064075
sample2 private6.6.0.42112110109,8896872061
TestCases — base6.6.0.42397052,5622351046
TestCases — extended6.6.0.42630240114,503861046
TestCases — control flow6.6.0.42853382175,940861046
TestCases — advanced6.6.0.4211846643710,7181051059
FaultCases6.6.0.421661152466164

"large exec" = execute bodies of ≥ 96 IL instructions. All handler/read/execute bindings resolved for all seven; all 131 protected methods decode with exact blob consumption and lift completely.

Expanded layout

expanded
many small, specialised handler classes
handler count scales with the size of the protected program
     3 methods /  9 VM instructions  →   7 handlers
     6 methods / 30 VM instructions  →  24 handlers
     8 methods / 53 VM instructions  →  38 handlers
    11 methods / 84 VM instructions  →  66 handlers
   101 methods / 1388 VM instructions → 574 handlers
heavy polymorphic duplication: several distinct classes, identical semantics

The scaling is the tell. The protector is not choosing from a fixed instruction set — it is generating handler classes for the blocks it found in the program it is protecting. That is also why duplication appears: three separate ldc.i4 <k>; ret handlers at opcodes 1, 4 and 22 in the same build, differing only in the field that carries k.

Compact / group layout

compact
~a dozen large "group" handler classes
handler count is fixed and does not scale with the program
median execute body ≈ 687 IL instructions (vs 86–306 in the expanded family)
a discriminator operand selects a sub-operation inside the group

The corpus test asserts the presence of both families structurally, again with no names:

AgileDevirtualizer.Tests/RuntimeFamilyCorpusTests.cs
Assert.Contains(fingerprints,
    item => item.Fingerprint.HandlerCount == 11
        && item.Fingerprint.MedianExecuteInstructions > 500);
Assert.Contains(fingerprints, item => item.Fingerprint.HandlerCount > 500);
Assert.True(fingerprints.Select(item => item.Fingerprint.ExecuteShapeHash)
        .Distinct().Count() >= 3,
    "The corpus should contain several independently generated runtime shapes.");

Why one architecture handles both

Nothing in §03–§11 assumes a handler count, a handler size, or a fixed number of operations per handler. The base type has two abstract slots; the registry is an ordered ldtoken run; operands come from interpreting the read method; semantics come from interpreting the execute method. A group handler that internally dispatches on a discriminator byte is, from the interpreter's point of view, just a handler whose execute IL happens to contain a switch that resolves concretely once the operand is known — the same mechanism that already resolves "is this method static?" branches.

⚠️
Do not generalise these numbers. "574 handlers" is a property of one build of one sample. "11 group handlers" is a property of one other. The corpus contains two product versions (6.6.0.35 and 6.6.0.42) and two structural families; that is evidence for those, and nothing more. The repo description of the compact layout's internal discriminator dispatch comes from the project's own DESIGN.md: the sample that exhibits it is not redistributable, so it could not be re-verified for this article.

14 From VM Instructions to Semantic IR

At this point the hard reverse-engineering is done. What remains is the part that, in practice, turned out to be harder: producing CIL that is both semantically exact and acceptable to a verifier.

The naive route — concatenate every VM instruction's lifted CIL, patch the branch operands, done — is what the project's --legacy-emission path still does, and it works for straight-line code. It falls down the moment control flow merges, because a linear walk has no notion of "what is on the stack at the top of this block, given all its predecessors". The whole Analysis/ namespace exists to answer that question properly.

The pipeline

lowering stages
VM code stream
   ↓  MethodDecoder + OperandDecoder
decoded VM instructions  (VmInstruction[])
   ↓  ExecuteInterpreter
lifted CIL sequences     (List<LiftedOp>[] — one list per VM instruction)
   ↓  LegacySemanticIrAdapter
semantic operations      (SemanticOperation[] — no CilOpCode remains)
   ↓  ControlFlowGraphBuilder
basic blocks + typed edges + exception regions
   ↓  WorklistAnalyzer / SsaGraphBuilder / SccpAnalyzer / SsaDeadCodeAnalysis
verified data flow
   ↓  SemanticCfgEmitter + SemanticCilLowerer
CilMethodBody

Why an opcode-free IR

SemanticOperation deliberately erases the concrete opcode. It carries a family (Add, Convert, CompareLessThan, LoadLocal, …) and a set of orthogonal attributes:

Analysis/SemanticInstructionSemantics.csannotated
internal readonly record struct SemanticInstructionSemantics(
    SemanticSignedness      Signedness   = SemanticSignedness.None,      // None | Signed | Unsigned
    SemanticOverflowMode    Overflow     = SemanticOverflowMode.Unchecked,
    SemanticPrimitiveType   PrimitiveType= SemanticPrimitiveType.None,   // Int8 … Float64 | Reference | Typed
    SemanticOperandEncoding Encoding     = SemanticOperandEncoding.Default, // Implicit | ShortInline | Inline
    SemanticDispatchKind    Dispatch     = SemanticDispatchKind.None,    // Direct | Virtual
    SemanticPrefixKind      Prefix       = SemanticPrefixKind.None,      // Constrained | Readonly | Tail | Volatile | Unaligned
    bool                    UnorderedFloatingPoint = false);             // NaN semantics

So clt and clt.un both become CompareLessThan, distinguished by Signedness + UnorderedFloatingPoint. ldloc.0, ldloc.s 0 and ldloc 0 all become LoadLocal with different Encoding. Twenty distinct conv.ovf.* opcodes collapse to Convert + Checked + a primitive type + a signedness.

The point of this is not tidiness. It is that the reverse mapping becomes an independent implementation. SemanticCilLowerer is the only code that turns attributes back into a CilOpCode, and it does not read the input opcode. That makes an end-to-end structural comparison meaningful: if the semantic route independently reconstructs the exact same body the direct route produced, the attribute set is complete. repo The project runs that comparison as a permanent test over 113 methods with zero structural differences.

Analysis/SemanticCilLowerer.cs
SemanticOperationCode.Divide    => Signed(operation, CilOpCodes.Div, CilOpCodes.Div_Un),
SemanticOperationCode.Remainder => Signed(operation, CilOpCodes.Rem, CilOpCodes.Rem_Un),
SemanticOperationCode.Add       => Arithmetic(operation, CilOpCodes.Add,
                                              CilOpCodes.Add_Ovf, CilOpCodes.Add_Ovf_Un),
SemanticOperationCode.CompareLessThan => Comparison(operation,
                                              CilOpCodes.Clt, CilOpCodes.Clt_Un),
...
private static CilOpCode Comparison(SemanticOperation operation,
    CilOpCode signedOrdered, CilOpCode unsignedOrUnordered) =>
    (operation.Semantics.Signedness, operation.Semantics.UnorderedFloatingPoint) switch
    {
        (SemanticSignedness.Signed,   false) => signedOrdered,
        (SemanticSignedness.Unsigned, true ) => unsignedOrUnordered,
        _ => throw Missing(),
    };

Note the throw Missing() arms. An attribute combination with no defined lowering is not silently approximated — CfgEmissionPolicy calls SemanticCilLowerer.CanLower on every operation and terminator before emission, and rejects the method if any of them has no lowering.

Real semantic IR

The diagnostic dump for ComputeI4Arithmetic measured:

09-semantic-ir.txtabridged
B0 VM[0..0]
  VM#0000 LoadArgument   { Encoding = Inline }             operand=SemanticArgumentReference { Index = 1 }
  VM#0000 LoadConstant   { PrimitiveType = Int32, Encoding = Implicit }   operand=0
  VM#0000 CompareEqual   { }                               operand=none
  VM#0000 LoadConstant   { PrimitiveType = Int32, Encoding = Inline }     operand=0
  VM#0000 CompareEqual   { }                               operand=none
  terminator Conditional { Predicate = True, Encoding = Inline }: brtrue →#2
B1 VM[1..1]
  VM#0001 LoadConstant   { PrimitiveType = Int32, Encoding = Inline }     operand=-1
  terminator Return: ret
B2 VM[2..2]
  VM#0002 LoadArgument   { Encoding = Inline }             operand=SemanticArgumentReference { Index = 0 }
  VM#0002 LoadArgument   { Encoding = Inline }             operand=SemanticArgumentReference { Index = 1 }
  VM#0002 Remainder      { Signedness = Signed }           operand=none
  VM#0002 StoreLocal     { Encoding = Inline }             operand=SemanticLocalReference { Index = 0 }
  ...
  terminator Return: ret

Notice Remainder { Signedness = Signed } — the IR records that this must lower to rem and not rem.un, as a semantic property rather than as a remembered opcode.


15 Building the Control-Flow Graph

ControlFlowGraphBuilder works in VM-instruction index space throughout. A basic block is a contiguous range of VM instructions.

Leaders

A VM index becomes a block leader if it is:

  • index 0;
  • the target of any lifted branch or switch case (VmTarget);
  • the instruction after any terminator;
  • an EH boundary: tryStart, tryEnd + 1, handlerStart, handlerEnd + 1, or a filter start.

Including EH boundaries is not optional — a region boundary that fell inside a basic block would make it impossible to attach a valid handler later.

Typed edges

The project uses twelve edge kinds, and the distinctions matter for later phases:

KindProduced byWhy it is distinct
FallThroughno terminator, next indexordinary sequential flow
Branchbr within one regionplain unconditional jump
Leaveany edge whose RegionPath loses a framemust lower to leave, may unwind finallys
ConditionalTaken / ConditionalFallThrougha conditional terminatorSCCP folds one of the two
SwitchCase (with index) / SwitchDefaultswitchcase index drives dispatcher analysis
ExceptionCatch / ExceptionFilter / ExceptionFinally / ExceptionFaultevery block inside a try, to the region's dispatch startseeds a different entry stack; forbids inserted edge code
ExceptionFilterHandlera successful endfilterthe accepted-filter path is a separate entry

The Leave classification is generic and structural — it is not "the lifted op was a leave". Any edge, including a lexical fall-through, that exits an exception region is a Leave:

Analysis/ControlFlowGraphBuilder.cs
var kind = block.RegionPath.ExitsTo(targetPath)
    ? ControlFlowEdgeKind.Leave : ControlFlowEdgeKind.FallThrough;
💡
repo This exact rule was introduced because the region-aware phi audit found 20 formal edges (19 in the private sample1 corpus, one in the advanced fixture) that left an EH region but were still labelled lexical FallThrough. Every one of them would have been a candidate for an illegal inserted copy. This is a good example of a verification pass paying for itself.

RegionPath

Every block carries an ordered outer-to-inner list of RegionFrame(RegionId, ClauseKind, Zone) where Zone is Try, Filter or Handler. Ordering is by region span (widest first), so nesting is explicit:

Analysis/RegionPath.cs
public bool ExitsTo(RegionPath target)
{
    int shared = 0;
    while (shared < Frames.Count && shared < target.Frames.Count
        && Frames[shared] == target.Frames[shared])
        shared++;
    return shared < Frames.Count;      // we lose at least one frame ⇒ this edge exits a region
}

For RethrowWithoutFilter, a block at VM index 3 sits inside all three regions and its path reads EH2.Try(Finally) > EH1.Try(Catch) > EH0.Try(Catch).

The graph for a real method

03-blocks.txt — ComputeI4Arithmeticmeasured
B0: VM [0..0] regionPath=outside
  #0000 [000] op21(KHc==[1], KXc==[2])
    LoadArgument: ldarg 1
    LoadConstant: ldc.i4.0
    CompareEqual: ceq
    LoadConstant: ldc.i4 0
    CompareEqual: ceq
    TERM Conditional: brtrue →#2
B1: VM [1..1] regionPath=outside
  #0001 [001] op22(LXc==[-1], Lnc==[100663302])
    LoadConstant: ldc.i4 -1
    TERM Return: ret
B2: VM [2..2] regionPath=outside
  #0002 [002] op23(...)
    ... 11 operations ...
    TERM Return: ret

Edges:
B0 -> B2 kind=ConditionalTaken
B0 -> B1 kind=ConditionalFallThrough
the same graph
            [B0]  divisor == 0 ?
           /    \
       taken    fallthrough
         |          |
       [B2]       [B1]
    real work    return -1
         |          |
        ret        ret

The validator's invariants

ControlFlowGraphValidator is a pure checker; its output gates emission through CfgEmissionPolicy. What it actually enforces:

  • Identity: block at position i has Id == i.
  • Coverage: blocks tile [0, InstructionCount) contiguously with no gap, no overlap, no empty or reversed range — and the last block ends exactly at InstructionCount - 1.
  • Edge endpoints are in range.
  • Edge/EH consistency: every exception edge carries an ExceptionRegionId; no normal edge does.
  • Out-degree: a Branch block has ≥1 normal edge; Conditional ≥2; Switch ≥ cases + 1 (the default); a FallThrough block that is not the last has ≥1.
Analysis/ControlFlowGraphValidator.cs
int expectedMinimum = block.Terminator.Kind switch
{
    SemanticTerminatorKind.FallThrough when
        block.EndInstructionIndex + 1 < graph.InstructionCount => 1,
    SemanticTerminatorKind.Branch      => 1,
    SemanticTerminatorKind.Conditional => 2,
    SemanticTerminatorKind.Switch      => block.Terminator.TargetInstructionIndices.Count + 1,
    _ => 0,
};
if (normal.Length < expectedMinimum)
    errors.Add($"B{block.Id} {block.Terminator.Kind} has {normal.Length} normal edges, "
        + $"expected at least {expectedMinimum}");

The coverage check is the strongest of these. Because VM instructions are the atoms and blocks are contiguous ranges of them, "the blocks cover every instruction exactly once" is a complete statement about the partition. A dropped instruction or a mis-computed leader cannot hide.


16 Abstract Interpretation and the Worklist Analyzer

A linear decoder can track the evaluation stack because there is only one path. Once blocks have multiple predecessors, "the stack at the entry of B" is a function of every path that reaches B — and with a loop, of itself. That requires a fixed point.

The abstract domain

AbstractValue is a finite lattice element with four independent components:

Analysis/AbstractValue.cs
internal sealed record AbstractValue(
    AbstractValueKind  Kind,          // Unknown | Int32 | Int64 | NativeInt | Float32 | Float64
                                      // | Reference | ManagedPointer | ValueType
    string?            ExactType,     // metadata full name when known
    AbstractNullability Nullability,  // NotApplicable | Null | NonNull | MaybeNull
    bool               HasConstant,
    object?            Constant);

The join is where finiteness is bought:

Analysis/AbstractValue.cs
public static AbstractValue Join(AbstractValue left, AbstractValue right)
{
    if (left == right) return left;
    if (left.Kind == AbstractValueKind.Unknown || right.Kind == AbstractValueKind.Unknown)
        return Unknown;
    if (left.Kind != right.Kind)
        return JoinNullAndReference(left, right) ?? Unknown;   // two references ⇒ System.Object

    string? exactType = string.Equals(left.ExactType, right.ExactType, StringComparison.Ordinal)
        ? left.ExactType
        : left.Kind == AbstractValueKind.Reference ? "System.Object" : null;
    var nullability = JoinNullability(left.Nullability, right.Nullability);
    bool sameConstant = left.HasConstant && right.HasConstant
        && Equals(left.Constant, right.Constant);
    return new AbstractValue(left.Kind, exactType, nullability,
        sameConstant, sameConstant ? left.Constant : null);
}

A constant survives only while every incoming path agrees; the first disagreement widens it. Reference types widen to System.Object. There are no chains that can ascend forever, so the fixed point terminates.

AbstractState holds { Reachable, Stack, Locals, RegionPath, IsImprecise }. A null stack means "conflicting shape" — for example two predecessors arriving with different stack depths. That is representable, and it is a rejection condition rather than a crash.

The worklist

Analysis/WorklistAnalyzer.cs
entries[0] = AbstractState.Entry(graph.Blocks[0].RegionPath);
queue.Enqueue(0);

while (queue.Count > 0 && iterations < MaximumIterations)     // 100_000
{
    int blockId = queue.Dequeue();
    var exit = SemanticTransfer.Transfer(graph.Blocks[blockId], entries[blockId]);
    exits[blockId] = exit;

    foreach (var edge in SsaControlFlow.Outgoing(graph, block))
    {
        var target   = graph.Blocks[edge.TargetBlockId];
        var incoming = StateForEdge(exit, edge, target.RegionPath);
        var joined   = AbstractState.Join(entries[target.Id], incoming, target.RegionPath);
        if (entries[target.Id].LatticeEquals(joined))
            continue;                                   // no change ⇒ don't re-enqueue
        entries[target.Id] = joined;
        if (queued.Add(target.Id))
            queue.Enqueue(target.Id);
    }
}

In pseudocode this is the textbook forward analysis:

algorithm
IN[entry] := ⊤empty ;  IN[b] := ⊥unreachable  for all other b
worklist  := { entry }

while worklist ≠ ∅:
    b := pop(worklist)
    OUT[b] := transfer(b, IN[b])
    for each edge b → s:
        contribution := edgeState(OUT[b], edge)      ← EH edges rewrite the stack, see below
        new := IN[s] ⊔ contribution
        if new ≠ IN[s]:
            IN[s] := new
            push(worklist, s)

Exception edges rewrite the state

The CLI does not let the evaluation stack flow into a handler. It is discarded and replaced by a handler-kind-specific entry state. StateForEdge encodes exactly that:

Analysis/WorklistAnalyzer.cs
if (ControlFlowEdgeSemantics.SeedsExceptionObject(edge.Kind))
{
    return new AbstractState(true,
        [AbstractValue.Reference("System.Exception", nonNull: true)],   // exactly one value
        source.Locals, targetPath, source.IsImprecise);
}
if (edge.Kind is ControlFlowEdgeKind.ExceptionFinally or ControlFlowEdgeKind.ExceptionFault)
{
    return new AbstractState(true, Array.Empty<AbstractValue>(),        // empty stack
        source.Locals, targetPath, source.IsImprecise);
}
return source.WithRegion(targetPath);

Locals do flow across an exception edge — that is the whole point of a catch block being able to observe state established before the throw. This asymmetry (stack replaced, locals preserved) drives the EH-aware liveness rules in §23.

What non-convergence means

Convergence is checked, not assumed. Two diagnostics are produced per block: unreachable, and conflicting entry stack shape. CfgEmissionPolicy turns both into rejections:

Analysis/CfgEmissionPolicy.cs
if (graphErrors.Count > 0)
    return new CfgEmissionEligibility(true, false, features, "invalid CFG: " + string.Join("; ", graphErrors));
if (!analysis.Converged)
    return new CfgEmissionEligibility(true, false, features, "worklist did not converge");
if (analysis.Blocks.Values.Any(block => block.Entry.Stack is null))
    return new CfgEmissionEligibility(true, false, features, "worklist has a conflicting entry stack shape");

The policy also computes a feature set — ExceptionRegions | Leave | Switch | BackEdge | MergePoint | StraightLine — used purely to describe and select methods for optimisation tiers, never by name or token. For the advanced fixture the real distribution is measured: 4 exception-region/leave methods, 2 switches, 3 back-edge methods, 6 merge-point methods, 5 straight-line methods.


17 SSA Construction

Everything after this point — constant propagation, dead-code elimination, dispatcher removal, local coalescing — is far easier when each value has exactly one definition. That is what SSA buys.

The classic motivation:

why phi nodes
if (x)  a = 10;               a1 = 10
else    a = 20;       ⇒       a2 = 20
return a;                     a3 = φ(a1, a2)
                              return a3

Three kinds of phi

SsaGraphBuilder creates phis for three things, and the third is the one most stack-machine SSA implementations get wrong:

Location kindCreated forNote
Variable (local)every tracked local slot at every block with predecessorsseeded from the worklist's local lattice
Variable (argument)every argument slotarguments are mutable in CIL (starg)
EvaluationStackevery stack slot alive at a block entrythe VM leaves values on the stack across block boundaries

Stack phis exist because Agile's bytecode genuinely produces blocks with non-empty entry stacks. Look at CompareNumericPaths: opcode 12 lifts to a bare ldc.i4 0 with no terminator, and opcode 16 begins with a stloc 0 that consumes a value produced in a different block.

values crossing a block boundarymeasured, 0x06000004
[004] op11 => ldarg 1; ldarg 0; clt; ldc.i4 0; ceq; br →#6
[005] op12 => ldc.i4 0
[006] op13 => ldarg 2; ldarg 3; clt; ...
...
[010] op16 => stloc 0; ldarg 4; ldc.i4 0; cgt.un; stloc 1; ldloc 0; and; ldloc 1; and; ret
              ^^^^^^^^ consumes a boolean produced in B4 or B5

Exception objects are definitions, not phis

A block entered only by exception edges gets its entry stack from the CLI, not from its predecessors. The builder models that explicitly, and refuses to mix the two:

Analysis/SsaGraphBuilder.cs
bool exceptionalEntry = incoming.Length > 0 && incoming.All(edge => IsExceptionEdge(edge.Kind));
if (exceptionalEntry)
{
    bool hasExceptionObject = incoming.All(edge =>
        ControlFlowEdgeSemantics.SeedsExceptionObject(edge.Kind));
    int expectedStack = hasExceptionObject ? 1 : 0;
    if (state.Entry.Stack.Count != expectedStack)
        throw new InvalidOperationException(
            $"B{block.Id} exceptional entry stack has {state.Entry.Stack.Count}, expected {expectedStack}");
    if (hasExceptionObject)
        current.EntryStack.Add(NewValue(SsaValueKind.ExceptionObject, ..., stackSlot: 0).Id);
}
else
{
    if (incoming.Any(edge => IsExceptionEdge(edge.Kind)))
        throw new InvalidOperationException($"B{block.Id} mixes exceptional and normal evaluation-stack entry: ...");
    ...
}

A catch entry therefore receives a fresh, non-null ExceptionObject SSA definition — never a stack phi. A finally or fault entry receives an empty stack. This is the CLI's contract expressed as an SSA invariant.

The verifier

SsaVerifier is not a sanity check; it is a gate. It rejects on any of: duplicate definitions, duplicate instruction ids, reachability disagreeing with the worklist, non-contiguous ordinals, operation count changing during conversion, entry or exit stack depth disagreeing with the worklist, a use of an undefined value, a phi with an incomplete predecessor set or wrong method-entry arity, a stored use table that differs from the actual operands, and —

Analysis/SsaVerifier.cs
var dominators = ComputeDominators(graph);
...
if (!dominators.GetValueOrDefault(useBlock, []).Contains(definitionBlock.Value))
    errors.Add($"%{value.Id} defined in B{definitionBlock} does not dominate ...");

— an explicit dominance check: every definition must dominate every use. repo This passes on the complete 113-method baseline.

Real SSA

13-ssa.txt — ComputeI4Arithmeticmeasured
Valid: True     Reachable blocks: 3     Values: 19     Phi nodes: 6     Uses: 28

B0 reachable=True
  LoadArgument(%2)              ; VM#0000
  %9  = LoadConstant()
  %10 = CompareEqual(%2,%9)
  %11 = LoadConstant()
  %12 = CompareEqual(%10,%11)
  TERM Conditional(%12)

B1 reachable=True
  %3 = phi v0 (B0:%0)
  %4 = phi a0 (B0:%1)
  %5 = phi a1 (B0:%2)
  %13 = LoadConstant()
  TERM Return(%13)

B2 reachable=True
  %6 = phi v0 (B0:%0)
  %7 = phi a0 (B0:%1)
  %8 = phi a1 (B0:%2)
  %14 = Remainder(%7,%8)
  StoreLocal(%14)
  %15 = Add(%7,%8)
  %16 = LoadConstant()
  %17 = Multiply(%15,%16)
  %18 = Subtract(%17,%14)
  TERM Return(%18)

Note %18 = Subtract(%17,%14): SSA has already recognised that the ldloc 0 at the end of the block reads the same value the stloc 0 wrote — the local round-trip is visible as a direct dependency. That is what makes the local-elimination tiers in §26 possible.


18 SCCP and Dead-Code Elimination

Sparse Conditional Constant Propagation computes, simultaneously, which values are constant and which edges are executable — each refining the other. That simultaneity is what makes it stronger than running constant folding and reachability separately.

Analysis/SccpLattice.cs
Undefined  ⊑  Constant(c)  ⊑  Overdefined

Join(Undefined,   x)            = x
Join(x,           Undefined)    = x
Join(Overdefined, _)            = Overdefined
Join(Constant a,  Constant b)   = a ≡ b ? Constant a : Overdefined

Float and double constants compare by bit pattern (BitConverter.SingleToInt32Bits), not by == — so NaN joins with itself correctly and +0.0 does not silently unify with -0.0.

Initial arguments, initial locals and exception objects start Overdefined; everything else starts Undefined. A phi joins only inputs arriving on executable edges. A conditional whose predicate is constant marks exactly one successor edge executable:

Analysis/SccpAnalyzer.cs
if (terminator.Kind == SemanticTerminatorKind.Conditional)
{
    var kind = decision.ConditionalTaken
        ? ControlFlowEdgeKind.ConditionalTaken
        : ControlFlowEdgeKind.ConditionalFallThrough;
    return normal.Where(edge => edge.Kind == kind);
}
if (terminator.Kind == SemanticTerminatorKind.Switch && decision.SwitchIndex is { } index)
{
    var selected = normal.Where(edge => edge.Kind == ControlFlowEdgeKind.SwitchCase
        && edge.SwitchCaseIndex == index).ToArray();
    return selected.Length > 0 ? selected
        : normal.Where(edge => edge.Kind == ControlFlowEdgeKind.SwitchDefault);
}

Exception edges are always marked executable, unconditionally. A handler is reachable whenever its try body is, and no constant can prove otherwise.

Why this matters for a VM-generated method

This is the pass that dissolves protector scaffolding. A state-machine dispatcher looks like:

the shape SCCP attacksconceptual
state = 3;
for (;;)
{
    switch (state)
    {
        case 0:  ...; state = 2; continue;
        case 1:  ...; state = 5; continue;
        case 3:  real block;     state = 7; continue;
        ...
    }
}

SCCP sees the selector as a phi whose executable inputs are all literal constants, proves only certain cases reachable, and marks every other case edge — and therefore every block only reachable through it — non-executable. What remains is the real control flow.

private-corpus On sample1 the audit identifies 43 infeasible blocks, 41 folded constant guards and 41 finite cyclic dispatchers — with no name or token rule anywhere in the analysis.

Conservative DCE

Liveness is seeded from side-effect roots and terminator inputs and propagated backwards. The interesting part is what counts as a side effect:

Analysis/SemanticEffectClassifier.cs
SemanticOperationCode.Convert =>
    operation.Semantics.Overflow == SemanticOverflowMode.Unchecked,

SemanticOperationCode.Call or SemanticOperationCode.CallVirtual => IsPureMathAbs(operation),

private static bool CanThrowFromArithmetic(SemanticOperation operation) =>
    operation.Semantics.Overflow == SemanticOverflowMode.Checked
    || operation.Code is SemanticOperationCode.Divide or SemanticOperationCode.Remainder;

Read that as a list of things DCE will not remove:

  • any call, except a one-argument System.Math.Abs — the single whitelisted pure framework call, chosen because the protector emits it as scaffolding;
  • any div/rem — they can throw DivideByZeroException;
  • any checked arithmetic or checked conversion — they can throw OverflowException;
  • every memory operation, allocation, field access and prefix.

An unused x / 0 is not dead code. That distinction is the difference between a devirtualizer and a subtly-wrong optimiser.

ℹ️
repo The visible effect on the private corpus: after dispatcher rewriting, ILSpy's whole-assembly quality gate reports Math.Abs scaffolding falling from 161 occurrences to zero, switch from 46 to five, while (true) from 90 to 11, and decompiler goto IL_ labels from 54 to zero. The five remaining switches and 11 remaining infinite loops are real domain logic.

19 Removing Dispatcher Loops

Constant propagation makes a dispatcher's arms provably unreachable. It does not, by itself, remove the dispatcher: the switch, the state local, and the loop back-edge are still there, and a decompiler still renders them.

before
       [entry]  state = 3
          │
          ▼
   ┌──►[dispatch]  switch (state)
   │      │  │  │
   │   ┌──┘  │  └──┐
   │   ▼     ▼     ▼
   │ [c0]  [c3]  [c7]        c0, c7 proven unreachable
   │   │     │     │
   └───┴─────┴─────┘         every arm loops back
after
       [entry]
          │                  state store and its pure computation removed
          ▼
        [c3]                 direct transition; dispatch block gone

Recognition is structural

ControlFlowSimplifier.TryBuildDispatcher requires all of:

  1. the block's terminator is a Switch with exactly one input;
  2. that input is a phi in this block (not an ordinary value);
  3. every executable phi input is an SCCP constant convertible to Int64;
  4. at least two distinct state values exist;
  5. each state selects a retained switch-case edge (or the default);
  6. at least one selected target can reach the switch block again — it is genuinely cyclic.
Analysis/ControlFlowSimplification.cs
if (block.Terminator is not
    { Terminator.Kind: SemanticTerminatorKind.Switch, Inputs.Count: 1 } terminator)
    return false;
int selectorId = terminator.Inputs[0];
var phi = block.Phis.SingleOrDefault(candidate => candidate.Result.Id == selectorId);
if (phi is null) return false;
...
if (sccp.Values[input.ValueId] is not { Kind: SccpValueKind.Constant } value
    || !TryInt64(value.Constant, out long state))
    return false;
...
if (!targets.Values.Any(edge => CanReach(edge.TargetBlockId, block.Id, graph.Source, retainedEdges)))
    return false;

No method name, no metadata token, no "the protector usually calls it state".

Planning the rewrite

DispatcherEliminationPlanner then proves the rewrite legal, and this is where most of the safety lives. For every predecessor transition it requires:

  • the finite states cover every executable incoming edge — a single unaccounted-for predecessor aborts the whole plan;
  • the predecessor enters the dispatcher unconditionally (exactly one retained normal out-edge, and a Branch/FallThrough terminator);
  • predecessor, dispatcher and selected target are all in the same exception region;
  • the state computation is an isolated instruction suffix of the predecessor — every instruction from some point onward, nothing earlier;
  • every value it produces is consumed only within that suffix or by the selector phi (OutputsArePrivate);
  • every instruction in the suffix is a pure, constant definition (CanReplaceWithConstant).
Analysis/DispatcherEliminationPlan.cs
if (normalIncoming.Length != dispatcher.Transitions.Count)
{
    reason = "finite states do not cover every executable incoming edge";
    return false;
}
...
if (!ControlFlowSimplifier.SameRegion(...) || !ControlFlowSimplifier.SameRegion(...))
{
    reason = $"B{predecessor.Id} transition crosses an exception-region boundary";
    return false;
}
...
if (!suffix.SetEquals(removable))
{
    reason = "state calculation is not an isolated instruction suffix";
    return false;
}

And in OptimizedSemanticEmitter, any rejection from either the dispatcher or constant-branch planner abandons optimisation entirely for that method and falls back to the lossless semantic body — there is no partial rewrite:

Analysis/OptimizedSemanticEmitter.cs
if (dispatchers.Rejections.Count > 0 || branches.Rejections.Count > 0)
{
    string reason = string.Join(" | ", dispatchers.Rejections.Concat(branches.Rejections).Take(5));
    attempts.Add(new OptimizationAttempt("dispatcher-rewrite", "rejected", reason));
    return Lossless();
}

What is actually removed

Being precise about this matters:

RemovedKept
The proven state store and its pure constant computation suffixAny dispatcher that fails one gate — the method keeps its lossless body
The dispatch edge, replaced by a direct predecessor→target transitionEvery real domain switch (its selector is not an all-constant phi)
Blocks left unreachable, physically, via PrunedSemanticCfgEmitterUnreachable-block pruning in methods with exception handlers — the layout stays linear there
Analysis/OptimizedSemanticEmitter.cs
CilMethodBody selected = linear;
string layout = "linear";
if (decoded.ExceptionHandlers.Count == 0)
{
    selected = PrunedSemanticCfgEmitter.Emit(module, target, decoded, rewrite.Graph, tempLocalTypes);
    layout = "pruned";
}

private-corpus Reported outcome on sample1: all 41 dispatchers have a verified direct-transition/state-slice rewrite; 41 of 101 methods are optimised on the default route; the optimised assembly has the same nine PEVerify diagnostics as the lossless output, so the optimisation introduces no verifier regression.


20 Reconstructing CIL

Emission is split deliberately into two halves that do not know about each other.

separation of concerns
SemanticCfgEmitter      knows about blocks, labels, locals, EH regions, layout
                        does NOT choose opcodes

SemanticCilLowerer      chooses opcodes from semantic attributes only
                        does NOT know about blocks, labels or the original opcode

Instruction selection

Examples of what SemanticCilLowerer derives rather than remembers:

Semantic inputSelected CILDeciding attribute
Addadd / add.ovf / add.ovf.unOverflow × Signedness
Dividediv / div.unSignedness
CompareLessThanclt / clt.unSignedness + UnorderedFloatingPoint
LoadArgument(2)ldarg.2Encoding = Implicit and index ≤ 3
LoadLocalldloc.s / ldlocEncoding = ShortInline / Inline
LoadConstant(5)ldc.i4.5PrimitiveType = Int32, Encoding = Implicit
LoadElementldelem.i4, ldelem.ref, ldelem, …PrimitiveType
LoadFunctionPointerldftn / ldvirtftnDispatch
Prefixconstrained., readonly., tail., volatile., unaligned.Prefix
terminator Branchbr / br.s / leave / leave.sEncoding + whether the edge is a Leave

That last row is the only place the lowerer takes an out-of-band input, and it comes from the graph, not from the original opcode:

Analysis/SemanticCfgEmitter.cs
bool isLeave = terminator.Kind == SemanticTerminatorKind.Branch
    && graph.Outgoing(block).Any(edge => edge.Kind == ControlFlowEdgeKind.Leave);
var opCode = SemanticCilLowerer.Lower(terminator, isLeave);

Call dispatch and prefixes

Whether a call is call or callvirt comes from the VM's own virtual flag, which Agile stores as the reflection wrapper's trailing bool parameter:

Lift/ExecuteInterpreter.Calls.cs
// Agile stores the virtual-call flag as the wrapper's trailing bool parameter.
bool isVirtual = LastParamIsBool(m) && AsBool(popped[0]);
...
Emit(isVirtual ? CilOpCodes.Callvirt : CilOpCodes.Call, target);

constrained. is emitted when the receiver is provably a managed pointer to a value type and the call is virtual with no arguments — the shape where reflection would have accepted a boxed receiver but native CIL needs the prefix:

Lift/ExecuteInterpreter.Calls.cs
TypeSignature? constrainedReceiver = null;
if (isVirtual && ParamCount(target) == 0 && _eval.Count > 0
    && HandlerLocalValue(_eval.Peek()) is SymValue.OnStack
        { KnownType: { IsValueType: true } valueType, ManagedPointer: true })
    constrainedReceiver = valueType;
...
if (constrainedReceiver is not null)
    Emit(CilOpCodes.Constrained, constrainedReceiver.ToTypeDefOrRef());

This is visible in real output. GuidRoundTrip lifts to measured:

0x06000003 — one VM instruction
call System.Guid System.Guid::NewGuid(); stloc 0;
ldloca 0; constrained. System.Guid; callvirt System.String System.Object::ToString(); stloc 1;
ldloca 2; ldloc 1; castclass System.String; call System.Void System.Guid::.ctor(System.String);
ldloca 2; constrained. System.Guid; callvirt System.String System.Object::ToString();
ldloc 1; castclass System.String;
call System.Boolean System.String::op_Equality(System.String, System.String); ret

which ILSpy renders back as the original two-line method:

devirtualized outputILSpy
public static bool GuidRoundTrip()
{
    string text = Guid.NewGuid().ToString();
    return new Guid(text).ToString() == text;
}

Boxing, and why argument order forces a reorder

box only ever operates on the current top of the stack. When an argument that needs boxing is already buried under later arguments, boxing in place is impossible. The naive "push arg0; box; push arg1; box" is silently wrong — the second box applies to arg1, not arg0. EmitArgsBoxed handles this by pushing everything first, then spilling from the top down to the deepest argument that needs a box, boxing in place, and reloading:

Lift/ExecuteInterpreter.Calls.cs
for (int i = 0; i < n; i++) EmitPush(values[i]);
if (Array.TrueForAll(boxType, t => t is null)) return;   // fast path

var temps = new int[n];
for (int i = n - 1; i >= 1; i--)
{
    temps[i] = AllocTemp(KnownTypeOf(values[i]) ?? _module.CorLibTypeFactory.Object);
    Emit(CilOpCodes.Stloc, new TempLocalRef(temps[i]));
}
if (boxType[0] is { } bt0) Emit(CilOpCodes.Box, bt0.ToTypeDefOrRef());
for (int i = 1; i < n; i++)
{
    Emit(CilOpCodes.Ldloc, new TempLocalRef(temps[i]));
    if (boxType[i] is { } bti) Emit(CilOpCodes.Box, bti.ToTypeDefOrRef());
}

Those scratch locals are TempLocalRefs, a distinct operand type so the builder can route them to locals appended after the VM's own declared locals rather than confusing them with VM local indices.

Validation before installation

No body is ever installed unvalidated. Both emitters compute max stack and run the type-safety validator, and roll back on failure:

Emit/CilBuilder.cs
var original = target.CilMethodBody;
target.CilMethodBody = body;
try
{
    body.ComputeMaxStack();
    CilTypeSafetyValidator.Validate(body);
    return body;
}
catch
{
    target.CilMethodBody = original;   // rejected method stays virtualized rather than broken
    throw;
}

CilTypeSafetyValidator exists because ComputeMaxStack is a height calculation. It happily accepts managed-pointer shapes the CLR type verifier rejects. The validator catches those specifically — an uncertain body stays VM-backed rather than becoming unverifiable CIL.


21 Branch-Target Reconstruction

Agile branches in VM instruction index space. CIL branches to an instruction. Because one VM instruction expands to many CIL instructions (§12), the mapping is one-to-many and must be built explicitly.

the gap
VM:   [017]  brtrue →#42          ← 42 is an index into the opcode array

CIL:  IL_0031: brtrue IL_006A     ← IL_006A is a byte offset of a real instruction

The lifter never produces a byte offset. It produces a VmTarget(int Index) — a deliberately distinct type so an index can never be mistaken for a constant operand:

Lift/LiftedOp.cs
/// <summary>
/// A branch/switch target expressed as a VM-instruction index (the granularity Agile branches at).
/// The emitter turns these into real CIL labels once every VM instruction has a start label.
/// </summary>
internal readonly record struct VmTarget(int Index)
{
    public override string ToString() => $"→#{Index}";
}

The label array

Resolution is a two-pass walk: emit everything while recording where each VM instruction started, then bind labels.

Emit/CilBuilder.cs
int n = lifted.Count;
var labels     = new CilInstructionLabel[n + 1];   // note: n + 1
for (int i = 0; i <= n; i++) labels[i] = new CilInstructionLabel();
var startIndex = new int[n + 1];

for (int i = 0; i < n; i++)
{
    startIndex[i] = instrs.Count;
    foreach (var op in lifted[i])
        instrs.Add(Lower(..., op, i, decoded.ExceptionHandlers));
}
startIndex[n] = instrs.Count;
...
for (int i = n; i >= 0; i--)
    labels[i].Instruction = instrs[Math.Min(startIndex[i], instrs.Count - 1)];

The n + 1-th label is the "one past the end" position. It is genuinely needed: it backs any branch to an out-of-range index (the VM ends its dispatch loop when the IP runs past the table — that is how a return at the end is encoded), and it is the exclusive end bound for exception regions whose handler is the last VM instruction.

Two emission details that are easy to get wrong

A trailing return. The builder appends a ret to back the end label — unless the method's own lifted ops already end in one, because two consecutive rets are dead code that trips AsmResolver's stack calculator. And for a non-void method, a bare ret is invalid CIL even as dead code (PEVerify: "return value missing on the stack"), so a default value is materialised first. initobj is used because it works uniformly on reference and value types alike:

Emit/CilBuilder.cs
if (instrs.Count == 0 || instrs[^1].OpCode.Code != CilCode.Ret)
{
    var returnType = target.Signature!.ReturnType;
    if (!returnType.IsTypeOf("System", "Void"))
    {
        var defaultTemp = new CilLocalVariable(importer.ImportTypeSignature(returnType));
        body.LocalVariables.Add(defaultTemp);
        instrs.Add(new CilInstruction(CilOpCodes.Ldloca, defaultTemp));
        instrs.Add(new CilInstruction(CilOpCodes.Initobj, returnType.ToTypeDefOrRef()));
        instrs.Add(new CilInstruction(CilOpCodes.Ldloc, defaultTemp));
    }
    instrs.Add(new CilInstruction(CilOpCodes.Ret));
}

Locating insertion points by position, not by instance. When the builder later inserts missing leave instructions (§22) it collects positions against the pre-insertion index and applies them highest-first. The comment explains why IndexOf would be a bug: CilInstruction equality is value-based, and a method can repeat identical instructions dozens of times, so IndexOf can resolve to an unrelated earlier occurrence.

Real resolution

07-emitted-il.txt — ComputeI4Arithmeticmeasured
VM:   [000] op21 ... brtrue →#2          ← VM instruction index 2

CIL:
IL_0000: ldarg   System.Int32 divisor
IL_0004: ldc.i4.0
IL_0005: ceq
IL_0007: ldc.i4  0
IL_000C: ceq
IL_000E: brtrue  IL_0019                 ← label for VM index 2
IL_0013: ldc.i4  -1                      ← VM index 1 starts here
IL_0018: ret
IL_0019: ldarg   System.Byte value       ← VM index 2 starts here
IL_001D: ldarg   System.Int32 divisor
IL_0021: rem
IL_0022: stloc   V_0
IL_0026: ldarg   System.Byte value
IL_002A: ldarg   System.Int32 divisor
IL_002E: add
IL_002F: ldc.i4  2
IL_0034: mul
IL_0035: ldloc   V_0
IL_0039: sub
IL_003A: ret

Switch targets go through the same path: SymValue.SwitchTable holds decoded deltas, DoSetIp converts them to VmTarget[], and the emitter resolves each to a label.

Lift/ExecuteInterpreter.Calls.cs — DoSetIp
case SymValue.Ip ip:
    _termKind   = TermKind.Branch;
    _termTarget = _instr.Index + 1 + ip.Offset;
    break;
case SymValue.SwitchTable st:
    _termKind   = TermKind.Switch;
    _termSwitch = st.Deltas.Select(d => new VmTarget(_instr.Index + 1 + st.Base + d)).ToArray();
    break;
case SymValue.Operand { Value: { } v } when IsIntLike(v):
    // An absolute IP (e.g. int.MaxValue) drives the dispatch loop out of range = return.
    _termKind = TermKind.Return;
    break;
default:
    throw new LiftUnsupported($"setIp with unmodelled value {arg}");

Note the third case: a return in the original method can be encoded as "set the IP out of range". Recognising that as ret rather than as a wild branch is a semantic decision, made explicitly, with an unmodelled shape rejected.


22 Rebuilding Exception Handlers

The decoded EH clause is five inclusive VM indices plus an optional token. The CIL exception handler is four labels plus a type. The conversion is mechanical; the traps are not.

conversion
EhClause                        CilExceptionHandler
────────────────────────────────────────────────────────────────
ClauseType    0|1|2|4     →     HandlerType  Exception|Filter|Finally|Fault
TryStart      (inclusive) →     TryStart     = label[TryStart]
TryEnd        (inclusive) →     TryEnd       = label[TryEnd + 1]        ← exclusive
HandlerStart  (inclusive) →     HandlerStart = label[HandlerStart]
HandlerEnd    (inclusive) →     HandlerEnd   = label[HandlerEnd + 1]    ← exclusive
ExtraToken    (catch)     →     ExceptionType = imported TypeDefOrRef

The clause-type values map directly onto CilExceptionHandlerType0/1/2/4 are the same constants ECMA-335 uses — so the cast is exact rather than a lookup table.

The missing-leave problem

ECMA-335 forbids falling out of a protected region: you must leave it with an explicit leave. The VM does not have that rule. Its bytecode only contains an explicit terminator at a region boundary when the original method happened to have a branch there. A region whose last VM instruction simply flows into the next one — a one-instruction catch { } body ending in a pop, say — leaves nothing to convert.

So the emitter inserts the missing leave:

Emit/CilBuilder.cs
var leaveInsertions = new List<(int Pos, CilInstructionLabel Exit)>();
foreach (var eh in decoded.ExceptionHandlers)
{
    var exit = labels[Math.Clamp(eh.HandlerEnd + 1, 0, n)];
    CollectLeaveInsertion(instrs, startIndex, Math.Clamp(eh.TryEnd + 1, 0, n),     exit, leaveInsertions);
    CollectLeaveInsertion(instrs, startIndex, Math.Clamp(eh.HandlerEnd + 1, 0, n), exit, leaveInsertions);
}
foreach (var (pos, exit) in leaveInsertions.OrderByDescending(x => x.Pos))
    instrs.Insert(pos, new CilInstruction(CilOpCodes.Leave, exit));

Both the try's exit point and the handler's exit point need this, and both leave to the instruction after the handler.

How each terminator is treated

ConstructRecognitionEmitted
leave any edge whose RegionPath loses a frame (§15) — not a lifted opcode leave/leave.s via the isLeave flag
endfinally the VM's unwind dispatcher, recognised structurally: exactly one switch in the execute body, a resume arm through setIp, and ≥ 2 throwing arms — and the current VM index is the inclusive end of a decoded finally/fault region endfinally
rethrow a throw of the context's current-exception accessor at the inclusive end of a decoded catch handler rethrow
throw a throw of a live stack value whose tracked type derives from System.Exception throw
VM guard throw anything else reaching a throw rejectedLiftUnsupported
endfilter modelled in the IR and CFG; not exercised by a real protected fixture shadow-only

The throw vs rethrow distinction is not cosmetic — it changes the stack trace. The code is deliberately narrow about it:

Lift/ExecuteInterpreter.ExceptionFlow.cs
/// Converts a throw of the VM context's current exception at the inclusive end of a catch
/// handler into native <c>rethrow</c>. Restricting it to a decoded catch terminator prevents an
/// ordinary source `throw ex` from being rewritten with different stack-trace semantics.
private bool TryEmitRethrow(SymValue exception)
{
    if (exception is not SymValue.CurrentException
        || !_vmExceptionHandlers.Any(eh => eh.ClauseType == 0 && eh.HandlerEnd == _instr.Index))
        return false;
    Emit(CilOpCodes.Rethrow);
    _termKind = TermKind.Resolved;
    return true;
}

And the unwind-dispatcher recognition, which distinguishes VM machinery from a real source switch:

Lift/ExecuteInterpreter.ExceptionFlow.cs
var switches = body.Instructions.Where(i => i.OpCode.Code == CilCode.Switch).ToList();
if (switches.Count != 1 || !ReferenceEquals(switches[0], candidate))
    return false;

bool resumesViaIp = false;
int throws = 0;
foreach (var instruction in body.Instructions)
{
    if (instruction.OpCode.Code == CilCode.Throw) throws++;
    if (instruction.Operand is IMethodDescriptor called && Same(ResolveM(called), _vocab.SetIp))
        resumesViaIp = true;
}
return resumesViaIp && throws >= 2;

Some builds fuse useful work — a constrained. Dispose call, for instance — before that same tail, so recognition happens when the tail is reached and preserves everything the interpreter already emitted.

Catch entry state

Before lifting any instruction, PrepareExceptionHandlerEntry checks whether it is a catch handler start, and if so resets the VM stack shadow to exactly one value of the resolved catch type:

Lift/ExecuteInterpreter.ExceptionFlow.cs
_vmValueTypes.Clear();
_vmValueTypes.Push(new VmStackType(catchType, ManagedPointer: false, KnownNull: false));

Without this reset, values from mutually exclusive throw paths leak into the handler's modelled state. With it, the handler starts from the CLI's actual contract.

Verified end to end

RethrowWithoutFilter — two nested catches, a rethrow, and a finally — devirtualizes to CIL that ILSpy renders as repo, README:

devirtualized 0x0600000DILSpy
try
{
    try
    {
        ...
        switch (mode)
        {
        case 0:  return 10;
        case 1:  throw new ArgumentException("inner");
        default: throw new InvalidOperationException("escape");
        }
    }
    catch (ArgumentException)
    {
        ...
        throw;                       // native rethrow
    }
}
catch (ArgumentException ex3)
{
    ...
    return 100 + ex3.Message.Length;
}
finally
{
    ...
}

The if (mode == 0) / if (mode == 1) chain in the source came back as a switch because that is the concrete branch shape this build's VM bytecode encoded. Both are correct, semantically equivalent CIL — the tool reproduces the bytecode's structure rather than guessing at the original C#.


23 EH-Aware SSA

Everything in §17–§19 assumed ordinary control flow. Exception handling breaks three assumptions at once: control can transfer from anywhere inside a try, the evaluation stack is discarded on that transfer, and there are implicit executions (a finally running on the way out) that no branch instruction encodes.

CLI entry contracts, modelled explicitly

entry state per handler kind
catch    →  stack = [ exception ]     locals preserved   type = the resolved catch type
filter   →  stack = [ exception ]     locals preserved   type = System.Object (CLI stack type)
  ↳ accepted filter handler: a SECOND, separate entry with its own fresh exception object
finally  →  stack = [ ]               locals preserved
fault    →  stack = [ ]               locals preserved

ExceptionEntryModel makes each of these a first-class record, and ExceptionEntryModelVerifier checks entry inventory, edge kind, region path, stack depth, non-null SSA definition, type, and the absence of evaluation-stack phis at those entries. A negative fixture proves that an unresolved catch metadata token is rejected rather than defaulted.

Note that filter evaluation and the accepted filter handler are deliberately two entries. Both receive a CLI-created exception object; RegionZone.Filter distinguishes the zones; and a distinct ExceptionFilterHandler edge models a successful endfilter.

Continuations

ExceptionContinuationModel records what actually happens after each EH terminator — the part CIL leaves implicit:

Analysis/ExceptionContinuationModel.cs
LeaveContinuation      { Edge, FinallyRegionIds (inner→outer), FinalTargetBlockId, Transition }
RethrowContinuation    { SourceBlockId, ActiveCatchRegionId, ContinuesDynamicExceptionSearch }
EndFinallyContinuation { SourceBlockId, HandlerRegionId, HandlerKind,
                         ResumableLeaveIds, ContinuesExceptionUnwind }
EndFilterContinuation  { SourceBlockId, FilterRegionId, AcceptedHandlerBlockId,
                         RejectedContinuesExceptionSearch }

Three rules encoded there that are easy to state and easy to get wrong:

  • a leave carries the ordered inner-to-outer chain of finallys that must run before it reaches its target;
  • an endfinally distinguishes resuming a pending leave from continuing an exception unwind, and a fault handler may never resume a normal leave;
  • rethrow stays tied to its active catch but continues the dynamic exception search — it does not simply branch anywhere.

repo Measured on the advanced fixture: 11 leave continuations (nine of which unwind at least one finally), one rethrow, four endfinally sites. Negative tests reject a rethrow outside a catch and a non-int32 endfilter predicate.

Liveness must be widened

This is the single most important EH-specific correction in the project. Ordinary DCE says: a store whose value is never read is dead. Under exception handling that is false — a catch or finally can observe the last store that happened before a throwing operation, even though no normal path reads it.

Analysis/EhSsaShadowPlanner — the emission closure rule
DCE-live values
  ∪  EVERY source-variable store
  ∪  their transitive dependencies (store inputs, phis, pure constants, operation definitions)
  =  the EH emission closure

repo This rule was not designed up front; it was forced by an external validation gate. Installing EH SSA bodies into an isolated artifact and running PEVerify plus the runtime oracle caught two defects the internal max-stack and type checks could not: a final nop anchor that allowed method fall-through, and removed stloc operations that left finally/loop state stale. The permanent fixes are an unreachable ldnull; throw end sentinel and this store-complete closure.

Stack-phi type recovery

Merging a Boolean and an Int32 at a block entry is legal on the CLI evaluation stack — both are int32 there — but a spill local needs one concrete metadata type. EhStackPhiTypeInference permits canonicalisation only for evaluation-stack phis and only within CLI-equivalent integral categories (Boolean/small integers/Int32, Int64/UInt64, native integers). Variable phis never get this treatment.

Region-aware phi-copy legality

Every live phi input is classified by its source and target RegionPath into one of four dispositions:

DispositionMeaning
EmittedCopya normal edge across a valid CLI transition — a real copy may be inserted
ImplicitVariableStateexception dispatch — the value is already in the local/argument state; inserted edge code is forbidden
RequiresLeaveUnwindthe copy's leave unwinds a finally, so copy timing is not yet modelled — explicitly deferred
Illegalthe transition violates ECMA-335 region rules

RegionTransitionClassifier is the rule engine, and its rejections read like the spec:

Analysis/RegionTransition.cs
if (added.Any(frame => frame.Zone is RegionZone.Filter or RegionZone.Handler))
    return Invalid(source, target, "normal control flow cannot enter a filter or handler");
if (removed.Length > 0 && edge.Kind != ControlFlowEdgeKind.Leave)
    return Invalid(source, target, "control flow exits an EH region without leave");
if (edge.Kind == ControlFlowEdgeKind.Leave
    && removed.Any(frame => frame.Zone == RegionZone.Filter
        || frame.Zone == RegionZone.Handler
        && frame.ClauseKind is ExceptionClauseKind.Finally or ExceptionClauseKind.Fault))
    return Invalid(source, target, "leave cannot exit filter/finally/fault code");
...
if (target.StartInstructionIndex != region.TryStart && !associatedHandlerReturn)
    return Invalid(source, target,
        $"control flow enters EH{frame.RegionId}.Try below its first instruction");

And exception-dispatch edges are given RegionCopyPlacement.None with the reason string "CLI exception dispatch; emitted edge copies are forbidden" — a hard structural prohibition, not a preference.

💡
repo Current audited state: the advanced fixture has 130 normal/method-entry copies, 27 implicit exception-entry variable states, 14 finally-dependent deferred leaves, and zero illegal transitions. The private sample1 corpus reports 4,388 / 70 / 11 / zero. Negative tests reject entry into the middle of a try, normal entry into a handler, branch exit without leave, and leave out of filter/finally/fault code.

What the strict production tier actually accepts

EH SSA is enabled by default, but the tier is deliberately narrow. From Devirtualizer.Run and EhSsaValidationSelection, a method is admitted only with: a valid convergent CFG and SSA; verified entry, continuation and RegionPath models; exact types; catch/finally clauses only; function-pointer rematerialization restricted to the direct adjacent single-use shape (§25); and either no evaluation-stack phi edge copies at all, or only the runtime-proven shape — non-critical normal edges whose source and target have identical RegionPath and no finally unwind.

Filter and fault stay shadow-only. Any rejected method falls back automatically to the stable semantic emitter and is reported. There is no silent partial application.


24 Phi Lowering Across Exception Regions

Lowering SSA back to CIL means destroying phi nodes. The standard technique is to insert a copy on each incoming edge, so that all inputs of a phi end up in the same storage location before control reaches the merge:

ordinary phi lowering
  B1: ... %a           B2: ... %b
      stloc temp           stloc temp        ← inserted copies
        \                    /
         ▼                  ▼
          B3:  %c = φ(%a, %b)
               ldloc temp

Outside exception handling this is unremarkable. Inside it, that inserted stloc temp is a real instruction at a real position, and its position determines which exception region it belongs to.

The failure mode

why a naive copy is unsoundconceptual
try {
    ...
    // source block ends here; edge leaves the try
}                     ← inserting `stloc temp` at "source exit" puts it INSIDE the try:
                        it now runs before the finally, and can itself be interrupted
catch { ... }
finally { ... }

// target block starts here
                      ← inserting `stloc temp` at "target entry" puts it OUTSIDE:
                        it now runs AFTER the finally, which may have observed the old value

Worse, on an exception-dispatch edge there is no position at all. The CLI transfers control to a handler from an arbitrary point inside the try; there is no instruction slot where a copy could be placed that is guaranteed to have executed.

The project's answer: fail closed, by construction

Rather than trying to find a safe position, RegionTransitionClassifier enumerates which placements are legal for each transition kind — and for exception dispatch, the answer is none:

Analysis/RegionTransition.cs
private const RegionCopyPlacement NormalPlacements = RegionCopyPlacement.SourceExit
    | RegionCopyPlacement.TargetEntry | RegionCopyPlacement.SplitBlock;

// exception dispatch:
return new RegionTransition(RegionTransitionKind.ExceptionDispatch,
    source.RegionPath, target.RegionPath,
    RegionCopyPlacement.None,           // ← no legal placement exists
    null, RequiresFinallyUnwind: false,
    "CLI exception dispatch; emitted edge copies are forbidden");

That is sound because the values a handler needs are in locals and arguments, which survive the transfer (§16). The classifier labels these ImplicitVariableState: the state is already where it needs to be, so no copy is required or permitted.

For a normal edge that unwinds a finally, the transition is flagged RequiresFinallyUnwind with the reason "copy timing depends on finally unwind", and the copy is deferred — not attempted, not approximated:

Analysis/RegionTransition.cs
bool finallyUnwind = edge.Kind == ControlFlowEdgeKind.Leave
    && removed.Any(frame => frame.Zone == RegionZone.Try
        && frame.ClauseKind == ExceptionClauseKind.Finally);

Placement for the copies that are legal

Three placements exist, and each has a precondition:

PlacementUsed when
SourceExitthe source has exactly one relevant successor, so the copy cannot execute on a path that does not take this edge
TargetEntrythe target has exactly one relevant predecessor, so the copy cannot be reached from a different edge
SplitBlocka critical edge — both degrees > 1. A synthetic block is created solely to hold the copy

repo The project states this rule explicitly: source-exit and target-entry placement are used "only when the corresponding degree proves it unambiguous"; every other copy goes through a split block. SsaEdgeCopyVerifier then independently reconstructs copy coverage, exact types and placement from the SSA graph and compares — a second implementation checking the first, not an assertion by the first.

The strict-tier gate

For methods with exception handlers, the default route accepts only two possibilities:

EhSsaValidationSelection.RuntimeProven
accept if:
    no evaluation-stack phi edge copies at all
  OR
    every stack-phi copy is on a normal, NON-CRITICAL edge
      whose source and target have IDENTICAL RegionPath
      and which does not unwind a finally

anything else → fall back to the stable semantic emitter, and report it

private-corpus The reported result of that gate: four exact Int32 phi copies in one sample1 method, all on non-critical normal edges with identical source/target RegionPath — two inside the same catch try-path and two entirely outside EH. That method's serialized candidate was verified as PEVerify-neutral, and a reflection probe proved identical values for all 25 static fields it writes, before the shape was admitted to production.

⚠️
Note what this means in practice: the project ships a strictly less aggressive phi lowering for EH methods than for non-EH ones, permanently, because the general case is not proven. That is the fail-closed philosophy applied to its own roadmap — a deferred case stays deferred rather than being shipped behind a heuristic.

25 Managed Pointers and Function Pointers

Some values cannot be treated as ordinary values, because their type is not the whole story — their provenance matters to the verifier.

Managed pointers

Agile represents "the address of a slot" with a by-ref wrapper object constructed as new Wrapper(ctx.getLocals(), index). The interpreter recognises this structurally — a newobj whose first argument is a SlotArray — and produces a SlotAddr, which materialises as ldloca/ldarga:

Lift/ExecuteInterpreter.Calls.cs — EvalNewobj
// (VM slots array, index) → the "address of a VM slot" wrapper (structural: no name needed).
if (pc == 2 && args[0] is SymValue.SlotArray sa && TryInt(args[1], out int idx))
{
    _eval.Push(new SymValue.SlotAddr(sa.IsArgs, idx));
    return;
}

// The SAME by-ref wrapper also supports constructing "address of an arbitrary array element" —
// detected structurally by the ctor's OWN declared parameter type (System.Array), not by the
// argument's runtime shape, since the array/index here are genuine runtime values.
if (pc == 2 && ctor?.Signature is { ParameterTypes.Count: 2 } sig
    && sig.ParameterTypes[0].IsTypeOf("System", "Array"))
{
    _eval.Push(new SymValue.ArrayElemAddr(args[0], args[1]));
    return;
}

The distinction in that second case is careful: the same wrapper type serves two roles, and they are told apart by the constructor's declared parameter type rather than by what happens to be on the stack.

SymValue.OnStack carries a ManagedPointer flag so that a by-ref value that has travelled through the VM's boxed stack is still recognised as an address. That flag drives two decisions: it prevents an address from triggering ordinary boxing, and it enables constrained. dispatch (§20).

And when a by-ref value is stored into an ordinary VM local, the missing dereference is reconstructed — because assigning the wrapper to a slot reads the pointed-to element; it does not make the local a by-ref local:

Lift/ExecuteInterpreter.ManagedPointers.cs
if (value is not SymValue.OnStack { ManagedPointer: true } pointer
    || localType is ByReferenceTypeSignature)
    return value;

if (pointer.KnownType is not { } pointedType)
    throw new LiftUnsupported("managed-pointer local store has an unknown pointed-to type");

Emit(CilOpCodes.Ldobj, pointedType.ToTypeDefOrRef());
return new SymValue.OnStack(pointedType);

Unknown pointed-to type ⇒ reject. There is no "assume object" branch.

Function pointers, and why a local is not safe

Consider the canonical delegate creation:

delegate construction
ldnull                              ; or a receiver
ldftn      void SomeClass::Callback(object)
newobj     instance void SomeDelegate::.ctor(object, native int)

The CLR verifier treats this as a single recognised idiom. The native int that ldftn produces is not an ordinary IntPtr: it is verifiable only when it flows directly from ldftn into a delegate constructor. Spilling it changes that:

what a naive SSA lowering would do
ldftn    void SomeClass::Callback(object)
stloc    V_7                        ; ← V_7 : native int
...
ldnull
ldloc    V_7                        ; the verifier now sees an arbitrary native int
newobj   instance void SomeDelegate::.ctor(object, native int)

This is exactly what the EH SSA shadow emitter's "one fresh local per value" strategy would produce, and it is why EhFunctionPointerShadowModelBuilder exists: such values are modelled as ephemeral stack values that must be rematerialised at their consumer, not as locals.

The gate is deliberately narrow. All of these must hold:

Analysis/EhFunctionPointerShadowModel.cs
if (definition.Operation.Semantics.Dispatch != SemanticDispatchKind.Direct)
    return Invalid($"I{definition.Id} is not a direct ldftn");            // ldvirtftn → fail closed
if (definition.Inputs.Count != 0 || definition.Outputs.Count != 1)
    return Invalid($"I{definition.Id} does not have the direct ldftn stack shape");
...
if (uses.Length != 1 || uses[0].Kind != SsaUseKind.InstructionInput || ...)
    return Invalid($"function pointer %{valueId} does not have one instruction use");
if (consumer.BlockId != definition.BlockId
    || consumer.Ordinal != definition.Ordinal + 1)
    return Invalid($"function pointer %{valueId} crosses an instruction or block boundary");
...
if (consumer.Operation.Code != SemanticOperationCode.NewObject
    || ... || !IsNativeInt(signature.ParameterTypes[inputIndex].FullName))
    return Invalid($"function pointer %{valueId} is not consumed by a native-int constructor parameter");

Direct ldftn only; no receiver; exactly one live use; the consumer is the immediately adjacent instruction in the same block; and that consumer is a newobj taking the pointer in a native int parameter position. ldvirtftn, multi-use, phi-carried and any cross-instruction or cross-block form all fail closed.

💡
repo How this shape earned production status is instructive. It affects four values across two sample1 methods. A dedicated offline fixture was built that exercises all four with no external traffic: two delegate-typed UI event subscriptions, one started background worker thread, and a native HTTP-client write callback validated end-to-end against a local loopback server that returns a locally generated encrypted token. The baseline and rematerialized bodies both decode the token and exit successfully. Only then was the shape enabled. The permanent gate runs FunctionPointerProbe <artifact> auto auto — both methods are discovered from their signatures and ldftn shapes, not from names or tokens.

Address-taken locals are excluded from coalescing outright

The same principle appears in the CIL cleanup tiers. A local whose address is ever taken has an unknown true live extent — anything could write through the pointer — so CilLocalInterferenceGraph does not reason about it:

Emit/CilLocalInterferenceGraph.cs
if (instruction.OpCode.Code is not (CilCode.Ldloc or CilCode.Ldloc_S
    or CilCode.Stloc or CilCode.Stloc_S))
    disqualified.Add(local);

Any reference that is not a plain load or store — ldloca above all — disqualifies the local entirely. Excluded rather than analysed.


26 Local Coalescing and Copy Propagation

SSA-derived CIL is correct and unreadable. Each SSA value wants its own storage, so a direct lowering produces chains like:

the problem
V_17 = <expression>
V_21 = V_17
V_29 = V_21
... use V_29

Every one of those becomes a distinct C# variable in a decompiler. The project attacks this with five independently verified tiers, each of which is emitted detached, re-validated, and rejected in favour of the previous body if it does not pass.

BASEEH SSA shadow body
one fresh typed local per stack-phi / spill valuecorrect, verbose
T1Single-block copy propagation
exact-type aliases only inside ONE CIL basic block definitions never move branch targets and EH boundaries split the proof domain
T2Expression scheduling
adjacent stloc; ldloc pairs local has exactly one def and one use neither instruction is a branch target or EH boundary no instruction or throwing effect moves
T3Cross-block copy propagation
every load in the identical EH region nesting instruction-level clean-vs-tainted reachability one tainted path ⇒ reject
T4Interference-based coalescing
exact backward liveness at instruction granularity non-overlapping live ranges share a slot no copy instruction needs to connect them
T5Redundant self-store removal
ldloc X; castclass X's-own-type; stloc X unconditionally safe — needs no control-flow reasoning

T3 — the reachability proof

Propagating a copy across blocks requires proving that no path from the definition to a load can redefine the source first. CrossBlockPropagationLegality does this with an instruction-level search that tracks, for every position, whether it is reached cleanly or only through a redefinition. A load reachable through even one tainted path is rejected. It also requires every load to sit in the identical exception-region nesting — no leave/endfinally/endfilter continuation is modelled, and a mismatch simply rejects that candidate.

T4 — and the two real bugs it produced

Interference-based coalescing is the tier that actually attacks the shadow emitter's "one fresh local per value" shape by construction. It is also where the project found its two most instructive defects.

The flow graph is widened with two kinds of implicit edge that CIL never encodes as a branch:

Emit/CilLocalInterferenceGraph.cs
// every instruction inside a try region → its handler's entry
//   (a value the handler could read stays live across the whole guarded region)
// a `leave` that exits a finally/fault region → that region's handler entry,
//   and `endfinally` → the next exited handler, or the leave's real target
⚠️
repo Bug 1: a value staged before a leave that implicitly runs an enclosing finally was merged with a finally-local, corrupting a caught-and-rethrown exception's return value — because the leave/finally chain was not modelled yet. Fixed by adding the chain edges above.

Bug 2: even with that fix, a nested try/finally-inside-try/catch shape still corrupted a decoded response string. Rather than chase nested-region interactions one at a time, merging was restricted to locals whose entire live range stays inside one exception-region membership signature (ConfinedEligibleLocals). A local needing cross-region reasoning is excluded outright rather than trusted to a necessarily incomplete edge model. Cost: about 3% of the win (554→538 locals removed on sample1). Both bugs have permanent regression tests.
Emit/CilLocalInterferenceGraph.cs — the confinement rule
void Check(CilLocalVariable local, int position)
{
    if (crossRegion.Contains(local)) return;
    if (!reference.TryGetValue(local, out var signature))
        reference[local] = membership[position];
    else if (!signature.SetEquals(membership[position]))
        crossRegion.Add(local);          // live in two different region memberships ⇒ excluded
}

T5 — and the lesson about measuring the right thing

private-corpus T4's raw numbers looked excellent: one sample1 method fell from 155 CIL locals to 17, another from 143 to 16. But decompiling before and after showed ILSpy emitting 49 literal x = x; self-assignments that had not existed before — roughly cancelling the line-count improvement (189 → 193 lines).

Root cause: coalescing unified a slot that used to be two locals of two related types, one narrowed into the other by a castclass that is now a same-type no-op. The existing self-store remover only matched a literally adjacent ldloc X; stloc X. Teaching it to tolerate exactly that intervening same-type cast — unconditionally safe, since a cast can never throw against its own declared type, including for null — deleted all three instructions.

result on one methodprivate-corpus
                       before T5    after T5
C# self-assignments        49           0
C# lines                  193         144
CIL casts                  55           6
decompiler aliases         63          14

The general lesson is in §35: CIL-level metrics and decompiler-visible quality are not the same objective, and optimising the first can silently degrade the second.

How a tier is selected

Not by instruction count. CilStructuralQualityGate scores what actually survives into C#:

Analysis/CilStructuralQualityGate.cs
// These weights measure decompiler scaffolding, not runtime cost. Locals, explicit casts and
// copy aliases usually survive into C# and are therefore more expensive than a single CIL op.
public int Cost => checked(Instructions + Locals * 3 + Casts * 4
    + Aliases * 5 + BasicBlocks * 2 + Spills * 4);

int growthAllowance = Math.Max(4, (before.Instructions + 19) / 20);
if (after.Instructions > before.Instructions + growthAllowance)
    return new CilStructuralQualityDecision(false, before, after,
        $"instruction growth exceeds +{growthAllowance}");
if (after.Cost >= before.Cost)
    return new CilStructuralQualityDecision(false, before, after,
        after.Cost == before.Cost ? "equal structural cost" : "higher structural cost");

A candidate may be a few instructions larger if it removes enough scaffolding, but growth is hard-capped at max(4, ceil(baseline/20)) regardless of score. private-corpus Three sample1 selections and one advanced selection actually use that bounded-growth path, each trading one extra CIL instruction for two fewer locals.


27 Redundant Cast Cleanup

Reconstructed CIL is cast-heavy for a structural reason. Reflection returns System.Object; VM locals are declared System.Object (§07); receiver narrowing inserts a castclass at every instance call site (§10). Most of those casts are provably no-ops, but "provably" is doing real work in that sentence — removing a castclass that can fail suppresses an InvalidCastException and changes behaviour.

Four dispositions

Analysis/CilRedundantCastAnalyzer.cs
internal enum CilConversionDisposition
{
    ProvenRedundant,          // removable
    RepresentationChanging,   // box, width/signedness/precision change — never removable
    RuntimeChecked,           // unbox.any, overflow conv, an unproven castclass — never removable
    Unknown,                  // source provenance unknown — never removable
}

Only ProvenRedundant is ever removed, and only when it is not a branch or EH boundary.

The assignability proof

Analysis/CilRedundantCastAnalyzer.cs
private static bool IsAssignable(TypeSignature source, TypeSignature target, bool boxed)
{
    if (SameType(source, target)) return true;                         // exact identity
    if (target.IsTypeOf("System", "Object")) return true;              // CLI universal
    if (boxed && target.IsTypeOf("System", "ValueType")) return true;   // CLI universal
    if (source is SzArrayTypeSignature or ArrayTypeSignature
        && target.IsTypeOf("System", "Array")) return true;             // CLI universal
    if (IsAssignableThroughHierarchy(source, target)) return true;      // real CLR hierarchy
    return false;
}

Identity comparison uses SignatureComparer, which compares the encoded signature and its resolution scope — so two types with the same namespace/name from different assemblies are correctly treated as different.

The hierarchy case defers to AsmResolver's own IsAssignableTo — the same base-type/interface walk CilTypeSafetyValidator already trusts for verification — wrapped in the same fail-closed try/catch used everywhere else for defensive resolution:

Analysis/CilRedundantCastAnalyzer.cs
private static bool IsAssignableThroughHierarchy(TypeSignature source, TypeSignature target)
{
    try
    {
        var context = source.ContextModule?.RuntimeContext ?? target.ContextModule?.RuntimeContext;
        return source.IsAssignableTo(target, context);
    }
    catch { return false; }        // unresolvable ⇒ keep the cast
}

What is and is not proven

CastVerdictReason
LabelControlremovedreal base class in the resolvable hierarchy
ButtonISupportInitializeremovedonly if the interface list resolves
FooBar (unrelated siblings)keptremoving would suppress InvalidCastException
null → anythingremoveda cast of null never throws
anything → objectremovedCLI universal
boxkeptchanges representation and may allocate
unbox.anykeptperforms null/type checks
conv.i4 on a known Int32removedexact non-overflow identity
conv.ovf.*keptcan throw OverflowException
any cast at a branch/EH boundarykeptthe analyzer's proof domain is per basic block
source provenance unknownkeptUnknown is never removable

Uncertainty is always resolved toward keeping the runtime check.

One known gap is documented rather than papered over: repo IsAssignableTo on the small set of CorLibTypeFactory shortcut types (String, Int32, …) does not fully resolve their own interface list, so a cast from one of those to an interface they implement stays unproven and is kept. It fails closed; ordinary class types are unaffected.

How the hierarchy case was found

repo Hierarchy resolution was originally deliberately excluded — the analyzer proved redundancy only by exact identity plus the CLI-universal cases. The gap surfaced through a colleague's manual dnSpy inspection of a fully virtualized, VM-reconstructed InitializeComponent: every inherited Control/ISupportInitialize member access on a derived-typed field carried a redundant upcast. On that one method, CIL casts fell 283 → 1 and C# casts 217 → 9. Corpus-wide, selection went 65 → 71 methods and removed conversions 1,662 → 1,911, with the "residual alias/cast canonicalization" debt bucket falling 39,758 → 23,830 (40%).

Three adversarial unit tests (RedundantCastHierarchyTests) prove a base-class upcast, an interface upcast, and — crucially — that an unrelated-sibling cast must stay RuntimeChecked, independently of the corpus.

ℹ️
The pass is opt-in. --cast-shadow classifies everything and reports, but installs the ordinary body — its serialized output is byte-identical to a run without cast analysis. --cast-cleanup installs the candidate only when label, max-stack, type-safety and structural-quality validation all pass. It deletes in place and iterates to a fixed point; it never reorders effects.

28 Fail-Closed Design

This is the project's organising principle, and it is worth stating on its own because it explains almost every design decision above.

the rule
if semantics are PROVEN:
        emit the reconstructed method

if semantics are UNCERTAIN:
        keep the original VM-backed method
        report the unsupported case, with a reason

never:  guess
never:  silently partially devirtualize

The rule is enforced at five independent levels.

1 — Lift

LiftUnsupported exists for exactly this. Its doc comment is the policy in one sentence:

Lift/LiftUnsupported.cs
/// Raised when the lifter meets a construct it does not yet model. We fail the whole VM instruction
/// loudly rather than emit a guess — a wrong instruction is far worse than a reported gap.
internal sealed class LiftUnsupported(string message) : Exception(message);

One unlifted instruction fails the whole method. Devirtualizer.Run breaks out of the loop on the first gap and records it:

Emit/Devirtualizer.cs
if (gap is not null)
{
    diagnostics?.WriteStatus("Unsupported: " + gap);
    failures.Add($"0x{vm.Token:X8} {target.Name}: not fully lifted ({gap})");
    continue;                            // method stays VM-backed
}

2 — Policy

CfgEmissionPolicy refuses a method with an invalid CFG, a non-convergent worklist, a conflicting entry stack shape, or any operation or terminator with no independent CIL lowering — before anything is built.

3 — Emission

SemanticEmissionController builds detached, asserts the target body was not touched during construction, installs, and restores on any failure:

Analysis/SemanticEmissionController.cs
if (!ReferenceEquals(target.CilMethodBody, originalBody))
    throw new InvalidOperationException("detached semantic emitter changed the installed body");
...
CfgEmissionDecision Failure(CfgControlFlowFeatures features, string reason)
{
    if (!ReferenceEquals(target.CilMethodBody, originalBody))
        target.CilMethodBody = originalBody;
    return new CfgEmissionDecision(CfgEmissionOutcome.SemanticFailure, features, reason);
}

And there is no silent fallback between routes. A semantic failure is reported as semantic-failed; the legacy builder is reachable only through an explicit --legacy-emission.

4 — Optimisation tiers

Each tier is emitted detached and validated. Rejection keeps the previously verified body, and the reason is recorded as an OptimizationAttempt. This is visible in real output — here is the actual decision line for one fixture method measured:

--show-cfg-decisions
0x06000007 SwitchWithFinally: Activated; features=ExceptionRegions, Leave, Switch, BackEdge, MergePoint;
  strict EH SSA installed; blocks=11; copies=0; spills=2;
  eh-locals=baseline (candidate rejected (StackImbalanceException: ...));
  eh-expressions=baseline (candidate rejected (StackImbalanceException: ...));
  eh-cross-block=coalesced; cross-block-instructions=0; cross-block-locals=2;
  eh-interference=coalesced; interference-locals=1

Two tiers rejected their candidates and kept the baseline; two succeeded. The method still ships, with the best verified body — and the rejections are printed rather than hidden.

5 — Writer

The VM resource is removed only when every method was rebuilt:

Emit/RuntimeDependencyCleanup.cs
if (result.Total == 0 || result.Devirtualized != result.Total)
    return false;                    // a partial output still needs its VM runtime
module.Resources.Remove(resource);
return true;

Standalone-ness is then verified against the serialized file, not an optimistic in-memory scan — the output is re-read from disk and its assembly references are checked. And a zero-success run copies the input byte-for-byte rather than re-serializing, because failed build attempts can leave importers having staged metadata even after a body rollback:

Emit/Devirtualizer.cs
/// Failed build attempts can still have caused importers to stage metadata members before the
/// method body was rolled back, so serializing the in-memory module is not a semantic no-op.
/// A byte-for-byte copy is the only honest output for a zero-success run.

Why this matters for RE tooling specifically

A devirtualizer that guesses produces output that looks right. The reader has no way to tell a correctly recovered branch from a plausible invented one, and the error surfaces as a subtly wrong conclusion much later. A devirtualizer that fails loudly produces an output where the recovered parts are trustworthy and the unrecovered parts are labelled — which is strictly more useful even though it is less impressive.

The same principle explains the paranoid bits elsewhere: the exact-blob-consumption invariants, the independent verifiers behind every analysis (SsaVerifier, SccpVerifier, SsaDeadCodeVerifier, ControlFlowSimplificationVerifier, OptimizedGraphVerifier, SsaEdgeCopyVerifier, SsaPhiLoweringVerifier), and the top-level catch that reports unexpected errors by message only — because a default .NET stack trace embeds the build machine's absolute paths, which is unacceptable in a publicly distributed CLI tool.


29 Validation

VALIDATION.md opens by separating three things that are commonly conflated:

VALIDATION.md
1. Lifted             — every VM instruction was translated to a candidate CIL sequence.
2. Builder-accepted   — AsmResolver could compute max stack and write the candidate body.
3. Semantically valid — the written assembly passes an external CLR verifier and produces the
                        same runtime result as known-good code.

Only level 3 is a correctness claim. In particular, `Devirtualized 101/101` reports level 2; it
does not by itself prove that all rebuilt bodies are semantically correct after metadata is
rewritten.

That distinction is unusual and worth borrowing. "101/101" is the number a tool wants to print; it is an acceptance count, not a proof.

Structural validation

CheckEnforced by
Complete operand consumption (per method and per blob)MethodDecoder, LocalsDecoder, EhDecoder
Every _CSVM token resolves to a MethodDefinitionVMResource.TryParse
Block coverage, edge endpoints, edge/EH consistency, out-degreeControlFlowGraphValidator
Worklist convergence and consistent entry stack shapesWorklistAnalyzer + CfgEmissionPolicy
SSA dominance, single definition, phi arity, stack agreementSsaVerifier
Stack heightCilMethodBody.ComputeMaxStack
Managed-pointer type safety beyond heightCilTypeSafetyValidator
Every EH entry/continuation contractExceptionEntryModelVerifier, ExceptionContinuationModelVerifier
Phi-copy region legalityRegionAwarePhiCopyLegalityVerifier

External verification

The .NET Framework SDK's PEVerify.exe is run on source, protected and rewritten artifacts. I re-ran that gate on the public fixture while writing this measured:

PEVerify /IL /MD
--- protected TestCases.exe ---
[IL]: Error: [... : TestCases.TestPatterns::BuildDictionary][offset 0x00000036]
      [found ref 'System.Object'][expected ref 'System.Collections.Generic.Dictionary`2[...]']
      Unexpected type on the stack.
1 Error(s) Verifying TestCases.exe

--- devirtualized TestCases-devirt.exe ---
All Classes and Methods in TestCases-devirt.exe Verified.

The protected input has a verifier error; the devirtualized output has none. That is the cleanest possible statement of what the tool produces.

Semantic validation against a known-source oracle

TestCasesInvoker loads a selected TestCases.exe through reflection, so the identical executable test matrix can target the source, the protected, and the devirtualized assemblies without changing or re-protecting the fixture. The advanced matrix is 46 vectors covering true/false/equal/reversed comparisons, negatives, zero markers, NaN, infinity, null and reference branches, zero and negative divisors, byte limits, exact remainder, unchecked Int32 overflow, dense switches, caught exceptions, early returns, observable finally side effects, loop back-edges, continue/break through finally, divergent merge states, filtered exceptions, nested catch/rethrow, and escaping throws.

Running the fixture directly measured:

protected vs devirtualized, same inputs
--- protected ---                        --- devirtualized ---
Test1 (array length index): True         Test1 (array length index): True
dict count: 2                            dict count: 2
  first = helloworld                       first = helloworld
  second = world                           second = world
Test3 (guid roundtrip): True             Test3 (guid roundtrip): True
Test4 (numeric comparisons): True        Test4 (numeric comparisons): True
Test5 (reference nulls): True            Test5 (reference nulls): True
Test6 (i4 arithmetic): 58                Test6 (i4 arithmetic): 58
💡
repo The oracle is honest about a case where the protector is wrong. Agile.NET 6.6 has one isolated protection-time semantic drift: CompareNumericPaths(..., NaN, ...) returns True from the protected assembly, while both the original CLR code and the recovered native CIL return False — because ordered comparisons with NaN are false. The gate records this explicitly and fails if the protected assembly differs from the source on any other vector. Which means: on that vector, the devirtualized output is more correct than the input.

Corpus results

CorpusDecodedLiftedDevirtualizedStandalonePEVerifyRuntime oracle
TestCases advanced (11 methods) measured 11/1184/84 instr11/11 yes0 errors46/46 repo
TestCases control-flow (8) repo 8/853/538/8 yes1→0 errors29/29
FaultCases (1, real fault clause) repo 1/16/61/1 yescleanboth paths
sample1 (101) private 101/1011388/1388101/101 yes183→9safe matrix
sample2 (1) private 1/112/121/1 yescleanpassword path

sample1's PEVerify counts are diagnostics, not a score: its protected input is already heavily unverifiable. Token-level grouping attributes all nine remaining diagnostics to five methods that were never virtualized — none belongs to the 101 rebuilt bodies.

The fault fixture — a validation story worth telling

repo C# cannot emit a CLR fault clause, so the project hand-writes one in ILAsm. Agile.NET reports a successful build for it, but the protected DLL is rejected by both the CLR loader (E_INVALIDARG) and PEVerify — because its native-resource directory is truncated. It is still valid VM transformation input. The devirtualizer lifts and emits the method 1/1, and the writer removes only that provably-invalid directory (NativeResourceSanitizer), preserving valid icons, manifests and version resources. The standalone result is PEVerify-clean and matches the source oracle exactly: normal completion returns 10 without executing the fault handler; exceptional completion preserves InvalidOperationException, message fault-case, and the observable fault state 77.

What should not be generalised

  • The corpus contains two product versions (6.6.0.35, 6.6.0.42) and two structural handler families. That is evidence for those, and nothing else. Broader version support remains open until real protected/runtime pairs are added.
  • sample1 and sample2 are not in the public repository, and neither are the protected fixture binaries (bin/ is gitignored). What ships is the fixture source, the Agile.NET .cls project files, and the scripts — so the corpus is reproducible by anyone with a licensed copy of the protector, but not downloadable.
  • sample1's runtime validation is a safe, non-destructive matrix: UI construction and control wiring, application state transitions (including an idle/disconnected state and a synthetic fixture standing in for live external state), window-close cleanup, and background-thread teardown — plus a separate, user-confirmed pass against the real external system the application manages. Any state-changing or external-service path is deliberately kept outside automation.

30 Full End-to-End Example

One method, all the way through. Every value below is real, taken from the repository's advanced control-flow fixture measured.

① Original C#

TestCases/Program.cs
public static int ComputeI4Arithmetic(byte value, int divisor)
{
    if (divisor == 0)
    {
        return -1;
    }

    int remainder = value % divisor;
    int mixed = (value + divisor) * 2;
    return mixed - remainder;
}

② The virtualized stub

protected TestCases.exeILSpy
[MethodImpl(MethodImplOptions.NoInlining)]
public static int ComputeI4Arithmetic(byte value, int divisor)
{
    return (int)CSVMRuntime.RunMethod("ca6cbf7e-8a4c-4377-b62e-24a1f9bd41df",
                                      new object[2] { value, divisor });
}

③ The _CSVM record — 98 bytes at file offset 0x1BC8

raw bytes
7E BF 6C CA 4C 8A 77 43 B6 2E 24 A1 F9 BD 41 DF    Guid   ca6cbf7e-8a4c-4377-b62e-24a1f9bd41df
06 00 00 06                                        token  0x06000006
10 00 00 00 03 00 00 00 08 00 00 00 1D 00 00 00 08 00 00 00     locals blob (16 B)
32 00 00 00 ...                                                  code blob   (50 B)
00 00 00 00                                                      EH blob     (0 B)

④ Locals

decode
03 00 00 00   count = 3
08 00 00 00   I4       → System.Int32
1D 00 00 00   SZARRAY  → System.Object     (no token payload — see §07)
08 00 00 00   I4       → System.Int32

tool:  locals (3): Int32, Object, Int32     ✓ blob consumed exactly (16/16)

⑤ Raw VM opcode stream

code blob, first 10 bytes
03 00 00 00     instructionCount = 3
15 00           opcode[0] = 0x0015 = 21
16 00           opcode[1] = 0x0016 = 22
17 00           opcode[2] = 0x0017 = 23
                remaining 40 bytes = the operand blob

⑥ Opcode → handler

from the registry's ldtoken order
21 → lXU=.J3c=      read K3c=     exec Knc=
22 → lXU=.LHc=      read MHc=     exec L3c=
23 → lXU=.MXc=      read N3c=     exec Nnc=

⑦ Operands, by interpreting each handler's read method

40 operand bytes, split by interpretation
blob[10..15]  01 00 02 00 00 00                     6 B  → op21
                 u16 KHc=[0] = 1
                 i32 KXc=[0] = 2

blob[16..24]  08 FF FF FF FF 06 00 00 06            9 B  → op22
                 u8  tag     = 0x08 (I4)  ⇒ read an Int32
                 i32 LXc=[0] = -1
                 u32 Lnc=[0] = 0x06000006

blob[25..49]  00 00 01 00 00 00 08 00 00 00
              00 00 01 00 08 02 00 00 00
              00 00 06 00 00 06                    25 B  → op23
                 u16 Mnc=[0..2] = 0, 1, 0
                 i32 M3c=[0]    = 8
                 u16 Mnc=[3..4] = 0, 1
                 u8  tag = 0x08 ⇒ i32 NHc=[0] = 2
                 u16 Mnc=[5]    = 0
                 u32 NXc=[0]    = 0x06000006

6 + 9 + 25 = 40   ✓ operand blob consumed exactly (50/50 including the 10-byte header)

⑧ Handler semantics — symbolic interpretation of each execute method

$ ... --lift ComputeI4Arithmetic
[000] op21(KHc==[1], KXc==[2])
     => ldarg 1; ldc.i4.0; ceq; ldc.i4 0; ceq; brtrue →#2
[001] op22(LXc==[-1], Lnc==[100663302])
     => ldc.i4 -1; ret
[002] op23(Mnc==[0,1,0,0,1,0], M3c==[8], NHc==[2], NXc==[100663302])
     => ldarg 0; ldarg 1; rem; stloc 0; ldarg 0; ldarg 1; add; ldc.i4 2; mul; ldloc 0; sub; ret

⑨ Semantic IR

09-semantic-ir.txtabridged
B2  VM#0002 LoadArgument { Encoding = Inline }   operand=SemanticArgumentReference { Index = 0 }
    VM#0002 LoadArgument { Encoding = Inline }   operand=SemanticArgumentReference { Index = 1 }
    VM#0002 Remainder    { Signedness = Signed } operand=none
    VM#0002 StoreLocal   { Encoding = Inline }   operand=SemanticLocalReference { Index = 0 }
    ...
    terminator Return

⑩ CFG

03-blocks.txt / 10-cfg-validation.txt
B0: VM [0..0]  regionPath=outside   TERM Conditional: brtrue →#2
B1: VM [1..1]  regionPath=outside   TERM Return
B2: VM [2..2]  regionPath=outside   TERM Return

Edges:  B0 -> B2  ConditionalTaken
        B0 -> B1  ConditionalFallThrough

Instructions: 3   Blocks: 3   Edges: 2   Exception regions: 0
VALID: all formal CFG invariants passed

⑪ SSA

13-ssa.txt
Valid: True   Reachable blocks: 3   Values: 19   Phi nodes: 6   Uses: 28

B2:  %6 = phi v0 (B0:%0)   %7 = phi a0 (B0:%1)   %8 = phi a1 (B0:%2)
     %14 = Remainder(%7,%8)      StoreLocal(%14)
     %15 = Add(%7,%8)            %16 = LoadConstant()
     %17 = Multiply(%15,%16)     %18 = Subtract(%17,%14)
     TERM Return(%18)

SCCP finds no constant guard here (both branches depend on a runtime argument) and DCE finds nothing dead, so the optimised route produces the same body as the lossless one. The emission decision recorded for this method is Activated; features=StraightLine; independently validated and installed.

⑫ Reconstructed CIL

07-emitted-il.txt
IL_0000: ldarg   System.Int32 divisor
IL_0004: ldc.i4.0
IL_0005: ceq
IL_0007: ldc.i4  0
IL_000C: ceq
IL_000E: brtrue  IL_0019
IL_0013: ldc.i4  -1
IL_0018: ret
IL_0019: ldarg   System.Byte value
IL_001D: ldarg   System.Int32 divisor
IL_0021: rem
IL_0022: stloc   V_0
IL_0026: ldarg   System.Byte value
IL_002A: ldarg   System.Int32 divisor
IL_002E: add
IL_002F: ldc.i4  2
IL_0034: mul
IL_0035: ldloc   V_0
IL_0039: sub
IL_003A: ret

Exception handlers: (none)

⑬ Decompiled output

devirtualized TestCases-devirt.exeILSpy
[MethodImpl(MethodImplOptions.NoInlining)]
public static int ComputeI4Arithmetic(byte value, int divisor)
{
    if (divisor == 0)
    {
        return -1;
    }
    int num = value % divisor;
    return (value + divisor) * 2 - num;
}

Compare with ①. The only differences are a variable name and the fact that mixed was never a separate slot in the CIL, so the decompiler inlines it — both are faithful renderings of the same bytecode. The [MethodImpl(NoInlining)] attribute is the protector's, and is preserved because attributes were never virtualized in the first place.

The whole run

$ dotnet run --project AgileDevirtualizer -- TestCases.exe AgileDotNet.VMRuntime.dll out/TestCases-devirt.exe --show-cfg-decisions
[*] Loading VM runtime: .../AgileDotNet.VMRuntime.dll
    handler base : lXU=.3XU=
    context type : lXU=.AHY=
    opcodes      : 66  (read 66, exec 66)
[*] Loading input assembly: .../TestCases.exe
[*] _CSVM resource '_CSVM': 11 virtualized method(s).
[*] Devirtualized 11/11 method(s).
[*] CFG emission: candidates=11, activated=11, optimized=4, semantic-failed=0, not-selected=0.
    ExceptionRegions   4
    Leave              4
    Switch             2
    BackEdge           3
    MergePoint         6
    StraightLine       5
      · 0x06000001 CheckArrayLengthIndex: Activated; features=StraightLine; ...
      · 0x06000006 ComputeI4Arithmetic:   Activated; features=StraightLine; ...
      · 0x06000007 SwitchWithFinally:     Activated; features=ExceptionRegions, Leave, Switch,
                                          BackEdge, MergePoint; strict EH SSA installed; ...
      · 0x0600000D RethrowWithoutFilter:  Activated; features=ExceptionRegions, Leave,
                                          MergePoint; strict EH SSA installed; ...
[*] Writing: out/TestCases-devirt.exe
[*] Standalone output: no VM runtime reference remains.
[*] Done.

The output assembly has no reference to AgileDotNet.VMRuntime, the _CSVM resource is gone, it is PEVerify-clean, and it produces identical results to the original source across the full oracle matrix.


31 Running the Devirtualizer

Requires the .NET 8 SDK.

build and run
dotnet build AgileDevirtualizer/AgileDevirtualizer.csproj -c Release
dotnet run --project AgileDevirtualizer -- <protected.exe> <VMRuntime.dll> [output.exe] [flags]

The usage string the program itself prints:

Program.cscurrent
AgileDevirtualizer <input-assembly> <VMRuntime.dll> [output]
    [--cfg-emission | --legacy-emission]
    [--optimize | --no-optimize] [--typed-ssa] [--ssa-phi]
    [--cast-shadow | --cast-cleanup]
    [--ssa-edge-shadow] [--eh-ssa | --no-eh-ssa]
    [--eh-ssa-validation-artifact | --eh-ssa-copy-validation-artifact]
    [--quality-report path --ilspy-directory path --quality-reference-directory path]
    [--exclude token[,token...]] [--dump [token|index]]

Modes

Without a third positional argument the tool runs in inspection mode: it loads the runtime, prints the handler base, context type and opcode count, finds _CSVM, and decodes every virtualized method, reporting the blob-consumption invariant. With an output path it devirtualizes and writes.

Emission flags

FlagEffect
(none)Optimized semantic CIL: dispatcher-loop elimination, constant-branch folding, strict EH SSA. The default.
--cfg-emissionExplicit equivalent of the default.
--no-optimizeLossless semantic route — the rollback path.
--legacy-emissionThe straightforward CilBuilder lowering, kept as a test-only oracle to diff against.
--typed-ssaAdds typed straight-line SSA lowering; installs only strictly smaller bodies.
--ssa-phiAdds multi-block SSA phi lowering (congruence-class slots / typed edge copies, critical edges split). Installs only when strictly better and independently verified.
--eh-ssa / --no-eh-ssaExplicit enable (same as default) / permanent rollback of the strict EH SSA tier.
--cast-shadowClassifies every conversion and reports, but installs the ordinary body — output byte-identical to a run without it.
--cast-cleanupRemoves only proven-redundant conversions, gated on full re-validation.
--exclude 0600001D,0x0600002AForces the named MethodDef tokens to stay VM-backed. A bisection safety valve; validated as real method tokens.

Inspection flags

FlagPrints
--vocabThe structurally identified VM vocabulary (§11).
--classifyComparison primitives recovered by probing (§09).
--helpersRuntime helpers grouped by BCL anchor (§09).
--dump [filter]Decoded VM instructions and operands per method.
--lift [filter]The same, plus the lifted CIL sequence for every instruction.
--show-cfg-decisionsPer-method activation decision, feature set and every optimisation attempt.
--show-failuresThe first 30 failure reasons in full.

The filter matches a method's full name, escaped display name, signature or token hex, case-insensitively — so --lift ComputeI4Arithmetic and --lift 06000006 are equivalent.

Environment variables

DEVIRT_DIAGNOSTICS_DIRper-method dump dir
DBG=1full stack traces
DBG_DEVIRT=<token>trace one method's rejection
DBG_CIL=<token>dump emitted CIL
TRACE_OP=<opcode>trace one handler's lift
TRACE_OPENBRANCH=1trace branch resolution

The diagnostics directory is the most useful of these. It writes nineteen files per method — 01-vm-instructions.txt through 19-eh-continuations.txt, including 04-cfg.dot for Graphviz — and every listing in §30 came from it.

Exit codes

0success
1usage
2no _CSVM found
3one or more methods failed to decode
4conflicting flags
5refused to overwrite the input runtime
99unexpected error (message only)
⚠️
The runtime DLL must be the one that shipped with the executable. A runtime from a different build has a different opcode map, different handler semantics and different operand layouts. It will not produce a warning — it will produce a decode failure, or, worse, a plausible wrong decode. There is no cross-build compatibility here by design: that is the same property that makes the approach generic.

Bring the full dependency set. Resolution failures against missing sibling DLLs change cross-module member widening, and the output then keeps a VM runtime reference instead of being standalone.

Write to a separate directory. The CLI refuses (exit 5) to overwrite the runtime path used as input, because a partial output may need a visibility-adjusted runtime beside it.

32 Architecture Summary

the whole system
          AgileDotNet.VMRuntime.dll                        protected.exe
                     │                                            │
                     ▼                                            ▼
              RuntimeModel.Load                             VMResource.Find
       ┌─────────────┼─────────────┐                    (self-validating parse)
       │             │             │                              │
  FindHandler   TryGetHandler  FindOverride                       ▼
   Registry        Slots       (MethodImpl)              VMMethod[] { guid, token,
       │             │             │                       locals, code, eh }
       └──────┬──────┴─────────────┘                              │
              ▼                                                   ▼
     HandlerInfo[] indexed by opcode  ────────────────►   MethodDecoder.Decode
              │                                            ├─ OperandDecoder   (runs read-method IL)
              ▼                                            ├─ LocalsDecoder
     RuntimeVocabulary  ·  RuntimeHelpers                   └─ EhDecoder
     ConditionClassifier                                            │
              │                                                     ▼
              └────────────────────►  ExecuteInterpreter  ◄─── DecodedMethod
                                   (symbolic execution of
                                    each execute-method IL)
                                             │
                                             ▼
                                    List<LiftedOp>[]  — one CIL sequence per VM instruction
                                             │
                                             ▼
                                  LegacySemanticIrAdapter
                                             │
                                             ▼
                                  ControlFlowGraphBuilder ──► ControlFlowGraphValidator
                                             │
                                             ▼
                                     WorklistAnalyzer  (AbstractState fixed point)
                                             │
                                             ▼
                                     CfgEmissionPolicy   ── not eligible ──► keep VM body
                                             │
                        ┌────────────────────┴────────────────────┐
                        ▼                                         ▼
              OptimizedSemanticEmitter                 EhSsaValidationEmissionController
        SsaGraphBuilder → SsaVerifier                  EhSsaShadowPlanner → EhSsaShadowEmitter
        SccpAnalyzer   → SccpVerifier                  T1 single-block copy propagation
        SsaDeadCodeAnalysis                            T2 expression scheduling
        ControlFlowSimplifier                          T3 cross-block copy propagation
        DispatcherEliminationPlanner                   T4 interference coalescing
        ConstantBranchEliminationPlanner               T5 self-store removal
        OptimizedGraphRewriter/Verifier                          │
                        │                                        │
                        └────────────────┬───────────────────────┘
                                         ▼
                            SemanticCfgEmitter / PrunedSemanticCfgEmitter
                                         │  (labels, locals, EH regions, layout)
                                         ▼
                              SemanticCilLowerer   ← the only attribute → opcode map
                                         │
                                         ▼
                       ComputeMaxStack + CilTypeSafetyValidator
                                         │
                       ┌─────────────────┴─────────────────┐
                    passes                              fails
                       ▼                                   ▼
             SemanticEmissionController            restore original VM body
                  installs the body                report "semantic-failed"
                       │
                       ▼
        all methods rewritten? ──yes──► RuntimeDependencyCleanup removes _CSVM
                       │                            │
                       ▼                            ▼
              partial: keep _CSVM        standalone reconstructed assembly
              and the runtime DLL        (verified against the file on disk)

Two things are worth reading off that diagram. The left column is discovery — everything derived from the runtime DLL, once per run. The right column is per method, and every arrow into "installs the body" passes through a validator with a rollback path.


33 What "Generic" Does and Does Not Mean

"Generic" is an overloaded word in RE tooling, so it is worth being exact.

what it does NOT mean
✗  every future Agile.NET build is guaranteed to work
✗  every method in every supported build is guaranteed to devirtualize
✗  the tool has been tested against every version of the protector
what it DOES mean
✓  no hardcoded opcode numbers          — recovered from the registry's ldtoken order
✓  no hardcoded handler names           — bound by MethodImpl / signature
✓  no hardcoded operand layouts         — derived by interpreting each read method
✓  no hardcoded handler semantics       — derived by interpreting each execute method
✓  no hardcoded comparison identities   — recovered by concrete probing
✓  no hardcoded helper identities       — anchored on the .NET BCL reflection surface
✓  no dependence on one sample, one handler permutation, or one handler count
✓  no method name or metadata token anywhere in any selection or activation rule

That last line is enforced repeatedly in the project's own tests: dispatcher recognition, EH tier activation, optimisation selection, and the function-pointer probe all state explicitly that they use graph and type properties only.

Corpus-complete vs framework-open

DESIGN.md uses a pair of terms that are genuinely useful and worth adopting more widely:

CORPUS-COMPLETE
every method in every checked-in protected build works, end to end
Measured
sample1 101/101 · sample2 1/1 · TestCases 3/3, 6/6, 8/8, 11/11 · FaultCases 1/1 — 131 methods
FRAMEWORK-OPEN
a new handler shape is an explicit, reported unsupported case — not a silent failure and not a redesign
What a new build needs
usually nothing: discovery is structural. Where it does need work, the work is a new modelled shape in ExecuteInterpreter — additive, not architectural.

The distinction matters because the two failure modes are completely different. A corpus-incomplete tool is broken on its own test data. A framework-open tool is one that knows it has not seen everything and behaves correctly when it meets something new — it reports, rather than guessing.

The project marks its own milestones this way. M3b (the execute lifter) and M4 (the builder) are both labelled "CORPUS-COMPLETE, FRAMEWORK-OPEN", with the explicit note that "general new execute-handler shapes remain explicit unsupported cases until represented by real fixtures". That is an honest status, not a hedge.

Where genericity comes from, structurally

Every derivation in this article bottoms out in something the protector cannot randomize without breaking its own runtime:

Derived factAnchored onWhy the protector can't move it
Handler base typetwo abstract slots, one taking BinaryReaderthe runtime must call them polymorphically
Context typethe execute slot's parameter typeit is the definition
Opcode mapldtoken order in a static ctorthe dispatch table must be built in opcode order
Operand layoutthe read method's own ILit is the decoder
Handler semanticsthe execute method's own ILit is the implementation
Comparison relationsobservable input→output behaviourthe behaviour is the requirement
Reflection helpersSystem.Reflection API namesthey belong to the BCL, not the protector
Stack pop vs peekwhether the body contains a stflda popping stack must mutate
Locals vs args arraywhich array the local setter indexesonly locals are written
IP getter/setterreading and writing the same fieldthey must share storage

Rename everything, shuffle the opcode order, duplicate every handler class, generate a fresh runtime per build — none of it touches that column.


34 Limitations

This is not a universal Agile.NET unpacker, and the honest list is longer than the feature list.

Scope

  • It reverses one transformation. Agile.NET's separate identifier-renaming pass is untouched — a devirtualized method still has whatever obfuscated names the protector assigned to types, methods and fields. So does the runtime. That is a different transformation and a different tool's job.
  • It does not crack anything. The engine has no knowledge of any particular application and no interest in what a method does — only in how to reverse the encoding.
  • String encryption, resource encryption, anti-debug and the other Agile.NET features are out of scope entirely.

Coverage

  • New execute-handler shapes are explicit unsupported cases. A handler idiom absent from every fixture will raise LiftUnsupported with a reason and leave the method VM-backed. That is correct behaviour, but it is still a gap.
  • Two product versions. 6.6.0.35 and 6.6.0.42. Other versions remain framework-open until real protected/runtime pairs exist to test against.
  • Filter and fault emission is shadow-only in the strict EH tier (§08, §23), because Agile.NET 6.6 refuses to virtualize a method containing endfilter and no real protected fixture exercises the serialized form. Synthetic fixtures cover the models; a real one does not exist.
  • Phi copies that unwind a finally are deferred, not supported (§24).
  • Residual decompiler noise. A handful of methods across the wider corpus still retain visible copy noise. The project tracks it as a ranked debt baseline rather than claiming it away — the current actionable baseline is nine EH data-flow methods, seven managed-pointer methods and eight exact-type/materialization methods.

Environment

  • The runtime DLL must match the executable. No cross-build compatibility.
  • Missing dependencies degrade the output rather than failing loudly — see the warning in §31.
  • Malformed inputs are handled narrowly. NativeResourceSanitizer repairs exactly one proven-invalid shape (a truncated native-resource directory). Anything else is not repaired.
  • One metadata layout is not fully preservable. If a module places a nested TypeDef before its enclosing one, AsmResolver cannot preserve that table order, so the writer uses a coherent standard rebuild instead. Raw VM token preservation for that layout remains future work — it matters only for partial outputs, where rejected methods still resolve tokens through the resource.

Verifier and framework assumptions

  • PEVerify is the external oracle, which ties the strongest validation to Windows and the .NET Framework SDK. The fixtures target net48; the tool itself is net8.0.
  • Some emitted CIL is verifiable-but-unusual. Receiver narrowing inserts casts a C# compiler would not; IntPtr.Size reads survive as _ = IntPtr.Size; in decompiled output because they are genuinely in the bytecode. The tool reproduces the bytecode, not the original C#.
  • One AsmResolver quirk is worked around rather than fixed. Calling ComputeMaxStack a third time on the same body instance throws a spurious StackImbalanceException even with zero further edits, so later cleanup tiers verify on a fresh CilMethodBodyCloner clone.
⚠️
The single most important limitation to internalise: a reported failure is the tool working correctly. "Devirtualized 97/101, 4 not fully lifted" is a better outcome than "101/101" produced by a tool that guesses — because in the first case you know exactly which four methods you still need to read as VM bytecode.

35 Lessons Learned

Behaviour is more stable than names

Every naming-based approach to this problem fails on the second build. Every behavioural one survives arbitrary renaming, arbitrary opcode shuffling, and arbitrary handler duplication. This is not specific to .NET: it is the general lesson that identity should be derived from what something does, not what it is called. The comparison classifier is the purest expression — it does not even look at the method, it runs it.

An interpreter can be reverse-engineered by reusing its own decoding logic

The strongest idea in this project is also the simplest: instead of documenting an operand grammar, execute the grammar. The read method is the only fully correct specification of its own operand format, including the data-dependent widths that no table can express. The same principle applies to any protected format that ships its own reader — and most do, because they have to.

The runtime DLL is a machine-readable specification

Reframing "the protector ships an interpreter" as "the protector ships a specification, in executable form" changes what kind of work is required. It is no longer "read a lot of IL in dnSpy and write down what you find" — it is "write a program that reads the specification". The first scales linearly with the number of handlers; the second does not scale with them at all.

CFG and EH correctness are harder than opcode decoding

Decoding was M1–M2. Everything from M3 onwards — CFG, worklist, SSA, EH region legality, phi lowering, five cleanup tiers — is 71% of the project's 24,231 lines of C# and the overwhelming majority of the bugs. The two most instructive defects in the whole project (the leave/finally coalescing corruption and the nested-region one, §26) were both about implicit control flow that CIL never encodes as a branch.

Generating valid CIL is harder than understanding the VM

A surprising amount of engineering exists purely to satisfy the verifier rather than to recover semantics: receiver narrowing and its public-only restriction, the argument-reorder-around-box dance, constrained. emission, function-pointer rematerialization, the non-void ret default value, inserted leave instructions, and CilTypeSafetyValidator existing at all because max-stack is only a height check. None of that is about Agile.NET; all of it is about ECMA-335.

Fail-closed beats heuristics, permanently

Every rewrite in this project has the same shape: build detached, verify independently, install only on success, otherwise keep the previous verified body and report. That discipline is what makes it possible to add aggressive optimisations — dispatcher elimination, interference coalescing, hierarchy-aware cast removal — without ever risking the baseline. The cost is real (T4 gave up ~3% of its win to a confinement rule) and it is worth paying.

Validation needs both structural and runtime checks — and they find different bugs

This is the lesson with the sharpest evidence. The EH SSA external validation gate caught two defects that internal max-stack and type checks could not: a final nop anchor allowing method fall-through, and removed stloc operations leaving finally/loop state stale. Structural invariants prove internal consistency. Only running the code proves it does the same thing.

Measure the thing you actually care about

The T4/T5 episode (§26) is a small, complete cautionary tale: CIL locals collapsed from 155 to 17 — a genuinely large win — while the decompiled C# got longer, because the pass introduced 49 self-assignments that had not existed. If the objective is "readable output", CIL instruction counts are a proxy, and proxies drift. The project's answer was to build a token-addressed per-method quality report that decompiles through ILSpy and ranks residual debt by visible C# plus exact CIL metrics — and, notably, to keep that report strictly observational so it never influences selection.

Documentation drifts faster than code

A small but real one. DESIGN.md still names CilInterpreter, HandlerClassifier, VmOp and OpcodeMap; none exist. ControlFlowGraphBuilder's doc comment still describes the CFG as observational, though it is now the production path. In a research codebase that refactors hard, prose ages faster than tests do — which is an argument for making the tests the specification and treating the prose as a map that needs re-surveying.


36 Conclusion

Agile.NET's code virtualization relocates a method's logic out of CIL and into a proprietary bytecode interpreted by a runtime the protector generates fresh for every build — randomized names, randomized opcode order, duplicated handler classes, and operand formats that vary with the data they encode. The standard analysis approach, reading the bytecode by hand in a decompiler, does not survive contact with a second sample.

The path this project takes instead is to treat the shipped runtime as what it actually is — an executable specification — and derive everything from it, every run:

the full path
Agile VM bytecode
   → structural handler discovery      (two abstract slots, ldtoken registration order)
   → operand interpretation            (execute the handler's own read method)
   → behavioural handler classification (symbolic execution of the execute method)
   → semantic IR                       (opcode-free; signedness, overflow, encoding, dispatch)
   → control-flow graph                (typed edges, exception regions, RegionPath)
   → worklist / SSA / SCCP / DCE       (each with its own independent verifier)
   → EH-aware lowering                 (entry contracts, continuations, region-legal copies)
   → CIL reconstruction                (labels, locals, real exception handlers)
   → external verification             (PEVerify + a known-source runtime oracle)
   → verified standalone assembly

Nothing in that chain is keyed on a name, an opcode number, an operand width, or a particular sample. The parts that could be uncertain are not guessed: they are rejected, with a reason, and the method stays VM-backed. Across seven independently generated runtimes — two product versions, both observed handler architectures, handler counts from 6 to 574 — 131 protected methods decode with exact blob consumption and lift completely; the public fixtures rebuild to standalone, PEVerify-clean assemblies that match their known source across the full oracle matrix.

What generalises beyond .NET is the method rather than the tool. Any protection that must ship its own interpreter has, by construction, handed you a complete specification of its format — you simply have to be willing to run it instead of reading it. And any transformation tool that will be trusted to reason about code it did not write is better off reporting a gap than filling it.

Source, design notes, validation methodology and the public fixture set are in agile-net-devirtualizer — MIT licensed, built on AsmResolver.