Decompiling ref readonly parameters: one custom attribute away from in
ref readonly and in compile to byte-identical parameter metadata — same [in] flag, same modreq(InAttribute). The only difference is one custom attribute. Here is what Glass.NET reads.
ref readonly parameters, added in C# 12, look like they should be a big deal in metadata. They are a new direction for passing arguments, a new rule about what you are allowed to pass, and a fourth member of the by-reference family alongside ref, out and in. So it is a little surprising that in the compiled assembly a ref readonly parameter is byte-for-byte identical to an in parameter except for a single custom attribute. That one attribute is the entire job a decompiler has to do here, and it is also exactly the piece a decompiler that only reads signatures will miss — quietly turning every ref readonly back into in. This post walks the metadata for both and shows what Glass.NET reconstructs.
Two keywords, one binary shape
Start with two methods that are deliberately the same except for the parameter direction:
public static class Geometry
{
public static double LengthIn(in Vector3 v) =>
Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
public static double LengthReq(ref readonly Vector3 v) =>
Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
}
in has been around since C# 7.2; ref readonly is the newer, stricter cousin. The practical difference in source is about what the caller may pass. An in parameter accepts anything silently — a variable, a field, even a literal — and the compiler conjures a hidden temporary for the cases that are not already a location. A ref readonly parameter wants an actual location and warns when it does not get one, which is the point: it is for APIs that take a reference to avoid copying a large struct, where passing a throwaway temporary is almost always a mistake the author wants flagged. That is a compile-time rule, enforced by the compiler and reported as a warning, and — crucially — it leaves no trace in the parameter’s shape at all.
Reading the parameter metadata
Here is where the two methods stop looking different. Decompile LengthIn to IL and the parameter is a managed reference with a directional flag and a required modifier:
.method public hidebysig static float64 LengthIn (
[in] valuetype Vector3& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) v
) cil managed
{
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
// ...
}
Three things encode the in. The [in] flag is the parameter’s direction (ParamAttributes.In). The modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) is a required custom modifier welded onto the Vector3& type in the signature — modreq, not modopt, so it is part of the signature and a consumer that does not understand it must refuse to bind. And the .param [1] block carries a System.Runtime.CompilerServices.IsReadOnlyAttribute, the marker that says “this reference is read-only.”
Now LengthReq, the ref readonly version:
.method public hidebysig static float64 LengthReq (
[in] valuetype Vector3& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute) v
) cil managed
{
.param [1]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.RequiresLocationAttribute::.ctor() = ( 01 00 00 00 )
// ...
}
Read the two signatures side by side. The return type is the same, the [in] flag is the same, and the modreq(InAttribute) is the same required modifier. The only difference in the entire method is the name of the custom attribute in the .param block: IsReadOnlyAttribute for in, RequiresLocationAttribute for ref readonly. That is the whole distinction, and it is a plain custom attribute — not a modifier, not a flag, not anything that participates in signature matching.
Why the shape is shared on purpose
This is not an accident of implementation; it is the feature’s defining constraint. The language team wanted two things at once. First, changing an existing in parameter in a published library to ref readonly must not break any caller that was compiled against the old signature — it is meant to be a safe, source- and binary-compatible tightening. Second, a compiler that does understand ref readonly should enforce the stricter “pass a location” rule.
A required modifier cannot deliver the first goal. The whole meaning of modreq is “refuse to bind if you do not understand this,” so a dedicated modreq(RequiresLocation) would make every pre-C#-12 caller fail to compile — the exact opposite of compatible. So ref readonly reuses the existing in binary encoding, [in] plus modreq(InAttribute), which old compilers already treat as an ordinary in parameter, and layers the new meaning on through a custom attribute — RequiresLocationAttribute — which old compilers simply ignore the way they ignore any attribute they do not recognise. The modreq is for compatibility; the attribute is for the new semantics. That division is why the correct keyword can only be recovered from the attribute.
Where RequiresLocationAttribute comes from
If you go looking for System.Runtime.CompilerServices.RequiresLocationAttribute and it is not in the framework you targeted, that is expected — the compiler generates it. When the target framework does not define the type, Roslyn embeds a private copy directly into the assembly being built, marked Microsoft.CodeAnalysis.EmbeddedAttribute and CompilerGenerated, exactly as it does for IsReadOnlyAttribute, IsExternalInit and the nullable-context attributes when they are absent. Glass.NET recognises embedded attribute types for what they are — compiler plumbing — and uses RequiresLocationAttribute to reconstruct ref readonly without surfacing the embedded type itself as source you wrote.
The call site is identical, too
It is tempting to hope the call disambiguates the parameter, but it does not. Both in and ref readonly are passed by read-only reference, so the compiler takes an address and calls:
// Geometry.LengthReq(in v); — v is a local
ldloca.s v
call float64 Geometry::LengthReq(valuetype Vector3& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute))
That is the same ldloca + call an in call produces, and the parameter type in the call token is the same Vector3& modreq(InAttribute). If you pass an rvalue to the ref readonly method — which the compiler accepts with a warning such as CS9193 (“argument should be a variable”) — it simply spills the value into a read-only temporary first and takes its address; still an address, still the same call shape. Nothing at the call site records whether the callee declared in or ref readonly. So Glass.NET renders each argument’s keyword from the parameter it binds to in the callee’s definition, not from anything stamped on the call.
What a signature-only decompiler gets wrong
A decompiler that reconstructs parameters from the signature alone — the [in] flag and the modreq(InAttribute) — has no way to tell the two apart, because those are identical. The honest output of that approach is to render every such parameter as in, which is wrong for every ref readonly in the assembly. It is a quiet kind of wrong: the code still compiles and still runs, so nothing flags it, but the recovered API now claims a looser contract than the author wrote, and anyone reading it to understand the library’s intent — why does this method take a reference, and does it mind a temporary? — is misled.
Glass.NET reads the .param custom attributes and resolves the direction from there: RequiresLocationAttribute yields ref readonly, IsReadOnlyAttribute yields in, and a bare modreq(InAttribute) with neither attribute — which is what hand-written IL or another language can produce — falls back to in, the shape’s native meaning. The result is the source you started with:
public static double LengthIn(in Vector3 v) => /* ... */;
public static double LengthReq(ref readonly Vector3 v) => /* ... */;
You can confirm it both ways in Glass: the decompiled C# view shows ref readonly on the parameter, and the IL view next to it shows the RequiresLocationAttribute in the .param [1] block that produced it, with the shared modreq(InAttribute) right there on the signature so you can see that the keyword really did come from the attribute and nowhere else.
The takeaway
ref readonly is one of those features where the interesting metadata story is how little metadata there is. The parameter is encoded as an ordinary in — [in] flag, modreq(System.Runtime.InteropServices.InAttribute) — precisely so that older compilers keep binding to it, and the entire distinction rides on a single custom attribute, RequiresLocationAttribute, that the compiler often has to embed into the assembly itself. Read the attribute and you recover ref readonly; read only the signature and you get in for everything, correct-looking and subtly wrong. Glass.NET reads the attribute, falls back to in only when there is genuinely nothing else to go on, and shows you the IL alongside so you can see which half of the metadata the keyword came from.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.