ContentManager resolution, caching and failure rules
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 modules/content, Game.cpp and TitleContainer.cpp at 009d40f5; five examples were syntax-checked with g++ -fsyntax-only (-DCNA_RENDERER_EASYGL, sibling sharp-runtime headers not pinned by TARGET). Nothing was executed; Android packaged .cnb loading and the reentrancy hazards are established by reading only.
ContentManager::Load<T> is an existence-priority resolver: the first candidate file that exists is the one that is read, and a decode failure is final. This page gives the rules a game can rely on at this snapshot: which spelling of a name reaches which tier, what RootDirectory does and does not confine, what the cache keys on and keeps, which exception each tier throws, and where the thread and reentrancy limits are. It deepens the ContentManager guide and Tutorial 45; the function-by-function trace for maintainers is on Content runtime internals.
Existence selects the candidate; decoding never retries
On a cache miss the manager builds one base path from the root and the logical name and then walks a fixed ladder. The ladder is a header template in ContentManager.hpp (Load<T> and ResolveAssetPath); the only per-type code is the explicit Load<Audio::SoundEffect> specialisation at the end of ContentManager.cpp, which repeats the same tiers without a cache.
Load<T>(name)
|
v
cache[(typeid(T), NormalizeKey(name))] --hit--> copy of the cached value (no file access)
| miss
v
base = BuildAssetPath(name) root joined to name, '\' becomes '/'
|
+-- base + ".xnb" readable? ---------yes--> XNB reader graph ---------------+
| no |
+-- base + ".cnb" exists? -----------yes--> CNB loader for the file's id ---+
| no |
+-- name ends in ".cnb", base exists? yes--> CNB loader --------------------+
| no |
+-- loose reader registered for T? ---no--> ContentLoadException |
| yes |
+-- base exists (file or directory)? yes--> reader.Read(base) --------------+
+-- base + ".cnj" exists? -----------yes--> reader.Read(base + ".cnj") -----+
+-- base + ext for each GetExtensions() entry, first that exists --> Read --+
+-- nothing exists ---------------------> reader.Read(base), which reports it
|
the selected candidate is terminal: success is cached, failure is thrown
ContentManager::Load<T> at this snapshot. After a cache miss the manager tries the compiled XNB file, the compiled CNB file, a name that itself ends in .cnb, and only then asks the loose reader registered for T to take the literal path, a .cnj sidecar or its own native extensions. Every arrow to the right ends the search: whichever candidate is selected is decoded once, a successful result enters the type-and-name cache, and a failure is thrown to the caller without trying a lower tier.The two compiled tiers test existence differently. The XNB tier asks TryReadAssetBytes to read the file, because packaged assets (Android) have no existence-only query: an ordinary file is opened, sized (at most INT32_MAX bytes) and read, and when the path does not exist on the filesystem the platform's case-insensitive file loader is consulted. The CNB and loose tiers use std::filesystem::exists and plain file streams, so they never reach packaged assets; whether a .cnb inside an Android package loads has not been established.
Existence is not validity. A malformed .xnb, an invalid .cnb or .cnj, a corrupt literal file, a directory that happens to carry the asset's name, or a corrupt first native extension (hero.png when a good hero.jpg sits beside it) stops the load. CNA never falls back to a lower-priority sibling after a decode failure, and a .cnb built from an edited PNG keeps winning until it is rebuilt or deleted, because no timestamps are compared.
Because the cache is written only after a reader returns, a failed top-level load leaves no entry and can simply be retried once the file is fixed. The CnjResolverOrderTests.cpp cases and ContentManagerXnbTest.XnbWinsOverCnjAndNativeExtensionForTheSameName pin the order.
How the spelling of a name chooses a tier
The compiled probes append their suffix to the complete name, and only .cnb has a literal tier of its own. A dot inside a name has no special meaning: ResolveAssetPath tests existence rather than has_extension(), precisely so that a localized name such as Flag.en-US still gets the reader's extensions appended.
| Call | Candidates, in order | Consequence |
|---|---|---|
Load<Texture2D>("art/logo") | art/logo.xnb, art/logo.cnb, literal art/logo, art/logo.cnj, then .png .jpg .jpeg .bmp .gif .tga .tif .tiff .qoi | The usual form: compiled content wins, a sidecar outranks the native image |
Load<Texture2D>("art/logo.png") | art/logo.png.xnb, art/logo.png.cnb, literal art/logo.png | The native PNG is chosen even when art/logo.xnb or art/logo.cnj exist |
Load<Texture2D>("art/logo.xnb") | art/logo.xnb.xnb, art/logo.xnb.cnb, then art/logo.xnb as a literal loose file | The XNB bytes are handed to the image decoder, which fails; spell "art/logo" to read it as XNB |
Load<Texture2D>("art/logo.cnb") | art/logo.cnb.xnb, art/logo.cnb.cnb, then art/logo.cnb as a literal CNB | The one suffix the manager recognises; the CNB loader validates it like tier 2 |
Load<Texture2D>("") | Content.xnb, Content.cnb, then the root directory itself as the literal candidate | An empty name is not rejected up front; the loose reader fails on the directory (only the protected ReadAsset<T> refuses an empty name, with ArgumentNullException) |
Literal file versus sidecar
With both sprites/hero.png and sprites/hero.cnj present, the spelling decides which one is read:
ContentManager& content = getContentProperty();
Texture2D native = content.Load<Texture2D>("sprites/hero.png"); // the literal PNG
Texture2D sidecar = content.Load<Texture2D>("sprites/hero"); // hero.cnj, unless hero.xnb/.cnb exist
Both results are cached under different keys, which CnjCacheIsolationTest pins in both loading orders. A Texture2D, TextureCube or SoundEffect .cnj must carry a sourceFile; the reader resolves it (relative to the .cnj file's own folder, contained in the root) and then calls Load<T> again with the resolved logical name. That nested call is a full, checked load: it walks the whole ladder, so sprites/hero.png.xnb would still win, and it enters the cache under its own key. The containment rules for sourceFile are on CNJ documents.
The .cnj probe runs for every type, including media
The manager-level .cnj probe does not ask whether the reader understands CNJ. Texture2D, TextureCube and SoundEffect branch on the extension; the loose Song and Video readers do not. SongTypeReader::Read constructs Media::Song(path, name), and the Song constructor only checks that the file exists, so with music/theme.cnj and music/theme.ogg side by side, Load<Song>("music/theme") succeeds and returns a Song whose file is the JSON document; the failure appears only at playback. The loose Video reader behaves the same way. Pass the explicit media file name for these two types:
Media::Song theme = content.Load<Media::Song>("music/theme.ogg"); // no .cnj detour
Two more per-type details sit on this tier. The Model reader lists .cnj first in GetExtensions(), which is redundant with the manager-level probe and harmless. And the loose Video reader is registered in every build: without the FFmpeg backend its raw-file constructor throws NotSupportedException through RequireVideoDecoderAvailable(), which the loose tier wraps in ContentLoadException, whereas an XNB or CNB Video loads its metadata anywhere and fails only in VideoPlayer::Play.
RootDirectory is a base path, not a sandbox
BuildAssetPath turns backslashes into slashes in both the root and the name, widens both as UTF-8 and joins them with std::filesystem::path::operator/. It does not reject absolute names, collapse dot segments or look at symlinks, and the existing path is then matched case-insensitively component by component (ResolveExistingNativePath):
root "Content" "sprites/logo" -> Content/sprites/logo
root "Content" "../shared/logo" -> Content/../shared/logo (leaves the root)
root "Content" "/srv/logo" -> /srv/logo (an absolute name replaces the root)
root "" "sprites/logo" -> sprites/logo (relative to the working directory)
The helper that turns a resolved sidecar path back into a logical name states the contract in its comment: Load() accepts a root-relative logical name and, by established contract, an explicit absolute outside-root asset. A name taken from a mod file, a save game or the network therefore has to be validated by the application before it reaches Load<T>.
Paths written inside content are contained
Containment applies to references a file makes, not to the caller's argument. Each reference family has its own base directory, which matters when content lives in subfolders:
| Reference | Resolved against | Refused |
|---|---|---|
CNJ sourceFile | The folder of the .cnj that names it | Empty, absolute, outside the root after weakly_canonical (compared component by component, so symlinks are resolved first), a .cnj target, or a target whose sibling .cnj exists |
CNJ sidecar fields (Model vertices, indices, skeleton, clips, morph targets; Texture3D data; Effect vertex/fragment; SpriteFont texture) | The content root, not the .cnj folder | Anything that does not stay a relative path inside the root |
.skinnedmodel.json fields | The manifest file's own folder | Escapes from the root (or from the file's own folder for an explicitly external bundle) |
XNB external references (ReadExternalReference) | The folder of the referencing logical asset | Absolute, drive-qualified and UNC spellings; a lexically normalised result above the root (a sibling ../textures/foo is allowed) |
| XNB Song and Video media names | The folder of the .xnb | Anything outside the root, checked on the stored spelling and again on the extension-probed file; an explicitly loaded outside-root .xnb is confined to its own folder instead |
CNB XREF names and Song/Video stream references | The content root, through the manager | CnbLogicalNameProblem: empty, backslash, leading /, drive letter, any .. segment, invalid UTF-8 |
The shared primitives are in PathContainment.hpp; ContentPathContainmentTests.cpp (18 cases) asserts that each rejection happens before the file is read and does not poison the cache, and that normalising .. segments which stay inside the root are still accepted. The checks compare canonicalised paths before the later open; they are not an open-by-handle policy, so a concurrent filesystem change between check and use is not excluded (see Content input boundaries).
Cache identity is textual
NormalizeKey copies the name, turns \ into / and passes every byte through std::tolower; the key is that string plus typeid(T). It does not include the root, resolve dot segments or canonicalise a path. Consequences:
Sprites\Logoandsprites/logoshare one entry;textures/../logoandlogocan name one file but occupy two entries;- two files that differ only in case on a case-sensitive filesystem collapse to one key, so the second load returns the first file's asset;
- changing
RootDirectorydoes not invalidate a name that is already cached; callUnload()first when reusing names under a new root; - the same name under two types is two entries, which is why asking for a different
Treaches normal validation instead ofstd::bad_any_cast(CnjAssetCacheTypeSafetyTests.cpp).
What the cache holds
There is one cache, loadedAssets_, an unordered_map from the type-and-name key to a std::any. Every successful generic load stores the value there and returns a copy by value, so T must be copy-constructible. For the XNA value types whose GPU state is shared-owned (Texture2D, TextureCube, Model, SpriteFont) and for the shared_ptr assets (shared_ptr<Texture3D>, shared_ptr<Effect>, shared_ptr<SkinnedModelEXT>) the cache and every caller share one underlying object. Audio::SoundEffect is move-only with a per-owner dispose cascade, so its specialisation decodes a fresh, independently owned value on every call and caches nothing. There is no separate weak texture cache at this snapshot, and TextureCube, which is copyable, is cached like any other type.
Unload, Dispose and destruction
Unload()is exactlyloadedAssets_.clear(). It disposes nothing, cannot revoke copies already handed out (they stay valid, and their GPU objects die with the last copy), and leaves the loose readers, the CNJ loader table, the root, the device and service pointers and the manifest snapshot untouched.Dispose()calls the protectedDispose(true), which runsUnload()once and marks the manager disposed; a second call does nothing. AfterwardsLoad<T>throwsstd::runtime_errorandReadAsset<T>throwsSystem::ObjectDisposedException.- The destructor is defaulted and does not call
Dispose(). A derived manager that overridesDispose(bool)must addusing ContentManager::Dispose;, because declaring the name hides the public overload.
Nested loads are neither transactional nor cycle-checked
Readers load their dependencies through the same manager: a sourceFile, a SpriteFont atlas, the textures of a Model .cnj, the typed external references of an XNB. Each nested result enters the cache as soon as it returns, so an outer reader that fails afterwards leaves its successful dependencies cached. Nothing marks a name as in progress: by reading, an asset whose references lead back to itself (for example an EffectMaterial XNB whose effect reference names its own asset, or a cycle of untyped external references) re-enters Load until the stack is exhausted. The CNJ sourceFile rules are the one place a cycle is refused explicitly. A related single-thread hazard: Load<T> holds a plain reference to the reader object owned by the registration map, so a reader that calls RegisterTypeReader<T> for its own T destroys itself mid-call.
The Game's manager is assigned by value
Game owns its manager as a member, sets its device and registers the built-in XNB readers in its constructor, and hands out a reference through getContentProperty(). setContentProperty(const ContentManager&) is a memberwise copy assignment into that member: it copies the cache, the shared loose-reader objects, the CNJ loader table, the manifest snapshot, the disposed flag, the root and the raw service-provider and device pointers. It does not swap in another object as FNA's property does. Assigning a standalone manager that never received setGraphicsDevice() therefore replaces the game's device pointer with null, and later GPU loads depend on finding an IGraphicsDeviceService through whatever provider was copied. To change only the root, configure the existing manager in place:
ContentManager& content = getContentProperty();
content.Unload(); // cached names do not include the root
content.setRootDirectoryProperty("OtherContent");
Service provider, device and derived managers
getGraphicsDeviceInternal() uses the pointer given to setGraphicsDevice(); failing that it calls GetService(typeid(IGraphicsDeviceService)) on the provider; with neither it throws ContentLoadException ("no GraphicsDevice is available"). Both pointers are borrowed, and none of the three constructors rejects a null provider (XNA's throw ArgumentNullException). ContentManager(IServiceProvider*) leaves the root empty, while ContentManager() and the Game's own manager use "Content". A manager used without a Game needs both of the things a Game supplies:
CNA::Internal::Xnb::RegisterAllBuiltInXnbReaders(); // process-wide, idempotent
ContentManager assets(&services, "Content");
assets.setGraphicsDevice(device); // or register an IGraphicsDeviceService
Texture2D logo = assets.Load<Texture2D>("sprites/logo");
A small program can also bypass the manager entirely: the CNAEXT constructor Texture2D(const std::string&, GraphicsDevice&), as in Texture2D logo("assets/logo.png", getGraphicsDeviceProperty());, decodes one image file directly, with no ladder, cache or containment.
The protected XNA seams exist but sit beside the ladder, not under it. ReadAsset<T> reads through the virtual OpenStream() and never touches the cache; the base OpenStream() reads <root>/<name>.xnb through TryReadAssetBytes. Load<T> never calls OpenStream(), so ResourceContentManager, which overrides it to serve binary resources from a System::Resources::ResourceManager (separate ContentLoadException messages for no resource family, not found, not binary and too large; ArgumentNullException for a null resource manager in the two-argument constructor), is reached only by code that calls ReadAsset<T> from a derived class. ContentRuntimeContractTests.cpp and ResourceContentManagerTests.cpp cover the seam.
TitleContainer::OpenStream is a separate direct-file helper that the manager never calls. It normalises backslashes, uses a rooted name as given and joins a relative one to TitleLocation with lexically_normal(), so .. segments, absolute names and symlinks can leave the title folder; it is meant for application-controlled paths. Its other rules are on GameWindow and the supporting types.
What a failed load throws, tier by tier
ContentLoadException is the common case but not a universal wrapper. The loose tier normalises; the XNB tier does not; the CNB tier normalises its own container errors and the wrong-type case only.
| Situation | Caller sees |
|---|---|
| Manager already disposed | std::runtime_error from Load<T> and Load<SoundEffect>; System::ObjectDisposedException from ReadAsset<T> |
No compiled file and no loose reader for T | ContentLoadException ("No reader registered for type") |
XNB: file unopenable, unsized, unreadable or above INT32_MAX; bad magic, platform, version, length or flags; unregistered or version-mismatched reader; bad indices; limits exceeded | ContentLoadException |
| XNB: fewer than 10 header bytes, or a body that ends inside a declared blob | System::IO::EndOfStreamException, unwrapped |
XNB: requested T differs from what the root reader produces | std::bad_any_cast |
| XNB: object creation refused by the device or profile (a Texture3D on Reach, a vertex declaration the profile cannot express), or a Song whose companion file is missing | The underlying System::NotSupportedException or System::IO::FileNotFoundException, unwrapped |
CNB: container, schema, codec, limit or XREF problem | ContentLoadException naming the file |
CNB: requested T differs from the file's asset type | ContentLoadException naming both, raised after the loader has already built the object (the check is an any_cast of the result) |
| CNB: another exception from inside a loader, such as a texture the device refuses | Propagates unwrapped |
Loose reader throws any std::exception | ContentLoadException with the original as its inner cause; a ContentLoadException passes through unchanged |
Loose tier of Load<SoundEffect> | Not wrapped: whatever the reader throws |
A caller that must survive bad content therefore catches both levels:
try {
LevelData level = content.Load<LevelData>("levels/intro");
StartLevel(level);
} catch (const ContentLoadException& error) {
ShowContentDiagnostic(error.what()); // validated content errors, wrapped loose failures
} catch (const std::exception& error) {
ReportAssetFailure(error.what()); // XNB-tier and loader exceptions that are not wrapped
}
The same list, as the XNB container fuzzer accepts it, is on Content input boundaries.
Threads and reentrancy
A manager's cache, reader maps, CNJ loader table, manifest vector, root string, pointers and disposed flag are unsynchronised, and so is the process-wide XNB reader registry in ContentTypeReaderManager.cpp (a function-local static map). Only the CNB loader registry takes a std::shared_mutex. A ContentReader acquires a renderer-thread context lease when the manager holds a direct device pointer, which accommodates renderers with thread affinity but does not make parallel loads safe. The working rule: register readers and configure the manager first, perform every load, unload and registration from one owner, and hand finished assets to workers, as Tutorial 77 does.
The content manifest is a diagnostic snapshot
GetContentManifest() lazily scans the root and returns a reference to the manager's own vector; RefreshContentManifest() rebuilds it, and GetXnbReaderUsageSummary() aggregates reader names and asks the global registry whether each is currently registered. The rules that surprise:
Load<T>never consults it; every load probes the live filesystem.- A row proves presence, not validity: reader names come only from uncompressed, well-formed
.xnbfiles, and a compressed or malformed file shows an empty list because every scan error for that file is swallowed. - Files are grouped by their path minus the last extension, and only
.xnband.cnjare special; a.cnbis listed among the native extensions, so the snapshot does not model the CNB tier. - Row and summary order come from
unordered_mapiteration and are not stable. - A missing or unreadable root yields an empty snapshot with no status to tell the two apart.
- A refresh invalidates references and iterators into the previous rows; copy before sorting or keeping them.
Unload(),Dispose()and a root change do not refresh it.
Porting rules of thumb
- Use an extensionless name when compiled or sidecar precedence is wanted; use the literal file name when the native file itself must win, and always for Song and Video.
- Treat
RootDirectoryandTitleContaineras trusted-path conveniences; validate names that come from outside the game. - Register custom readers and attach a device before the first load; in a tool or test also call
RegisterAllBuiltInXnbReaders(). - Call
Unload()before reusing names under a different root, and configure the Game's manager in place instead of assigning another one. - Catch
std::exceptionas well asContentLoadExceptionwhen content may be malformed. - XNA and FNA assets load only for the reader families that exist at this snapshot; compiled Effect bytecode additionally needs a renderer build that reports
CompiledEffects(see XNB type readers), and.cnb,.cnjor native files are the route where a reader or a renderer route is missing.
Evidence and what is not proven
Every rule above was read from ContentManager.hpp, ContentManager.cpp, ContentReader.cpp, CnjSourceFile.hpp, Game.cpp and TitleContainer.cpp at this snapshot; nothing was built or executed. Tests that pin parts of it exist in the ContentManager test folder: CnjResolverOrderTests, ContentManagerXnbTests, CnjAssetCacheTypeSafetyTests, CnjCacheIsolationTests, ContentPathContainmentTests, UnicodeContentRootTests, ContentRuntimeContractTests and ResourceContentManagerTests. Established only by reading, with no test found: the Song and Video .cnj shadowing, the self-reference recursion, the reader-replacement hazard, the null device after setContentProperty, and whether .cnb loads from an Android package. The C++ examples were syntax-checked with g++ -std=c++23 -fsyntax-only -DCNA_RENDERER_EASYGL against the snapshot's headers and a sibling sharp-runtime checkout that the snapshot does not pin; LevelData, StartLevel and the two report functions stand for game code.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- ContentManager guide · Tutorial 45: The Content Manager
- Architecture
- Content architecture
- Internals
- Content runtime internals
- Maintainer workflow
- I need to modify ContentManager
- Tests and validation
- Test architecture and change recipes
- Reference
- Public headers: content