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.