I need to modify ContentManager

CNA snapshot 3b43bd86  ·  Development › Maintainer Handbook  ·  source links pinned to 3b43bd86

✓

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 changeOwnerFirst tests
Tier order, cache lookup or store, the keyLoad<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 rootCnjAssetCacheTypeSafetyTest, CnjCacheIsolationTest, ContentManagerTextureCacheCycleTest, CnjResolverOrderTest
Path building, case-insensitive lookup, non-ASCII roots, packaged assetsTitleRootedDirectory 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 readerA 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 readerCnjResolverOrderTest, the reader's own Cnj* suite, malformed and wrong-type cases
A new or changed XNB readerThe process-wide reader registry (ContentTypeReaderManager.cpp), the built-ins in XnbBuiltInReaders.cpp, the shared ContentReader.cppContentTypeReaderManagerTest, XnbBuiltInReaderRegistrationTest, the reader's own suite under modules/content/tests/CNA/Internal/Xnb, ContentReaderExternalReferenceTest
A new CNB asset type or schemaA 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 DisposeRecordDisposableObject and the disposal list, Unload(), Dispose(bool), the content-ownership links of the resource types and the SoundEffect specialization in ContentSoundEffectLoaders.cppContentManagerTextureCacheCycleTest, ContentManagerTextureCacheTeardownTest, the ownership suites (ContentOwnershipCompatibilityTest, ContentTextureOwnershipTest, ContentModelChildOwnershipTest, ContentNestedOwnershipTest, ContentPolymorphicOwnershipTest, ContentAudioOwnershipTest), the sound-effect XNB suite
Error mappingContentLoadException wrapping in the loose tier, the CNB wrong-type message, the XNB decodersContentRuntimeContractTest, ContentLoadExceptionContractTest
A manager that does not read the content rootThe protected OpenStream and ReadAsset<T> seam, TryOpenCompiledStream, RouteLoadsThroughOpenStreamEXT and ResourceContentManagerContentStreamRoutingTest, ResourceContentManagerTest

Read first

  1. The Load<T>, LoadXnbAsset<T>, LoadCnbAsset<T> and ResolveAssetPath templates 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.
  2. ContentManager.cpp: the constructors (which only declare the built-in .cnb loaders registered), getGraphicsDeviceInternal, the path and file services and Dispose(bool). Beside it: detail/BuiltinContent.hpp (the per-type registration Load<T> calls), the family files such as ContentTexture2DLoaders.cpp and ContentSoundEffectLoaders.cpp (with the SoundEffect specialization), ContentManagerUntyped.cpp (the untyped XNB path) and ContentAllLoaders.cpp (RegisterBuiltinLoaders).
  3. ContentReader.cpp: root dispatch, the two-pass reader table, shared resources, external references and the renderer-thread lease; then CnbLoaderRegistry.cpp to compare the CNB registry with the XNB one.
  4. 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. Only name.xnb is tried, whereas a name already ending in .cnb also 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 ContentLoadException rather than std::bad_any_cast. The root is not in the key; instead the RootDirectory setter throws System::InvalidOperationException while anything is cached, as measured XNA does (583af2a5e), so only Unload() empties the cache before a root can change.
  • Unload() disposes what the readers recorded. Since 39ef8ea2f (ISSUE-011) every tier records each disposable object an asset owns, a repeated reference included, and Unload() disposes them in read order, as XNA does, then clears the records and the cache; a disposal callback that changes the records ends it with InvalidOperationException. Copies and shared references already handed out share the disposed resources. Dispose() routes to Dispose(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-constructible T. Audio::SoundEffect is 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 .cnb loaders registered (CnbLoaderRegistry::DeclareBuiltIns()), and a Game declares the built-in XNB readers before Initialize(); Load<T>() installs the readers of T on its first use (the loose reader on the instance, the .cnb loader and the .xnb readers 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 internal RegisterAllBuiltInXnbReaders() 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 is std::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 Load calls 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).

ElementWriter sideReader side
XNBWriters 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 manifestsThe 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
CNBAsset type id, schema version and codec identity declared by the writer and checked by the build before every writeCnbDocument::ParseFile validates the whole file and every CRC, CnbLoaderRegistry::ResolveForDocument selects the loader, the manager casts to T
Logical namesCnbLogicalNameProblem is the single rule for external-reference names, applied by the document, the writer and every media codecThe 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 buildsNever read by the runtime
DevicesThe pipeline creates no GraphicsDevice; a texture is decoded into plain dataThe 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 .cnj precedence explicitly, and test the missing file, a malformed file and a wrong requested type. The loose tier wraps a std::exception in ContentLoadException and passes a ContentLoadException through.
  • New XNB reader: add a process-wide canonical factory with AddTypeCreator and a version contract (or declare it with ReflectiveTypeReaderBuilder<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_ptr for Texture3D), and add golden vectors plus a runtime Load<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

FailureWhat the caller sees
Load, ReadAsset or Unload on a disposed managerSystem::ObjectDisposedException from all three
Changing RootDirectory while assets are cachedSystem::InvalidOperationException
An OpenStream override that throws, or returns no streamThat exception, or a ContentLoadException; the lower tiers are not tried
No reader registered for T after both compiled tiers missContentLoadException "No reader registered for type"
A .cnb holding a different asset type than TContentLoadException naming what the file holds
Wrong T for an .xnbContentLoadException 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 readerContentLoadException naming the reader, not a null dereference
No device for a GPU-requiring loadContentLoadException "no GraphicsDevice is available"
Oversized or truncated inputContentLoadException 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

  1. 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).
  2. The content group: cmake --build --preset unit-content and the filter for the suites above, then the aggregate CnaTests and its discovered entries.
  3. 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 the cna-content tool) and CnaXnbDependencyBoundary (proves nothing a game links reaches a build-time dependency); all carry the labels content;xnb.
  4. A pipeline-to-runtime round trip for any format change, and the XNB and CNB golden vectors.
  5. 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 touchedAlso check
Key normalisation or path buildingThe 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 orderEvery 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 schemaThe pipeline writer and importer that share it, the CNB format documentation (CNB Format), and the bindings' expectations for the format.
Ownership or cache policyTexture and effect lifetime in the graphics tests, SoundEffect independence, and games that call Load from Draw and rely on the cache.
Registered readersMultiple 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?

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.