The cross-platform contract: axes, composition and evidence per route

CNA snapshot 009d40f5  ·  Deep Dives › Cross-platform engineering  ·  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 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:

InputQuestion it answersChosen byVisible in code as
Target operating systemWhich 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 implementationWho 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 rendererWho turns draw calls into pixels?CNA_GRAPHICS_RENDERER and optionally CNA_GRAPHICS_RENDERERS; at run time GraphicsRendererSelectionthe default identity's CNA_RENDERER_<X>; the latched active renderer
Audio implementationWho opens playback and capture devices, and (for two values) mixes?CNA_AUDIO_PLATFORM (default SDL3)SOUND_ENABLED for SDL3 and ALSA only
Framework surfaceWhat does the game program against?the headers it includesMicrosoft::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
Figure. Four configuration inputs (target OS, platform implementation, renderer set, audio implementation) flow into CMake's admission gates, which refuse invalid combinations instead of substituting another choice. An admitted configuration produces one binary carrying one platform instance, a compiled renderer registry, one audio implementation and the framework surface. Execution evidence and oracle evidence then apply only to that exact composed route on a named host; admission alone is not execution.

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: SDL2 platform with SDL3 audio (so -DCNA_PLATFORM=SDL2 alone fails, because audio defaults to SDL3), SDL3 platform with SDL2 audio, and an SDL2-only configuration whose renderer links SDL3 by identity (Sdl2OnlyConfiguration.cmake);
  • any selection that genuinely needs SDL when CNA_ENABLE_SDL=OFF: the SDL3/SDL2 platforms and audio values and the renderers SDL_RENDERER, SDL_GPU, FNA3D and FREEDIRECT (SdlAvailability.cmake);
  • TERMINAL with anything but the CPU or no-output renderers SOFTWARE, PORTABLEGL, HEADLESS and STUB;
  • host-exclusive values on the wrong host: WIN32 off Windows, TERMINAL on Windows, X11/WAYLAND without their development packages, ALSA off Linux, and the OS partitions of the renderer set (five Windows-only identities, five Emscripten-only ones, macOS-only METAL; RendererCombinations.cmake);
  • the reserved identifiers SDL12 and EMSCRIPTEN (platform) and OPENAL and WASAPI (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_PLATFORMOffered whenBoundary it owns
SDL3 (default)alwaysSDL3 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
SDL2alwaysan independent implementation over real SDL 2.30, not SDL3 in a compatibility mode; deliberately narrow (no gamepad, clipboard or mouse-snapshot service)
X11non-Windows, non-mobile, non-web targets with libX11, libXext and XKBlib.hXlib windows, XKB keyboard, XIM composition, ICCCM clipboard and PRIMARY with INCR transfers, XDND, pointer grabs, native handles and GLX/Vulkan bridges, no SDL anywhere
WAYLANDnon-Windows, non-Apple, non-mobile, non-web targets with the Wayland client, xkbcommon, protocols and scannera 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
WIN32Windows targets, native or MinGW crossuser32/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
HEADLESSalways (and always compiled)one window object, no services, every capability false: the deterministic reference
TERMINALnon-Windows targetsPOSIX 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:

  • needsWindow and a RendererWindowKind: None (the no-window families), Plain (SDL_RENDERER and the Direct3D family), OpenGL, Vulkan or Metal. AreWindowKindsCompatible() decides whether a window created for one family can be reused by another;
  • needsGlContext and needsVulkanSurface: the family is handed the platform's GL-context or Vulkan-surface service and never reaches through IPlatform or resolves a native window itself;
  • needsSurfacePresenter: a CPU rasteriser reaches a screen only through IPlatformSurfacePresenter, which the platform builds where it reports surfacePresentation (on TERMINAL, 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:

  1. a source path exists for this target, platform, renderer and audio choice;
  2. configuration admits the exact combination;
  3. it compiles and links;
  4. the artifact executes on a named native, emulated or translated host;
  5. the selected platform and the intended renderer or service actually engage;
  6. 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 snapshotStrongest automatic evidenceWhat it does not reach
Linux, SDL3 / SDL2 / native X11 / Headless / Terminalplatform-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 linkedreal desktops and physical GPUs: every Linux job runs under Xvfb with Mesa software drivers
Linux, native Waylandnone: suites exist and run against local compositors onlyany recorded automatic run
Windowsa MinGW cross-build of the Win32 platform harness run under Wine on every pushthe 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)
Weba multi-renderer bundle build/link and the HTML_DOM browser suite in headless ChromiumWebGL, Canvas and SVG_DOM browser execution
macOS and iOSworkflows that build and launch an .app, build METAL, final-link an iOS device bundle and launch one simulator framephysical iOS devices, Retina and frame-pacing observations
Androidnone (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

  1. PlatformSelection.cmake: the implemented, host-limited and reserved platform values and their refusal messages.
  2. IPlatform.hpp, PlatformFactory.hpp and CurrentPlatform.hpp: the contract, its lifetime and the ambient accessor.
  3. PlatformCapabilities.hpp: the 32 capability flags and what "false" promises.
  4. GraphicsRendererDescriptor.hpp: what a renderer family asks of the platform.
  5. TargetPlatform.hpp: the target axis and why its macros have their own prefix.
  6. platform-ci.yml: which routes run automatically and on what host.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Maintainer workflow
Modify a platform backend
Tests and validation
Test architecture