I need to modify a platform backend
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. Read from the platform sources, CMake and launchers at c1c316b9; nothing was built or run, and no native-host result (Xvfb, Weston, Wine, native Windows) is claimed.
CNA has three implementations of one platform contract, SDL3, HEADLESS and TERMINAL, and most platform changes touch either the contract or exactly one of them. The risk is in the difference: a change that is correct for SDL3 under one video driver and silently wrong under another (SDL reaches Windows, X11, Wayland, macOS, iOS, Android and the browser through its own drivers), a refusal Headless or Terminal no longer makes truthfully, or a capability that is advertised before its service is complete. This recipe gives the routine for changing a backend or the contract safely: who owns which decision, how the conformance suite holds every backend to the same rules, how to compare a sibling backend, how a new suite is registered so it actually runs, and which mechanical gates a platform change must pass. Everything was read from the CNA source at snapshot c1c316b9; nothing was built or run.
The per-backend source tours are the pages under Platform backends; the contract itself is described on Platform architecture, and the input path on Input internals. This recipe links to them rather than repeating them.
Find the owner
Decide first which of four things you are changing. The answer fixes where the test goes and which siblings must move with you.
| You are changing | It lives in | Everything that must follow |
|---|---|---|
| The contract (a method, capability flag, event type, service interface) | modules/platform/include/CNA/Platform: IPlatform, IPlatformWindow, the service interfaces, PlatformCapabilities (32 flags, all defaulting to false), PlatformEvent (a std::variant of 16 alternatives) | All three implementations (or a false capability and a refusing accessor in each), the conformance suites, Game and the input bridge where they consume it, the golden event transcript, the C ABI if a public type changes. |
| One backend's behaviour | modules/platform/src/<Name> (Sdl3, Headless, Terminal) | Its own mapper and integration suites, the conformance suite, and a look at the sibling that solves the same problem differently. |
| Selection or build wiring | cmake/PlatformSelection.cmake, modules/platform/CMakeLists.txt, modules/platform/src/PlatformFactory.cpp | Change build configuration; the selection script tests; the SDL-free lane. |
| How the runtime consumes a platform | Game.cpp (PollEvents, Tick, ambient installation), SdlInputBridge.cpp (historical name; it defines PlatformInputBridge::ProcessEvent), GraphicsDevice.cpp (window and service acquisition) | Every implementation, through the parameterised event-semantics golden test; a backend-specific fix in the consumer is a smell. |
The contract a backend is held to
These rules are enforced by the implementation-neutral conformance suite in PlatformConformanceTests.cpp, which is parameterised over PlatformFactory::GetAvailable(): SDL3 when it is selected, Headless always, and Terminal on POSIX. Adding a backend to that list is what enrols it. Its test names take the form EveryImplementation/PlatformConformance.<Case>/<Backend>.
| Rule | Held by (case name) | What breaks it |
|---|---|---|
A service accessor is null exactly when its presence capability is false (dialogs: when neither messageBox nor nativeFileDialog). Keyboard and mouse flags describe quality, so they are exempt. | EveryServiceIsNullExactlyWhenItsCapabilityIsFalse | Flipping a flag before the accessor and its refusal path are complete; returning a stub for a service the host lacks. |
Unsupported means refused, naming the capability: PlatformNotSupportedException carrying the PlatformCapability; never a silent no-op. | AnUnsupportedCapabilityRefusesNamingItself | A presenter or adoption call that accepts and drops its input. |
| Capabilities are stable across calls and read once by the runtime. | CapabilitiesAreStableAcrossCalls | A capability that changes with live state after the first answer. SDL3 builds the set on each call from fixed conditions and a cached probe. |
| Subsystems are reference-counted; an unpaired release is a no-op; there is no platform-wide initialise or shutdown. | SubsystemsAreRefcounted, UnpairedReleaseIsANoOp | Quitting a global native library on release (SDL's refcount is process-wide, which is why CnaPlatformTests is shuffled and repeated). |
Events arrive in a caller-owned batch: PollEvents clears stale content and reuses capacity, and is safe with no subsystem acquired. | PollEventsClearsStaleCallerContent, PollEventsReusesCapacity, PollEventsIsSafeWithNoSubsystemAcquired | Appending to a stale batch; allocating per frame; requiring video before the first poll. |
Timing is monotonic, the frequency is non-zero, and Delay advances the counter. | PerformanceCounterIsMonotonic, PerformanceFrequencyIsNeverZero, DelayAdvancesTheCounter, TicksAreMonotonic | Changing a backend's clock or sleep changes frame pacing for every game on it, because Game::Tick sleeps with Delay(1) and then spins. |
Windows: logical size versus drawable pixels versus scale are distinct; a size change lands after Sync(); window ids are non-zero; a second window follows multipleWindows; destroying a window frees its slot; borderless fullscreen follows its capability; unsupported state changes follow the operational-error contract. | PlatformWindowConformance cases: SizeChangeLandsAfterSync, PixelSizeAndDisplayScaleAreSane, NativeHandleAgreesWithTheCapability, ASecondWindowFollowsTheMultipleWindowsCapability, DestroyingAWindowReleasesItsCapacity, BorderlessFullscreenFollowsItsCapability, StateChangesFollowTheOperationalErrorContract | Sizing a swapchain from logical bounds; reporting the old size after a set; leaking a window slot. |
| Native pointers, native event constants and event-lifetime strings stay inside the backend; the contract headers pull in no SDL, Vulkan or GL header. | ContractIsSdlFreeTests.EveryContractHeaderCompilesWithoutSdl; the platform source gates | Exposing an SDL_Window* or an SDL event through a contract type; a native handle crosses only as the tagged, non-owning NativeWindowHandle. |
A sibling comparison is useful but is not what the suite does: CNA's contract document describes SDL3 as the reference implementation, whereas the parameterised cases assert the same rules on each implementation and never diff one against another. Read SDL3 as the behavioural sibling, and the suite as the oracle.
Who owns window, event, input and present
| Concern | Owner | Consumer and the trap |
|---|---|---|
| The platform object | Game owns exactly one, as its first member, installed as the ambient platform before any other member is built, guarded by a PlatformInstallation scope, capabilities read once; outside a Game a lazily created default is immortal. | The static XNA input APIs resolve the ambient platform on every call. The platform must outlive the window and the device; a change to construction or destruction order is a lifetime change (Ownership and shutdown). |
| Windows | The backend's window wrapper: an owning window from CreateWindow, or a non-owning one from AdoptWindow/AdoptWindowHandle where supported (Headless and Terminal refuse). GraphicsDevice decides what kind of window a renderer needs from its descriptor. | A backend's NativeWindowHandle is a tagged, non-owning snapshot. A window opening with nothing drawn is often a renderer or descriptor question, not a platform one. |
| Events | The backend's mapper turns native input into PlatformEvent values inside PollEvents. | Game::PollEvents hands each event to PlatformInputBridge::ProcessEvent first and applies quit, window and lifecycle handling second, so an Exit() mid-batch does not hide later events from input. The golden test EveryImplementation/GameEventSemanticsGoldenTest pins this against platform-event-semantics.txt; regenerate it only deliberately, with CNA_UPDATE_EVENT_GOLDEN=1, and review the diff. |
| Input state | The backend's snapshot services (GetKeyboard()->Update(), mouse, and, once the Gamepad subsystem is up, gamepad and joystick), advanced once per frame at the end of PollEvents. | Events and snapshots fail independently: a key that reaches a text callback but not Keyboard::GetState is a snapshot-service bug; the reverse is a mapper or bridge bug. Sensor and mapped-controller state reaches the game only through snapshots. |
| Presenting a frame | The renderer, through one narrow service the descriptor asks for: a GL context, a Vulkan surface, or, for a CPU family, IPlatformSurfacePresenter. | A presenter is a one-finished-frame API, built only for a family whose descriptor sets needsSurfacePresenter and only where the platform reports surfacePresentation without a native window handle (today: SOFTWARE on Terminal attached to a TTY). A platform never draws. |
Read first
IPlatform.hpp,IPlatformWindow.hppandPlatformCapabilities.hpp: the promises, before any backend.docs/platform-abstraction.md: the contract's own rules and the twelve-step procedure for adding an implementation. Its implementation table lists the 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 are authoritative.PlatformConformanceTests.cpp: the behaviour any backend, new or old, must preserve.- The backend page for the one you are changing, then the sibling that solves the same problem differently (below).
Game.cpp:PollEvents,Tickand the platform installation, to see exactly what the consumer assumes.
Compare a sibling backend
Read the sibling for the same concern before deciding a behaviour is a bug. The differences below are deliberate, not accidents to normalise.
| Concern | SDL3 | Headless | Terminal |
|---|---|---|---|
| Event pump | SDL_PollEvent into a mapper, the same code over every SDL video driver | Drains a queue filled by InjectEvent | SIGWINCH flag, then one byte-stream decoder |
| Logical versus pixel size | SDL pixel density; drawable from SDL_GetWindowSizeInPixels | Identical; scale 1.0 | Nominal 8×16 pixels per cell; scale 1.0 |
| Adoption | SDL windows by id or handle | Refused (contract default) | Refused (contract default) |
| Graphics services | Sdl3GlContext, Sdl3VulkanSurface, Sdl3SurfacePresenter | None | TerminalSurfacePresenter only, and only on a TTY |
Inside SDL3 the comparable differences come from SDL’s video drivers: a bare Xvfb has no window manager, a Wayland compositor applies its own scale and configures windows asynchronously, and Windows scales by DPI. CnaPlatformSdl3X11Tests and CnaPlatformSdl3WaylandTests run the window suites under the x11 and wayland drivers on private servers, and Sdl3WindowTest.NativeHandleMatchesTheVideoDriver checks the handle each driver hands a renderer.
Terminal is a byte-stream decoder, not a sibling to normalise toward. A new event, service or key mapping needs the full set: extend the taxonomy helpers, every mapper, the bridge where it applies, and the golden transcript.
Reproduce narrowly
Select the backend at configure time and run only its cases. The Sdl3* suites are compiled into the tree only when SDL3 is selected, and the Terminal* suites only off Windows.
# Headless (and Terminal on POSIX) in one SDL-free binary; this mirrors the axes of the platform
# workflow's SDL-free job (its own configure uses ALSA audio)
cmake -S . -B cmake-build-headless -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DCNA_PLATFORM=HEADLESS -DCNA_ENABLE_SDL=OFF -DCNA_AUDIO_PLATFORM=NULL \
-DCNA_GRAPHICS_RENDERER=HEADLESS -DCNA_BUILD_TESTS=ON \
-DCNA_PLATFORM_CTEST_BINARY=CnaPlatformModuleTests
cmake --build cmake-build-headless --target CnaPlatformModuleTests
./cmake-build-headless/CnaPlatformModuleTests \
--gtest_filter='EveryImplementation/PlatformConformance.*' # from the repository root
ctest --test-dir cmake-build-headless -N -L platform # what is registered, and needs what
# SDL3 under its x11 and wayland drivers, in an SDL3 tree with tests enabled
ctest --test-dir <sdl3-tree> -R '^CnaPlatformSdl3(X11|Wayland)Tests$' --output-on-failure
CNA_PLATFORM_CTEST_BINARY=CnaPlatformModuleTests points the platform CTest entries at the focused binary, as that CI cell does; the binary does not contain the graphics and runtime suites, so it proves a smaller contract (the window-ownership and event-golden tests live in other modules). Read the exact selector and refusal rules in the reserved and refused identities table: a request outside the offered set is a configure error, never a fallback to SDL3, and the retired WIN32, X11 and WAYLAND values say to use SDL3.
Which host a result came from is part of the result. Suites that need a server go through launchers that start a private one and exit 77 (a skip, not a pass) when it is absent: tools/platform/x11_test_server.sh (private Xvfb), tools/platform/wayland_test_server.sh (private headless Weston), and tools/platform/run_gpu_tests_private.sh for a real GPU. A bare Xvfb has no window manager, so maximise, minimise, EWMH fullscreen and focus do not occur there and asserting them tests the environment. On Windows the windows driver is exercised by the native MSVC SDL3 job in platform-ci.yml, which runs only on manual dispatch.
Make the smallest change
- Contract first, then every backend. Change the neutral contract only if every platform needs the concept. Then implement it or refuse it in each backend, with a false capability, a null accessor and
PlatformNotSupportedException, and give Headless a truthful refusal or value first. - Capabilities start false. Flip a flag only when the accessor and its refusal behaviour are complete, and derive it where the service is created.
- Translate at the edge. Native input becomes CNA enums and owning event values inside the backend. Do not let an
SDL_*type or an event-lifetime string across the contract; SDL headers stay insidesrc/Sdl3. - Give every native resource an explicit owner and test constructor failure as well as teardown order. The window wrapper, its back-pointers and the platform have a fixed destruction order; a callback must not observe a destroyed owner.
- Keep host policy out of the backend. Process-wide settings such as timer resolution or the current directory are the application’s decision. Keep
Delayfrom changing process-global timer state. - Poll once per frame, never in a hot loop. No platform call inside a draw, audio or input inner loop, and no allocation once the batch reaches steady-state capacity.
Mechanical gates and where a suite is registered
A platform change runs the seven boundary gates listed in the contract document; they also run in the HEADLESS cell of the platform CI workflow, and two of them run at configure time when a Python 3 interpreter is found.
python3 tools/platform/sdl_inventory.py --check
python3 tools/platform/sdl_classify.py --check
python3 tools/platform/renderer_sdl_audit.py --check
python3 tools/platform/sdl_ratchet.py --check --strict
python3 tools/platform/hot_path_lint.py
python3 tools/platform/nonproduction_sdl_audit.py --check
python3 tools/platform/check_contract.py
The SDL ratchet keeps a checked-in budget that may go down and never up, and counts any SDL reference outside its allowlist (the platform module, the SDL3 audio implementation and its mixer, and the SDL-based renderer families) as a boundary regression; the hot-path lint rejects platform calls through a platform receiver inside per-pixel, per-vertex, per-sample or per-event loops, and a justified exception carries // CNA_PLATFORM_HOT_PATH_OK: <reason>. Configure-time options: CNA_PLATFORM_RATCHET and CNA_PLATFORM_RATCHET_STRICT (both on) and CNA_PLATFORM_HOT_PATH_LINT (on). The audits protect architecture and classification; a heuristic check under-reports by design, so a pass is not proof and a failure is real.
New test files must reach CTest, and that is the step most often forgotten. Backend-specific test sources are filtered out of every tree that did not select that backend by patterns in UnitTests.cmake (Sdl3* when SDL3 is not selected, Terminal* on Windows); a new backend's files need the same treatment, or every other configuration fails to link. The display-independent contract runs as CnaPlatformTests (shuffled and repeated three times) through a filter of suite-name tokens, and the window half as CnaPlatformWindowTests in its own process with SDL's dummy video driver; a new suite whose name matches none of those tokens is discovered as an individual case but is absent from the grouped entry, so extend the filter or add a dedicated registration with its launcher. Suites that need a real window system get dedicated entries that start a private server: CnaPlatformSdl3X11Tests (private Xvfb) and CnaPlatformSdl3WaylandTests (private headless Weston), each skipping (77) where its server is missing. The general test layout is on Test architecture, and the how-to for the tests themselves is Add a regression test.
Prove it
- The conformance case (or a new one) fails before the change on the backend in question, and the backend's own mapper test pins the translation.
- The neighbouring backends still pass the same suite: at least Headless and the sibling that solves the same problem, in a tree that selected each (a conformance run in one tree covers the selected backend, Headless and Terminal).
- The event-semantics golden test is unchanged, or its transcript diff is deliberate and reviewed.
- The seven gates pass, and, for anything touching a shared path, the SDL-free cell (
CNA_ENABLE_SDL=OFF, Headless) still configures and links without SDL. - A real-host smoke run with a renderer that exercises the changed seam (window, GL context, Vulkan surface or presenter): the host, the display server and the skip count stated. Where the change is host-specific, say plainly what was not run: native Windows, a real desktop, a real compositor, a physical device.
Check the blast radius
| Change | Also check |
|---|---|
| A contract method or capability | All three backends; the platform capability matrix in the user guide (Platform Support); consumers that read the flag once at startup. |
| Event vocabulary | Every mapper; the input bridge; Game::PollEvents; the golden transcript; the input suites (Input: change route); the signature-freeze tests if a public XNA type moves. |
| Window size or scale | A renderer's swapchain or framebuffer rebuild and UpdateViewportFromWindow (Fix a renderer bug); high-DPI descriptors; a minimise must not produce a 0×0 swapchain. |
Timing or Delay | Frame pacing for every game: Game::Tick, the fixed-step path and the sleep-precision estimate. |
| Selection or link inputs | The refusal paths, the SDL-free link closure gates, and the CI matrix (What CI actually covers). |
Review checklist
- Is every new capability flag matched by a non-null accessor and a refusing path on the backends that lack it, and does
EveryServiceIsNullExactlyWhenItsCapabilityIsFalsestill hold? - Does anything native (a pointer, a constant, a string that outlives its event) cross the contract?
- Does the change alter event order, batch clearing or snapshot timing, and is the golden transcript consistent with that?
- Are logical size, drawable pixels and scale still three separate values, with
Sync()respected? - Are ownership and destruction order stated and tested for constructor failure and for a window that outlives its platform?
- Do the seven gates pass, and is any hot-path exception justified in a comment?
- Is each new suite registered (source filter, grouped entry or launcher), and does the report distinguish skipped, environment-limited and real-host results?
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- 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.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-237: A comment in Sdl3Platform.cpp refers to a Game::UpdateInput() that does not exist — The SDL3 platform explains its lazy controller-subsystem start by saying Game::UpdateInput() pumps the input services only once the subsystem is initialised, but Game has no UpdateInput(); the per-frame controller pump l
- CNA-GAP-054: The terminal window title is stored but never emitted, so SetTitle has no visible effect — TerminalWindow::SetTitle records the title and defers the OSC emission to the session, but the session prologue emits no title sequence and nothing else writes one, so a terminal window title is never shown.
- CNA-GAP-055: TerminalSession does not restore or re-establish the terminal on job-control stop and continue (SIGTSTP/SIGCONT) — The session's restoring signal set covers termination and crash signals but not SIGTSTP/SIGCONT, so a Ctrl-Z that suspends a terminal game leaves the terminal in raw mode, the alternate screen and mouse reporting until t
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- User guide
- Platform support: the implementations · Platform support: capability matrix · Windows, X11 and Wayland: validation · Platform support: what CI covers
- Architecture
- Platform architecture
- Internals
- Platform backends · SDL3 platform internals · Headless platform internals · Input internals
- Maintainer workflow
- What to test after changing X · Thread and callback map · Change build configuration
- Tests and validation
- Test architecture and change recipes · Add a regression test
- Reference
- Selection axes index · Test target index