Mapped classes
A generated layout class gives you its types. Sometimes you want your class - one that already exists, one
with a List<T> instead of an array, one that the rest of the program is built around. [CStructMapped] asks
the generator to write the conversion between such a class and a parsed value.
// The generator writes ReadFrom, WriteTo, and the registration; the properties are matched by name.
[CStructMapped(Layout = "header")]
public sealed partial class HeaderRecord
{
public ushort Kind { get; set; }
public uint Length { get; set; }
}
[CStructMapped]
public sealed partial class SampleRecord
{
public byte Count { get; set; }
public List<ushort> Values { get; set; } = [];
[CStructMember("name")]
public string FileName { get; set; } = string.Empty;
public Samples.Color Colour { get; set; }
}
private static void GeneratedMappedClasses()
{
byte[] bytes = [0x02, 0x00, 0x06, 0x00, 0x00, 0x00];
// The layout class reads its root straight into the mapped class; Wire.Layout.ReadValue<T>(bytes, "header")
// is the same call with the root named, and the generated instance bridges to it as well.
HeaderRecord record = Wire.ReadValue<HeaderRecord>(bytes);
Equal(6u, record.Length);
Equal(6u, Wire.Layout.ReadValue<HeaderRecord>(bytes, "header").Length);
Equal(6u, Wire.ToMapped<HeaderRecord>(Wire.Parse(bytes)).Length);
True(!Wire.TryReadValue(bytes[..3], out HeaderRecord? _), "three bytes are not a header");
SequenceEqual(bytes, Wire.SerializeMapped(record));
// Names match by exact spelling, then case-insensitively, then ignoring underscores; [CStructMember] overrides.
byte[] sampleBytes = [2, 0x34, 0x12, 0x78, 0x56, (byte)'p', (byte)'n', (byte)'g', 0, 0, 0, 0, 0, 0xC3, 0xA9, (byte)'t', (byte)'e', 0, 0, (byte)'o', (byte)'k', 0, 2, 7];
SampleRecord sample = Samples.Layout.ReadValue<SampleRecord>(sampleBytes, "sample");
Equal(2, sample.Values.Count);
Equal("png\0\0\0\0\0", sample.FileName);
Equal(Samples.Color.Green, sample.Colour);
}
What is generated
For each attributed class the generator adds an implementation of ICStructMapped<T>: a static ReadFrom(StructValue)
that creates an instance and assigns each property, a static WriteTo(T, StructValue) that fills a value from an
instance, and a module initializer that registers the type with MappedTypes. There is no reflection anywhere:
the runtime's ReadValue<T>, Get<T>, and the write operations look the type up in the registry, which is why
mapped classes work in a trimmed or Native AOT application (see Trimming and Native AOT).
The mapper considers every public property with a getter and a setter (an init setter counts). A property with
no public setter is left alone.
Matching properties to members
A property finds its layout member by name: the exact spelling first, then a case-insensitive match, then a match
that ignores underscores - FileName finds file_name. [CStructMember("name")] names the member explicitly,
which is how FileName maps to name in the example.
With Layout = "header" on the attribute, the generator resolves the names at build time against the
[CStructLayout] classes in the same project and reports CSG102 for a property that matches nothing. Without
it the names are resolved at run time, when the value's shape is known.
Conversions
Each property receives its member through the same rules Get<T> applies:
- Integers widen without loss and fail with
Get<T>'s message when the value does not fit. - A C# enum takes the layout enum's numeric value, or matches by name.
- A
stringreceives text; a fixedchar[]member keeps its padding unlessTrimFixedTextis set. - Arrays map to
T[],List<T>,IList<T>,IReadOnlyList<T>, orIEnumerable<T>. - A nested struct maps to another
[CStructMapped]class (or toStructValueto keep it untyped); a class that is neither isCSG101. - A pointer maps to
Pointer<T>for a typed target or toValues.Pointerfor the raw address. - A union maps to
UnionValue; read the member you want from it, as unions shows. - A nullable property (
uint?) receivesnullfor a conditional member that was not read.
From generated code
A generated layout class bridges to mapped classes without leaving the typed world:
Wire.ReadValue<HeaderRecord>(bytes)reads the root into the mapped class - the same asWire.Layout.ReadValue<HeaderRecord>(bytes, "header")without naming the root - andWire.TryReadValue<HeaderRecord>(bytes, out var record)reports a failure asfalse. Both take a span, an array, memory, aReadOnlySequence<byte>, or a stream.Wire.ToMapped<HeaderRecord>(header)converts a generated instance.Wire.ParseMapped<HeaderRecord>(bytes)parses straight into the mapped class.Wire.SerializeMapped(record)writes one.
Each goes through a StructValue built from the generated instance's bytes, so the cost is a runtime parse; use
it at the edges of a program, not in a hot loop where the generated class itself is the faster type.
Check yourself
- Which of these properties is mapped:
public int A { get; set; },public int B { get; },public int C { get; init; }? - What does
Layout = "..."on the attribute change? - Why does a mapped class not need any trimming annotations?
Answers
AandC;Bhas no setter.- Name resolution happens at build time, so a property with no counterpart becomes a
CSG102warning instead of a run-time failure. - The generator writes the property assignments as ordinary C#; nothing is discovered by reflection at run time.
Exercise
Give SampleRecord a property public byte Missing { get; set; } and Layout = "sample" on its attribute, then
build. Remove the property (or add [CStructMember("count")]) to make the warning go away.
Solution
The build reports CSG102: 'Missing' matches no member of the layout 'sample' (by exact name, case-insensitively, or ignoring underscores); add [CStructMember("name")] or rename it. With [CStructMember("count")] both Count
and Missing receive the same member.