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

CNA snapshot c1c316b9  ·  Deep Dives › Cross-platform engineering  ·  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. Checked by reading the platform module headers, the CMake selection files, the renderer descriptor and the workflow files at c1c316b9; 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 three platform implementations, or which of SDL’s video drivers, 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, 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; or HEADLESS with ALSA audio and CNA_ENABLE_SDL=OFF for a Linux build that contains no SDL at all.

Independence does not mean every Cartesian product is buildable. The configure step refuses, by name and without fallback:

  • any selection that genuinely needs SDL when CNA_ENABLE_SDL=OFF: the SDL3 platform and audio values and the three SDL-linking renderers SDL_RENDERER, SDL_GPU and FNA3D (SdlAvailability.cmake);
  • TERMINAL with anything but the CPU or no-output renderers SOFTWARE, HEADLESS and STUB;
  • host-exclusive values on the wrong host: TERMINAL on Windows, ALSA off Linux, and the OS partitions of the renderer set (the Windows-only DIRECTX9 and DIRECTX11, the Emscripten-only WEBGL2, the Apple-only METAL; RendererCombinations.cmake);
  • the reserved identifiers SDL12 and EMSCRIPTEN (platform) and OPENAL and WASAPI (audio), which are recognised names with no implementation, and the retired platform values WIN32, X11 and WAYLAND, whose message says to configure SDL3, which reaches those window systems through its own video drivers.

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.

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. Because the header is compiled in the game's own translation unit, CNA::BuildConfig exports the CNA_PLATFORM_<NAME> definition the header keys off to consumers too, so a game that adds CNA as a subdirectory gets the rename (PlatformSelection.cmake). Under HEADLESS and TERMINAL the header is empty, and those implementations report the managedEntrypoint capability as false.

Implemented, host-limited and reserved values

Three 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 on every window system SDL drives (Windows, X11, Wayland, macOS, iOS, Android and the browser, through SDL’s own video drivers); native handles for the renderers that need them; the only implementation any CI job configures on macOS, iOS, Android and the web (CMake also offers HEADLESS and TERMINAL there); mobile entry-point glue
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

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 the SDL3, Headless and Terminal internals pages. Under SDL3 some answers depend on the video driver SDL chose: the primary selection exists only on X11 and Wayland desktops, and the native handle a renderer receives is the one that driver created (an HWND, an X11 display and window, or a Wayland display and surface).

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, and GraphicsDevice gives it a presenter window only where the platform reports surfacePresentation without nativeWindowHandle, which today means TERMINAL when standard output is a TTY.

That is why the same SOFTWARE renderer presents into a terminal but stays off-screen on SDL3, even though SDL3 implements a presenter: that platform offers a native window handle, so the device treats the presenter as not the only route to a screen and creates no window for a CPU renderer (GraphicsDevice.cpp, createOrAttachWindow). Under HEADLESS it presents nowhere. Likewise, VULKAN needs a platform that reports vulkanSurface (SDL3 reports it only when its SDL has Vulkan support). 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), 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 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 an SDL-free build there 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 (over SDL’s X11 or Wayland driver), 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 run can prove which renderer was selected 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 / Headless / Terminalplatform-ci legs build and run the contract, window, event-oracle and terminal tests, including SDL3 on a private Xvfb and on a private headless Weston; an SDL-free Headless lane with ALSA audio links no SDLreal desktops and physical GPUs: every Linux job runs on private virtual displays with Mesa software drivers
Windowsnone automatic: a native MSVC SDL3 platform job and a native MSVC DIRECTX11 job run on manual dispatch (CNA records the latter passing all 300 registered CTest entries on 2026-10-06)Windows drivers on physical GPUs, multi-monitor setups, repeatable CI
Weba multi-renderer bundle build/link (WEBGL2, WEBGPU), built and inspected, not runbrowser execution of any of them (one headless-Chrome run is recorded by hand, outside CI)
macOS and iOSworkflows that build and launch an .app, build METAL, build iOS device and simulator bundles for SDL_RENDERER and METAL 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; each workflow takes sharp-runtime from the branch matching its own. 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