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 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. Read from ContentManager.hpp, ContentManager.cpp, the per-family loader files and the content tests at the snapshot; 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 3b43bd86; 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 (System::ObjectDisposedException), normalises the key, returns a cache hit, reads only through ReadAsset<T> when RouteLoadsThroughOpenStreamEXT(true) was set, installs the built-in readers of T, then reads name.xnb through the virtual OpenStream (tier 1; only the base implementation's own missing-file refusal falls through), tries 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 | TitleRootedDirectory and ResolveContentRoot (a relative root that exists under TitleLocation.Path, or the platform base path, is used there, otherwise the working directory; Android keeps its packaged-asset namespace), 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) installed for a built-in type by its detail::BuiltinContent<T>::Register in the family’s Content*Loaders.cpp (which Load<T> calls on first use), or registered by a game through RegisterTypeReader<T>, which keeps precedence over a built-in installed later; 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 declared by DeclareBuiltIns, and the exact boxed return type in the type’s detail::BuiltinContent<T>::RegisterCnbLoader() with a CNA_BUILTIN_CONTENT specialization so that Load<T> installs it (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 | RecordDisposableObject and the disposal list, Unload(), Dispose(bool), the content-ownership links of the resource types and the SoundEffect specialization in ContentSoundEffectLoaders.cpp | ContentManagerTextureCacheCycleTest, ContentManagerTextureCacheTeardownTest, the ownership suites (ContentOwnershipCompatibilityTest, ContentTextureOwnershipTest, ContentModelChildOwnershipTest, ContentNestedOwnershipTest, ContentPolymorphicOwnershipTest, ContentAudioOwnershipTest), 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, TryOpenCompiledStream, RouteLoadsThroughOpenStreamEXT and ResourceContentManager | ContentStreamRoutingTest, 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 (which only declare the built-in.cnbloaders registered),getGraphicsDeviceInternal, the path and file services andDispose(bool). Beside it:detail/BuiltinContent.hpp(the per-type registrationLoad<T>calls), the family files such asContentTexture2DLoaders.cppandContentSoundEffectLoaders.cpp(with theSoundEffectspecialization),ContentManagerUntyped.cpp(the untyped XNB path) andContentAllLoaders.cpp(RegisterBuiltinLoaders).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), and it is read through
OpenStream, so an override serves it and an override's refusal ends the load; CNB is second, and only then the loose tier. Onlyname.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. The root is not in the key; instead theRootDirectorysetter throwsSystem::InvalidOperationExceptionwhile anything is cached, as measured XNA does (583af2a5e), so onlyUnload()empties the cache before a root can change. Unload()disposes what the readers recorded. Since39ef8ea2f(ISSUE-011) every tier records each disposable object an asset owns, a repeated reference included, andUnload()disposes them in read order, as XNA does, then clears the records and the cache; a disposal callback that changes the records ends it withInvalidOperationException. Copies and shared references already handed out share the disposed resources.Dispose()routes toDispose(bool), which unloads only for an explicit disposal and marks the manager disposed either way. Objects a game created itself are not touched, so do not promise more than the recorded set.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 declared early and installed on first use. Every manager constructor only declares the built-in
.cnbloaders registered (CnbLoaderRegistry::DeclareBuiltIns()), and aGamedeclares the built-in XNB readers beforeInitialize();Load<T>()installs the readers ofTon its first use (the loose reader on the instance, the.cnbloader and the.xnbreaders into the process-wide registries), so a program links only the readers, importers and codecs of the types it loads. A standalone manager in a tool, test or host gets its type’s family the same way, but no direct device pointer until its owner provides one.RegisterBuiltinLoaders()(CNAEXT) and the internalRegisterAllBuiltInXnbReaders()install everything at once and link every importer; they are for hosts that must expose every type without naming it, such as the C API, and for tests that clear the registries. The XNB reader registry is process-wide and guarded by a shared mutex, with first registration winning, except that a game’s registration made while the built-ins are declared but not yet installed gives way to the built-in, as if the built-ins had been registered first. - 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 are unlocked (the process-wide registries are not), so concurrent
Loadcalls on one manager 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, ReadAsset or Unload on a disposed manager | System::ObjectDisposedException from all three |
Changing RootDirectory while assets are cached | System::InvalidOperationException |
An OpenStream override that throws, or returns no stream | That exception, or a ContentLoadException; the lower tiers are not tried |
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 | ContentLoadException naming the asset and the type the reader produced: every cast in ContentReader goes through CastReadResult (KF-9), as XNA's reader refuses a wrong type |
| 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 (an end of stream inside the header is wrapped, not leaked as EndOfStreamException); 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 refuses LZ4 (it now shares the typed decoder's header checks). 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, does
Unload()remain the only invalidation, and does it still dispose exactly what the readers recorded? - 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<SoundEffect> lets its loose tier's System::NotSupportedException escape instead of ContentLoadException — The SoundEffect specialization of ContentManager::Load returns its loose reader's result without the generic template's wrapper, so a missing or undecodable sound file escapes as System::NotSupportedException rather than
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