# 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:

```csharp
[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.

<figure class="my-8"><svg viewBox="0 0 720 220" role="img" aria-label="Three layers mapping source to IL to decompiler output for an inline array access. Top: the source buf index 3. Middle: the IL, which is a struct with InlineArray(8) attribute over one int field, and an access that calls InlineArrayElementRef returning a byref then ldind.i4. Bottom left: a naive decompiler shows the raw intrinsic call and dereference, marked does not recompile. Bottom right: Glass folds it back to buf index 3 over the Buffer8 declaration, marked recompiles." style="width:100%;height:auto"><defs><style>.t{font:600 12px system-ui;fill:#1547e0}.x{font:11px system-ui;fill:#475569}.m{font:600 10px ui-monospace,monospace;fill:#0f172a}.src{fill:#eef4ff;stroke:#8eb4ff}.il{fill:#f1f5f9;stroke:#cbd5e1}.bad{fill:#fee2e2;stroke:#fca5a5}.good{fill:#dcfce7;stroke:#86efac}.ar{stroke:#64748b;stroke-width:1.8;fill:none}</style><marker id="g" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#64748b"/></marker></defs><rect class="src" x="250" y="12" width="220" height="30" rx="5"/><text class="t" x="262" y="26">source</text><text class="m" x="330" y="26">buf[3]</text><rect class="il" x="150" y="64" width="420" height="58" rx="5"/><text class="t" x="162" y="82">IL</text><text class="m" x="162" y="100">[InlineArray(8)] struct { int _element0 }</text><text class="m" x="162" y="116">call InlineArrayElementRef(ref,3) → ldind.i4</text><path class="ar" d="M360 42 v22" marker-end="url(#g)"/><path class="ar" d="M300 122 v16 h-120 v16" marker-end="url(#g)"/><path class="ar" d="M420 122 v16 h120 v16" marker-end="url(#g)"/><rect class="bad" x="30" y="154" width="300" height="52" rx="5"/><text class="t" x="42" y="172" fill="#dc2626">naive decompiler</text><text class="m" x="42" y="190">InlineArrayElementRef(ref buf,3)</text><text class="x" x="42" y="202" fill="#dc2626">references &lt;PrivateImplementationDetails&gt; — won't build</text><rect class="good" x="388" y="154" width="300" height="52" rx="5"/><text class="t" x="400" y="172" fill="#15803d">Glass.NET</text><text class="m" x="400" y="190">buf[3]   // over Buffer8</text><text class="x" x="400" y="202" fill="#15803d">recovers the indexer — recompiles</text></svg></figure>

## stackalloc is a localloc wearing a Span

Now the second feature. Modern `stackalloc` assigned to a `Span<T>` looks completely safe in source:

```csharp
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.

