SDL3 platform internals

CNA snapshot 009d40f5  ·  Development › Platform internals  ·  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. The named tests exist and their CTest registration was read; none was executed. Real-host behaviour (windows, focus, DPI, fullscreen and presentation on real desktops and GPUs) and the optional services (tray, camera, dialogs, sensors, haptics) still need validation on hosts that have them.

modules/platform/src/Sdl3 implements CNA::Platform::IPlatform on SDL3: windows, events, keyboard and mouse snapshots, controllers, timing, native handles and the OpenGL, Vulkan and CPU-presentation services a renderer asks for. It is selected at configure time with CNA_PLATFORM=SDL3 (the default on every operating system), and a separate renderer identity decides how pixels are produced. This page is for maintainers changing the SDL3 backend itself; how a game chooses a platform is in the Platforms guide, and which capabilities each backend reports is in its capability matrix.

Object and ownership map

Game  (owns unique_ptr<IPlatform>, installs it as the current platform)
  └─ Sdl3Platform
       ├─ input services      Sdl3Keyboard  Sdl3Mouse  Sdl3Gamepad  Sdl3Joystick
       │                      Sdl3TextInput Sdl3Sensors Sdl3Haptics Sdl3InputDevices
       ├─ system services     Sdl3Clipboard Sdl3PrimarySelection Sdl3Displays
       │                      Sdl3FileSystem Sdl3SystemInfo Sdl3Dialogs
       │                      Sdl3Tray Sdl3CameraProvider
       ├─ graphics services   Sdl3GlContext  Sdl3VulkanSurface
       ├─ ownedRefCounts_     subsystem references THIS instance acquired
       ├─ CreateWindow        → Sdl3Window (ownsWindow = true,  destroys its SDL_Window)
       └─ AdoptWindow /
          AdoptWindowHandle   → Sdl3Window (ownsWindow = false, never destroys it)

GraphicsDevice → renderer descriptor → WindowDescription.renderIntent
               → CreateWindow or AdoptWindowHandle → the narrow services it declares

Sdl3Platform.hpp holds every service as a plain member object, so the accessors hand out raw pointers that are borrowed, never separately freed, and valid for the platform's lifetime. Most accessors are never null. Four follow the contract rule that a service is null exactly when its capability is false: GetPrimarySelection (X11/Wayland-capable Unix builds only), GetTray, GetCamera and GetVulkanSurface. GetGamepad and GetJoystick are never null but have a side effect, described under events.

Sdl3Window is the one place in the module that stores an SDL_Window*; everything outside receives a NativeWindowHandle value. CreateWindow returns an owning wrapper. AdoptWindow(WindowId) resolves an SDL window id with SDL_GetWindowFromID; AdoptWindowHandle(std::uintptr_t) interprets the integer as the SDL_Window* token that Sdl3Window::GetWindowHandle returns and validates it by round-tripping SDL_GetWindowID/SDL_GetWindowFromID. Both adoption paths construct Sdl3Window(raw, false), and ~Sdl3Window calls SDL_DestroyWindow only when it owns the window. A maintainer changing window lifetime must check the creation path and both adoption paths, because a destructor that is correct for one is wrong for the other. The main adoption caller is GraphicsDevice: when PresentationParameters carries a non-zero device window handle it calls AdoptWindowHandle and records that it does not own the window, so it will neither destroy it nor rebuild it for a fallback renderer candidate (GraphicsDevice.cpp).

Subsystems are acquired, counted per instance and never quit globally

The constructor acquires no SDL subsystem. It only seeds hints that must precede initialization, each only when the host has not set it: the Android back-button trap, the Linux classic joystick discovery hint (see events) and, on mobile targets, the full XNA orientation set. AcquireSubsystem takes the process-wide SDL lock, calls SDL_InitSubSystem and increments ownedRefCounts_ for this instance. ReleaseSubsystem treats an unpaired release as a documented no-op; when the last Sensor or Haptic reference goes, it first calls Deactivate() on the sensor or haptic service so a cached native handle is not closed after SDL has invalidated it. IsSubsystemInitialized reads SDL_WasInit, which is process-global: it reports a subsystem the host initialized too.

The destructor deactivates the sensor and haptic caches, then calls SDL_QuitSubSystem once for every reference this instance still owns. It deliberately never calls SDL_Quit(): global SDL lifetime belongs to the host application, which may hold subsystems of its own. The tests Sdl3PlatformTest.DestructorReleasesOnlyWhatThisInstanceAcquired and ConcurrentInstancesShareOneBalancedSubsystemLifecycle assert that balance against real SDL.

Reading the capability set is not free of side effects. GetCapabilities probes Vulkan with a temporary, balanced video reference, and asks Sdl3CameraProvider::IsSupported(), which starts SDL_INIT_CAMERA once per process when SDL has a camera driver and deliberately never quits it, because an SDL camera id is only meaningful inside the session that issued it (Sdl3Camera.cpp). Game's constructor reads the capability set once, so constructing a Game on such a host starts the camera subsystem outside ownedRefCounts_.

The Xlib error handler and the exit-under-lock invariant

When AcquireSubsystem(Video) succeeds, InstallX11NonFatalErrorHandlerOnce runs. If SDL's current video driver is x11, it resolves XSetErrorHandler through dlsym(RTLD_DEFAULT, …) and installs a handler that logs the protocol error to stderr and returns. The lookup is dynamic so an SDL3 build does not gain a mandatory Xlib link; on every other driver it does nothing, and XSetIOErrorHandler (a genuinely dead connection) is left alone. The handler mirrors XErrorEvent's ABI-frozen layout locally; its field order (resourceid before serial) is asserted by Sdl3XErrorHandlerTest.

The reason is a concrete shutdown hazard, and the current code guards it twice. Xlib's default handler calls exit(). When the failing request came from inside Sdl3Platform::CreateWindow, which holds the SDL global-state mutex across SDL_CreateWindow, exit() ran the static platform destructor, which locked the same non-recursive mutex on the same thread and never returned; depending only on timing, one X error either killed a test binary or hung it. Besides the non-fatal handler, ~Sdl3Platform now asks SdlGlobalStateHeldByThisThread() and skips the lock only when this very thread already holds it. That is safe precisely in that case: no other thread can be inside the critical section while this one holds it. It is not a general recommendation to make the mutex recursive; the source records that option as rejected because it would sanction re-entrancy in every other locked method. Sdl3PlatformExitUnderLockTest proves the invariant with two forked children, one holding the lock and a control that does not.

Window creation and render intent

GraphicsDevice, not Game, asks for the window. For the renderer candidate being created it acquires PlatformSubsystem::Video when the renderer descriptor says needsVideoSubsystem, then either adopts the caller's window (above) or fills a WindowDescription: resizable = false (XNA's AllowUserResizing default), highDpi from the descriptor's wantsHighDpi, renderIntent mapped from the descriptor's windowKind, and the OpenGL framebuffer bits. A renderer that needs no window (the CPU and headless families) gets none on SDL3, because SDL3 offers a native handle and GraphicsDevice keeps CPU renderers off-screen on windowing platforms.

Sdl3Platform::CreateWindow holds the SDL lock and translates the description into creation-time flags only: SDL_WINDOW_RESIZABLE, SDL_WINDOW_BORDERLESS, SDL_WINDOW_HIGH_PIXEL_DENSITY, and SDL_WINDOW_HIDDEN whenever the window is invisible or a fullscreen mode was requested. Render intent maps to SDL_WINDOW_OPENGL, SDL_WINDOW_VULKAN or SDL_WINDOW_METAL; None adds nothing. For OpenGL intent it first calls SDL_GL_ResetAttributes(), because GL attributes are process-global and survive window creation (a previous multisampled window would otherwise force MSAA onto a new zero-sample one), then sets depth, stencil and double-buffering when requested and always sets the multisample pair explicitly. That reset is also why GraphicsDevice passes the framebuffer requirement through WindowDescription: FNA3D primes the same attributes itself, and the reset erases that priming.

SDL_CreateWindow failure becomes a PlatformException carrying SDL's error text. A temporary unique_ptr<SDL_Window, decltype(&SDL_DestroyWindow)> protects the native window until Sdl3Window has taken ownership. Explicit position (when not centred) and minimum/maximum size are applied afterwards and throw on failure; a requested fullscreen mode is applied once a real window and display exist, and only then is the window shown, which avoids a visible windowed flash. When AcquireSubsystem(Video) itself fails, the exception text lists the video drivers compiled into that SDL build, which separates "this session is not running X11/Wayland" from "this SDL was configured without that backend".

The descriptor decides intent before any window exists. The five EasyGL identities (OPENGLES2, OPENGLES3, OPENGL33, WEBGL1, WEBGL2) and OPENGL4 declare an OpenGL window and set needsGlContext, so they later receive Sdl3GlContext; FNA3D also declares an OpenGL window but does not ask for the platform GL service; VULKAN declares a Vulkan window and sets needsVulkanSurface; METAL declares Metal. SDL_GPU declares a plain window and claims it inside the renderer with SDL_ClaimWindowForGPUDevice (SdlGpuRenderer.cpp), which needs no creation flag. Trying to "fix" a renderer by setting a creation-time flag after SDL_CreateWindow cannot work. The consumers are traced in renderer selection, EasyGL, Vulkan and SDL_gpu.

One event batch and two input paths

Game::Tick → Game::PollEvents
  → Sdl3Platform::PollEvents(batch)          batch.clear() keeps its capacity
      while SDL_PollEvent:
        MapSdlEvent(SDL_Event → value PlatformEvent)
        → Sdl3Mouse::ObserveEvent            wheel totals accumulate
        → Sdl3Joystick::ObserveEvent         joystick hotplug opens/closes devices
        → batch.push_back
  → for each event: PlatformInputBridge::ProcessEvent, then Game's own handling
  → keyboard->Update(), mouse->Update()     Sdl3Keyboard / Sdl3Mouse snapshots
  → gamepad->Update(), joystick->Update()   only once IsSubsystemInitialized(Gamepad)
Keyboard::GetState / Mouse::GetState → GetSnapshot() of those services

Sdl3Platform::PollEvents clears the caller-owned vector without discarding capacity, drains SDL, maps each accepted event and appends a value event; SDL events CNA does not consume return false from the mapper and are dropped. Sdl3EventMapper.cpp maps quit; thirteen window event types (SDL_EVENT_WINDOW_SHOWN and …_EXPOSED both become Exposed); key down/up; text input, text editing and editing candidates; mouse motion, button and wheel; finger down/up/motion/cancel; the five drop events; gamepad, joystick, keyboard and mouse hotplug; sensor updates; gamepad axis and button; and the four application-lifecycle events (background, foreground, low memory, terminating). Details a maintainer must preserve:

  • It keeps SDL_EVENT_WINDOW_RESIZED (logical size) distinct from SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED (drawable size) and SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED; under high DPI the drawable can change while the logical size does not.
  • Candidate strings and drop payloads are copied into the value event, because SDL owns them only until the event is recycled. Drop coordinates are zeroed on begin and complete.
  • KeyEvent::pressed comes from the event type, not from SDL's down field, which injected events may leave stale.
  • Wheel values are passed through unchanged: SDL3 has already applied the host's natural-scrolling preference, and reversing it again would change established FNA/CNA behaviour. (The SDL2 backend negates flipped wheels; see SDL2 events.)
  • Touch events carry the window's logical client size, queried at mapping time; sensor events keep SDL's sensor_timestamp, not the queueing time; unmapped gamepad axes and buttons are dropped.

Two services observe the batch before Game sees it: Sdl3Mouse::ObserveEvent accumulates wheel totals (scroll is event-driven, not pollable) and Sdl3Joystick::ObserveEvent opens or closes a device on joystick hotplug. Then Game::PollEvents (Game.cpp) hands every event to PlatformInputBridge::ProcessEvent before its own handling, so a QuitEvent or CloseRequested that calls Exit() does not stop the rest of the batch reaching input state. Game acts on CloseRequested itself rather than waiting for SDL to synthesize a quit.

Do not confuse event notification with public polling state. Keyboard::GetState and Mouse::GetState read the snapshots returned by the platform services' GetSnapshot(), and those snapshots advance when Game::PollEvents calls Update() after the batch loop. Sdl3Keyboard::Update makes one pass over SDL_GetKeyboardState, maps each held scancode's unmodified key to a KeyCode (de-duplicated) and reads modifiers from SDL_GetModState. Sdl3Mouse::Update reads SDL_GetMouseState, repacks SDL's 1-based button mask into CNA's bit order and, in relative mode, accumulates SDL_GetRelativeMouseState deltas that ConsumeRelativeDelta drains. The bridge feeds callbacks, text input and the input manager's accumulators; it is not the source of these snapshots.

Controllers are lazy. GetGamepad and GetJoystick call EnsureControllerSubsystem, which runs at most once per instance: it marks itself done before trying, acquires the Gamepad subsystem, absorbs a PlatformException (the services then simply report no devices; a host can ask IsSubsystemInitialized(Gamepad)), and on success performs one initial Update() so the first query does not read an empty device list. Game::PollEvents pumps gamepads and joysticks only once that subsystem is initialized, so its per-frame pump is never what triggers the acquisition. On Linux (not Android or Emscripten) the constructor seeds SDL_HINT_JOYSTICK_LINUX_CLASSIC=1 unless the host set it; the source records that SDL's default event-device scan blocked a first GamePad.GetState() for about 1.6 seconds on the Linux reference host. Comments in Sdl3Platform.cpp still name a Game::UpdateInput(); no such function exists at this snapshot, and the gate lives in Game::PollEvents. The full event-to-public-state trace is in Input internals, and the per-frame order in One frame source trace.

Logical size, drawable size, DPI, focus and fullscreen

Sdl3Window.cpp keeps three measurements apart:

QuerySDL callFailure behaviourConsumer
GetClientBoundsSDL_GetWindowPosition, SDL_GetWindowSize (logical)Never throws; keeps the last successfully queried rectangle (seeded in the constructor)GameWindow.ClientBounds, read every frame and during event-driven refreshes
GetPixelSizeSDL_GetWindowSizeInPixels (drawable)Throws PlatformExceptionRenderer storage and swapchains, via GraphicsDevice::createRenderer
GetDisplayScaleSDL_GetWindowPixelDensityReturns 1.0 when SDL reports 0Logical-to-drawable transforms

GetDisplayScale deliberately returns SDL's pixel density, not SDL_GetWindowDisplayScale: the latter folds in the user's UI/content-scale preference and can be 1.25 for a window whose drawable has no extra pixels, which would shift every input-to-render transform. The mapper emits separate logical-resize, pixel-size and display-scale events, and Game::PollEvents answers all three the same way: GameWindow refreshes from the platform, GraphicsDevice::UpdateViewportFromWindow() re-reads the geometry and the renderer's OnSurfaceInvalidated runs. Width and height are never interchangeable between the logical and drawable spaces.

On Emscripten, GetPixelSize contains a guarded repair: after an Escape-driven exit from browser fullscreen, the DOM restores the canvas backing store before SDL updates its cached window size, so SDL keeps reporting the fullscreen size. On that one transition the window queries the canvas element size and CSS size, corrects SDL's logical size with SDL_SetWindowSize (SDL also scales browser pointer events by it) and returns the canvas size until SDL catches up. The transition guard exists because, in a pthread build, a DOM query from the game worker is a synchronous proxy to the main thread.

Fullscreen is policy, not a flag flip. SetFullscreenMode(BorderlessFullscreen) sets the desktop mode (SDL_SetWindowFullscreenMode(window, nullptr)) and then fullscreen. ExclusiveFullscreen maps to browser fullscreen on the Web (SDL defers it until a user gesture; a repeated pending request that sets no SDL error is tolerated), to the desktop mode on Android, and elsewhere to the closest real display mode from SDL_GetClosestFullscreenDisplayMode, honouring the window's high-density flag. If the display offers no such mode (an Xvfb screen, a compositor that publishes one mode, a phone-shaped 480×800 back buffer on a desktop) it falls back to the desktop mode instead of throwing, because XNA gives a game only the IsFullScreen boolean and no way to handle a refusal. SetSize on an exclusive-fullscreen window selects the closest mode the same way, since SDL_SetWindowSize has no effect in fullscreen; otherwise it calls SDL_SetWindowSize and throws on failure.

Sync() calls SDL_SyncWindow and deliberately ignores its result. The only false return is a timeout, which is ordinary on X11 and Wayland, where the compositor owns geometry. An earlier version threw here and turned an ordinary window-edge drag into an uncaught exception; the current code treats Sync as a best-effort ordering point, matching the contract and PlatformConformanceTests' EXPECT_NO_THROW(window_->Sync()). Title, resizability, border and show/hide/minimize/maximize/restore throw PlatformException on SDL failure; HasFocus and IsMinimized read window flags; minimum and maximum size are applied only at creation; cursor shape and visibility belong to Sdl3Mouse, not the window; SetSupportedOrientations acts only on mobile targets.

When diagnosing a resize bug, inspect both the mapped event and the window's current logical and pixel queries before blaming the renderer. When diagnosing a blank window, establish whether it was intentionally staged hidden, whether fullscreen and the final show completed, and whether SDL's video driver actually created the requested surface.

Native-handle and graphics-service boundaries

Sdl3Window::GetNativeHandle decides the window system from SDL_GetCurrentVideoDriver()'s name, not from whichever window property answers (Xwayland answers both X11 and Wayland queries), and then reads SDL window properties into the tagged NativeWindowHandle:

SDL driverNativeWindowSystemFields filled
windowsWin32window = HWND
x11X11display = Display*; windowId = the XID, read as a number property (an XID is an integer resource id, not an address)
waylandWaylanddisplay and surface
cocoaCocoawindow
androidAndroidwindow
emscriptenWebnone by design
dummy, offscreen, anything elseHeadlessnone, so a GPU renderer refuses deterministically instead of dereferencing a null it was told was valid

This is a borrowed description of the host window, not an ownership transfer. GraphicsDevice::createRenderer copies the handle, the pixel size and the display scale into the renderer's create arguments, and hands the renderer only the services its descriptor declares (needsGlContext, needsVulkanSurface, needsSurfacePresenter). A graphics backend must match the tag and lifetime rather than reinterpret every handle as void*.

Sdl3GraphicsServices.cpp implements the three seams:

  • Sdl3GlContext creates a context under the SDL lock: it resets GL attributes again, sets version, profile, colour, depth, stencil, multisample, double-buffer, robust-access and reset-notification attributes from GlContextDescription before SDL_GL_CreateContext (attributes set afterwards are silently ignored), and throws PlatformException on failure. It provides make-current, current binding, swap, proc address and a proc-address loader, and GetContextAttributes reports what the driver actually granted. On Emscripten, MakeCurrent first makes the real WebGL context current in Emscripten's thread-local state, because SDL's cached-current check can claim a context is current on a fresh Wasm pthread when it is not; SetSwapInterval returns true without acting there, because CNA's Asyncify loop registers no Emscripten main loop for SDL to retime.
  • Sdl3VulkanSurface returns SDL's required instance extensions inside a balanced SDL_Vulkan_LoadLibrary/UnloadLibrary lease, creates a surface for a live window id with SDL_Vulkan_CreateSurface, and destroys it with the instance the caller supplies (a null surface is a no-op). Surface handles cross the contract as std::uint64_t, because VkSurfaceKHR is a pointer on 64-bit targets and an integer on 32-bit ones.
  • Sdl3SurfacePresenter is the CPU-frame route. CreateSurfacePresenter dynamic_casts the window and throws PlatformException for anything that is not an Sdl3Window, preventing accidental use of a foreign platform's wrapper. The presenter is built on an SDL_Renderer with a streaming RGBA32 texture recreated on size change; frames pass the shared stride validation in SurfaceFrameValidation.hpp, and Letterbox, Overscan and Stretch map to SDL's logical presentation modes (None centres the frame, Native places it top-left).

HasVulkanSupport probes the loader even before normal startup: under the SDL lock it takes a temporary video reference, tries SDL_Vulkan_LoadLibrary, unloads it and releases video, so an early GetCapabilities() neither caches the misleading "video not initialized" failure nor changes the host's state. The answer is cached per platform instance, so a host whose video subsystem cannot start at the first probe keeps vulkanSurface false for that instance.

Capabilities are conservative

GetCapabilities advertises a feature only when its service is wired: 28 of the 32 flags are unconditionally true; vulkanSurface follows the probe; primarySelection is true only on Unix builds that are neither Android nor Emscripten (only X11 and Wayland desktops have a real primary selection); tray is compiled in for Windows, Linux and macOS; camera needs an SDL camera driver whose subsystem actually starts. A new service should follow the capability-to-accessor invariant and gain a conformance test, not merely flip a boolean because SDL has a similarly named API. Sdl3PlatformTest.HostDependentCapabilitiesAgreeWithTheirServices and VulkanCapabilityProbePreservesTheHostsVideoSubsystemState hold that line; the user-facing values are in the capability matrix.

Timing, synchronization and failure model

GetPerformanceCounter, GetPerformanceFrequency, GetTicksMilliseconds and Delay adapt SDL_GetPerformanceCounter, SDL_GetPerformanceFrequency, SDL_GetTicks and SDL_Delay directly. They do not decide fixed versus variable timestep: Game::Tick sleeps with Delay(1) while it calibrates its own sleep precision, spins with std::this_thread::yield() for the remainder, and then polls events. SDL_GetTicks counts from SDL's initialization, not from platform creation as the IPlatform comment words it; Sdl3Platform.hpp documents that, and Game's timing uses the performance counter.

Sdl3Synchronization.hpp defines SdlGlobalStateLock, a guard over one heap-allocated, never-destroyed mutex that also records its owning thread. It serializes the process-global SDL state: subsystem counts (AcquireSubsystem, ReleaseSubsystem, IsSubsystemInitialized, the destructor), CreateWindow, the Vulkan probe, GL context creation and the Vulkan extension, create and destroy calls. Every acquisition must go through this type; a raw std::lock_guard on the same mutex would leave the owner unset and make the destructor's reentrancy check lie. It is not a promise that every public Game or graphics API is thread-safe, and MakeCurrent and SwapBuffers do not take it. The GL context lease that separates frame rendering from background uploads is a different mechanism, described in EasyGL renderer internals; the wider thread map is in Thread and callback map.

The failure model is split on purpose. Window, context, surface and presenter failures throw PlatformException with SDL's error text. Some operations return status instead: SetSwapInterval (a driver declining adaptive vsync must not abort a frame), Sync, GetClientBounds, the exclusive-mode search that falls back to the desktop mode, and the controller subsystem whose failure is absorbed. Optional services return null with a false capability. Preserve that distinction when adding an operation: turning an optional host feature into a startup exception changes framework behaviour for every game.

Validation and source-reading route

The SDL3 test sources are compiled into CnaTests only when CNA_PLATFORM=SDL3 (cmake/UnitTests.cmake filters them out otherwise), and every case is also discovered as its own CTest entry. Three grouped entries select them by filter:

CTest entryWhat it selectsEnvironment
CnaPlatformTestsThe display-independent contract: Sdl3PlatformTest, Sdl3EventMapperTests, Sdl3InputTest, Sdl3ServiceTest, the conformance suite, Headless, Terminal and current-platform suites--gtest_shuffle --gtest_repeat=3, SDL_AUDIODRIVER=dummy; shuffled because SDL's subsystem refcount is process-global and an imbalance shows up as order dependence
CnaPlatformWindowTestsSdl3WindowTest, Sdl3DisplayTest, Sdl3GraphicsServiceTest, Sdl3PresenterTest, PlatformWindowConformance and the graphics/game window ownership suitesOwn process with SDL_VIDEODRIVER=dummy, because inside the shared binary another suite has usually committed SDL to a driver already
CnaPlatformXErrorHandlerTestsSdl3XErrorHandlerTestA real X connection: SDL_VIDEODRIVER=x11, DISPLAY forced to the test display, 60 s timeout

The source files are Sdl3PlatformTests.cpp (subsystem balance, capabilities versus services, timing, controller laziness, the Linux joystick hint), Sdl3WindowTests.cpp (ownership and adoption, pixel versus logical size, fullscreen at a phone-shaped size, native-handle consistency), Sdl3EventMapperTests.cpp and Sdl3KeyCodeRoundTripTests.cpp (translation), Sdl3InputServicesTests.cpp and Sdl3InputDevicesTests.cpp (snapshots and devices), Sdl3DeviceServicesTests.cpp and Sdl3SystemServicesTests.cpp (sensors, haptics, displays and system services), Sdl3GraphicsServicesTests.cpp (the graphics seam and presenter), and Sdl3PlatformExitUnderLockTests.cpp with Sdl3XErrorHandlerTests.cpp (failure and shutdown). PlatformConformanceTests.cpp is parameterised over PlatformFactory::GetAvailable(), so an SDL3 build runs it for SDL3, Headless and, on POSIX, Terminal.

⚠

These tests exist and their registration was read at 009d40f5; none was executed for this page. A host with only SDL's dummy video driver cannot establish real window, focus, DPI or presentation behaviour by passing mapper and dummy-driver tests, and several cases skip without a display. Real-host behaviour and the optional services (tray, camera, dialogs, sensors, haptics) still need validation on the hosts that have them. For a window or native-handle change, run the targeted platform entries and then a real selected-renderer smoke test; the recipe is in Modify a platform backend and the change-to-test map in What to test after changing X.

Read in this order

  1. Sdl3Platform.hpp and Sdl3Platform.cpp: service ownership, subsystem balance, the X error handler, CreateWindow, adoption and the event batch.
  2. Sdl3Synchronization.hpp: the global lock and the owner-thread query the destructor depends on.
  3. Sdl3Window.cpp and Sdl3GraphicsServices.cpp: logical versus drawable dimensions, fullscreen policy, native handles and the GL, Vulkan and presenter seams.
  4. Sdl3EventMapper.cpp and Sdl3InputServices.cpp: compare value events with polled snapshots, especially during resize and focus changes.
  5. Game.cpp (Game::PollEvents) and GraphicsDevice.cpp (window creation, createRenderer): the two consumers that decide when the platform is pumped and which service a renderer receives.
  6. Sdl3PlatformTests.cpp and Sdl3GraphicsServicesTests.cpp: the contract assertions to extend before changing a service.

For comparison, the native backends solve the same contract without SDL: X11, Wayland and Win32; the map of all seven is on Platform backends.

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

Maintainer workflow
Modify a platform backend