XNB type readers: wire contracts and validation
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the reader sources, XnbCanonicalData.cpp and the fixture manifests at 009d40f5; no test was run. Char, String and Decimal behaviour belongs to sharp-runtime and was read at an unpinned sibling revision.
The 61 built-in XNB type readers are listed on XNB Loading & Interoperability; this page gives what each family actually reads, validates and refuses at this snapshot, and which evidence stands behind it. Most of the texture, font, audio and model readers now share one canonical decoder with the build-time XNB importer, so the same rules decide what a game can load and what cna-content can transcode to CNB. Container and session rules (indices, shared resources, limits) are on The XNB container and object graph.
One decoder for the game and the build
The texture, SpriteFont, SoundEffect, Curve, Song, Video, vertex/index-buffer and stock-effect readers in modules/content/src/Xnb call the Decode…XnbData functions of XnbCanonicalData.cpp and then build runtime objects from the decoded description; the Model reader shares the graph walk ReadXnbModelGraph with the importer through a different sink. DecodeXnbCanonicalAsset, the build-time entry, runs the same decoders with no GraphicsDevice and no reader objects. A validation added to a decoder therefore changes what loads and what builds at once, which is also why several limits described in older notes as missing now hold for both.
Every reader that creates GPU objects (textures, fonts, buffers, models, effects, videos) needs the owning manager: without one it throws ContentLoadException ("no GraphicsDevice available"), and with one it asks getGraphicsDeviceInternal(). The SoundEffect reader needs neither. Unless a reader overrides SupportsVersion (none does), it admits serialized reader version 0 only.
Primitive and math layouts
Primitives are explicit little-endian reads through BinaryReader, never host-memory copies: Boolean is one byte, the integers and IEEE floats their fixed widths. String is a 7-bit length followed by that many bytes, stored in a std::string without UTF-8 validation. Char is decoded from UTF-8 into a UTF-16 code unit by sharp-runtime's BinaryReader::ReadChar; at the unpinned sibling revision read for this page a supplementary scalar yields its high surrogate and keeps the low one for the next character read, so a four-byte sequence in an XNB Char field is not rejected there.
The math readers (MathContentTypeReaders.hpp) are literal field sequences: vectors and quaternions read Singles in component order, Matrix sixteen Singles from M11 to M44, Color four bytes R, G, B, A (not its packed integer), Plane a normal and D, Point and Rectangle Int32s, BoundingBox min and max, BoundingSphere centre and radius, BoundingFrustum one matrix, Ray position and direction. None checks finiteness, normalisation, non-negative sizes or radii, min/max order or invertibility. ContentReaderTest.MathReadHelpersReadFieldsInFnaOrder pins the field order; external fixtures exercise only the Char, Rectangle and Vector3 lists nested in SpriteFonts, so most scalar and math layouts rest on CNA's own writer-and-reader symmetry.
Decimal, TimeSpan and DateTime
DecimalReader reads the .NET wire order lo, mid, hi, flags through sharp-runtime's ReadDecimal, taking the sign from bit 31 and the scale from bits 16 to 23 and ignoring the other flag bits. It exists only where sharp-runtime reports a native 128-bit integer (SHARP_RUNTIME_HAS_NATIVE_INT128), which is why the built-in count is 60 on toolchains without one, MSVC among them. TimeSpanReader keeps a signed 64-bit tick count. DateTimeReader reads a UInt64, masks off the top two bits and keeps the ticks: the DateTimeKind is discarded even though sharp-runtime's DateTime has a (ticks, kind) constructor and stores a kind, so a Utc or Local value loads as an unspecified-kind tick count and a Local value is not converted to local time as XNA's reader does (CNA-GAP-034); a kind value of 3, which the managed constructor rejects, is admitted by the mask. The tests cover ordinary positive values (DateTimeReaderExtractsTicksMaskingOutKindBits), not bit-exact Decimal or DateTime metadata across compilers.
Curve
CurveReader reads PreLoop, PostLoop, an Int32 key count and then per key position, value, tangent in, tangent out and continuity. It does not call ComputeTangents. The count now goes through CheckCollectionElementCount, so a negative count or one above ten million is refused; the enum integers are cast without range checks, and NaN, infinity and duplicate positions are admitted. An existing curve is extended, not cleared. The evidence is CNA's own curve_two_keys.xnb corpus file (uncompressed and LZX) and focused tests; no externally produced Curve XNB is committed.
Textures
Texture2DReader
The body is surface format, width, height, level count, then a byte count and payload per level. The decoder and reader enforce, in this order:
- Format. A version-5 file stores the XNA 4.0
SurfaceFormatordinal; every ordinal fromColortoHdrBlendableis accepted, plus CNA'sColorBgraEXT. A version-4 file stores the XNA 3.1 ordinal, of which only four are mapped: 1 toColorBgraEXTand 28, 30, 32 to DXT1, DXT3, DXT5; any other legacy ordinal is refused. - Dimensions. Width and height must be positive and no larger than the device's
GetMaxTextureDimension(); the decoded size of level 0 goes throughCheckedMultiplyOrThrowandCheckDecodedByteSize. - Mip chain. The level count must lie between 1 and the full chain, and a Texture2D must carry either level 0 alone or the complete chain, never a prefix, so loading never invents absent mip data.
- Bytes. Every level's byte count must equal exactly what its format and size require (DXT sizes rounded up to 4×4 blocks), and the cumulative decoded size of all levels must stay within
maxDecompressedSize.
DXT data stays compressed when the active renderer reports LoadsCompressedContentNativelyEXT() and accepts that format as a block transfer; otherwise it is decompressed in software to Color before upload. One exception keeps XNA content loading: under the Reach profile, a block-aligned non-power-of-two DXT atlas is always decompressed to Color, because XNA's loader accepted such authored atlases (the Platformer sample's HUD font is DXT3 at 128×132) although the public Reach Texture2D constructor refuses that shape. Non-DXT formats are uploaded as stored, so whether a float or 64-bit format actually creates a texture is decided by the device and profile. An existing texture is reused only when its size, upload format and level count match; otherwise the reader throws. The payload is not byte-swapped for Xbox 360 files: a 'x' texture loads as stored, and the build-time transcoder refuses it. The registration also covers the legacy TextureReader name, an inert base reader that returns the existing instance or null.
Evidence: MonoGame-produced white-1.xnb (one Color pixel) and Explosion.xnb (despite its name a 64×64 Color texture, LZX-compressed), an LZ4 variant of white-1.xnb produced with upstream liblz4, the genuine XNA 4.0 Hud.xnb for the Reach atlas case, the DXT3 atlases nested in two MonoGame SpriteFonts, CNA's own texture2d_color_mips.xnb, and a 1,500-iteration container fuzz run over white-1.xnb in each compression. No externally produced DXT1 or DXT5 Texture2D, version-4 file or Xbox 360 texture is committed.
Texture3DReader
The target is shared_ptr<Texture3D>, because Texture3D is move-only. The wire adds depth; each level is one blob for the whole volume. Formats are those Texture::IsVolumeFormatAllowedByProfileEXT allows under HiDef: Color, the packed 16-bit formats, Rgba1010102, Rg32, Rgba64, Alpha8 and the float formats. DXT and the signed normalised formats are refused, and data are uploaded as stored, with no software decompression. Dimensions must be positive, the level count may be any prefix of the full chain, and every level's byte count must be exact, with the same cumulative decoded-size budget. The reader itself imposes no per-axis limit; creating the texture does: a Reach device refuses volume textures entirely and HiDef caps each axis (256 by the common renderer default), both as System::NotSupportedException, which the XNB tier does not wrap. An existing instance is reused as it is. Evidence: CNA's own texture3d_two_slices.xnb and hand-built bodies in Texture3DTextureCubeContentTypeReaderTests.cpp, whose texture cases are skipped on renderers that cannot hold textures; no external Texture3D XNB exists in the tree.
TextureCubeReader
The body is format, edge size, level count, then six faces in XNA face order, each with all its levels. Formats are the XNA 4.0 set without the two signed normalised ones, which no cube may carry on either profile. Sizes and exact per-level byte counts are validated as for Texture2D and the decoded-size budget is cumulative over all six faces and levels; a partial chain is accepted. DXT stays compressed when the renderer accepts it as a cube block transfer, otherwise it is decompressed to Color; an existing cube must match size, format and level count. The reader performs no device-dimension query before allocation. Evidence: the MonoGame SampleCube64DXT1Mips.xnb travels through ContentManager across all six faces and seven levels, sampling selected levels rather than every pixel; CNA's texturecube_six_faces.xnb; no DXT3 or DXT5 cube fixture.
SpriteFontReader
A SpriteFont body is a nested graph read inline: a Texture2D atlas (through the full Texture2D rules above), a List<Rectangle> of glyph bounds, a List<Rectangle> of cropping, a List<Char>, the Int32 line spacing, the Single spacing, a List<Vector3> of kerning and an optional default character. Each nested object must name exactly the expected reader at version 0, and each list count is bounded. The four lists must have equal lengths, or the load fails with "glyph, cropping, character, and kerning counts differ". Still not validated: rectangles against the atlas, finite spacing, duplicate characters (the runtime lookup keeps the last index of a repeated character) and whether the default character is in the map. Reading into an existing font throws System::NotImplementedException, which ordinary dispatch never reaches. RegisterSpriteFontXnbReader also registers the three closed list readers the font's table names. Evidence: MonoGame's Default.xnb (uncompressed, DXT3 atlas) and FontCalibri14.xnb (LZX, multi-block), the genuine XNA 4.0 Hud.xnb, and CNA's spritefont_two_glyphs.xnb; they establish the ordinary graph and compression, not malformed-font handling.
ModelReader and the buffer readers
The Model graph (XnbModelGraphReader.hpp, ModelContentTypeReaders.cpp) is read in XNA's order:
- a UInt32 bone count, then each bone's name (an indexed
String) and transform; - for each bone, a parent reference, a child count and the child references;
- an Int32 mesh count, then per mesh its name, parent-bone reference, bounding sphere, tag and an Int32 part count;
- per part the vertex offset, vertex count, start index and primitive count, a tag, and three shared-resource references for the vertex buffer, index buffer and effect;
- the root-bone reference and the model's tag.
A bone reference is one byte when the model has fewer than 255 bones and a UInt32 otherwise; 0 means none and n means bone n - 1. All counts are bounded, and child, mesh-parent and root references are range-checked. The serialized parent of each bone is read and ignored: parents come from the child lists, through ModelBone::AddChild, which sets the child's parent each time, so contradictory lists leave a bone under the last parent that listed it. The part's draw ranges and much mesh metadata are trusted. Tags are accepted when null, when they decode to a Dictionary<String, Object> (boxed as ObjectDictionaryEXT), a Dictionary<String, List<Vector3>> (boxed with its managed type name, the shape XNA's triangle-picking sample writes) or a shared_ptr<System::Object> from a custom reader; any other non-null tag is refused. Buffers and effects arrive through shared-resource fixups and are owned by the returned Model. Reading into an existing Model throws ContentLoadException.
The buffer readers: VertexDeclarationReader requires a positive stride and bounds the element count but trusts each element's offset and enums; VertexBufferReader checks the vertex count times stride with CheckedMultiplyOrThrow and the decoded-size limit and reads exactly that many bytes; IndexBufferReader reads a 16-bit flag and a byte count that must be non-negative and a whole number of indices. Evidence: the MonoGame BlenderDefaultCube.xnb (bones, one mesh and part, buffers, a BasicEffect and the fixups), CNA's model_triangle_basiceffect.xnb, and a 1,500-iteration container fuzz run over the cube, skipped where the renderer has no 3D pipeline. None of it proves that every accepted graph is safe to draw.
Effects
The five stock-effect readers (StockEffectContentTypeReaders.cpp) read their textures first, as typed external references with the containment rules of the object session, and then their fields:
| Reader | External references | Fields after them |
|---|---|---|
BasicEffectReader | Texture2D | diffuse, emissive, specular colour (Vector3), specular power, alpha, vertex colour enabled; a present texture also turns TextureEnabled on |
AlphaTestEffectReader | Texture2D | alpha function (Int32 CompareFunction), reference alpha (UInt32), diffuse colour, alpha, vertex colour enabled |
DualTextureEffectReader | Texture2D, Texture2D | diffuse colour, alpha, vertex colour enabled |
EnvironmentMapEffectReader | Texture2D, TextureCube | environment-map amount, environment-map specular, Fresnel factor, diffuse and emissive colour, alpha |
SkinnedEffectReader | Texture2D | weights per vertex, diffuse, emissive and specular colour, specular power, alpha |
All five return shared_ptr<Effect>, not the concrete class, because std::any_cast needs the exact type and a Model's effect slot cannot know which stock effect a file used. The Model fixture establishes BasicEffect; the other four rest on field-order tests with hand-built bytes and CNA's own writer.
The general EffectReader (EffectContentTypeReader.cpp) reads an Int32 length between 0 and 64 MiB, exactly that many bytes of Direct3D 9 Effect Framework bytecode, and constructs Effect(device, bytes). A renderer build without the CompiledEffects capability makes that constructor throw, and the reader wraps any such std::exception in ContentLoadException. It accepts no .fx source, HLSL, DXBC or MGFX. Which renderer builds report the capability is on Effects System.
EffectMaterialReader reads a typed external reference to the effect (a missing one is refused), clones it into an EffectMaterial and applies a Dictionary<String, Object> of parameter values. A name the effect does not declare is skipped, as FNA does; a value of a type CNA cannot convert is refused rather than dropped, and texture values are retained by the material so the parameter never points into the reader's temporary table. ExternalReferenceReader returns the untyped external reference as std::any.
SoundEffect, Song and Video
SoundEffectReader
The body is a WAVEFORMATEX block (its length at least 16 bytes and at most 1 MiB), an optional extension, the sample bytes (read exactly), loop start, loop length and a duration in milliseconds. On an Xbox 360 file ('x') the WAVEFORMATEX fields are byte-swapped as FNA does; the samples are not. Then:
- 16-bit PCM and 8-bit PCM are taken directly; 32-bit IEEE float, MS-ADPCM and IMA-ADPCM (4 bits) are wrapped as an in-memory WAV and decoded to 16-bit PCM by CNA's own
DecodeWavToPcm16in the audio module, available on every audio implementation; XMA2 and unknown tags are refused. - An MS-ADPCM block whose extension is shorter than the 32 bytes of a real coefficient table (XNB files write
cbSize0) receives the standard coefficient table and a samples-per-block value derived from the block alignment. - Only mono and stereo are accepted, a zero sample rate is refused, and processing caps the rate at 384,000 Hz.
- Negative loop values are refused and
loopStart + loopLengthis compared with the decoded frame count in 64-bit arithmetic, so an extreme pair cannot overflow. - The stored duration is a sanity oracle: a decoded length more than twice or less than half of it is refused. It cannot prove waveform identity.
The loop region is kept as the file declares it (processing policy AsAuthored); only a build gives a loopless source the whole sound, as XNA's processor does. Evidence: six MonoGame tone fixtures (16-bit mono and stereo, 8-bit, float, IMA-ADPCM, MS-ADPCM), the WAVEFORMATEX property sweep in SoundEffectContentTypeReaderPropertyTests.cpp and a 1,500-iteration container fuzz run; nothing covers XMA2 or arbitrary extensions.
SongReader
A Song body is a media reference and a duration. A file from a real content pipeline writes the duration as an object through Int32Reader, so its table has two entries; CNA's older hand-built fixtures wrote a bare Int32 with a one-entry table, and the reader tells them apart by the table size. The reference is contained relative to the .xnb's folder; when the resolved path is longer than four characters its last four are stripped and FNA's Normalize probe tries the stem, then .ogg, .oga and .qoa, falling back to the stored spelling. That keeps the desktop convention of shipping an .ogg beside an XNA .wma placeholder; it is not general extension parsing. The chosen file is contained a second time, so a probe cannot select a symlink out of the root. The Song constructor checks that the file exists, so a valid XNB with no companion fails during Load<Song> with System::IO::FileNotFoundException, unwrapped; decoding waits for playback. The MonoGame one_two_three.xnb with its .ogg establishes dispatch, sibling lookup, duration and the .ogg route, not playback or the other probes.
VideoReader
A Video body carries a reference, duration, width, height, frame rate and soundtrack type, either as direct fields (CNA's historical fixtures) or as FNA's object references through StringReader, Int32Reader and SingleReader, again told apart by the table size. The reference is contained and probed like a Song's, with .ogv then .ogg. The reader stores the metadata through the trusted-metadata constructor and never opens the media, so it loads without the FFmpeg backend and even without the file; VideoPlayer::Play is where failure appears. There it throws NotSupportedException in a build without a video backend, stays stopped without an exception when the decoder cannot open the file, and throws InvalidOperationException when the stream's size or frame rate (beyond a 1 fps tolerance) disagrees with the stored metadata. The strongest test uses a test-built XNB and CNA's FFmpeg fixture; no externally produced Video XNB is committed.
From XNB to CNB: what transcoding refuses
cna-content accepts an .xnb as a source and rebuilds it as native CNB through DecodeXnbCanonicalAsset and XnbContentPipeline.cpp. It never wraps XNB bytes inside a CNB file, and it refuses what frozen CNB schema 1 cannot express rather than normalising it silently:
- files above 64 MiB, a declared length that is not exactly the file size, and a root reader at a version other than 0;
- roots other than Texture2D, Texture3D, TextureCube, SpriteFont, SoundEffect, Curve, Song, Video and Model, reported with the reader's identity (
CustomRootStillFailsWithReaderIdentity); - shared resources in any root except a Model;
- texture formats other than
Colorand DXT1/3/5, which become Rgba8, and Xbox 360 texture or sample payloads; - a Model with any non-null tag, and malformed or unsupported Model semantics (
ModelStillRejectsUnsupportedOrMalformedSemanticsPrecisely); - external media or texture references that are absolute, escape the source root or are not valid logical names.
Where both formats express the same object, XnbContentPipelineTests.cpp compares the runtime result of the original XNB with the runtime result of the transcoded CNB: identical pixels for Texture2D (uncompressed and LZ4), equivalent SpriteFont and SoundEffect semantics, and graph, buffer, sharing and effect equivalence for Models, including the MonoGame cube through Model schema 2. Those are the field-level comparisons behind the claim that migration preserves content; they were read, not run.
Evidence and what is not proven
Read at this snapshot from the reader sources and headers linked above, XnbCanonicalData.cpp and the tests and fixture manifests under tests/assets/xnb; nothing was built or run. External-producer evidence exists for Texture2D, TextureCube, SpriteFont, Model with BasicEffect, SoundEffect, Song and a List<String> written by genuine XNA 4.0; Texture3D, Curve, Video and four of the five stock effects rest on CNA's own writer and hand-built bytes. CNA's own XNA 4.0 interoperability claims for the files it writes are summarised on XNB Loading & Interoperability. The Char, String and Decimal behaviour belongs to sharp-runtime and was read at a sibling revision the snapshot does not pin.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Content architecture
- Maintainer workflow
- I need to modify ContentManager
- Tests and validation
- Test architecture and change recipes