Tutorial 148: Custom Content Types with XNB, Reflective and CNB Readers

CNA Tutorials  ·  CNA snapshot 009d40f5

ℹ

What you’ll learn: How to choose between loose-file, XNB, reflective and CNB readers for a custom type, and how to write, register and debug each one against ContentManager::Load<T>.

ℹ

Before you start — Tutorial 46: Custom Content Readers (the loose-file route this tutorial builds on) and Tutorial 45: ContentManager. Everything below is written against CNA snapshot 009d40f5. The C++ on this page was syntax-checked against that snapshot’s headers; it has not been linked or run, so treat the hand-built .xnb bytes as a walk-through of the format rather than as a test result.

One game type, four ways in

Tutorial 46 loaded a LevelData from a JSON file the game reads itself. That is the right tool for files you author. It is the wrong tool when the bytes come from somewhere else: a content pipeline that already wrote a compiled .xnb for your type, or a build step of your own that writes a compact binary. This tutorial takes one type, MyGame::LevelData, and gets it into getContentProperty().Load<LevelData>() through the three compiled routes CNA has at this snapshot:

  1. A hand-written ContentTypeReader<T> for an .xnb whose writer you control.
  2. A ReflectiveTypeReaderBuilder<T> for an .xnb the XNA pipeline serialised reflectively, where you declare the field list once.
  3. A CNB loader registered with ContentManager::RegisterCnbLoaderEXT<T> for CNA’s own binary container.

All three sit above the loose-file readers in the ContentManager::Load<T> ladder. For a logical name it tries name.xnb first, then name.cnb (and a literal name.cnb), and only then the loose-file reader registered for T. None of them needs a reader registered on the ContentManager instance: an .xnb is dispatched by the reader table inside the file and a .cnb by the asset type id in its header.

Which mechanism do I choose?

MechanismBytes come fromRegisteredPick it when
LooseFileContentTypeReader<T>
Tutorial 46
A file you author and parse yourself (JSON, CSV, any binary) Per ContentManager instance: content.RegisterTypeReader<T>(std::make_unique<...>()) You control the source format and have no compile step.
RegisterCnjLoader<T>(typeName, fn) A .cnj JSON document whose "type" string names the loader Per instance; throws if T already has a reader Several .cnj types should deserialise into one C++ type.
ContentTypeReader<T> + ContentTypeReaderManager::AddTypeCreator An .xnb whose type-reader table names your reader Process-wide, by canonical reader name You own both ends (a custom ContentTypeWriter and this reader), or you port an existing XNA reader.
ReflectiveTypeReaderBuilder<T> An .xnb the XNA pipeline wrote for a type with no custom writer (the implicit reflective one) Process-wide, under ReflectiveReader`1[[Your.Type]] You cannot change how the file was written and only need the fields.
ContentManager::RegisterCnbLoaderEXT<T> A .cnb with a custom asset type id Process-wide; the table is guarded by a mutex You want CNA’s checksummed, deterministic container and write it from your own tool.
⚠

Two similarly named base classes. LooseFileContentTypeReader<T>::Read(const std::string& path, ContentManager&) is CNA’s own loose-file contract. ContentTypeReader<T>::Read(ContentReader&, std::optional<T>) is the binary XNB contract that ported XNA/FNA readers use. They are unrelated and are registered in different places, which is why a tutorial written before the July 2026 rename can send you to the wrong one.

The type and a byte helper

The type is the one from tutorial 46 with two changes that matter to a binary format: difficulty is an enum, and EnemySpawn is a class deriving from System::Object. That base class is how CNA models a .NET reference type: an .xnb writes a reference type inside a list with a reader index in front of every element, where it writes a value type inline. Overriding GetTypeName() gives the type the .NET name the file will use.

// LevelData.hpp -- the game's own content types (namespace MyGame == the .NET namespace an
// XNA-built .xnb would name).
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <vector>

#include "Microsoft/Xna/Framework/Vector3.hpp"
#include "System/Object.hpp"

namespace MyGame
{
    enum class Difficulty : std::int32_t { Easy = 0, Normal = 1, Hard = 2 };

    // A C# *class*: a reference type, so an .xnb writes it with its own reader index when it sits
    // in a list. Deriving from System::Object and answering GetTypeName() is what marks it as one.
    class EnemySpawn : public System::Object
    {
    public:
        Microsoft::Xna::Framework::Vector3 position;
        std::string                        type;
        std::int32_t                       health = 0;

        [[nodiscard]] const std::string& GetTypeName() const override
        {
            static const std::string name = "MyGame.EnemySpawn";
            return name;
        }
    };

    struct LevelData
    {
        std::string                              name;
        std::string                              skyboxTexture;
        std::int32_t                             width  = 0;
        std::int32_t                             height = 0;
        Difficulty                               difficulty = Difficulty::Normal;
        std::vector<std::shared_ptr<EnemySpawn>> enemies;
    };
}

To keep the walk-through self-contained, this helper writes the small subset of the .xnb container the examples need: the 10-byte header (XNB, platform byte w, version 5, flags 0, little-endian total length), then a body made of 7-bit-length-prefixed strings, little-endian Int32 values and floats. It does not compress.

// XnbBytes.hpp -- just enough of the .xnb container to write a small, uncompressed test file.
#pragma once
#include <cstdint>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>

class XnbBytes
{
public:
    void Write7Bit(std::uint32_t value)
    {
        while (value >= 0x80u) { body_.push_back(static_cast<std::uint8_t>(value | 0x80u)); value >>= 7; }
        body_.push_back(static_cast<std::uint8_t>(value));
    }
    void WriteString(const std::string& text)          // 7-bit length prefix, then UTF-8 bytes
    {
        Write7Bit(static_cast<std::uint32_t>(text.size()));
        body_.insert(body_.end(), text.begin(), text.end());
    }
    void WriteInt32(std::int32_t value)                // little-endian
    {
        for (int shift = 0; shift < 32; shift += 8)
            body_.push_back(static_cast<std::uint8_t>((static_cast<std::uint32_t>(value) >> shift) & 0xFFu));
    }
    void WriteFloat(float value)
    {
        std::uint32_t bits = 0;
        std::memcpy(&bits, &value, sizeof bits);
        WriteInt32(static_cast<std::int32_t>(bits));
    }
    // One type-reader table entry: the reader's name, then its version (0 for every built-in).
    void WriteReader(const std::string& name) { WriteString(name); WriteInt32(0); }

    // 'X' 'N' 'B', platform 'w', version 5, flags 0 (Reach, uncompressed), total length, body.
    void Save(const std::string& path) const
    {
        std::vector<std::uint8_t> file{'X', 'N', 'B', 'w', 5, 0};
        const std::uint32_t total = 10u + static_cast<std::uint32_t>(body_.size());
        for (int shift = 0; shift < 32; shift += 8)
            file.push_back(static_cast<std::uint8_t>((total >> shift) & 0xFFu));
        file.insert(file.end(), body_.begin(), body_.end());
        std::ofstream out(path, std::ios::binary);
        out.write(reinterpret_cast<const char*>(file.data()), static_cast<std::streamsize>(file.size()));
    }

private:
    std::vector<std::uint8_t> body_;
};

A hand-written ContentTypeReader<T>

Derive from ContentTypeReader<T>. The one thing that is easy to get wrong is the constructor: its argument is the .NET name of the type the reader produces, not the reader’s own name. Override the protected Read(ContentReader&, std::optional<T>) and consume the payload in exactly the order the writer produced it. ContentReader is a BinaryReader, so ReadString(), ReadInt32() and friends are there, plus ReadVector3(), ReadColor(), ReadMatrix() and the rest of XNA’s value-type helpers.

#include "CNA/Internal/Xnb/XnbBuiltInReaders.hpp"
#include "Microsoft/Xna/Framework/Content/ContentManager.hpp"
#include "Microsoft/Xna/Framework/Content/ContentReader.hpp"
#include "Microsoft/Xna/Framework/Content/ContentTypeReader.hpp"
#include "Microsoft/Xna/Framework/Content/ContentTypeReaderManager.hpp"

#include "LevelData.hpp"
#include "XnbBytes.hpp"

using namespace Microsoft::Xna::Framework::Content;
using MyGame::EnemySpawn;
using MyGame::LevelData;
// The reader. The base-class constructor takes the .NET name of the TARGET type (what the reader
// produces), not the reader's own name.
class LevelDataReader final : public ContentTypeReader<LevelData>
{
public:
    LevelDataReader() : ContentTypeReader<LevelData>("MyGame.LevelData") {}

protected:
    LevelData Read(ContentReader& input, std::optional<LevelData> /*existing*/) override
    {
        LevelData level;
        level.name          = input.ReadString();
        level.skyboxTexture = input.ReadString();
        level.width         = input.ReadInt32();
        level.height        = input.ReadInt32();
        level.difficulty    = static_cast<MyGame::Difficulty>(input.ReadInt32());

        const std::int32_t count = input.ReadInt32();
        input.CheckCollectionElementCount(count, "MyGame.LevelDataReader");
        for (std::int32_t i = 0; i < count; ++i)
        {
            auto enemy      = std::make_shared<EnemySpawn>();
            enemy->type     = input.ReadString();
            enemy->health   = input.ReadInt32();
            enemy->position = input.ReadVector3();
            level.enemies.push_back(std::move(enemy));
        }
        return level;
    }
};

CheckCollectionElementCount is the CNA guard every built-in collection reader calls before it allocates: it refuses a negative or absurd count (the default ceiling is ten million elements) with a ContentLoadException instead of letting a corrupt file ask for a gigabyte.

Register the reader by its canonical name before the first Load<LevelData>():

ContentTypeReaderManager::AddTypeCreator(
    "MyGame.LevelDataReader",
    [] { return std::make_unique<LevelDataReader>(); });

Canonical names, in one paragraph

The type-reader table inside an .xnb stores assembly-qualified names such as MyGame.LevelDataReader, MyGame, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null. CNA strips the assembly qualifier from the name and from every generic argument before it looks the reader up, so the registry key is MyGame.LevelDataReader and a list of your class is Microsoft.Xna.Framework.Content.ListReader`1[[MyGame.EnemySpawn]]. The rules that follow from the registry code:

  • The registry is process-wide and has no locking. Register during startup, before any content (including a background load) can run. In a Game subclass, the constructor body is a good place: the base constructor has already registered the built-ins.
  • The first registration for a name stays. A repeat AddTypeCreator with the same name is silently ignored, so it is safe to call twice and impossible to override a reader that is already there. To swap one, call ContentTypeReaderManager::RemoveTypeCreatorEXT(name) first.
  • The factory is called once per .xnb file that references the name, so a reader may keep per-file state.
  • Versions are strict: the reader table stores a version next to each name, and the reader must report it through getTypeVersionProperty() (default 0) or override SupportsVersion(int) to accept several.
  • An .xnb’s reader table must resolve in full before any object is read. A file that lists a reader you have not registered fails as a whole, even if the object you asked for would never have used it.

Making a test .xnb

You need a file for the reader to read. The real producer is a ContentTypeWriter in an XNA-style content pipeline (see below). For a first run, the container is small enough to write by hand. This function writes what a writer for LevelData would have written: one reader in the table, no shared resources, root object dispatched to table entry 1, then the payload in the reader’s order.

// What an XNA-side ContentTypeWriter<LevelData> would have written, byte for byte.
static void WriteLevelXnb(const std::string& path)
{
    XnbBytes x;
    x.Write7Bit(1);                                       // one type reader in the table
    x.WriteReader("MyGame.LevelDataReader, MyGame, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
    x.Write7Bit(0);                                       // no shared resources
    x.Write7Bit(1);                                       // root object: table entry 1
    x.WriteString("Ice Cavern");
    x.WriteString("textures/skybox_ice");
    x.WriteInt32(64);
    x.WriteInt32(48);
    x.WriteInt32(2);                                      // Hard
    x.WriteInt32(2);                                      // two enemies
    x.WriteString("Goblin"); x.WriteInt32(30); x.WriteFloat(10); x.WriteFloat(0); x.WriteFloat(5);
    x.WriteString("Troll");  x.WriteInt32(80); x.WriteFloat(30); x.WriteFloat(0); x.WriteFloat(20);
    x.Save(path);
}

Loading it needs the built-in readers only if the file names one, but a program without a Game registers none on its own, so a tool, test or server calls RegisterAllBuiltInXnbReaders() itself. A standalone ContentManager has no GraphicsDevice either, which is fine for data types like this one and not for a Texture2D.

int main()
{
    const std::filesystem::path root = std::filesystem::path("Content") / "Levels";
    std::filesystem::create_directories(root);
    WriteLevelXnb((root / "level01.xnb").string());

    // A tool, test or server has no Game, so nothing registered the built-in readers for it.
    CNA::Internal::Xnb::RegisterAllBuiltInXnbReaders();

    // The canonical name: the table entry with its assembly qualifier stripped.
    ContentTypeReaderManager::AddTypeCreator(
        "MyGame.LevelDataReader",
        [] { return std::make_unique<LevelDataReader>(); });

    ContentManager content;                               // root directory "Content"
    const LevelData level = content.Load<LevelData>("Levels/level01");
    std::cout << level.name << " " << level.width << "x" << level.height << ", "
              << level.enemies.size() << " enemies\n";
    return 0;
}
ⓘ

Relative paths and the working directory. A desktop ContentManager resolves RootDirectory relative to the process’s current working directory, and it matches directory and file names case-insensitively. Run the program from the directory that contains Content/.

The writer that pairs with it

If you build your content with a custom compiler, the matching ContentTypeWriter names the runtime reader and writes the fields in the reader’s order. The pipeline side deals in a build-time type (here LevelContent), which XNA keeps separate from the runtime type the game reads:

// writerA.cpp -- the build-time half that pairs with LevelDataReader (Part A).
#include <cstdint>
#include <memory>
#include <string>
#include <vector>

#include "CNA/Content/Pipeline/ContentCompiler.hpp"
#include "CNA/Content/Pipeline/XnaPipelineBridge.hpp"
#include "Microsoft/Xna/Framework/Vector3.hpp"

namespace Xna      = Microsoft::Xna::Framework::Content::Pipeline;
namespace Compiler = Microsoft::Xna::Framework::Content::Pipeline::Serialization::Compiler;
using Microsoft::Xna::Framework::Vector3;

// Build-time ("intermediate") shape of the same data. It is NOT the runtime LevelData: XNA
// separates the type the pipeline processes from the type the game reads.
class LevelContent final : public Xna::ContentItem
{
public:
    struct Enemy { std::string type; std::int32_t health = 0; Vector3 position; };

    std::string        name;
    std::string        skyboxTexture;
    std::int32_t       width = 0, height = 0, difficulty = 1;
    std::vector<Enemy> enemies;

    [[nodiscard]] const std::string& GetTypeName() const override
    {
        static const std::string typeName = "MyGame.Pipeline.LevelContent";
        return typeName;
    }
};

class LevelContentWriter final : public Compiler::ContentTypeWriter<LevelContent>
{
public:
    // The runtime reader's .NET name -- the string that lands in the .xnb's type-reader table.
    [[nodiscard]] std::string GetRuntimeReader(Xna::TargetPlatform) const override
    {
        return "MyGame.LevelDataReader, MyGame";
    }

protected:
    // Write in exactly the order LevelDataReader::Read consumes.
    void Write(Compiler::ContentWriter& output, const std::shared_ptr<LevelContent>& value) override
    {
        output.Write(value->name);
        output.Write(value->skyboxTexture);
        output.Write(value->width);
        output.Write(value->height);
        output.Write(value->difficulty);
        output.Write(static_cast<std::int32_t>(value->enemies.size()));
        for (const LevelContent::Enemy& enemy : value->enemies)
        {
            output.Write(enemy.type);
            output.Write(enemy.health);
            output.Write(enemy.position);
        }
    }
};

This class compiles against the snapshot’s headers. This page did not build a compiler executable around it or run one, so the .xnb this writer would emit has not been observed here. Reader and writer agree by construction only as far as you keep the two field orders in step.

Reflective readers: declare the fields once

XNA compiles a type that has no ContentTypeWriter through an implicit reflective writer, and the file then names Microsoft.Xna.Framework.Content.ReflectiveReader`1[[Your.Type, ...]]. CNA has no runtime reflection, so it asks you for the one thing reflection provided, the field list, through ReflectiveTypeReaderBuilder<T>. The builder registers the reader under the right canonical name (and an enum reader for every EnumField).

⚠

Declare fields in wire order, not declaration order. The header comment in CNA says IntermediateSerializer writes a type’s serialised properties first, then its public fields, each in declaration order, and that this was measured on a real sample’s settings type. Decode one real file to confirm the order instead of reading the C# source.

The example below reads the same LevelData. Three things need care. EnemySpawn is a class, so it registers with RegisterShared() (its elements are dispatched by reader index; the value-shaped Register() would leave that index unread and desynchronise everything after it). The closed generic List<EnemySpawn> has to be registered by hand, because the built-in set only covers the closed generics CNA itself needs. And the enum uses EnumField with its .NET name.

#include "CNA/Internal/Xnb/CollectionContentTypeReaders.hpp"
#include "CNA/Internal/Xnb/XnbBuiltInReaders.hpp"
#include "Microsoft/Xna/Framework/Content/ContentManager.hpp"
#include "Microsoft/Xna/Framework/Content/ContentTypeReaderManager.hpp"
#include "Microsoft/Xna/Framework/Content/ReflectiveTypeReader.hpp"

#include "LevelData.hpp"
#include "XnbBytes.hpp"

using namespace Microsoft::Xna::Framework::Content;
using MyGame::EnemySpawn;
using MyGame::LevelData;
static void DescribeLevelTypes()
{
    // 1. The list element is a class, so it is dispatched by reader index: RegisterShared().
    //    Field order is the WIRE order: what IntermediateSerializer wrote, not necessarily the
    //    order in the C++ (or C#) declaration.
    ReflectiveTypeReaderBuilder<EnemySpawn>("MyGame.EnemySpawn")
        .Field(&EnemySpawn::type)
        .Field(&EnemySpawn::health)
        .Field(&EnemySpawn::position)
        .RegisterShared();

    // 2. The closed generic List<EnemySpawn> has to be registered by hand: the built-in set only
    //    covers the closed generics CNA itself needs. The second argument is the canonical name
    //    of the reader that produces the elements.
    ContentTypeReaderManager::AddTypeCreator(
        "Microsoft.Xna.Framework.Content.ListReader`1[[MyGame.EnemySpawn]]",
        [] {
            return std::make_unique<CNA::Internal::Xnb::ListReader<std::shared_ptr<EnemySpawn>>>(
                "System.Collections.Generic.List`1[[MyGame.EnemySpawn]]",
                CanonicalReflectiveReaderNameEXT("MyGame.EnemySpawn"));
        });

    // 3. The root. EnumField also registers the EnumReader the .xnb's table names.
    ReflectiveTypeReaderBuilder<LevelData>("MyGame.LevelData")
        .Field(&LevelData::name)
        .Field(&LevelData::skyboxTexture)
        .Field(&LevelData::width)
        .Field(&LevelData::height)
        .EnumField(&LevelData::difficulty, "MyGame.Difficulty")
        .Field(&LevelData::enemies)
        .Register();
}

This is the file such a type produces, written out so you can see where every reader index goes. Strings are reference types, so each one carries the index of the StringReader table entry (2 here); Int32 and the enum are written inline; the list carries its own reader index (5) and a count, and each element carries the EnemySpawn reader index (6) before its fields.

static void WriteReflectiveLevelXnb(const std::string& path)
{
    const char* asm_ = ", MyGame, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]";
    XnbBytes x;
    x.Write7Bit(6);                                                       // six table entries
    x.WriteReader(std::string("Microsoft.Xna.Framework.Content.ReflectiveReader`1[[MyGame.LevelData") + asm_);      // 1
    x.WriteReader("Microsoft.Xna.Framework.Content.StringReader");                                                   // 2
    x.WriteReader("Microsoft.Xna.Framework.Content.Int32Reader");                                                    // 3
    x.WriteReader(std::string("Microsoft.Xna.Framework.Content.EnumReader`1[[MyGame.Difficulty") + asm_);          // 4
    x.WriteReader(std::string("Microsoft.Xna.Framework.Content.ListReader`1[[MyGame.EnemySpawn") + asm_);          // 5
    x.WriteReader(std::string("Microsoft.Xna.Framework.Content.ReflectiveReader`1[[MyGame.EnemySpawn") + asm_);    // 6
    x.Write7Bit(0);                                                       // no shared resources
    x.Write7Bit(1);                                                       // root -> LevelData reader

    x.Write7Bit(2); x.WriteString("Ice Cavern");                          // name: string = index 2 + text
    x.Write7Bit(2); x.WriteString("textures/skybox_ice");                 // skyboxTexture
    x.WriteInt32(64);                                                     // width  (value type: inline)
    x.WriteInt32(48);                                                     // height
    x.WriteInt32(2);                                                      // difficulty (enum: inline Int32)
    x.Write7Bit(5); x.WriteInt32(2);                                      // enemies: list reader index 5, count
    x.Write7Bit(6);                                                       //   element 0: EnemySpawn reader index 6
    x.Write7Bit(2); x.WriteString("Goblin"); x.WriteInt32(30);
    x.WriteFloat(10); x.WriteFloat(0); x.WriteFloat(5);
    x.Write7Bit(6);                                                       //   element 1
    x.Write7Bit(2); x.WriteString("Troll");  x.WriteInt32(80);
    x.WriteFloat(30); x.WriteFloat(0); x.WriteFloat(20);
    x.Save(path);
}
int main()
{
    const std::filesystem::path root = std::filesystem::path("Content") / "Levels";
    std::filesystem::create_directories(root);
    WriteReflectiveLevelXnb((root / "level02.xnb").string());

    CNA::Internal::Xnb::RegisterAllBuiltInXnbReaders();
    DescribeLevelTypes();

    ContentManager content;
    const LevelData level = content.Load<LevelData>("Levels/level02");
    std::cout << level.name << ", " << level.enemies.size() << " enemies, first: "
              << level.enemies.front()->type << "\n";
    return 0;
}

Builder reference

CallWhat it declares
.Field(&T::member)The next member. Read inline when the member is bool, float, double, int32_t, uint32_t, int64_t, uint8_t, Vector2/3/4, Matrix, Quaternion, Color, Point or System::TimeSpan. Any other member type is read with ReadObject, which consumes a reader index first: correct for strings, lists and nested objects, wrong for a value type that is not in the list. Use Custom() for those. An enum member is refused at compile time.
.EnumField(&T::e, "Ns.Enum")An enum, written inline as Int32. Also queues the EnumReader`1[[Ns.Enum]] registration the file’s reader table names.
.SharedResourceField(&T::m)A [ContentSerializer(SharedResource = true)] member: a 1-based index into the file’s shared-resource table, assigned after the root and every shared resource have been read. Needs RegisterShared(); Register() throws std::logic_error if such a field is present.
.Base(baseBuilder)Prepends a base type’s declared members, the way ReflectiveReader<T> reads the base first. Call it before the derived members.
.Custom(fn)A member the wire format does not map onto directly: fn(T&, ContentReader&) reads whatever is there and applies it. Position in the chain is what matters.
.Register()Registers a value-shaped reader (an asset root, or an inline value).
.RegisterShared<TStored = T>()Registers a reference-shaped reader returning std::shared_ptr<TStored>. Use it for list elements, dictionary values and Model.Tag (RegisterShared<System::Object>()).
.RegisterAbstract<TStored>()A resolving-only reader for an abstract base that appears in the reader table but is never instantiated. It throws ContentLoadException if content dispatches to it.

EnumTypeReader<TEnum> is the class behind EnumField. You only construct it yourself if you register an enum reader by hand (EnumTypeReader<TEnum>::CanonicalReaderName(name) is the registry key). It is not one of the 61 built-in readers.

The same as with AddTypeCreator: the first registration for a name stays. If you register a type once with Register() and later with RegisterShared(), the second call is silently ignored. Remove the entry with RemoveTypeCreatorEXT first when you need the other shape.

A custom CNB asset type

CNB, CNA’s own container, is a much smaller extension point than XNB. A .cnb header carries one u32 asset type id, and the loader you register says which C++ type that id decodes to. The rules, from RegisterCnbLoaderEXT and CnbLoaderRegistry:

  • Mint the id from a name: CnbAssetTypeIdFromName("MyGame.LevelData") is FNV-1a-32 of the name with the top bit set (0x80000000 and above).
  • Only the custom range. Ids below 0x40000000 are CNA’s built-ins and 0x40000000 to 0x7FFFFFFF is reserved. Registering one throws std::invalid_argument.
  • The name must hash to the id you pass, or the registration throws std::invalid_argument. Two different names whose hashes collide throw std::logic_error on the second registration.
  • The file carries the name. CnbWriter::SetMetadata(typeName, contentName) is required for a custom type; Build() refuses a custom-typed file without a matching name. The load path compares the file’s name to the registered one before it dispatches, so two types whose 31-bit hashes collide cannot decode each other’s files.
  • Registration is process-wide and outlives every ContentManager. Register before the first Load<T>(); T must be exactly the type your factory returns.

The constants and the encoder, which run wherever you write the file (your own tool, a unit test, or a custom cna-content):

#include "CNA/Content/Cnb/CnbByteReader.hpp"
#include "CNA/Content/Cnb/CnbByteWriter.hpp"
#include "CNA/Content/Cnb/CnbDocument.hpp"
#include "CNA/Content/Cnb/CnbFormat.hpp"
#include "CNA/Content/Cnb/CnbWriter.hpp"
#include "Microsoft/Xna/Framework/Content/ContentManager.hpp"

#include "LevelData.hpp"

namespace Cnb = CNA::Content::Cnb;
using Microsoft::Xna::Framework::Content::ContentManager;
using MyGame::EnemySpawn;
using MyGame::LevelData;
constexpr const char*  kTypeName   = "MyGame.LevelData";
constexpr std::uint32_t kSchema    = 1u;
constexpr Cnb::CnbChunkId kLevelChunk = Cnb::MakeChunkId('l', 'v', 'l', '0');
// The build side: run it from your own tool, a custom cna-content, or a unit test.
void WriteLevelCnb(const LevelData& level, const std::string& logicalName, const std::string& path)
{
    Cnb::CnbByteWriter payload;                       // little-endian; strings are u32 length + UTF-8
    payload.WriteString(level.name);
    payload.WriteString(level.skyboxTexture);
    payload.WriteI32(level.width);
    payload.WriteI32(level.height);
    payload.WriteU32(static_cast<std::uint32_t>(level.difficulty));
    payload.WriteU32(static_cast<std::uint32_t>(level.enemies.size()));
    for (const auto& enemy : level.enemies)
    {
        payload.WriteString(enemy->type);
        payload.WriteI32(enemy->health);
        payload.WriteF32(enemy->position.X);
        payload.WriteF32(enemy->position.Y);
        payload.WriteF32(enemy->position.Z);
    }

    Cnb::CnbWriter writer(Cnb::CnbAssetTypeIdFromName(kTypeName), kSchema);
    writer.SetMetadata(kTypeName, logicalName);       // required for a custom type
    writer.AddChunk(kLevelChunk, payload.Take(), Cnb::CnbChunkFlags::Mandatory, 4u);
    writer.WriteToFile(path);                         // Build() validates everything first
}

The decoder is the runtime half. RequireAsset(id, maxSchemaVersion) checks the id and that the file’s schema version is between 1 and the highest one you understand; RequireMandatoryChunksUnderstood refuses a file that carries a mandatory chunk you did not list (an unknown optional chunk is ignored, which lets a newer writer add data an older reader can skip); RequireExhausted turns a layout disagreement (trailing bytes) into an error instead of silence. All of them throw ContentLoadException.

// The runtime side.
LevelData DecodeLevel(const Cnb::CnbDocument& document, ContentManager& /*content*/)
{
    document.RequireAsset(Cnb::CnbAssetTypeIdFromName(kTypeName), kSchema);
    const Cnb::CnbChunkId known[] = {kLevelChunk};
    document.RequireMandatoryChunksUnderstood(known);

    Cnb::CnbByteReader reader = document.OpenChunk(document.RequireSingle(kLevelChunk));
    LevelData level;
    level.name          = reader.ReadString();
    level.skyboxTexture = reader.ReadString();
    level.width         = reader.ReadI32();
    level.height        = reader.ReadI32();
    level.difficulty    = static_cast<MyGame::Difficulty>(reader.ReadU32());
    const std::uint32_t count = reader.ReadCount(0, "enemies");   // 0 = variable-length elements
    for (std::uint32_t i = 0; i < count; ++i)
    {
        auto enemy        = std::make_shared<EnemySpawn>();
        enemy->type       = reader.ReadString();
        enemy->health     = reader.ReadI32();
        enemy->position.X = reader.ReadF32();
        enemy->position.Y = reader.ReadF32();
        enemy->position.Z = reader.ReadF32();
        level.enemies.push_back(std::move(enemy));
    }
    reader.RequireExhausted();                        // trailing bytes = layout disagreement
    return level;
}
int main()
{
    // Registration is process-wide and must precede the first Load<LevelData>().
    ContentManager::RegisterCnbLoaderEXT<LevelData>(
        Cnb::CnbAssetTypeIdFromName(kTypeName), kTypeName, &DecodeLevel);

    LevelData level;
    level.name = "Ice Cavern"; level.skyboxTexture = "textures/skybox_ice";
    level.width = 64; level.height = 48; level.difficulty = MyGame::Difficulty::Hard;
    auto goblin = std::make_shared<EnemySpawn>();
    goblin->type = "Goblin"; goblin->health = 30; goblin->position = {10.0f, 0.0f, 5.0f};
    level.enemies.push_back(goblin);

    std::filesystem::create_directories("Content/Levels");
    WriteLevelCnb(level, "Levels/level03", "Content/Levels/level03.cnb");

    ContentManager content;                               // root directory "Content"
    const LevelData loaded = content.Load<LevelData>("Levels/level03");
    std::cout << loaded.name << ", " << loaded.enemies.size() << " enemy\n";
    return 0;
}

The .cnb tier sits directly below .xnb in the ladder and above the loose readers, so a compiled Levels/level03.cnb beats a same-named level03.level.json or level03.cnj beside it. The container itself (64-byte header, 48-byte table-of-contents entries, CRC-32C on every structure) is described on the CNB Format page.

Producing the file from cna-content

Nothing in the stock cna-content knows your type. To make cna-content build Levels -o Content produce it, you build your own compiler executable: register an importer for your source extension, a processor, and a writer whose output is your CnbWriter bytes, then hand the registry to RunContentCompiler. CNA ships a complete example of exactly this in modules/content/examples/custom-content-compiler.cpp (a .greeting source becomes an ExampleGame.Greeting .cnb), and an XNA-shaped one, with an importer, a processor and ContentTypeWriter classes reaching an .xnb, in modules/content-pipeline/examples/xna-custom-pipeline.cpp. Both files were syntax-checked against the snapshot’s headers; neither was built or run for this page.

⚠

Experimental. CNA marks the custom-component API experimental (ContentPipelineExtensionApiIsExperimental = true). A custom compiler is linked from source against cna_content_compiler (the CNA::ContentCompiler alias), which is a source and toolchain compatibility model, not a plugin ABI: rebuild it whenever you update CNA. The examples are built by CNA only with CNA_BUILD_EXAMPLES or CNA_BUILD_TESTS. See Content Pipeline for the components.

Troubleshooting

Each row comes from a message or a rule in the snapshot’s source.

What you seeCauseFix
'…' references an unregistered .xnb content type reader '…' The file’s reader table names a reader the registry does not have. The canonical name is printed (assembly qualifier removed). Register that exact string with AddTypeCreator (or a builder) before loading. A list of your own class needs its closed ListReader registered too.
… uses reader '…' at an unsupported version (n) Your reader reports a different getTypeVersionProperty() than the file stored; the check is strict by default. Match the version, or override SupportsVersion(int).
ContentManager::Load<T>(): No reader registered for type, asset '…' No .xnb and no .cnb exists under that name, and T has no loose-file reader. This is what a wrong path or a wrong working directory looks like for a custom T. Check the logical name (no extension), RootDirectory and the working directory first.
std::bad_any_cast from an .xnb load The file loaded, but its root reader produces a different type than the T you asked for. The .xnb tier has no wrapper that turns this into a ContentLoadException. Ask for the right T (a value type versus std::shared_ptr<T> matters: a RegisterShared() root loads as Load<std::shared_ptr<T>>). Catch std::exception, not only ContentLoadException.
Fields come out shifted or garbage, later objects fail The reader consumed a different number of bytes than the writer wrote, most often a reference type registered with Register() instead of RegisterShared(), or fields declared in declaration order instead of wire order. Compare the file against a hex dump and against the reader’s order. CNA’s tools/xnb/xnb_conformance.py can check the container and every built-in payload, but it has no decoder for a custom root type.
… has an incorrect type reader index A dispatched index in the payload is outside the file’s reader table. Reader and writer disagree on layout, or the file is corrupt.
std::invalid_argument from RegisterCnbLoaderEXT: “is not a game-defined identifier” / “is not the identifier '…' hashes to” The id is not in the custom range, or it was not minted from the same name you passed. Use CnbAssetTypeIdFromName(name) for the id and pass the same name.
… holds a … asset, which this build of CNA has no .cnb loader for The loader was not registered before the first Load<T>(), or the file’s id is not the one you registered. Register at startup; confirm the id with cna_tool_cnb_info (see Tutorial 146).
… holds a … asset, which is not the type requested The .cnb decodes to a different C++ type than T. T must be exactly the type the loader factory returns.
… declares custom type '…', but asset type id … is registered for '…' Two different type names hash to the same 31-bit id; CNA refuses to decode one as the other. Rename one of the two types.

Next steps