Decompiling C# 12 inline arrays and stackalloc: fixed buffers without the unsafe
C# 12 added inline arrays, and they are easy to misread even in source: a fixed-size buffer that lives _inside_ its containing struct, value-type all the way down, no heap allocation, no array object. It is the managed, safe answer to the C programmer's `int buf[8]` — the thing you previously could only get with an `unsafe` fixed-size buffer. Paired with `stackalloc`, which has been quietly modernised to hand you a `Span<T>` instead of a raw pointer, you can now write buffer-heavy, allocation-free code without a single `unsafe` keyword.
That safety is a source-level illusion built by the compiler, and the illusion is exactly what a decompiler has to reconstruct. Underneath, an inline array is not an array — it is a one-field struct with an attribute that tells the _runtime_ to lay out N copies of the field. Element access is not `ldelem` — it is a call to a runtime intrinsic that returns a managed reference. And `stackalloc` is not safe at all underneath — it is a `localloc`, the rawest stack allocation the CLR has, wrapped in a `Span` constructor so the pointer never reaches your eyes. A decompiler that lowers to the metal and stops shows you ref arithmetic, helper calls with names you cannot type, and `unsafe` blocks that were never in the source. The code it emits often will not even recompile.
This post walks the IL for both features and shows what a decompiler must recognise to put the source back together.
## An inline array is a struct wearing an attribute
Here is the declaration and a trivial use:
[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer8
{
private int _element0;
}
public static int Sum(Buffer8 buf)
{
int total = 0;
for (int i = 0; i < 8; i++)
total += buf[i];
return total;
}
`Buffer8` has one field, `_element0`, and one attribute, `[InlineArray(8)]`. That is the whole trick: the attribute instructs the runtime to allocate the field eight times contiguously, so a `Buffer8` is 32 bytes and `buf[3]` means "the fourth `int` in that block." There is no array object and no length word — the length is baked into the attribute, known at compile time, and never stored at runtime.
In metadata the type is a plain `sealed value type` with a single field. Nothing in the type definition says "array"; the only signal is the custom attribute:
.class public sequential ansi sealed beforefieldinit Buffer8
extends [System.Runtime]System.ValueType
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.InlineArrayAttribute::.ctor(int32)
= ( 01 00 08 00 00 00 00 00 ) // InlineArray(8)
.field private int32 _element0
}
A decompiler that classifies types by their shape sees a one-field struct and, unless it reads that attribute, prints it as a one-field struct — losing the entire meaning. So the first recognition rule is: **a value type carrying`[InlineArray(N)]` is an inline array of its single field's type, length N**, and it must be rendered with the attribute and the single backing field exactly as the compiler emitted them.
## Indexing lowers to a runtime intrinsic, not ldelem
Now the access. `buf[i]` compiles not to an array-load but to a call that produces a managed reference to element `i`. The compiler routes it through a generated helper in `<PrivateImplementationDetails>` that wraps `RuntimeHelpers.InlineArrayElementRef` (or builds a `Span<T>` over the buffer with `InlineArrayAsSpan` and indexes that, depending on context). The read inside the loop lowers roughly to:
// total += buf[i];
ldarga.s buf
ldloc i
call !!1& [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InlineArrayElementRef<valuetype Buffer8, int32>(!!0&, int32)
ldind.i4
add
The `InlineArrayElementRef` call takes a reference to the buffer and an index, and returns a _byref_ to the element; `ldind.i4` then dereferences it to load the `int`. For a write you would see `stind.i4` against the same kind of ref. There is no `ldelem`, no bounds-check opcode pattern, nothing that looks like an array — just an intrinsic call returning a `ref`.
A naive decompiler renders that literally: a call to `RuntimeHelpers.InlineArrayElementRef<Buffer8, int>(ref buf, i)` followed by a dereference, or worse, a reference to a `<PrivateImplementationDetails>` helper whose name contains characters you cannot write in C#. Either way the output does not say `buf[i]` and does not recompile. The recognition rule is: **a call to the inline-array element-ref intrinsic (or the generated helper around it) against an`[InlineArray]` type, followed by a load or store through the returned ref, is an indexer access — render it as `buf[i]`.** The same applies to the `InlineArrayAsSpan` form, which a decompiler should fold back either into element access or into a `Span<T>` view of the buffer, depending on how the result is used.
## stackalloc is a localloc wearing a Span
Now the second feature. Modern `stackalloc` assigned to a `Span<T>` looks completely safe in source:
public static int SumFirstFour(ReadOnlySpan<int> input)
{
Span<int> scratch = stackalloc int[4];
for (int i = 0; i < 4; i++)
scratch[i] = input[i] * input[i];
int total = 0;
foreach (int v in scratch) total += v;
return total;
}
No `unsafe`, no pointer. But `stackalloc` has exactly one lowering: the `localloc` instruction, which allocates a block on the current method's stack frame and pushes a native pointer to it. The compiler sizes the block (`4 * sizeof(int)`), allocates, then constructs a `Span<int>` over the raw pointer and the element count using the `Span<T>(void*, int)` constructor:
// Span<int> scratch = stackalloc int[4];
ldc.i4.4
conv.u
ldc.i4.4
mul.ovf.un // 4 elements * 4 bytes
localloc // -> native int (pointer to the stack block)
ldc.i4.4
newobj instance void valuetype [System.Runtime]System.Span`1<int32>::.ctor(void*, int32)
stloc.0 // scratch
That is the signature: an element-count `ldc`, a size multiply (`conv.u` / `mul.ovf.un`), a `localloc`, then a `newobj` on `Span<T>::.ctor(void*, int32)`. A decompiler that renders those instructions literally produces an `unsafe` method with a `void*`, a multiplication, a `localloc` intrinsic (which C# cannot even express directly), and a `Span` constructor taking a pointer. None of that is in the source, and the `void*` forces an `unsafe` context the author deliberately avoided.
The recognition rule folds the whole shape into one expression: **a`localloc` whose size is `count * sizeof(T)`, consumed by `Span<T>` or `ReadOnlySpan<T>`'s `(void*, int)` constructor, is `stackalloc T[count]`.** When the element type's size is a compile-time constant and the count is too, Glass reconstructs `stackalloc int[4]`; when the count is a variable, it reconstructs `stackalloc int[n]`. The raw pointer, the multiply, and the constructor all disappear back into the single `stackalloc` the author typed, and the method stays safe — no `unsafe` keyword, because the source had none.
There is a subtlety worth naming: not every `stackalloc` becomes a `Span`. The older form, `int* p = stackalloc int[4];`, assigns the pointer directly and _is_ genuinely unsafe — there the `void*` and `unsafe` belong in the output, because they were in the source. The decompiler distinguishes the two by what consumes the `localloc`: a `Span`/`ReadOnlySpan` constructor means the safe form, a direct pointer assignment means the unsafe form. Getting that distinction right is the difference between faithfully reproducing a safe method and falsely accusing it of being unsafe.
## Why faithful recovery matters here specifically
For most language features, a decompiler that gets the details slightly wrong produces code that is ugly but still builds. These two are different, because both features exist precisely to give you low-level performance _while staying in safe, high-level C#_ — and the incorrect decompilation destroys exactly that property.
An inline array rendered as `InlineArrayElementRef` calls references helpers in `<PrivateImplementationDetails>`, a compiler-internal type whose members have names containing `<` and `>` that are illegal in C# source. That output cannot be pasted back and compiled; it is a description of the IL, not a reconstruction of the program. A `stackalloc` rendered as `localloc` plus a `void*` turns a safe method into one that requires `unsafe`, misrepresenting the method's safety contract and, again, often failing to compile without edits the author never made.
Glass reads both patterns at the level the author worked at. It classifies `[InlineArray(N)]` structs as inline arrays and renders their element access as indexers; it recognises the `localloc`-plus-`Span`-constructor shape as `stackalloc` and keeps the method safe, while still distinguishing the genuinely unsafe pointer form. The output reads like the source — `buf[i]` and `stackalloc int[4]` — and, just as importantly, recompiles like the source, which is the real test of whether a decompiler understood the program or merely transcribed its bytes.
## Summary
Inline arrays and modern `stackalloc` are two of the clearest cases where the IL is dramatically lower-level than the C#. An inline array is a one-field struct whose `[InlineArray(N)]` attribute is the only evidence it is a buffer, indexed through runtime intrinsics that return byrefs rather than any array opcode. A `Span`-typed `stackalloc` is a raw `localloc` dressed in a `Span<T>(void*, int)` constructor so the pointer never shows. A decompiler earns its keep by recovering the author's intent: the inline-array declaration and its indexers, the single `stackalloc` expression, and the safe, `unsafe`-free method body that made these features worth using in the first place.