Table of Contents

Expressions, defines, and runtime variables

Portable expressions calculate integer values used by array counts, bitfield widths, enum members, and #define declarations. They are not general C expressions and cannot call application code.

A simple count

#define WORD_COUNT 4

struct root {
    uint16 values[WORD_COUNT];
};

WORD_COUNT supplies a default count of four. A caller variable can override that name for an operation, so it is not an immutable array-size declaration. Use [4] for a literal fixed count. Multidimensional arrays currently require identifier-free count expressions in every dimension; even a named #define is rejected there.

When a value is not known until one operation, use a caller variable:

struct packet {
    uint8 payload[COUNT];
};
var variables = new Dictionary<string, int>
{
    ["COUNT"] = 3,
};

The operation copies the dictionary and uses the caller value in preference to a matching #define. Undefined names and circular dependencies fail explicitly.

Operators and precedence

Expressions support decimal, hexadecimal (0x), binary (0b), and octal (0o) integers, parentheses, unary !/-/~, and these binary operators:

Precedence, high to low Operators
Unary -, ~, !
Multiply/divide/remainder *, /, %
Add/subtract +, -
Shift <<, >>
Relational <, <=, >, >=
Equality ==, !=
Bitwise AND &
Bitwise XOR ^
Bitwise OR \|
Logical AND &&
Logical OR \|\|
Conditional c ? a : b (right-associative; only the selected arm is evaluated)

Operators on the same row are evaluated left to right; % truncates like / and fails on a zero divisor. The two calls sizeof(type) and offsetof(type, field) are accepted in array dimensions and fold to literals when the layout is constructed: the type may be a primitive, a typedef, an enum, a pointer (sizeof(uint8*) is the pointer width), or a complete fixed-size struct or union declared anywhere in the layout, and the field must be statically placed. No other call is accepted, and nothing ever invokes user code. A qualified enum.Member names one member of a named enum or flag as a constant.

Pointer stars follow the complete type name: sizeof(unsigned int**) is valid, but sizeof(h*o) is not. The latter is a multiplication expression, not a type name. Use sizeof(type) with the intended storage type; arithmetic can appear outside the call, such as 2 * sizeof(uint16).

A nested field's value

A scalar read earlier in the same struct, or in any struct read before, is a variable under its bare name: after h hdr; with struct h { uint8 n; }, uint8 v[n] counts with the n just read. When two nested fields have the same member name, or a header is clearer when spelled as a path, name the field through the struct field that holds it:

struct h { uint8 n; uint8 pad; };
struct root {
    h a;
    h b;
    uint8 first[a.n];
    uint8 second[b.n];
};

a.n and b.n are the values of n inside a and b; a path may reach through several levels (a.b.n). The head of a path is a scalar struct field of the layout (not an array element and not a pointer target), and the value is published while that field is read, written, or measured, so every operation counts with the same number. A path that names no such field is an undefined identifier when it is evaluated, like any other unknown name. The nested-references fixture checks v[hdr.n] through parsing, addressing, and serialization.

An inline struct with a member name works exactly like a member of a named struct type. Its body has no type name, but the member name starts the path:

struct packet {
    struct { uint8 count; uint8 flags; } hdr;
    uint16 items[hdr.count];
};

With the input 02 00 0A 00 0B 00, hdr.count is 2, so items holds the two values 10 and 11 and the struct is six bytes long.

A path starts at the struct the expression is written in, however deep that struct itself is nested. Below, mid counts v with hdr.n, its own member's field, and root counts w with m.hdr.k, the same kind of field one level further down:

struct h { uint8 n; uint8 k; };
struct mid { h hdr; uint8 v[hdr.n]; };
struct root { mid m; uint8 w[m.hdr.k]; uint8 tail; };

With the input 02 01 07 08 09 04, the reader first reads m.hdr: n is 2 and k is 1. Inside m, hdr.n is 2, so v holds 07 08; back in root, m.hdr.k is 1, so w holds 09, and tail is 4. While m.hdr is read, each of its fields is available under both spellings, hdr.n and m.hdr.n. Like a bare name, a path keeps the last value read under it, so a later hdr of another struct replaces the value of hdr.n from then on.

A union's members are different. They are published only while the union itself is read: the members overlap the same bytes, so no single value of theirs remains once the union ends. After a union member u, both u.n and n are undefined identifiers, whether u has a named union type or is an inline union { ... } u;.

Which fields an expression can use

An expression computes an integer, so it can only read fields whose value is an integer. These are integer types, characters (their character code), bool (1 or 0), enums (the member's number), and pointers (the stored address, not the value it points to). A custom codec's field counts when the value it decodes is an integer.

Other fields have no single integer value: text (char name[4], utf8 name[8], cstring), arrays, structs, unions, floating-point and fixed-point numbers, and UUIDs. Naming one of them in an expression is a layout error, reported when the layout is built:

struct root { char tag[4]; uint8 body[tag]; };

This fails with Field 'tag' is text, but a layout expression uses it; layout expressions can only use integer fields (integers, characters, bool, enums and pointers). To branch on a four-character tag, read it as a number and name the values with an enum:

enum chunk : uint32 { IHDR = 0x52444849 };  // "IHDR" read little-endian
struct root { chunk tag; switch (tag) { case chunk.IHDR: { uint32 width; } } };

A name can belong to more than one field, for example a numeric n in one struct and a text n in a nested one. The layout is then valid, and the value in effect is the last field read under that name. While that is the text field, an expression that uses n fails with 'n' is text, but layout expressions can only use integer fields ...; it never falls back to an older value. The same holds when a #define shares the name with a non-integer field.

Expressions use exact 128-bit integers

Every ordinary layout expression is evaluated in one number range: signed 128-bit integers, from -2^127 through 2^127 - 1. That range is wide enough to hold every value a field up to 64 bits can store, signed or unsigned. An expression therefore sees the exact number a field holds. A kernel address such as 0xFFFF800000001000 is a large positive number, not a negative one, and a uint64 size is never mistaken for a small or negative count.

The arithmetic is checked, which means a result outside the range fails instead of wrapping around:

  • addition, subtraction, multiplication, negation, and left shift fail when the result leaves the range;
  • division and % truncate toward zero, and fail on a zero divisor or on -2^127 divided by -1;
  • a shift count must be between 0 and 127 and is never silently masked;
  • right shift is arithmetic, so a negative value stays negative;
  • ~, &, |, and ^ operate on the two's-complement bits of the 128-bit value; and
  • comparisons and logical operators produce 0 or 1.

A literal is its exact value, in every base. 0xFFFFFFFF is 4294967295, as it is in C, and -0x80000000 is -2147483648. A literal outside the range, such as a 32-digit hexadecimal uint128 mask, is kept exactly (an enum or a constant can still use it), but an expression that evaluates it fails: The literal 340282366920938463463374607431768211455 is outside the 128-bit range that layout expressions support.

Example: follow a pointer only when it is set

A linked list in a memory dump often stores the next node's address as a 64-bit number, with zero meaning "no next node". This layout reads a payload only after a nonzero next:

struct node {
    uint64 next;
    if (next != 0) {
        uint32 payload;
    }
};

With little-endian input 00 10 00 00 00 80 FF FF EF BE AD DE, next sits at offset 0 and holds 18446603336221200384 (0xFFFF800000001000). The condition is true, so payload is read at offset 4 + 4 = 8 and holds 3735928559 (0xDEADBEEF). The node is 12 bytes. With next equal to zero (00 00 00 00 00 00 00 00), the condition is false, payload is absent, and the node is 8 bytes. Writing and updating evaluate the same condition on the value being written, so both produce these same bytes.

Each use checks its own range

The expression range is wider than most places a result can go. Each place checks the final value where it uses it, and the failure names that value:

Use Accepted values Failure for a larger value
Array length or @count read from data 0 through MaxArrayElements Array length 2147483648 exceeds MaxArrayElements (1000000).
Fixed array length, typedef shape, bit width, @align, @N a signed 32-bit integer (then each rule's own limits) The array length for data is 4294967296, which does not fit in a signed 32-bit integer.
if condition any value; nonzero is true none
switch selector and case labels any value in the range none

For example, in struct root { uint32 count; uint8 items[count]; }; the bytes 00 00 00 80 give count the valid uint32 value 2147483648. The count is exact, so the read fails at items with the element limit and that number, not with a negative length. A switch (tag) on a uint64 tag can match case 0xFFFFFFFFFFFFFFFF:.

Values outside the range

A uint128 field at or above 2^127 is still read normally. It only fails when an expression selects it, and then the failure names the field and its value: 'n' is 170141183460469231731687303715884105728, which is outside the 128-bit range that layout expressions support. The same diagnostic applies when ?:, &&, or || selects that field. An unselected operand is not evaluated: 0 ? n : 0 is zero whatever n holds.

Enum expressions use the full backing range

An enum may use signed or unsigned 8-, 16-, 32-, or 64-bit backing storage. Its member expressions use exact BigInteger arithmetic and are checked against that declared range. Every member value fits the 128-bit expression range, so an ordinary expression can use any member, such as case big.High: for a uint64 member above 2^63.

enum state : uint8 {
    None = 0,
    Ready = 1,
    Busy = Ready << 1
};

An omitted first value starts at zero; each later omitted value is the previous exact value plus one. Every result is range-checked. enum state : uint8 { Maximum = 255, Next } fails because Next would be 256.

Bitwise enum operations use signed two's-complement BigInteger behavior, and shift counts must be less than the backing width. Arithmetic never wraps.

Non-integer defines and conditionals

A header's other #define forms are accepted so it can be pasted unchanged, but they are constants, not expression inputs: #define MAGIC "CD001" (text), #define RAW b"\x00\x01" (bytes), #define HAS_TAIL (a bare name), #define SZ(x) ((x) + 1) (a function-like macro kept as text, never expanded), and any line whose value is not an integer expression at all (kept as text, as dissect keeps it). CStruct.Constants publishes every define by name as a LayoutConstant whose Kind says which form it was; an integer define that could be evaluated without caller variables is published as Integer - with its exact value, even one beyond the 128-bit expression range such as (1 << 127) - and one that depends on a variable as Expression. Using a non-integer constant in a count, or a value outside the 128-bit range in any expression, is a layout error; a define that names an unknown identifier and is never used is not (a compiler ignores an unused macro too). #ifdef/#ifndef test whether a name has been defined by any form so far, or listed in CStructCompilationOptions.Defined.

Evaluation limits and reuse

The constructor prepares expression trees once, checks dependency cycles, and caches values that do not depend on runtime variables. A caller override recalculates only definitions that depend on that name.

CStructCompilationOptions limits expression/dependency depth and total work so hostile source cannot create unbounded preparation. Public callers supply only IReadOnlyDictionary<string, int> values; parser and expression tree types remain internal.

Expression failures encountered while preparing a layout become CStructLayoutException. The same expression evaluated against decoded or supplied values during an operation fails as that operation: CStructReadException for reads, address lookups, and lengths; CStructWriteException for serialization and updates. If a runtime array unexpectedly becomes huge or negative, check the supplied variable, byte order of any upstream count, and the expression before increasing a safety limit.

Checking a value with a constraint

The same expressions state which values a field may hold: uint16 magic @expect(magic == 0xEF53); fails the read when the condition evaluates to zero. See field constraints.

Comparisons and short-circuit predicates

For worked conditional examples, see Choose fields with if and switch.

Comparisons ==, !=, <, <=, >, >= and logical !, &&, || produce integer 0 or 1. Nonzero operands are true. && skips its right operand when the left is zero; || skips it when the left is nonzero. Inactive operands may contain unavailable identifiers without causing an evaluation error. Active expressions still enforce checked arithmetic, cycle, depth and work limits.

Precedence from highest to lowest: unary ! - ~, multiplication/division, addition/subtraction, shifts, relational comparisons, equality, bitwise &, bitwise |, logical &&, logical ||. Parentheses override precedence. For example, count != 0 && size / count > 2 does not divide when count is zero.