A ref field is the first C# feature that lets a field hold a reference instead of a value, and it is the first that forced a change to the runtime's own rules about what a field signature may contain. Before .NET 7 a field could not be a managed pointer; the runtime rejected the metadata. ref struct types like Span<T> faked it with an internal intrinsic. C# 11 and .NET 7 made it real — and in doing so created metadata that decompilers written for older IL simply never had to parse.
The trouble is that most of what makes a ref field safe is not in executable IL at all. A ref field can point at a local that goes out of scope; the rules that stop it — ref-escape analysis — run entirely in the compiler and leave their footprints in metadata the JIT ignores: a modreq here, a ScopedRefAttribute there, an [UnscopedRef] on the one method that is allowed to break the default. A decompiler that reads only the instructions produces code that runs identically and expresses a different safety contract — and you discover the difference only when you recompile it and the compiler allows an escape the author forbade, or forbids a return the author intended.
This post reads the raw metadata for three things — a ref field, a ref struct that stores one, and a scoped parameter — and shows where the safety lives and how Glass.NET turns it back into the right keywords.
A ref field is a BYREF in a FIELD signature
Start with the smallest ref struct that holds a ref field and read what the compiler emits.
public ref struct IntRef
{
public ref int Value; // a ref field
public IntRef(ref int target) => Value = ref target; // ref assignment
}
The field declaration compiles to a field whose signature is not the usual "FIELD then element type." It is FIELD, then ELEMENT_TYPE_BYREF, then the element type:
.field public int32& Value
// ^^^^^^ the '&' is ELEMENT_TYPE_BYREF — a managed reference, in a FIELD sig
// signature bytes: 06 10 08
// 06 = FIELD
// 10 = ELEMENT_TYPE_BYREF
// 08 = ELEMENT_TYPE_I4 (int)
That 10 byte — a BYREF inside a field signature — is the whole feature at the metadata level, and it is exactly what a pre-.NET 7 runtime refused to load. An older decompiler that walks a field signature expecting FIELD <type> reads the 10 as the element type of a malformed signature, or skips it, and renders the field as plain int32 Value. The resulting source declares a value field, not a ref field — it drops the one property that makes IntRef worth writing, and it will not compile inside a ref struct as the same type.
The ref assignment in the constructor is its own instruction, distinct from a value store:
// Value = ref target;
ldarg.0 // this
ldarg.1 // ref target (already a managed pointer)
stfld int32& IntRef::Value // store the REFERENCE into the ref field
The stfld targets a field whose type is int32&. Contrast the value version, this.Value = target, which would ldind.i4 to dereference target first and then stfld int32 into a value field. Glass reads the field's byref signature, recognizes the stfld into a byref field as a ref-assignment, and reconstructs Value = ref target rather than Value = target — two statements that differ by one keyword and mean entirely different things about lifetime.
The ref struct markers around it
IntRef itself carries the metadata that says "this is a ref struct": it is a value type marked with two attributes, and a decompiler needs both to regenerate the ref struct modifier and the obsolete-guard that keeps old compilers away.
.class public sequential ansi sealed beforefieldinit IntRef
extends [System.Runtime]System.ValueType
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsByRefLikeAttribute::.ctor()
.custom instance void [System.Runtime]System.ObsoleteAttribute::.ctor(string, bool)
= {string('Types with embedded references...') bool(true)}
}
IsByRefLikeAttribute is the real marker — it is what ref struct compiles to, and it is what tells the compiler the type may not be boxed, may not be a field of a non-ref struct, may not be captured, and so on. The [Obsolete(..., error: true)] with that specific message is a guard the compiler emits so that a compiler too old to understand byref-like types refuses to touch it instead of silently mishandling it — the same fail-closed pattern you see with required members. Glass maps IsByRefLikeAttribute back to the ref in ref struct and recognizes the paired synthetic [Obsolete] as compiler-generated, so it does not surface the attribute as if the author wrote it — but it does preserve the ref struct keyword that produced it.
scoped: a contract with no instructions
Now the part no amount of reading IL will recover, because there is no IL to read. scoped constrains where a ref may travel, and that constraint is checked only at compile time.
public static ref int Risky(scoped ref int x, ref int y)
{
// return ref x; // compile ERROR: x is scoped, may not escape
return ref y; // ok: y is not scoped
}
scoped on the parameter x compiles to a marker attribute on that parameter and changes not one byte of the method body:
.method public static int32& Risky(
int32& x,
int32& y) cil managed
{
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.ScopedRefAttribute::.ctor()
// ^ parameter 1 (x) carries [ScopedRef]; parameter 2 (y) does not
...
}
Both x and y are int32& in the signature — identical byref parameters as far as the instructions are concerned. The only thing that distinguishes scoped ref from plain ref is the ScopedRefAttribute on .param [1]. The JIT ignores it entirely; the method runs the same whether or not it is there. But to the C# compiler it is a promise that x will not escape, which is what lets callers pass a reference to a short-lived local as x safely.
A decompiler that lists the parameters from the signature alone sees two ref int parameters and emits Risky(ref int x, ref int y). That compiles, and it runs identically — but it has quietly widened the contract: the regenerated method now permits return ref x, an escape the original explicitly forbade. Nobody notices until someone edits the decompiled source, adds a return ref x, and the compiler that should have rejected it accepts it, because the scoped that would have stopped it is gone. Glass reads .param custom attributes alongside the signature and restores scoped on exactly the parameters that carry ScopedRefAttribute.
ref, in, and ref readonly share one BYREF
The same "read the modifier, not just the byref" discipline separates the three read-oriented byref kinds, which are indistinguishable in the raw signature.
void A(ref int x); // BYREF, no modifier
void B(in int x); // BYREF + modreq(InAttribute), param has [IsReadOnly]
void C(ref readonly int x); // BYREF + IsReadOnly signal, treated as ref-readonly (C# 12)
All three parameters are int32&. in adds modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) to the signature and marks the parameter [IsReadOnly]; ref readonly carries the read-only signal without the in-specific call-site leniency, so the compiler can warn when an argument is passed by a form that in would have accepted silently. A decompiler that reads only the BYREF collapses all three into ref, which changes the overload's signature and the rules at every call site. Glass inspects the modreq and the [IsReadOnly] attribute together and picks ref, in, or ref readonly accordingly.
[UnscopedRef]: the escape hatch that must survive
scoped tightens; [UnscopedRef] loosens — and dropping it breaks compilation rather than weakening a contract silently, which makes it the one of these a decompiler absolutely cannot afford to miss.
Some refs are implicitly scoped. The this of a struct instance method is one: by default you may not return a ref to a field of the struct, because the struct might be a temporary. A struct that needs to hand out a ref into itself marks the method [UnscopedRef]:
public struct Buffer
{
private int _first;
[UnscopedRef] // without this, the return does not compile
public ref int First() => ref _first;
}
In metadata the method carries UnscopedRefAttribute:
.method public hidebysig instance int32& First() cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.UnscopedRefAttribute::.ctor()
// body: ldarg.0; ldflda int32 Buffer::_first; ret
}
If a decompiler drops this attribute, the regenerated ref int First() => ref _first; is rejected by the compiler — the default scoping on this forbids returning a ref to _first. So unlike scoped, where the loss is a silent widening, the loss of [UnscopedRef] is a loud compile error the moment you try to rebuild. Either way the decompiled source no longer matches the author's program. Glass surfaces [UnscopedRef] exactly where it appears, because here the attribute is not decoration — it is the thing that makes the method legal, and the regenerated type does not round-trip without it.
Reading all of it together
The through-line across ref fields, scoped, in/ref readonly, and [UnscopedRef] is that C#'s ref-safety model lives in three places the JIT does not care about: the BYREF in a signature (the ref itself), the modreq next to it (in vs plain ref), and the attributes around it (ScopedRefAttribute, IsReadOnlyAttribute, UnscopedRefAttribute). A decompiler that reads only the executable instructions gets code that runs and a safety contract that is subtly or completely wrong — reassignable where the original was once-only, escapable where the original was scoped, or uncompilable where the original was fine. Glass.NET reads the signature, the custom modifiers and the parameter and method attributes as one unit, which is what it takes to hand you ref struct, ref int, scoped ref, ref readonly and [UnscopedRef] back as the author wrote them — source that both runs the same and carries the same ref-safety guarantees.
Top comments (0)