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

Chapter 33 ContentManager and Asset Resolution

ContentManager maps a logical name and requested C++ type to an XNB object, a CNJ object, or a native loose asset. Its most important rule is selection, not decoding: the first existing candidate is terminal even if the selected reader later rejects it. This chapter owns path choice, caching, and manager lifetime. Chapters 34 and 35 own XNB; Chapter 36 owns CNJ.

ContentManager Load first checks its cache. On a miss it probes the logical base plus dot xnb. If absent, it obtains the loose type reader, checks an existing literal base, then base plus dot cnj, then the reader's ordered native extensions. The first existing candidate is terminal; reader failure does not continue to a lower-priority candidate. A successful result enters the relevant cache.
Figure 33.1: ContentManager candidate order at 1bb2145d. Existence selects a path; it does not prove that the selected reader can decode or use the asset.

33.1 The resolution algorithm

On a cache miss, let base = BuildAssetPath(assetName). The generic Load<T> path and the Texture2D, SoundEffect, and TextureCube specializations follow this order:

1 if (exists(base + ".xnb"))
2 return LoadXnbAsset<T>(base + ".xnb");
3
4 reader = registered LooseFileContentTypeReader<T>;
5 if (exists(base))
6 return reader.Read(base, content);
7 if (exists(base + ".cnj"))
8 return reader.Read(base + ".cnj", content);
9 for (const auto& extension : reader.GetExtensions())
10 if (exists(base + extension))
11 return reader.Read(base + extension, content);
12 return reader.Read(base, content); // preserve the reader’s diagnostic

Current contract.  Existence selects a candidate; successful decoding does not. A malformed XNB, invalid CNJ, corrupt literal file, directory, or rejected first native extension stops the load. CNA does not retry a lower-priority sibling.

The XNB probe appends .xnb to the complete name. Therefore Load<Texture2D>("art/logo.png") first tests art/logo.png.xnb, then the literal PNG. To parse art/logo.xnb as XNB, pass "art/logo". Passing the suffix itself tests logo.xnb.xnb; if absent, the loose reader receives the original XNB file. Dots in logical names have no special meaning.

A literal path wins over its sidecar. With hero.png and hero.cnj present:

1 auto native = content.Load<Texture2D>("sprites/hero.png");
2 auto sidecar = content.Load<Texture2D>("sprites/hero");

The first call selects the literal PNG after the extra XNB probe. The second selects hero.cnj before the native-extension list. A CNJ sourceFile then starts a checked nested load; it is not a direct decoder bypass.

33.1.1 Native extension order

Requested type Ordered native candidates after literal and CNJ
Texture2D .png, .jpg, .jpeg, .bmp, .gif, .tga, .tif, .tiff, .qoi
TextureCube .dds
SoundEffect .wav
Song .mp3, .ogg, .wav, .flac, .opus, .aac, .wma
Video .mp4, .ogv, .webm, .mkv, .avi, .mov; loose reader excluded on Emscripten, Android, and MinGW
Model .cnj, .gltf, .glb; the first entry is redundant with the manager-level CNJ probe
SpriteFont, Effect, AnimationClipEXT, Curve self-contained .cnj

The global CNJ probe runs even when a reader does not advertise or understand CNJ. In particular, the current loose Song and Video readers treat the selected path as media. An extensionless name with a same-named .cnj therefore feeds JSON to the media object instead of falling through to the native audio/video file. Pass the explicit media filename for those types; a generic media-sidecar contract does not exist at the pin.

33.2 RootDirectory and containment

RootDirectory is a base path, not a sandbox. BuildAssetPath joins it with the logical name but does not reject absolute paths, canonicalize dot segments, or check symlinks:

1 root = "Content"
2 "sprites/logo" -> "Content/sprites/logo"
3 "../shared/logo" -> "Content/../shared/logo"
4 "/srv/logo" -> "/srv/logo"

Top-level names must therefore be trusted or validated by the application. Internal references have narrower rules:

  • CNJ sourceFile rejects absolute paths, traversal and symlink escape, another CNJ, and implicit sidecar cycles.

  • ContentReader::ReadExternalReference<T> rejects absolute, drive, UNC, and normalized above-root paths.

  • XNB Song and Video readers contain companion files within the content root. For an explicitly loaded external XNB, the XNB’s own directory becomes the authorized bundle root.

These checks compare canonicalized paths before the eventual open; they are not a race-proof open-by-handle policy. Chapter 37 records the TOCTOU boundary.

33.2.1 Cache identity is textual

NormalizeKey converts backslashes to slashes and lowercases bytes. It does not include the root, resolve dot segments, or canonicalize a file. Consequences include:

  • Sprites\Logo and sprites/logo share a cache key;

  • textures/../logo and logo may name one file but occupy two entries;

  • two case-distinct files on a case-sensitive filesystem collapse to one logical key;

  • changing RootDirectory does not invalidate an already cached logical name.

Call Unload before intentionally reusing names under a new root.

33.3 Direct and managed loading

A direct texture constructor with a concrete path remains useful for a small program:

1 Texture2D logo("assets/logo.png", getGraphicsDeviceProperty());

Game instead exposes its configured manager in LoadContent:

1 ContentManager& content = getContentProperty();
2 content.setRootDirectoryProperty("Content");
3 auto logo = content.Load<Texture2D>("sprites/logo");
4 auto font = content.Load<SpriteFont>("fonts/hud");

RegisterTypeReader<T> installs one loose reader for a C++ type. RegisterCnjLoader<T> installs named CNJ factories that produce a type without a pre-existing reader. Both are CNAEXT surfaces; complete registration before loading begins.

33.4 Caching and ownership

Successful generic loads enter a strong cache keyed by requested type and normalized logical name. The write occurs only after a reader returns, so a failed top-level load can be retried. Specializations differ:

Type Manager state Ownership consequence
Ordinary copyable T Strong std::any value Repeated loads return copies of the cached value; caller-held shared handles can outlive eviction.
Texture2D Weak renderer and optional pixel shadow A live returned texture keeps its renderer alive; after eviction, a new load can construct a new renderer.
SoundEffect, TextureCube No manager cache Move-only values are decoded independently on each call.

Unload clears only the generic and texture caches. It does not dispose caller-held values, clear readers, reset the root/device/provider, or refresh the manifest. Dispose calls Unload once and marks the manager; later loads throw std::runtime_error. The default C++ destructor does not call that public method.

Nested reader activity is not transactional. A reader can cache dependencies and then fail, leaving those successful nested entries. There is no provisional key or cycle detector, so a recursive load of the same uncached type/name can recurse until stack exhaustion.

33.4.1 Game content assignment copies state

Game::setContentProperty(const ContentManager&) performs memberwise assignment into the existing manager. It copies caches, readers, manifest, disposed state, and raw service/device pointers; it does not replace a managed-object reference as FNA does. Assigning a standalone manager can overwrite Game’s valid graphics-device pointer with null. Configure the existing manager in place when only the root should change:

1 auto& content = getContentProperty();
2 content.setRootDirectoryProperty("OtherContent");

33.5 Failure and concurrency boundaries

ContentLoadException covers many validated content errors, including malformed XNB/CNJ data, absent reader registration, bad reader indices, and unsupported reader versions. It is not a universal wrapper. Depending on the selected path, callers can also see std::runtime_error, FileNotFoundException, EndOfStreamException, std::bad_any_cast, or an exception from custom reader code. Empty names are not rejected up front.

1 try {
2 auto level = content.Load<GameLevelData>("levels/intro");
3 StartLevel(level);
4 } catch (const ContentLoadException& error) {
5 ShowContentDiagnostic(error.what());
6 } catch (const std::exception& error) {
7 ReportAssetFailure(error.what());
8 }

The manager’s maps, manifest vector, strings, pointers, and disposed flag are unsynchronized. Concurrent load/unload/registration/root/manifest operations can form C++ data races. Single-threaded reentrancy also has hazards: self-loading cycles have no sentinel, and a reader that replaces its own registration can destroy the active object. Register and configure first, serialize manager mutation through one owner, and publish completed assets to workers.

33.6 Provider, ResourceContentManager, and TitleContainer

The IServiceProvider* constructors retain a raw pointer, but the pinned ContentManager never calls GetService. A GPU-backed standalone manager needs an explicit device:

1 ContentManager assets(&services, "Content");
2 assets.setGraphicsDevice(device);
3 auto logo = assets.Load<Texture2D>("sprites/logo");

Game performs that association for its own manager. Both pointers remain borrowed.

ResourceContentManager is a surface stub. Its protected OpenStream ignores the asset name and throws std::runtime_error; inherited Load<T> does not call that virtual method. The class therefore loads ordinary files only through its base behavior and does not provide embedded resources.

33.6.1 TitleContainer

TitleContainer::OpenStream is an independent direct-file helper. It converts backslashes, resolves relative names against the process-global TitleLocation, and returns a read stream. ContentManager does not call it. The CNAEXT title-location setter is mutable global configuration and is unsynchronized.

Resolution is lexical rather than contained: parent segments, absolute paths, and symlinks can leave the title directory. Android adds an SDL asset fallback and copies the loaded bytes into a MemoryStream. The helper is suitable for application-controlled paths, not untrusted filenames or content-root redirection.

33.7 Content manifest

GetContentManifest lazily scans the root and returns a reference to manager-owned rows. RefreshContentManifest rebuilds them. A row records a logical path, XNB/CNJ presence, native extensions, and—for readable uncompressed XNBs—reader names. GetXnbReaderUsageSummary aggregates those names and reports whether the global registry currently contains each key.

The manifest is diagnostic only:

  • Load performs fresh filesystem probes and never consults it.

  • Presence does not establish a valid file, compatible reader, device, or loadable graph.

  • Compressed or malformed XNBs can appear with an empty reader-name list.

  • Directory and hash-map iteration make row and summary order unstable.

  • Missing or inaccessible trees can yield an empty or partial snapshot without a public scan-status result.

  • Refresh can invalidate held element references and iterators; copy before sorting or retaining the data.

Unload, disposal, and root changes do not automatically refresh this separate snapshot.

33.8 Audio and video readers

Loose readers and XNB readers solve different problems. Loose Song and Video simply wrap the selected media path (Video also requires the manager device). XNB readers decode the XNA-specific body and resolve an external companion.

33.8.1 SoundEffect

The XNB SoundEffectReader handles native PCM16 directly. PCM8, IEEE float, IMA-ADPCM, and MS-ADPCM are wrapped as an in-memory WAV and sent through SDL’s decoder; XMA2 is rejected. MS-ADPCM content without its coefficient extension receives the standard coefficient table and a block-derived sample count. The full wire contract and loop-boundary defect are in §35.4.10.

33.8.2 Song

An XNB Song body contains a reference string and duration in milliseconds. The reader resolves the reference within the authorized root, strips the final four characters when long enough, and probes the extensionless stem, .ogg, .oga, then .qoa. If none exists, it restores the serialized path. This preserves the desktop substitution convention for XNA .wma placeholders; it is not general extension parsing.

Construction checks that the selected path exists, so a well-formed XNB with no companion fails during Load<Song> with FileNotFoundException. Decoding remains deferred until playback. The retained MonoGame fixture establishes XNB dispatch, sibling lookup, duration, and the .ogg route; it does not establish playback or the other substitutions.

33.8.3 Video

An XNB Video body contains a reference, duration, width, height, frame rate, and soundtrack enum. The reader contains the reference, probes the extensionless stem, .ogv, then .ogg, and stores the serialized metadata in a Video. It does not open or validate the media during content loading.

VideoPlayer::Play performs the FFmpeg open on supported native builds and compares stream dimensions and frame rate with the serialized metadata. Missing or unusable media can leave CNA stopped without the FNA-style file exception. The strongest test uses a test-built XNB and CNA’s FFmpeg fixture; no external-producer Video XNB is retained. On platforms where the FFmpeg-backed implementation is excluded, declarations alone do not establish a usable route.

33.9 Porting implications

Use an extensionless logical name when XNB/CNJ precedence is desired; use a concrete literal filename when the native file itself must win. Treat the root and TitleContainer as trusted-path conveniences, not sandboxes. Register readers and attach a graphics device before loading, keep manager mutation serialized, and call Unload when deliberately changing the cache namespace.

Existing XNA/FNA games can reuse compiled assets only for reader families supported at this pin. Arbitrary Effect bytecode remains a separate unsupported contract (Chapter 17); CNJ or a native conversion is appropriate where the XNB reader or runtime capability is absent.

33.10 Summary

ContentManager’s contract is a deterministic existence-priority resolver with type-specific caching. XNB, literal, CNJ, and native-extension paths are not fallback attempts after decode failure. The distinction explains most surprising content behavior: explicit filename choice, root changes hidden by cache, sidecars shadowing native media, and a manifest that inventories candidates without certifying them.

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