Core module internals

CNA snapshot 009d40f5  ·  Development › Module 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. Checked by reading the TARGET sources, tests, CMake files and the multi-renderer workflow definition; no build, test or workflow was executed. The static-initialisation behaviour of an invalid CNA_GRAPHICS_RENDERER value is derived from source and no test sets one. Native-host behaviour of the Unicode path helpers on a Windows machine with a non-UTF-8 ANSI code page and race-resistant filesystem confinement remain unverified.

modules/core/ is a small physical module with a wide blast radius. It runs no Game loop and owns no GPU device; it holds the identity and policy code that runtime, graphics, content, storage, media, the C API and tooling all consult: renderer names and the process-wide renderer-selection state, compile-target classification, logging, the generated release identity, the CNAException base and the UTF-8 path conversion and containment helpers that every filesystem-touching module must use. This page is for a maintainer changing any of those. The user-facing contracts are in Runtime renderer selection and Storage: path containment; everything below was checked by reading the TARGET snapshot, and no build or test was executed for it.

Target boundary and public surface

modules/core/CMakeLists.txt defines two targets. cna_core (alias CNA::Core) is created through cna_add_module in modules/CMakeLists.txt, which makes every CNA module a STATIC library carrying cna_build_config and its own include/ root; with CNA_SHARED_LIBRARY on, these archives are folded into the one shared runtime library instead of becoming separate shared objects. Its sources are a CONFIGURE_DEPENDS glob of src/*.cpp (eight files at TARGET), and it links Sharp Runtime's Core.Base component PUBLIC through cna_link_sharp_runtime. The second target, cna_core_headers (alias CNA::CoreHeaders), is an INTERFACE library with the same include root. It exists for modules whose public headers name core declarations (the CNAEXT marker, PlayerIndex, header-only internals such as PathContainment.hpp) without calling any core symbol, so that their link closures stay exact.

LinksModules at TARGETConsequence for a change
cna_core_headers onlymath, storage, platform, phone, inspectorAdding a call to a non-inline core function (for example Logger::Warn) from one of these turns a header dependency into a link dependency; the module's CMakeLists.txt must then link cna_core, or its focused test executable stops linking.
cna_core archivegraphics core, input, audio, content, runtime, devices, devices-ext, every renderer family (PRIVATE), the C APIAny behavioural change in the archive reaches all of these; a green CnaCoreTests is not enough.

The generated release identity rides both targets. cmake/Version.cmake renders cmake/templates/Version.hpp.in into ${CNA_BINARY_DIR}/generated/include/CNA/Version.hpp (the project's own binary directory, not CMAKE_BINARY_DIR, so a consumer that adds CNA with add_subdirectory does not receive it in its build root), and both core targets add that directory as an include root, so a consumer writes #include "CNA/Version.hpp" without knowing which root resolves it. The only source of truth is project(CNA VERSION …) plus CNA_VERSION_PRERELEASE in the root CMakeLists.txt; the header exposes CNA_VERSION_MAJOR/MINOR/PATCH/PRERELEASE/STRING for the preprocessor and constexpr accessors for everything else, and states that this product version is independent of the C ABI's CNA_ABI_VERSION and of the XNA API level. The TARGET snapshot still declares product version 0.1.0 with the pre-release identifier of the first alpha tag, so the version string does not distinguish this development snapshot from that tag; identify a snapshot by its commit.

Core links no SDL. Its Logger owns its own sink and Entrypoint.hpp now lives in the platform module (modules/platform/include/CNA/Platform/Entrypoint.hpp), so nothing under modules/core includes an SDL header. The comment on probe_core in cmake/Tests/ModuleProbes.cmake still calls SDL3 an accepted private detail of Logger.cpp; that comment predates the move and no longer describes the module, and the probe's forbidden-library pattern does not name SDL.

⚠

Stale Emscripten link option. On Emscripten, modules/core/CMakeLists.txt still adds target_link_options(cna_core INTERFACE "-lembind"), with a comment saying the browser preference reader uses emscripten::val. The reader at TARGET, modules/core/src/GraphicsRendererSelectionEmscripten.cpp, is written with EM_JS, and its own comment records that embind was dropped deliberately so bundles need not carry embind's runtime. The option is therefore a leftover whose stated reason no longer holds. Removing it needs a real Emscripten bundle link, because the failure it once papered over was a link-time error that a compile of cna_core cannot show; not executed here.

cmake/BuildPerformance.cmake also treats core as a pilot for the native-only unity build (CNA_ENABLE_UNITY_BUILD, default OFF, used by the unit-unity configure preset and the unit-core-math-unity build preset): all eight core sources compile as one unity group, cna_core_unity_0. A helper added to an anonymous namespace in one core source must not reuse a name another core source already uses there.

HeaderWhat it holdsMain consumers
GraphicsRendererType.hppThe 25-member identity enum, the only name table, case-insensitive parsing, the compile-time defaultgraphics registry, C API, tools, selection
GraphicsRendererSelection.hpp, GraphicsRendererFallbackRecord.hppProcess-wide selection policy and the graphics-side handshake; the four fallback reasonsGraphicsDevice::resolveRenderer, games, the browser shell
GraphicsBackendMaturity.hpp, GraphicsBackendCategory.hppconstexpr maturity and technology class for every identityautomatic fallback ranking, launchers
TargetPlatform.hpp, DesktopOS.hppCompile-target classification and the Apple target macrosdevices, platform, runtime
Logger.hpp, LogLevel.hpp, LogCategory.hppThe process loggeralmost every module
CNAException.hpp, Internal/ExceptionSerialization.hppException base and the shared message/inner-exception serialization keysthe C API barrier (for CNAException itself); the serialization keys are used by the storage, content, gamer-services and net exception types
AssemblyInfo.hpp, Internal/DefaultWindowTitle.hppDeclared product title and the default window titleGraphicsDevice window creation
Internal/PathUtf8.hpp, ContentPath.hpp, PathContainment.hpp, CaseInsensitivePath.hppThe filesystem path modelcontent, content pipeline, storage, media, audio, runtime, platform
Internal/PackedRounding.hppClamp-and-round with ties to even, NaN to 0, UNORM/SNORM expansionmath Color, graphics packed vectors
CNAHelper.hpp, Microsoft/Xna/Framework/PlayerIndex.hppThe CNAEXT marker; the XNA PlayerIndex enum (One…Four = 0…3)every public header; input, storage, gamer services

Renderer identity: names, ordinals and the compile-time default

GraphicsRendererType is dense: 25 enumerators from SdlRenderer (0) to PortableGL (24). Its own comment says the ordinals are not a stable numeric contract, because removing an identity renumbers everything after it. The stable numbers are the C ABI's CNA_GRAPHICS_RENDERER_* values in modules/c-api/include/CNA/C/graphics.h: UNKNOWN is 0, the identities occupy values from 1 up to CNA_GRAPHICS_RENDERER_MAXIMUM (46, PORTABLEGL), and a retired identity leaves a permanent gap. SdlRenderer is therefore ordinal 0 in C++ but 1 in C. Never persist, log as an identity or pass across the ABI the C++ ordinal.

getGraphicsRendererName is the single place the spellings exist ("SDL_RENDERER", "OPENGLES3", …; "UNKNOWN" for a value outside the enum). tryParseGraphicsRendererName folds ASCII case by hand and walks ordinals 0 … PortableGL; the loop bound names the last enumerator, so an identity appended after PortableGL is unparseable until that bound moves (the selection tests' Absent() helper uses the same bound). A new identity also has to satisfy scripts/check_renderer_identities.py, which compares the enum, the CMake selection list and the runtime registry against a canonical 25-entry table, and the exhaustive maturity and category switches, whose trailing return (not default:) is deliberate: the omission is caught by GraphicsBackendMaturityTests.cpp and GraphicsBackendCategoryTests.cpp in the graphics tests, not by the compiler.

getCurrentGraphicsRendererType() is constexpr: an #elif chain over the CNA_RENDERER_<X> compile definition, with CNA_GL_PROFILE_* choosing among the five EasyGL identities and an #error when no definition is set. It is only correct because of an invariant cmake/RendererSelection.cmake maintains: each family's own CNA_RENDERER_<X> is applied to that family's target, and only the default identity's macro is defined project-wide; multi-renderer builds additionally define CNA_MULTI_RENDERER. scripts/check_runtime_renderer_discipline.py fails a new arm that calls add_compile_definitions() for its own identity. In a multi-renderer build this function therefore answers the build default, never the renderer that is running; the running one comes from GraphicsRendererSelection::GetActive().

The two classification headers are callable for any identity, not just the compiled one. Maturity: SDL_RENDERER, the three non-web EasyGL profiles, VULKAN and the three Direct3D identities are Production; WEBGPU, SOFTWARE, FREEDIRECT, FNA3D, SVG_DOM and PORTABLEGL are Experimental; the rest are Supported (Historical and Deprecated are unused). Category: HEADLESS and STUB are Diagnostic, SOFTWARE and PORTABLEGL are Software, the five browser-only identities are Web, and five identities are TranslationLayer. These values feed the automatic fallback ranking described in the selection section. They are declared metadata in the two switch statements, not measurements: a maturity of Production records CNA's own classification of the backend, and the header itself says the value can change as a backend gains coverage. The C ABI exposes both classifications through its own converting tables (MapBackendMaturityToC and MapBackendCategoryToC in modules/c-api/src/CnaCApiCoreExt.cpp), and identity values cross the ABI through an explicit table (MapRendererTypeToC), never through a cast of the C++ ordinal.

For a maintainer comparing against an older snapshot on next: the only recent change to cmake/RendererSelection.cmake that touches this area is the default-OFF CNA_OPENGL4_COMPILED_EFFECTS option inside the OPENGL4 arm. That option is a feature define, not an identity macro, so the identity discipline described above is unchanged, and no file under modules/core differs between the two snapshots.

Target operating system is not the platform implementation

CNA::TargetPlatform classifies the build (Desktop, Android, iOS, Web) from __EMSCRIPTEN__, __ANDROID__ and TARGET_OS_IPHONE; getCurrentPlatformName() additionally separates "macOS", "Windows" and "Linux" for logs, and isMobilePlatform() is true for Android and iOS. The header defines only the Apple target macros (CNA_TARGET_APPLE, then exactly one of CNA_TARGET_IOS or CNA_TARGET_MACOS) so the two-level __APPLE__/TargetConditionals.h test is written once. The prefix is deliberately not CNA_PLATFORM_: cmake/PlatformSelection.cmake defines CNA_PLATFORM_<NAME> for the selected window/event implementation (SDL3, X11, HEADLESS, …), which is an independent axis. The enum itself was renamed from CNA::Platform when the platform namespace arrived, because an enum and a namespace of the same name in one scope are ill-formed. getCurrentDesktopOS() in modules/core/src/DesktopOS.cpp throws CNAException when the target is not Desktop.

Exceptions and the C ABI barrier

CNAException derives from Sharp Runtime's System::Exception and adds nothing but two constructors. Its place in the hierarchy is load-bearing for the C API: CallWithExceptionBarrier in modules/c-api/src/CnaCApiDetail.hpp catches it in its own arm and returns CNA_RESULT_INVALID_STATE with CNA_ERROR_CATEGORY_STATE, after the platform and engine-layer arms and before the generic System::ArgumentException, NotSupportedException and InvalidOperationException arms. Re-parenting CNAException, or deriving a new CNA exception from it or from one of those bases, changes which arm fires and therefore the result code a binding sees. Internal/ExceptionSerialization.hpp is the one place the "Message" and "InnerException" keys are defined for XNA exception types that expose the .NET serialization constructor; Sharp Runtime's System::Exception has no serialization support of its own, and the inner exception travels in process as a std::exception_ptr.

The CNAEXT marker

CNAHelper.hpp defines CNAEXT, which tags public members that are not part of XNA 4.0 and normally expands to nothing. With CNA_STRICT_XNA_API defined it expands to [[deprecated]]; the cna_strict_xna_api_check executable in cmake/Harnesses.cmake (GNU/Clang with tests enabled) compiles tools/devices/StrictXnaApiSurfaceCheck.cpp that way with -Werror=deprecated-declarations. The header comment still names that file under tests/Microsoft/Devices/; the CMake target is authoritative. A companion CTest, StrictXnaApiSurfaceLeakCheck_MustFailToCompile, builds a target that deliberately calls a CNAEXT member and passes only if that build fails, so the strict mode cannot silently stop rejecting extension calls.

Renderer selection is global policy, not a native renderer

modules/core/src/GraphicsRendererSelection.cpp holds only policy: what was asked for, whether the choice is still open, and what happened. It knows nothing about descriptors, windows or SDL. The graphics module publishes the compiled-in set into it and reads the decision back through the separate GraphicsRendererSelectionAccessEXT class, so the game-facing and graphics-facing directions cannot be confused. The user-level precedence, fallback and web rules are on the runtime selection guide; the device side of the handshake is traced in Renderer selection internals.

static initialisation  (generated CnaRendererRegistry.generated.cpp)
  PublishAvailable(compiled set, default = first registry entry)
    '- RebuildAttemptOrder -> GetSelected -> ConsultEnvironmentOnce
         (CNA_GRAPHICS_RENDERER, or Module.cnaPreferredRenderer on Emscripten, read HERE)

game code, before the first GraphicsDevice
  SetFallbackChain(span) | EnableAutomaticFallback(bool)     enables fallback
  SetPreferred(type | name)      RejectIfNotCompiledIn unless fallback is already on
  GetAvailable / IsAvailable / GetSelected / IsLatched       never latch

GraphicsDevice::resolveRenderer   (modules/graphics/src/Xna/GraphicsDevice.cpp)
  GetAttemptOrder()  -> for each candidate:
       no descriptor (NotCompiledIn) / GraphicsAdapter null-or-reference-device flag names another
       renderer / window kind cannot be recreated / CNA_DEBUG_UNAVAILABLE_RENDERERS / availability probe
            -> RecordFallback(reason)            Logger::Warn(RENDER), appended to history
       construction throws, chain length 1   -> rethrown unchanged (no record)
       construction throws, chain longer     -> RecordFallback(InitializationFailed)
       success                               -> Latch(candidate)  (active set, latched = true)
  chain exhausted -> InvalidOperationException("no graphics renderer could be created", first failure)

The state is one function-local static SelectionState:

FieldWritten byMeaning
available, defaultType, publishedPublishAvailableThe compiled-in identities and the configured default. Before publication defaultType is Stub and available is empty.
preferredSetPreferredHighest precedence.
environmentPreferred, environmentConsultedConsultEnvironmentOnceMiddle precedence, read once per process (or per ResetForTestingEXT).
fallbackEnabled, fallbackChainSetFallbackChain, EnableAutomaticFallbackOff by default; the chain is tried after the selected identity, duplicates skipped.
attemptOrderRebuildAttemptOrderRecomputed on every input change so GetAttemptOrder can hand out a span.
active, latched, historyLatch, RecordFallbackWhat was created, whether the choice is frozen, what was skipped and why.

Precedence. GetSelected() returns an explicit SetPreferred value, else the environment value, else the published default. On Emscripten the page property Module.cnaPreferredRenderer is read at the environment level (a real environment variable still wins), so page configuration cannot outrank an explicit C++ call. The property reader copies at most 63 ASCII characters into a static buffer once; a non-ASCII or over-long value reads as absent rather than being truncated into something that might parse. The exported cna_set_preferred_renderer is a thin wrapper over SetPreferred that turns any exception into a return value of 0 and a warning.

Rejection is evaluated at call time. RejectIfNotCompiledIn throws only if the set is published, the identity is absent and fallback is currently disabled. So SetPreferred(absent) followed by SetFallbackChain(…) throws on the first call, while the reverse order records the absent identity later as NotCompiledIn. EnableAutomaticFallback(true) snapshots the ranking from available at the moment it is called and replaces any explicit chain; EnableAutomaticFallback(false) clears the chain and disables fallback even if the chain had come from SetFallbackChain. The automatic ranking sorts stably by maturity ordinal, adds 10 for the Software category and puts STUB at 100, so HEADLESS (a Supported Diagnostic identity) ranks ahead of every Experimental and CPU renderer.

⚠

Maintainer note: when the environment is first read. PublishAvailable ends by calling RebuildAttemptOrder, which calls GetSelected, which consults the environment. PublishAvailable is called from a namespace-scope initialiser in the generated registry (cmake/templates/CnaRendererRegistry.generated.cpp.in), so the first read happens during static initialisation, before main, normally with fallback still off because no game code has run yet. Read that way, a CNA_GRAPHICS_RENDERER (or page property) value that names no identity, or names one that is not compiled in, throws out of a static initialiser, which ends the process through std::terminate rather than reaching game code as a catchable exception. Checked by reading at 009d40f5; not executed, and no test sets an invalid value.

Latching. Latch is called only at the end of a successful resolveRenderer; a construction that throws leaves the selection open, so a game that catches the error may reconfigure and try again (GraphicsRendererFallbackTest.AFailedResolutionDoesNotLatch). After the latch, SetPreferred, SetFallbackChain and EnableAutomaticFallback throw InvalidOperationException naming the active renderer; GetActive() throws until something has been created. The latch forbids changing the choice, not recreating the same renderer: GraphicsDevice::Reset, multisample reconstruction and a second device keep it. The comment on IsLatched() in the header still says the latch happens when the first device begins construction; the implementation (and the design note beside Latch in GraphicsDevice.cpp) latch on success.

Threads. Only latched is atomic (release on Latch, acquire in IsLatched), so a late call from another thread is detected rather than raced on that flag. Everything else is plain std::vector and std::optional with no lock, and the spans returned by GetAvailable, GetFallbackHistory and GetAttemptOrder alias those vectors, invalidated by the next republish, reset, preference change or fallback record. The supported rule is the header's: select before any graphics thread starts and before the first device is constructed; there is no concurrent reconfiguration contract. See the thread and callback map.

Tests. ResetForTestingEXT clears preference, environment state, fallback, active identity, history and the latch, but keeps the published set and default, and on Emscripten the page property stays cached in its static buffer. Because it ends by rebuilding the attempt order, which calls GetSelected, it also re-reads the environment variable at once, so a fixture that resets while an invalid CNA_GRAPHICS_RENDERER is set would throw from the reset itself. It exists only for test fixtures (GraphicsRendererSelectionTest and GraphicsRendererFallbackTest call it in SetUp and TearDown, and SetUp republishes the registry); a game calling it would reopen a decision the rest of CNA has already acted on. For a new renderer, changes here have to stay aligned with CMake identity validation, the generated registry and the C ABI value table.

The C ABI surface. The selection API is mirrored for bindings in modules/c-api/src/CnaCApiCoreExt.cpp: routes for setting the preferred renderer by identity or by name, reading the selected and active identity, the latch, the available set, the fallback chain, automatic fallback and the fallback history each call the same GraphicsRendererSelection functions inside the exception barrier. The same file also exposes a reset route meant for tests (cna_graphics_renderer_reset_selection_for_tests_ext) and the assembly-title setter and getter (cna_assembly_set_title_ext, cna_assembly_copy_title_ext); C has no static-initialisation construct, so the AssemblyTitleAttributeEXT object has no C form and a C caller sets the title from main. Read at 009d40f5; the C API library was not built or run for this page. See C API internals for the adapter machinery.

Path conversion and containment are cross-platform invariants

modules/core/include/CNA/Internal/PathUtf8.hpp states the path model in three rules. A std::filesystem::path is carried native (UTF-16 on Windows, raw bytes on POSIX) to every open, stat, create, enumerate or delete. A narrow std::string holding a path means UTF-8, in public APIs, log lines, manifests, serialized references and map keys, and is converted back before it touches the filesystem. path::string() and generic_string() are not UTF-8 on Windows: they convert through the process ANSI code page and throw on an unmappable character, and fixing separators fixes nothing about encoding. The header also records the counter-intuitive measurement behind the rules: handing UTF-8 bytes to a narrow C API such as fopen is worse than the status quo on a CP1252 machine, because the C runtime reads the two UTF-8 bytes of é as two CP1252 characters.

FunctionContract
PathToUtf8Native path to UTF-8 with its own separators; for text a human reads.
PathToGenericUtf8Native path to UTF-8 with / separators; for identities, cache keys, manifests. Separators only: no ./.. resolution, canonicalisation, case folding or Unicode normalisation.
PathFromUtf8Exact inverse. On Windows malformed UTF-8 throws filesystem_error; on POSIX the bytes are preserved, because a real filename need not be UTF-8.
TryPathFromUtf8Total form for untrusted text: any exception becomes an empty optional.
IsWellFormedUtf8Rejects truncated sequences, stray continuation bytes, overlong forms, surrogates and values above U+10FFFF, but accepts an embedded NUL; a caller that needs a C-string-safe path must reject NUL separately.
ContentPathToUtf8, ContentPathFromUtf8ContentPath.hpp: the content pipeline's older spellings, forwarding unchanged to the generic helpers.

modules/core/include/CNA/Internal/PathContainment.hpp protects every join of an untrusted asset, storage or media path. The algorithm is the same in every entry point: replace \ with /; refuse empty text and anything IsDisallowedAbsolutePath calls rooted (a leading /, which also covers UNC //server, or a drive letter followed by :, including drive-relative C:asset) on the string, on every host, because on POSIX "C:/Windows" is an ordinary relative name and on Windows "/etc/passwd" is not is_absolute() yet still discards the join base; convert with TryPathFromUtf8, so text that cannot name a path is simply not contained; join natively and lexically_normal() the result; then compare path components with lexically_relative, never a string prefix, so content-evil is not inside content. A result that is empty, "." (the root itself) or starts with .. fails. With canonicalize true (the default) both sides first go through weakly_canonical, so an existing symlink under the root that points outside it is caught, and a canonicalisation error is a failure.

Entry pointRoot vs join baseProduction callers at TARGET
ResolveContainedPath(base, rel)same directoryStorageDevice::DeleteContainer, the StorageContainer constructor, PlaylistParser, root-relative content sidecars
ResolveContainedPathFromBase(root, base, rel)separate, string form (the join base may lie below the root)none directly at TARGET; ResolveContainedPath is this function with the root and the base equal, and the referring-file variant below is built on the native core
ResolveContainedNativePathFromBase(root, base, rel)separate, nativeStorageContainer::ResolveNativePath (every file and directory operation)
ResolveContainedPathRelativeToFile(root, file, rel)root is the content root if the referring file lies lexically inside it, otherwise the file's own directorysong and video XNB readers, manifest-relative content sidecars
ResolveContainedUtf8Path, ValidateContainedNativePathnative coremodel and CNB content pipelines
ValidateContainedPath(root, candidate, false)lexical check of an already-built pathContentManager cache-key naming
IsDisallowedAbsolutePath, IsRootedPathbuilding blocksContentReader external references, XNB and content-pipeline importers

The drive-letter test is purely textual: any text whose first two characters are an ASCII letter followed by : is refused, whatever follows, so a relative POSIX file name such as a:b is not contained either. That is the price of one rule on every host. Three further details are easy to break. First, the returned resolvedPath is always the lexical join, never the canonical form, and the string variants return it in generic form: MediaLibrary keys songs by the exact text PlaylistParser produced, and an earlier version that returned native separators missed every lookup on Windows. Second, the referring-file variant is what lets "../textures/foo" from effects/myeffect climb out of effects/ while staying inside the content root, and confines an explicitly external bundle to its own directory. Third, ContentReader::ReadExternalReference is deliberately different again: it works in logical asset-name space, rejects rooted spellings with IsDisallowedAbsolutePath, joins lexically onto the referring asset's directory and refuses a result beginning with ../, without touching the filesystem, before handing the name to ContentManager::Load; see Content runtime internals.

ℹ

What containment does not promise. The check and the later open are separate filesystem operations, so a symlink or directory swapped in between is not caught; nothing in these helpers is race-resistant confinement. The Unicode suite's own header says its cases pass on Linux before and after the migration it guards; their discriminating value is on a Windows host with a non-UTF-8 ANSI code page. The storage side of these limits is on Storage internals.

CaseInsensitivePath.hpp is the companion used where XNA content names are case-insensitive: ResolveExistingNativePath returns the requested path unchanged if it already exists, otherwise walks it component by component and substitutes the single directory entry that matches under ASCII-only case folding (FoldAsciiCase, never the locale-dependent std::tolower, which can rewrite UTF-8 continuation bytes); a missing or ambiguous component returns the original path so the caller's not-found handling stays authoritative. Callers include ContentManager, TitleContainer, WaveBank and the platform's StandardFileSystem. A "simpler" string-prefix check, a narrow fopen(path.string()) or a locale-aware fold would reintroduce both the security and the Unicode failures these helpers exist to prevent.

Logging and process globals

Logger::Log in modules/core/src/Logger.cpp returns early when condition is false or the level is disabled (level <= minimumLevel_ numerically, so lower numbers are more severe), formats [LEVEL][CATEGORY] message without a trailing newline, then takes SinkMutex() and either calls the installed sink or writes the line plus '\n' to stderr. The default destination is stderr, never stdout, because the Terminal platform draws its frame on stdout; that makes the destination a correctness matter, pinned by LoggerTest.DefaultSinkWritesToStderrAndNeverStdout. SetSink and ResetSink take the same mutex; an empty std::function restores the default.

DetailBehaviour at TARGET
LevelsFATAL 0, ERROR 1, WARN 2, INFO 3, DEBUG 4, TRACE 5, EXPERIMENT 100. EXPERIMENT messages appear only after SetMinimumLevel(LogLevel::EXPERIMENT).
Default minimumINFO when NDEBUG is defined, otherwise TRACE (a constant-initialised static, so safe during static initialisation).
CategoriesAPPLICATION, ERROR, SYSTEM, AUDIO, VIDEO, RENDER, INPUT, TEST, GPU. There is no per-category filter any more (SDL's separate category priorities went with the SDL sink).
…If overloadsAlways log under APPLICATION; they take no category.

Lines from two threads cannot interleave through the logger, because the whole write happens under the mutex. The source makes no other promises, and a maintainer should not invent them. minimumLevel_ is a plain static read without the lock, so changing the level while another thread logs is a data race. The user sink runs while a non-recursive std::mutex is held, so a sink that logs through Logger, or calls SetSink/ResetSink, deadlocks (formally undefined behaviour); a diagnostic patch must not make the sink re-enter CNA logging. An exception thrown by a sink propagates to the caller of Log (the lock_guard releases the mutex); this layer does not swallow it.

Core keeps no long-lived resource graph to dispose, but it does own several process-lifetime globals that outlive any Game. Tests must restore them explicitly rather than rely on case order; see also the ownership and lifetime map.

GlobalWhereSynchronisationHow tests restore it
Renderer selection stateState() in GraphicsRendererSelection.cpponly latched is atomicResetForTestingEXT (keeps the published set)
Minimum log levelLogger::minimumLevel_noneLoggerTest::TearDown restores the previous level
Log sinkCurrentSink()SinkMutex()ResetSink
Declared product titlestorage() in AssemblyInfo.cppnone; the getter returns a referenceSetAssemblyTitleEXT("")
Browser renderer propertystatic buffer in GraphicsRendererSelectionEmscripten.cppnone; read oncenot resettable

The product title is the C++ stand-in for .NET's AssemblyTitleAttribute: SetAssemblyTitleEXT or a namespace-scope AssemblyTitleAttributeEXT object sets it before main. DefaultWindowTitle.cpp turns it into the default window title (the declared title, else the executable's file name without directory or extension, read with GetModuleFileNameW, _NSGetExecutablePath or /proc/self/exe, else "Game"; Emscripten has no executable and goes straight to the last resort), and its one caller is GraphicsDevice window creation. It is a process default, not per-Game configuration.

Failures, tests and human change route

Failure behaviour differs by area, and each is deliberate:

  • Selection throws Sharp Runtime ArgumentException (unknown name through the string overload) or InvalidOperationException (absent identity without fallback, late call, GetActive before creation, unknown environment value) and leaves the request unlatched. Fallback attempts are recorded with a reason and a RENDER warning; native rollback of a failed candidate (window, video subsystem, renderer object) belongs to GraphicsDevice.
  • Path helpers are written not to throw for untrusted text: they return ok == false or an empty optional, and the calling module chooses the exception (std::invalid_argument in storage, ContentLoadException in content).
  • Logger formats and calls a user sink; the sink's exceptions are the caller's.

Core's tests are nine files with 106 GoogleTest definitions; the selection contract is tested from the graphics tree, because a meaningful test needs the graphics module's published registry.

FileSuites (definitions)Guards
LoggerTests.cppLoggerTest (11)level gate, every level and category reaching the sink, conditional overloads, line format and no trailing newline, ResetSink, stderr not stdout, level round trip. Not covered: concurrent level changes, sink re-entry.
PathUtf8Tests.cppPathUtf8Test (10), IsWellFormedUtf8Test (10), ContentPathCompatibilityTest (2)round trips, files created and enumerated through UTF-8 text, no ../case/Unicode normalisation, the UTF-8 validator's accept and reject classes
PathContainmentTests.cppPathContainmentTest (30)traversal, absolute, drive-letter, drive-relative, rooted-backslash and UNC spellings on POSIX, sibling-prefix, root-equal, nested join base, external referring file, symlink escape (skipped where symlinks cannot be created)
UnicodePathResolutionTests.cppUnicodeTree (13), IsRootedPathTest (3), IsDisallowedAbsolutePathUnicodeTest (2), FoldAsciiCaseTest (1)containment and case-insensitive resolution over real non-ASCII trees
CaseInsensitivePathTests.cppCaseInsensitivePathTest (4)exact paths preserved, case variants resolved, separators normalised, unresolved paths returned
TargetPlatformTests.cpp, VersionTests.cpp, DefaultWindowTitleTests.cpp, PlayerIndexTests.cppTargetPlatformTest (7), VersionTest (6), DefaultWindowTitleTests (5), PlayerIndexTest (2)the smaller public contracts
GraphicsRendererSelectionTests.cpp, GraphicsRendererFallbackTests.cppGraphicsRendererSelectionTest (20), GraphicsRendererFallbackTest (10)availability, precedence, rejection messages, latch on success only, reconstruction after latch, chain order and deduplication, automatic fallback resolving to the built renderer. No unit test sets CNA_GRAPHICS_RENDERER; the multi-renderer CI workflow's runtime-reachability step does, as configured in .github/workflows/multi-renderer-ci.yml.

Registration: every file above is compiled into CnaTests, which cmake/UnitTests.cmake registers with gtest_discover_tests(… DISCOVERY_MODE PRE_TEST), so each case is its own CTest entry. CnaCoreTests is a focused iteration executable built from the same test objects (the cna_core_test_objects library) and linked against the core group's dependency (cna_core), the shared cna_test_build_config and gtest_main; it is EXCLUDE_FROM_ALL and deliberately not registered with CTest. The unit-core build preset builds it after cmake --preset unit (STUB renderer, Debug); the selection tests live in CnaGraphicsTests. See Building: tests and Test architecture.

  1. Renderer name or preference change: run the selection and fallback suites, the identity gates (check_renderer_identities.py, check_runtime_renderer_discipline.py and the CnaRendererDefaultSelection_* CTest cases), a single-renderer and a multi-renderer configuration, then the graphics constructor and fallback tests. Re-read the C ABI value table if an identity moved.
  2. Path change: run the four path suites plus a content and a storage integration case, on Windows as well as POSIX; a Linux pass alone does not exercise the code-page failures.
  3. Logging change: run LoggerTest and review any new sink for re-entry and thread behaviour, which the suite does not cover.
  4. Public header or exception change: also build the aggregate CnaTests, a consumer, and the C API barrier tests, because the arm order in CallWithExceptionBarrier depends on the hierarchy.

A green core-only run is not enough for a path rule or renderer identity used by another module. Broader routes are in I need to fix a renderer bug, I need to change build configuration and the Maintainer Handbook; the module's row in the module index lists its targets.

Curated source route

  1. core/CMakeLists.txt, the cna_add_module definition in modules/CMakeLists.txt and Version.cmake: the archive versus header-only boundary and the generated public identity.
  2. GraphicsRendererType.hpp, then CNA/C/graphics.h: dense C++ ordinals against stable C values.
  3. GraphicsRendererSelection.hpp and GraphicsRendererSelection.cpp: compiled set, requested choice, attempt order and latched identity as separate fields.
  4. CnaRendererRegistry.generated.cpp.in and GraphicsDevice::resolveRenderer in GraphicsDevice.cpp: when the set is published and when the choice latches.
  5. PathUtf8.hpp and PathContainment.hpp: native conversion into component and symlink containment; then UnicodePathResolutionTests.cpp for the non-ASCII cases.
  6. Logger.cpp and LoggerTests.cpp: sink lock, stderr channel and tested formatting.
  7. TargetPlatform.hpp: keep the operating-system target distinct from the platform implementation.

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