Trimming and Native AOT
CStructSharp ships as a trimmable library on both targets and declares Native AOT compatibility on .NET 10. A
published Native AOT program runs every operation: parsing, selected reads, typed reads into your own classes,
generated layouts and views, writes from classes and dictionaries, updates in place, debug reads, and the
diagnostics. This page says what that promise covers, how generated code and your own mapped classes take part
without reflection, and why dynamic stays on the JIT.
The repository proves the page on every push: tests/CStructSharp.AotConsumer publishes with PublishAot=true,
reports zero trim or AOT warnings, and runs the cases below, generated layout and mapped class included.
What the package declares
| Target | IsTrimmable |
IsAotCompatible |
Meaning for your publish |
|---|---|---|---|
| net10.0 | yes | yes | PublishTrimmed and PublishAot produce no warnings from the package; the trim and AOT analyzers have verified every code path |
| net8.0 | yes | no claim | PublishTrimmed works the same way; the package makes no Native AOT claim on .NET 8 because that SDK's analyzer cannot verify the library's dynamic-code guard (the AOT consumer runs on .NET 10) |
Nothing in the library needs runtime code generation or reflection: a [CStructLayout] class is ordinary C#
the generator wrote at build time, mapping to and from your classes goes through ICStructMapped<T> (static
code the generator or you write), and the value objects implement
IDynamicMetaObjectProvider directly instead of deriving from DynamicObject, whose constructor requires dynamic
code. That keeps the library clean; a dynamic call site in your own code is a different matter (see
dynamic access).
Publish
Add the usual properties to the application project; the library needs no settings of its own:
<PropertyGroup>
<PublishAot>true</PublishAot>
<!-- or PublishTrimmed for a trimmed JIT publish -->
</PropertyGroup>
dotnet publish -c Release -r linux-x64
Nothing the library does involves reflection. Parsing, selected reads, Get<T> of primitives, strings, enums,
arrays, and values, and writes from dictionaries or parsed values are plain code. Mapping bytes to and from your
own classes is plain code too: a class becomes a mapping target by implementing ICStructMapped<T> - two static
members the [CStructMapped] source generator writes for a partial class, or that you write by hand - and by
registering itself with MappedTypes.Register<T>(). There is no annotation to add and no member for the trimmer to
lose.
Generated layouts
A [CStructLayout] class is the most direct AOT path: the generator turns the layout into readers, writers,
views, and typed setters at build time, so the published program contains straight-line code with the offsets
filled in and nothing for the trimmer to remove. The generator runs inside the compiler and ships in the package
as an analyzer; PublishAot and PublishTrimmed need no extra setting. The AOT consumer runs this class:
[CStructLayout("struct point { int16 x; int16 y; }; struct record { uint8 tag; point origin; point corners[2]; uint8 flags[3]; };")]
public static partial class Shapes { }
Shapes.Record generated = Shapes.Parse(recordBytes); // typed properties, no StructValue in between
byte[] written = Shapes.Serialize(generated);
var view = new Shapes.RecordView(recordBytes); // allocation-free
Shapes.Update.Tag(edited, 9); // a typed setter at a build-time offset
The generated code series teaches the path; the analyzer's CSG300 warns when a project
that publishes trimmed or AOT binds a parsed value as dynamic.
The awaitable and sequence forms are as AOT-clean as the rest: ParseAsync, WriteAsync, TryParse, Records,
the view enumerator, ParseManyAsync, the layout class's ReadValue<T>, TryGet, GetOrDefault, and a with
copy of the options are all run by the AOT consumer after publishing, with no IL warnings. Iterators and async
methods compile to ordinary state-machine classes; nothing in them is discovered at run time.
Mapped classes
A [CStructMapped] class needs only the attribute; the AOT consumer maps this record with a generated mapper:
[CStructMapped(Layout = "record")]
public sealed partial class MappedRecord
{
public byte Tag { get; set; }
public Point Origin { get; set; } = new();
public IList<Point> Corners { get; set; } = [];
public byte[] Flags { get; set; } = [];
}
Point here is the hand-written class below (a mapped property's class must be [CStructMapped] or implement
ICStructMapped<T> itself). The classes written by hand show what the generator produces and how a project
without the generator takes part:
using System.Runtime.CompilerServices;
using CStructSharp;
using CStructSharp.Values;
public sealed class Point : ICStructMapped<Point>
{
public short X { get; set; }
public short Y { get; set; }
public static Point ReadFrom(StructValue source) => new() { X = source.Get<short>("x"), Y = source.Get<short>("y"), };
public static void WriteTo(Point value, StructValue target)
{
target["x"] = value.X;
target["y"] = value.Y;
}
// Runs before any other code in the assembly, on every runtime; generated classes register the same way.
[ModuleInitializer]
internal static void Register() => MappedTypes.Register<Point>();
}
public sealed class Record : ICStructMapped<Record>
{
public byte Tag { get; set; }
public Point Origin { get; set; } = new();
public Point[] Corners { get; set; } = [];
public byte[] Flags { get; set; } = [];
public static Record ReadFrom(StructValue source) => new()
{
Tag = source.Get<byte>("tag"),
Origin = source.Get<Point>("origin"), // a nested mapped class
Corners = source.Get<Point[]>("corners"), // an array of them
Flags = source.Get<byte[]>("flags"),
};
public static void WriteTo(Record value, StructValue target)
{
target["tag"] = value.Tag;
target["origin"] = value.Origin; // nested instances are mapped in turn
target["corners"] = value.Corners;
target["flags"] = value.Flags;
}
[ModuleInitializer]
internal static void Register() => MappedTypes.Register<Record>();
}
var layout = new CStruct("struct point { int16 x; int16 y; }; struct record { uint8 tag; point origin; point corners[2]; uint8 flags[3]; };");
Record record = layout.ReadValue<Record>(bytes, "record");
byte[] written = layout.Serialize("record", record);
Register from a module initializer, never from a static constructor: a static constructor that no other code
triggers is removed by the Native AOT compiler, and the first ReadValue<Record> would then report the type as not
mapped. The mapper decides the collection type: Get<T> hands out arrays, and a mapper is free to copy them into a
List<T> or anything else. A class that does not implement the interface is not a mapping target; ReadValue<T>
reports a CStructReadException naming the type, and a write reports a CStructWriteException that says what is
writable (a StructValue, a dictionary, or a registered mapped class).
Dynamic access is JIT-only
dynamic header = layout.Parse(bytes, "header"); header.kind compiles to a call into the C# runtime binder
(Microsoft.CSharp.RuntimeBinder), and that binder generates code at run time. The SDK says so at publish time -
every dynamic operation in your code reports IL2026 under trimming and IL3050 under Native AOT ("The
'dynamic' feature requires runtime-code generation, which is incompatible with AOT") - and if the warnings are
suppressed, the published program fails inside the binder on the first dynamic member access. This is a property
of C#'s dynamic, not of CStructSharp; the library's own values are AOT-clean.
In a trimmed or AOT application read members with Get<T>/TryGet<T>, index the value as a dictionary
(header["kind"]), or map to a class with ReadValue<T>:
StructValue header = layout.Parse(bytes, "header");
ushort kind = header.Get<ushort>("kind"); // instead of (ushort)dynamicHeader.kind
object? raw = header["length"]; // the stored value, untyped
Read values and paths lists what dynamic trades away on the JIT as well.
The WebAssembly bridge
The browser runtime is the same library published trimmed (TrimMode=full) inside the WASM bridge. Its values are
dictionary and list shaped, so it never registers a mapped class; the details are in
web development.
Checklist
PublishAot/PublishTrimmedon the application; nothing on the package.- Every class you read into or write from is
[CStructMapped]or implementsICStructMapped<T>and is registered (generated classes do both for you); a[CStructLayout]class needs nothing. - No
dynamicin the application:Get<T>, dictionary indexing,ReadValue<T>, or a generated class instead (CSG300points at thedynamicuses). - Run the published binary once through a typed read and a write; a removed member or an interface member surfaces
as a
CStructReadExceptionwith the remedy in the message.
Next: Typed values for the mapping rules themselves, and performance for what a published program spends per operation.