Chapter 44 The Input System
CNA’s Microsoft::Xna::Framework::Input namespace covers keyboard, mouse, gamepad, and touch — and, layered underneath it, a set of CNA-only extensions with no XNA counterpart at all. The current source audit counts 522 input test cases and a compile-time strict-API freeze. That is substantial automated evidence, but it does not replace physical keyboard, mouse, gamepad, touchscreen, IME, and high-DPI verification.
44.1 Architecture: event-driven, not poll-driven
FNA queries SDL when GetState() is called. CNA caches state from the events drained by Game::PollEvents(). SdlInputBridge::ProcessEvent updates InputManager, TextInputEXT, and the touch gesture detector; the public GetState() methods read those snapshots. FrameworkDispatcher::Update() drives the per-frame touch gesture update.
Repeated reads without another event return the same keyboard, absolute-mouse, gamepad, and touch state. Relative mouse mode is the exception: the first Mouse::GetState() drains its motion accumulator, and a second read returns zero until more motion arrives. The event pump and state reads are single-threaded and unsynchronized; both belong on the game-loop/video thread.
Two ordinary keys have an additional framework side effect: F9 and F10 simulate graphics-context loss and restoration in Game::PollEvents(). The event is sent to SdlInputBridge::ProcessEvent first, so both keys still reach Keyboard::GetState(); application code may observe them but cannot suppress the debug action. Avoid binding gameplay to them unless that game-loop behavior is changed.
Three tags recur in the headers: STRICT (the frozen FNA/XNA-shaped surface), EXT (an FNA-compatible extension, using the ...EXT suffix convention from this book’s game-loop chapter), and CNAEXT (a CNA-only addition with no FNA counterpart at all). PublicApiInputSignatureFreezeTests.cpp compile-checks the strict member set.
44.2 Keyboard
Keyboard is static-only, offering GetState() and a per-PlayerIndex overload. KeyboardState stores keys in a std::unordered_set<Keys> instead of FNA’s bitfield while preserving the observable queries, sorted GetPressedKeys() result, and FNA-shaped hash formula. Keys has exactly 160 values, independently confirmed byte-for-byte identical to FNA’s numeric values, including several distinctly odd hexadecimal outliers (Pause = 0x13, Kana = 0x15, ChatPadGreen = 0xCA) preserved exactly rather than renumbered for tidiness. The SDL bridge has 125 scancode-to-key cases but 126 cases in the reverse direction, and many enum values have no SDL scancode source at all. IME keys, ChatPad keys, and several browser/media keys therefore cannot round-trip through the EXT scancode helpers. Typed text must go through TextInputEXT, not through assumptions that every Unicode character has a Keys value.
44.2.1 KeyboardState and keyboard extensions
KeyboardState’s public surface is compact:
- KeyboardState(std::initializer_list<Keys> keys)
-
the ordinary way application and test code constructs one, for example KeyboardState{Keys::W, Keys::LeftShift}.
- getItem(Keys key) / operator[](Keys key)
-
both return a KeyState (Down or Up) for one key.
- IsKeyDown(key) / IsKeyUp(key)
-
boolean key queries.
- GetPressedKeys()
-
returns every currently-down key as an ascending-sorted std::vector<Keys>; the sort is part of the contract because the backing hash set has no stable iteration order.
Keyboard’s EXT methods bridge XNA’s US-QWERTY-shaped Keys enum and SDL3’s distinction between a physical scancode and a layout-dependent keycode. GetKeyFromScancodeEXT() maps a US-named physical position into the current layout, which supports position-based movement controls on non-US keyboards. The public API has no general keycode-to-physical-position inverse; the inverse helpers apply to textual names. GetModStateEXT() returns SDL’s modifier mask. GetScancodeNameEXT and GetKeyNameEXT, plus their name-to-key forms, provide current-layout labels for rebinding interfaces.
44.3 Mouse
Mouse is static-only: GetState() and SetPosition(x, y), the latter converting logical coordinates to physical window coordinates before calling SDL’s warp function. The shared routing first asks an associated SDL renderer, then a registered graphics renderer, then falls back to pass-through. Those tiers make the result renderer- and presentation-dependent; the audit below and Chapter 19’s matrix state the exact boundary. MouseState carries the expected X/Y, five buttons, and a cumulative, 120-units-per-notch ScrollWheelValue; a CNAEXT horizontal-scroll addition is deliberately excluded from Equals / GetHashCode / ToString specifically so those stay byte-identical to FNA’s own output despite the extra field existing. A wholly CNAEXT class, MouseCursor, wraps SDL_Cursor* with eleven lazily created, process-lifetime stock system cursors. MonoGame’s PlatformDispose() frees a shared stock cursor handle unconditionally, which can invalidate other users; CNA makes Dispose() a no-op for shared stock instances.
44.3.1 MouseState and mouse extensions
MouseState is a plain data carrier — X / Y, five ButtonState properties (LeftButton / RightButton / MiddleButton / XButton1 / XButton2), the cumulative ScrollWheelValue already named above, and (CNAEXT) a parallel HorizontalScrollWheelValueEXT for horizontal scroll, deliberately excluded from Equals / GetHashCode / ToString as already noted. Mouse itself, beyond GetState() and SetPosition, exposes relative (pointer-lock-style) mouse mode and window/capture-related EXT methods:
- IsRelativeMouseModeEXT
-
(get/set) toggles SDL’s own relative mouse mode, where motion is reported as unbounded deltas rather than an absolute, screen-clamped position — the standard technique for an FPS-style camera look. Both accessors are safe to call with no window at all (no published window handle and no focused window): the getter simply reports false rather than querying SDL with a null window (whose behavior SDL leaves undefined), and the setter is a no-op. While relative mode is active, each SDL_EVENT_MOUSE_MOTION adds its relative delta to a separate accumulator; motion outside that mode is not accumulated, and toggling the mode flushes stale deltas. MouseState’s public X / Y read and immediately drain that accumulator in relative mode; otherwise they report the absolute position.
- SetPosition(x, y)
-
is a documented no-op in relative mode because an absolute warp has no meaning while the mouse reports deltas.
- SetCaptureEXT(enabled)
-
engages OS-level mouse capture (input keeps arriving even if the cursor leaves the window bounds), distinct from relative mode and combinable with it.
- GetGlobalPositionEXT / WarpGlobalEXT
-
read or set the mouse position in desktop rather than window-relative coordinates.
- ClickedEXT
-
a CNAEXT System::MulticastAction<int> event (Chapter 56 covers this sharp-runtime type) firing once per click with the button index, for code that wants a discrete click event rather than polling button state every frame.
44.3.2 Logical coordinates are a renderer contract
The write direction is only half the story. Incoming SDL_EVENT_MOUSE_MOTION and mouse-button positions pass through SdlInputBridge::to_logical_position() before updating InputManager; touch starts from normalized window position and reaches the same helper. Thus a bad or missing transform affects absolute mouse reads, click coordinates, and touch together, while relative mouse motion deliberately remains an unscaled delta.
The SDL_RENDERER route has the strongest existing proof. A test creates a logical presentation inside a window, injects a physical motion event at , and observes logical through Mouse::GetState(). Separate conversion tests cover the inverse and a horizontal letterbox offset. The latter tests cannot inspect the OS cursor under Wayland, however: SetPosition() writes the requested logical point into InputManager before attempting conversion and warp, so immediately reading that point back proves the cache assignment even if the physical cursor landed elsewhere. The former ASCII renderer’s integration test uses this same weak immediate set/get shape.
Other renderers in the inherited audit cohort are not interchangeable:
-
•
EasyGL and Canvas register an offset-free height scale. It matches their default fixed-height, dynamic-width mode, but not the other presentation modes they store.
-
•
WebGPU and SDL GPU implement all five viewport modes, but their transform uses physical pixel dimensions for SDL event and warp values expressed in window coordinates. A high-pixel-density display therefore introduces a density-factor error. Reads in a letterbox bar also return false, causing raw window coordinates to replace the outside logical point the method had already computed.
-
•
Vulkan has no override or registry entry. Its SpriteBatch projection stretches the virtual canvas across the physical swapchain, so absolute input becomes misaligned after a non-1:1 resize. Keeping its window-coordinate size equal to the requested virtual size is the only portable caller-side guard today.
-
•
BGFX and D3D9/11/12 also pass through, but those renderers in the inherited audit cohort ignore logical presentation and draw in physical coordinates; input matches that limitation rather than the requested mode.
-
•
FREEDIRECT’s correct direct helper methods are shadowed by the SDL renderer created inside free-direct. Public mapping is pass-through until the first Present() installs its hardcoded letterbox, then follows SDL’s mapping. Its four-property test invokes the helpers directly and does not exercise this public route.
The complete inherited fourteen-product matrix and its test evidence are in Chapter 19, §19.2.1. FNA has no corresponding renderer split: Mouse.GetState() multiplies window X/Y by the independent backbuffer/window ratios, and SetPosition() applies their inverses. CNA needs more machinery because its presentation modes include offsets and dynamic logical dimensions, but callers should not mistake that richer vocabulary for uniform implementation.
44.4 GamePad
The full XNA surface — GetCapabilities, GetState with configurable GamePadDeadZone handling, SetVibration — is wired to real SDL_Gamepad hardware, not stubbed. CNA exposes at most four player slots; an out-of-range player-index override is clamped rather than creating a fifth slot. Dead-zone constants match FNA exactly (LeftDeadZone = 7849/32768, RightDeadZone = 8689/32768, TriggerThreshold = 30/255). Beyond the strict XNA surface, an extensive EXT layer exposes real hardware capability FNA itself already extends beyond stock XNA: GUID, light-bar and trigger-rumble control, gyroscope/accelerometer reads, and several CNA-only additions with no FNA counterpart at all (player-index assignment, power info, button-label queries, device name/path/serial/firmware, Steam handle, connection state, touchpad finger tracking).
44.4.1 Bugs found and fixed
Historical note. The gamepad route originally omitted SDL_INIT_GAMEPAD, so it received no events. Another implementation queried rumble support by calling SDL_RumbleGamepad(0,0,0), which stopped active vibration; it now uses a non-mutating property query. Stick normalization used 32768 for negative samples instead of FNA’s 32767, making yield instead of approximately . Finally, the initialized gamepad subsystem was not shut down on device recreation. An idempotent ShutdownGamepadSubsystem() now runs during game disposal.
44.4.2 GamePadState and dead-zone modes
GamePadState composes four sub-structures, each a plain value carrier read directly from its header: GamePadThumbSticks (Left / Right, each a Vector2), GamePadTriggers (Left / Right, each a scalar float), GamePadButtons (eleven ButtonState properties — every face/shoulder/stick-click/back/start/big button — plus a ButtonStateFromFlag(Buttons) convenience for looking one up by the Buttons flag enum instead of by name), and GamePadDPad (Up / Down / Left / Right, each a ButtonState). GamePadState itself adds IsConnected, PacketNumber (incremented when the hardware state changes), and IsButtonDown / IsButtonUp convenience wrappers over the same Buttons flag enum.
The three GamePadDeadZone modes differ in shape as well as threshold. None filters nothing. The default IndependentAxes excludes and clamps each axis separately, which can distort direction near the boundary. Circular excludes by vector magnitude and clamps to the unit circle. None and IndependentAxes retain a square clamp, so changing modes can also change maximum diagonal magnitude.
Beyond the strict surface, GetGyroEXT / GetAccelerometerEXT expose a controller’s built-in motion sensors, distinct from host-device sensors. They require hardware that reports them, and GamePadCapabilities::getHasGyroEXTProperty() / getHasAccelerometerEXTProperty() exist specifically so calling code can check before relying on either. SetLightBarEXT and SetTriggerVibrationEXT (independent trigger motors rather than the two whole-controller motors) are modern-controller extensions, gated by their GamePadCapabilities flags.
44.5 Touch and gestures
TouchPanel is static-only (MAX_TOUCHES = 8), providing GetCapabilities(), GetState(), and ReadGesture(). TouchCollection is immutable only by contract, not by construction — its IsReadOnly flag is advisory, matching an identical inconsistency already present in FNA’s own implementation. GestureType is an eleven-value bit-flag enum (Tap, DoubleTap, Hold, Drag, Flick, Pinch, and their variants). One value is None; all ten actual gesture types are implemented by GestureDetector, including both completion events. There is no declared gesture that silently lacks a detector.
Historical note. The touch route once ignored SDL_EVENT_FINGER_CANCELED, leaving canceled touches active. GetCapabilities() used only an observed-touch flag, so an untouched enumerated device appeared disconnected. Touch-state advancement also occurred inside GetState(), making repeated reads mutate a frame. The current implementation handles cancellation, enumerates SDL devices with the observed flag as a fallback, and advances state once per frame through AdvanceTouchFrame().
44.5.1 TouchCollection, TouchLocation, and GestureSample
TouchCollection is a per-frame snapshot of tracked touches. It provides Count / IsConnected (the sticky “a touch has been observed” flag described below), the by-index operator[], and the same general ordered-container surface Chapter 7’s own CurveKeyCollection coverage already described — Contains / IndexOf, a CNAEXT empty(), and CNAEXT iterator support for range-based for. Each TouchLocation inside it carries an Id stable across frames for one finger, a State (TouchLocationState: Pressed, Moved, Released, or Invalid for a stale/expired entry), a Position, and (CNAEXT) a PressureEXT reading on hardware that reports one. GestureSample (returned by TouchPanel::ReadGesture()) carries a GestureType, a Timestamp, and up to two positions/deltas (Position / Position2, Delta / Delta2); the second pair is used only by two-finger gestures like Pinch, left at their default for single-finger gestures.
The two connected flags do not use the same evidence. TouchPanel::GetCapabilities() enumerates SDL touch devices on every call, then falls back to the sticky observed-touch flag or a currently live touch. It can therefore report a device before the first interaction. TouchCollection::IsConnected, in contrast, reads only that sticky flag; it can remain false for an enumerated but untouched device until the first finger-down event. Use capabilities for device discovery and the collection’s count for whether a finger is currently tracked.
EnabledGestures is an active filter: ReadGesture() returns only enabled types. GestureType uses powers of two from Tap = 1 through PinchComplete = 512, so callers combine selections with |.
GestureSample::Timestamp deliberately differs from an FNA defect. FNA computes it as TimeSpan.FromTicks(Environment.TickCount) — but TickCount is a millisecond value, while FromTicks expects 100-nanosecond units. CNA converts milliseconds to ticks. Neither engine defines an absolute epoch for the field; the regression establishes non-negative, strictly increasing values across two gestures under an advanced test clock.
44.6 TextInputEXT and CNA-only CNAEXT extensions
TextInputEXT is not itself a CNA invention — FNA ships this same extension beyond the strict XNA 4.0 surface, and CNA ports it directly: TextInput / TextEditing events, StartTextInput / StopTextInput, on-screen-keyboard visibility, and input-rectangle placement. The implementation has three event channels: committed TextInput, in-progress TextEditing, and the CNA-only TextEditingCandidatesEXT candidate-list event. CNA also adds an explicit on-screen-keyboard/IME type hint.
The CNA::Input namespace also hosts these CNAEXT subsystems: Clipboard (system clipboard text); Joysticks (the raw, unmapped SDL joystick API — axes, buttons, hats, trackballs — deliberately independent of GamePad’s own mapped view of the same physical device); Sensors (host device accelerometer/gyroscope, distinct from a gamepad’s own motion sensors covered above); Power (battery state); and Haptics (SDL3 force feedback). Haptic actuation requires capable hardware and an initialized SDL haptic subsystem; most gamepads support only simple rumble and this layer does not initialize SDL_INIT_HAPTIC itself). This entire CNA::Input extension layer is compiled unconditionally, even when CNA_DEVICES and CNA_CNAEXT are off. Clipboard and Power overlap the optional CNA::Devices surface; because CNA_DEVICES defaults off, the input versions are the only clipboard and host-power APIs present in a default build.
44.6.1 TextInputEXT contract
TextInput is a System::MulticastAction<charcs> that fires once per composed UTF-16 unit; a non-BMP code point arrives as a surrogate pair. TextEditing carries the uncommitted IME candidate, cursor start, and selection length. StartTextInput() and StopTextInput() enable or disable this event period, and IsTextInputActive() reports it. SetInputRectangle() positions a composition popup in logical window coordinates, while IsScreenKeyboardShown() reports a platform keyboard. The CNA-only StartTextInputWithTypeEXT() adds a plain-text, numeric, email, or similar mobile keyboard hint.
44.6.2 CNAEXT-only device subsystem examples
These five subsystems are small static CNAEXT classes with no XNA counterpart. Clipboard wraps SDL with GetTextEXT(), SetTextEXT(text), and HasTextEXT(). Joysticks exposes the raw, unmapped view of connected joystick hardware (GetJoysticksEXT() enumerates every connected device; GetCapabilitiesEXT(id) / GetStateEXT(id) read one device’s shape and current state directly by axis/button/hat index) — deliberately independent of GamePad’s own mapped, Xbox-360-shaped view of the same physical hardware, for the rarer case where an application needs a device’s true native layout rather than GamePad’s normalized abstraction. Sensors (GetSensorsEXT(), GetAccelerometerEXT / GetGyroscopeEXT) reads the host device’s own motion sensors — a laptop’s built-in accelerometer, a Steam Deck’s gyro — distinct from a gamepad controller’s own motion sensors already covered under GamePad’s EXT surface above. The current implementation never initializes SDL_INIT_SENSOR itself, however. Unless another subsystem has already initialized SDL sensors, enumeration is empty and both readers return false; fake-backend tests do not expose this latent production no-op. Power is a single method, GetInfoEXT(secondsLeft&, percent&), returning a PowerStateEXT plus two output parameters — the host device’s own battery state, not a gamepad’s (that is GamePad::GetPowerInfoEXT instead).
Haptics maintains device and effect lifetimes. GetHapticsEXT() enumerates force-feedback-capable devices, and OpenEXT(id) / OpenFromJoystickEXT(joystickId) / OpenFromMouseEXT() each return an owning HapticDevice handle. That handle’s own surface splits cleanly into two tiers: a simple InitRumbleEXT() / PlayRumbleEXT(strength, lengthMs) / StopRumbleEXT() trio for the common single-motor-strength case (the same conceptual level as GamePad::SetVibration, just reachable for non-gamepad haptic hardware too), and a full HapticEffectEXT lifecycle — CreateEffectEXT / UpdateEffectEXT / RunEffectEXT / StopEffectEXT / DestroyEffectEXT, plus GetEffectStatusEXT to poll whether an effect is running. Shaped feedback such as a directional kick or sustained curve is available only when IsEffectSupportedEXT accepts that effect type.
Like Sensors, this wrapper assumes its SDL subsystem already exists. No input-module path calls SDL_InitSubSystem(SDL_INIT_HAPTIC), and the focused haptic tests replace the backend rather than exercising subsystem startup. Unless the host or another module has initialized it, enumeration/open calls can fail before hardware capability is relevant. Treat subsystem ownership as an application integration requirement for both extension families.
44.7 Focus loss and held input
Current contract. Desktop focus loss and gain update Game::IsActive and raise the corresponding events. Keyboard and mouse snapshots are retained across focus loss, matching FNA. Gate gameplay input on IsActive; do not expect focus loss to synthesize key-up or button-up events.
Historical note. Game::PollEvents() once handled only mobile background/foreground events. It now also handles SDL_EVENT_WINDOW_FOCUS_LOST and FOCUS_GAINED. FNA additionally restores an X11 fullscreen-desktop flag and toggles the screensaver; CNA does not reproduce those side effects at the pin. WindowFocusLostDoesNotClearHeldKeysMatchingFna preserves the held-state policy.
44.8 Platform-specific boundaries
A few platform constraints are external to CNA. On Wayland, SDL_GetGlobalMouseState silently returns , because the compositor’s own security model forbids querying the global pointer position at all — absolute mouse warping is similarly focus-gated, while relative (pointer-lock) mouse mode works normally. On Windows, XInput controllers report no USB vendor/product ID, so the EXT GUID query returns "xinput" instead of a hexadecimal identifier. Some platforms enumerate a touch device only after its first interaction. CNA’s capability query therefore combines live SDL enumeration with an observed-touch fallback; the separate collection flag retains the first-touch boundary described above. In a browser via Emscripten, gamepads remain invisible to SDL until the user presses a button on one, a browser privacy restriction, and relative mouse mode maps onto the Pointer Lock API, which itself requires a preceding user gesture to engage. Non-US keyboard layouts expose an XNA limitation: several accented and non-ASCII characters common on European layouts have no corresponding Keys enum value at all and are simply dropped in keycode mode — TextInputEXT is the correct API for capturing that text instead.
44.9 Status
The 522-case corpus stated at the chapter opening remains bounded by fifteen formally blocked, hardware-only campaign tasks: physical keyboard, mouse, gamepad, touchscreen, IME, and high-DPI display checks. These are absent observations, not automated passes. The Joysticks / Sensors / Power CNAEXT extensions have fake-backend unit coverage but no demo-UI or manual-hardware verification pass; Sensors also has the concrete subsystem-initialization gap described above.