The cross-platform contract: axes, composition and evidence per route
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 platform module headers, the CMake selection files, the renderer descriptor and the workflow files at 009d40f5; nothing was configured, built or run. The route table states what workflows are configured to do, not recorded passes.
"Which platform does CNA run on?" has no one-word answer at this snapshot. A running CNA program is the composition of five separately chosen inputs: the target operating system and toolchain, one platform implementation, a compiled renderer set with one active renderer, one audio implementation, and the XNA-shaped framework surface the game codes against. This page explains how those inputs compose, what the platform contract owns and deliberately does not own, where renderer requirements meet platform capabilities, and why every platform claim has to be read as a vector of separate evidence facts rather than a single "supported". It is for anyone choosing a configuration, porting CNA to a new host, or judging what a green workflow proves; the option-by-option reference is the platforms guide.
Five inputs, composed rather than collapsed
Older descriptions used "the SDL3 layer" or a renderer name as if it named the platform. The source separates the questions, and each has its own selector, its own compile-time marker and its own run-time query:
| Input | Question it answers | Chosen by | Visible in code as |
|---|---|---|---|
| Target operating system | Which object format, SDK, ABI and host admission rules apply? | the CMake toolchain or host (CMAKE_SYSTEM_NAME, EMSCRIPTEN, ANDROID, the iOS toolchain) | CNA::TargetPlatform, CNA_TARGET_APPLE/CNA_TARGET_IOS/CNA_TARGET_MACOS |
| Platform implementation | Who owns windows, events, input devices, time, system services, GL contexts, Vulkan surfaces and CPU-frame presentation? | CNA_PLATFORM (default SDL3 on every OS) | CNA_PLATFORM_<NAME>; CNA::Platform::IPlatform |
| Graphics renderer | Who turns draw calls into pixels? | CNA_GRAPHICS_RENDERER and optionally CNA_GRAPHICS_RENDERERS; at run time GraphicsRendererSelection | the default identity's CNA_RENDERER_<X>; the latched active renderer |
| Audio implementation | Who opens playback and capture devices, and (for two values) mixes? | CNA_AUDIO_PLATFORM (default SDL3) | SOUND_ENABLED for SDL3 and ALSA only |
| Framework surface | What does the game program against? | the headers it includes | Microsoft::Xna::Framework, optional CNAEXT (CNA_CNAEXT) and the experimental C ABI |
The first four are native-service choices; the fifth is the stable application-facing shape above them. Only the fifth is meant to stay the same when the others change, which is the whole point of the separation: a game written against Game, SpriteBatch and ContentManager should not need to know which of the seven platform implementations delivered its events.
target OS / CNA_PLATFORM renderer set + CNA_AUDIO_PLATFORM
toolchain (windows, events, default identity (playback, capture,
input, services) (runtime-active one) optional mixer)
| | | |
+--------------------+---------+----------+----------------------+
v
CMake admission: PlatformSelection, AudioPlatformSelection,
SdlAvailability, Sdl2OnlyConfiguration, RendererSelection,
RendererCombinations (refuse, never fall back)
|
v
one binary: one IPlatform, a compiled renderer registry,
one audio implementation, the XNA-shaped framework (+CNAEXT, C ABI)
|
+---------------------+----------------------+
v v
execution on a named host with behaviour / pixel oracle
the intended platform and renderer (only where one is retained)
actually engaged
Meaningful combinations, and the ones that are refused
Because the axes are separate, combinations that a single "backend" name could not express are ordinary configurations: HEADLESS platform with NULL audio and the HEADLESS renderer for a server or a test process; the SDL3 platform with NULL audio for a deterministic graphics test; TERMINAL with SOFTWARE for a POSIX terminal program; native X11 with ALSA audio and CNA_ENABLE_SDL=OFF for a build that contains no SDL at all; or the real SDL 2.30 platform with SDL2 audio while the rest of the framework stays current.
Independence does not mean every Cartesian product is buildable. The configure step refuses, by name and without fallback:
- mixing SDL major versions in one process:
SDL2platform withSDL3audio (so-DCNA_PLATFORM=SDL2alone fails, because audio defaults toSDL3),SDL3platform withSDL2audio, and an SDL2-only configuration whose renderer links SDL3 by identity (Sdl2OnlyConfiguration.cmake); - any selection that genuinely needs SDL when
CNA_ENABLE_SDL=OFF: theSDL3/SDL2platforms and audio values and the renderersSDL_RENDERER,SDL_GPU,FNA3DandFREEDIRECT(SdlAvailability.cmake); TERMINALwith anything but the CPU or no-output renderersSOFTWARE,PORTABLEGL,HEADLESSandSTUB;- host-exclusive values on the wrong host:
WIN32off Windows,TERMINALon Windows,X11/WAYLANDwithout their development packages,ALSAoff Linux, and the OS partitions of the renderer set (five Windows-only identities, five Emscripten-only ones, macOS-onlyMETAL;RendererCombinations.cmake); - the reserved identifiers
SDL12andEMSCRIPTEN(platform) andOPENALandWASAPI(audio), which are recognised names with no implementation.
The refusals are a feature of the evidence, not only of the build: a refused pair fails at configure time with a message naming the missing capability or package, so it can never surface later as a null window, a missing presenter or a renderer silently drawing through a different route. A selection that is admitted can still fail at run time when the host lacks a driver; the evidence section separates those facts. The whole rule set, with the recipes, is on the platforms guide and Tutorial 127.
What the platform contract owns
modules/platform owns the namespace CNA::Platform. Its root interface, IPlatform, groups six kinds of service: refcounted acquisition of the video, audio, gamepad, haptic and sensor subsystems; window creation and adoption (by id or by native handle) with a stable window identity; batched event polling; monotonic counters, millisecond ticks and delay; the input and system services (keyboard, mouse, text input, gamepad, joystick, haptics, sensors, input devices, clipboard, primary selection, displays, dialogs, tray, camera, file system, system information); and the optional graphics bridges (GetGlContext(), GetVulkanSurface(), CreateSurfacePresenter()).
One instance, created once
PlatformFactory is the single place an implementation is chosen: Create() returns the build-time default, and the header records that it is called once at startup and that the pointer is never replaced while windows, renderers and input devices exist. Create(name) and GetAvailable() exist because more than one implementation can be compiled in: HEADLESS is always compiled as a conformance reference and TERMINAL on every non-Windows target, so the conformance suite can run two implementations side by side in one process. There is no environment variable that swaps the implementation at run time.
Much of the XNA API is static (Keyboard::GetState(), StorageDevice, TitleContainer) and cannot receive a platform argument, so CurrentPlatform.hpp provides an ambient accessor: GetCurrentPlatform() lazily creates the PlatformFactory::Create() default on first use, and SetCurrentPlatform() lets Game install its own instance at startup (and tests install HeadlessPlatform) so the whole process shares one. The header is explicit that this accessor is for the static API surface only; a Game owns its platform and passes it down. The startup order is traced on startup internals.
The host keeps its global policy
The contract has no Initialize()/Shutdown() pair, on purpose. CNA production code never calls SDL_Init() or SDL_Quit(); it acquires individual subsystems lazily through AcquireSubsystem() and lets the underlying library refcount them, so an application that already calls SDL_Quit() in its own main() keeps that control. ReleaseSubsystem() of a subsystem that was never acquired is a no-op rather than an error, because cleanup can legitimately run after a partial initialisation; normal owners still balance every successful acquisition. The native Windows backend follows the same principle for DPI: Win32DpiSupport.hpp never calls SetProcessDpiAwarenessContext, because DPI awareness is process-global and belongs to the application; the backend reads the awareness the host chose and reports coherent values for it.
No SDL in the public contract, enforced
The public platform headers expose no SDL type. The selected toolkit is linked privately, a configure-time ratchet (sdl_ratchet.py, strict by default through CNA_PLATFORM_RATCHET) rejects new direct SDL use outside the admitted boundary, and a hot-path lint (hot_path_lint.py) rejects platform calls inside per-pixel, per-vertex, per-sample and per-event loops. Two further shape rules make misuse hard rather than merely discouraged: GetCapabilities() is documented as read once and cached by the owning subsystem (calling it per frame is a defect), and PollEvents() fills a caller-owned vector once per frame instead of dispatching one virtual call per event.
The entry point is a build concern
Entrypoint.hpp must be included by the translation unit that defines main(). On Android, SDL's Java glue finds the game by dlsym() of a symbol literally named SDL_main; on iOS, UIKit owns the process and must start before the game's entry point runs. Both need a preprocessing-time rename that no run-time platform object could perform, so this cannot be an IPlatform method. Under HEADLESS, TERMINAL and WIN32 the header is empty, and those implementations report the managedEntrypoint capability as false; WIN32 deliberately imposes no WinMain, so a console or test host keeps its own main().
Implemented, host-limited and reserved values
Seven implementations exist at this snapshot. The table gives the boundary each one owns; the per-backend internals pages carry the mechanics.
CNA_PLATFORM | Offered when | Boundary it owns |
|---|---|---|
SDL3 (default) | always | SDL3 window, event, input and system-service adaptation; the only implementation any CI job configures on macOS, iOS, Android and the web (CMake also offers SDL2, HEADLESS and TERMINAL there, and X11 on macOS); mobile entry-point glue |
SDL2 | always | an independent implementation over real SDL 2.30, not SDL3 in a compatibility mode; deliberately narrow (no gamepad, clipboard or mouse-snapshot service) |
X11 | non-Windows, non-mobile, non-web targets with libX11, libXext and XKBlib.h | Xlib windows, XKB keyboard, XIM composition, ICCCM clipboard and PRIMARY with INCR transfers, XDND, pointer grabs, native handles and GLX/Vulkan bridges, no SDL anywhere |
WAYLAND | non-Windows, non-Apple, non-mobile, non-web targets with the Wayland client, xkbcommon, protocols and scanner | a compositor client with xdg-shell roles, text-input-v3, data devices, relative pointer and constraints, fractional scaling, EGL and Vulkan surfaces; never falls back through Xwayland |
WIN32 | Windows targets, native or MinGW cross | user32/gdi32 HWND creation and message pumping, raw keyboard and mouse input, UTF-16 converted to UTF-8 at the boundary, committed text input through WM_CHAR (the ime capability is deliberately false: no IME composition, no drag-and-drop, see CNA-GAP-058), clipboard, per-monitor DPI reporting, native handles for the Direct3D, Vulkan and OpenGL families; no gamepad |
HEADLESS | always (and always compiled) | one window object, no services, every capability false: the deterministic reference |
TERMINAL | non-Windows targets | POSIX termios/poll input, a Kitty keyboard-protocol probe and a CPU surface presenter onto the terminal |
A few boundary facts are worth knowing before porting a game, because they are design choices rather than gaps. X11 reports highDpi false by design (one coordinate space) and derives its scale from X resources and monitor data under an explicit policy. Wayland keeps a window's wl_surface alive across hide and show but destroys and recreates its xdg role (WaylandWindow.cpp, Hide/Show), because committing a buffer to an unconfigured role is a protocol error; a missing optional global becomes a capability refusal, not a null call; and there is no global pointer position on Wayland at all. Win32 converts every string at the boundary, so public strings stay UTF-8. Every "false" in the 32-flag capability set means the call refuses deterministically with PlatformNotSupportedException; the matrix is on the platforms guide and the backends are traced on X11, Wayland and Win32 internals.
Targeting Emscripten with the SDL3 implementation is a different thing from a native CNA_PLATFORM=EMSCRIPTEN backend: the latter is reserved and refused, so the browser build is SDL3 on a web target (see the web target). Likewise iOS and Android are target operating systems served by SDL3, not platform values.
Renderer needs meet platform capabilities
A renderer family does not assume that every platform hands it an SDL window. Its descriptor (GraphicsRendererDescriptor.hpp) declares what it needs, and GraphicsDevice resolves those needs against the platform once:
needsWindowand aRendererWindowKind:None(the no-window families),Plain(SDL_RENDERERand the Direct3D family),OpenGL,VulkanorMetal.AreWindowKindsCompatible()decides whether a window created for one family can be reused by another;needsGlContextandneedsVulkanSurface: the family is handed the platform's GL-context or Vulkan-surface service and never reaches throughIPlatformor resolves a native window itself;needsSurfacePresenter: a CPU rasteriser reaches a screen only throughIPlatformSurfacePresenter, which the platform builds where it reportssurfacePresentation(onTERMINAL, when standard output is a TTY).
That is why the same SOFTWARE renderer can present into an X11 window through XPutImage, into a terminal, or nowhere under HEADLESS, and why VULKAN cannot run on the SDL2 platform (no vulkanSurface). Some mismatches are caught at configure time (the TERMINAL renderer list, the SDL-linked families under CNA_ENABLE_SDL=OFF); others, such as a Vulkan family on a platform that reports no Vulkan surface, are refused when the device is created, with an exception rather than a null dereference. The selection-side mechanics are on renderer selection internals.
Runtime selection does not relax the rules
A multi-renderer build (CNA_GRAPHICS_RENDERERS) compiles several families, and every listed family must be admissible for the target and platform; the combination gates apply to the whole list. At run time GraphicsRendererSelection picks one, and the choice latches when the first GraphicsDevice is created: from then on the window kind, swap chain or CPU surface and every graphics resource belong to that renderer. Fallback to another compiled family is attempted only during that initial creation and only if the application enabled it (SetFallbackChain() or EnableAutomaticFallback(true)); GetFallbackHistory() records what happened. A live resource graph is never migrated to another renderer. See runtime renderer selection.
Audio remains a separate axis
The implemented CNA_AUDIO_PLATFORM values are SDL3 (SDL3_mixer), SDL2 (transport only), NULL (a paced silent device) and ALSA (Linux only; libasound.so.2 is loaded at run time and mixed by CNA's own mixer instead of SDL3_mixer); OPENAL and WASAPI are reserved and refused (AudioPlatformSelection.cmake). The independence is deliberate: a terminal or headless program may still use SDL3 audio, and an SDL3-windowed program may choose NULL. Two consequences are easy to miss. Only SDL3 and ALSA define SOUND_ENABLED, so under SDL2 and NULL the XNA audio facade compiles without a mixer and SoundEffect.Play() returns false. And Windows and macOS have no SDL-free audio value, so a fully SDL-free WIN32 build is silent (NULL). What each value does to the sound route is on the audio guide and Tutorial 137.
Target taxonomy answers a different question
The target vocabulary in TargetPlatform.hpp is a compile-time classification of the build, not of the platform implementation. CNA::TargetPlatform has four values, Desktop, Android, iOS and Web, chosen by getCurrentPlatform() from __EMSCRIPTEN__, __ANDROID__ and the Apple target conditionals; getCurrentPlatformName() refines it to "Web", "Android", "iOS", "macOS", "Windows", "Linux" or "Desktop", and DesktopOS.hpp separately enumerates Windows, Linux, MacOSX and Other. isMobilePlatform() is what makes Game's loop park while a mobile application is backgrounded. The target macros use the prefix CNA_TARGET_, not CNA_PLATFORM_, on purpose: the header records that the enumeration was once named CNA::Platform and collided with the platform namespace, and that the two axes must not share a prefix.
These values say where the binary is targeted. They do not say which IPlatform, renderer or audio implementation was composed. "Linux" admits SDL3, SDL2, X11, WAYLAND, HEADLESS and TERMINAL, each with a different renderer and audio envelope, so a phrase such as "the Linux renderer" or "works on Linux" is underspecified until the other axes are named. The core module's side is on core module internals.
A platform claim is an evidence vector
For any composed route, six facts can be established, and none follows from the one before it:
- a source path exists for this target, platform, renderer and audio choice;
- configuration admits the exact combination;
- it compiles and links;
- the artifact executes on a named native, emulated or translated host;
- the selected platform and the intended renderer or service actually engage;
- behaviour or pixels meet an identified oracle.
The gaps between the steps are concrete. A renderer descriptor can compile while its driver is unavailable. A Windows executable can run under Wine without loading the intended translation DLL, which is why the Direct3D wrappers refuse a run whose log lacks the translator's marker (see Windows cross-builds and Wine). A browser test can prove DOM structure without compositor pixels, and a link can succeed without any browser at all (see web renderer evidence). A platform conformance test can run on HEADLESS without exercising SDL. Reports that keep each coordinate separate, and keep "unsupported", "blocked" and "not attempted" apart, stay true when one coordinate changes.
| Route at this snapshot | Strongest automatic evidence | What it does not reach |
|---|---|---|
| Linux, SDL3 / SDL2 / native X11 / Headless / Terminal | platform-ci legs build and run the contract, window, event-oracle and terminal tests; native X11 runs with and without GPU renderers and with ALSA, with no SDL linked | real desktops and physical GPUs: every Linux job runs under Xvfb with Mesa software drivers |
| Linux, native Wayland | none: suites exist and run against local compositors only | any recorded automatic run |
| Windows | a MinGW cross-build of the Win32 platform harness run under Wine on every push | the full engine on Windows (manual MSVC workflows only), Windows drivers, multi-monitor; interactive IME is not an evidence gap but a missing feature (the backend reports ime false, CNA-GAP-058) |
| Web | a multi-renderer bundle build/link and the HTML_DOM browser suite in headless Chromium | WebGL, Canvas and SVG_DOM browser execution |
| macOS and iOS | workflows that build and launch an .app, build METAL, final-link an iOS device bundle and launch one simulator frame | physical iOS devices, Retina and frame-pacing observations |
| Android | none (no workflow, no preset) | everything at run time; see Android and Apple |
The Web, macOS and iOS cells state what those workflows are configured to do, not runs that passed. The Emscripten, HTML_DOM, Apple and Metal workflows (like the content-pipeline Windows workflow) pin August 2026 sharp-runtime revisions that register neither Resources nor Xml.Serialization, which CNA's default component closure now requests, so by a static reading each of them stops at configure time with 'Unknown Sharp Runtime component' (CNA-BUG-199); the per-workflow detail is on the platforms guide. Nothing on this page was built or run.
The platform architecture makes routes cleaner to compose and test; it does not retroactively promote older compile evidence into execution, hardware or oracle evidence. The general method is on compatibility and evidence.
Read in this order
PlatformSelection.cmake: the implemented, host-limited and reserved platform values and their refusal messages.IPlatform.hpp,PlatformFactory.hppandCurrentPlatform.hpp: the contract, its lifetime and the ambient accessor.PlatformCapabilities.hpp: the 32 capability flags and what "false" promises.GraphicsRendererDescriptor.hpp: what a renderer family asks of the platform.TargetPlatform.hpp: the target axis and why its macros have their own prefix.platform-ci.yml: which routes run automatically and on what host.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Platforms: four separate axes · Native platforms guide · Tutorial 127: choose platform, renderer and audio
- Architecture
- Platform architecture
- Internals
- Platform implementations internals · Renderer selection internals · Core module: target platform
- Maintainer workflow
- Modify a platform backend
- Tests and validation
- Test architecture