# Decompiling function pointers: calli, delegate*, and where the calling convention hides


Function pointers are the one place in modern C# where the language hands you something the runtime has always had but almost never exposed: a call through a raw code address, with no object in sight. `delegate*<int, int>` looks like a delegate and is nothing like one. There is no `MulticastDelegate`, no `Invoke`, no allocation, no target object — just a native-sized integer holding an address and a single IL instruction, `calli`, that calls through it. That is exactly what makes it a hard decompilation problem: a decompiler reading a normal call follows a token to a named method; reading a `calli`, it has no token to follow, only a *standalone signature* it must decode to recover the types and the calling convention.

And the calling convention is the sharpest part. `delegate* unmanaged[Cdecl]<int, int>` has to survive compilation, but there is no "Cdecl" keyword in the signature — the convention is smuggled in as `modopt` types welded onto the return type. A decompiler that does not know to look for them shows you an `unmanaged` pointer with no idea whether it is Cdecl or Stdcall, which is the difference between code that runs and code that corrupts the stack. This is a working tour of how a function pointer is laid down, what `calli` names, where the convention actually lives, and how [Glass.NET](https://delta1labs.com/products/glass) rebuilds the `delegate*` you wrote.

## The source

Three function pointers: a managed one, an unmanaged Cdecl one, and a call site that invokes through a managed pointer loaded from a static method.

```csharp
public static unsafe class Native
{
    // A managed function pointer: no object, no Invoke.
    public static int Apply(delegate*<int, int> op, int x) => op(x);

    // An unmanaged Cdecl function pointer — the kind you hand to P/Invoke-free interop.
    public static double CallCdecl(delegate* unmanaged[Cdecl]<double, double> fn, double v)
        => fn(v);

    // Load the address of a real method into a managed pointer and call through it.
    public static int Double(int n) => n * 2;
    public static int Demo()
    {
        delegate*<int, int> p = &Double;     // ldftn Double
        return Apply(p, 21);                  // -> 42
    }
}
```

## What `calli` actually is

Compile `Apply` and look at the body. The call to `op(x)` is not a `call` or `callvirt` — those take a method token naming a target. It is `calli`, which takes a **StandAloneSig** token: a signature that belongs to no method and no type, describing the call itself.

```
.method public hidebysig static int32 Apply(method int32 *(int32) op, int32 x) cil managed
{
    ldarg.1                       // x  -> the argument
    ldarg.0                       // op -> the code ADDRESS, pushed last (top of stack)
    calli      int32(int32)       // call through the address, per this standalone signature
    ret
}
```

Read `calli int32(int32)` as: "call through the address on top of the evaluation stack, treating the value beneath it as an `int32` argument and the result as `int32`." The parameter type in the IL method signature, `method int32 *(int32)`, is how a function pointer parameter is spelled in metadata — `method <ret> *(<args>)`. There is no `MethodDef` or `MemberRef` anywhere in this call: the only description of what is being invoked is that standalone signature. That is the whole challenge — and the whole key — to decompiling it.

<figure class="my-8"><svg viewBox="0 0 680 190" role="img" aria-label="A normal call carries a method token that names a target method the decompiler can follow. A calli instruction carries no target token at all; it takes a StandAloneSig token describing only the convention, return type, and parameter types, and calls through the code address on top of the evaluation stack. The decompiler must decode that standalone signature to recover the function pointer's shape." 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 10.5px ui-monospace,monospace;fill:#0f172a}.norm{fill:#eef4ff;stroke:#8eb4ff}.cal{fill:#e6fffb;stroke:#2dd4bf}.sig{fill:#fef9c3;stroke:#fde047}.ar{stroke:#2dd4bf;stroke-width:2;fill:none}.arn{stroke:#8eb4ff;stroke-width:2;fill:none}</style><marker id="f1" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#2dd4bf"/></marker><marker id="f2" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#8eb4ff"/></marker></defs><rect class="norm" x="12" y="24" width="300" height="60" rx="6"/><text class="t" x="24" y="44">call / callvirt</text><text class="m" x="24" y="64">token → MethodDef "Double"</text><text class="x" x="24" y="80">decompiler follows the token to a name</text><path class="arn" d="M312 54 h40" marker-end="url(#f2)"/><text class="x" x="356" y="50">target known statically</text><rect class="cal" x="12" y="108" width="300" height="68" rx="6"/><text class="t" x="24" y="128">calli</text><text class="m" x="24" y="148">StandAloneSig token (no target!)</text><text class="x" x="24" y="166">address is on the stack at run time</text><path class="ar" d="M312 142 h40" marker-end="url(#f1)"/><rect class="sig" x="356" y="108" width="312" height="68" rx="6"/><text class="t" x="368" y="128">decode the standalone signature</text><text class="m" x="368" y="148">convention · int32 · (int32)</text><text class="x" x="368" y="166">→ delegate*&lt;int,int&gt; shape recovered</text></svg></figure>

## Where the calling convention hides

The managed case is the easy one — the signature's leading calling-convention byte says `default` (managed) and the shape is `int32(int32)`. The unmanaged case is where decompilers earn their keep. Look at `CallCdecl`:

```
.method public hidebysig static float64 CallCdecl(
    method unmanaged cdecl float64 modopt([System.Runtime]System.Runtime.CompilerServices.CallConvCdecl) *(float64) fn,
    float64 v) cil managed
{
    ldarg.1
    ldarg.0
    calli      unmanaged cdecl float64 modopt(...CallConvCdecl) (float64)
    ret
}
```

Two things encode the convention, and only one of them is a real flag. The signature carries an `unmanaged` calling-convention bit — but that only says "this is an unmanaged call," not *which* ABI. The specific convention is the **`modopt`** — an optional modifier — naming a synthetic framework type: `System.Runtime.CompilerServices.CallConvCdecl`. There is a `CallConv*` type for each convention: `CallConvCdecl`, `CallConvStdcall`, `CallConvThiscall`, `CallConvFastcall`, and composable markers like `CallConvSuppressGCTransition`. The C# `unmanaged[Cdecl]` compiles to the `unmanaged` bit plus a `CallConvCdecl` modopt on the return type; `unmanaged[Cdecl, SuppressGCTransition]` compiles to *two* modopts. To recover the source, a decompiler must collect every `CallConv*` modopt on the signature's return type and map each back to its short name — there is no single field that just says "Cdecl."

## What a naive decompiler shows

A decompiler that handles `calli` but ignores the `modopt` chain gets the shape right and the convention wrong — or drops the function-pointer syntax entirely and shows the raw primitive, an `IntPtr`-like call it cannot name:

```csharp
// Naive output: shape present, convention lost (or shown as bare 'unmanaged').
public unsafe static double CallCdecl(delegate* unmanaged<double, double> fn, double v)
    => fn(v);    // Cdecl dropped — this is now ambiguous and, if re-emitted, wrong
```

Dropping `[Cdecl]` is not cosmetic. The calling convention determines who cleans the stack and how arguments are passed; a `delegate* unmanaged<...>` with the wrong (or missing) convention re-compiled against a native library that expects Cdecl will mismatch the ABI and corrupt the stack at the call. The convention is load-bearing, and it lives only in those modopts.

## What Glass reconstructs

Glass is built on [ICSharpCode.Decompiler](https://delta1labs.com/products/glass), the ILSpy engine, which decodes the `calli` standalone signature and the `CallConv*` modopt chain and rebuilds the function-pointer types exactly:

```csharp
// Glass output: shape and convention both recovered.
public unsafe static int Apply(delegate*<int, int> op, int x) => op(x);

public unsafe static double CallCdecl(delegate* unmanaged[Cdecl]<double, double> fn, double v)
    => fn(v);

public unsafe static int Demo()
{
    delegate*<int, int> p = &Double;    // ldftn recovered as the address-of operator
    return Apply(p, 21);
}
```

Three recoveries are worth naming. The **managed** pointer comes back as `delegate*<int, int>` from the default-convention standalone signature. The **unmanaged** pointer comes back as `delegate* unmanaged[Cdecl]<double, double>` because Glass read the `CallConvCdecl` modopt, not just the `unmanaged` bit. And in `Demo`, the `ldftn Double` that loaded the method's address is reconstructed as the C# address-of operator `&Double` — the one case where the decompiler *can* name what a function pointer points at, because the target was taken statically right there in the IL.

## What a function pointer will never tell you

That last point is also the limit. The standalone signature is static metadata, so Glass always recovers the *shape* and *convention* of a function pointer. But the **value** — which method the pointer actually holds — is runtime data. When it is loaded by a nearby `ldftn`, as in `Demo`, the decompiler can follow it and show `&Double`. When the pointer arrives as a parameter (as in `Apply` and `CallCdecl`) or is handed in from native code, its destination is whatever address flowed in at run time, and no amount of static analysis can name it — exactly as a C function pointer's target is unknowable until the program runs.

- **The signature always decompiles.** Parameter types, return type, and calling convention are baked into the `calli` site; Glass recovers them every time.
- **The target decompiles only when it is loaded locally.** A `ldftn` in view becomes `&Method`; a pointer passed in stays an address whose destination is a runtime fact.
- **Drop to IL to confirm the convention.** When an unmanaged pointer's ABI matters, the `modopt` chain on the standalone signature is the ground truth — read it in the IL view and you see every `CallConv*` the compiler emitted.

Read the C# to get the `delegate*` types back with their conventions intact; drop to the IL to watch `calli` call through an address that no token names — the one call in .NET where the decompiler can tell you the shape of the thing being called and, in general, not what it is.

