I need to modify a platform backend

CNA snapshot 009d40f5  ·  Development › Maintainer Handbook  ·  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. 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 changingIt lives inEverything 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 behaviourmodules/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 wiringcmake/PlatformSelection.cmake, cmake/PlatformX11.cmake, cmake/PlatformWayland.cmake, modules/platform/CMakeLists.txt, modules/platform/src/PlatformFactory.cppChange build configuration; the selection script tests; the SDL-free lanes.
How the runtime consumes a platformGame.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>.

RuleHeld 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.EveryServiceIsNullExactlyWhenItsCapabilityIsFalseFlipping 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.AnUnsupportedCapabilityRefusesNamingItselfA presenter or adoption call that accepts and drops its input.
Capabilities are stable across calls and read once by the runtime.CapabilitiesAreStableAcrossCallsA 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, UnpairedReleaseIsANoOpQuitting 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, PollEventsIsSafeWithNoSubsystemAcquiredAppending 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, TicksAreMonotonicChanging 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, StateChangesFollowTheOperationalErrorContractSizing 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 scansExposing 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

ConcernOwnerConsumer and the trap
The platform objectGame 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).
WindowsThe 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.
EventsThe 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 stateThe 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 frameThe 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

  1. IPlatform.hpp, IPlatformWindow.hpp and PlatformCapabilities.hpp: the promises, before any backend.
  2. 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.
  3. PlatformConformanceTests.cpp: the behaviour any backend, new or old, must preserve.
  4. The backend page for the one you are changing, then the sibling that solves the same problem differently (below).
  5. Game.cpp: PollEvents, Tick and 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.

ConcernSDL3X11WaylandWin32
Event pumpSDL_PollEvent into a mapperAn ordered multiplexer: XPending/XNextEvent, then evdev, desktop-portal and tray pumpsProtocol callbacks enqueue; PollEvents moves themPeekMessageW drain, dispatch to the window procedure
Logical versus pixel sizeSDL pixel densityOne coordinate space, scale always 1Compositor integer or fractional scaleClient rectangle is the drawable; scale from the window DPI
Size changeSynchronous requestAsynchronous; Sync() flushesAsynchronous configure with a first-configure waitSetWindowPos then Sync() drains this window's messages
AdoptionSDL windows by id or handleRegistered windows or an XIDOwn windows onlyBy id or HWND
Capability timingBuilt on each call from what is wired; the Vulkan probe is taken once and cachedComputed once in the constructor, after an early connectionComputed once in the constructor from the registry snapshotBuilt 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::Bind behind its CNA_WAYLAND_HAVE_* definition, then derive the flag in ComputeCapabilities so 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 through X11Headers.hpp, and <windows.h> only through Win32Common.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 Delay from 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

  1. 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.
  2. 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).
  3. The event-semantics golden test is unchanged, or its transcript diff is deliberate and reviewed.
  4. 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.
  5. 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

ChangeAlso check
A contract method or capabilityAll seven backends; the platform capability matrix in the user guide (Platform Support); consumers that read the flag once at startup.
Event vocabularyEvery 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 scaleA 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 DelayFrame pacing for every game: Game::Tick, the fixed-step path and the sleep-precision estimate.
Selection or link inputsThe 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 EveryServiceIsNullExactlyWhenItsCapabilityIsFalse still 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?

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.