I need to modify a platform backend

CNA snapshot c1c316b9  ·  Development › Maintainer Handbook  ·  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. 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 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 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 behaviourmodules/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 wiringcmake/PlatformSelection.cmake, modules/platform/CMakeLists.txt, modules/platform/src/PlatformFactory.cppChange build configuration; the selection script tests; the SDL-free lane.
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(): 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>.

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. 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, 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 platform source gatesExposing 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

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 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.
  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.

ConcernSDL3HeadlessTerminal
Event pumpSDL_PollEvent into a mapper, the same code over every SDL video driverDrains a queue filled by InjectEventSIGWINCH flag, then one byte-stream decoder
Logical versus pixel sizeSDL pixel density; drawable from SDL_GetWindowSizeInPixelsIdentical; scale 1.0Nominal 8×16 pixels per cell; scale 1.0
AdoptionSDL windows by id or handleRefused (contract default)Refused (contract default)
Graphics servicesSdl3GlContext, Sdl3VulkanSurface, Sdl3SurfacePresenterNoneTerminalSurfacePresenter 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 inside src/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 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 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

  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 cell (CNA_ENABLE_SDL=OFF, Headless) 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 three 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 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.