Skip to content
The CNA BibleCNA 0.1.0-alpha.1 Edition

Chapter 34 The XNB Container

An XNB begins with a small framing header, optionally decompresses into one object stream, and then hands that stream to the reader architecture in Chapter 35. Keeping the layers separate is important: a valid header does not make the object graph safe, and a correct type reader cannot repair a truncated or allocation-hostile container.

An XNB file passes through its ten-byte header and optional LZX decompression into one ContentReader session. The file's reader table maps type identifiers to registered readers. Readers construct the root object, nested objects, and shared-resource references. Deferred fixups connect shared resources after the main object stream. Container validity, reader availability, and object-graph validity are separate boundaries.
Figure 34.1: XNB container, reader-table, and object-graph layers. Passing an earlier boundary does not establish the later ones.

34.1 The ten-byte header

Offset Field Rule
0–2 magic three separately read bytes: XNB
3 platform one of the 16 FNA platform identifiers
4 version exactly 4 or 5
5 flags compression bits plus profile flags
6–9 total length little-endian signed 32-bit file length

Reading the magic one byte at a time is a deliberate deviation from FNA’s implementation: a truncated stream throws EndOfStreamException at the missing byte rather than comparing partially initialized data against one packed magic constant.

The accepted platform bytes are FNA-exact: w x m i a d X W n u p M r P g l. MonoGame’s later post-fork identifiers are rejected. Compatibility here means accepting the historical FNA/XNA set, not every container another descendant also calls XNB.

34.2 Compression is four-valued

Two independent flag bits produce four outcomes:

Bits Result Runtime behavior
neither None object stream starts at byte 10
0x80 Lzx read decompressed-size hint and decode framed LZX blocks
0x40 Lz4 named ContentLoadException; deliberately deferred
both Unknown reject instead of guessing which codec wins

The LZ4 bit comes from MonoGame’s implementation, while LZX is the historical XNA route. The reader keeps this provenance explicit rather than treating compression as one Boolean.

34.3 LZX preserves state across blocks

CNA’s decoder is a line-by-line C++ port of FNA’s LzxDecoder.cs. One decoder instance serves one file so the sliding window, repeated-offset LRU, and Huffman tables persist across the framed blocks. Constructing a fresh decoder for each block would pass trivial independent payloads and corrupt real multi-block streams.

The window exponent is bounded to 15–21; XNB uses 16. Block framing uses a two-byte big-endian compressed size, with the 0xFF sentinel introducing the extended form and explicit frame size. After all blocks, the produced byte count must exactly equal the declared decompressed size.

The strongest decoder oracle is cross-implementation: CNA output is compared byte-for-byte with FNA’s unmodified decoder executed under Mono. That oracle found a real heap-buffer-overflow in the long-code table-growth path, a class of defect that self-consistency fixtures could miss.

34.4 The reader table starts inside the decoded stream

The object stream begins with a 7-bit-encoded reader count, followed by reader-name/version pairs. The count is bounded, each name is retained in raw and normalized form, and parse errors are translated into ContentLoadException so callers do not need to catch a parser’s std::invalid_argument separately.

34.4.1 Canonical names require a parser

A reader name may contain nested generic arguments, assembly qualifiers, version, culture, and public-key tokens. Splitting at the first comma is therefore wrong. CNA’s recursive parser tracks nested brackets, strips assembly metadata, and rebuilds a canonical Base[[arg1],[arg2]] key. Its recursion is bounded by the same object-depth limit used later by the graph reader; before that guard, a sub-megabyte crafted name could exhaust the C++ stack.

34.5 Limits and enforcement points

Limit Value Enforcement
maxFileSize 64 MiB compressed payload before allocation; not the initial whole-file read
maxDecompressedSize 256 MiB size hint and decoded output
maxStringBytes 1 MiB post-read type-reader-name bound
maxTypeReaderCount 4,096 reader table count
maxObjectNestingDepth 256 type-name and object-graph recursion
maxSharedResourceCount 1,000,000 shared-resource table

The first row is intentionally uncomfortable. LoadXnbAsset<T>() and the reader-name scanner read the entire file into memory before the compressed-payload guard runs. The declared 64-MiB limit therefore does not cap an uncompressed file or the initial allocation. It is a partially enforced control, not a complete file-admission firewall.

34.6 Length checks close the container boundary

The loader cross-checks the header’s totalLength against the bytes actually read before using offsets into the body. Uncompressed content starts at byte 10; LZX adds the four-byte decompressed-size field and begins its compressed payload at byte 14. Truncation, impossible lengths, unsupported compression, and decoded-size disagreement all fail before object readers receive the stream.

This cross-check was added after a confirmed heap-buffer-overflow. The durable rule is broader: never trust the internal length merely because the filesystem supplied that many bytes, and never trust the filesystem length merely because an internal field points inside it. Validate the relationship in both directions.

34.7 What the container does not prove

A clean container says the envelope, compression, and reader table are structurally acceptable. It does not prove that reader versions are supported, nested object indices are in range, shared-resource fixups resolve, texture mip sizes match, or referenced paths remain inside their allowed root. Those checks belong to the object session and concrete readers in the next chapter.

Type at least three characters. Results are ranked by how often and where the words occur.