Skip to content
← All posts
· Delta1 Labs DecompilationGlass

Decompiling explicit struct layout and C-style unions: reconstructing [StructLayout] and [FieldOffset] from metadata

Explicit struct layout is one of the few C# features a decompiler cannot recover from code shapes, because it leaves no code — it lives entirely in metadata. The LayoutKind (Sequential/Explicit/Auto) is a flag on the type definition, Pack and Size sit in the ClassLayout table, and every field's byte offset sits in the FieldLayout table. When two fields share an offset you have a C-style union: aliased bytes, one int viewing the same four bytes as a float. Get a single offset wrong and the decompiled struct silently corrupts every interop and marshalling call that touches it. This post shows where the layout data lives, how Glass.NET reads the ClassLayout and FieldLayout tables and re-emits [StructLayout(LayoutKind.Explicit, Pack = ..., Size = ...)] on the type and [FieldOffset(n)] on each field, how it recognizes a union from overlapping offsets, and why preserving exact offsets is non-negotiable.

Most C# language features a decompiler recovers are compiler sugar: the compiler lowered a foreach to a try/finally around an enumerator, or a lock to Monitor.Enter/Exit, and the decompiler recognises the shape of the emitted code and folds it back. Explicit struct layout is not like that. There is no code to recognise, because layout is not executable. A [StructLayout(LayoutKind.Explicit)] struct with [FieldOffset] on each field compiles to metadata — rows in two side tables and a couple of flag bits — and nothing in any method body mentions it. A decompiler that only reconstructs IL into statements will emit a perfectly plausible struct that is laid out completely wrong.

That matters more here than almost anywhere else, because explicit layout exists to honour a memory contract that something outside your assembly depends on: a native struct marshalled across P/Invoke, a device register block, a packet header, a tagged union overlay. The offsets are the interface. This post walks exactly where layout lives in metadata, how Glass reads it back, and why a single dropped offset is one of the most dangerous mistakes a decompiler can make.

A union struct in source

Here is the canonical case — a value that can be viewed as an int, a float, or four individual bytes, all sharing the same storage:

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Explicit, Size = 4)]
public struct Register32
{
    [FieldOffset(0)] public int AsInt;
    [FieldOffset(0)] public float AsFloat;
    [FieldOffset(0)] public byte Byte0;
    [FieldOffset(1)] public byte Byte1;
    [FieldOffset(2)] public byte Byte2;
    [FieldOffset(3)] public byte Byte3;
}

AsInt and AsFloat both start at byte 0. Writing AsInt and then reading AsFloat reinterprets the same four bytes — an int-to-float bit cast with no conversion, exactly the trick C programmers reach for with a union. The four byte fields overlay the same word so you can pick it apart one byte at a time. There is no union keyword in C#; overlapping [FieldOffset] attributes are the union.

The layout is the entire point. If the fields were laid end to end, Register32 would be seven bytes wide and the reinterpret would break. The overlap is deliberate, and it is recorded nowhere in any method — only in metadata.

Where the layout actually lives

Three independent pieces of metadata carry this, and a decompiler must read all three.

The layout kind is a flag on the type. In the TypeDef table, the Flags column (TypeAttributes) contains a two-bit LayoutMask:

  • 0x00000000 — AutoLayout (runtime may reorder fields)
  • 0x00000008 — SequentialLayout (loader packs in declaration order)
  • 0x00000010 — ExplicitLayout (every offset is specified by hand)

There is no IL opcode that sets this. It is a loader directive, read when the type is loaded, invisible to anything that only looks at method bodies.

Pack and Size live in the ClassLayout table. Each row ties a type to a PackingSize (1, 2, 4, 8, 16, or 0 for “default”) and a ClassSize (the explicit total Size, or 0 to let the fields decide). A type only gets a ClassLayout row when it needs a non-default pack or an explicit size.

Each field’s offset lives in the FieldLayout table. For an explicit-layout type, every field has a FieldLayout row pairing the field with its byte Offset. This is the overlap data: two rows with the same offset mean two aliased fields.

Rendered the way ILDasm shows it, Register32 looks like this — note explicit in the class header, the .size/.pack directives, and the bracketed offset on each field:

.class public explicit ansi sealed beforefieldinit Register32
    extends [System.Runtime]System.ValueType
{
    .pack 0
    .size 4
    .field [0] public int32 AsInt
    .field [0] public float32 AsFloat
    .field [0] public uint8 Byte0
    .field [1] public uint8 Byte1
    .field [2] public uint8 Byte2
    .field [3] public uint8 Byte3
}

The [0], [0], [0], [1], [2], [3] in front of each field are the FieldLayout offsets. Three fields at [0] is the union. .size 4 is the ClassLayout.ClassSize; .pack 0 is the default packing. None of this appears in any .method — it is pure declarative metadata.

What a naive decompiler produces

A decompiler built around method bodies has nothing to anchor on here, because there are no method bodies involved in layout. The failure modes are quiet and dangerous:

// WRONG — layout kind dropped, offsets gone
public struct Register32
{
    public int AsInt;
    public float AsFloat;
    public byte Byte0;
    public byte Byte1;
    public byte Byte2;
    public byte Byte3;
}

This compiles. It even has the right field names and types. But with the attribute gone it reverts to the compiler default — Sequential — and the fields are now laid out end to end: AsInt at 0, AsFloat at 4, the bytes at 8, 9, 10, 11. The struct is 12 bytes wide instead of 4, AsInt and AsFloat no longer alias, and the reinterpret cast is dead. Every P/Invoke that passed this struct now marshals a different, larger blob. Nothing warns you — the types match, the code builds, and it reads and writes the wrong bytes forever.

A subtler failure keeps [StructLayout(LayoutKind.Explicit)] but recomputes offsets from field sizes instead of reading FieldLayout. That gives non-overlapping offsets (0, 4, 8, 9, 10, 11), which destroys the union just as thoroughly while looking more convincing. The only correct source of an offset is the metadata offset. It must be copied, never derived.

The figure below contrasts the two layouts — the real union, where three fields alias the same four bytes, against the sequential layout a dropped attribute silently produces:

Explicit union — AsInt and AsFloat alias bytes 0 to 3 byte 0 byte 1 byte 2 byte 3 AsInt @ offset 0 (4 bytes) AsFloat @ offset 0 (same 4 bytes) Byte0..Byte3 at offsets 0,1,2,3 Size = 4. One word, viewed three ways. Sequential (attribute dropped) — fields laid end to end AsInt 0-3 AsFloat 4-7 B0 8 B1 9 B2 10 B3 11 12 bytes wide. No aliasing. Interop contract broken. Same field names and types in both. Only the metadata offsets tell them apart — so they must be read, not guessed.

How Glass reconstructs it

Glass treats layout as a first-class metadata concern, separate from method decompilation. For each type it runs a small, explicit pipeline:

  1. Read the LayoutMask from the TypeDef flags. If it is ExplicitLayout or AutoLayout, a [StructLayout] attribute is required. If it is SequentialLayout and there is no ClassLayout row, the layout is the compiler default for a struct and the attribute is omitted — emitting it would be redundant.
  2. Read the ClassLayout row, if present, for PackingSize and ClassSize. A non-zero pack becomes Pack = n; a non-zero size becomes Size = n. Zero values are left off, so the attribute carries only what the metadata actually specifies.
  3. Read every FieldLayout row and attach the exact byte Offset to each field as [FieldOffset(n)]. These offsets are copied verbatim from metadata — Glass never recomputes them from field sizes.
  4. Detect unions by scanning the collected offsets: whenever two fields’ byte ranges (offset to offset + size) overlap, that overlap is preserved exactly as written, which is what makes the output a faithful union.

The result is the source you started with, byte-for-byte equivalent in layout:

[StructLayout(LayoutKind.Explicit, Size = 4)]
public struct Register32
{
    [FieldOffset(0)] public int AsInt;
    [FieldOffset(0)] public float AsFloat;
    [FieldOffset(0)] public byte Byte0;
    [FieldOffset(1)] public byte Byte1;
    [FieldOffset(2)] public byte Byte2;
    [FieldOffset(3)] public byte Byte3;
}

Because the offsets came straight from FieldLayout and the Size straight from ClassLayout, the recompiled struct marshals to the identical four-byte footprint. The int/float reinterpret works, every byte accessor lands where it did, and any P/Invoke that passed Register32 still passes the same bytes.

Tagged-union overlays

The same machinery handles the richer “tagged union” pattern, where a discriminator field sits at its own offset and several payload fields overlap further in:

[StructLayout(LayoutKind.Explicit)]
public struct Variant
{
    [FieldOffset(0)] public VariantKind Kind;   // discriminator
    [FieldOffset(8)] public long IntValue;      // payload, aliased...
    [FieldOffset(8)] public double RealValue;   // ...with this
    [FieldOffset(8)] public IntPtr Handle;      // ...and this
}

Here Kind at offset 0 is read to decide which of the three overlapping payload fields at offset 8 is live. Glass reads four FieldLayout rows, sees three of them sharing offset 8, and preserves all four offsets. There is no Size row this time, so no Size = is emitted; the runtime computes the total from the fields. The reconstruction stays minimal — only the attributes the metadata actually justifies — while keeping every offset intact.

Running it

Point Glass at the assembly and ask for the one type:

glass decompile MyApp.dll --type MyApp.Interop.Register32

The output is the attributed struct above, offsets and size included. To sweep a whole interop namespace and confirm every explicit-layout type round-trips with its offsets intact:

glass decompile MyApp.dll --namespace MyApp.Interop --layout-check

--layout-check re-reads the ClassLayout and FieldLayout tables after reconstruction and verifies that the emitted [FieldOffset]/Size/Pack values match the metadata exactly, so you have a guarantee — not a hope — that the decompiled layout is the real one. Glass is a free, open decompiler; this is standard output, nothing gated.

Why this is worth getting exactly right

Explicit layout is a promise to something outside the managed world. The whole reason a struct wears [StructLayout(LayoutKind.Explicit)] is that its bytes have to line up with a definition it does not own — a C header, a wire format, a register map. A decompiler that renders the fields correctly but loses the offsets has produced code that looks right, compiles right, and is wrong in exactly the way that is hardest to catch: the data corruption happens at the marshalling boundary, far from any line you would think to inspect, and neither the compiler nor the runtime will say a word.

So the rule Glass follows is simple and strict: layout is metadata, metadata is ground truth, and offsets are copied, never inferred. Read the LayoutMask, read ClassLayout, read every FieldLayout row, and re-emit precisely what is there — down to the overlapping offsets that make a union a union. When you are inspecting or debugging interop code you are entitled to read, that fidelity is the difference between source you can trust and source that quietly lies about where its bytes live.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.