Operators look like the easy part of decompilation. The C# compiler turns a + b on a user-defined type into a call to a static method named op_Addition; the method carries the specialname flag; a decompiler keeps a table from reserved names to operator tokens and emits operator +. Done.
The table is necessary, and it is where most decompilers stop, and three features of the language sit just past it. User-defined conversions can be overloaded purely on return type — legal IL, illegal for an ordinary C# method, so emitting them as methods yields source that will not compile. Checked operators from C# 11 are distinct methods with a Checked infix, selected at the call site by context, and have to be rendered with the checked keyword and paired with their unchecked twins or the author's overflow semantics vanish. And a handful of operators — true/false, ++, and the C# 14 instance compound assignments — compile to call shapes that must be folded back into expressions, not shown as invocations. This post reads the IL for each and how Glass.NET reverses it.
The table case: static, specialname, reserved name
Here is the type the rest of the post works on — a money amount in cents with the operators a real one would have:
public readonly struct Money : IEquatable<Money>
{
public long Cents { get; }
public Money(long cents) => Cents = cents;
public static Money operator +(Money a, Money b) => new(unchecked(a.Cents + b.Cents));
public static bool operator ==(Money a, Money b) => a.Cents == b.Cents;
public static bool operator !=(Money a, Money b) => a.Cents != b.Cents;
public bool Equals(Money other) => Cents == other.Cents;
public override bool Equals(object? o) => o is Money m && Equals(m);
public override int GetHashCode() => Cents.GetHashCode();
}
operator + compiles to a static method whose name is fixed by the CLI specification and whose specialname flag tells tools "this is not an ordinary method":
.method public hidebysig specialname static
valuetype Money op_Addition(valuetype Money a, valuetype Money b) cil managed
{
ldarga.s a
call instance int64 Money::get_Cents()
ldarga.s b
call instance int64 Money::get_Cents()
add // unchecked: wraps on overflow
newobj instance void Money::.ctor(int64)
ret
}
And a use of it, var total = price + tax;, compiles to a plain call:
ldloc.0 // price
ldloc.1 // tax
call valuetype Money Money::op_Addition(valuetype Money, valuetype Money)
stloc.2 // total
Three things identify the operator, and a decompiler needs all three. The name op_Addition is reserved, but a C# author can declare an ordinary method with that name — the compiler allows it, it simply cannot be invoked with +. The specialname flag is what distinguishes the real operator; a method named op_Addition without it must be rendered as a method, or the decompiled source changes meaning. And the arity matters: op_Addition with two parameters is binary +, while op_UnaryPlus with one is unary +; op_Subtraction and op_UnaryNegation likewise, so the table is keyed by name and parameter count. Glass checks all three, then does the thing that makes the output readable: at each call to a recognised operator it folds the call back into an expression, so the local is price + tax and not Money.op_Addition(price, tax).
The equality pair has one extra rule: C# requires == and != to be declared together, and a decompiler that recognises one and not the other emits source that does not compile. The same holds for </>, <=/>=, and true/false. Glass renders the pair as the pair.
Conversions: the overload C# cannot express as a method
Add the conversions a money type wants — narrowing to int and long, widening from int:
public static explicit operator int(Money m) => (int)m.Cents;
public static explicit operator long(Money m) => m.Cents;
public static implicit operator Money(int cents) => new(cents);
All three compile to reserved names: op_Explicit for the explicit conversions, op_Implicit for the implicit one. Read the first two carefully:
.method public hidebysig specialname static int32 op_Explicit(valuetype Money m) cil managed
.method public hidebysig specialname static int64 op_Explicit(valuetype Money m) cil managed
.method public hidebysig specialname static valuetype Money op_Implicit(int32 cents) cil managed
Two methods, same name, same single parameter, different return type. In the CLI a method's signature includes its return type, so these are two distinct methods and the metadata holds both without complaint. In C#, an ordinary method is identified by name and parameter types only; declaring static int op_Explicit(Money m) and static long op_Explicit(Money m) side by side is error CS0111, a duplicate member. The only C# syntax that permits two members to differ by return type alone is the conversion operator, where the target type is part of what you are declaring.
So for conversions the operator rendering is not a readability choice — a decompiler that does not recognise op_Explicit and emits the two methods literally produces source that cannot compile at all, and the error is confusing because both methods look perfectly normal on their own.
The call sites are where the return type is chosen. (int)m and (long)m both call a method named op_Explicit with the same argument; the only difference is the signature in the call token:
ldloc.0
call int32 Money::op_Explicit(valuetype Money) // (int)m — resolves to the int32-returning one
...
ldloc.0
call int64 Money::op_Explicit(valuetype Money) // (long)m — resolves to the int64-returning one
Glass resolves each call token to its exact method — return type included — and emits the cast with the right target type. The reverse direction is subtler. Money m = 5; has no cast in the source; the implicit conversion is invisible. In IL it is as visible as any call:
ldc.i4.5
call valuetype Money Money::op_Implicit(int32)
stloc.0
A decompiler can legally render this as Money m = (Money)5; — it compiles, since an implicit conversion may always be invoked explicitly — but it is not what the author wrote, and it hides the fact that the conversion is implicit. Glass elides the call when the target type makes the conversion unambiguous, giving back Money m = 5;, and keeps an explicit cast only when removing it would change overload resolution at that site.
Checked operators: the same expression, a different method
C# 11 let a type say what + does when the surrounding context is checked. The money type gains an overflow-checked twin for addition and for the narrowing conversion:
public static Money operator checked +(Money a, Money b) => new(checked(a.Cents + b.Cents));
public static explicit operator checked int(Money m) => checked((int)m.Cents);
Each compiles to a separate method with Checked spliced into the reserved name:
.method public hidebysig specialname static
valuetype Money op_CheckedAddition(valuetype Money a, valuetype Money b) cil managed
{
ldarga.s a
call instance int64 Money::get_Cents()
ldarga.s b
call instance int64 Money::get_Cents()
add.ovf // the one instruction that differs from op_Addition
newobj instance void Money::.ctor(int64)
ret
}
.method public hidebysig specialname static int32 op_CheckedExplicit(valuetype Money m) cil managed
{
ldarga.s m
call instance int64 Money::get_Cents()
conv.ovf.i4 // vs conv.i4 in the unchecked op_Explicit
ret
}
The full set follows the same pattern: op_CheckedSubtraction, op_CheckedMultiply, op_CheckedDivision, op_CheckedUnaryNegation, op_CheckedIncrement, op_CheckedDecrement, and op_CheckedExplicit. The language rule is that a checked operator may only be declared if the unchecked form exists too — operator checked + requires operator + — because the compiler must always have a method to call in an unchecked context.
Which method it calls is decided entirely at the use site, by context:
var a = price + tax; // unchecked context (the default)
var b = checked(price + tax); // checked expression
checked { var c = price + tax; } // checked block
call valuetype Money Money::op_Addition(valuetype Money, valuetype Money) // a
call valuetype Money Money::op_CheckedAddition(valuetype Money, valuetype Money) // b
call valuetype Money Money::op_CheckedAddition(valuetype Money, valuetype Money) // c
Two reconstruction problems follow. On the declaration side, a decompiler that only knows the pre-C# 11 names sees op_CheckedAddition as an unrecognised specialname method and emits public static Money op_CheckedAddition(Money a, Money b) — which compiles, and is wrong, because the type has silently lost its checked behaviour: a recompiled checked(a + b) now calls the wrapping op_Addition. On the use side, every checked(price + tax) in the original becomes Money.op_CheckedAddition(price, tax) in the output, a method call where the author wrote an expression.
Glass recognises the op_Checked* family as operators, emits operator checked + and explicit operator checked int with the keyword in place, and at each call site to a checked method wraps the folded expression in checked(...) — or, when a run of consecutive statements all call checked forms, a checked { } block, which is the shape the author most likely wrote. The built-in types give the same discipline a familiar face: add.ovf on two ints decompiles to checked(x + y), and the user-defined case is the same rule applied to a named method instead of an instruction.
The newest arithmetic operator, >>> from C# 11, is a smaller version of the same lesson: for built-in types it is the shr.un instruction, and for user-defined types it is op_UnsignedRightShift, a name that did not exist in the table before 2022 and that an older decompiler renders as a call.
true, false, and the short-circuit expansion
operator true and operator false compile to op_True and op_False, and they are the only operators the compiler calls on your behalf without an operator in the source. A money type that treats zero as false:
public static bool operator true(Money m) => m.Cents != 0;
public static bool operator false(Money m) => m.Cents == 0;
public static Money operator &(Money a, Money b) => new(Math.Min(a.Cents, b.Cents));
An if (balance) compiles to a call the author never wrote:
ldloc.0
call bool Money::op_True(valuetype Money)
brfalse.s SKIP
Glass folds the op_True call back into the condition — if (balance), not if (Money.op_True(balance)). The expansion of && is the interesting one, because it is defined in terms of false and &: a && b on a user type with these operators means "if a is false, the result is a; otherwise a & b":
ldloc.0 // a
dup
call bool Money::op_False(valuetype Money)
brtrue.s RESULT_IS_A // a is "false" → short-circuit, leave a on the stack
ldloc.1 // b
call valuetype Money Money::op_BitwiseAnd(valuetype Money, valuetype Money)
RESULT_IS_A:
A decompiler that renders operators but not this pattern produces something like Money.op_False(a) ? a : (a & b), which is correct and unreadable. Glass matches the dup / op_False / brtrue / op_BitwiseAnd shape and emits a && b. The || form is symmetric over op_True and op_BitwiseOr.
Increment, and the operator that is not static
operator ++ compiles to op_Increment, a static method that returns a new value; the compiler stores the result back into the variable. balance++ is:
ldloc.0
call valuetype Money Money::op_Increment(valuetype Money)
stloc.0
Rendered literally, that is balance = Money.op_Increment(balance);. The idiomatic form is balance++, and the prefix/postfix distinction is recoverable from whether the pre-increment value is kept on the stack (a dup before the call) — Glass reads that to choose ++balance versus balance++. The same applies to compound assignment on types that only define the static operator: balance += tax is literally balance = balance + tax in IL — a call to op_Addition and a store — and Glass rewrites the load-call-store-to-the-same-location shape as +=.
C# 14 changes that last case. A type may now declare the compound form directly, as an instance method that mutates the receiver in place:
public void operator +=(Money other) => _cents += other.Cents; // C# 14 instance operator
In metadata this is unlike every operator before it: it is an instance method, it returns void, and it is marked so that a compiler that does not understand it refuses the type rather than misreading it:
.method public hidebysig specialname instance void op_AdditionAssignment(valuetype Money other) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute::.ctor(string)
= {string('UserDefinedCompoundAssignmentOperators')}
...
}
A balance += tax against this type compiles to ldloca.s balance; ldloc tax; call instance void Money::op_AdditionAssignment(Money) — no store, because the method mutated balance through the managed pointer. Every heuristic in the older playbook misses this: the detector that requires static sees an ordinary method; the compound-assignment rewriter that looks for load-call-store finds no store. The output is balance.op_AdditionAssignment(tax);, which compiles and reads like a bug. Glass recognises the instance op_*Assignment family with the CompilerFeatureRequired marker, renders the declaration as public void operator +=(Money other), and folds the call site back to balance += tax.
Reading it as one rule
The through-line is that operators are a naming convention plus a flag at the metadata level, and everything about how they appear in source — the symbol, the checked keyword, the implicit cast that is not written, the && that expands into three calls, the += that is now a mutating instance method — is reconstructed from the shape of the call site, not from the method alone. A decompiler that stops at the name table hands you compiling-but-wrong source for checked operators, uncompilable source for return-type-overloaded conversions, and method calls where the author wrote arithmetic. Glass.NET reads the flag, the name, the arity, the full call-token signature and the surrounding instruction pattern together, which is what it takes to give you back operator checked +, explicit operator long, if (balance), and balance += tax as the author wrote them.
Top comments (0)