Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilation.NETDeep Dive

Decompiling operator overloads: op_ names, checked operators, and the conversions that only compile as operators

A user-defined operator is a static method with a reserved name and the specialname flag — op_Addition, op_Implicit, op_Equality — and mapping it back to an operator declaration looks like a table lookup. Three things make it harder than the table. User-defined conversions may be overloaded on return type alone, which is legal IL and illegal C#: two op_Explicit methods with identical parameters differ only in what they return, so a decompiler that emits them as ordinary methods produces source that cannot compile. C# 11 added checked operators — op_CheckedAddition, op_CheckedExplicit — that must be rendered with the checked keyword and paired with their unchecked twin, or every checked(a + b) call site decompiles to a method call. And op_True/op_False, op_Increment and the C# 14 instance compound-assignment operators each compile to call patterns that have to be folded back into syntax rather than shown as invocations. This post reads the IL for each, shows the call sites that drive the reconstruction, and walks how Glass.NET turns them back into the operators the author wrote.

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.

ordinary — the table casea + bcall specialname static Money::op_Additionstatic + specialname + reserved name → operator +checked — C# 11checked(a + b)call Money::op_CheckedAddition (add.ovf)chosen at the call site; needs the unchecked twinconversions — overload on return type onlycall int32 Money::op_Explicit(Money)call int64 Money::op_Explicit(Money)same name, same parameter, different return:legal IL — illegal as two C# methods (CS0111)compound assignment — C# 14 instance forma += bcall instance void Money::op_AdditionAssignmentnot static, returns void, [CompilerFeatureRequired]a static-only detector shows a method call

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.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.