Chapter 35 XNB Type Readers and Object Graphs
An XNB body is a serialized object graph. Its table names reader factories; one ContentReader session constructs the root and nested values, then resolves shared resources. Chapter 34 covers the envelope and compression. This chapter covers reader registration, graph semantics, built-in layouts, and their evidence.
35.1 Two registration systems
XNB readers use the process-wide ContentTypeReaderManager. Loose and CNJ readers use a per-ContentManager map. The similarly named extension points are not interchangeable:
| Operation | Scope | Selection |
|---|---|---|
| AddTypeCreator(name, factory) | Process-wide XNB registry | Canonical reader name from the file’s type-reader table. |
| RegisterTypeReader<T> | One manager | Requested C++ type after XNB is absent. |
| RegisterCnjLoader<T> | One manager | CNJ envelope type string through the generated generic CNJ reader. |
CNA has no reflection fallback for XNB names. The global registry starts empty and is not populated by Game or ContentManager. Applications using built-in XNB content must register it before the first load:
The umbrella call is idempotent. Closed generic collection readers beyond the known SpriteFont dependencies still require exact registration because C++ cannot construct an arbitrary ListReader<T> from reflection.
AddTypeCreator retains the first factory for an exact key; later registrations are ignored. ClearTypeCreators clears all families for all managers and is intended for test isolation. The map is unsynchronized, and empty names or empty factories are not rejected. Complete registration before concurrent loads.
35.2 One object-graph session
35.2.1 ContentReader contract
The manager constructs ContentReader at the body immediately after the container header. ReadAsset<T> then:
-
1.
parses the type-reader table at the current stream position;
-
2.
reads the shared-resource count;
-
3.
deserializes one indexed root object;
-
4.
reads every shared object;
-
5.
runs queued typed fixups.
The constructor comment saying the table was already consumed is stale. A second ReadAsset does not rewind or reuse the first table. Treat one reader as one body session.
The reader borrows raw pointers to its stream and manager. Its base BinaryReader closes the stream by default but does not delete it. The manager’s stack ordering is safe; a standalone caller must keep both referents alive and account for stream closure.
Initialization creates one fresh reader for every table entry, enforces its serialized-version policy, then calls Initialize in a second pass. The ContentTypeReaderManager passed to initialization is temporary; a custom reader must not retain that reference. The reader instances themselves die with the session.
35.2.2 Indices, shared resources, and external references
Object indices are one-based: zero means null/default, and positive selects table reader . A zero for a non-default-constructible target with no existing value cannot represent C#’s default(T) and raises ContentLoadException. A reader/result type mismatch can instead surface std::bad_any_cast.
A positive shared-resource index queues a fixup. CNA reads all shared objects before running any fixup, enabling forward and cyclic references. Index zero queues nothing; a negative index also queues nothing because the implementation tests only index > 0. Out-of-range positive indices are rejected. Wrong typed values and callback exceptions are not normalized into one transaction, so reader-side effects can survive a later failure.
Disposal recording is narrower than its documentation. It recognizes only a statically typed shared_ptr<U> where U derives from IDisposable, and only when the caller supplied a callback. Normal manager XNB loading supplies none; type-erased shared reads skip tracking. Do not treat the reader as a general lifetime coordinator.
ReadExternalReference<T> returns empty for an empty string, otherwise requires a manager, confines the logical path, and re-enters ordinary Load<T>. Despite its template declaration, the implementation is linked only for Texture2D and TextureCube at the pin.
35.2.3 Limits
XnbReadLimits is applied at named sites, not as one aggregate budget. Reader-table and shared-resource counts are checked; collection and decoded-image limits apply when a reader calls their helpers. Object dispatch and nested generic names have a bounded depth, with RAII restoring the counter after failure. The type-reader-name bound is checked only after the underlying string allocation, and ordinary serialized strings do not use that same limit.
The manager reads an uncompressed file before the compressed-payload size control could help. Consequently these limits are useful defenses, not proof that every allocation or total object graph is bounded.
35.3 ContentTypeReader and type erasure
ContentTypeReader<T> lets an author implement typed Read(ContentReader&, optional<T>). CNA’s separate ContentTypeReaderBase erases that call to std::any. Canonical target names are strings, not checked runtime types; the eventual any_cast is the effective type check.
Copyable targets are stored directly in any. Move-only targets are boxed as shared_ptr<T>. Indexed graph dispatch unwraps both forms, which is how bare SoundEffect and TextureCube roots load. The public direct ReadObject<T>(reader) and ReadRawObject<T>(reader) overloads always cast to a bare T; they therefore fail for those move-only targets. Use indexed dispatch for them.
An existing instance is transported into the reader and a value is returned; the caller’s original C++ object is not updated in place. Collection readers additionally differ: List appends to supplied elements, Dictionary clears first, and Array resizes. Duplicate dictionary keys are silently retained at their first value because CNA uses unordered_map::emplace; FNA’s Dictionary.Add throws.
Closed collections create their named element readers from the global registry rather than using the initialized peer instance in the current file table. This works for stateless built-ins. Custom stateful or closed-generic readers must register and test their exact combinations.
35.4 Built-in reader families
Unless a reader widens SupportsVersion, CNA accepts serialized reader version zero only. FNA generally reads and ignores these reader-version integers. Version admission is therefore a CNA compatibility boundary separate from the XNB container version.
35.4.1 Scalar and math layouts
Primitive readers consume explicit little-endian binary operations, not host-memory layouts. Boolean is one byte; integers and IEEE floats use their fixed widths. XNB Char decodes one UTF-16 code unit from one to three UTF-8 bytes and rejects a four-byte code point. String is a 7-bit byte length followed by bytes in std::string; this layer does not validate UTF-8.
Math readers are literal field sequences:
-
•
vectors, quaternion, and matrix read Singles in public component/field order;
-
•
Color reads four RGBA bytes, not its packed integer;
-
•
Plane, Point, Rectangle, Box, Sphere, Frustum, and Ray read their obvious constituent values and construct the public type.
They do not validate finiteness, normalization, nonnegative dimensions/radii, min/max ordering, or matrix invertibility. CNA also has no generic EnumReader<T> fallback; an asset naming one needs an exact application-provided factory.
Evidence is mostly local writer/reader symmetry. External SpriteFont fixtures exercise selected nested Char, Rectangle, and Vector3 values, but no independent corpus covers every scalar/math layout or Unicode edge case.
35.4.2 Decimal, TimeSpan, and DateTime
Decimal reads the .NET lo/mid/hi/flags wire into a 96-bit mantissa, sign, and scale. It is not registered on MSVC-family builds because sharp-runtime uses unsigned __int128. Reserved flags are not validated, and negative zero is canonicalized, unlike the managed reference behavior.
TimeSpan preserves a signed 64-bit tick count. DateTime preserves the lower 62 tick bits but drops the two-bit DateTimeKind; kind value three is also admitted after masking where the managed constructor rejects it. The focused tests establish ordinary positive values, not bit-for-bit Decimal or DateTime metadata fidelity across compilers.
35.4.3 CurveReader
CurveReader consumes PreLoop, PostLoop, signed key count, then each key’s position, value, precomputed incoming/outgoing tangents, and continuity. It does not call ComputeTangents.
Enum integers are cast without range checks. A negative count becomes an empty loop, while a large positive count bypasses the common collection ceiling. NaN, infinity, duplicate positions, and invalid loop/continuity values are admitted. Direct existing-value use appends keys without clearing. The retained test uses a locally written ordinary body; no external XNB Curve fixture or manager-level integration artifact is present.
35.4.4 Texture2DReader
The payload is surface format, width, height, level count, then a byte count and payload for every mip. CNA accepts Color and DXT1/3/5; DXT data are software-decompressed to RGBA before upload. Dimensions, topology, checked decoded size, device limit, and exact per-level byte counts are validated.
Version-4 legacy mapping is narrow, and a recognized ColorBgraEXT mapping still reaches the reader’s accepted-format refusal. CNA does not perform FNA’s Xbox 360 texture byte swaps. External-producer evidence includes an exact one-pixel MonoGame Color XNB, an LZX Color texture checked for nonuniform output, and the DXT3 atlas nested in SpriteFont. No retained DXT1/DXT5, version-4, or Xbox Texture2D artifact establishes those paths.
35.4.5 Texture3DReader
Texture3D uses a copyable shared_ptr<Texture3D> target. Its wire adds depth and stores one blob per mip. Color and DXT1/3/5 are supported; compressed volume data are divided into depth slices and decompressed per slice. Dimensions and decoded multiplication are checked.
Compressed byte counts need not be exactly divisible by depth or equal the required block total; extra complete data and a remainder can be ignored after required slices succeed. No aggregate mip budget or per-axis device limit is imposed here. Texture creation cleanly refuses a renderer that reports no volume-texture capability. Evidence is a locally written small Color body with renderer-dependent success/refusal; there is no external Texture3D XNB or compressed-volume fixture.
35.4.6 TextureCubeReader
TextureCube reads format, base size, level count, then six faces in XNA face order, each with all mips. It supports Color and DXT1/3/5 and validates raw Color sizes. Compressed levels require enough blocks but can contain surplus bytes. The base decoded-face cap is not an aggregate six-face/mip-chain budget, and there is no pre-allocation cube-capability query.
A retained MonoGame DXT1 cube travels through ContentManager across all faces and seven levels. The test samples only selected nonuniform and terminal-level outcomes; it does not establish exact pixels, orientation, DXT3/5, or every face.
35.4.7 SpriteFontReader
SpriteFont is a nested graph: Texture2D atlas; Rectangle glyph and crop lists; Char list; line spacing; float spacing; Vector3 kerning list; then optional default character. The umbrella registers its three closed list dependencies and Texture2D.
Count limits apply per list, but the reader does not require parallel lists to have equal lengths, validate rectangles against the atlas, require finite spacing, or reject duplicate characters. The runtime lookup keeps the last duplicate index. Direct existing-font input is unsupported.
Two externally produced MonoGame fixtures establish the ordinary graph: an uncompressed DXT3 font and an LZX-compressed font. They are strong layout and compression evidence, not malformed font validation.
35.4.8 ModelReader
ModelReader reconstructs declarations, vertex/index buffers, bones, meshes, parts, and their shared buffer/effect references. Counts and major byte multiplications are bounded. Vertex declaration offsets/enums versus stride, index-byte divisibility, draw ranges, and much mesh metadata remain trusted.
The serialized parent field on each bone is consumed but ignored; parent links are formed from child lists. Contradictory lists can therefore overwrite a child’s parent or disagree with the serialized scalar. Tags are consumed to keep alignment but any non-null tag is rejected.
The retained uncompressed MonoGame cube establishes two bones, one mesh/part, buffers, a BasicEffect, and shared-resource fixups. Deterministic whole-container mutation exercises clean failure around that artifact. It does not validate arbitrary hierarchy/draw metadata or prove that every accepted graph is safe to draw.
35.4.9 Stock-effect readers
Five readers construct BasicEffect, AlphaTestEffect, DualTextureEffect, EnvironmentMapEffect, and SkinnedEffect from their ordinary FNA-shaped material fields. They require the manager’s GraphicsDevice and return shared_ptr<Effect>; textures are external Texture2D/TextureCube references with CNA’s logical containment.
The general EffectReader is separately registered for a length-prefixed XNA/FNA Direct3D 9 Effect Framework binary. It requires a GraphicsDevice, reads at most 64 MiB exactly, and calls the public Effect byte-buffer constructor. FNA3D accepts it in the normal build; SDL_GPU, EasyGL and Vulkan require their compiled-effects option. A renderer whose CompiledEffects capability is false yields a wrapped ContentLoadException. This route is distinct from the five field-wise stock readers and does not accept .fx source, HLSL source, DXBC, or MGFX.
The tag’s XnbBuiltInReaders.hpp umbrella comment still calls the general reader a “known-unsupported” registration, but EffectContentTypeReader.cpp constructs the effect as described above. The implementation and focused tests supersede that stale comment.
The external Model fixture establishes its nested BasicEffect. The other four have locally built field-order tests but no external-producer root XNB or texture graph. Reader success also does not establish identical field consumption by every renderer; Chapter 15 owns draw-time behavior.
35.4.10 SoundEffectReader
SoundEffectReader consumes a WAVEFORMATEX block, audio bytes, loop start, loop length, and duration. PCM16 enters the native raw-buffer route. PCM8, IEEE float, IMA-ADPCM, and MS-ADPCM are wrapped as WAV and decoded by SDL; missing MS-ADPCM coefficient data is synthesized from the standard table. XMA2 is rejected. Xbox WAVEFORMATEX fields receive the same selective byte swaps as FNA.
Loop metadata is not bounded against decoded audio. In the wrapped-WAV route, a positive loop computes signed loopStart + loopLength before writing the WAV sample-loop endpoint; an extreme positive pair can overflow signed Int32. Ordinary loop metadata is tested, but this adversarial edge is not.
External PCM16 and ADPCM fixtures establish several decode paths; move-only root dispatch is also covered. Those artifacts do not establish XMA2, arbitrary WAVEFORMATEX extensions, malformed loop safety, or every platform decoder.
35.5 Registration and platform inventory
RegisterAllBuiltInXnbReaders registers primitives, math, Decimal/DateTime/TimeSpan, Curve, 2D/3D/cube textures, SpriteFont, SoundEffect, Song, stock effects, Model support, and the renderer-qualified general Effect reader. Video is included only when FFmpeg is compiled. Decimal availability also varies by compiler. A single unconditional reader count would misrepresent those builds.
35.6 Rules for custom readers
A custom binary reader should preserve these session rules:
-
1.
register the exact canonical XNB name before loading;
-
2.
treat zero as the null/default object index and positive indices as one-based;
-
3.
declare a serialized-version policy deliberately;
-
4.
use indexed object dispatch for polymorphic and move-only values;
-
5.
bound counts and decoded sizes before allocation;
-
6.
queue shared-resource fixups and run them only after all shared objects are read;
-
7.
confine external references and re-enter ContentManager for their cache/resolution policy;
-
8.
keep reader, temporary manager, stream, and owning manager lifetimes within the session.
Field-by-field decoding can appear correct while violating identity, null semantics, lifetime, or resource bounds. A complete test should therefore include a real container table, manager load, nested/shared references where applicable, a failure case before allocation, and an observable result beyond successful construction.
35.7 Summary
CNA implements a substantial XNB object graph without .NET reflection. That strength creates explicit boundaries: applications must register readers; versions are stricter than FNA; closed generics are finite; move-only values use a separate erased representation; and reader-specific validation varies. The per-family evidence above should be read as a set of scoped contracts, not as one claim that all XNB content is interchangeable.