Write and serialize values
Reading turns bytes into values. Serialization does the reverse: it checks that a C# value matches a selected layout and encodes the value as bytes.
Before writing, construct the same CStruct configuration you use for reading. Byte order, alignment, pointer size,
runtime variables, and the selected root must describe the destination format. A different choice can produce valid
bytes for the wrong format.
Start with an owned byte array
For most application code, start with the overload that returns byte[]:
byte[] output = layout.Serialize("sample", value);
The library creates an exact-sized result and owns any staging required during the operation. This is the simplest choice when the result will be sent, saved, or passed to another API.
The executable round-trip example reads this layout and writes it in three ways:
struct sample {
uint16 id;
uint8 flags;
};
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());
}
For id = 0x1234 and flags = 0xA5, every output form produces:
34 12 A5
└ id ┘ flags
The first two bytes are little-endian 0x1234.
Supply a value with the correct shape
A struct can be supplied as:
- the dynamic object returned by parsing;
- a dictionary,
ExpandoObject, or parsedStructValuewith matching member names; or - an instance of a class implementing
ICStructMapped<T>(generated by[CStructMapped]or written by hand), registered withMappedTypes.Register<T>().
All required fields must be present and convertible to the declared layout type. A fixed array must have exactly the declared number of elements. A numeric value must fit its width. Null is valid only for a scalar pointer, where it encodes address zero.
Enums and unions need extra care. Keep EnumValueResult when an unknown enum value must survive a round trip. Keep
an unmodified UnionValue to preserve all overlapping raw bytes, or explicitly select a member before writing a new
union value.
Write into existing storage
Use Serialize(Span<byte>, ...) when you already have a writable memory region:
Span<byte> destination = stackalloc byte[8];
int written = layout.Serialize(destination, "sample", value);
ReadOnlySpan<byte> result = destination[..written];
The return value is the number of bytes initialized at the beginning of the span. Capacity after that prefix remains unchanged. If the span is too small, serialization fails.
Use Serialize(IBufferWriter<byte>, ...) for pipelines, pooled writers, or other APIs that expose
IBufferWriter<byte>. CStructSharp requests writable windows, fills them, advances the writer, and returns the
number of appended bytes.
These two forms use storage owned by the caller. If a later field fails after earlier output has been initialized or
advanced, CStructSharp cannot roll that prefix back. Stage through the byte[] overload when all-or-nothing output is
more important than avoiding an allocation.
Write to a stream
Write writes at a writable, seekable stream's current position:
using var stream = new MemoryStream();
layout.Write(stream, "sample", value);
The stream remains open and belongs to the caller. Direct stream writing is not transactional: a later conversion or physical write failure can leave earlier bytes written. To change an existing field while keeping surrounding bytes in place, use Update existing data instead.
WriteAsync is the awaitable form for a file opened for asynchronous I/O or a network stream: the value is
serialized first - every validation the synchronous writer performs happens before a byte is sent, so a rejected
value writes nothing - and the bytes go out in one WriteAsync. Its cancellationToken parameter is linked with
WriteOptions.CancellationToken.
private static async Task WriteAsyncExample()
{
var layout = new CStruct("struct header { uint16 kind; uint32 length; };");
var value = new Dictionary<string, object?> { ["kind"] = (ushort)2, ["length"] = 6u, };
using var stream = new MemoryStream();
// Validation happens before the write: a rejected value leaves the stream empty.
await layout.WriteAsync(stream, "header", value);
SequenceEqual([0x02, 0x00, 0x06, 0x00, 0x00, 0x00], stream.ToArray());
try
{
await layout.WriteAsync(stream, "header", new Dictionary<string, object?> { ["kind"] = 70000, ["length"] = 6u, });
True(false, "70000 does not fit uint16");
}
catch (CStructWriteException)
{
Equal(6L, stream.Length);
}
}
Verify output
For a new format integration:
- Check the returned length against the expected layout size.
- Compare the result with a known byte fixture, not only with another implementation using the same assumptions.
- Parse the output with the same layout and compare meaningful values.
- Remember that alignment padding may be normalized to zero. Untouched union raw storage is the explicit byte-preserving case.
Most write failures come from a missing member, the wrong collection length, a number outside its declared range, an
incorrect enum or union shape, insufficient output capacity, or a configured safety limit. Inspect
CStructWriteException.Path when available before changing the layout. A member the layout does not declare is
not a failure by default - it is skipped - so a misspelled key such as lenght silently leaves the field to
another (missing) value; set WriteOptions.UnknownMembers = UnknownMemberPolicy.Reject to have it named instead.
Use Spans and buffer writers for more ownership detail, or Enums and Unions for their lossless write models.