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 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Read from the platform sources, CMake and launchers at 009d40f5; nothing was built or run, and no native-host result (Xvfb, Weston, Wine, native Windows) is claimed.
CNA has seven implementations of one platform contract, 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 and silently wrong for the native X11, Wayland or Win32 backends, 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 009d40f5; 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 seven 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, Sdl2, X11, Wayland, Win32, 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, cmake/PlatformX11.cmake, cmake/PlatformWayland.cmake, modules/platform/CMakeLists.txt, modules/platform/src/PlatformFactory.cpp | Change build configuration; the selection script tests; the SDL-free lanes. |
| 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(): the selected SDL or native backend, 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. X11 and Wayland compute theirs once in the constructor; SDL3 and Win32 build 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 per-backend *IsSdlFree and Win32NoSdlTests source scans | Exposing an SDL_Window*, an HWND or a wl_proxy* through a contract type. |
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 omits X11 and Wayland; 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 | X11 | Wayland | Win32 |
|---|---|---|---|---|
| Event pump | SDL_PollEvent into a mapper | An ordered multiplexer: XPending/XNextEvent, then evdev, desktop-portal and tray pumps | Protocol callbacks enqueue; PollEvents moves them | PeekMessageW drain, dispatch to the window procedure |
| Logical versus pixel size | SDL pixel density | One coordinate space, scale always 1 | Compositor integer or fractional scale | Client rectangle is the drawable; scale from the window DPI |
| Size change | Synchronous request | Asynchronous; Sync() flushes | Asynchronous configure with a first-configure wait | SetWindowPos then Sync() drains this window's messages |
| Adoption | SDL windows by id or handle | Registered windows or an XID | Own windows only | By id or HWND |
| Capability timing | Built on each call from what is wired; the Vulkan probe is taken once and cached | Computed once in the constructor, after an early connection | Computed once in the constructor from the registry snapshot | Built on each call from constants and the Vulkan loader probe |
SDL2 is deliberately narrower (no mouse service, no text input, OpenGL as its only graphics seam), and Terminal is a byte-stream decoder with a nominal 8×16 pixel cell; neither is 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 backend's suites are compiled into the tree only when its implementation is selected, so a Linux build never runs the Win32 suites.
# the selected backend plus Headless (and Terminal on POSIX) in one binary; this mirrors the
# axes of the platform workflow's SDL-free X11 job (its own configure also sets CNA_ENABLE_NET=OFF,
# CNA_BUILD_EXAMPLES=ON and a private CNA_TEST_DISPLAY)
cmake -S . -B cmake-build-x11 -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DCNA_PLATFORM=X11 -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-x11 --target CnaPlatformModuleTests
./cmake-build-x11/CnaPlatformModuleTests \
--gtest_filter='EveryImplementation/PlatformConformance.*/X11:X11KeyCodeMapping.*' # from the repository root
ctest --test-dir cmake-build-x11 -N -L platform # what is registered, and needs what
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.
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, window manager, ibus), tools/platform/wayland_test_server.sh (private compositor), 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. Win32 has been measured by CNA under Wine on Linux; tools/platform/validate_win32_native.ps1 and the manual native-MSVC workflow are what establish per-monitor DPI, real keyboard and IME stacks, clipboard interoperability and device removal, and a Wine pass is not native-Windows evidence.
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 (for Wayland: bind the global in
WaylandConnection::Bindbehind itsCNA_WAYLAND_HAVE_*definition, then derive the flag inComputeCapabilitiesso the accessor is non-null exactly when it is true). - Translate at the edge. Native input becomes CNA enums and owning event values inside the backend. Do not let an
SDL_*, Xlib, Wayland or Win32 type or an event-lifetime string across the contract. X headers enter only throughX11Headers.hpp, and<windows.h>only throughWin32Common.hpp. - 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. Win32 does not set DPI awareness, timer resolution or the current directory; the application decides. 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 in the X11 or Wayland trees (and their shared Xkb, Freedesktop, Posix and Linux directories) 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*, Sdl2*, X11*, Wayland*, Win32*, and the shared Xkb/Freedesktop/Posix/Linux suites for X11 and Wayland only); 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. Native suites are split by what they need (CnaX11MappingTests needs nothing, CnaX11IntegrationTests a private Xvfb, CnaWaylandProtocolTests an in-process test compositor, CnaWaylandWestonTests a private Weston). Win32 has a separate standalone project, tools/platform/standalone_tests, that builds the platform module and its suite without sharp-runtime, SDL or a renderer, cross-built with mingw-w64 and run under Wine or built natively with MSVC. 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 X11 cell (
CNA_ENABLE_SDL=OFF) 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 seven 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 SDL2/SDL3 same-process exclusion, 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-179: Win32 EnterExclusiveFullscreen fails silently yet GetFullscreenMode reports ExclusiveFullscreen — EnterExclusiveFullscreen returns silently when a monitor-info, EnumDisplaySettingsW or ChangeDisplaySettingsExW step fails, though its comment says a refusal is reported; SetFullscreenMode still records ExclusiveFullscre
- CNA-BUG-180: Win32GlContext ignores multisample and per-channel format requests and reports the requested description as if it were granted — DescribeFormat ignores GlContextDescription's per-channel and multisample fields, and GetContextAttributes returns the requested description verbatim, so a legacy fallback context is reported as the requested version.
- CNA-BUG-196: CNA_X11_HAVE_VULKAN_HEADERS is defined by the X11 CMake but read by no source, so the dependency summary implies a Vulkan-header gate that does not exist — PlatformX11.cmake defines CNA_X11_HAVE_VULKAN_HEADERS and lists Vulkan headers as an optional dependency, but no X11 source reads the macro; the X11 Vulkan surface is created unconditionally and resolves its entry point
- CNA-BUG-237: Comments in Sdl3Platform.cpp and X11Platform.cpp refer to a Game::UpdateInput() that does not exist — Both native backends explain their 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 pu
- CNA-BUG-238: Win32Window's CnaPlatformWindow.Owned property is written and removed but never read, and its comment misdescribes AdoptWindowHandle — The kOwnedWindowProperty is set on every CNA-created HWND and removed on destroy but read nowhere; its comment says it lets AdoptWindowHandle refuse an owned window, whereas adoption returns a borrowed wrapper sharing th
- CNA-BUG-239: Two stale comments in the Win32 backend: a nonexistent spike path and a wrong Windows version — Win32DirectXIntegrationTests.cpp says real device and swapchain creation on the platform's handle is proved by spikes/win32-directx-spike/, a directory that has never existed (spikes/win32-spike covers window, message pu
- CNA-BUG-240: X11 header comments misdescribe the connection lifetime, the service accessors and the Xlib locking — X11Connection's comment says the connection opens on the first Video acquisition, but the platform opens it in its constructor; the accessor comments call services null before Video and AcquireSubsystem a thrower of Plat
- 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
- CNA-GAP-056: A foreign HWND adopted by handle is not subclassed and is absent from windows_, so the Win32 GL, Vulkan, relative-mouse and text-input services refuse it — AdoptWindowHandle wraps a foreign HWND without subclassing it or adding it to windows_, so it raises no CNA events and FindWindow cannot resolve its id; the WGL, Vulkan, relative-mouse and text-input services then reject
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- User guide
- Platform support: the seven implementations · Platform support: capability matrix · Native platforms: validation harnesses · Platform support: what CI covers
- Architecture
- Platform architecture
- Internals
- Platform backends · X11 platform internals · Wayland platform internals · Win32 platform internals · 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