Content pipeline internals

CNA snapshot 009d40f5  ·  Development › Content internals  ·  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 source and test registrations at the TARGET snapshot; no build or test was executed. The 'no GraphicsDevice' statement is a text search, and the .contentproj path was read in BuildContent and content.cpp rather than run.

The content pipeline is a typed build graph, not a runtime loader. A build selects an importer from the source path, resolves a processor from the importer's declared output type and resolves a writer from the processed type and the requested container; the cna-content command line surrounds that three-stage conversion with source discovery, configuration, dependency fingerprints, output ownership and atomic publication. This page follows one asset through that machinery at the TARGET snapshot, for the maintainer who adds a source type, changes a writer or touches the build cache. What the tool offers a user is on the Content Pipeline guide; the runtime that later loads the XNB or CNB it writes is on Content runtime internals.

ℹ

The user guide already documents the route table, the command-line options, .cna-content.json, the manifest's user-visible behaviour, cna_add_content() and the XNA facade's representation counts (routes, command line, incremental builds, CMake, facade). This page does not repeat them. It says where in the source each of those things happens, in which order, and which checks a change must not bypass. The container formats themselves are on CNB Format and XNB Loading and Interoperability.

Physical boundaries and their reason

The name content-pipeline does not own every pipeline implementation. Four CMake targets share the work, and the split is a dependency boundary, not tidiness:

TargetSourcesWhat it ownsLinked by
cna_contentmodules/content/src/Pipeline plus src/Xnb, src/CnbThe canonical engine: ContentPipeline, ContentPipelineRegistry, the build manifest and the source-format routes for image, sound, song, video, model, .cnj, compiled effect and .xnb sources; the CNB and XNB writersEvery game that loads content (it is the runtime module)
cna_content_pipelinemodules/content-pipeline/srcBuild-time only: the XNA-shaped importers, processors, tasks and ContentCompiler facade, the FreeType .spritefont route, the block-compression encoder, the .x and .fbx readers, the build-time media decoder, and the effect-compiler and XMA-encoder servicesOnly cna_content_compiler
cna_content_compilertools/content/content.cppThe whole command-line coordinator as a static library (alias CNA::ContentCompiler), so the stock tool and user-built compilers share one implementationThe tool, custom compilers, the tests
cna_content_tooltools/content/content_main.cppThe cna-content executable: 42 lines that build a registry factory and call RunContentCompilerNothing

The reason for the split is stated in the content-pipeline CMakeLists.txt. cna_content_pipeline links cna_content PUBLIC, but the reverse edge does not exist, and FreeType (CNA_ENABLE_FONT_PIPELINE, a three-state OFF/AUTO/ON switch), the build-time FFmpeg libraries (CNA_ENABLE_MEDIA_PIPELINE, driven by the same probe as CNA_FFMPEG_AVAILABLE and independent of the runtime modules/video-ffmpeg backend), zlib (for compressed arrays in binary FBX) and the configure-time CNA_FXC_EXECUTABLE / CNA_FXC_LAUNCHER defaults are all PRIVATE to it. ToolContentPipeline.cmake then links the new library into cna_content_compiler, and no runtime target links it (the unit-test executables in UnitTests.cmake and the two XNA fuzz harnesses in Harnesses.cmake link it directly, which is fine because they are build-time tools). That is what “keeps FreeType out of a runtime game's closure” means concretely: the dependency graph enforces it, not a convention. The optional-dependency table for users is on the guide.

Two consequences are easy to miss. First, ContentCompiler.hpp, which declares RegisterBuiltInContentPipeline and RunContentCompiler, lives in the runtime module's include tree, but both functions are defined in tools/content/content.cpp, so a program that links only cna_content can include the header and not link the calls. The header is careful to duplicate the options struct rather than include a build-time header. Second, a user-defined compiler is a separately linked executable built on cna_content_compiler, not a dynamically loaded plugin: components are registered explicitly, this page found no path that loads one at run time, and ContentPipelineExtensionApiIsExperimental is true in ContentPipeline.hpp. The repository builds both a canonical example (modules/content/examples/custom-content-compiler.cpp, target cna_custom_content_compiler_example) and an XNA-shaped one (modules/content-pipeline/examples/xna-custom-pipeline.cpp, target cna_xna_custom_pipeline_example), both only when CNA_BUILD_EXAMPLES or CNA_BUILD_TESTS is on. When changing the boundary, inspect the target graph before moving a dependency: a source importer may legitimately need FFmpeg, but loading an already-compiled XNB must not.

The build step creates no runtime device

Nothing in the pipeline sources (or their headers under include/CNA/Content/Pipeline), in the build-time module (sources, headers and tests) or in tools/content names GraphicsDevice; a text search of those trees at TARGET finds no occurrence. A texture is decoded into CnbTextureData, a model into plain model data, and the first native GPU allocation happens in the runtime loader, not in ImageImporter. That is a property of the pipeline sources, not of the link closure: cna_content as a whole still links cna_graphics_core because the runtime half needs it, so the tool binary contains device code it never reaches. The Windows CI lane described on the guide relies on exactly this (headless platform and renderer, no GPU).

One asset through the canonical coordinator

cna-content main / a custom compiler's main
  -> RunContentCompiler(arguments, registry factory)          tools/content/content.cpp
       -> Run: ParseCommandLine                               usage error -> exit 2
       -> createRegistry(ContentCompilerOptions)              stock front end: new registry
            + RegisterBuiltInContentPipeline                  every source route
       -> RegisterXnbOutputContentPipeline(options)           always, whatever --format says
       -> registry.Freeze()
       -> DiscoverBuilds -> LoadConfiguration -> ApplyConfiguration
          -> sort by logical name
       -> AcquireOutputLease -> LoadManifest
       -> PrepareBuildNode per item          (std::async batches of --workers)
            -> ContentPipeline::Build(request)
                 -> RunImportAndProcess
                      -> ResolveImporter(source, explicit importer)
                      -> importer.Import(ContentImporterContext) -> ContentValue(stable type)
                      -> ResolveProcessor(type, explicit or importer default)
                      -> ValidateParameters -> processor.Process -> ContentValue(stable type)
                 -> ResolveWriter(processed type, explicit writer, CNB or XNB)
                 -> writer.Write -> bytes + schema + additional outputs + warnings
            -> manifest entry, direct fingerprint, staged bytes
       -> cycle check, dependency ordering
       -> ExecuteBuildNode waves (at most --workers ready nodes each)
            -> skip current, publish staged bytes, or rebuild
       -> collect obsolete owned outputs -> publish manifest (only if nothing failed)
  -> at run time: ContentManager::Load<T>

The registry: stable names, frozen before use

ContentPipeline takes a shared_ptr<const ContentPipelineRegistry> and calls Freeze() on it in its constructor; RunContentCompiler freezes it too, after adding the XNB writers. Registration takes a std::unique_lock on the registry's mutex and throws std::logic_error once frozen (ContentPipeline.cpp); every lookup takes a std::shared_lock and returns shared component pointers. The registry validates what a component declares: a non-empty name and version, at least one importer output type and source extension (lowercase, with the leading dot), non-empty processor input and output types, and a writer input type. A duplicate importer, processor or writer name is a std::logic_error. The writer's own asset/schema/codec declaration is validated later, by Build before every write: it must be non-empty, strictly ordered by asset type id, canonical type name and schema version, free of duplicates, and complete (non-zero identifiers, a type name, a valid codec identity).

Selection is by stable names and stable type strings, never by registration order and never by std::type_index, which is kept only as an in-process check before a component casts an erased value and is never persisted. Two rules keep the defaults honest. An importer or processor whose SelectedByNameOnly() is true does not compete for the default route of an extension or type (that is why .wav is a SoundEffect unless a project names the Song importer, and why the XNA-named TextureProcessor does not claim every .png); only a single surviving candidate is chosen automatically, and anything else fails with a message that names every candidate. Writers are keyed by the pair (output format, processed type), so one processed type can have a CNB writer and an XNB writer without shadowing. DocumentAbsentWriter lets a registration record why a container has no writer for a type (the reason is appended to the resolution failure), which is how “compiled Effect has no CNB schema” is reported as a decision and not as an omission (EffectContentPipeline.cpp records that one, and ProcessedTypeCoverageTests.cpp in the build-time module's tests fails when a processed type has neither a writer nor a recorded reason for a container).

The lock protects registry configuration, not arbitrary state inside a component. The writer contract states that one registered instance may serve concurrent build nodes after the freeze, and the CLI runs independent nodes with std::async when --workers is above 1, so a new importer, processor or writer must be immutable or reentrant, or guard its own state. Do not add a global scratch buffer without checking that path. ContentPipelineCoreTests.cpp carries the registry contracts by name (duplicate stable names, freezing, FrozenRegistrySupportsConcurrentBuildCalls, ambiguity needing explicit names).

Import and process

ContentPipeline::RunImportAndProcess canonicalizes the source root (weakly, so a not-yet-existing path is tolerated), resolves the external source-root capabilities, requires the primary source to lie inside the root and be a regular file, validates the logical name with CnbLogicalNameProblem, and only then resolves the importer and records the primary-source dependency (a nested build records its source as an ordinary SourceFile dependency instead, never as a second primary source). A named external source root is a separate explicit capability, aliased by 1 to 64 lowercase letters, digits or hyphens starting with a letter (at most 32 roots, no overlap), not permission for arbitrary traversal.

  • The importer runs against a call-scoped ContentImporterContext. The ContentValue it returns must be non-empty and its StableType() must be one of the importer's declared output types, otherwise the build fails with “importer returned undeclared output type”.
  • The processor is resolved from that stable type. An explicit processor overrides the importer's DefaultProcessor() (XNA's ContentImporterAttribute.DefaultProcessor).
  • ValidateParameters runs before Process, and the processor must return its declared OutputType(). Under ContentStrictness::XnaCompatible only the two mistakes a processor's defaults survive are downgraded to warnings: an unknown parameter name and a value of the wrong type, both thrown as ContentParameterError (the font route additionally tolerates a character with no glyph). The loop is bounded by the parameter count as it was, one removal per pass, so two wrong parameters do not exhaust it. Impossible operations, such as a compression format with no encoder, still fail in both modes.
  • Every stage is wrapped: RethrowWithContext converts a failure into a ContentPipelineError carrying the source, logical asset, ContentPipelineStage (Selection, Import, Process, Write, Graph or Publish) and component name, with the original exception nested. That context is the first diagnostic to preserve when debugging a build failure.

Writer selection and the contract with the manifest

ContentPipeline::Build then chooses a writer by the tuple (output format, processed stable type), not by file name, and enforces the contract that lets independently extensible components share one manifest. It asks the writer for its OutputSchemaIdentities() before writing, so the build cache records the schema and codec even if a component version was left unchanged, then checks the written result: non-empty bytes, a non-zero asset type identifier and schema version, and that the identity actually written is one the writer declared (RequireDeclaredWriterOutput). Each additional output must have a valid, unique logical name, non-empty bytes and a declared identity, and the node may not exceed MaxContentBuildOutputs (256) outputs. Writer warnings are forwarded to the build log as warnings against the writer's own component name, which is how the glTF-to-XNB losses become the indented warning (...) lines the guide describes.

Nested builds requested by a processor carry their dependencies, runtime references and output schemas into the parent. Identical repeated nested outputs are shared (a model whose meshes share a material shares that material's texture, and the source comment records a twelve-mesh tank naming one .tga nine times); two outputs under one logical name with different bytes, type, schema or root reader are refused as a collision. These checks are the contract between extensible components and manifest publication, and bypassing them to make one asset build can corrupt the graph for other assets.

Concrete source tour: image to Texture2D

RegisterTexture2DContentPipeline in Texture2DContentPipeline.cpp registers three components: ImageImporter (version 1), TextureProcessor (version 3) and Texture2DContentWriter (version 1). The encoder it is handed is what makes DXT possible: the built-in registration passes MakeBlockCompressionTextureEncoder() from the build-time module, so a registry built without cna_content_pipeline refuses a compressed textureFormat at ValidateParameters with a message saying which build provides an encoder.

The importer

ImageImporter advertises fourteen extensions (.png .jpg .jpeg .bmp .tga .gif .psd .hdr .pic .pnm .dds .dib .pfm .ppm); the last four are the ones XNA's TextureImporter accepts and a plain stb decode does not. It reads the source as bytes and declares three output types, because a DDS is three formats wearing one extension: a cube map imports as ImportedTextureCubeType and a volume as ImportedTexture3DType, so the source extension alone does not imply Texture2D, and the graph then resolves whichever processor takes what came out. An ordinary image returns ImportedImageType with decoded RGBA8 pixels, plus any extra mip levels or block-compressed levels a DDS carried. One TARGET detail worth knowing: ApplyPngFileGammaEXT reproduces GDI+ behaviour for a PNG that has an alpha channel and declares a non-standard gAMA chunk (an sRGB or iCCP chunk suppresses it), because XNA's importer loaded images through GDI+; a file gamma that already matches the 2.2 display gamma (an exponent within 1e-5 of one) is left alone. The test APngsOwnGammaChunkIsAppliedTheWayGdiPlusAppliesIt pins it.

The processor

TextureProcessor consumes ImportedImageType and applies, in this fixed order, color key, resize to a power of two, premultiplication, mip generation and optional block compression; the comment in Process calls that order “the whole texture policy”. The order matters: generating mips before premultiplying leaks the color of transparent texels into visible pixels in distant mips (PremultiplicationRunsBeforeMipGeneration). Details that change what a build produces:

  • Color key. A key the build asked for is XNA's: all four channels take part in the match and the color is cleared with the alpha. A key authored in a .cnj is CNA's own and keeps the color. The two differ visibly only when premultiplication is off, and changing the authored rule would change what every committed .cnj means.
  • Premultiplication defaults to true (processor version 3; the version moved so that incremental builds rebuild alpha-bearing textures), because SpriteBatch::Begin()'s default blend state is the premultiplied one. An explicit parameter wins over a source's authored preference.
  • passThrough disables the color key, the resize and the premultiply, and a block-compressed .dds then keeps its own blocks verbatim instead of being re-encoded.
  • Profile limits apply only to .xnb output. Reach limits a Texture2D to 2048 texels a side and requires power-of-two sizes for a mipmapped texture; HiDef allows 4096 and an aspect ratio up to 2048. A .cnb records no target profile and is not limited.
  • Compression. DxtCompressed resolves to DXT5 when any texel has partial alpha and to DXT1 otherwise. For .cnb output the request is answered with a warning and the uncompressed Rgba8 pixels, because CNB texture schema 1 stores Rgba8 only; for .xnb output the level-0 dimensions must be multiples of four (XNA's own message is reproduced) before the encoder runs.

The processed value is ProcessedTexture2DType holding Cnb::CnbTextureData (mip data, not a GPU texture), which is why importer and processor are shared by both containers and only the writer differs.

The two writers

For CNB, Texture2DContentWriter::Write calls Cnb::EncodeTexture2DToCnb and declares the Texture2D asset type, CnbTextureSchemaVersion and the codec CNA.Cnb.EncodeTexture2DToCnb version 1. For XNB, RegisterXnbOutputContentPipeline in XnbOutputContentPipeline.cpp registers CNA.XnbTexture2DWriter against the same processed type but the XNB format key; it converts the CNB texture to XNB data with ConvertCnbTextureToXnb, which takes the first representation the selected container version can express and refuses a texture with none rather than re-encode it silently. Every XNB writer's identity version is the codec version plus XnbOutputOptionsDigest (platform, container version, profile, compression and reader-name spelling), so a build for another target platform invalidates the previous artifacts instead of reusing them, and its schema declares an XnbOutputAssetId (append-only, persisted in manifests) with the container version byte as the schema version. At run time ContentManager::Load<Texture2D> prefers the XNB, then the CNB, then loose sources (the ladder); the tests that connect the two halves are BuildsHeadlesslyThroughDistinctImporterProcessorAndWriter, IsByteIdenticalToTheUnchangedSourceProducer and ResultLoadsThroughTheExistingContentManagerRuntimePath in Texture2DContentPipelineTests.cpp.

XNA project and extension facade

modules/content-pipeline/src/Xna implements the XNA-named importer and processor interfaces, the Tasks classes and the bridge code, while modules/content/src/Xnb owns the canonical binary XNB reader and writer machinery. The facade is a view over the canonical engine, not a second engine, and it never serializes a second incompatible XNB dialect.

ContentCompiler

ContentCompiler.cpp registers the canonical built-in XNB writers first: its constructor wraps the primitives, math types, Curve, the closed list and ordered-dictionary types and the processed model graph as facade type writers. AddWriter then refuses a second writer for a type that is already registered and refuses any addition after the compiler has compiled (the first per-platform registry lookup freezes it). Each target platform gets its own registry, built lazily on first use and cached: the built-in writers are registered, then user writers only where none exists. CompileObject writes through the same Xnb::XnbWriter and Finish() that the canonical writers use, maps the XNA platform and profile onto the container options, and lets the root type writer decline LZX compression for content that does not compress well (ShouldCompressContent). RegisterXnaXnbOutput in XnaPipelineBridge.cpp registers one CNA.XnaObjectXnbWriter[<type>] per compiler-known type that has no XNB writer yet, under XnbOutputAssetId::XnaObject.

The bridge

The bridge maps XNA component names and processor parameters onto canonical routes. ToOpaqueData and ToProcessorParameters convert between the typed canonical parameters (bool, i64, u64, f64, string) and the boxed dictionary XNA processors read; a value the canonical variant cannot carry is refused. BuildAndLoadAsset is ContentPipeline::ImportAndProcess in-process: the nested source becomes a SourceFile dependency of the outer node and the nested outputs are adopted by it. BuildAsset is stricter, and its rules are worth reading before changing collision behaviour:

  • When the request names no asset of its own and another item of the same build already asks for that source in exactly this way (same importer, processor and parameters), the asset is that item. The reference becomes a runtime reference by name and nothing is built, so the node neither waits for the item nor rebuilds when it changes; the source comment records XNA's own Windows Phone build writing a Car.xnb that names an effect with no ReplaceColor.xnb beside it.
  • A nested build with no asset name gets a generated <derived>_<index> name, where the index counts per derived name (a model naming two different textures produces _0 twice); the same source built the same way twice keeps one name. Only _0 is measured against XNA, so a second build of the same source under a different processing is refused instead of given _1.
  • The authored spelling of a nested source name becomes the asset name, while the file itself is located case-insensitively, because the public XNA sample sources were written on Windows (the source comment cites a .x naming ..\textures\asteroid1.tga beside a directory called Textures).
  • A nested name equal to the current asset's own name, or reused for a different source or processing, throws. The outer node copies the nested dependencies (a nested primary source becomes a SourceFile), runtime references and deployment files, records the nested output and adds the nested writer schemas so its manifest entry can describe what it publishes.

A custom XNA route must register its importer, processor and writer before the registry freezes, and should be exercised through XnaCustomPipelineAcceptanceTests.cpp and the custom-pipeline example rather than only an isolated class test.

Content projects and the CMake entry point

A .contentproj is not a second format with a second engine behind it. Run recognises it, and Tasks::BuildContent (in BuildContent.cpp) writes a strict configuration naming exactly the project's Compile items and runs the same coordinator with build <root> -o <output> --format xnb --config <file> --xna-compatible --only-configured-assets, adding --xnb-platform, --xnb-profile, --build-configuration and --xnb-compress lzx from the project or from a command-line override (the way msbuild /p: would override the file). A project item that no registered component can route is reported up front and the remaining items are still built, but the run then exits with status 1 and skips the copying of Content and None items, which otherwise happens after a successful build.

cna_add_content in ToolContentPipeline.cmake accepts either SOURCE_DIR or CONTENT_PROJECT and forwards the FORMAT, platform, profile and compression selections to the CLI without re-validating them, because the CLI is the one authority on valid names. It creates a custom target (not part of ALL) that invokes the tool whenever it is requested; the CLI manifest decides whether anything needs rebuilding. Cross-compilation requires a host CONTENT_EXECUTABLE, since the target-platform binary cannot run on the build host. The option table is on the guide.

⚠

A .contentproj and the tool's format option are not independent inputs at TARGET. cna_add_content refuses FORMAT, XNB_PLATFORM, XNB_PROFILE, XNB_COMPRESS, CONFIG_FILE and XNA_COMPATIBLE together with CONTENT_PROJECT, and a project build always writes XNB. To produce CNB from the same sources, build a source directory with SOURCE_DIR.

Incrementality, graph ordering and output ownership

Run in tools/content/content.cpp does its work in a fixed order (see the diagram above): it builds the registry from the parsed options (so an --fx-compiler choice can enter registration, because the backend's identity enters the fingerprint), registers the XNB writers bound to this invocation's container options even when --format cnb was requested, since a project may still select XNB for individual assets, freezes the registry, discovers sources, applies configuration, sorts the assets by logical name and takes the output-root lease. Two invocations in one process with different compiler paths are independent because nothing is cached between calls. The overload of RunContentCompiler that takes an already-built registry refuses any build-tool option that would have to reach registration instead of ignoring it: the comparison against a default ContentCompilerOptions covers the effect compiler and launcher, the XMA encoder, launcher and arguments, the font directories and the build configuration.

What the manifest records

The manifest is .cna-content-manifest.json at format version 9 (ContentBuildManifestVersion in ContentBuildManifest.hpp; the writer and parser are in ContentBuildManifest.cpp). Each entry holds the importer, processor and writer identities with versions, the writer asset/schema/codec declarations, the typed processor parameters, the categorized dependencies (primary source, source file, content-build, generated), runtime references kept apart from build inputs, the outputs the node owns (logical name, path, asset type and schema or, for XNB, the root reader name, and a SHA-256), deployment files with their digests, and a directFingerprint and an effective fingerprint. The persisted fingerprintState decomposes the aggregate into nine domains (primary source bytes, source dependency set and bytes, content dependency set and fingerprints, processor parameters, writer schemas, output definitions and deployment definitions), which is what lets --explain say why a node rebuilt. The direct fingerprint covers direct inputs and the identities of dependency edges; the effective one also folds in the effective fingerprints of the content-build dependencies, which are only known once those nodes have run. A manifest that fails to parse is classed Corrupt, one with another version Incompatible, and only a Current manifest is trusted to authorize deleting old outputs.

Preparation, ordering and execution

PrepareBuildNode either reuses the previous manifest entry, when IsPreviousGraphCurrent finds the node id, source path and output path unchanged, the route still current (importer, processor and writer identities and versions, the typed parameters, and the writer's declared schemas still covering the recorded ones) and the direct fingerprint unchanged, or runs ContentPipeline::Build, builds the new manifest entry, refreshes its direct fingerprint and stages the bytes: each output is written atomically into a per-invocation staging directory (CnaContentStaging.hpp, under the system temporary directory, with a scavenger for abandoned directories) and deployment files are copied there and re-hashed. With --workers above 1 these preparations run in std::async batches of that size.

The coordinator then orders the nodes. Every content-build dependency must name a discovered primary node, or the dependent fails; a cycle is found by an explicit-stack depth-first walk and reported once with the whole chain; a node whose dependency failed fails with that dependency named, and nothing it would have published is written. Ready nodes run in waves of at most --workers, each wave joining all its futures before the next starts. ExecuteBuildNode refreshes the effective fingerprint from the dependencies that already ran, then either publishes the staged bytes of a node that was rebuilt in the preparation step (verifying each output's size and SHA-256 first), skips a reused node whose effective fingerprint and published output digests still match (AssessEffectiveBuildState), or rebuilds it when graph state requires it. A component may not silently change the frozen build topology: if a rebuild produces a different direct fingerprint or different content-build dependencies than were planned, the node fails with “a component changed the frozen build topology without a changed direct fingerprint”. Nested outputs that another planned node owns are dropped from the node's manifest entry, because a project usually lists a model's textures as items of its own.

Publication, cleanup and ownership

Publication writes each output with the atomic-file helpers (CnaToolAtomicWrite.hpp); the manifest itself is written only when every item succeeded and only if its serialized text differs from what was read. Cleanup of obsolete outputs runs only for a trusted current manifest, only when nothing failed, and refuses (as an error, not a skip) any path that is a symlink or not a regular file, whose parent directory component is a symlink, that escapes the output root, or whose bytes no longer match the SHA-256 the previous manifest recorded. So a corrupt or old manifest is a reason to rebuild, not a license to delete files found in the output tree. cna-content clean applies the same collection to the whole manifest, refuses a symlinked output directory, and removes the manifest last, and only if it did not change while clean ran.

Changing asset naming, schema version, dependencies or writer bytes must be treated as an incremental-build change as well as a codec change: bump the component or schema identity, and check that old outputs rebuild instead of being mistaken for current ones. A superficial “the output works on a clean build” test misses stale-cache and unsafe-cleanup regressions. The per-output atomic writes do not give a single transactional snapshot of every asset while another process reads the directory, and the output-root lease (.cna-content.lock) serializes only CNA's own build and clean operations for that root, not external readers, which matters to deployment tooling. The user-visible side is in the guide's incremental-build section, and CNB determinism covers the container half of reproducible bytes.

What the fingerprint hashes, and the one timestamp check

Every fingerprint domain in ContentBuildManifest.cpp is a SHA-256 (ContentSha256, ContentFileSha256) over canonical bytes: source and dependency file contents, component and codec identities with their versions, the typed parameters, output definitions and deployment definitions. File modification times never enter it, so touching a file without changing its bytes does not rebuild it, and a restored older file with identical bytes is still current. A skip is also conditional on the published outputs: a missing or edited output is reported as compiled output missing or compiled output digest mismatch and rebuilt (ContentPipelineCliTest.MissingOrTamperedOutputForcesARebuild).

The only timestamp comparison in content.cpp belongs to .contentproj builds: a Content or None item copied with CopyToOutputDirectory set to PreserveNewest is skipped when the target's modification time is not older than the source's, as MSBuild does. Those copies sit outside the fingerprinted graph and are not recorded as owned outputs. Read at this snapshot; not executed.

Failure and extension checklist

  • New source type. Declare lowercase extensions and stable output types; add a processor for that type (or require an explicit one); register CNB and XNB writers as appropriate, or document the absence with DocumentAbsentWriter; and test the full CLI-to-runtime round trip.
  • Sibling files. If the importer references sibling files, go through the context's dependency APIs (ResolveSourceDependency, and on the processor side AddContentBuildDependency, AddGeneratedDependency, AddRuntimeReference, AddDeploymentFile) so the manifest notices changes and containment rules (no absolute paths, no traversal, no symlink escape, external roots only by alias) stay intact.
  • Child assets. If a processor emits child assets, give them stable logical names, schemas and dependency records.
  • New writer or schema. Bump its component and schema identity when the encoding changes and verify that old outputs rebuild; CustomWriterSchemaAndCodecEvolutionCannotSkipStaleOutput is the fixture for that.
  • Cases to test. Source traversal, malformed input, duplicate routes, nested collisions, missing optional dependencies, and worker-count determinism.
  • Dependencies. Do not register build-time codec dependencies in a runtime target to make a command-line case pass.

Focused evidence, with the file each name lives in (test presence only; nothing here was executed):

Test fileWhat it pins
ContentPipelineCoreTests.cppRoute, type and parameter contracts: duplicate names, freeze, concurrent builds, ambiguity needing explicit names, undeclared output types, deployment-file containment, external-root and symlink escapes, output-count limits
Texture2DContentPipelineTests.cppHeadless import, process and write; golden bytes against the unchanged producer; runtime load; premultiplication, color key, mip and resize behaviour; DDS cube and volume import; PNG gamma
ContentPipelineCliTests.cppAtomic publication, staging scavenging, graph ordering and cycle reporting, fingerprint and --explain classification, worker-count determinism, failed rebuilds keeping old output, manifest ownership, obsolete-output collection, clean, and the output lease
ContentPipelineCMakeIntegrationTests.cppThe cna_add_content entry point
XnaCustomPipelineAcceptanceTests.cppExternal XNA-style routes and their manifest dependencies
XnbOutputContentPipelineTests.cppThe XNB output writers; run it, and the runtime ContentManager tests, for any texture or format change

The unit-content build preset builds CnaContentTests and is a focused starting point, but the XNA build-time suites live in the separate CnaContentPipelineTests target (modules/content-pipeline/tests); inspect the configured CTest labels and test discovery before claiming that a particular configuration includes them. The general test layout is on Test architecture.

Curated source reading order

  1. ContentPipeline.hpp: learn the stable-type, request, component, registry and result contracts before reading a concrete codec.
  2. ContentPipeline.cpp: follow registration and freeze, route resolution, stage validation and contextual exceptions.
  3. Texture2DContentPipeline.cpp and XnbOutputContentPipeline.cpp: compare shared processing with container-specific writing.
  4. tools/content/content.cpp and ContentBuildManifest.cpp: read discovery, graph, fingerprint, stage, publish and cleanup together; this is where build correctness extends beyond one codec.
  5. XnaPipelineBridge.cpp and ContentCompiler.cpp: see how the compatibility facade enters the canonical graph and the XNB writer.
  6. ToolContentPipeline.cmake and the content-pipeline CMakeLists.txt: verify the CLI targets and the optional build-time dependency boundaries before changing linkage.

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