Table of Contents

Arrays, character buffers, and strings

Portable supports arrays of one or more dimensions (see Multidimensional arrays). Text has two distinct storage shapes:

  • fixed character buffers own an exact number of code units; and
  • terminated strings continue until a NUL or line-feed marker.

Array counts are element counts, not byte counts. For fixed-size elements, the byte extent is count multiplied by the complete element stride. In aligned mode, that stride includes any tail padding in a nested struct. Variable-size elements, such as terminated strings, are measured sequentially instead.

Fixed arrays

T field[expression]; declares an array whose expression must become a non-negative Int32.

struct root {
    uint16 values[2];
    uint8 tail;
};

For packed little-endian bytes 34 12 78 56 9A:

Path Offset Value
root.values / root.values[0] 0 4660
root.values[1] 2 22136
root.tail 4 154
offset   0    1    2    3    4
byte    34   12   78   56   9A
field   values[0] values[1] tail

Even counts zero and one produce collection-shaped results. A zero-length array consumes no element bytes; the next field may begin at the same packed offset or at its own aligned offset.

GetArrayLength returns the evaluated count. Serialization needs exactly that many items, including zero or one. The fixed-arrays fixture checks values 17, 34, and 126 at offsets 0, 1, and 2.

Runtime expression arrays

The expression may depend on a #define, a qualified enum member (kind.Max), an earlier field exposed by an operation, sizeof/offsetof of a fixed type, or an integer variable supplied by the caller:

struct root {
    uint8 values[N];
    byte tail;
};

With N = 2, bytes 11 22 7E place the two array items at offsets 0 and 1 and tail at offset 2.

Because N can change, GetStructSizeInBytes("root") cannot return one fixed size. Pass the same variable values to parse, address, length, serialize, write, and update operations. Counts remain subject to ReadOptions.MaxArrayElements and the corresponding write limits.

Data-sized arrays

Two declarations take their count from the data instead of an expression. Both need an element type with one fixed size (a primitive, an enum, a fixed struct, or a pointer), so every element can be stepped over:

struct record {
    uint32 magic;
    uint16 values[EOF];
};
struct table {
    entry entries[];
    uint8 tail;
};

values[EOF] reads every whole element that remains in the input: for memory input and seekable streams the count is (length - position) / element size, computed once per array; a trailing partial element is a read error, a non-seekable stream is a read error, and EOF keeps its ordinary meaning if the layout defines it. entries[] on a non-character type reads elements until an element whose bytes are all zero; that terminator is consumed and does not appear in the value (an element type with padding bytes must have those bytes zero too). Writing a data-sized array writes exactly the elements supplied (plus one zero element for the terminated form); updating an element changes it in place; the count cannot be changed by an update; GetArrayLength reports the count; and the containing struct has no fixed size. char name[] and wchar name[] remain terminated strings. The data-sized-arrays fixture checks both forms.

Fixed character buffers

char[N] stores exactly N one-byte code units. wchar[N], wchar<[N], and wchar>[N] store exactly N UTF-16 code units with neutral, little-, or big-endian byte order. Their direct result is a C# string, but their storage still has fixed array behavior.

For struct root { char value[2]; byte tail; }; and bytes 41 42 7E:

Offset Bytes Field/value
0 41 42 root.value = "AB"
2 7E root.tail = 126

Writing "A" to char[2] produces 41 00. Writing more than two code units fails instead of extending the field. An embedded zero is ordinary fixed-buffer content.

A Unicode character outside the Basic Multilingual Plane consumes two UTF-16 code units. Updating one indexed wchar changes one raw code unit; it does not repair a neighboring surrogate automatically.

Byte-bounded UTF-8 buffers

utf8 name[byte_count]; reads exactly the declared number of bytes and returns one strictly decoded UTF-8 string. The count may be fixed or use an earlier field, define, or caller variable:

struct root {
    uint16 byte_count;
    utf8 name[byte_count];
    uint8 tail;
};

For little-endian bytes 02 00 C3 A9 7E, name is "é" and tail is 126 at offset 4. The count is encoded bytes, not Unicode characters or UTF-16 code units. Embedded NULs are preserved. No terminator is required, searched for, or added. A zero-length buffer returns "". Malformed UTF-8, including a sequence cut off at the declared boundary, fails with ReadFailed. The decoder never borrows bytes from the next field to complete a sequence.

Writing uses strict UTF-8, rejects encoded text larger than the declared capacity, and zero-pads shorter text to fill that capacity. Invalid UTF-16 input (such as an unpaired surrogate) fails. Read and write string-byte limits apply to the full declared capacity, alongside ordinary array and total-byte limits. Reads preserve padding as NUL characters unless ReadOptions.TrimFixedText is set, which removes trailing NULs only.

GetArrayLength reports the byte capacity. An indexed read such as root.name[1] returns the raw byte at that position; individual indexed updates can make the full string invalid. A scalar utf8 is likewise a one-byte code unit, returned as Byte. It has no endian suffix. utf8[] and multidimensional utf8 declarations are unsupported: use utf8_string_zero for NUL-terminated text, or an array of structs containing bounded UTF-8 fields for multiple strings.

Multidimensional arrays

T field[a][b]...; declares an array with more than one dimension, outermost first:

struct root {
    uint8 matrix[3][4];
};

Every dimension's own count is an element count, and elements are laid out in row-major order - the same sequential order a flat uint8[12] field would use. A read produces an N-deep nested list (root.matrix is a list of 3 rows, each a list of 4 columns); a write accepts the same nested shape (a list of 3 lists of 4 values each), and every row's own length is checked exactly like a one-dimensional array's length. Selecting a partial index (root.matrix[2]) returns the corresponding lower-dimensional sub-array rather than one element - see Paths, array indices, and pointer access.

CStructSharp currently requires every dimension of a multidimensional declaration to be compile-time fixed. For this check, fixed means an expression with no named dependencies: [2 + 1][4] works, but [ROWS][4] is rejected even when ROWS has a #define value. Use literal arithmetic in each dimension. Runtime counts and named count expressions are supported for one-dimensional arrays. This is a Portable implementation restriction, not a general C rule. C99 variable-length arrays can have runtime inner dimensions in supported contexts, such as block-scope int matrix[rows][cols]; they are not ISO C struct members. See GCC's variable-length array documentation.

For example, this Portable declaration is rejected:

struct root {
    uint8 count;
    uint8 values[count][3];  // InvalidLayout: only a 1-D array may have a runtime-sized dimension
};

A fixed table of fixed-width strings is an ordinary combination of this feature with fixed character buffers above - char names[10][32] declares 10 rows of a 32-code-unit buffer each. Reading it produces a list of 10 strings (each exactly like a one-dimensional char[32] field's own string result); writing accepts a list of 10 strings. An unsized dimension (char[]) is permanently restricted to being the sole dimension of a one-dimensional declarator - char names[10][] is rejected, not silently treated as some other shape.

Terminated strings

An empty character dimension (char[], wchar[], wchar<[], or wchar>[]) scans for a NUL code unit. Named terminated primitives can use NUL or LF. Their byte size is found while reading, and their alignment is one.

Family Strict encoding Terminator Bytes for "A"
char[], cstring, ascii_string_zero ASCII NUL 41 00
ascii_string_newline ASCII LF 41 0A
utf8_string_zero UTF-8 NUL 41 00
utf8_string_newline UTF-8 LF 41 0A
wchar[], string, unicode_string_zero UTF-16 in layout order NUL code unit LE: 41 00 00 00
string<, unicode_string_zero< UTF-16LE NUL code unit 41 00 00 00
string>, unicode_string_zero> UTF-16BE NUL code unit 00 41 00 00
unicode_string_newline* Matching UTF-16 variant LF code unit LE: 41 00 0A 00
struct root { utf8_string_zero value; byte tail; }
bytes        41 00 7E
value        └─A─┘  └tail
offsets       0      2

The terminated-strings fixture checks this exact example. Decoding rejects non-ASCII input for ASCII handlers, malformed UTF-8, odd-byte UTF-16, and unpaired surrogates. There is no replacement-character or byte-order-mark detection mode.

Named terminated strings can also be array elements:

struct root { cstring names[2]; uint8 tail; };

For bytes 41 00 42 43 00 63, names[0] is "A" at offset 0, names[1] is "BC" at offset 2, and tail is 99 at offset 5. Selecting a later string or field measures preceding strings, including their terminators. A fixed element count does not imply a fixed byte extent.

MaxStringBytes includes the complete encoded terminator. A limit of 2 rejects 41 42 00 with ReadLimitExceeded. GetArrayLength returns the decoded character/code-unit count without the terminator.

A selected update may replace a terminated value only inside the existing storage plan; it does not relocate later fields. A value containing its own terminator is invalid.

Choose the correct shape

Shape Fixed size Length result Indexed path Main limit
Fixed T[N] Only when count and element size are fixed Element count Yes Array elements
Runtime T[N] No Evaluated element count Yes Array elements and expression
Fixed char[N] / wchar[N] Yes Code-unit count Yes Array elements
Multidimensional T[a][b]... Only with fixed-size elements (every dimension is fixed) Current dimension's own count Yes, up to one index per dimension Total leaf elements
Terminated string No Decoded count No element indexing Encoded string bytes

Do not confuse zero-filled fixed text with a terminated scan, infer an array count from remaining stream bytes, omit runtime variables on a later operation, or assume UTF-16 code units equal user-perceived characters.

See the text guide, selected-read guide, and update guide.

Other byte-bounded encodings

latin1 name[N], cp437 name[N], utf16le name[N], and utf16be name[N] use the same byte-counted contract as utf8. N can be a constant or a runtime expression. Reads preserve embedded NULs and BOM characters; writes add no BOM, reject unmappable or malformed text, and zero-pad unused capacity. UTF-16 requires an even byte capacity and valid surrogate pairs. The byte order is explicit in the type name and does not depend on the enclosing layout.

Scalar values and indexed elements are raw bytes with alignment 1. Unsized and multidimensional buffers are rejected; use arrays of containing structs for tables of text. String-byte and array-element limits both apply. Existing wchar[N] continues to count UTF-16 code units, so wchar[4] occupies eight bytes while utf16le[4] occupies four.

CP437 follows the Unicode mapping (including control characters for bytes 0–31), not the old display-font glyphs. It is deterministic in managed and browser builds. Latin-1 is ISO-8859-1, not Windows-1252.