Research · Reverse Engineering
Agile.NET Devirtualizer — Reversing a .NET Code-Virtualization VM From Its Own Runtime
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:
static int Add(int a, int b)
{
return a + b;
}
Its CIL is four instructions:
ldarg.0
ldarg.1
add
ret
Virtualization replaces that with an indirection:
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.
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:
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:
[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:
| Preserved | Destroyed |
|---|---|
| Method token, name, signature, visibility, custom attributes | The entire method body |
| Declaring type and its field layout | Local variable table (moved into the resource) |
The MethodImplOptions.NoInlining marker the protector adds | Exception handler table (moved into the resource) |
| Everything the CLR needs to call the method | All 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:
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
_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:
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:
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:
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 there —
void f(BinaryReader)andvoid 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 computessetIp(getIp() + delta - 1)— that resolves toindex + 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:
-
Operands are decoded by the handler itself, from a
BinaryReaderpositioned in a shared operand blob. So the operand grammar is a program, not a table. - 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:
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.
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:
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.
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:
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:
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.
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:
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.
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:
[*] 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 opcode | Handler type | Operand bytes† | Recovered semantics |
|---|---|---|---|
0x0001 | lXU=.tHY= | 9 | ldc.i4 <k>; ret |
0x0004 | lXU=.x3Y= | 9 | ldc.i4 <k>; ret |
0x0015 (21) | lXU=.J3c= | 6 | ldarg <n>; ldc.i4.0; ceq; ldc.i4 0; ceq; brtrue <t> |
0x0016 (22) | lXU=.LHc= | 9 | ldc.i4 <k>; ret |
0x0017 (23) | lXU=.MXc= | 25 | a 12-instruction arithmetic block ending in ret |
0x0022 (34) | lXU=.dXc= | 10 / 11 / 25 | ldstr <s>; newobj <ctor>; throw |
0x0027 (39) | lXU=.lXc= | 0 | nothing (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
TestCasesbuilds — 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.
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:
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:
- the record count must be in
(0, 500_000]; - every method token must resolve to a real
MethodDefinitionin this module; - the stream must be consumed exactly — a single leftover byte means a mis-parse and the candidate is rejected.
Table layout
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:
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
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:
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.
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:
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.
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.
case CilCode.Stfld:
{
var value = Pop(stack);
Pop(stack); // the target object (ThisRef)
if (instr.Operand is IFieldDescriptor field)
fields[field.Name!] = value;
break;
}
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:
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:
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:
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:
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.
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.
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
| Code | CorElementType | Extra bytes | Decoded signature |
|---|---|---|---|
0x02–0x0D | BOOLEAN…R8 | 0 | the matching corlib primitive |
0x0E | STRING | 0 | System.String |
0x11 | VALUETYPE | 4 | resolved from the token |
0x13 | VAR | 4 | resolved from the token |
0x15 | GENERICINST | 4 (+4) | token when flag is 0x11, else System.Object |
0x18/0x19 | I/U | 0 | IntPtr/UIntPtr |
0x1E | MVAR | 4 | resolved from the token |
| anything else | incl. CLASS 0x12, SZARRAY 0x1D, OBJECT 0x1C | 0 | System.Object |
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
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 object —
GuidRoundTrip, 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.
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:
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:
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:
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:
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),
};
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/).
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:
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:
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
| Problem | Mechanism | Where |
|---|---|---|
| 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:
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:
[*] 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:
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:
[*] 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.
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 symbolic value model
SymValue is a 22-case discriminated union. The ones that carry the
design:
SymValue case | Meaning | Materialises as |
|---|---|---|
Ctx | the execute method's ctx parameter | nothing (VM plumbing) |
StackRef | result of ctx.getStack() | nothing |
SlotArray(IsArgs) | result of ctx.getLocals()/getArgs() | nothing |
SlotRead(IsArgs, Index) | a pending slot read | ldloc / ldarg |
SlotAddr(IsArgs, Index) | the runtime's by-ref wrapper over a slot | ldloca / ldarga |
ArrayElemAddr(Array, Index) | the same wrapper over a real array element | ldelema |
Operand(Value) | a decoded operand field | ldc.* / token load |
ResolvedString(Value, RawToken) | a user string plus its original heap token | ldstr, or char[] reconstruction |
Resolved(Kind, Member) | a metadata member from a reflection resolve | the call/field/newobj it feeds |
OnStack(KnownType, Peeked, ManagedPointer, KnownNull) | a value that is already on the real CIL stack | nothing — or dup if Peeked |
Cond(Rel, Negate) | a genuine runtime-computed boolean | already emitted; only the negation remains |
Ip(Offset) | ctx.getIp() + k | a branch target index |
SwitchTable(Deltas, Base) | an operand jump table indexed by a stack value | switch |
CurrentException | the VM context's in-flight exception | rethrow (at a catch terminator) |
FnPtr(Method) | method.MethodHandle.GetFunctionPointer() | ldftn |
DefaultValue(Type) | the Activator-backed default(T) factory result | initobj into a scratch local |
Unknown(Reason) | anything unmodelled | throws 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):
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:
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.
// - "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:
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:
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:
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.
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:
// 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.)
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
[*] 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.
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:
=== 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.
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:
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
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:
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.LiftreturnsList<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 ownretis real; earlier ones are dead.EmitTerminatorfires once, at the end, with whatever_termKindsurvived. -
ControlFlowGraphBuilder.BuildOperationsmust strip the trailing terminator out of a block's operation list and hoist it into the block'sSemanticTerminator— 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:
| Runtime | Version | Methods | VM instrs | Handlers | exec switch | large exec | total exec IL | median | max |
|---|---|---|---|---|---|---|---|---|---|
| sample1 private | 6.6.0.35 | 101 | 1388 | 574 | 2 | 499 | 338,417 | 306 | 4075 |
| sample2 private | 6.6.0.42 | 1 | 12 | 11 | 0 | 10 | 9,889 | 687 | 2061 |
| TestCases — base | 6.6.0.42 | 3 | 9 | 7 | 0 | 5 | 2,562 | 235 | 1046 |
| TestCases — extended | 6.6.0.42 | 6 | 30 | 24 | 0 | 11 | 4,503 | 86 | 1046 |
| TestCases — control flow | 6.6.0.42 | 8 | 53 | 38 | 2 | 17 | 5,940 | 86 | 1046 |
| TestCases — advanced | 6.6.0.42 | 11 | 84 | 66 | 4 | 37 | 10,718 | 105 | 1059 |
| FaultCases | 6.6.0.42 | 1 | 6 | 6 | 1 | 1 | 524 | 66 | 164 |
"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
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
~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:
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.
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
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:
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.
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:
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:
| Kind | Produced by | Why it is distinct |
|---|---|---|
FallThrough | no terminator, next index | ordinary sequential flow |
Branch | br within one region | plain unconditional jump |
Leave | any edge whose RegionPath loses a frame | must lower to leave, may unwind finallys |
ConditionalTaken / ConditionalFallThrough | a conditional terminator | SCCP folds one of the two |
SwitchCase (with index) / SwitchDefault | switch | case index drives dispatcher analysis |
ExceptionCatch / ExceptionFilter / ExceptionFinally / ExceptionFault | every block inside a try, to the region's dispatch start | seeds a different entry stack; forbids inserted edge code |
ExceptionFilterHandler | a successful endfilter | the 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:
var kind = block.RegionPath.ExitsTo(targetPath)
? ControlFlowEdgeKind.Leave : ControlFlowEdgeKind.FallThrough;
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:
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
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
[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 atInstructionCount - 1. - Edge endpoints are in range.
- Edge/EH consistency: every exception edge carries an
ExceptionRegionId; no normal edge does. - Out-degree: a
Branchblock has ≥1 normal edge;Conditional≥2;Switch≥ cases + 1 (the default); aFallThroughblock that is not the last has ≥1.
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:
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:
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
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:
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:
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:
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:
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 kind | Created for | Note |
|---|---|---|
Variable (local) | every tracked local slot at every block with predecessors | seeded from the worklist's local lattice |
Variable (argument) | every argument slot | arguments are mutable in CIL (starg) |
EvaluationStack | every stack slot alive at a block entry | the 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.
[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:
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 —
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
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.
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:
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:
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:
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 throwDivideByZeroException; - 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.
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.
[entry] state = 3
│
▼
┌──►[dispatch] switch (state)
│ │ │ │
│ ┌──┘ │ └──┐
│ ▼ ▼ ▼
│ [c0] [c3] [c7] c0, c7 proven unreachable
│ │ │ │
└───┴─────┴─────┘ every arm loops back
[entry]
│ state store and its pure computation removed
▼
[c3] direct transition; dispatch block gone
Recognition is structural
ControlFlowSimplifier.TryBuildDispatcher requires all of:
- the block's terminator is a
Switchwith exactly one input; - that input is a phi in this block (not an ordinary value);
- every executable phi input is an SCCP constant convertible to
Int64; - at least two distinct state values exist;
- each state selects a retained switch-case edge (or the default);
- at least one selected target can reach the switch block again — it is genuinely cyclic.
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/FallThroughterminator); - 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).
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:
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:
| Removed | Kept |
|---|---|
| The proven state store and its pure constant computation suffix | Any dispatcher that fails one gate — the method keeps its lossless body |
| The dispatch edge, replaced by a direct predecessor→target transition | Every real domain switch (its selector is not an all-constant phi) |
Blocks left unreachable, physically, via PrunedSemanticCfgEmitter | Unreachable-block pruning in methods with exception handlers — the layout stays linear there |
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.
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 input | Selected CIL | Deciding attribute |
|---|---|---|
Add | add / add.ovf / add.ovf.un | Overflow × Signedness |
Divide | div / div.un | Signedness |
CompareLessThan | clt / clt.un | Signedness + UnorderedFloatingPoint |
LoadArgument(2) | ldarg.2 | Encoding = Implicit and index ≤ 3 |
LoadLocal | ldloc.s / ldloc | Encoding = ShortInline / Inline |
LoadConstant(5) | ldc.i4.5 | PrimitiveType = Int32, Encoding = Implicit |
LoadElement | ldelem.i4, ldelem.ref, ldelem, … | PrimitiveType |
LoadFunctionPointer | ldftn / ldvirtftn | Dispatch |
Prefix | constrained., readonly., tail., volatile., unaligned. | Prefix |
terminator Branch | br / br.s / leave / leave.s | Encoding + 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:
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:
// 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:
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:
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:
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:
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:
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.
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:
/// <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.
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:
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
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.
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.
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
CilExceptionHandlerType — 0/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:
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
| Construct | Recognition | Emitted |
|---|---|---|
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 |
rejected — LiftUnsupported |
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:
/// 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:
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:
_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:
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
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:
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
leavecarries the ordered inner-to-outer chain of finallys that must run before it reaches its target; - an
endfinallydistinguishes resuming a pending leave from continuing an exception unwind, and a fault handler may never resume a normal leave; rethrowstays 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.
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:
| Disposition | Meaning |
|---|---|
EmittedCopy | a normal edge across a valid CLI transition — a real copy may be inserted |
ImplicitVariableState | exception dispatch — the value is already in the local/argument state; inserted edge code is forbidden |
RequiresLeaveUnwind | the copy's leave unwinds a finally, so copy timing is not yet modelled — explicitly deferred |
Illegal | the transition violates ECMA-335 region rules |
RegionTransitionClassifier is the rule engine, and its rejections read
like the spec:
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.
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:
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
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:
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:
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:
| Placement | Used when |
|---|---|
SourceExit | the source has exactly one relevant successor, so the copy cannot execute on a path that does not take this edge |
TargetEntry | the target has exactly one relevant predecessor, so the copy cannot be reached from a different edge |
SplitBlock | a 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:
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.
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:
// (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:
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:
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:
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:
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.
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:
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:
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.
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
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:
// 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
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.
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.
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#:
// 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
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
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:
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
| Cast | Verdict | Reason |
|---|---|---|
Label → Control | removed | real base class in the resolvable hierarchy |
Button → ISupportInitialize | removed | only if the interface list resolves |
Foo → Bar (unrelated siblings) | kept | removing would suppress InvalidCastException |
null → anything | removed | a cast of null never throws |
anything → object | removed | CLI universal |
box | kept | changes representation and may allocate |
unbox.any | kept | performs null/type checks |
conv.i4 on a known Int32 | removed | exact non-overflow identity |
conv.ovf.* | kept | can throw OverflowException |
| any cast at a branch/EH boundary | kept | the analyzer's proof domain is per basic block |
| source provenance unknown | kept | Unknown 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.
--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.
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:
/// 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:
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:
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:
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:
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:
/// 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:
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
| Check | Enforced by |
|---|---|
| Complete operand consumption (per method and per blob) | MethodDecoder, LocalsDecoder, EhDecoder |
Every _CSVM token resolves to a MethodDefinition | VMResource.TryParse |
| Block coverage, edge endpoints, edge/EH consistency, out-degree | ControlFlowGraphValidator |
| Worklist convergence and consistent entry stack shapes | WorklistAnalyzer + CfgEmissionPolicy |
| SSA dominance, single definition, phi arity, stack agreement | SsaVerifier |
| Stack height | CilMethodBody.ComputeMaxStack |
| Managed-pointer type safety beyond height | CilTypeSafetyValidator |
| Every EH entry/continuation contract | ExceptionEntryModelVerifier, ExceptionContinuationModelVerifier |
| Phi-copy region legality | RegionAwarePhiCopyLegalityVerifier |
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:
--- 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 --- --- 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
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
| Corpus | Decoded | Lifted | Devirtualized | Standalone | PEVerify | Runtime oracle |
|---|---|---|---|---|---|---|
| TestCases advanced (11 methods) measured | 11/11 | 84/84 instr | 11/11 | yes | 0 errors | 46/46 repo |
| TestCases control-flow (8) repo | 8/8 | 53/53 | 8/8 | yes | 1→0 errors | 29/29 |
FaultCases (1, real fault clause) repo |
1/1 | 6/6 | 1/1 | yes | clean | both paths |
| sample1 (101) private | 101/101 | 1388/1388 | 101/101 | yes | 183→9 | safe matrix |
| sample2 (1) private | 1/1 | 12/12 | 1/1 | yes | clean | password 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.clsproject 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#
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
[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
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
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
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
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
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
[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
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
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
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
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
[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
[*] 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.
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:
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
| Flag | Effect |
|---|---|
| (none) | Optimized semantic CIL: dispatcher-loop elimination, constant-branch folding, strict EH SSA. The default. |
--cfg-emission | Explicit equivalent of the default. |
--no-optimize | Lossless semantic route — the rollback path. |
--legacy-emission | The straightforward CilBuilder lowering, kept as a test-only oracle to diff against. |
--typed-ssa | Adds typed straight-line SSA lowering; installs only strictly smaller bodies. |
--ssa-phi | Adds 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-ssa | Explicit enable (same as default) / permanent rollback of the strict EH SSA tier. |
--cast-shadow | Classifies every conversion and reports, but installs the ordinary body — output byte-identical to a run without it. |
--cast-cleanup | Removes only proven-redundant conversions, gated on full re-validation. |
--exclude 0600001D,0x0600002A | Forces the named MethodDef tokens to stay VM-backed. A bisection safety valve; validated as real method tokens. |
Inspection flags
| Flag | Prints |
|---|---|
--vocab | The structurally identified VM vocabulary (§11). |
--classify | Comparison primitives recovered by probing (§09). |
--helpers | Runtime 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-decisions | Per-method activation decision, feature set and every optimisation attempt. |
--show-failures | The 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
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
_CSVM foundBring 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
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.
✗ 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
✓ 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:
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 fact | Anchored on | Why the protector can't move it |
|---|---|---|
| Handler base type | two abstract slots, one taking BinaryReader | the runtime must call them polymorphically |
| Context type | the execute slot's parameter type | it is the definition |
| Opcode map | ldtoken order in a static ctor | the dispatch table must be built in opcode order |
| Operand layout | the read method's own IL | it is the decoder |
| Handler semantics | the execute method's own IL | it is the implementation |
| Comparison relations | observable input→output behaviour | the behaviour is the requirement |
| Reflection helpers | System.Reflection API names | they belong to the BCL, not the protector |
| Stack pop vs peek | whether the body contains a stfld | a popping stack must mutate |
| Locals vs args array | which array the local setter indexes | only locals are written |
| IP getter/setter | reading and writing the same field | they 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
LiftUnsupportedwith 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
endfilterand 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.
NativeResourceSanitizerrepairs 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
TypeDefbefore 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 isnet8.0. -
Some emitted CIL is verifiable-but-unusual. Receiver narrowing
inserts casts a C# compiler would not;
IntPtr.Sizereads 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
ComputeMaxStacka third time on the same body instance throws a spuriousStackImbalanceExceptioneven with zero further edits, so later cleanup tiers verify on a freshCilMethodBodyClonerclone.
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:
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.