Decompiling a decimal constant: why 3.14m is not an IL constant, and the [DecimalConstant] attribute that rebuilds it
Every other numeric literal in C# — 42, 3.14, 1_000_000L — lands in the IL as an ldc instruction or in the metadata Constant table, a value the runtime loads directly. decimal cannot: it is a 128-bit struct, not a primitive the CLR has a load opcode for, so a const decimal or a default decimal parameter has nowhere native to live, and the compiler solves it with a trick that trips up anyone reading raw IL — it encodes the number into a [DecimalConstantAttribute] whose five constructor arguments (scale, sign, and three 32-bit integers) spell out the exact value. This post follows a decompiler through that reconstruction: why the Constant table rejects decimal, how the attribute's arguments map to the four ints a decimal is built from, why a const decimal field has no field initializer in any constructor, how a default decimal parameter differs from a default int one, and why mistaking the attribute for an ordinary annotation turns 3.14m back into new decimal(314, 0, 0, false, 2) instead of a literal.
Take the most ordinary line of C# imaginable:
public const decimal TaxRate = 0.075m;
Compile it, open the assembly, and look for where 0.075 is stored. It is not in the IL — there is no ldc instruction for it. It is not in the metadata Constant table, where const int and default parameter values live. The number has, apparently, vanished. It hasn’t: it has been dismantled into five integers and tucked into an attribute, and a decompiler that doesn’t know to look there will render your tax rate as a pile of constructor arguments. This is the one numeric literal in C# that a decompiler has to reconstruct rather than read, and the mechanism is worth seeing in full.
Why decimal has nowhere to live
Every other numeric literal has a native home. A const int or a default int parameter is written into the Constant metadata table, whose blob format defines an encoding for each primitive element type — ELEMENT_TYPE_I4, ELEMENT_TYPE_R8, ELEMENT_TYPE_STRING, and so on. A literal in a method body becomes an instruction: ldc.i4 42, ldc.r8 3.14. Both channels share one assumption — that the value is a type the CLR represents natively.
decimal is not. System.Decimal is a 128-bit struct defined in the class library, built from a 96-bit integer plus a scale and a sign. The CLR has no load opcode for it and the Constant blob format has no element type for it. So when the language says “this decimal is a constant”, there is no metadata slot that can hold the answer. The compiler has to encode the value some other way — and the way it chose is System.Runtime.CompilerServices.DecimalConstantAttribute.
The attribute is the number
Here is the field above as the compiler emits it, in IL:
.field public static initonly valuetype [System.Runtime]System.Decimal TaxRate
.custom instance void [System.Runtime]System.Runtime.CompilerServices.DecimalConstantAttribute::.ctor(
uint8, uint8, uint32, uint32, uint32) = ( 01 00 03 00 00 00 00 00 00 00 00 00 4B 00 00 00 00 00 )
Those five constructor arguments are not metadata about the number — they are the number, in exactly the pieces System.Decimal is assembled from. The constructor signature is (byte scale, byte sign, uint hi, uint mid, uint low):
- scale =
3— the number of digits after the decimal point (divide the integer by 10³). - sign =
0— positive (1 would mean negative). - hi / mid / low =
0 / 0 / 75— the three 32-bit words of the 96-bit unscaled integer; here just75in the low word.
Put together: the unscaled integer 75, scaled by 10⁻³, positive → 0.075. The decompiler runs the same arithmetic the runtime’s decimal(int lo, int mid, int hi, bool isNegative, byte scale) constructor would, and recovers the literal 0.075m. Nothing is rounded and nothing is lost — the attribute is a complete, exact description of the value.
A const decimal has no initializer — on purpose
There is a second surprise waiting for a decompiler that only reads method bodies. You might expect const decimal TaxRate = 0.075m to compile to a store in the static constructor — .cctor assigns the field — but it does not. const in C# means compile-time constant: the value has to be usable in other constant contexts, so the compiler cannot implement it by running code at type-init time (that would make it readonly, not const). Instead:
- the field is emitted as a
static initonlyfield carrying the[DecimalConstant]attribute, and - every use of
TaxRatein source is replaced, at compile time, by the reconstructed value inlined at the call site.
So the .cctor is empty of any store to TaxRate, and there is no instance initializer. A decompiler that walks constructors looking for the field’s value finds nothing and would wrongly report the field as uninitialized. The value is in the attribute, and recovering the original declaration means reading it from there — then presenting the field as const, not static readonly, because the initonly flag plus the [DecimalConstant] attribute together are the signature of a const decimal.
A default decimal parameter takes a different road than a default int
The same gap reshapes optional parameters. Compare:
void A(int rate = 5) { }
void B(decimal rate = 0.075m) { }
For A, the default 5 goes into the Constant table and the parameter gets the HasDefault flag; a decompiler reads the Constant blob and writes = 5. For B, the Constant table can’t hold a decimal, so the compiler marks the parameter [opt] (the Optional flag) without a HasDefault entry, and attaches [DecimalConstant(3, 0, 0, 0, 75)] to the parameter:
.method ... void B(valuetype System.Decimal rate)
{
.param [1]
.custom instance void ...DecimalConstantAttribute::.ctor(uint8,uint8,uint32,uint32,uint32)
= ( 01 00 03 00 00 00 00 00 00 00 00 00 4B 00 00 00 00 00 )
}
A decompiler recovering default values therefore needs two code paths: read the Constant table for the primitives, and check for [DecimalConstant] on the parameter for decimal. Honour only the Constant table and the decimal default disappears — the parameter renders as having none. Worse, a decompiler that treats [DecimalConstant] as an ordinary custom attribute will print it literally into the source:
// WRONG — the attribute is an encoding, not something the author wrote:
void B([DecimalConstant(3, 0, 0, 0, 75)] decimal rate) { }
That line never existed in the original code. The attribute is the compiler’s private encoding of = 0.075m, and a faithful decompiler consumes it and renders the thing the programmer actually typed.
Why Glass treats it as a value, not an annotation
The rule a decompiler has to encode is: [DecimalConstant] is never shown — it is always consumed. Wherever it appears — on a const field, a static readonly field initialised from a constant, or an optional parameter — Glass.NET reads the five arguments, reassembles the System.Decimal, and emits the literal the author wrote: const decimal TaxRate = 0.075m;, decimal rate = 0.075m. The attribute itself is suppressed, exactly as the [Extension] attribute on an extension method or the [IsReadOnly] modreq on a readonly struct is suppressed — these are the compiler’s bookkeeping, not the source.
It is a small feature with an outsized failure mode. Get it wrong and every monetary constant, tax rate and threshold in a decompiled financial app comes back as new decimal(75, 0, 0, false, 3) or, worse, as an attribute the programmer never wrote — technically correct, humanly unreadable. Get it right and 0.075m reads back as 0.075m, which, for the one numeric type that has no native home in the metadata, is the whole job.
See how Glass handles the other values the compiler hides in metadata rather than IL in decompiling optional and named arguments and decompiling an array initializer.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.