I need to modify ContentManager
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. Read from ContentManager.hpp, ContentManager.cpp and the content tests at 009d40f5; no test was built or run, and behaviour on packaged Android assets was not established.
ContentManager is the runtime front door for every asset a game loads, and its behaviour is spread across a header template, a registry per format and a shared reader. A small edit to path resolution, the cache key or a reader can change what loads, what is shared and what a caller can still observe after Unload(). This recipe names the owner of each kind of change, lists the invariants that must survive it, sets out the contract with the build-time pipeline that writes what the manager reads, and gives the tests that prove it. Everything was read from the CNA source at snapshot 009d40f5; no test was built or run for this page.
The load ladder is traced function by function on Content runtime internals; the user-level behaviour is on the ContentManager guide and XNB loading guide. This recipe does not repeat the trace; it tells you where to make a change and how to keep it safe.
Find the owner
The manager's ladder is a header template, not a .cpp function, so tier order and cache behaviour live in ContentManager.hpp. In order, Load<T> checks the disposed flag, normalises the key, returns a cache hit, then tries name.xnb (tier 1), name.cnb (tier 2) and a literal .cnb name (tier 2b), and only then a per-type loose reader through typeReaders_, whose path resolution prefers a literal file, then a .cnj sidecar, then the reader's extensions. The rest is in ContentManager.cpp.
| You want to change | Owner | First tests |
|---|---|---|
| Tier order, cache lookup or store, the key | Load<T> and NormalizeKey: the AssetCacheKey is the pair (requested C++ type, name with \ turned into / and each byte lower-cased), not the resolved path or the root | CnjAssetCacheTypeSafetyTest, CnjCacheIsolationTest, ContentManagerTextureCacheCycleTest, CnjResolverOrderTest |
| Path building, case-insensitive lookup, non-ASCII roots, packaged assets | BuildAssetPath, ResolveExistingAssetPath, TryReadAssetBytes (Android relative paths go through the platform file system; the CNB and loose tiers use std::filesystem instead) | UnicodeContentRootTest, ContentPathContainmentTest |
| A new loose-file format or a new loose reader | A LooseFileContentTypeReader<T> (header) registered in RegisterBuiltinLoaders or through RegisterTypeReader<T>; extension and .cnj precedence are part of the reader | CnjResolverOrderTest, the reader's own Cnj* suite, malformed and wrong-type cases |
| A new or changed XNB reader | The process-wide reader registry (ContentTypeReaderManager.cpp), the built-ins in XnbBuiltInReaders.cpp, the shared ContentReader.cpp | ContentTypeReaderManagerTest, XnbBuiltInReaderRegistrationTest, the reader's own suite under modules/content/tests/CNA/Internal/Xnb, ContentReaderExternalReferenceTest |
| A new CNB asset type or schema | A codec and schema under modules/content/src/Cnb, a CnbLoaderRegistry identity and the exact boxed return type in RegisterBuiltinLoaders (or RegisterCnbLoaderEXT<T> for a game type in the custom identifier range) | CnbContentManagerTest, CnbSpecConformanceTest, the golden-vector and codec suites in the Cnb tests |
Ownership, Unload or Dispose | Unload(), Dispose(bool) and the SoundEffect specialization at the end of the .cpp | ContentManagerTextureCacheCycleTest, ContentManagerTextureCacheTeardownTest, the sound-effect XNB suite |
| Error mapping | ContentLoadException wrapping in the loose tier, the CNB wrong-type message, the XNB decoders | ContentRuntimeContractTest, ContentLoadExceptionContractTest |
| A manager that does not read the content root | The protected OpenStream and ReadAsset<T> seam and ResourceContentManager | ResourceContentManagerTest |
Read first
- The
Load<T>,LoadXnbAsset<T>,LoadCnbAsset<T>andResolveAssetPathtemplates in the header. Tier priority is decided here, and a change to one tier without reading the others is how a caller's literal path starts shadowing a compiled asset. ContentManager.cpp: the constructors,getGraphicsDeviceInternal, the path and file services,Dispose(bool), the untyped XNB path,RegisterBuiltinLoadersand theSoundEffectspecialization.ContentReader.cpp: root dispatch, the two-pass reader table, shared resources, external references and the renderer-thread lease; thenCnbLoaderRegistry.cppto compare the CNB registry with the XNB one.- The neighbouring tests above, to see which paths are already pinned.
Invariants a change must keep
- Compiled assets outrank everything CNA compiles from. XNB wins even over a caller's literal path or a registered loose reader (a genuine external asset must not be shadowed by a convenience), CNB is second, and only then the loose tier. Only
name.xnbis tried, whereas a name already ending in.cnbalso reaches tier 2b. Changing the order is a compatibility decision: it needs resolver-order tests, not just a loader test. - The cache key includes the type. That is what turns "same name, different type" into a clear
ContentLoadExceptionrather thanstd::bad_any_cast. Adding the resolved path or root to the key would make a changed root invalidate a hit, which today onlyUnload()does. Unload()is exactlyloadedAssets_.clear(). It cannot revoke copies or shared references already handed out;Dispose()routes toDispose(bool), which unloads only for an explicit disposal and marks the manager disposed. Do not promise that the manager destroys every GPU object it ever returned.Load<T>returns by value, so the cache needs a copy-constructibleT.Audio::SoundEffectis move-only with a per-owner dispose cascade, so its explicit specialization returns an independently owned value and caches nothing; a cache-policy change must cover that special case.- Readers are registered per instance and per process. Every manager constructor calls
RegisterBuiltinLoaders()(twelve loose readers on the instance, then idempotent CNB built-ins into the process-wideCnbLoaderRegistry); the XNB reader registry is process-wide and unlocked, with first registration winning. AGame's manager gets a direct device pointer and the built-in XNB readers beforeInitialize(); a standalone manager in a tool, test or host has neither until its owner provides them. - Identifier discipline in CNB. Built-in identifiers (below
0x80000000) and custom ones are registered through different entry points, a custom identifier must equal the hash of its canonical name, and a custom match also requires the file's own canonical type name to agree because a 31-bit hash can collide. Re-registering the same identifier with the same name is tolerated; anything else isstd::logic_error. - External references stay inside the content tree. They resolve relative to the referencing asset, refuse absolute references, and refuse a result that climbs above the root; the typed form walks the full ladder and the typed cache, the untyped form insists on a compiled
.xnb. - Threading is not solved by the lease. The reader acquires a renderer-thread context lease only when the manager has a direct device pointer; the manager's cache and reader maps and the XNB registry are unlocked, so concurrent
Loadcalls are not supported.
Two things are easy to assume and are not so. The CNAEXT content manifest (RefreshContentManifest(), GetContentManifest()) is a snapshot that Load<T> does not consult, and it does not model the CNB tier. And by reading, BuildAssetPath joins the root and the caller's name without a containment check; containment is enforced at the reference layers (external references, CNB external-reference names, sidecar and media fields), so do not assume a name taken from untrusted input is confined.
The contract with the pipeline
The runtime and the build-time pipeline meet in the bytes on disk, and a change on either side is a change to that contract (Content pipeline internals, Modify the Content Pipeline).
| Element | Writer side | Reader side |
|---|---|---|
| XNB | Writers registered against the XNB format key; identity includes the codec version and an options digest (platform, container version, profile, compression, reader-name spelling); an XnbOutputAssetId that is append-only and persisted in manifests | The file's own type-reader table selects readers through ContentTypeReaderManager; header, size limits (XnbReadLimits) and decompressors are shared with the pipeline's canonical XNB importer, so a reader change alters what cna-content accepts as a source |
| CNB | Asset type id, schema version and codec identity declared by the writer and checked by the build before every write | CnbDocument::ParseFile validates the whole file and every CRC, CnbLoaderRegistry::ResolveForDocument selects the loader, the manager casts to T |
| Logical names | CnbLogicalNameProblem is the single rule for external-reference names, applied by the document, the writer and every media codec | The same function on read, followed by the manager's own containment checks: the writer must not be able to produce a file its own reader refuses |
| Manifest and cache | .cna-content-manifest.json drives incremental builds | Never read by the runtime |
| Devices | The pipeline creates no GraphicsDevice; a texture is decoded into plain data | The first GPU allocation happens in the runtime loader, on the manager's device |
When you change a schema, a reader's expectations or a naming rule, the proof is a round trip across the boundary: build through the pipeline, then load through ContentManager. Texture2DContentPipelineTests names the pattern (ResultLoadsThroughTheExistingContentManagerRuntimePath, and a byte-identity check against the unchanged producer). Bump the writer's component or schema identity when the encoding changes so old outputs rebuild instead of being mistaken for current ones.
Reproduce narrowly
cmake --preset unit # Debug, STUB, tests on; networking, video, Draco off
cmake --build --preset unit-content # builds CnaContentTests
./cmake-build-unit/CnaContentTests --gtest_list_tests | grep -i 'CnjAssetCacheTypeSafety\|ContentManagerXnb'
./cmake-build-unit/CnaContentTests --gtest_filter='CnjResolverOrderTest.*:CnjAssetCacheTypeSafetyTest.*' # from the repository root
Reproduce with a scratch content root and the fewest files: a unique temporary directory, one asset, ContentManager cm(nullptr, root). Record the logical name, which tier was tried, the resolved UTF-8 path and whether the platform file-system fallback was consulted. Several texture-reader suites skip under the STUB renderer (they use CNA_SKIP_IF_RENDERER_IS_NONE_OF), so a GPU-backed case needs a renderer that can hold textures, and a skipped case is not evidence. Cases that spawn a tool process are absent on Windows, Emscripten, Android and iOS.
Make the smallest change
- New loose format: implement a
LooseFileContentTypeReader<T>, register it, decide extension and.cnjprecedence explicitly, and test the missing file, a malformed file and a wrong requested type. The loose tier wraps astd::exceptioninContentLoadExceptionand passes aContentLoadExceptionthrough. - New XNB reader: add a process-wide canonical factory with
AddTypeCreatorand a version contract (or declare it withReflectiveTypeReaderBuilder<T>), wire it through the built-in registration, and add a fixture proving the type table, shared references and decompression. Mind the two-pass initialisation: every reader in the file's table is created and version-checked before any is initialised. - New CNB type: validate the container before constructing objects, keep the reserved and custom identifier rules, register with the exact boxed type (a
shared_ptrforTexture3D), and add golden vectors plus a runtimeLoad<T>round trip. - Any path change: test a Unicode root, packaged lookup on the platforms that have it, external-reference containment and the cache key. If the loaded object is graphics- or audio-backed, run those subsystems' lifetime tests too.
- Do not add locking piecemeal. A global mutex on one map does not make the manager thread-safe; async loading would have to audit the unlocked cache and reader maps, the unlocked XNB registry, graphics thread affinity and callback order together.
Error paths a change can move
| Failure | What the caller sees |
|---|---|
| Load on a disposed manager | std::runtime_error from Load<T>; System::ObjectDisposedException from ReadAsset<T> |
No reader registered for T after both compiled tiers miss | ContentLoadException "No reader registered for type" |
A .cnb holding a different asset type than T | ContentLoadException naming what the file holds |
Wrong T for an .xnb | The XNB tier has no equivalent wrapper; asking for the wrong type can surface std::bad_any_cast (a documented gap, not a contract to rely on) |
| Unregistered or version-mismatched XNB reader | ContentLoadException naming the reader, not a null dereference |
| No device for a GPU-requiring load | ContentLoadException "no GraphicsDevice is available" |
| Oversized or truncated input | ContentLoadException from the file-size and header checks; the decoders bound counts and sizes through XnbReadLimits before allocating |
The SoundEffect specialization's loose tier does not wrap errors the way the generic template does, and the untyped external-reference decoder has fewer guards than the typed one (it refuses LZ4 and, by reading, does not repeat the 14-byte compressed-payload minimum check). Both are recorded gaps on the internals page; do not "fix" one silently while working on something else, and do not write a test that pins the accidental behaviour.
Prove it
- A test that fails before the change: a case in the owning suite, with a scratch content root, that pins the tier, the key, the exception or the ownership rule you touched, plus its negative twin (the wrong type, the missing file, a malformed payload).
- The content group:
cmake --build --preset unit-contentand the filter for the suites above, then the aggregateCnaTestsand its discovered entries. - The format conformance entries that guard the binary formats, when a reader or writer changed:
CnaXnbSpecificationConformance(an independent checker written from the published XNB specification over the repository's real fixtures),CnaXnbModelCorpusSweep(glTF fixtures built to Model XNB, needs thecna-contenttool) andCnaXnbDependencyBoundary(proves nothing a game links reaches a build-time dependency); all carry the labelscontent;xnb. - A pipeline-to-runtime round trip for any format change, and the XNB and CNB golden vectors.
- Renderer-backed loads on a rendering renderer for anything that constructs textures, effects or models, and the disposal and background-load cases if the context lease is involved; audio-backed loads for sound, song and video.
Check the blast radius
| If the change touched | Also check |
|---|---|
| Key normalisation or path building | The C API's path and key routes, which expose them directly (cna_content_manager_get_normalized_key_size, cna_content_manager_copy_asset_path, and the manager routes in content.h) and their CApi_* tests; sample and tutorial code that spells asset names. |
| The ladder order | Every consumer that relies on .xnb over .cnb over loose files, including projects that ship both a compiled and a source asset of the same name. |
| A reader or a CNB schema | The pipeline writer and importer that share it, the CNB format documentation (CNB Format), and the bindings' expectations for the format. |
| Ownership or cache policy | Texture and effect lifetime in the graphics tests, SoundEffect independence, and games that call Load from Draw and rely on the cache. |
| Registered readers | Multiple managers in one process (the XNB registry is shared), tools and tests that create managers without a Game. |
Review checklist
- Which tier does the change live in, and was the tier order left alone, or is the reorder justified with a resolver-order test?
- Does the cache key still include the type, and does
Unload()remain the only invalidation? - Is the exception for each failure path stated, and is any accidental behaviour pinned by a new test?
- If a format or naming rule changed, was the writer's identity bumped and a pipeline-to-runtime round trip added?
- Does anything new assume a graphics device, a thread, or a locked registry that does not exist?
- Are skips (STUB-gated readers, absent tools) counted and reported, and is what was not run stated?
- Are the
docs/content pages and the C API routes for the touched behaviour up to date?
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- CNJ documents: envelope, sourceFile and sidecar rules — The rules a .cnj document must satisfy in CNA: envelope validation, which reader accepts which type, how sourceFile and sidecar paths resolve, which parser reads which field, and what CNJ does not bound.
- Content input boundaries and hostile-input behaviour — Where CNA enforces size, count, nesting and path limits across XNB, CNB, CNJ and the build importer, how the loaders are fuzzed, which exceptions hostile files produce, and which gaps remain.
- ContentManager resolution, caching and failure rules — Which file ContentManager::Load<T> actually reads, what RootDirectory does and does not confine, what the cache keeps, and which exception each tier throws at this snapshot.
- The XNB container and the ContentReader object graph — Byte-level and session-level rules for reading .xnb files in CNA: header, compression, the type-reader table, one-based object indices, shared resources, limits and custom-reader rules.
- XNB type readers: wire contracts and validation — What each built-in XNB type reader reads, validates and refuses at this snapshot, from primitives and textures to SpriteFont, Model, effects and audio, with the evidence behind each.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-008: Load<Song> and Load<Video> resolve a same-named .cnj file and return a media object that points at JSON — ResolveAssetPath tries name.cnj before the Song and Video readers' media extensions, and neither reader handles .cnj, so a same-named .cnj silently becomes the Song or Video file.
- CNA-BUG-122: ContentManager::Load<T> leaks non-XNA exception types: std::bad_any_cast for a wrong .xnb type, unwrapped Load<SoundEffect> errors, std::runtime_error when disposed — ContentManager::Load wraps loose-reader failures in ContentLoadException, but its .xnb tier lets std::bad_any_cast escape, the SoundEffect specialization wraps nothing, and a disposed manager throws std::runtime_error.
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- User guide
- ContentManager: asset paths · ContentManager: caching and lifetime · XNB loading: how ContentManager resolves an asset · CNB Format: the loading path
- Architecture
- Content architecture
- Maintainer workflow
- Modify the Content Pipeline · What to test after changing X · Thread and callback map
- Tests and validation
- Test architecture and change recipes · Add a regression test
- Reference
- Test target index · Public header index