Input internals

CNA snapshot 009d40f5  ·  Development › Input 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. Read from source at this snapshot. The named test files exist and their case counts were read; none was executed. A native Win32 end-to-end key path, real touch hardware and the X11 and Wayland input matrices are not established here.

CNA input runs on two parallel paths. Game::PollEvents hands every PlatformEvent to PlatformInputBridge, which drives callbacks, text input, touch, click notifications and a set of legacy accumulators; but the public Keyboard::GetState and Mouse::GetState never read those accumulators. They copy the snapshot that the selected platform's IPlatformKeyboard or IPlatformMouse publishes when Update() runs at the end of the same PollEvents. Confusing the two paths lets an event test pass while gameplay polling is broken, so this page is for maintainers who change a backend mapper, the bridge or a public input type. The user-facing API is on Input System; the surrounding frame is traced on One frame source trace.

Module contract and ownership

modules/input/CMakeLists.txt globs every .cpp under src/ recursively into cna_add_module(cna_input Input …) and links, all PUBLIC, cna_graphics_core, cna_math, cna_core and cna_platform, plus the Sharp Runtime partitions Core.Base and Collections.Core. It also sets LINK_INTERFACE_MULTIPLICITY 3: graphics core and input reference each other as static archives, and the file's comment records that a third archive contributed by a renderer family made two repetitions on the link line insufficient. The dependency direction is the point of the module: cna_platform defines the backend-neutral PlatformEvent alternatives and the input service interfaces and knows nothing about XNA types; cna_input turns that contract into XNA and CNA public values and callbacks. Every native input API — SDL, Win32, X11, Wayland, evdev, the terminal decoder — lives in cna_platform, and a backend alone is responsible for native key codes, pointer coordinates and device state. No file in the input module includes an SDL header.

Directory in modules/input/srcContents
XnaThe XNA-shaped public types of the Microsoft::Xna::Framework::Input namespace: Keyboard, Mouse, MouseCursor, GamePad and its value types, TextInputEXT, and Touch/ (TouchPanel, TouchCollection, TouchLocation, GestureSample).
CnaExtCNA extensions outside the XNA surface: Clipboard, Haptics, HapticDevice, InputDevices, Joysticks, Power, Sensors.
InternalSdlInputBridge.cpp (the file name is historical: it defines PlatformInputBridge::ProcessEvent and also the SdlInputBridge key-name and scancode helpers), InputManager.cpp (legacy keyboard and mouse accumulators) and GestureDetector.cpp.

Ownership. A Game owns exactly one platform in its platform_ member and installs it as the process-wide ambient platform before any other member is built (see the ownership tree). The public static input APIs cannot be handed a context, so each of them — Keyboard, Mouse, GamePad, Joysticks, InputDevices, TextInputEXT, TouchPanel::GetCapabilities — resolves CNA::Platform::GetCurrentPlatform() on every call and asks it for a service. While a Game is alive that is the newest live game's platform. With nothing installed, CurrentPlatform.cpp lazily creates and owns a default platform, so a call to Keyboard::GetState() before any Game exists, or after the last one is destroyed, constructs a platform as a side effect. The borrowed platform must therefore not be assumed to outlive its game.

The platform vocabulary: events, snapshots and who consumes them

PlatformEvent.hpp defines PlatformEvent as a std::variant of sixteen alternatives, delivered in batches through IPlatform::PollEvents. No SDL enumerator or type appears in it; the mapping from a native event lives in the backend. A key event carries both the layout-independent physical Scancode and the layout-dependent KeyCode, plus the focused window, a modifier bitmask, pressed and repeat. They are different concepts: bind-by-position games want the scancode, text fields want the keycode, and a mapper must not substitute one for the other. Two consumers see each batch, and neither handles every alternative.

AlternativePlatformInputBridge::ProcessEventGame::PollEvents (after the bridge)
KeyEventLegacy held-key set (repeats ignored); control-character synthesis for text inputF9 and F10 (not repeats) call the renderer's context-loss debug seam
TextInputEvent, TextEditingEvent, TextEditingCandidatesEventDelivered to TextInputEXT::TextInput, TextEditing and TextEditingCandidatesEXT—
MouseMotionEvent, MouseButtonEvent, MouseWheelEventLegacy position, button and wheel accumulators; Mouse::ClickedEXT; optional mouse-to-touch emulation—
TouchEventFinger-id mapping, TouchPanel event state, gesture detector—
DeviceEventMouse, keyboard and joystick connect and disconnect callbacks; other device kinds are not forwarded—
QuitEvent—Exit()
WindowEvent—Resize and focus handling, renderer surface invalidation, Exit() on CloseRequested
DropEvent—GameWindow::OnDropEXT
AppLifecycleEvent—Suspend, resume, low-memory log, terminate
SensorEvent, ControllerAxisEvent, ControllerButtonEvent——

The last row matters: sensor and mapped-controller state reaches the game through the services' snapshots, not through the bridge, so a gamepad event that a backend queues has no public effect by itself. The two snapshot contracts are equally small. IPlatformKeyboard.hpp publishes a KeyboardSnapshot: a vector of held KeyCode values plus a modifier bitmask. IPlatformMouse.hpp publishes a MouseSnapshot: the window whose client coordinates x and y use, cumulative scrollX and scrollY in XNA's 120-per-notch units, and a button mask whose bits 0 to 4 are left, middle, right, X1 and X2 (no native mask may pass through). Relative motion is deliberately not in the snapshot: it is drained through ConsumeRelativeDelta().

KeyCode and Keys agree by construction. Keyboard::GetState and the bridge both convert with a plain static_cast between CNA::Platform::KeyCode and the public Keys enumeration. That is safe only because both adopt the Windows virtual-key numbering, and KeyCodeMatchesXnaKeysTests.cpp (four cases) exists precisely because the platform module cannot include Keys and would not notice a drift. A backend that adds or renumbers a key code has to keep that file passing.

Which backend provides which input service

Every service accessor on IPlatform returns null when the matching capability is false, rather than a stub that silently does nothing. The public input APIs then degrade quietly instead of throwing: Keyboard::GetState returns an empty KeyboardState, Mouse::GetState a default MouseState, GamePad::GetState a disconnected state, the Joysticks and InputDevices queries empty lists, and the TextInputEXT start and stop calls do nothing. The table below was read from the accessors of each backend at this snapshot; the per-backend pages hold the detail.

PlatformKeyboard, mouseText inputGamepad, joystick
SDL3BothYesYes, acquired on the first accessor call (EnsureControllerSubsystem)
SDL2Keyboard only; GetMouse() is nullNoNo
X11BothYes (XIM)Through the evdev hub when compiled in; acquired on first use
WaylandBoth, but only while a compositor connection existsSame conditionThrough the evdev hub when compiled in; acquired on first use
Win32BothYes, as a per-window modeNone: both accessors return null
TerminalOnly when both standard input and output are terminalsNoNo
HeadlessNoneNoNo

Events and services are independent, which produces two easy-to-misread cases. SDL2 maps mouse, wheel and text events even though it has no mouse or text service, so the bridge updates its accumulators and TextInputEXT callbacks while Mouse::GetState keeps returning a default state. Headless has no event source of its own at all; a script that injects a KeyEvent reaches the bridge and never makes Keyboard::GetState report a key, because the keyboard service is null. Do not infer that a device works from an event alone. The capability table is on Platform Support.

Actual order in one Game frame

Game::Tick
  → Game::PollEvents()
      platform_->PollEvents(eventBatch_->events)          batch cleared, capacity reused
          SDL3:  SDL_PollEvent → MapSdlEvent → Sdl3Mouse::ObserveEvent (wheel totals)
                                             → Sdl3Joystick::ObserveEvent (hotplug) → push
          Win32: PeekMessageW → TranslateMessage → DispatchMessageW
                 → Win32EventMapper → pending_ → moved into the batch
      for each event, in order:
          PlatformInputBridge::ProcessEvent(event)         the input state machine first
          std::visit: Quit / CloseRequested → Exit(); F9/F10 debug hooks;
                      window, drop and lifecycle handling
      platform_->GetKeyboard()->Update()                   snapshot behind Keyboard::GetState
      platform_->GetMouse()->Update()                      snapshot behind Mouse::GetState
      if IsSubsystemInitialized(Gamepad):
          GetGamepad()->Update(); GetJoystick()->Update()
  → Game::Update, zero or more times
      user code: Keyboard / Mouse / GamePad / TouchPanel ::GetState()
      FrameworkDispatcher::Update → TouchPanel::Update (once a touch device exists)

The whole input pump is the tail of Game::PollEvents in Game.cpp. There is no separate Game::UpdateInput at this snapshot, although comments in Sdl3Platform.cpp and X11Platform.cpp still refer to the pump by that name. PollEvents runs once per Tick on the desktop loop, from RunOneFrame (which calls Tick), from WaitWhileSuspended on a suspended mobile game (so snapshots keep advancing while nothing updates or draws) and from the Emscripten frame body. A search of the TARGET tree for callers of PlatformInputBridge::ProcessEvent, excluding tests and examples, finds this one site only, so an embedding host that pumps a platform itself feeds no bridge.

Four consequences follow from the order. Input sees every event before Game acts on it, so a quit or close request that calls Exit() does not stop the rest of the batch from reaching the input state machine; Exit() is idempotent, which is why a close request and the quit a backend synthesizes from it in the same batch cost nothing. Snapshots advance once per PollEvents, not on every GetState call: a query before the first pump sees initial state, a query in Update sees the last completed pump, and when a slow Tick runs several fixed-step Update calls they all see the same snapshot. The exception is relative mouse motion, which is consume-on-read (see below), so the first read in a frame drains it. Controllers are pumped only after something has asked for them: reaching GetGamepad() or GetJoystick() is itself what acquires the subsystem on the platforms that do it lazily, so an unconditional pump would put the enumeration cost back at frame one.

GameEventSemanticsGoldenTests.cpp lists "Exit() does not stop draining the event batch; a wheel event after quit still lands" as one of four findings, and its checked-in transcript has cases named quit-then-wheel-in-one-frame and quit-then-focus-lost-in-one-frame. The test is parameterized over every platform in PlatformFactory::GetAvailable() and skips a platform that cannot back the build's renderer. Read it with care: the wheel total in that test is produced by the test's own scripted mouse service, so the case demonstrates that the snapshot update still runs after an exit request in the same batch; the bridge-before-visit order itself is what the source of PollEvents shows. The transcript also scripts F9 with and without repeat, but it records only IsActive, RunApplication, the client bounds and the wheel delta, so it cannot show the renderer seam being called. Touch state is the other per-update rather than per-frame item: TouchPanel::Update is reached through FrameworkDispatcher::Update (FrameworkDispatcher.cpp), which Game::Update calls once per update step, and it is skipped until a touch device is known to exist.

✎

Maintainer note — F9 and F10. After the bridge has seen a key press that is not a repeat, Game::PollEvents calls DebugSimulateContextLoss() on F9 and DebugRestoreContext() on F10 on the game's renderer. The switch has no build guard. The base implementations are no-ops, but several renderer families override them, so a game that binds F9 or F10 also triggers the renderer's debug seam there. The keys are still delivered to game code, because the bridge has already recorded them. Recorded from reading; not executed.

Keyboard: SDL3 versus Win32, and the public query

SDL3. Sdl3Platform::PollEvents drains SDL, and Sdl3EventMapper.cpp maps SDL_EVENT_KEY_DOWN/UP to one KeyEvent carrying the SDL scancode, the layout keycode, modifiers and repeat; the event type, not SDL's convenience down field, decides pressed, because injected events may leave the field stale (EventTypeWinsOverStaleNativeDownFields covers it). Separately, Sdl3Keyboard::Update in Sdl3InputServices.cpp makes one pass over SDL_GetKeyboardState, resolves each held scancode to its unmodified layout keycode with SDL_GetKeyFromScancode, converts it to a KeyCode, drops None, de-duplicates and reads the modifiers from SDL_GetModState. That pass is the source of public held-key polling. A wrong event mapping can break text and callback behaviour while Keyboard::GetState stays correct; a wrong snapshot conversion does the reverse.

Win32. Win32Platform.cpp drains the calling thread's whole message queue with PeekMessageW, calls TranslateMessage (which is what produces WM_CHAR after a key message) and DispatchMessageW; window procedures translate synchronously through Win32EventMapper.cpp into a pending list that is moved into the caller's batch. Win32EventMapper::TranslateKey reads the scan code and extended bit from lParam, resolves the generic Shift, Control and Alt virtual keys to the sided key (Shift through MapVirtualKeyW, Control and Alt through the extended flag), takes bit 30 of a press as auto-repeat, and deliberately returns "not handled" so DefWindowProcW still runs and Alt+F4 and menu mnemonics keep working. The mapper records the keys it has reported as held and, on WM_KILLFOCUS, emits a release for each with no modifiers, so an Alt+Tab cannot leave the event-derived held set stuck. The polled snapshot is independent: Win32Keyboard::Update in Win32InputServices.cpp calls GetKeyboardState and tests the high "held" bit, not the low toggle bit that stays set for as long as the Caps Lock light is on. Using the mapper's held vector as the public snapshot would ignore that timing and layout contract.

The public query. Keyboard.cpp takes GetCurrentPlatform().GetKeyboard(), copies GetSnapshot().pressedKeys (skipping KeyCode::None) into an independent KeyboardState value and returns an empty state when there is no service. GetState(PlayerIndex) ignores the index and calls the same function; the keyboard is not split by player. GetModStateEXT converts the snapshot's modifier bitmask. The name and scancode helpers (GetKeyFromScancodeEXT, GetScancodeNameEXT, GetKeyNameEXT and their reverse lookups) go through the SdlInputBridge functions, which ask the optional virtual members of IPlatformKeyboard first and fall back to CNA's own scancode tables.

What the bridge does with a key. In ProcessEvent a KeyEvent becomes a Keys value either by casting the keycode or, when scancode mode is on, through a scancode table; a key that maps to nothing is dropped before anything else happens. Presses that are auto-repeat do not touch the legacy held-key set, so InputManager::GetKeyboardState() keeps a repeating key down exactly once until its release. The same event drives text input: presses of Home, End, Backspace, Tab, Enter and Delete are delivered to TextInputEXT::TextInput as the control characters 2, 3, 8, 9, 13 and 127, and Ctrl+V as 22 with the literal text of the paste suppressed until the V or Ctrl key is released. None of this is gated on text input being active. The Ctrl test reads the legacy accumulator, which is why that accumulator is not dead code even though nothing public reads it.

Scancode mode. FNA_KEYBOARD_USE_SCANCODES=1 is read once per process into a function-local static, which is why the tests use an override hook and why the keyboard tests cannot flip it in a shared binary. At this snapshot it changes only the bridge's interpretation of events and the result of Keyboard::GetKeyFromScancodeEXT (which then returns the key unchanged). No platform keyboard service reads it, so it does not alter what Keyboard::GetState reports; that follows from a search of the platform sources, and the in-repo notes that describe scancode mode as layout-independent polling were not brought in line. Physical-position bindings therefore rely on the layout resolution in the platform snapshot, not on the environment variable.

⚠

The test-shim trap, concretely. The bridge tests — SdlInputBridgeKeyboardTests.cpp, SdlInputBridgeGoldenTests.cpp and InputResetTests.cpp — declare a local struct Keyboard whose GetState() returns InputManager::GetKeyboardState(), and a local struct Mouse reading InputManager::GetMouseState(). Their assertions read like public-API assertions but check the legacy accumulators. The public snapshot path is covered separately by KeyboardInputTests.cpp and MouseInputTests.cpp, which install a canned platform whose service publishes an explicit snapshot. A change is proven on both paths only when both files' cases pass.

Mouse state, coordinates and wheel ownership

SDL3. The mapper produces neutral motion, button and wheel events. Sdl3Mouse::ObserveEvent runs inside PollEvents, before the bridge, and accumulates wheel totals because SDL has no held-state wheel query: each event's float is cast to an integer number of notches first and then multiplied by 120, so sub-notch precision-wheel motion is discarded, and the total is clamped to the int range. Sdl3Mouse::Update reads SDL_GetMouseState for the position (truncated to integers) and buttons, repacks SDL's 1-based button flags into CNA's left, middle, right, X1, X2 mask, keeps the last window id when SDL reports no mouse focus and, in relative mode, adds SDL_GetRelativeMouseState to a delta that ConsumeRelativeDelta drains and zeroes.

Win32. The mapper tracks the pointer position, a held-button bitmask (buttons 1 to 5, including the two X buttons) and wheel totals. Its wheel events carry delta / WHEEL_DELTA notches, the horizontal sign is flipped so a horizontal tilt matches the SDL3 convention, and the totals it keeps are raw WHEEL_DELTA units accumulated without truncation, so a high-resolution wheel advances by fractions of 120 where the SDL3 snapshot would not. Win32Mouse::Update picks the focused CNA window (or any window), copies the mapper's buttons, wheel totals and position, and falls back to GetCursorPos and ScreenToClient when the mapper has no known pointer position, which is before the first motion and again after WM_MOUSELEAVE. Relative displacement comes from raw input into Win32Mouse's own accumulator. The two backends therefore share the MouseSnapshot contract without deriving every field identically, and the wheel granularity is a real difference between them.

The public query. Mouse.cpp builds MouseState from the service snapshot: scroll-wheel value from scrollY, the horizontal ScrollWheelValueEXT from scrollX, and the five buttons from mask bits 0 to 4. In absolute mode it converts the window-client position to logical game coordinates with the renderer registered for the window (windowId_ if GraphicsDevice published one, else the snapshot's window); with no renderer or no transform the coordinates pass through unchanged, which is also the windowless 1:1 fallback. That is why, under a virtual resolution with letterboxing, the reported position is a logical position and not a window pixel. Mouse::SetPosition applies the exact inverse before asking the platform to warp the pointer, and does nothing in relative mode. In relative mode GetState reports the drained delta as X and Y, so two consecutive reads are not equivalent: the first drains the motion and the second returns zero.

The bridge keeps its own copy for legacy tests. Its motion path converts through the same renderer transform, its wheel path uses the same whole-notch truncation, and it accumulates relative deltas only while InputManager::SetMouseRelativeMode(true) has been called, which nothing outside tests does. It also fires Mouse::ClickedEXT with a zero-based button index on every press and implements mouse-to-touch emulation (below). When debugging a wrong mouse coordinate, decide which layer is at fault: native client position, platform snapshot, renderer transform, or event-only emulation. Do not correct logical coordinates inside a native backend and then apply the transform a second time in Mouse.

The remaining mouse members go through the same service: SetCursor maps twelve shapes or a custom RGBA image, SetCaptureEXT, GetGlobalPositionEXT and WarpGlobalEXT use the desktop-space calls that terminal and Wayland refuse, and setIsRelativeMouseModeEXTProperty returns without effect when neither a published window nor a snapshot window is known. The per-backend details for X11, Wayland and the terminal decoder are on their platform pages.

Text, touch, devices and controllers

Text input

Committed text and key state are separate paths, and text is a mode. TextInputEXT::StartTextInput() (TextInputEXT.cpp) forwards to IPlatformTextInput::Start for the window that GraphicsDevice published as the active input surface, and does nothing when there is no service or no published window; a native start failure is swallowed to keep the void FNA contract. The SDL3 service starts the mode with SDL_StartTextInput. On Win32 the gate is explicit: the event mapper consumes every WM_CHAR without emitting a TextInputEvent until the window's text mode is on (control characters other than Tab, and DEL, are dropped even then, and surrogate pairs are reassembled), and the mapper's header comment says this matches what a game that never called StartTextInput sees on every other backend. The window handle and id are published when the device creates or adopts a window and cleared when it destroys one.

A TextInputEvent carries UTF-8. The bridge decodes it to UTF-16 and calls TextInput once per char16_t unit, so a code point above U+FFFF arrives as two calls; invalid lead bytes, truncated or overlong sequences and encoded surrogates become U+FFFD and decoding resynchronizes. TextEditingEvent reaches TextEditing with the composition text, cursor and selection length (both zero for empty text), and candidate lists reach TextEditingCandidatesEXT. Text is not synthesized from key presses, except for the control characters listed under the keyboard section, because doing so would lose IME composition and layout behaviour. Win32 reports IME as unsupported; composition events come from the SDL3 mapper, from Wayland's text-input v3 and from X11's opt-in composition, and the per-backend behaviour is on the platform pages.

Touch and gestures

A TouchEvent reaches the bridge with a native 64-bit finger id and normalized coordinates. The bridge maps each finger id to a small stable panel id (allocated upward from 1 in one process-wide map, erased on release or cancel), sets the sticky touch-device flag on a finger-down, and then makes two calls: TouchPanel::INTERNAL_setTouchState with a logical position (normalized coordinate times the event's client size, then through the renderer transform), and TouchPanel::INTERNAL_onTouchEvent for gestures, which scales the normalized value by TouchPanel's display size and drops the event while that size is unknown. GraphicsDevice publishes the display size at construction and on every reset.

TouchPanel.cpp owns the resulting state. TouchPanel::GetState() first looks at the slot arrays that SetFinger fills — the older polling path, which the real bridge does not feed — and otherwise reads the event map, ordered by touch id and truncated to MAX_TOUCHES (8), with previous state and position preserved for moved and released touches. Advancing that state is not the bridge's job. TouchPanel::Update, reached from FrameworkDispatcher::Update once per Game::Update, copies current values to previous, retires touches that were released, promotes pressed touches to moved and runs the gesture detector's timing pass. GetCapabilities() reports a maximum touch count of 4 (a fixed XNA compatibility value, not the tracking cap) or 0 when no touch device is known, and consults the platform's device enumeration, the sticky flag and live touches without changing state. While InputSuppressedEXT is set, which the gamer-services guide does around its overlays, GetState() is empty and the gesture queue is cleared and refuses new samples. MouseTouchEmulationEnabledEXT (off by default) makes the left mouse button produce a synthetic finger through the same two entry points.

GestureDetector.cpp is a state machine in process-wide file statics: idle, holding, held, just-tapped, three drag modes and pinching. It recognizes only enabled gestures, measures time with the monotonic clock (a manually advanced test clock exists), and uses a 35-pixel movement threshold, a 300 ms double-tap window, a one-second hold and a flick velocity floor of 100 with exponential smoothing. Which backends deliver TouchEvent at all is a platform question: SDL3 maps finger events, X11 and Wayland have touch services, and the SDL2 and Win32 mappers and the terminal and Headless platforms do not.

Devices and hotplug callbacks

DeviceEvent is the only route to the connect and disconnect callbacks. For mouse and keyboard the bridge invokes InputDevices::MouseConnectedEXT and its three siblings with the device id. For a joystick it invokes Joysticks::ConnectedEXT only if the joystick service reports the id connected and a process-wide announced set has not seen it, and DisconnectedEXT only for an id it announced. Gamepad, touch, haptic and sensor device events are not forwarded. InputDevices::GetMiceEXT, GetKeyboardsEXT and GetTouchDevicesEXT enumerate through IPlatformInputDevices and return an empty list when the platform cannot.

Controllers: player slots, device ids and lazy acquisition

GamePad.cpp maps PlayerIndex to a slot in a fixed bank of four (GamepadSlotCount); an out-of-range index returns a default state without touching the service, and only a valid slot calls GetGamepad(). GetState reads the slot's snapshot, applies the requested dead-zone mode to sticks and triggers, and casts the button bitmask to Buttons, whose values the platform's GamepadButton enumeration deliberately shares. Joysticks::GetStateEXT in Joysticks.cpp addresses raw devices by platform DeviceId narrowed to 32 bits and skips ids that do not fit. A logical player slot is not a hotplug id: never persist one as the other.

The lazy path is what keeps controller startup cost away from games that never read a pad. On SDL3, GetGamepad and GetJoystick call EnsureControllerSubsystem, which marks itself done before it tries, acquires PlatformSubsystem::Gamepad, absorbs a PlatformException (the services then report no devices, and IsSubsystemInitialized(Gamepad) tells a host the truth) and performs one initial update so the first query does not read an empty list; X11 and Wayland likewise acquire the evdev-backed subsystem on the first accessor call and run one immediate update. Game::PollEvents then pumps them only once the subsystem is up. SDL3 seeds its classic Linux joystick discovery hint for the same reason; see the SDL3 events section.

Threading and reset hooks

Input is a single-threaded, game-loop-thread API. The input module's source contains no mutex or atomic; InputManager, the bridge's finger map and joystick set, the gesture detector, TouchPanel's arrays, map and gesture queue, and the static Mouse::ClickedEXT, TextInputEXT and device callback lists are plain process-wide state. Writes come from PlatformInputBridge::ProcessEvent during PollEvents and reads from Update and Draw on the same thread, as docs/input-backend.md (section 6) and the InputManager class comment state. A backend that receives a callback on an OS or device worker thread must marshal it into PollEvents or synchronize explicitly; a service interface alone does not make these APIs safe for concurrent mutation. Whether the vendored System::MulticastAction behind the callback lists synchronizes internally was not checked. The wider map is on Thread and callback map.

InputManager::ResetAllForTests exists so fixtures are deterministic. In order it clears the bridge's file statics (scancode override, text-control flags, finger map and id counter, emulation state, announced joysticks), the accumulators, TouchPanel (arrays, event map, gesture queue, display size and window handle), the gesture detector, Mouse and TextInputEXT. It does not reset the InputDevices or Joysticks callback lists, which have their own ResetForTests. It is not a production lifecycle.

Human change and validation route

Decide first which layer a change concerns: physical scancode, layout keycode, committed text, or held-state snapshot. Update the native mapper and the platform service as needed, then the bridge only if it consumes that event, then the public type. A single test layer cannot prove both the event path and the snapshot path.

ChangeCheckTests to start with
Key mappingScancode versus keycode versus text; keep KeyCode and Keys numerically equalSdl3EventMapperTests.cpp, Win32EventMapperTests.cpp, Win32KeyCodeTests.cpp, KeyCodeMatchesXnaKeysTests.cpp, then KeyboardInputTests.cpp
MouseButtons, absolute and relative coordinates, wheel accumulation, the logical-to-window inverseSdl3InputServicesTests.cpp (32) and Win32PlatformTests.cpp (18) for service behaviour at different host depths, MouseInputTests.cpp (43 cases), MouseGlobalTests.cpp, SdlInputBridgeMouseTests.cpp
TextUTF-8 decoding, control synthesis, the start and stop mode, IME eventsSdlInputBridgeTextInputTests.cpp (25), SdlInputBridgeCandidatesTests.cpp, TextInputEXTTests.cpp (24)
Touch and gesturesDown, move and up, release cleanup, id mapping, gesture queue, mouse emulationTouchInputTests.cpp (50), TouchEdgeCaseTests.cpp (30), GestureDetectorTests.cpp (36), SdlInputBridgeTouchGestureTests.cpp, SdlInputBridgeMouseTouchEmulationTests.cpp
ControllersKeep lazy acquisition and the slot-versus-device-id distinctionGamePadInputTests.cpp, JoystickTests.cpp, InputDevicesHotplugTests.cpp
Frame orderBridge before visit, snapshots at the end of PollEventsGameEventSemanticsGoldenTests.cpp, and the platform conformance suite

These files exist at this snapshot and their macro counts were read; none was executed for this page. The input module's group builds as the focused CnaInputModuleTests target and the platform module's as CnaPlatformModuleTests through cmake/UnitTests.cmake. Two build conditions shape what a passing run means. The Win32 suites — the mapper, key-code and platform tests — are compiled only when CNA_PLATFORM=WIN32, so a Linux build never runs them; and the SDL3 service tests skip when the build's platform is not SDL3 or no display exists. A native Win32 end-to-end key path still requires a Windows host: the mapper tests drive synthetic message triples with no window or message loop, which proves the translation rules and not a real message pump. A change to a public XNA input signature also needs the signature-freeze tests (PublicApiInputSignatureFreezeTests.cpp, PublicApiInputCompileTests.cpp) and a review of the C ABI in CnaCApiInput.cpp and the language bindings. See Test architecture for the wider recipes.

Source reading order

  1. PlatformEvent.hpp, IPlatformKeyboard.hpp and IPlatformMouse.hpp — distinguish the event vocabulary from the snapshot services before tracing a backend.
  2. Game.cpp — follow PollEvents: bridge-before-visit, then the keyboard, mouse and gated controller updates.
  3. Sdl3EventMapper.cpp, Sdl3InputServices.cpp and Win32EventMapper.cpp — compare SDL event mapping, SDL snapshots and native Win32 translation; then Win32InputServices.cpp for the Win32 snapshots.
  4. SdlInputBridge.cpp and InputManager.cpp — the event side effects, and which state is legacy and test-facing.
  5. Keyboard.cpp, Mouse.cpp and TouchPanel.cpp — what a public query really observes and what it consumes.
  6. docs/input-backend.md — the module's own architecture and thread-safety notes; read it after the source, since a few sentences in it predate the service migration.

For where these paths sit in the whole loop, continue with One frame source trace and the Audio and input architecture map.

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