# Decompiling params collections: where did my array go?


`params` is the oldest convenience in C#: write `string.Format("{0}+{1}", a, b)` and the compiler quietly builds the `object[]` that `Format` actually takes. For twenty years that desugaring had exactly one shape — a `newarr`, a run of `stelem`, and the call — and every decompiler learned to fold it back into loose arguments.

C# 13 generalised `params` to any collection type, and the interesting member of the new family, `params ReadOnlySpan<T>`, allocates **nothing on the heap**. The arguments go into a stack-allocated buffer and a span is wrapped around them. The method signature barely changes; the entire difference is at the **call site**. A decompiler that still only knows the `newarr` shape will show you a compiler-generated buffer type you can't name and a span constructor where the source had a plain call.

## The signature barely moves; the call site moves a lot

Consider a logging method migrated from the classic form to the span form:

```csharp
// Classic — unchanged since C# 1
public static void LogOld(params object[] args) { /* ... */ }

// C# 13 — one word of the type changes
public static void LogNew(params ReadOnlySpan<object> args) { /* ... */ }
```

Both are called identically: `LogNew(id, name, DateTime.Now)`. But the IL the **caller** emits is completely different. The classic form is the familiar heap array:

```
// LogOld(id, name, now)
ldc.i4.3
newarr     [System.Runtime]System.Object     // allocate object[3] on the heap
dup
ldc.i4.0
ldarg.0
stelem.ref                                     // args[0] = id
dup
ldc.i4.1
ldarg.1
stelem.ref                                     // args[1] = name
// ... args[2] = now ...
call       void Log::LogOld(object[])
```

The span form allocates no array. The compiler emits a **compiler-generated inline-array type** — a struct carrying `[InlineArray(3)]`, the same mechanism C# uses for [inline arrays and stackalloc](https://delta1labs.com/blog/decompile-inline-arrays-stackalloc) — as a local in the caller's frame, stores each argument into it through the inline-array intrinsics, builds a `ReadOnlySpan<object>` over the buffer, and passes that:

```
// LogNew(id, name, now)
.locals init ([0] valuetype '<>y__InlineArray3`1'<object> buffer)
ldloca.s   buffer
initobj    valuetype '<>y__InlineArray3`1'<object>
// element 0
ldloca.s   buffer
ldc.i4.0
call       !0& InlineArrayElementRef<'<>y__InlineArray3`1'<object>,object>(ref, int)
ldarg.0
stind.ref                                      // buffer[0] = id
// ... buffer[1] = name, buffer[2] = now via the same intrinsic ...
ldloca.s   buffer
ldc.i4.3
call       ReadOnlySpan<object> InlineArrayAsReadOnlySpan<...>(ref, int)
call       void Log::LogNew(valuetype ReadOnlySpan`1<object>)
```

No `newarr`, no heap object, no GC pressure. The buffer is a value type on the stack; it dies with the frame. Every bit of the difference is in the caller — the callee just received a span.

## The three shapes a decompiler must know

`params` now has a small family of lowerings, chosen by the parameter's collection type. A decompiler reads the **call site** to tell them apart and must fold all of them back to loose arguments.

<figure class="my-8"><svg viewBox="0 0 640 196" role="img" aria-label="Three params lowerings: params array emits newarr plus stelem; params ReadOnlySpan emits an inline-array buffer plus a span wrap with no heap allocation; params List or IEnumerable emits a collection builder call. All three decompile back to a call with loose arguments." style="width:100%;height:auto"><defs><style>.h{fill:#fee2e2;stroke:#f87171}.z{fill:#dcfce7;stroke:#4ade80}.c{fill:#fef3c7;stroke:#f59e0b}.o{fill:#e8eefc;stroke:#8eb4ff}.t{font:600 12px system-ui;fill:#1e293b}.s{font:11px system-ui;fill:#475569}.m{font:10px ui-monospace,monospace;fill:#334155}.a{stroke:#64748b;stroke-width:1.5;marker-end:url(#ah3)}</style><marker id="ah3" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="#64748b"/></marker></defs><rect class="h" x="10" y="20" width="190" height="52" rx="6"/><text class="t" x="22" y="40">params T[]</text><text class="m" x="22" y="58">newarr + stelem (heap)</text><rect class="z" x="10" y="82" width="190" height="52" rx="6"/><text class="t" x="22" y="102">params ReadOnlySpan&lt;T&gt;</text><text class="m" x="22" y="120">inline-array buffer + span</text><rect class="c" x="10" y="144" width="190" height="44" rx="6"/><text class="t" x="22" y="164">params List&lt;T&gt; / IEnumerable</text><text class="m" x="22" y="182">collection builder</text><rect class="o" x="420" y="78" width="200" height="54" rx="6"/><text class="t" x="438" y="100">decompiles to</text><text class="m" x="438" y="120">foo(a, b, c)</text><line class="a" x1="200" y1="44" x2="418" y2="98"/><line class="a" x1="200" y1="108" x2="418" y2="105"/><line class="a" x1="200" y1="166" x2="418" y2="112"/></svg></figure>

**`params T[]` — the classic path, unchanged.** Parameter type is an array, so the caller emits `newarr` + `stelem` + call. A decompiler folds it exactly as it always has. Nothing new here, and that's the point: old code keeps its old IL.

**`params ReadOnlySpan<T>` / `params Span<T>` — the zero-alloc path.** The `<>y__InlineArrayN` buffer, the element stores through `InlineArrayElementRef`, the `InlineArrayAsReadOnlySpan` call, then the target call. The recognition rule is: *a compiler-generated inline-array local, populated element-by-element and wrapped in a span that immediately feeds a `params` call, is loose arguments.* Render it as `LogNew(id, name, now)`, not as a visible buffer and a span constructor — the `<>y__InlineArray3` type can't be named in C# source, so emitting it literally produces code that won't recompile.

**`params List<T>` / `params IEnumerable<T>` — the collection-builder path.** For a non-span collection, the compiler reuses the [collection-expression](https://delta1labs.com/blog/decompile-collection-expressions-primary-constructors) machinery: it builds the collection (a `List<T>` via its add path, or a type's `[CollectionBuilder]` method) and passes the finished collection. The decompiler must recognise the builder sequence and fold it back to loose arguments too.

All three collapse to the same source — `foo(a, b, c)` — so the decompiler's job is to erase the mechanism and recover the call.

## The tell: a span fed by newarr

Here's the subtle case worth catching. Even a `params ReadOnlySpan<T>` parameter doesn't *always* get the stack buffer. The compiler stack-allocates only when it can prove the span won't escape; on the paths it can't, it falls back to a heap array and wraps the span around **that**. So you'll occasionally see:

```
ldc.i4.3
newarr     [System.Runtime]System.Object       // heap array, not an inline buffer
dup / stelem.ref ...                            // fill it
newobj     instance void ReadOnlySpan<object>::.ctor(!0[])   // span over the array
call       void Log::LogNew(valuetype ReadOnlySpan`1<object>)
```

A `newarr` feeding a `ReadOnlySpan` constructor, rather than a `<>y__InlineArray` buffer, is the fingerprint of the **fallback** path. It's still a correct `params` call and still folds to `LogNew(id, name, now)` in the decompiled source — but it tells you this particular call did allocate, which is exactly the thing the span form was chosen to avoid. When you're reading decompiled output to understand an allocation profile, that distinction is the whole story: same source line, different cost, and only the IL shows which one you got.

## What a good decompiler emits

Put the forms side by side and the obligation is clear. For the zero-alloc call, a decompiler that stops at the metal shows:

```csharp
// Wrong: literal, and won't recompile — <>y__InlineArray3 isn't nameable
<>y__InlineArray3<object> buffer = default;
InlineArrayElementRef<...>(ref buffer, 0) = id;
// ...
LogNew(InlineArrayAsReadOnlySpan<...>(ref buffer, 3));
```

while recovering the author's intent yields:

```csharp
// Right: the call the author actually wrote
LogNew(id, name, DateTime.Now);
```

[Glass.NET](https://delta1labs.com/) folds all three `params` shapes — the classic array, the inline-array span, and the collection-builder forms — back to a call with loose arguments, and keeps an *explicit* span or list argument explicit when the source genuinely passed one rather than loose values. The feature moved the interesting work from the method signature to the call site, so that's where a decompiler has to look. Recover the call, drop the buffer, and note the allocation tell when you need it.

See [inline arrays and stackalloc](https://delta1labs.com/blog/decompile-inline-arrays-stackalloc) for the buffer mechanism this reuses, and [collection expressions](https://delta1labs.com/blog/decompile-collection-expressions-primary-constructors) for the builder path the non-span forms share.

