SDL2 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. Live display validation remains: a dummy video driver cannot show real window, drawable-size or presentation behaviour.

modules/platform/src/Sdl2 implements the same CNA::Platform::IPlatform contract as the SDL3 backend, but only a deliberately small part of it: windows and events, keyboard polling, display enumeration, an OpenGL context, timing, and portable file-system and system-information services. It has no mouse snapshot, controller, text-input, clipboard, Vulkan-surface, CPU-presenter or typed native-handle service at this snapshot. This page is for maintainers who change or extend it; the build rules that keep SDL2 and SDL3 out of one process are summarised for users in the Platforms guide. The presence of a backend's source files must not be read as feature parity with SDL3.

Lifetime and window ownership

How an SDL2 build is assembled

CNA_PLATFORM=SDL2 is in the available list on every target in PlatformSelection.cmake. Only after that selection does the root CMake file include ThirdPartySDL2.cmake, which fetches SDL 2.30.11 at the pinned commit in CNA_SDL2_GIT_TAG (or uses a local checkout named by CNA_SDL2_ROOT), so a default build never downloads a second toolkit. cna_platform links SDL2::SDL2 privately. Sdl2OnlyConfiguration.cmake then refuses the two mixed pairings, platform SDL2 with audio SDL3 (what -DCNA_PLATFORM=SDL2 alone produces, because audio defaults to SDL3) and platform SDL3 with audio SDL2, because the two libraries export identically named entry points and each backend's calls would bind to whichever library the loader reached first. With both axes on SDL2 it refuses a CNA_GRAPHICS_RENDERER among SDL_RENDERER, SDL_GPU, FNA3D and FREEDIRECT, which link SDL3 directly, and sets CNA_SDL2_ONLY_CONFIGURATION, which keeps SDL3-linked test and harness targets out of the build. CNA_ENABLE_SDL=OFF refuses SDL2 like SDL3.

The service boundary

Sdl2Platform.hpp owns exactly five service objects. Every other accessor returns null, and GetCapabilities reports six of the 32 flags true: multipleWindows, highDpi, multipleDisplays, borderlessFullscreen, openGlContext and exactKeyboardState. The source comment states the rule: a flag may only turn on in the same change that wires its accessor.

AccessorResult at this snapshot
GetKeyboardSdl2Platform::Keyboard (snapshot from SDL_GetKeyboardState)
GetDisplaysSdl2Displays (Sdl2SystemServices.cpp): display list, modes, window display, screen saver
GetGlContextSdl2Platform::GlContext
GetFileSystem, GetSystemInfoThe portable Common::StandardFileSystem and Common::StandardSystemInfo, not SDL; preference paths live under a cna-sdl2 directory in the system temporary directory, distinct per implementation so two platforms in one process cannot collide
GetMouse, GetGamepad, GetJoystick, GetTextInput, GetSensors, GetHaptics, GetInputDevices, GetClipboard, GetDialogs, GetTray, GetCamera, GetVulkanSurface, GetPrimarySelection (inherited default)nullptr
CreateSurfacePresenterThrows PlatformNotSupportedException(SurfacePresentation)

Subsystems

Sdl2Platform's constructor is defaulted: it acquires nothing and sets no hints. Sdl2Platform.cpp guards SDL state with its own function-local std::mutex, a separate and simpler lock than SDL3's owner-tracking SdlGlobalStateLock. AcquireSubsystem calls SDL_InitSubSystem (success is 0 in SDL2), maps Gamepad to SDL_INIT_GAMECONTROLLER, and counts only this instance's successful references; a failure is a PlatformException with SDL's error text, without SDL3's list of compiled video drivers. ReleaseSubsystem ignores unpaired releases, and the destructor calls SDL_QuitSubSystem once per owned reference. Neither calls global SDL_Quit() on a host's unrelated SDL state. There is no sensor or haptic cache to deactivate and no Xlib error handler; those protections exist only in the SDL3 backend.

Window creation and adoption

CreateWindow holds the mutex and translates WindowDescription into SDL2 flags: resizable, borderless, SDL_WINDOW_ALLOW_HIGHDPI, SDL_WINDOW_HIDDEN when invisible or fullscreen, SDL_WINDOW_OPENGL for OpenGL intent and SDL_WINDOW_VULKAN for Vulkan intent (Metal intent adds nothing). For OpenGL intent it calls SDL_GL_ResetAttributes() and then sets depth, stencil, double-buffer and the multisample pair unconditionally, zeros included. Centring is passed as SDL_WINDOWPOS_CENTERED at creation rather than applied afterwards. The result is wrapped in an owning Sdl2Window; minimum and maximum size are applied without error checks (the SDL2 calls return nothing). A requested fullscreen window starts hidden, changes mode, then shows.

AdoptWindow resolves a non-zero SDL window id; AdoptWindowHandle interprets the integer token as an SDL_Window* and validates the id round trip. Both create Sdl2Window(raw, false), whose destructor must not, and does not, call SDL_DestroyWindow.

What the window reports

Sdl2Window.cpp is thin. GetClientBounds returns SDL2's logical position and size with no last-known-value cache. GetPixelSize uses SDL_GL_GetDrawableSize and falls back to the logical size when that returns no positive drawable. GetDisplayScale is the width ratio of pixel to logical size, or 1.0 when either is zero. Sync calls SDL_PumpEvents, because SDL2 has no SDL_SyncWindow; it cannot fail, which is what the platform conformance suite expects of every Sync. Fullscreen is a direct flag mapping: borderless is SDL_WINDOW_FULLSCREEN_DESKTOP, exclusive is SDL_WINDOW_FULLSCREEN at the window's current display mode, and SetFullscreenMode is the one setter that throws. Unlike SDL3 there is no closest-mode search, no desktop-mode fallback and no special case for a size change while exclusive; SetSize is a plain SDL_SetWindowSize.

GetNativeHandle deliberately returns NativeWindowSystem::Unknown with no pointers, even though SDL2 has SDL_SysWMinfo: its platform union is not part of this backend's contract, and OpenGL needs only the SDL window id. A renderer that needs an HWND, XID or Wayland surface cannot invent a pointer cast around that contract; it must see the false nativeWindowHandle capability and refuse.

Event translation does not imply every public input service

Game::PollEvents → Sdl2Platform::PollEvents(batch)     batch.clear()
  SDL_PollEvent → QuitEvent | WindowEvent | KeyEvent | TextInputEvent
                | MouseMotionEvent | MouseButtonEvent | MouseWheelEvent
  → PlatformInputBridge::ProcessEvent, then Game's own handling
  → keyboard->Update()                 GetMouse() is null: no mouse Update()
Keyboard::GetState → Sdl2Platform::Keyboard snapshot
                     (SDL_GetKeyboardState + SDL_GetModState, taken in Update)
Mouse::GetState    → GetMouse() == nullptr → default-constructed MouseState

PollEvents maps seven SDL2 event families and drops everything else: text editing, touch, drops, device hotplug, controllers, sensors and application-lifecycle events never reach the batch. Window events map SHOWN and EXPOSED to Exposed, RESIZED to Resized, SIZE_CHANGED to PixelSizeChanged, plus focus gained/lost, CLOSE to CloseRequested, minimized, maximized, restored and moved. SDL2 has no dedicated drawable-size or display-scale event, so DisplayScaleChanged and DisplayChanged never occur on this backend, and the PixelSizeChanged it does emit comes from SDL2's generic size-changed notification rather than a measured drawable change. Game::PollEvents treats Resized and PixelSizeChanged identically, so the practical effect is a viewport refresh on every size change.

The mapper translates mouse and text events even though GetMouse and GetTextInput return null. That is not a contradiction: the events reach PlatformInputBridge, which updates the input manager's position, button and wheel accumulators, the mouse click callback and TextInputEXT characters, while the polled service stays unimplemented. Mouse::GetState reads the platform mouse snapshot, finds no service and returns a default-constructed MouseState; textInput is reported false. A maintainer must not document Mouse.GetState() as supported here merely because SDL_MOUSEMOTION is mapped. The general event-versus-snapshot distinction is traced in Input internals.

Wheel direction differs from SDL3's mapper: SDL2 explicitly negates both wheel axes when the event's direction is SDL_MOUSEWHEEL_FLIPPED, whereas the SDL3 mapper passes SDL's already-adjusted values through. On a host with natural scrolling the two backends can therefore report opposite wheel signs; this follows from reading both mappers and has not been observed on a host.

Keyboard polling runs when Game::PollEvents calls Update() after the batch loop: it reads SDL's scan array, maps each held scancode's key through ToKeyCode into CNA's virtual-key vocabulary, and reads modifiers from SDL_GetModState. Unlike SDL3 it does not de-duplicate keycodes that several scancodes map to (keypad Enter and Return both become Enter); Keyboard::GetState builds a set, so the duplicate is harmless there. ToKeyCode treats letters and digits as the contiguous ASCII ranges they are, and the function keys as two ranges: F1–F12 map to virtual keys 112–123 and F13–F24 restart at 124. A single SDLK_F1…SDLK_F24 range once swallowed the navigation, editing and keypad keys that SDL2 places between F12 and F13, so every arrow key reported as a function key; that defect is fixed, and the tests KeysBetweenF12AndF13AreNotTranslatedAsFunctionKeys and BothFunctionKeyRangesTranslateToTheirVirtualKeyCodes guard the edge that is easy to break when SDL2 and SDL3 translation are consolidated.

Timing is a thin adapter over SDL_GetPerformanceCounter, SDL_GetPerformanceFrequency, the 64-bit SDL_GetTicks64 and SDL_Delay; the fixed-step scheduling is Game's. SDL errors in subsystem acquisition, window creation, fullscreen and GL setup are wrapped as PlatformException. Optional services are null with false capability flags, so callers take a controlled fallback path. Do not add an optimistic capability flag until its accessor and conformance tests exist.

Only the OpenGL seam is implemented

Sdl2Platform::GlContext creates contexts under the mutex after SDL_GL_ResetAttributes(), setting version, profile, colour, depth, stencil, multisample, double-buffer, robust-access and reset-notification attributes from GlContextDescription. It destroys contexts with SDL_GL_DeleteContext, makes one current (throwing on failure), reports the current binding, swaps a window, forwards the swap interval as a status and returns proc addresses and a loader; GetContextAttributes reports what the driver granted. SwapBuffers cannot report a failure, because SDL2's swap call returns nothing. This is the seam the EasyGL identities and OPENGL4 consume through needsGlContext; the configure-time guard's own message recommends OPENGLES2, OPENGLES3 or OPENGL33 for an SDL2-only build.

There is no IPlatformVulkanSurface, no IPlatformSurfacePresenter and no typed native handle. WindowRenderIntent::Vulkan sets SDL_WINDOW_VULKAN in CreateWindow, but that does not implement CNA's Vulkan surface contract: GetVulkanSurface still returns null. The refusal therefore happens at renderer construction, after the window exists: GraphicsDevice::createRenderer passes the null service into the create arguments and the Vulkan renderer's RequirePlatformVulkanSurface throws PlatformNotSupportedException(VulkanSurface) (PlatformVulkanRendererState.hpp). Nothing at configure time rejects VULKAN with CNA_PLATFORM=SDL2; the guard's error text even lists VULKAN among the SDL-independent renderers to choose. The CPU renderers stay off-screen, because GraphicsDevice gives a CPU family a window only when the platform's presenter is its only display, and SDL2 reports no presentation. A backend that requires SDL3-specific GPU APIs cannot run here simply because both libraries contain "SDL" in their names; the configure guard rejects those families.

To extend SDL2, first decide whether the feature is an event (several are already mapped), a persistent input snapshot service, or a graphics or window capability. Implement its IPlatform* contract, preserve borrowed versus owning window semantics, update GetCapabilities in the same change, and add conformance plus live-host tests. Recheck the interaction with the SDL3 implementation, but do not assume the same native signatures: SDL2 returns 0 for success where SDL3 returns true, and several SDL2 window calls return nothing. An OpenGL change needs a real drawable and presentation test; the small SDL2 unit suite alone cannot prove pixels reach a display. The general recipe is Modify a platform backend.

Evidence and reading order

Sdl2PlatformTests.cpp holds five cases in the Sdl2PlatformTests suite: factory and capabilities (FactoryAndCapabilitiesDescribeTheImplementedBackend), translation of queued native events (NativeQueueEventsBecomePlatformEvents), and keyboard mapping across the F12/F13 boundary, both function-key ranges and the ASCII letter and digit ranges. Because SDL2 and SDL3 imported targets declare mutually exclusive interface requirements, cmake/UnitTests.cmake builds this file into its own executable, cna_platform_sdl2_tests, registered as the CTest entry CnaSdl2PlatformTests with SDL_VIDEODRIVER=dummy, and only when CNA_PLATFORM=SDL2. The configure-time rules have their own entry, CnaSdl2OnlyRendererGate, which runs Sdl2OnlyConfiguration.cmake in script mode against an SDL3-linked renderer, an SDL-independent one and both mixed pairings. The implementation-neutral conformance suite, parameterised over PlatformFactory::GetAvailable(), also runs against SDL2 in an SDL2 build.

⚠

These tests exist and their registration was read at 009d40f5; none was executed for this page. They do not establish parity with SDL3's gamepad, mouse polling, Vulkan or clipboard services, which the source explicitly omits, and a dummy video driver proves nothing about a live display: real-window, drawable-size and presentation behaviour still needs validation on a display-capable host. Use an OpenGL smoke test on a real display for window or context work, and check the test target index for the selected build's registration.

Read in this order

  1. Sdl2OnlyConfiguration.cmake and ThirdPartySDL2.cmake: which configurations may contain SDL2 at all, and where the library comes from.
  2. Sdl2Platform.hpp and Sdl2Platform.cpp: map the real service and null-accessor boundary before reading the event cases.
  3. Sdl2Window.cpp and Sdl2SystemServices.cpp: ownership, drawable scaling and display enumeration.
  4. Sdl2PlatformTests.cpp: which contracts have direct test evidence, and where a new service needs a new test.

The fuller SDL3 implementation of the same contract is on SDL3 platform internals; the map of all seven backends 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