Platform backends

CNA snapshot c1c316b9  ·  Development › Platform internals  ·  source links pinned to c1c316b9

✓

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 c1c316b9; unless a sentence says otherwise, nothing here was built or executed. The selector matrix and refusal paths were read from the CMake files at this snapshot, not from a configure run. CTest entries and CI cells are listed as registered and configured; none was executed, and the offered set is not a claim that every offered combination builds or runs.

CNA has three implementations of its platform contract, selected at configure time with CNA_PLATFORM: SDL3 (the default and the one windowing implementation), HEADLESS and TERMINAL. Windows, X11, Wayland, macOS, iOS, Android and the browser are all reached through SDL3’s own video drivers, and a renderer that needs a native handle (an HWND, an X11 Display* and window, or a wl_display* and wl_surface*) receives it through IPlatformWindow::GetNativeHandle(). Two further names, SDL12 and EMSCRIPTEN, are reserved and refused. This page is the maintainer’s map of those backends: how a selection becomes a live IPlatform, how the refusal paths work, what is shared and what is backend-specific, and where each backend’s evidence lives. What each backend offers a game, its capability column and its CI status are in the user guides Platform Support and Windows, X11 and Wayland; the contract’s rules are on Platform architecture.

Current selector matrix

Every row below was read from PlatformSelection.cmake, modules/platform/CMakeLists.txt and PlatformFactory.cpp at this snapshot. “Compiled” means the backend’s translation units are in cna_platform; “factory name” is the string PlatformFactory::Create(name) and GetAvailable() use, which is not always the CMake spelling.

CNA_PLATFORMOffered by CMakeCompiledFactory nameNative link inputsPrimary maintainer concerns
SDL3 (default)Always; refused when CNA_ENABLE_SDL=OFFWhen selectedSDL3SDL3::SDL3 (PRIVATE on desktop; PUBLIC, plus SDL3main, on Android and iOS; UIKit on iOS)Subsystem refcounts in a host-shared library, window creation and adoption, event mapping, logical vs pixel size, GL/Vulkan/presenter services, the native handle of whichever video driver SDL chose
HEADLESSAlwaysAlways, whatever the selectionHeadlessNoneTruthful refusals, one in-memory window, deterministic services for display-free runs and conformance
TERMINALOn every non-Windows target; renderer restricted to SOFTWARE, HEADLESS, STUBAlways on non-Windows targetsTerminalNone (termios, poll and signals are libc)Terminal session leases and restoration, signal handling, byte-stream input decoding, ANSI presentation of CPU frames

Because Headless is always compiled and Terminal is compiled on every POSIX target, “one selected platform” does not mean only one platform class exists in a binary: GetAvailable() lists SDL3 first when it is selected, then Headless, then Terminal off Windows. The selection decides which one PlatformFactory::Create() returns and therefore which one Game runs on; the others exist so the conformance suite has more than one implementation in the same process.

The offered set is a CMake fact, not a validation claim. Nothing in the selection files excludes HEADLESS or TERMINAL on macOS or Emscripten; whether such a combination builds or runs is not established by these pages, and the status CNA’s CI actually configures is in Status by operating system.

Reserved and refused identities

A value of CNA_PLATFORM that does not name an offered implementation never falls back to another backend. PlatformSelection.cmake has three refusal paths, all FATAL_ERROR at configure time; values are compared with STREQUAL and IN_LIST, so they are case-sensitive.

RequestWhere it is refusedWhat the message says
SDL12, EMSCRIPTEN (every target); TERMINAL on a Windows targetThe reserved list _cna_platforms_reserved; a host-conditional implementation is appended to it on the hosts it does not support“is a reserved identifier that is NOT implemented”, the available set, and a pointer to CNA’s platform plan; for TERMINAL the reason (it is built on termios and has no Windows console path). It says falling back to SDL3 would build something other than what was asked for.
WIN32, X11, WAYLANDThe list _cna_platforms_retiredThe value names a window system CNA reaches through SDL3: the message says to configure -DCNA_PLATFORM=SDL3, that SDL picks its windows, x11 or wayland video driver at run time (SDL_VIDEODRIVER selects one explicitly), and that renderers still receive the native handles through IPlatformWindow::GetNativeHandle(). It is deliberately not an alias.
Any other nameThe final IN_LIST check“is not a known platform” with both the available and the reserved lists.

The CMake GUI choice list (set_property(CACHE CNA_PLATFORM PROPERTY STRINGS …)) shows only the offered set. The reserved names reach no source file: there is no CNA_PLATFORM_SDL12 or CNA_PLATFORM_EMSCRIPTEN branch in PlatformFactory.cpp; a web build uses the default SDL3 backend. The script-mode test CnaPlatformSelection_Retired (registered in UnitTests.cmake, running RetiredPlatformSelection.cmake against the production selection file on a Windows and a non-Windows host model) keeps WIN32, X11 and WAYLAND refused by name and an unknown name refused as unknown.

Two refusals involving the platform axis live outside that file and run after it:

  • SdlAvailability.cmake refuses platform SDL3 when CNA_ENABLE_SDL=OFF (as it refuses SDL3 audio and the SDL_RENDERER, SDL_GPU and FNA3D renderers), with a message that begins “CNA_ENABLE_SDL=OFF, but this configuration genuinely requires SDL”.
  • RendererSelection.cmake refuses TERMINAL with any renderer other than the three CPU ones (SOFTWARE, HEADLESS, STUB), before any renderer dependency is probed. There is no equivalent gate for HEADLESS; a GPU renderer on Headless is refused at run time by the renderer factory (see Headless platform internals).

From the CMake option to a live IPlatform

CMakeLists.txt (top level, in this order)
  RendererIdentities.cmake    renderer name validated first
  ApplePlatform.cmake         Apple sysroot / deployment target
  PlatformSelection.cmake     CNA_PLATFORM: offered, reserved, retired, refused
     |                        add_compile_definitions(CNA_PLATFORM_<NAME>)
  AudioPlatformSelection.cmake
  SdlAvailability.cmake       CNA_ENABLE_SDL=OFF refusals
  ThirdPartySDL.cmake         vendored SDL3 only if CNA_ENABLE_SDL is on
        |
modules/platform/CMakeLists.txt
  src/*.cpp (contract types) + src/Sdl3/*.cpp (when SDL3 is selected) + src/Common/*.cpp
  + src/Headless/*.cpp (always) + src/Terminal/*.cpp (not WIN32)
        |
PlatformFactory.cpp   kDefaultName chosen by #if CNA_PLATFORM_<NAME>
        |
Game::Game()  ->  PlatformFactory::Create()  ->  Game(std::unique_ptr<IPlatform>)
                  platform_ is the first member; InstallPlatform() -> SetCurrentPlatform()
                  platformCapabilities_ = GetCapabilities()   read once, cached

The platform decision is settled before SDL is configured at all, because whether SDL is needed is a consequence of the platform, audio and renderer choices together. The compile definition CNA_PLATFORM_<NAME> is added project-wide (and exported to consumers through CNA::BuildConfig, so a game's own main() sees it too) and is what PlatformFactory.cpp and the entry-point header key off.

At run time, Game.cpp owns the platform: platform_ is initialised first, installed as the ambient platform from that initialiser (so a GraphicsDevice or ContentManager constructed later finds it instead of creating a second one), guarded by a PlatformInstallation scope object that undoes the installation if a later member throws, and uninstalled in ~Game. Outside a Game, CurrentPlatform.cpp creates a default platform lazily on first use of GetCurrentPlatform(). Its holders are deliberately immortal (allocated once, never destroyed at process exit) so that teardown code running from late static destructors still finds them; ResetCurrentPlatform() destroys the lazy default explicitly. One consequence is recorded in lsan_x11_mesa.supp: a process that draws through a standalone GraphicsDevice on the lazy default exits without eglTerminate, and the Mesa allocation that results is suppressed for LeakSanitizer. The startup and shutdown order around this is traced in Startup source trace and Ownership and shutdown.

Reading one backend

Every backend page in this section follows the same route. The table after the list shows where each step lands in each implementation. Backend sources live under modules/platform/ (one src/<Backend>/ directory each) and their tests under modules/platform/tests/.

  1. Factory registration and CMake target. Find the #if defined(CNA_PLATFORM_<NAME>) arms in PlatformFactory::Create(name) and GetAvailable(), and the source glob and PRIVATE links in modules/platform/CMakeLists.txt.
  2. Construction without eager initialisation. The contract has no Initialize/Shutdown pair; subsystems are acquired and released with refcounts, an unpaired release is a no-op, and a backend may do real work in its constructor as long as its capability set stays stable for the platform's lifetime.
  3. Window creation, adoption and destruction, and the event pump. Owning windows come from CreateWindow; AdoptWindow/AdoptWindowHandle return non-owning wrappers where a backend supports them (Headless and Terminal inherit the contract's refusing default). Then find PollEvents.
  4. Native event to PlatformEvent to public input. Follow one event through the backend's mapper, Game::PollEvents, PlatformInputBridge::ProcessEvent and, separately, the snapshot service's Update() (see Debugging).
  5. Timing. Game::Tick reads GetPerformanceCounter/GetPerformanceFrequency, and in fixed-step mode sleeps with Delay(1) while it can, then spins on std::this_thread::yield(); changing a backend's clock or sleep changes frame pacing for every game on it.
  6. The narrow service a renderer receives. A renderer descriptor declares needsWindow, needsVideoSubsystem, windowKind, needsGlContext, needsVulkanSurface and needsSurfacePresenter; GraphicsDevice hands it only those services plus a NativeWindowHandle snapshot (see Renderer selection internals).
  7. Tests and CI host. Find the backend's own suites, their CTest registration and host prerequisites, and what the workflow files configure (see Where the evidence lives).
BackendEvent pumpLogical vs pixel sizeGraphics servicesAdoption
SDL3SDL_PollEvent loop into a mapper, the same code over every SDL video driverSDL pixel density; drawable from SDL_GetWindowSizeInPixelsSdl3GlContext, Sdl3VulkanSurface, Sdl3SurfacePresenterSDL windows by id or handle
HeadlessDrains a queue filled by InjectEventIdentical; scale 1.0NoneRefused (contract default)
TerminalSIGWINCH flag, then one byte-stream decoderNominal 8×16 pixels per cell; scale 1.0TerminalSurfacePresenter only, and only on a TTYRefused (contract default)

Shared vs backend-specific

Shared by every backend: the public contract in include/CNA/Platform (IPlatform, IPlatformWindow, the service interfaces, PlatformEvent, PlatformCapabilities, NativeWindowHandle, WindowDescription), the contract’s own value types in src/*.cpp, and the runtime and input consumers downstream of it. The rest of the source tree is organised by who may share what:

DirectoryUsed byContents
src/CommonAny backend that needs itStandardFileSystem, StandardSystemInfo, and the SurfaceFrame validation the SDL3 and Terminal presenters share
src/Sdl3, src/Headless, src/TerminalTheir own backendNative handles, event-loop mechanics, window and surface creation, native input translation; in src/Sdl3 also the gamepad, joystick, haptics, sensor, camera, tray, dialog and clipboard services every windowed build gets from SDL

SDL is confined to the platform module, where src/Sdl3 is the only backend that includes it, and to a short allowlist (the SDL3 audio implementation and its mixer, and the sdl-renderer, sdl-gpu and fna3d renderer families); it is linked PRIVATE on desktop targets, and must not migrate into the general contract or into input: ContractIsSdlFreeTests (ContractIsSdlFreeTests.cpp) includes every contract header and fails to compile if an SDL header comes along, and the source gates run by the platform CI cell (listed on Platform architecture) count and classify every remaining SDL reference. The input side is downstream of translation: PlatformInputBridge::ProcessEvent is implemented in SdlInputBridge.cpp, a historical filename; the code there consumes only neutral PlatformEvent values.

CNA's own contract document, docs/platform-abstraction.md, carries the twelve-step procedure for adding an implementation (directory, selection, CMake, factory, capabilities that start false, event mapping at the edge, explicit ownership, conformance, native tests, a default-selected build, the source gates, one CI cell). Its implementation table lists the same three backends and says a new implementation is for a host SDL3 cannot reach, not a second route to a window system SDL3 already drives; the selection files above remain authoritative. The maintainer-facing recipe is I need to modify a platform backend.

Debugging a backend

Log capability acquisition, window state transitions and the translated CNA events, not only raw native messages. After a resize, call Sync() before reading bounds back (the contract allows SetSize to be asynchronous) and compare GetClientBounds() (logical units) with GetPixelSize() (drawable pixels) and GetDisplayScale(); a swapchain sized from the wrong one is the classic resize bug.

For an input bug, prove each link separately. There are two paths out of the same batch, and they fail independently:

SDL event / injected event / terminal byte
  -> backend mapper -> PlatformEvent (value) in the caller's batch
       Game::PollEvents, for each event, in order:
         1. PlatformInputBridge::ProcessEvent   InputManager accumulators, TextInputEXT, touch, device events
         2. Game handling                       Quit -> Exit(); CloseRequested -> Exit();
                                                FocusLost/FocusGained -> IsActive;
                                                Resized / PixelSizeChanged / DisplayScaleChanged
                                                  -> GameWindow update, UpdateViewportFromWindow,
                                                     renderer OnSurfaceInvalidated
       after the whole batch:
         3. GetKeyboard()->Update(), GetMouse()->Update()
         4. GetGamepad()/GetJoystick()->Update()   only once the Gamepad subsystem is initialised
  Keyboard::GetState / Mouse::GetState read the snapshots from step 3,
  and return empty states when the platform has no such service

A key that fires a text or bridge callback but never shows in Keyboard::GetState is a snapshot-service problem; a key that shows in GetState but not in a callback is a mapper or bridge problem. Exit() does not stop the batch: later events still reach input. Game does not filter window events by id and re-queries the window rather than trusting a resize payload. Those last behaviours are pinned for every implementation by EveryImplementation/GameEventSemanticsGoldenTest.* against the checked-in transcript platform-event-semantics.txt. The detailed trace is in Input internals and One frame source trace.

For a window that opens but shows nothing, separate the platform's answer (capability flag, service pointer, native handle tag) from the renderer's own context, surface or presenter acquisition. A window-free renderer (SOFTWARE, HEADLESS, STUB) opening no window on a windowing platform is by design, not a platform bug.

Where the evidence lives

Backend-specific test sources in modules/platform/tests/CNA/Platform are compiled into CnaTests only where their backend can be: UnitTests.cmake drops the Sdl3* suites when SDL3 is not the selected platform and the Terminal* suites on Windows. The implementation-neutral suites always build and are parameterised over GetAvailable().

BackendCTest entries (cmake/UnitTests.cmake unless noted)Automatic CI (as configured in platform-ci.yml)
SDL3CnaPlatformTests, CnaPlatformWindowTests (SDL dummy video driver), CnaPlatformXErrorHandlerTests (real X display), CnaPlatformSdl3X11Tests (private Xvfb) and CnaPlatformSdl3WaylandTests (private headless Weston), which force SDL_VIDEODRIVER and skip (77) where their server is not installedMatrix cells with OPENGLES3, VULKAN and SOFTWARE, including SDL3 on X11 and on Wayland; a native MSVC Windows job on manual dispatch only
HeadlessCnaPlatformTests, CnaPlatformWindowTests (conformance instances)The display-free matrix cell, which also runs the platform source gates; an SDL-free job (CNA_ENABLE_SDL=OFF, Headless, ALSA audio)
TerminalCnaPlatformTests (the Terminal* suites its filter names), TerminalSoftwareDemoIntegration (registered in modules/graphics/examples)Matrix cell: TERMINAL, SOFTWARE, NULL audio, with the pseudo-TTY demo

Tests self-skip with exit code 77 when their environment is missing, and CTest reports those as skipped, so a green run on a host without the prerequisite proves nothing about that backend. The CI column describes what platform-ci.yml is configured to do at this snapshot; run results were not inspected. None of the tests on these pages was executed for them. The generated index of every registered entry is Test target index, and the change-driven view is What to test after changing X.

  1. PlatformSelection.cmake: what is offered, reserved, retired and refused, and why.
  2. modules/platform/CMakeLists.txt and PlatformFactory.cpp: what is compiled and how each backend is named.
  3. IPlatform.hpp, IPlatformWindow.hpp and PlatformCapabilities.hpp: the promises every backend makes.
  4. CurrentPlatform.cpp and Game.cpp: ownership, installation and the per-frame consumption of events and snapshots.
  5. PlatformConformanceTests.cpp and docs/platform-abstraction.md: the rules a new or changed backend must keep.
  6. Then one backend page: SDL3, Headless or Terminal.

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