Core module internals
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.
| Links | Modules at TARGET | Consequence for a change |
|---|---|---|
cna_core_headers only | math, storage, platform, phone, inspector | Adding 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 archive | graphics core, input, audio, content, runtime, devices, devices-ext, every renderer family (PRIVATE), the C API | Any 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.
| Header | What it holds | Main consumers |
|---|---|---|
GraphicsRendererType.hpp | The 25-member identity enum, the only name table, case-insensitive parsing, the compile-time default | graphics registry, C API, tools, selection |
GraphicsRendererSelection.hpp, GraphicsRendererFallbackRecord.hpp | Process-wide selection policy and the graphics-side handshake; the four fallback reasons | GraphicsDevice::resolveRenderer, games, the browser shell |
GraphicsBackendMaturity.hpp, GraphicsBackendCategory.hpp | constexpr maturity and technology class for every identity | automatic fallback ranking, launchers |
TargetPlatform.hpp, DesktopOS.hpp | Compile-target classification and the Apple target macros | devices, platform, runtime |
Logger.hpp, LogLevel.hpp, LogCategory.hpp | The process logger | almost every module |
CNAException.hpp, Internal/ExceptionSerialization.hpp | Exception base and the shared message/inner-exception serialization keys | the 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.hpp | Declared product title and the default window title | GraphicsDevice window creation |
Internal/PathUtf8.hpp, ContentPath.hpp, PathContainment.hpp, CaseInsensitivePath.hpp | The filesystem path model | content, content pipeline, storage, media, audio, runtime, platform |
Internal/PackedRounding.hpp | Clamp-and-round with ties to even, NaN to 0, UNORM/SNORM expansion | math Color, graphics packed vectors |
CNAHelper.hpp, Microsoft/Xna/Framework/PlayerIndex.hpp | The 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:
| Field | Written by | Meaning |
|---|---|---|
available, defaultType, published | PublishAvailable | The compiled-in identities and the configured default. Before publication defaultType is Stub and available is empty. |
preferred | SetPreferred | Highest precedence. |
environmentPreferred, environmentConsulted | ConsultEnvironmentOnce | Middle precedence, read once per process (or per ResetForTestingEXT). |
fallbackEnabled, fallbackChain | SetFallbackChain, EnableAutomaticFallback | Off by default; the chain is tried after the selected identity, duplicates skipped. |
attemptOrder | RebuildAttemptOrder | Recomputed on every input change so GetAttemptOrder can hand out a span. |
active, latched, history | Latch, RecordFallback | What 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.
| Function | Contract |
|---|---|
PathToUtf8 | Native path to UTF-8 with its own separators; for text a human reads. |
PathToGenericUtf8 | Native path to UTF-8 with / separators; for identities, cache keys, manifests. Separators only: no ./.. resolution, canonicalisation, case folding or Unicode normalisation. |
PathFromUtf8 | Exact inverse. On Windows malformed UTF-8 throws filesystem_error; on POSIX the bytes are preserved, because a real filename need not be UTF-8. |
TryPathFromUtf8 | Total form for untrusted text: any exception becomes an empty optional. |
IsWellFormedUtf8 | Rejects 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, ContentPathFromUtf8 | ContentPath.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 point | Root vs join base | Production callers at TARGET |
|---|---|---|
ResolveContainedPath(base, rel) | same directory | StorageDevice::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, native | StorageContainer::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 directory | song and video XNB readers, manifest-relative content sidecars |
ResolveContainedUtf8Path, ValidateContainedNativePath | native core | model and CNB content pipelines |
ValidateContainedPath(root, candidate, false) | lexical check of an already-built path | ContentManager cache-key naming |
IsDisallowedAbsolutePath, IsRootedPath | building blocks | ContentReader 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.
| Detail | Behaviour at TARGET |
|---|---|
| Levels | FATAL 0, ERROR 1, WARN 2, INFO 3, DEBUG 4, TRACE 5, EXPERIMENT 100. EXPERIMENT messages appear only after SetMinimumLevel(LogLevel::EXPERIMENT). |
| Default minimum | INFO when NDEBUG is defined, otherwise TRACE (a constant-initialised static, so safe during static initialisation). |
| Categories | APPLICATION, 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 overloads | Always 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.
| Global | Where | Synchronisation | How tests restore it |
|---|---|---|---|
| Renderer selection state | State() in GraphicsRendererSelection.cpp | only latched is atomic | ResetForTestingEXT (keeps the published set) |
| Minimum log level | Logger::minimumLevel_ | none | LoggerTest::TearDown restores the previous level |
| Log sink | CurrentSink() | SinkMutex() | ResetSink |
| Declared product title | storage() in AssemblyInfo.cpp | none; the getter returns a reference | SetAssemblyTitleEXT("") |
| Browser renderer property | static buffer in GraphicsRendererSelectionEmscripten.cpp | none; read once | not 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) orInvalidOperationException(absent identity without fallback, late call,GetActivebefore creation, unknown environment value) and leaves the request unlatched. Fallback attempts are recorded with a reason and aRENDERwarning; native rollback of a failed candidate (window, video subsystem, renderer object) belongs toGraphicsDevice. - Path helpers are written not to throw for untrusted text: they return
ok == falseor an emptyoptional, and the calling module chooses the exception (std::invalid_argumentin storage,ContentLoadExceptionin 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.
| File | Suites (definitions) | Guards |
|---|---|---|
LoggerTests.cpp | LoggerTest (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.cpp | PathUtf8Test (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.cpp | PathContainmentTest (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.cpp | UnicodeTree (13), IsRootedPathTest (3), IsDisallowedAbsolutePathUnicodeTest (2), FoldAsciiCaseTest (1) | containment and case-insensitive resolution over real non-ASCII trees |
CaseInsensitivePathTests.cpp | CaseInsensitivePathTest (4) | exact paths preserved, case variants resolved, separators normalised, unresolved paths returned |
TargetPlatformTests.cpp, VersionTests.cpp, DefaultWindowTitleTests.cpp, PlayerIndexTests.cpp | TargetPlatformTest (7), VersionTest (6), DefaultWindowTitleTests (5), PlayerIndexTest (2) | the smaller public contracts |
GraphicsRendererSelectionTests.cpp, GraphicsRendererFallbackTests.cpp | GraphicsRendererSelectionTest (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.
- Renderer name or preference change: run the selection and fallback suites, the identity gates (
check_renderer_identities.py,check_runtime_renderer_discipline.pyand theCnaRendererDefaultSelection_*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. - 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.
- Logging change: run
LoggerTestand review any new sink for re-entry and thread behaviour, which the suite does not cover. - Public header or exception change: also build the aggregate
CnaTests, a consumer, and the C API barrier tests, because the arm order inCallWithExceptionBarrierdepends 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
core/CMakeLists.txt, thecna_add_moduledefinition inmodules/CMakeLists.txtandVersion.cmake: the archive versus header-only boundary and the generated public identity.GraphicsRendererType.hpp, thenCNA/C/graphics.h: dense C++ ordinals against stable C values.GraphicsRendererSelection.hppandGraphicsRendererSelection.cpp: compiled set, requested choice, attempt order and latched identity as separate fields.CnaRendererRegistry.generated.cpp.inandGraphicsDevice::resolveRendererinGraphicsDevice.cpp: when the set is published and when the choice latches.PathUtf8.hppandPathContainment.hpp: native conversion into component and symlink containment; thenUnicodePathResolutionTests.cppfor the non-ASCII cases.Logger.cppandLoggerTests.cpp: sink lock,stderrchannel and tested formatting.TargetPlatform.hpp: keep the operating-system target distinct from the platform implementation.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- From C# to C++: CNA's translation conventions — How CNA represents C# XNA concepts in C++ so that code stays diffable against the reference: names, properties, aliases, events, interfaces, disposal, visibility, layout and the porting checklist.
- sharp-runtime components and how CNA consumes them — How sharp-runtime is cut into CMake components, how an include maps to a component and link closure, and how CNA 009d40f5 selects, links and instruments the components it needs.
- sharp-runtime parity boundaries and verification — sharp-runtime's parity target, its permanent deviations and their reasons, frozen naming, platform and 128-bit boundaries, and what its tests, negative consumers, audit index and sanitizer runs do and do not prove.
- sharp-runtime streams, UTF-8 text and tasks — Exact semantics of sharp-runtime's Stream and MemoryStream (as CNA's content streams use them), UTF8Encoding's validation and U+FFFD fallback, and the Task, WhenAll, WhenAny, TaskCompletionSource and Thread model.
- The cross-platform contract: axes, composition and evidence per route — How target OS, platform implementation, renderer set, audio implementation and the XNA surface compose in CNA, what IPlatform owns, and why each platform claim is an evidence vector.
- The sharp-runtime object model: Object, strings, exceptions, delegates and collections — What System::Object, String, Exception, IDisposable, GC, Delegate, MulticastAction, EventHandler, Type, TimeSpan and the fail-fast collections really are in sharp-runtime, and how CNA builds on them.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-055: Stale whole-registry renderer counts survive outside check_renderer_identities.py's list: the C API's CORE.md and FEATURE_MATRIX.md say 50, core_ext.h says 46, ModuleProbes.cmake says 42, and CHANGELOG says 49 for a 50-identity release — CNA has 25 public renderer identities, yet the C API's backend-classification contract, a public C header's Doxygen, a CMake test header and the alpha.1 release notes still state obsolete totals that no count gate reads.
- CNA-BUG-077: An invalid CNA_GRAPHICS_RENDERER value (or Module.cnaPreferredRenderer) terminates the process during static initialisation — The renderer registry publishes the compiled-in set from a namespace-scope initialiser that consults the environment, so an unknown or not-linked renderer name throws out of that initialiser and ends the process through
- CNA-BUG-152: StorageDevice::getFreeSpaceProperty, getTotalSpaceProperty and getIsConnectedProperty pass the UTF-8 root to std::filesystem through the narrow path constructor — The three device properties hand the UTF-8 root string straight to fs::exists, fs::space and fs::path, which on Windows reinterpret it in the ANSI code page, so with a non-ASCII root they query a different path.
- CNA-BUG-184: Core's build files still carry dependencies its sources dropped: -lembind on Emscripten and a link probe that tolerates SDL3 — modules/core/CMakeLists.txt still adds -lembind for every Emscripten consumer although the browser preference reader was rewritten with EM_JS to avoid embind, and probe_core still permits SDL3 although core no longer use
- CNA-BUG-215: GraphicsRendererSelection::IsLatched() is documented to latch when the first GraphicsDevice begins construction; it latches only on successful resolution — The header comment on IsLatched() says the selection latches when the first GraphicsDevice begins construction, but GraphicsDevice::resolveRenderer latches only after a renderer has been created, so a resolution that fai
- CNA-GAP-001: ContentLoadException derives from std::runtime_error, not System::Exception, so no Sharp Runtime catch clause spans CNA's XNA exceptions — Unlike XNA, where every framework exception is a System.Exception, CNA's ContentLoadException (deliberately) derives from std::runtime_error, so catch (const System::Exception&) misses content-load failures; only std::ex
- CNA-GAP-002: CNA::Logger serialises writes but not level changes, and a sink must not call back into the logger — minimumLevel_ is read and written without synchronisation and the sink runs under a non-recursive mutex, so changing the level while other threads log is a data race and a sink that logs deadlocks; the header states neit
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- Architecture
- Architecture overview · Graphics architecture
- Internals
- Renderer selection internals · Storage internals · Content runtime internals · C API internals
- Maintainer workflow
- Ownership and lifetime master map · Thread and callback map · I need to fix a renderer bug · I need to change build configuration · Maintainer Handbook
- Tests and validation
- Test architecture and change recipes
- Reference
- Module index · CMake option index · Test target index · Selection axes index