How the generator works
You do not need this page to use the generator. Read it when you want to know why the generated code agrees with the runtime, or when you are about to change either.
The pipeline
- Attribute. Roslyn finds every class with
[CStructLayout](ForAttributeWithMetadataName, so classes without the attribute cost nothing) and the generator builds a small equatable request: the layout text or file name, the options, the class name, its namespace and containing types,KeepNames,Root. - Parse and compile. The request's text goes through
LayoutParserandCStructCompiledModel- the same classesnew CStruct(text)calls. The result is the compiled model: declarations, member types, fixed offsets where the layout makes them fixed, folded#defines, the bitfield allocation. - Model.
GeneratedModelwalks the compiled model once and decides every C# name (PascalCase, collisions, reserved names) and every member's C# type. Name collisions becomeCSG003here, before any code exists. - Emit.
LayoutEmitterwrites the file in the order you see it: the frame (Definition,Layout,RootName), the types, the readers, the writers, the operations (Sizes,Offsets,Update,ParseWithDebug, the bridge), and the views.ExpressionEmitterturns a layout expression into a call chain overExpressionsso an array length or a selector is evaluated with the runtime's rules. - Compile. Roslyn adds the file to your compilation. Whatever it contains is checked like your own code.
[CStructMapped] has a smaller pipeline of its own: it inspects the class's properties, resolves names against
the project's layouts when Layout is given, and emits ReadFrom/WriteTo over StructValue.Get<T>.
One Core, two hosts
The generator is a netstandard2.0 assembly loaded into the compiler, where the runtime library is not
available. It therefore compiles the runtime's Core sources into itself: src/CStructSharp.Core/ is a source
folder, not a project, that both CStructSharp.csproj and CStructSharp.Generators.csproj include. Core holds
the parser, the expression evaluator, the compiled model with its placement rules, the introspection model, the
codec descriptors, the option types, and the exception family with every failure text - and nothing that does
I/O: no streams, no spans, no delegates, no reflection.
That is why the generated offsets are the runtime's offsets: they are computed by the same function. And it is
why a failure text is the same in both paths: ReadFailures and WriteFailures are Core, and both the runtime
reader and the generated reader build their messages from them.
The cursor
Generated code is straight-line C#, but the limits and the diagnostics must stay identical to the runtime's, so
the generated readers work through a small ref struct in CStructSharp.Generated:
ReadCursorholds the position, the remaining bytes, theReadOptionssnapshot, the byte budget, the pointer depth, and the path prefix.TakeUInt16()and friends read a primitive and advance;Seekmoves within the region;Completeattaches the offset to an exception at the operation boundary, the way the runtime does.WriteCursoris the writing counterpart, over a fixed span or a growable buffer.CompositeCursortracks where a struct started, for alignment and for the bitfield unit rules.Codecdecodes text and bitfields;Expressionsimplements the operators.
Everything the runtime reader checks (short reads, array limits, string budgets, pointer depth, total bytes) is checked by the cursor with the same texts, and the parity tests hold it to that.
How parity is tested
- Snapshots (
tests/CStructSharp.Generators.Tests/Snapshots/*.g.cs): the generated file for a fixture, compared byte for byte;UPDATE_SNAPSHOTS=1rewrites them, and a rewrite is reviewed as a diff. - Parity tests (
tests/CStructSharp.Generated.Parity/): every layout fixture the runtime is tested with is generated into one project bytools/quality/generate-parity-layouts.mjs, and for each one the generatedParseis compared withLayout.Parseon the same bytes, member by member, including pointers, unions, and conditional arms. A truncation sweep cuts the bytes at every length and requires the same exception type and text from both paths. Writers are checked by round trip: serialize the runtime's value with the generated writer and the generated value with the runtime writer, then compare the bytes. - Differential fuzzing (
tests/CStructSharp.Fuzz, targetgenerated-differential): random inputs through both readers and both writers, any disagreement is a finding. - Benchmarks (
GeneratedBenchmarks) keep the generated path measurably ahead of the runtime; the numbers are on the performance page.
Debugging generated code
- Turn on
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>and read the file; it is ordinary C# with the layout member named in a comment above each read. Wire.ParseWithDebug(bytes)returns the generated value together with the runtime's debug information - every member's offset and size - for the same bytes, so a wrong value can be placed.Wire.Layoutis the runtimeCStructfor the same text:Wire.Layout.Parse(bytes)is the oracle when you suspect the generated reader. If the two disagree, that is a bug; the parity project shows how to turn the layout into a fixture.dotnet build -v:dlists the generator's diagnostics with their locations when the build fails before code exists.
Check yourself
- Why can the generator not reference the runtime assembly?
- Where does the text of
Array length mismatch for values: expected 4, got 3.live? - What is the oracle a parity test compares the generated reader against?
Answers
- It runs inside the compiler as a
netstandard2.0analyzer; the runtime targets .NET 8 and 10 and is not loaded there. It compiles the Core sources instead. - In
WriteFailures, part of Core, used by both writers. - The runtime
CStructbuilt from the same definition (Wire.Layout).
Exercise
Write a layout with a member whose offset depends on data (uint8 count; uint8 items[count]; uint16 tail;),
generate it, and look for Offsets.Tail and Update.Tail in the generated file.
Solution
Neither exists: Sizes/Offsets and the typed setters cover members whose position the compiler fixed;
tail moves with count, so it is reached through Parse or through UpdatePath(bytes, "tail", value).