Your first generated layout
Install and make a first parse read a six-byte header with the runtime API. This
page reads the same bytes through generated code. You need a project that references the CStructSharp package;
the package contains the generator, and nothing else has to be installed.
Declare the layout
Put the layout text on a static partial class. partial is what lets the generator add members to a class you
declared; static because the class is a namespace for the generated types and methods, not something you
instantiate.
// The layout text lives in the attribute; the generator adds the members of this partial class at build time.
[CStructLayout("struct header { uint16 kind; uint32 length; };")]
public static partial class Wire
{
}
The text is the same layout language the runtime reads. A raw string literal ("""...""") is convenient for
longer layouts: when the generator reports a mistake in the text, it points at the exact line and column inside
the literal.
Parse and serialize
private static void GeneratedFirstLayout()
{
byte[] bytes = [0x02, 0x00, 0x06, 0x00, 0x00, 0x00];
// Parse returns the generated class; its properties have the C# types the layout implies.
Wire.Header header = Wire.Parse(bytes);
Equal((ushort)2, header.Kind);
Equal(6u, header.Length);
// Serialize takes the class back to bytes, and the runtime layout is still there for anything else.
SequenceEqual(bytes, Wire.Serialize(header));
Equal("header", Wire.RootName);
Equal(6, Wire.Layout.GetStructSizeInBytes("header"));
Equal(6, Wire.Sizes.Header);
}
Wire.Header is a class the generator wrote: Kind is a ushort because uint16 holds 0-65535, Length a
uint. Wire.Parse reads the root declaration (the first struct, or the one named by Root = "..."); every
declared struct also gets its own Parse<Name> overloads. Wire.Serialize writes the class back; the bytes equal
the input because the header has no padding and no value that a write would normalize.
Wire.Layout is the runtime CStruct for the same text, built on first use. Everything the runtime API offers -
paths, introspection, the memory-analysis API - is available through it, and Wire.Sizes.Header is the same
number GetStructSizeInBytes("header") reports, computed at build time.
The layout's integer #defines become constants of the same class. With #define WIRE_MAGIC 0xCAFE in the layout text,
Wire.WIRE_MAGIC is a const int (a const long when the value does not fit 32 bits), so code that checks a header can use the
layout's name for the number instead of repeating it. A #define whose value names a caller variable has no fixed value and gets no
constant, and one whose name a generated member already uses (Parse, a generated type) is the
CSG003 error.
The generated file, line by line
Turn on EmitCompilerGeneratedFiles (see where the generated files are)
and open Wire.CStructLayout.g.cs. Reading it once shows there is nothing hidden:
public static partial class Wire
{
public const string Definition = "struct header { uint16 kind; uint32 length; };";
public const string RootName = "header";
public static global::CStructSharp.CStruct Layout => LayoutInstance.Value;
The class keeps the text and the root name as constants and builds the runtime layout lazily, with the attribute's
options (Aligned, LittleEndian, PointerSize, ...) passed through.
public sealed partial class Header
{
public ushort Kind { get; set; }
public uint Length { get; set; }
}
One class per composite. It is partial too: you can add methods to it in your own file.
private static Header ReadHeaderMembers(ref global::CStructSharp.Generated.ReadCursor cursor, ...)
{
cursor.EnterComposite(member ?? "header", memberType);
var value = new Header();
var placement = global::CStructSharp.Generated.CompositeCursor.Start(cursor.Position, Aligned, Packing, Allocation);
// uint16 kind
{
cursor.Seek(placement.AdvanceToField(2), "kind", "uint16");
value.Kind = global::CStructSharp.Generated.Codec.ReadUInt16(cursor.Take(2, "kind", "uint16"), true);
placement.CompleteField(cursor.Position);
}
...
The reader is straight-line code: for each field, move to where the layout places it, take its bytes, decode them
with the shared Codec functions, store the property. ReadCursor is the small struct that carries the position,
the read options, and the budgets; CompositeCursor applies the same alignment and bitfield rules the runtime uses.
Every Take names the field it reads, so a short input fails with the same message the runtime would give:
Not enough bytes: needed 4, available 3 (field 'length' (uint32), in 'header', offset 2).
Every member of header sits at an offset the generator knows at build time, so the file also has a fixed reader:
private static Header ReadHeaderFixed(global::System.ReadOnlySpan<byte> source, bool trimFixedText)
{
var value = new Header();
FillHeaderFixed(source, trimFixedText, value);
return value;
}
private static void FillHeaderFixed(global::System.ReadOnlySpan<byte> source, bool trimFixedText, Header value)
{
// uint16 kind
value.Kind = global::CStructSharp.Generated.Codec.ReadUInt16(source.Slice(0, 2), true);
// uint32 length
value.Length = global::CStructSharp.Generated.Codec.ReadUInt32(source.Slice(2, 4), true);
}
ReadHeader, the method the Parse overloads call, is short: if (cursor.TryTakeFixed(6, 1, 6, 1, 0, out var fixedBytes)) return ReadHeaderFixed(...), and otherwise return ReadHeaderMembers(...). TryTakeFixed hands over
the struct's six bytes only when the member-by-member code would read exactly those bytes without a failure. That means
the bytes are present, the read budget covers them, and the nesting and array limits hold. A short input therefore
still reaches the member-by-member code and fails there with the message above. The writer has the same pair:
WriteHeaderFixed stores each member at its constant offset, and EncodeHeader uses it when
WriteCursor.TryReserveFixed confirms that nothing can fail, and otherwise calls EncodeHeaderMembers. A struct with a runtime-sized array, a condition, a
pointer, a bitfield, or a union member gets no fixed reader and is always read member by member.
The rest of the file holds the writer (EncodeHeader), the Serialize/Write overloads, the view, the constants,
and the setters. Each later lesson looks at one of those parts.
Check yourself
- Why must the attributed class be
partial? - What type does
uint32 lengthbecome, and why notint? - Is
Wire.Layoutbuilt when the program starts?
Answers
- The generator writes a second declaration of the same class; C# merges
partialdeclarations into one type. uint: the layout type is unsigned and needs the full 0 to 4,294,967,295 range, whichintcannot hold.- No. It is a
Lazy<CStruct>, created the first timeLayoutis read.
Exercise
Add a second struct to the layout - struct trailer { uint32 crc; }; - and read a trailer from the bytes
78 56 34 12 with the generated method for it.
Solution
[CStructLayout("struct header { uint16 kind; uint32 length; }; struct trailer { uint32 crc; };")]
public static partial class Wire { }
Wire.Trailer trailer = Wire.ParseTrailer(new byte[] { 0x78, 0x56, 0x34, 0x12 });
// trailer.Crc == 0x12345678
Wire.Parse still reads the header (the first declaration); ParseTrailer is generated because trailer is a
declared struct.