Tutorial 145: Build Content with cna-content

CNA Tutorials  ·  CNA snapshot 009d40f5

ℹ

What you’ll learn: the cna-content build workflow end to end: source layout, [BUILD] and [SKIP] output with --explain, a .cna-content.json configuration, inspecting a .cnb with cna_tool_cnb_info, the cna_add_content() CMake helper, and how Load<T> chooses between .xnb, .cnb and loose files.

Before you start — Tutorial 03: Your First CNA Window (a CNA project built with CMake) and Tutorial 45: ContentManager and Asset Pipeline (how Load<T> resolves names). You need a CNA checkout at snapshot 009d40f5, that is branch next, with sharp-runtime also on its next branch; the default branch of libcna/cna is still the alpha.1 tag commit and has no content pipeline. See Building for the sibling-checkout layout. The 3D part needs a 3D-capable renderer such as OPENGLES3 or VULKAN; the seven 2D-only renderers (SDL_RENDERER, DIRECT2D, CANVAS, HTML_DOM, SVG_DOM, FREEDIRECT, GDI) throw on 3D calls.

So far in this series you loaded loose files: a PNG, a WAV, a glTF. That works, and ContentManager will keep doing it. This tutorial adds the other half: a build step that compiles those sources into CNA's own .cnb container ahead of time, so the game loads validated, deterministic, already-decoded assets. The tool is cna-content. Nothing in your game code changes except that the files on disk are different; that is the point.

What you will build

  • A ContentSource/ directory holding a PNG, a WAV and a GLB.
  • A Content/ directory of .cnb files produced by cna-content, with an incremental manifest.
  • A small per-asset configuration that changes one texture and rebuilds only that texture.
  • A CMake target, cna_add_content(), so cmake --build compiles content before your game runs.
  • A game that loads all three assets by their logical names and draws them.

1. The source tree

Create a project directory next to your CNA checkout and put three source files in it. You supply the files:

  • Textures/wall.png: any small PNG. If you want to watch the colour key work later, paint a few pixels pure magenta, (255, 0, 255), fully opaque.
  • Sounds/explosion.wav: any 8-bit or 16-bit PCM WAV. The build refuses 24-bit and floating-point WAV files; see the troubleshooting table.
  • Models/robot.glb: any small glTF binary with one mesh, no skin and no image textures (export a plain mesh from your modelling tool). Textured and multi-part models need extra care, covered at the end.
my-content-game/
  CMakeLists.txt
  src/ContentTour.cpp
  ContentSource/
    Textures/wall.png
    Sounds/explosion.wav
    Models/robot.glb
ⓘ

Names come from paths. Each output takes the source's path below ContentSource/ with the last extension removed. Textures/wall.png becomes the logical name Textures/wall. The source directory is walked recursively, and any file whose extension no importer claims (a README.txt, a Blender file) is silently ignored.

2. Build the tool

The tool is an ordinary CMake target of the CNA tree, called cna_content_tool; its executable is named cna-content. It is built by default with CNA (no option enables it), so a configured CNA build tree already knows the target:

cmake --build <your-build-dir> --target cna_content_tool

The tool targets set no output-directory override, so with a single-configuration generator you will find cna-content at the top of the build tree (cna-content.exe on Windows, inside a configuration folder with a multi-configuration generator). The commands below write it as cna-content; use the path where it landed. If you have not configured a CNA build tree yet, follow Building first.

3. The first build

Run it from the project directory. -o is required, and for a directory build the output directory must not be inside the source directory:

cna-content build ContentSource -o Content --explain

Each asset produces one line, in logical-name order, followed by a summary. With --explain every asset also gets a reason: line. Paths and byte counts below are placeholders; yours will differ:

[BUILD] Models/robot -> /home/you/my-content-game/Content/Models/robot.cnb (1 output(s), NNNN bytes; CNA.GltfImporter -> CNA.ModelProcessor -> CNA.ModelContentWriter)
  reason: manifest unavailable
[BUILD] Sounds/explosion -> /home/you/my-content-game/Content/Sounds/explosion.cnb (1 output(s), NNNN bytes; CNA.WavImporter -> CNA.SoundEffectProcessor -> CNA.SoundEffectContentWriter)
  reason: manifest unavailable
[BUILD] Textures/wall -> /home/you/my-content-game/Content/Textures/wall.cnb (1 output(s), NNNN bytes; CNA.ImageImporter -> CNA.TextureProcessor -> CNA.Texture2DContentWriter)
  reason: manifest unavailable
Built: 3  Skipped: 0  Failed: 0

Read a [BUILD] line left to right: the logical name, where the file went, how many files that asset produced, and the three pipeline stages that ran: importer (reads the source), processor (applies policy, such as premultiplying a texture), writer (encodes the output). Any warnings the stages raise, for example a glTF feature that cannot be represented, appear indented under the asset as warning (component): text.

The output directory now holds the three .cnb files plus two bookkeeping files:

Content/
  Models/robot.cnb
  Sounds/explosion.cnb
  Textures/wall.cnb
  .cna-content-manifest.json     what was built, from what, by which component versions
  .cna-content.lock              held while a build or clean runs on this directory

4. Build again: nothing to do

cna-content build ContentSource -o Content --explain
[SKIP] Models/robot -> /home/you/my-content-game/Content/Models/robot.cnb
  reason: fingerprint and published output digests unchanged
[SKIP] Sounds/explosion -> /home/you/my-content-game/Content/Sounds/explosion.cnb
  reason: fingerprint and published output digests unchanged
[SKIP] Textures/wall -> /home/you/my-content-game/Content/Textures/wall.cnb
  reason: fingerprint and published output digests unchanged
Built: 0  Skipped: 3  Failed: 0

The manifest records a fingerprint of every input and the SHA-256 of every published output. An asset is skipped only when both still match, so deleting or hand-editing a .cnb also triggers a rebuild (the reason then reads compiled output missing or compiled output digest mismatch). Outputs are written to a temporary sibling and renamed into place, so an interrupted build never leaves a half-written file.

5. Configure one asset

Convention needs no configuration. To change how one asset is processed, add .cna-content.json to the source root. Keys are source paths relative to ContentSource/; each parameter carries an explicit type:

{
  "format": "CNA.ContentPipeline.Config",
  "version": 1,
  "assets": {
    "Textures/wall.png": {
      "parameters": {
        "colorKey":        { "type": "string", "value": "255,0,255" },
        "generateMipmaps": { "type": "bool",   "value": true }
      }
    }
  }
}

colorKey turns every texel that is exactly that colour fully transparent. generateMipmaps stores a full mip chain in the .cnb. (A third texture parameter matters more than either: premultiplyAlpha defaults to true, exactly as in XNA 4.0, so a pipeline-built texture is premultiplied. Its behaviour and the others are listed in Content Pipeline: texture parameters.) Rebuild:

cna-content build ContentSource -o Content --explain
[SKIP] Models/robot -> .../Content/Models/robot.cnb
  reason: fingerprint and published output digests unchanged
[SKIP] Sounds/explosion -> .../Content/Sounds/explosion.cnb
  reason: fingerprint and published output digests unchanged
[BUILD] Textures/wall -> .../Content/Textures/wall.cnb (1 output(s), NNNN bytes; CNA.ImageImporter -> CNA.TextureProcessor -> CNA.Texture2DContentWriter)
  reason: processor parameters changed
Built: 1  Skipped: 2  Failed: 0

Only the texture whose parameters changed was rebuilt. The wording of a reason: line can vary with what changed (a new source file gives new asset; an edited PNG gives primary source bytes changed), and --explain is how you find out. Unknown parameter names and badly typed values are refused rather than silently ignored: a typo such as generateMipMaps fails that asset with a message naming the processor and the parameter (unless you pass --xna-compatible, which downgrades that refusal to a warning).

6. Inspect an output

cna_tool_cnb_info is a validator and inspector for .cnb files. It is built by default with CNA like the other tools, and every structural check runs while it reads, so a malformed file gives a non-zero exit and a message saying how:

cna_tool_cnb_info Content/Models/robot.cnb
cna_tool_cnb_info Content/Models/robot.cnb --refs
Content/Models/robot.cnb
  container       1.0
  asset type      Model (0x00000005)
  schema version  1
  chunks          N
  type name       Microsoft.Xna.Framework.Graphics.Model
  content name    Models/robot

  chunk flags       offset      stored     logical align  codec     checksum
  ...                                              (one row per chunk)

  external references: 0

--refs prints only the names of the other assets the file depends on, one per line, for use from a build script. Your untextured model depends on nothing, so it prints nothing and exits with 0, which is the right answer. The counts, chunk names and sizes differ per file. The type and content name lines are printed when the file carries metadata, which files written by cna-content do. The byte-level layout of the container is documented on CNB Format.

7. Load the result

Your game asks for assets by logical name, exactly as before: no extension, path relative to the content root. Load<T> returns the asset by value, and a failed load throws ContentLoadException. From a Game the manager is getContentProperty(). SoundEffect has no default constructor, and the SpriteBatch needs the graphics device, which only exists once the game is constructed, so the members that hold them are std::optional.

// src/ContentTour.cpp
#include <cmath>
#include <optional>

#include "Microsoft/Xna/Framework/Audio/SoundEffect.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Content/ContentLoadException.hpp"
#include "Microsoft/Xna/Framework/Content/ContentManager.hpp"
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GameTime.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/DepthStencilState.hpp"
#include "Microsoft/Xna/Framework/Graphics/Model.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/MathHelper.hpp"
#include "Microsoft/Xna/Framework/Matrix.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "Microsoft/Xna/Framework/Vector3.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Audio;
using namespace Microsoft::Xna::Framework::Content;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;

class ContentTour final : public Game
{
public:
    ContentTour() : graphics_(this)
    {
        graphics_.setPreferredBackBufferWidthProperty(800);
        graphics_.setPreferredBackBufferHeightProperty(600);
    }

protected:
    void LoadContent() override
    {
        // A relative root is resolved against the process working directory,
        // so start the game from the directory that contains Content/.
        getContentProperty().setRootDirectoryProperty("Content");

        // Logical names: the path below the root, no extension.
        wall_  = getContentProperty().Load<Texture2D>("Textures/wall");
        robot_ = getContentProperty().Load<Model>("Models/robot");
        boom_.emplace(getContentProperty().Load<SoundEffect>("Sounds/explosion"));

        spriteBatch_.emplace(getGraphicsDeviceProperty());
    }

    void Update(GameTime& gameTime) override
    {
        const double dt = gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty();
        angle_ += static_cast<float>(dt);

        const bool space = Keyboard::GetState().IsKeyDown(Keys::Space);
        if (space && !spaceWasDown_ && boom_.has_value())
        {
            boom_->Play();
        }
        spaceWasDown_ = space;
    }

    void Draw(const GameTime&) override
    {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::CornflowerBlue);

        // 3D first: a Model draws itself with three matrices.
        gd.setDepthStencilStateProperty(DepthStencilState::Default);
        const Matrix world = Matrix::CreateRotationY(angle_);
        const Matrix view  = Matrix::CreateLookAt(
            Vector3(0.0f, 2.0f, 6.0f), Vector3::Zero, Vector3::Up);
        const Matrix proj  = Matrix::CreatePerspectiveFieldOfView(
            MathHelper::PiOver4, 800.0f / 600.0f, 0.1f, 100.0f);
        robot_.Draw(world, view, proj);

        // Then the 2D overlay.
        spriteBatch_->Begin();
        spriteBatch_->Draw(wall_, Vector2(16.0f, 16.0f), Color::White);
        spriteBatch_->End();

        // No gd.Present(): Game presents in EndDraw, after Draw() returns.
    }

private:
    GraphicsDeviceManager        graphics_;
    Texture2D                    wall_;
    Model                        robot_;
    std::optional<SoundEffect>   boom_;          // SoundEffect has no default constructor
    std::optional<SpriteBatch>   spriteBatch_;
    float                        angle_ = 0.0f;
    bool                         spaceWasDown_ = false;
};

int main()
{
    ContentTour game;
    game.Run();
    return 0;
}
⚠

The working directory decides. setRootDirectoryProperty("Content") is a relative path, and the .cnb and loose-file lookups resolve it like any relative filesystem path: against the directory the process was started from, not the directory of the executable. Start the game from the directory that contains Content/ (the build directory, with the CMake setup in the next step), or the load throws a ContentLoadException. A debugger or test runner often starts in a different directory.

Which file wins

For Load<Texture2D>("Textures/wall") the manager tries, in order: its cache; Textures/wall.xnb; Textures/wall.cnb; then the loose-file reader (the literal name, a .cnj sidecar, then .png, .jpg and the other image extensions). A compiled file therefore beats the source it was built from. Prove it: copy the PNG next to the .cnb.

cp ContentSource/Textures/wall.png Content/Textures/wall.png

Run the game again. The texture on screen still comes from wall.cnb: the magenta pixels you painted are transparent (the colour key from step 5), whereas the loose PNG would still show them. Rename wall.cnb to wall.cnb.off and the same call now reads the PNG, magenta and all. A genuine .xnb with the same name would beat both. Delete the copied PNG and restore the name when you are done, or use cna-content clean in step 9, which leaves files it did not write alone.

ⓘ

Why the two versions can look different. Beyond the colour key, a pipeline-built texture is premultiplied by default and a loose PNG is not (we found no premultiplication in the loose image decode path). If a soft-edged sprite looks different between a .png and its .cnb, that is the first thing to check; set premultiplyAlpha to false in the configuration when your project uses straight alpha.

8. Build content from CMake

Running cna-content by hand is fine for experiments. In a project you want the same command to run as part of the build. CNA defines cna_add_content() for that, available to any project that brings CNA in with add_subdirectory():

cmake_minimum_required(VERSION 3.20)
project(ContentTour LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# CNA snapshot 009d40f5 (branch next) as a sibling checkout; sharp-runtime (branch next) beside it.
set(CNA_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../cna")
set(CNA_GRAPHICS_RENDERER "OPENGLES3" CACHE STRING "CNA graphics renderer")
add_subdirectory(${CNA_DIR} ${CMAKE_BINARY_DIR}/cna)

add_executable(ContentTour src/ContentTour.cpp)
target_link_libraries(ContentTour PRIVATE CNA)

# Compile ContentSource/ into <this binary dir>/Content with cna-content.
cna_add_content(
    TARGET     ContentTourContent
    SOURCE_DIR ContentSource            # relative to this CMakeLists.txt
    OUTPUT_DIR Content                  # relative to this binary directory
)
add_dependencies(ContentTour ContentTourContent)
  • cna_add_content() creates a custom target that is not part of the default all build. add_dependencies(ContentTour ContentTourContent) is what makes building the game build the content first. The target also depends on cna_content_tool, so the tool is built before it is used.
  • It runs every time it is requested. That is deliberate: the manifest turns an unchanged run into a quick per-asset [SKIP], and CMake needs no second model of what depends on what.
  • Optional arguments: CONFIG_FILE (a configuration file elsewhere), WORKERS (1 to 64), FORMAT, QUIET. When cross-compiling you must pass CONTENT_EXECUTABLE naming a host cna-content, because a target-platform executable cannot run on the build machine. The full list is on Content Pipeline: CMake.

With a single-configuration generator the executable and Content/ both end up in the build directory, so cd <build-dir> && ./ContentTour runs the game with the right working directory.

9. Clean up

cna-content clean Content
Cleaned: N  Failed: 0

clean removes only files the manifest proves the pipeline wrote and that are still unchanged, then the manifest itself. It does not remove the lock file (which stays in the output directory), and it leaves alone anything it did not write, such as the PNG you copied in step 7. It refuses to run on a symlinked directory.

Beyond the three basics

  • Textured glTF. Without generateChildAssets a glTF must produce exactly one Model document. A textured or multi-part file may not: the build then stops with a message that names the parameter. Setting "generateChildAssets": { "type": "bool", "value": true } in the configuration publishes the extra Models, animation clips and extracted textures as deterministic siblings. Use cna_tool_cnb_info file.cnb --refs to see what a model expects to find; see Content Pipeline: model parameters.
  • XNA-compatible output. The same command with --format xnb writes .xnb files; see Tutorial 147.
  • Fonts. A .spritefont source needs FreeType at CNA configure time (CNA_ENABLE_FONT_PIPELINE) and the named font installed or found through --font-directory.

Troubleshooting

These are the messages and symptoms you are most likely to meet, each read from the tool's or the loader's source at this snapshot.

What you seeCause and fix
error: a directory build's output must not be inside its source root.The -o directory is under ContentSource/. Put the output beside it.
content assets 'A' and 'B' both resolve to logical name 'C'.Two sources in the same folder share a stem, for example wall.png and wall.wav. Rename one, or give one a logicalName in the configuration.
An asset you expected is not in the output and nothing was reportedNo importer claims its extension, so discovery skipped it silently. Check the route table on Content Pipeline.
content configuration asset '…' does not name a contained regular source file. or … has no registered importer for its source extension.A key in .cna-content.json is misspelled, points outside the source root, or names a file type with no importer. Keys are paths relative to the source root, using /.
glTF produced N Model documents; set ModelProcessor bool parameter 'generateChildAssets' to true …The glTF has several mesh groups, skins or generated pieces. Enable generateChildAssets for that asset, or split the file.
Audio file … contains …. Only 8-bit and 16-bit audio data is supported.The WAV is 24-bit or floating point. Re-export it as 8-bit or 16-bit PCM.
this build has no font rasterizer, so a .spritefont cannot be compiled…CNA was configured without FreeType. Reconfigure with -DCNA_ENABLE_FONT_PIPELINE=ON and the FreeType development files.
this build has no media decoder, so an MP3, WMA or WMV source cannot be read…The build-time media pipeline is off or FFmpeg was not found. Reconfigure with -DCNA_ENABLE_MEDIA_PIPELINE=ON against libavcodec, libavformat, libavutil and libswresample.
no usable effect compiler: …A .fx source needs an external fxc-compatible compiler. Pass --fx-compiler (and --fx-compiler-launcher wine off Windows), or build the effect from an already-compiled .fxb.
content output lease failed: another content build or clean operation is active for output root …Another cna-content is running on the same output directory, for example from a parallel CMake build. Wait for it, or use one output directory per content target.
A single-file build says it requires an output path ending in .cnbFor a single source file, -o is a file and its extension must match --format. For a directory, give the directory.
The game throws ContentLoadException for an asset that exists in the build outputThe working directory is not the directory containing Content/ (see step 7), or the logical name is wrong (case aside, it is the path below the content root, without extension).
'….cnb' holds a Texture2D asset, which is not the type requested for '…'.The .cnb is valid but you asked for a different C++ type than the asset was built as.
The .cnb seems to be ignoredA .xnb with the same logical name exists and wins over it. Remove or rename the .xnb.

Next steps