Table of Contents

Portable layout cookbook

Each recipe is backed by either the compiled documentation runner or a named pair in manual-fixtures-v1.json, executed on .NET 8 and .NET 10.

Before copying one, replace widths, byte order, placement, pointer coordinates, and limits with facts from your format. Similar-looking bytes do not prove that two formats share the same rules.

Decode a packed fixed header

Use explicit-width primitives in declaration order:

struct header {
    uint16 kind;
    uint32 length;
};

Little-endian bytes 02 00 06 00 00 00 decode as kind 2 and length 6. Packed size is 6. In aligned placement, length would move to offset 4 and total size would become 8.

private static void DecodeHeader()
{
    var layout = new CStruct("struct header { uint16 kind; uint32 length; };");
    ReadOnlySpan<byte> bytes = [0x02, 0x00, 0x06, 0x00, 0x00, 0x00];
    StructValue header = layout.Parse(bytes, "header");
    Equal((ushort)2, header.Get<ushort>("kind"));
    Equal(6U, header.Get<uint>("length"));

    bool read = layout.TryReadValue<Header>(bytes, "header", out Header? typed);
    True(read && typed is { Kind: 2, Length: 6 }, "Typed header result differed.");
    True(!layout.TryReadValue<Header>(bytes[..1], "header", out _), "Truncated TryReadValue should fail.");
}

Verify the result by checking field widths, the root starting position, and the format's byte order.

Mix byte orders without moving fields

Use < or > only on fields whose encoding overrides the layout order:

struct root {
    uint16> network;
    uint16< device;
};

Bytes 12 34 78 56 produce network=4660 and device=22136. The fields remain at offsets 0 and 2 and total size is 4. The alignment-and-endian-overrides fixture checks this behavior.

Read a payload whose count comes from elsewhere

struct packet {
    uint8 kind;
    uint8 payload[COUNT];
};
private static void RuntimePayload()
{
    var layout = new CStruct("struct packet { uint8 kind; uint8 payload[COUNT]; };");
    var variables = new Dictionary<string, int> { ["COUNT"] = 3 };
    byte[] bytes = [0x7F, 0x10, 0x20, 0x30];
    StructValue packet = layout.Parse(bytes, "packet", variables);
    Equal((byte)0x7F, packet.Get<byte>("kind"));
    Equal(3, packet.Get<byte[]>("payload").Length);
    object? secondPayload = layout.ReadValue(bytes, "packet.payload[1]", variables);
    Equal((byte)0x20, (byte)secondPayload!);

    using var stream = new MemoryStream(bytes);
    stream.Position = 1;
    Equal(3, layout.GetArrayLength(stream, "packet.payload", variables));
    Equal(1L, stream.Position);
}

Pass the same COUNT to every later read, address, length, write, or update. Do not infer the count from remaining stream bytes.

Store fixed-capacity text

Use char[N] or wchar[N] when the format reserves exactly N code units:

struct label {
    char text[4];
};
private static void FixedText()
{
    var layout = new CStruct("struct label { char text[4]; };");
    StructValue value = layout.Parse(new byte[] { 0x41, 0x42, 0x43, 0x00 }, "label");
    Equal("ABC\0", value.Get<string>("text"));
    SequenceEqual(
        [0x58, 0x59, 0x00, 0x00],
        layout.Serialize("label", new Dictionary<string, object?> { ["text"] = "XY" }));
}

Writing "XY" produces 58 59 00 00. This is not a terminated scan; a longer value fails.

Put terminated text before another field

Use a strict named handler:

struct root {
    utf8_string_zero name;
    uint8 flags;
};

Bytes 41 00 7E produce name="A" and flags=126 at offset 2. Set MaxStringBytes so a missing terminator cannot scan beyond the format's expected maximum. The terminated-strings and bounded-failures pairs check success and a limit failure.

Preserve an enum number with no name

enum state : uint32 {
    Known = 1
};

struct root {
    state value;
};
private static void PreserveEnum()
{
    var layout = new CStruct("enum state : uint32 { Known = 1 }; struct root { state value; };");
    var value = (EnumValueResult)layout.ReadValue(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, "root.value")!;
    Equal(new BigInteger(uint.MaxValue), value.Value);
    Equal(null, value.Name);
    Equal(32, value.BitWidth);
    True(!value.IsSigned, "uint32 enum should be unsigned.");
}

Check EnumValueResult.Name; when it is null, retain Value and RawBits instead of narrowing through int.

Preserve or choose a union member

union choice {
    uint8 small;
    uint16 large;
};
private static void PreserveUnion()
{
    var layout = new CStruct("union choice { uint8 small; uint16 large; };");
    UnionValue parsed = layout.ReadValue<UnionValue>(new byte[] { 0x34, 0x12 }, "choice");
    Equal("choice", parsed.UnionName);
    Equal((ushort)0x1234, (ushort)parsed.Members["large"]!);
    SequenceEqual([0x34, 0x12], layout.Serialize("choice", parsed));

    UnionValue selected = UnionValue.FromMember("choice", "small", (byte)0xA5);
    SequenceEqual([0xA5, 0x00], layout.Serialize("choice", selected));
}

Untouched raw storage 34 12 survives exactly. Selecting small=0xA5 starts from cleared two-byte storage and produces A5 00.

Decode compact flags

struct flags {
    uint8 low : 3;
    uint8 high : 5;
    uint16 next;
};

Bytes 8D 34 12 produce low 5, high 17, and next 4660. Both slices have byte offset 0. Confirm native C bitfield ABI before translating an existing header; Portable always allocates from the low bit.

Follow a relative pointer

Choose pointer width from the format and configure address mode:

private static void FollowPointer()
{
    var layout = new CStruct("struct root { uint8 *target; };", pointerSize: 1);
    using var stream = new MemoryStream([0x01, 0x2A]);
    StructValue root = layout.Parse(stream, "root");
    Pointer pointer = root.Get<Pointer>("target");
    Equal(1L, pointer.Address);
    True(pointer.IsDereferenced, "Pointer should be followed by default.");
    Equal((byte)0x2A, (byte)pointer.Value!);
}

For a relative format, use AddressingMode = Relative and the correct Origin. Zero stays null. Nonzero targets must fit the stream/memory region and configured traversal limits. Serialization writes a coordinate and does not relocate target data.

Paste a header as it is

A Windows SDK, Linux kernel, or dissect.cstruct definition compiles unchanged: the alias spellings are built in, typedef struct _X { ... } X, *PX; declares the tag and both aliases, a tagged inline union is a global type whose unnamed member is promoted, and _ fields are padding that never reach the result:

private static void WindowsHeader()
{
    // A header pasted from a Windows SDK or a dissect definition: SDK spellings, a repeated `_` padding
    // field, a tagged inline union whose members are promoted, and a pointer-to-string alias.
    const string definition = """
        typedef struct _RECORD {
            DWORD   Magic;
            WORD    Version;
            WORD    _;
            union version_information {
                DWORD Packed;
                struct { BYTE Major; BYTE Minor; WORD Build; };
            };
            DWORD   _;
            PWSTR   Name;
        } RECORD, *PRECORD;
        """;
    var layout = new CStruct(definition, pointerSize: 4, aligned: true);
    byte[] bytes = [0x4D, 0x5A, 0x00, 0x00, 0x02, 0x00, 0xFF, 0xFF, 0x0A, 0x00, 0x39, 0x30, 0xEE, 0xEE, 0xEE, 0xEE, 0x00, 0x00, 0x00, 0x00];
    StructValue record = layout.Parse(bytes, "RECORD");
    Equal(0x5A4DU, record.Get<uint>("Magic"));
    Equal((ushort)2, record.Get<ushort>("Version"));
    Equal((byte)10, record.Get<byte>("Major"));
    Equal((ushort)12345, record.Get<ushort>("Build"));
    Equal(0x3039000AU, record.Get<uint>("Packed"));
    True(!record.ContainsKey("_"), "padding is not a member");

    // The tag, the alias, and the pointer alias all name the same declaration; the promoted union writes
    // back through the member the data supplies and the padding as zeroes.
    Equal(20, layout.GetStructSizeInBytes("_RECORD"));
    byte[] written = layout.Serialize("RECORD", new Dictionary<string, object?> { ["Magic"] = 0x5A4DU, ["Version"] = (ushort)2, ["Packed"] = 0x3039000AU, ["Name"] = 0U });
    SequenceEqual([0x4D, 0x5A, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x39, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], written);
}

Only the long width (CLongWidth), the storage of an enum without a backing type (DefaultEnumStorage), and big-endian bitfield order (BitfieldAllocation) are choices a header leaves to the compiler; the migration guide lists the reading each option selects.

Read flags and data-terminated arrays

A flag decomposes into member names, entry entries[] reads until an all-zero element, and uint16 trailer[EOF] takes every whole element to the end of the input:

private static void FlagsAndDataSizedArrays()
{
    // A flag reads as a decomposed name list; `[]` on a struct is an array terminated by an all-zero
    // element (the exFAT/APFS habit) and `[EOF]` takes every whole element that remains.
    const string definition = """
        flag access : uint16 { READ, WRITE, EXEC, HIDDEN = 0x100 };
        struct entry { uint8 kind; uint8 size; };
        struct root {
            access mode;
            entry entries[];
            uint16 trailer[EOF];
        };
        """;
    var layout = new CStruct(definition);
    byte[] bytes = [0x05, 0x01, 1, 10, 2, 20, 0, 0, 0x34, 0x12, 0x78, 0x56];
    StructValue root = layout.Parse(bytes, "root");
    FlagValueResult mode = root.Get<FlagValueResult>("mode");
    Equal("READ|EXEC|HIDDEN", string.Join("|", mode.Names));
    True(mode.Has("EXEC") && !mode.Has("WRITE"), "EXEC is set and WRITE is not");
    Equal(2, root.Get<IReadOnlyList<object?>>("entries").Count);
    Equal((ushort)0x5678, root.Get<ushort>("trailer[1]"));

    // Writing appends the terminator element and nothing after the read-to-end array; a flag accepts names.
    byte[] written = layout.Serialize(
        "root",
        new Dictionary<string, object?>
        {
            ["mode"] = "READ|HIDDEN",
            ["entries"] = new object[] { new Dictionary<string, object?> { ["kind"] = (byte)3, ["size"] = (byte)30 } },
            ["trailer"] = new ushort[] { 1 },
        });
    SequenceEqual([0x01, 0x01, 3, 30, 0, 0, 0x01, 0x00], written);
}

Keep a header's defines and size expressions

Text and 64-bit #define values are published on CStruct.Constants, #ifdef selects declarations (also through CStructCompilationOptions.Defined), and a count may use %, ?:, sizeof, and offsetof:

private static void HeaderPreprocessor()
{
    // Preprocessor lines and expression forms that appear in real headers: text and 64-bit defines are
    // published as constants, `#ifdef` selects declarations, and counts may use sizeof, offsetof, % and ?:.
    const string definition = """
        #define MAGIC "CD001"
        #define BLOCK_MASK (1 << 40)
        #define LEGACY_VERSION 1
        #ifdef WIDE_COUNTS
        typedef uint32 count_t;
        #else
        typedef uint16 count_t;
        #endif
        struct header { uint8 kind; uint32 length; };
        struct root {
            count_t count;
            uint8 payload[count % 4 == 0 ? sizeof(header) : offsetof(header, length)];
        };
        """;
    var narrow = new CStruct(definition);
    Equal("CD001", narrow.Constants["MAGIC"].Value);
    Equal(BigInteger.One << 40, narrow.Constants["BLOCK_MASK"].Value);
    Equal(LayoutConstantKind.Integer, narrow.Constants["LEGACY_VERSION"].Kind);
    StructValue aligned = narrow.Parse(new byte[] { 4, 0, 1, 2, 3, 4, 5 }, "root");
    Equal(5, aligned.Get<byte[]>("payload").Length);
    StructValue unaligned = narrow.Parse(new byte[] { 3, 0, 1 }, "root");
    Equal(1, unaligned.Get<byte[]>("payload").Length);

    // The same source compiled with a -D style definition takes the other branch and is its own cache entry.
    var wide = CStruct.GetOrCompile(definition, compilationOptions: new CStructCompilationOptions { Defined = new HashSet<string> { "WIDE_COUNTS" } });
    Equal(4, wide.Layout.Declarations.Single(item => item.Name == "root").Fields[0].Size!.Value);
}

Register a codec of your own

A format-specific encoding (a protobuf varint, a SID blob) is an ICustomCodec the layout uses by name, including as an array count:

private static void CustomCodec()
{
    // A caller-defined type: a protobuf-style varint registered under the name a definition uses.
    var codecs = new List<ICustomCodec> { new Varint() };
    var layout = new CStruct(
        "struct entry { varint id; uint8 kind; }; struct root { varint count; entry items[count]; };",
        compilationOptions: new CStructCompilationOptions { Codecs = codecs });
    byte[] bytes = [2, 0x80, 0x01, 7, 0x05, 9];
    StructValue root = layout.Parse(bytes, "root");
    Equal(128UL, root.Get<ulong>("items[0].id"));
    Equal((byte)9, root.Get<byte>("items[1].kind"));
    SequenceEqual(bytes, layout.Serialize("root", root));
    using var stream = new MemoryStream(bytes);
    Equal(4L, layout.ResolveAddress(stream, "root.items[1].id"));
}

private sealed class Varint : ICustomCodec
{
    public string Name => "varint";

    public int? FixedSize => null;

    public int Alignment => 1;

    // The library hands the codec the bytes from the value's start: all remaining input for memory sources,
    // a growing window for other streams. The codec says how many it used.
    public OperationStatus Read(ReadOnlySpan<byte> source, out object? value, out int bytesConsumed)
    {
        ulong result = 0;
        for (int index = 0; index < source.Length && index < 10; index++)
        {
            result |= (ulong)(source[index] & 0x7F) << (7 * index);
            if ((source[index] & 0x80) == 0)
            {
                value = result;
                bytesConsumed = index + 1;
                return OperationStatus.Done;
            }
        }

        value = null;
        bytesConsumed = 0;
        return source.Length >= 10 ? OperationStatus.InvalidData : OperationStatus.NeedMoreData;
    }

    // DestinationTooSmall asks for a larger window; ten bytes always suffice for a 64-bit varint.
    public OperationStatus Write(Span<byte> destination, object value, out int bytesWritten)
    {
        ulong remaining = Convert.ToUInt64(value, CultureInfo.InvariantCulture);
        bytesWritten = 0;
        do
        {
            if (bytesWritten == destination.Length)
            {
                return OperationStatus.DestinationTooSmall;
            }

            byte next = (byte)(remaining & 0x7F);
            remaining >>= 7;
            destination[bytesWritten++] = remaining == 0 ? next : (byte)(next | 0x80);
        }
        while (remaining != 0);

        return OperationStatus.Done;
    }
}

Patch one field in existing data

Use a path and Update when surrounding bytes and positions must stay fixed:

private static void PatchField()
{
    var layout = new CStruct("struct item { uint16 id; uint8 flags; }; struct root { item value; };");
    using var stream = new MemoryStream([0xEE, 0xEE, 0x34, 0x12, 0x01]);
    stream.Position = 2;
    layout.Update(stream, "root.value.flags", (byte)0xA5);
    SequenceEqual([0xEE, 0xEE, 0x34, 0x12, 0xA5], stream.ToArray());
    Equal(2L, stream.Position);

    byte[] before = stream.ToArray();
    Throws<CStructWriteException>(() => layout.Update(stream, "root.value.flags", 999));
    SequenceEqual(before, stream.ToArray());
    Equal(2L, stream.Position);
}

Path, range, shape, and limit failures detected by CStructSharp happen before destination writes. A physical stream failure during final commit may still leave an accepted prefix.

For new output, use owned or caller-provided serialization:

private static void RoundTrip()
{
    var layout = new CStruct("struct sample { uint16 id; uint8 flags; };");
    byte[] input = [0x34, 0x12, 0xA5];
    object parsed = layout.Parse(input, "sample");
    SequenceEqual(input, layout.Serialize("sample", parsed));

    Span<byte> destination = stackalloc byte[8];
    destination.Fill(0xCC);
    int written = layout.Serialize(destination, "sample", parsed);
    Equal(3, written);
    SequenceEqual(input, destination[..written].ToArray());
    Equal((byte)0xCC, destination[written]);

    var writer = new ArrayBufferWriter<byte>();
    Equal(3L, layout.Serialize(writer, "sample", parsed));
    SequenceEqual(input, writer.WrittenSpan.ToArray());
}

Continue with Choose an API for ownership and failure tradeoffs.