The input model: snapshots, keys, the mouse and logical coordinates

CNA snapshot 009d40f5  ·  Deep Dives › Input, audio, media & services  ·  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. Checked by reading modules/input, modules/platform, the renderer transform implementations and Game::PollEvents at 009d40f5; two fragments were syntax-checked with g++ -fsyntax-only against TARGET headers and a sibling sharp-runtime checkout (not pinned by TARGET). Nothing was executed; physical keyboards, IMEs, high-density displays and per-renderer mapping in every presentation mode remain unverified.

This page states the exact contract of CNA's keyboard and mouse input: what a GetState() call really returns and when it changes, how the 160 Keys values relate to physical keys and keyboard layouts, which mouse members are XNA and which are extensions, and how window pixels become the logical coordinates a game sees. It is for anyone porting input code, binding controls on non-US layouts, or debugging a mouse position that looks wrong under a virtual resolution. The user-level tour is the Input System guide; the event plumbing behind it is traced for maintainers on Input internals.

Where a GetState value comes from

FNA asks SDL for the current device state inside each GetState() call. CNA does not. Native events are drained once per frame by Game::PollEvents in Game.cpp, every event is first handed to PlatformInputBridge::ProcessEvent, and at the end of that batch the selected platform's keyboard and mouse services publish a snapshot. Keyboard::GetState() and Mouse::GetState() copy that snapshot; they never call a native API. The bridge itself feeds text input, touch, click notifications, device callbacks and a set of legacy accumulators that no public getter reads. Older descriptions in which the bridge's InputManager is the source of the public keyboard and mouse state describe an earlier design and are stale.

Three consequences are part of the contract rather than implementation detail:

  • Repeatable reads. Two reads in the same frame return the same keyboard, absolute mouse and gamepad state; a read before the first pump sees the initial state, and several fixed-step Update calls inside one slow Tick all see the snapshot of the last completed pump.
  • One consume-on-read exception. In relative mouse mode Mouse::GetState() drains the service's motion accumulator (IPlatformMouse::ConsumeRelativeDelta), so the second read in a frame returns zero motion. This is FNA's deliberate draining semantic, kept on purpose.
  • One thread. The event pump and every state read belong on the game-loop thread. The input module contains no mutex or atomic, and TextInputEXT's own header states the same rule for its events.

Two ordinary keys carry a framework side effect: after the bridge has recorded a non-repeat press, Game::PollEvents calls the renderer's DebugSimulateContextLoss() on F9 and DebugRestoreContext() on F10. Both keys still reach Keyboard::GetState(), so a game can see them but cannot suppress the debug action; do not bind gameplay to them unless that loop code is changed. The frame order and the per-backend event mappers are on Input internals: actual order in one frame.

STRICT, EXT and CNAEXT members

CNA's input documentation (docs/input-public-api-frozen.md) sorts every public input member into tiers: STRICT members are XNA 4.0 API and must match XNA and FNA in name and signature; FNA-compatible members are STRICT members whose behaviour deliberately follows FNA where XNA left it to the platform; EXT members are FNA's own extensions beyond XNA (the EXT name suffix, for example Keyboard::GetKeyFromScancodeEXT or TextInputEXT); and CNAEXT members are CNA additions with no FNA counterpart. Non-enum extensions carry the CNAEXT marker macro whichever of the last two tiers they belong to, and a non-XNA enum value carries only the EXT suffix (Buttons::Misc1EXT). PublicApiInputSignatureFreezeTests.cpp takes the address of, or static_asserts, every frozen member, so a signature change fails to compile rather than slipping through.

Keys, KeyboardState and what a key value means

The Keys numbering

Keys has exactly 160 enumerators, numbered as the Windows virtual-key codes that XNA and FNA use, including the irregular ones: Pause = 19, Kana = 21, ImeConvert = 28, ChatPadGreen = 202, ProcessKey = 229. They are preserved exactly instead of being renumbered for tidiness, because games and saved input bindings persist keys by number. KeysValuesMatchXNANumericConstants in KeyboardInputTests.cpp pins every enumerator to a literal value (its comment records a name-for-name and value-for-value comparison with FNA's Keys.cs), and KeyCodeMatchesXnaKeysTests.cpp keeps the platform's own KeyCode numerically equal to Keys, which is what makes the plain cast between them safe.

KeyboardState

KeyboardState (KeyboardState.cpp) stores the pressed keys in a std::unordered_set<Keys> instead of FNA's eight 32-bit words, while keeping every observable query of the bitfield:

MemberContract at this snapshot
KeyboardState{Keys::W, Keys::LeftShift}The ordinary way test and application code builds a state. Values outside 0–255, including negative ones, are dropped by both constructors, because FNA's 256-bit field can never hold them.
getItem(key), operator[](key)Return KeyState::Down or KeyState::Up; the indexer mirrors FNA's this[Keys].
IsKeyDown, IsKeyUpComplementary boolean queries.
GetPressedKeys()A std::vector<Keys> sorted in ascending numeric order. The sort is part of the contract: FNA walks its bitfield from bit 0 upward, and a hash set has no stable iteration order.
GetHashCode()Rebuilds FNA's eight words and XORs them, so equal states hash exactly as in FNA; an empty state hashes to 0.
ToString() (CNAEXT)Returns the type name Microsoft.Xna.Framework.Input.KeyboardState, which is what FNA's value type prints because it declares no ToString.

Keyboard is static-only. Keyboard::GetState(PlayerIndex) ignores its argument and returns the same state as GetState(): the keyboard is not split by player. With no keyboard service on the selected platform (Headless, or a terminal that is not a TTY) both return an empty state rather than throwing.

Physical keys, layouts and the scancode helpers

XNA's Keys is shaped like a US QWERTY keyboard, while modern platforms distinguish a scancode (a physical position) from a keycode (what the current layout prints on that key). Keyboard::GetState() reports layout keycodes: on the default SDL3 platform the snapshot resolves each held scancode to its unmodified layout key with SDL_GetKeyFromScancode. The extension helpers bridge the two views; they live in Keyboard.cpp and the name tables in SdlInputBridge.cpp (the file name is historical; it holds no SDL call).

HelperWhat it answers
GetKeyFromScancodeEXT(key)Treats key as naming the physical position a US layout gives that name, and returns the key the current layout has there. It asks the platform keyboard service first and falls back to CNA's own table; it returns Keys::None for a key with no physical position.
GetModStateEXT()A CNA::Input::KeyModifiersEXT bit set (Shift, Ctrl, Alt, Gui and the lock states Caps, Num, Scroll, Mode) converted from the same frame snapshot, not a native modifier mask.
GetScancodeNameEXT, GetKeyNameEXTThe physical and the layout-dependent label of a key, for rebinding screens; empty for a key with no physical position.
GetScancodeFromNameEXT, GetKeyFromNameEXTThe inverse lookups by name; Keys::None when the name is unknown. There is no general keycode-to-position inverse beyond these name lookups.

The fallback tables are not symmetric. At this snapshot the bridge maps 122 platform scancodes to a Keys value but only 119 Keys values back to a scancode, because several positions share a key: the keypad Enter becomes Keys::Enter, the keypad period Keys::OemPeriod, and both the Application and Menu positions Keys::Apps. Forty non-None keys have no physical position at all and therefore cannot round-trip through the helpers: the IME keys (Kana, Kanji, ImeConvert, ProcessKey), the ChatPad keys, the browser and media keys, VolumeMute, and OemBackslash, the extra key of ISO keyboards whose platform scancode is deliberately left unmapped. Typed text must therefore go through TextInputEXT, never through the assumption that every character has a Keys value; several accented letters of European layouts have none and are simply absent from keyboard state.

The environment variable FNA_KEYBOARD_USE_SCANCODES=1 is read once per process. At this snapshot it changes only how the bridge interprets key events and makes GetKeyFromScancodeEXT return its argument unchanged; no platform keyboard service reads it, so it does not change what Keyboard::GetState() reports. Physical-position bindings therefore rest on the helper above, not on the variable.

Worked example: WASD on any layout

Binding movement to positions rather than letters keeps it usable on AZERTY, QWERTZ and Dvorak. Resolve the four positions once (and again if the layout can change), then poll the resolved keys:

#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
using namespace Microsoft::Xna::Framework::Input;

// The keys the current layout has where a US keyboard has W, A, S and D.
const Keys forwardKey = Keyboard::GetKeyFromScancodeEXT(Keys::W); // Z on AZERTY
const Keys leftKey    = Keyboard::GetKeyFromScancodeEXT(Keys::A); // Q on AZERTY
const Keys backKey    = Keyboard::GetKeyFromScancodeEXT(Keys::S);
const Keys rightKey   = Keyboard::GetKeyFromScancodeEXT(Keys::D);

const KeyboardState keyboard = Keyboard::GetState();
if (keyboard.IsKeyDown(forwardKey))
{
    // move forward
}

This fragment was syntax-checked against the TARGET headers with g++ -std=c++23 -fsyntax-only (not executed). With scancode mode switched on the helper returns its argument, which is also what the positions mean in that mode.

Mouse state and the mouse extensions

MouseState

MouseState (MouseState.cpp) is a plain value: X, Y, the five ButtonState values (LeftButton, RightButton, MiddleButton, XButton1, XButton2), the cumulative ScrollWheelValue in XNA's 120 units per notch, and the CNAEXT HorizontalScrollWheelValueEXT accumulated the same way. The constructor takes the buttons in XNA's order (left, middle, right, X1, X2). The horizontal value is deliberately excluded from Equals, GetHashCode and ToString, so those three stay identical to FNA's output even though the extra field exists; HorizontalScrollWheelEXTIsExcludedFromEqualityAndHash pins it. ToString prints [MouseState X=…, Y=…, Buttons=…, Wheel=…] with the pressed buttons in left, right, middle, X1, X2 order, and the hash is x ^ (y*31) ^ (wheel*17) computed in unsigned arithmetic so it cannot overflow a signed integer.

Mouse members at a glance

MemberTierBehaviour
GetState()STRICTAbsolute mode: the service's window-client position mapped to logical coordinates (next section). Relative mode: the drained motion delta as X/Y. A default state when the platform has no mouse service.
SetPosition(x, y)STRICTApplies the exact inverse transform and asks the service to warp; a documented no-op in relative mode, where an absolute warp has no meaning (FNA's own rule).
getIsRelativeMouseModeEXTProperty / set…EXTPointer-lock style relative mode for camera look. The getter asks the service and needs no window; the setter does nothing when neither a published window nor a snapshot window is known. A platform that cannot provide relative motion makes the setter throw instead: PlatformNotSupportedException from the terminal platform, from Wayland when the compositor lacks the relative-pointer and pointer-constraints protocols and from X11 without XInput2 raw motion, and PlatformException when the SDL3 or Win32 request itself fails. Mouse does not catch it (IPlatformMouse::SetRelativeMode documents the throw), so guard the call where such platforms matter.
SetCaptureEXT(enabled)CNAEXTAsks the platform to keep reporting motion while the pointer is outside the window; independent of, and combinable with, relative mode. Returns the platform's answer.
GetGlobalPositionEXT(x, y), WarpGlobalEXT(x, y)CNAEXTDesktop rather than window coordinates. GetGlobalPositionEXT zeroes both outputs first, so "no position available" reads (0, 0).
ClickedEXTCNAEXTA multicast System::MulticastAction<int> raised by the bridge on every button press with the zero-based button index, for code that wants a discrete click instead of comparing frames.

The desktop-coordinate calls depend on the platform's GlobalPointer capability. The native Wayland mouse service (clients are never given desktop coordinates) and the terminal mouse service both throw PlatformNotSupportedException from those operations, and Mouse::GetGlobalPositionEXT, WarpGlobalEXT and SetCaptureEXT do not catch it at this snapshot, so on those platforms the calls throw rather than answer (0, 0). The test that calls all three "on the real platform" runs on whatever platform the test build selected, not on Wayland. Relative mode, by contrast, is the supported pointer path on Wayland.

MouseCursor

MouseCursor (MouseCursor.cpp) holds a description, never a native handle; the platform service creates the native cursor when Mouse::SetCursor is called. There are twelve stock cursors (Arrow, Crosshair, Hand, IBeam, No, SizeAll, SizeNESW, SizeNS, SizeNWSE, SizeWE, Wait, WaitArrow), each a lazily constructed function-local static shared for the process lifetime. Dispose() on a stock instance is deliberately a no-op: MonoGame's PlatformDispose() frees a shared stock handle unconditionally, which can break every other user of that cursor, and DisposingAStockSingletonIsANoOpAndKeepsItUsable pins CNA's choice. FromTexture2D accepts only SurfaceFormat::Color or ColorSrgbEXT textures (std::invalid_argument otherwise) and throws std::runtime_error when the hot spot lies outside the texture.

Worked example: FPS-style camera look

// Once, when entering first-person control (inside a Game subclass):
setIsMouseVisibleProperty(false);
Mouse::setIsRelativeMouseModeEXTProperty(true);

// Each Update: relative mode reports motion since the previous read.
const MouseState mouse = Mouse::GetState();          // drains the accumulated delta
const float yaw   = static_cast<float>(mouse.getXProperty()) * mouseSensitivity;
const float pitch = static_cast<float>(mouse.getYProperty()) * mouseSensitivity;
cameraRotation = Quaternion::CreateFromYawPitchRoll(yaw, pitch, 0.0f) * cameraRotation;

Read the mouse once per update and pass the value around: a second GetState() in the same frame returns zero motion. The mouse part of this fragment was syntax-checked against the TARGET headers (not executed); in a browser build relative mode maps onto the Pointer Lock API, which the browser grants only after a user gesture.

Logical coordinates are a renderer contract

Under a virtual resolution or a letterboxed presentation, XNA code expects Mouse::GetState() to answer in game coordinates, not window pixels. CNA gets there through a registry keyed by the platform's WindowId: a renderer that knows its presentation geometry registers itself with IGraphicsRenderer::RegisterForWindow and implements TransformWindowToLogical and its inverse TransformLogicalToWindow (IGraphicsRenderer.hpp). By contract the renderer alone accounts for presentation scale, letterbox offsets and the difference between client units and drawable pixels on a high-density display.

The same lookup serves every direction. Mouse::GetState() converts the service's window-client position; the bridge converts the positions of motion and button events for its accumulators and for mouse-to-touch emulation; a touch event's normalized position is first multiplied by the event's client size and then goes through the same transform; and Mouse::SetPosition applies the inverse. A bad or missing transform therefore misplaces absolute reads, click positions and touch together, while relative motion is deliberately left as an unscaled delta. When no renderer is registered for the window, or the transform returns false, the coordinates pass through unchanged, which is also the correct 1:1 answer for a windowless or unscaled game.

Renderer families (read from source at this snapshot)Input transform
SDL_RENDERERRegisters; delegates to SDL_RenderCoordinatesFromWindow and SDL_RenderCoordinatesToWindow, so SDL's own logical presentation decides the mapping.
The five EasyGL identities, OPENGL4, SDL_GPU, WEBGPURegister; map through the presented viewport rectangle and its offset, converting between client units and drawable pixels with the display scale, and return false for a point in a letterbox bar.
SVG_DOM, HTML_DOMRegister; the same viewport mapping in window units, false in a bar, and no mapping at all until a virtual resolution is configured.
VULKANRegisters; maps through its presented rectangle divided by the display scale and answers true for every point, so a point in a bar yields an out-of-range logical coordinate.
FNA3D, GDI, DIRECT2DRegister; each maps through its own presentation layout helper.
CANVASRegisters; an offset-free uniform scale by virtual height over window height, which is exact only in its default fixed-height, dynamic-width mode.
DIRECTX11, DIRECTX12, FREEDIRECT, METALImplement both transform methods but never call RegisterForWindow, so input finds no renderer for the window and passes coordinates through unchanged (CNA-BUG-133).
DIRECTX9, SOFTWARE, PORTABLEGL, HEADLESS, STUBNeither register nor implement a transform: pass-through.

Two consequences are worth knowing. On the renderers that answer false in a bar, a pointer over a letterbox bar reports its raw window coordinate, not an out-of-range logical one, because the input side keeps the untransformed value when the renderer declines; a game that hit-tests near the edges of the play area should reject positions outside its logical bounds itself. And the richer vocabulary of presentation modes is not the same thing as uniform implementation: the table is a reading of each renderer's source, and no test compares the mapping of every renderer in every mode on a high-density display.

The automated proof of the mechanism itself is CoordinateTransformPreservesLetterboxOffsetAndInverse in MouseInputTests.cpp: a fake renderer with a 50-pixel letterbox offset maps a window point (100, 50) to logical (50, 50), and SetPosition(25, 75) reaches the service as window (75, 75). The round trip reads back through a canned mouse service that records the warp, so it proves the conversion and the hand-off, not that an operating-system cursor landed on that pixel. Renderer-specific example programs such as presentation_mode_contract_test.cpp and opengl4_transformcoords_test.cpp exist and were not executed for this page. The FREEDIRECT example freedirect_logical_transform_test.cpp is not evidence for the public route either: it calls the renderer's TransformLogicalToWindow and TransformWindowToLogical directly, while Mouse never reaches them because FREEDIRECT does not register for its window. FNA needs none of this machinery: its Mouse.GetState() multiplies window coordinates by the independent back-buffer-to-window ratios and SetPosition applies their inverse, because it has no offset-bearing presentation modes.

Focus loss and held input

A desktop FocusLost window event sets Game::IsActive to false and raises Deactivated; FocusGained sets it back, raises Activated and invalidates the renderer surface. The input bridge synthesizes no key-up or button-up events on focus loss, matching FNA; WindowFocusLostDoesNotClearHeldKeysMatchingFna pins that for the bridge's legacy accumulator. What the public snapshot shows after focus loss is decided by the platform keyboard and mouse services: the SDL3 service reads SDL's own keyboard state, and the Win32 mapper emits a release for every key it had reported as held when WM_KILLFOCUS arrives. Either way the portable rule is the same: gate gameplay input on IsActive and do not rely on focus loss to release keys. FNA additionally restores an X11 fullscreen-desktop flag and toggles the screensaver on focus changes; CNA's Game::PollEvents reproduces neither side effect.

void MyGame::Update(GameTime& gameTime)
{
    Game::Update(gameTime);                 // keeps FrameworkDispatcher::Update running
    if (!getIsActiveProperty())
    {
        return;                             // unfocused: ignore retained keyboard and mouse state
    }
    const KeyboardState keyboard = Keyboard::GetState();
    if (keyboard.IsKeyDown(Keys::Space))
    {
        // process held input only while the window is active
    }
}

Platform boundaries outside CNA

  • Wayland. Compositors do not give clients desktop pointer coordinates, and absolute warping is focus-gated. CNA's own manual verification log (an entry for an earlier revision, SDL3 on a Wayland session) records SDL_GetGlobalMouseState returning (0, 0) and names relative mode as the supported path; the native Wayland platform refuses the desktop-coordinate calls outright (see above).
  • Controller identifiers on Windows. An XInput controller reports no USB vendor or product id, so GamePad::GetGUIDEXT returns "xinput" instead of a hexadecimal identifier; the full formatting rule is on Gamepads and haptics.
  • Touch enumeration. Some platforms enumerate a touch device only after its first interaction, which is why the capability query combines live enumeration with an observed-touch fallback (Touch and gestures).
  • Browsers. A gamepad stays invisible until the user presses a button on it, and pointer lock needs a preceding user gesture. These are browser privacy rules that no library can bypass; they were not re-tested for this page.
  • Non-US layouts. Characters without a Keys value exist only as text; see the scancode section above.

Evidence and what it does not cover

The input module has 47 test sources with 523 statically counted GoogleTest-family definitions at this snapshot (the site's static count method), and the public signatures are frozen by compile-time tests. That is substantial automated evidence, but CNA's input plan (plans/plan_input.md) keeps fifteen manual-hardware checks formally blocked by design: physical keyboard, mouse, gamepad, touchscreen, IME and high-density display verification. They are absent observations, not passes. The renderer transform table above was read from source; nothing on this page was executed.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.