Read values and paths
Use Parse when you want a complete struct or union. Use ReadValue when you want one value, including a scalar
field deep inside a larger layout.
Before starting, you should have:
- a constructed
CStruct; - a byte array, span, memory region, or readable seekable stream;
- the case-sensitive root declaration name; and
- any integer variables needed by runtime-sized arrays.
The result types this guide returns (StructValue, UnionValue, EnumValueResult, Pointer, PrimitiveArray<T>)
live in the CStructSharp.Values namespace; add using CStructSharp.Values; next to using CStructSharp;.
The examples below build on the first header parse.
Read a complete struct
This call reads all fields in header into a StructValue:
StructValue header = layout.Parse(bytes, "header");
ushort kind = header.Get<ushort>("kind");
uint length = header.Get<uint>("length");
Get<T> converts one member with the same checked rules as ReadValue<T>: widening is fine, a value that does not
fit throws CStructReadException, and a name that does not exist throws CStructPathException listing the members
that do. The path form reaches nested values - packet.Get<byte>("items[2].tag"), node.Get<uint>("next.value.id")
through a dereferenced pointer - and TryGet<T> returns false instead of throwing.
Two more forms avoid the exception without losing the reason. TryGet<T>(path, out value, out failure) hands back
the exception Get<T> would have thrown - a CStructPathException when the member is not there (a conditional
arm that was not selected, a misspelled name), a CStructReadException when it is there but does not convert to
T - so code can tell the two apart without a catch. GetOrDefault<T>(path, fallback) returns the fallback in
both cases and the member otherwise:
private static void TryGetAndGetOrDefault()
{
var layout = new CStruct("struct message { uint8 kind; if (kind == 1) { uint32 code; } uint8 tail; };");
StructValue plain = layout.Parse([2, 9], "message");
// 'code' belongs to an arm that was not selected: absent, a path failure.
True(!plain.TryGet("code", out uint _, out CStructException? absent), "code was not read");
True(absent is CStructPathException, "absent members are path failures");
Equal(0u, plain.GetOrDefault("code", 0u));
// 'kind' is there but 200 does not fit a signed byte: unconvertible, a read failure.
StructValue wide = layout.Parse([200, 9], "message");
True(!wide.TryGet("kind", out sbyte _, out CStructException? unconvertible), "200 is not an sbyte");
True(unconvertible is CStructReadException, "conversions that lose data are read failures");
Equal((byte)200, wide.GetOrDefault("kind", (byte)0));
}
The same object is also an IDictionary<string, object?> (header["kind"], header.ContainsKey("kind"),
enumeration in declaration order) and supports dynamic member access - see Dynamic access
below for what that trades away.
A stream works the same way. Reading starts at the stream's current position and a successful read advances past the selected data:
using var stream = new MemoryStream(bytes);
StructValue header = layout.Parse(stream, "header");
The stream must be readable and seekable. Keep ownership of the stream; CStructSharp does not close it.
Dynamic access
A StructValue (and a UnionValue) can be declared dynamic, and then header.kind reads the member by name:
dynamic header = layout.Parse(bytes, "header");
uint length = header.length; // bound at run time; header.length is a uint
This is a convenience for exploratory tools, scripts, and layouts that are not known when the program is compiled. It is not the recommended style for application code, because everything the compiler would normally check moves to run time:
A misspelled or renamed member (
header.lenght) compiles and fails when it runs, with the C# runtime binder'sRuntimeBinderExceptionrather than aCStructPathExceptionthat lists the members the struct has.Values keep their storage type:
header.lengthis auint, soint total = header.lengththrows at run time instead of converting, and any arithmetic on adynamicoperand makes the whole expressiondynamic.Get<int>("length")performs the checked conversion instead.A property read is resolved against the layout's members first, so a field named like a
StructValueproperty (Count,Keys) shadows it; methods such asGet<T>still bind normally. A nested path is a chain of run-time binds (root.items[2].tag) whereGet<T>("items[2].tag")is one call.No IntelliSense, rename refactoring, or analyzer help; each call site pays the runtime binder's first-call cost, which is far above a dictionary lookup in a loop over many records; and the binder (
Microsoft.CSharp) is linked into the program.dynamicis JIT-only: the C# runtime binder behind it generates code at run time, so a trimmed or Native AOT publish reportsIL2026/IL3050for everydynamicoperation and the program fails in the binder if they are suppressed.Get<T>, dictionary indexing, andReadValue<T>work everywhere; see Trimming and Native AOT.
Read one selected value
A path starts with a root and follows fields with dots. Array indices use square brackets:
packet.payload[1]
The runtime-payload example reads a complete packet and then selects its second payload byte:
private static void RuntimePayload()
{
var layout = new CStruct("struct packet { uint8 kind; uint8 payload[COUNT]; };");
var variables = new Dictionary<string, int> { ["COUNT"] = 3 };
byte[] bytes = [0x7F, 0x10, 0x20, 0x30];
StructValue packet = layout.Parse(bytes, "packet", variables);
Equal((byte)0x7F, packet.Get<byte>("kind"));
Equal(3, packet.Get<byte[]>("payload").Length);
object? secondPayload = layout.ReadValue(bytes, "packet.payload[1]", variables);
Equal((byte)0x20, (byte)secondPayload!);
using var stream = new MemoryStream(bytes);
stream.Position = 1;
Equal(3, layout.GetArrayLength(stream, "packet.payload", variables));
Equal(1L, stream.Position);
}
With COUNT = 3 and bytes 7F 10 20 30, the results are:
packet.kind = 0x7F
packet.payload = [0x10, 0x20, 0x30]
packet.payload[1] = 0x20
The path is case-sensitive. Indexing starts at zero, so [1] is the second element. CStructSharp walks only the
parts of the layout required to locate and decode that target. A malformed field that occurs later and is unrelated
to the path does not block an earlier selected read.
Understand untyped results
The non-generic ReadValue method returns the direct representation for the selected layout type:
| Layout value | C# result |
|---|---|
| Integer, floating-point, Boolean, or character | Its matching CLR primitive, such as byte, ushort, float, bool, or char |
| Fixed-point value | double |
| UUID/GUID | Guid |
| Array | IList<object?>; a one-dimensional array of a fixed-width number or bool is a PrimitiveArray<T> whose Span exposes the typed values |
| Fixed character buffer or terminated text | string |
| Struct | StructValue (also IDictionary<string, object?>; usable as dynamic) |
| Enum | EnumValueResult |
| Union | UnionValue |
| Pointer | Pointer |
These richer enum, union, and pointer objects retain information that a plain integer or dictionary would lose. Keep them when you intend to write the value back faithfully.
A StructValue supports dynamic member access and dictionary lookup. Field names match the layout exactly.
PrimitiveArray<T> has a fixed length: you can replace an element, but cannot add or remove one. Its Span
accesses typed elements without boxing (wrapping a value in an object); ToArray() makes an independent copy.
Multidimensional arrays use nested collections, and text buffers return strings. Editing a parsed value does not
change the input bytes. Use serialization or an explicit update to write those changes.
Stream position and failures
Successful parse and read calls advance a stream through the value they consumed. TryReadValue<T> behaves
differently on an expected CStructSharp failure: it restores the stream position, returns false, and assigns the
default value to its output.
ResolveAddress and GetArrayLength also restore the position because their purpose is inspection rather
than consumption. Do not assume every method has the same position behavior; check the relevant API reference when
combining several operations on one stream.
Read asynchronously
Every stream read has an awaitable twin - ParseAsync, ReadValueAsync, ReadValueAsync<T>, ParseWithDebugAsync,
ReadValueWithDebugAsync, ResolveAddressAsync, GetArrayLengthAsync, and TryReadValueAsync<T> - for a
FileStream opened for asynchronous I/O, a network stream, or a request body: the bytes are read with
ReadAsync while the thread is free, and the value is then decoded by the same reader the synchronous forms use,
with the same limits and messages. A MemoryStream that exposes its buffer is read in place and the returned
ValueTask is already complete.
private static async Task ParseAsyncExample()
{
var layout = new CStruct("struct header { uint16 kind; uint32 length; };");
byte[] bytes = [0x02, 0x00, 0x06, 0x00, 0x00, 0x00, 0xFF];
string path = Path.Combine(Path.GetTempPath(), $"cstructsharp-{Guid.NewGuid():N}.bin");
await File.WriteAllBytesAsync(path, bytes);
try
{
// The bytes are read with ReadAsync while the thread is free; the decode itself is the ordinary reader.
await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
StructValue header = await layout.ParseAsync(file, "header", cancellationToken: timeout.Token);
Equal(6u, header.Get<uint>("length"));
Equal(6L, file.Position);
// The non-throwing form reports a failure instead of throwing it; the stream is back at its origin.
file.Position = 3;
ReadAttempt<StructValue> attempt = await layout.TryReadValueAsync<StructValue>(file, "header");
True(!attempt.Succeeded, "four bytes are not a header");
True(attempt.Failure is CStructReadException, "the failure is the read exception the throwing form raises");
Equal(3L, file.Position);
}
finally
{
File.Delete(path);
}
}
A seekable stream ends just after the value on success and back at its origin on any failure; a stream that cannot
seek is consumed up to MaxTotalBytesRead plus one byte, whatever the value needed. TryReadValueAsync<T> returns a
ReadAttempt<T> - Succeeded, Value, Failure - because an out parameter cannot cross an await. The
cancellationToken parameter ends the wait for bytes and the decode at its next boundary; it is linked with
ReadOptions.CancellationToken when both are given. One difference from the synchronous stream form: the buffered
region starts at the stream's current position, so a stored absolute pointer address counts from that origin (as it
does for a span or memory input), not from the stream's first byte - read a stream whose addresses are absolute
positions from position 0, or use the synchronous form.
Read a sequence of records
A file or a message body often holds one struct after another - a log of fixed-size entries, a stream of
count-prefixed frames - with nothing else in between. ParseMany reads such a sequence one record at a time: each
MoveNext of the returned IEnumerable<StructValue> parses the next record, so a foreach over a million-entry
file holds one value at a time and stops early whenever you break.
private static async Task ParseManyExample()
{
// Three fixed-size entries, then two count-prefixed frames: each record is parsed when the loop reaches it.
var log = new CStruct("struct entry { uint16 id; uint8 level; };");
byte[] entries = [1, 0, 3, 2, 0, 1, 3, 0, 2];
var levels = new List<byte>();
foreach (StructValue entry in log.ParseMany(entries, "entry"))
{
levels.Add(entry.Get<byte>("level"));
}
Equal("3,1,2", string.Join(",", levels));
var frames = new CStruct("struct frame { uint8 count; uint8 payload[count]; };");
byte[] framed = [2, 0xAA, 0xBB, 1, 0xCC];
using var stream = new MemoryStream(framed);
var sizes = new List<int>();
await foreach (StructValue frame in frames.ParseManyAsync(stream, "frame"))
{
sizes.Add(frame.Get<byte[]>("payload").Length);
}
Equal("2,1", string.Join(",", sizes));
// A trailing byte that is not a whole entry fails on the step that reaches it, naming the record.
byte[] trailing = [1, 0, 3, 9];
using IEnumerator<StructValue> records = log.ParseMany(trailing, "entry").GetEnumerator();
True(records.MoveNext(), "the first entry is whole");
try
{
records.MoveNext();
True(false, "unreachable");
}
catch (CStructReadException failure)
{
True(failure.Message.Contains("remaining 1 bytes are not a whole number of 3-byte elements", StringComparison.Ordinal), failure.Message);
Equal("[1].entry", failure.Path);
}
}
The root must be a struct declaration (ParseMany rejects a union or a scalar root as Parse does, before the first
record). A root with a fixed size advances by its size; a root that depends on its own data (a count-prefixed
payload) advances by the bytes the previous record consumed. Trailing bytes shorter than one record are not ignored:
the step that meets them throws, for a fixed-size root with the message a T v[EOF] array uses for a partial
element ("The remaining 2 bytes are not a whole number of 5-byte elements"); slice the input first when a trailer is
expected. A failure names the record by its index before the path - [3].header.length - and the read limits apply
to each record on its own (MaxArrayElements counts the elements inside a record; a sequence is not an array).
ParseMany takes a ReadOnlyMemory<byte>, a ReadOnlySequence<byte>, or a seekable Stream (read with the
stream reader, byte-exact, the stream left after each record). ParseManyAsync returns an IAsyncEnumerable for
await foreach: a fixed-size root is read exactly one record at a time with ReadAsync, which works on a stream
that cannot seek (a socket, a pipe); a runtime-sized root is read through a pooled window of at most
MaxTotalBytesRead plus one byte that refills from the start of a record it could not hold, which needs a seekable
stream. In the memory, sequence, and awaitable forms each record is its own region, so a stored absolute pointer
address counts from the record's first byte; the synchronous stream form counts from the stream's first byte, as
Parse(Stream) does. The generated series has the typed twin, Records, in
Sequences and TryParse.
Verify and troubleshoot
To verify a selected read:
- Write down the root's starting stream position.
- Calculate the target offset from the format.
- Confirm that the path names and index match the layout exactly.
- Compare the returned primitive type and value with the bytes.
An InvalidPath error means the selector does not match the compiled layout. A ReadFailed error means the path was
valid but the bytes could not be decoded, for example because the input was truncated. ReadLimitExceeded means the
operation reached a configured array, string, nesting, byte, or pointer limit.
For typed application models, continue with Map values to C# types; when the layout is known
at build time, the generated code series reads it into generated classes without paths at
all. For the exact path grammar,
including unions and pointer .address/.value access, see Paths and selection.