I need to modify ContentManager

CNA snapshot 009d40f5  ·  Development › Maintainer Handbook  ·  source links pinned to 009d40f5

✓

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 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 assetsBuildAssetPath, 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) registered in RegisterBuiltinLoaders or through RegisterTypeReader<T>; 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 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 DisposeUnload(), Dispose(bool) and the SoundEffect specialization at the end of the .cppContentManagerTextureCacheCycleTest, ContentManagerTextureCacheTeardownTest, 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 and ResourceContentManagerResourceContentManagerTest

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, getGraphicsDeviceInternal, the path and file services, Dispose(bool), the untyped XNB path, RegisterBuiltinLoaders and the SoundEffect specialization.
  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), 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. Adding the resolved path or root to the key would make a changed root invalidate a hit, which today only Unload() does.
  • Unload() is exactly loadedAssets_.clear(). It cannot revoke copies or shared references already handed out; Dispose() routes to Dispose(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-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 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-wide CnbLoaderRegistry); the XNB reader registry is process-wide and unlocked, with first registration winning. A Game's manager gets a direct device pointer and the built-in XNB readers before Initialize(); 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 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 and the XNB registry are unlocked, so concurrent Load calls 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 on a disposed managerstd::runtime_error from Load<T>; System::ObjectDisposedException from ReadAsset<T>
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 .xnbThe 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 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; 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

  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, 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?

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