Gamepads, joysticks, haptics and host power
Evidence basis: source-verified at the stated evidence commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit b0e97bb1; unless a sentence says otherwise, nothing here was built or executed. Checked by reading modules/input, the SDL3 gamepad and haptic services and the named tests at b0e97bb1; the three fragments were syntax-checked with g++ -fsyntax-only against TARGET headers and a sibling sharp-runtime checkout (not pinned by TARGET). Nothing was executed; CNA's own records list the gamepad extensions, haptics and host sensors as hardware-unverified.
This page gives the exact semantics of CNA's controller input: how physical pads become the four XNA player slots, how raw axes are normalized and dead-zoned, what GamePadState contains, what each gamepad extension does, and how the CNA-only joystick, haptics, power and sensor classes in CNA::Input relate to GamePad and to the optional host-device layer. It is for anyone porting controller code, adding rumble or motion controls, or supporting hardware beyond an Xbox-shaped pad. The task-level introduction is Tutorial 30: GamePad and controller input; the lazy subsystem acquisition and the per-backend support table are on Input internals: controllers.
From physical pads to four player slots
The full XNA surface (GetCapabilities, GetState with a GamePadDeadZone, SetVibration) reads real devices through the selected platform's gamepad service, not a stub. There are exactly four slots (GamepadSlotCount in IPlatformGamepad.hpp); GamePad.cpp maps PlayerIndex to a slot and returns a disconnected default state for an index outside the four without touching the service, so no fifth slot can be created. Only the SDL3 platform provides a gamepad service (SDL’s own drivers on Windows, Linux, macOS, Android and the other SDL targets); the Terminal and Headless platforms have none, and every GamePad query there reports a disconnected pad unless keyboard emulation (below) supplies player one. On Android the system Back button, which arrives as a press and release between two updates, is latched by the SDL3 platform: the next gamepad update reports Buttons.Back on player one once (an empty first slot reads as a connected pad holding only Back for that frame), as Windows Phone XNA gave that index its Back button, and the keyboard reports Keys::Escape once.
On SDL3 the service keeps a device in its slot for as long as it stays connected (Sdl3InputServices.cpp, Sdl3Gamepad::Update). Each update first retires only the ids that disappeared, then gives each newly connected pad the first free slot; reopening every device each frame would lose player assignment, interrupt running effects and make packet numbers follow list order instead of a controller. A fifth mapped pad stays visible to the raw joystick API below but cannot be named by a PlayerIndex. A player slot is not a device id: never persist one as the other.
While the Gamer Services Guide is visible it owns the pads: GamePad::GetState keeps the pad's connected flag but reports neutral sticks, triggers and buttons, and a button held when the Guide closes stays hidden from the game until it is released (the same rule as for keyboard and mouse on the input model page).
Axis normalization
SDL reports signed 16-bit axes. CNA divides both halves by 32767 and clamps, so the single over-range sample -32768 maps to -1 and -16384 maps to about -0.50002, as in FNA; thumbstick Y is negated into XNA's up-positive convention, and triggers are clamped to 0–1 (Sdl3GamepadControls.hpp, NormalizeGamepadAxis). Keeping this in one helper is what stops the event and snapshot paths from drifting. Motor strengths for every rumble call are clamped to 0–1 with NaN mapped to "off" before SDL's 16-bit conversion.
Defects fixed before this snapshot
Four gamepad defects recorded against earlier code no longer exist, and are kept here only as lessons. The gamepad route once never initialized SDL's gamepad subsystem and received no events; at this snapshot the subsystem is acquired through the platform (PlatformSubsystem::Gamepad) on the first gamepad or joystick query. A rumble-support probe once called the rumble function with zero strength, which stopped a running vibration; the capability is now read from the non-mutating SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN property. Negative stick samples were once divided by 32768 instead of 32767. And the gamepad subsystem was not released when the device was recreated; subsystem leases are now owned by the platform object and released with it.
Dead zones and GamePadState
The dead-zone constants are XInput's and FNA's: GamePad::LeftDeadZone = 7849/32768, RightDeadZone = 8689/32768, TriggerThreshold = 30/255. The three GamePadDeadZone modes differ in shape as well as in threshold (GamePadThumbSticks.cpp):
| Mode | Sticks | Final clamp |
|---|---|---|
None | Raw values | Square: each axis to -1..1 |
IndependentAxes (default) | ExcludeAxisDeadZone per axis: inside the threshold becomes 0, outside is shifted and rescaled to reach 1, which can bend the direction near the threshold | Square |
Circular | Excluded by vector length and rescaled radially | Unit circle |
Because two modes keep a square clamp, switching modes also changes the largest diagonal magnitude a stick can report. Triggers use ExcludeAxisDeadZone with TriggerThreshold in every mode except None, then clamp to 0–1.
GamePadState composes four value carriers: GamePadThumbSticks (Left, Right as Vector2), GamePadTriggers (Left, Right as float), GamePadButtons (eleven ButtonState values plus ButtonStateFromFlag(Buttons) for a lookup by flag) and GamePadDPad. It adds IsConnected, IsButtonDown/IsButtonUp over the Buttons flags, and PacketNumber. As in XNA, the state also sets the virtual buttons: LeftTrigger/RightTrigger when a trigger exceeds TriggerThreshold, and the eight thumbstick-direction buttons (LeftThumbstickLeft to RightThumbstickDown) when the already dead-zoned stick value on that axis lies beyond LeftDeadZone or RightDeadZone (StickToButtons in GamePadState.cpp). That is a second threshold applied on top of the mode's own dead zone: with IndependentAxes a direction button needs a raw deflection of about 0.42 on the left stick and 0.46 on the right, not 0.24 and 0.27 (arithmetic over the source, not measured). PacketNumber is the service's counter, incremented by the SDL3 service only when the published connected flag, buttons or axes differ from the previous update, so an unchanged pad keeps its number.
Worked example: circular dead zone and rumble
const GamePadState pad = GamePad::GetState(PlayerIndex::One, GamePadDeadZone::Circular);
if (pad.getIsConnectedProperty())
{
const Vector2 moveInput = pad.getThumbSticksProperty().getLeftProperty();
playerPosition += moveInput * moveSpeed * deltaSeconds;
if (pad.IsButtonDown(Buttons::A))
{
// Low-frequency (left) and high-frequency (right) motor strengths in [0, 1].
GamePad::SetVibration(PlayerIndex::One, 0.0f, 0.6f);
}
}
Syntax-checked against the TARGET headers (not executed). SetVibration returns false when the slot has no pad or the pad cannot rumble, and the motors keep running until another call sets them to zero.
The gamepad extensions
Beyond the strict surface, GamePad exposes what modern controllers report. Some members mirror FNA's own extensions; the rest have no FNA counterpart. All of them go through the same slot and service, return a neutral value for an empty slot, and are gated by GamePadCapabilities flags where hardware support varies.
| Members | Behaviour at this snapshot |
|---|---|
GetGUIDEXT | FNA's identifier format: "xinput" when vendor and product id are both zero (an XInput device on Windows reports none) and for a Steam virtual pad presenting an Xbox model; "4c05c405" and "4c05e60c" for a Steam virtual PlayStation 4 or 5 pad; otherwise eight hex digits of vendor and product in little-endian byte order. An empty string for an empty slot. |
SetLightBarEXT, SetTriggerVibrationEXT | Light-bar colour and the two trigger motors (independent of the two body motors). Gated by HasLightBarEXT and HasTriggerVibrationMotorsEXT. |
GetGyroEXT, GetAccelerometerEXT | The controller's own motion sensors, enabled on first read if the pad has them; false and a zero vector otherwise. Check getHasGyroEXTProperty() and getHasAccelerometerEXTProperty() first. These are not the host device's sensors. |
GetPlayerIndexEXT, SetPlayerIndexEXT | The controller's own player-number indicator (for example its LEDs), separate from the XNA slot. |
GetPowerInfoEXT | The controller battery as CNA::Input::PowerStateEXT plus a percentage; Error and -1 without a service. |
GetButtonLabelEXT | The glyph printed on a button (A/B/X/Y or Cross/Circle/Square/Triangle), for prompts that match the physical pad. |
GetNameEXT, GetPathEXT, GetSerialEXT, GetFirmwareVersionEXT, GetSteamHandleEXT, GetConnectionStateEXT | Device identity and wired or wireless state, read from the information captured when the pad was opened (the connection state is refreshed each update). |
GetTouchpadCountEXT, GetTouchpadFingerCountEXT, GetTouchpadFingerEXT | Touchpad finger tracking with position and pressure; the extra buttons Misc1EXT, Paddle1EXT–Paddle4EXT and TouchPadEXT appear in the button flags. |
setKeyboardEmulationEnabledEXT, getKeyboardEmulationEnabledEXT | Process-wide and off by default. When enabled, PlayerIndex::One gets a connected software pad driven by the keyboard, with or without a physical pad: left stick W/A/S/D, right stick the arrow keys, D-pad T/F/G/H, A/B/X/Y on K/L/J/I, shoulders Q/E, triggers Z/C (0 or 1), stick clicks Left/Right Shift, Start/Back Enter/Escape. Buttons are combined with a real pad's, a nonzero keyboard axis wins over the physical one, and the dead-zone modes still apply. Capabilities report the mapped controls; BigButton, vibration, sensors and identity are not emulated. The keys stay visible to Keyboard::GetState(), and players two to four are unchanged (docs/keyboard-device-emulation.md). |
The capability flags are probed per device when the pad is opened (buttons and axes it really has, rumble, trigger rumble, light bar, touchpad, gyroscope, accelerometer), so GetCapabilities describes the specific pad rather than an assumed Xbox 360 controller. None of these extensions has been observed on real hardware in CNA's own manual verification log, which records gamepad rumble, light bar, sensors, hot-plug and GetGUIDEXT as implemented but hardware-unverified.
Raw joysticks and device callbacks
CNA::Input::Joysticks is the raw, unmapped view of the same hardware, deliberately independent of GamePad's Xbox-shaped mapping, for the rare case that needs a device's native layout: GetJoysticksEXT() enumerates every connected device, and GetCapabilitiesEXT(id) and GetStateEXT(id) read one device's axis, button, hat and trackball counts and values by index (Joysticks.cpp). Devices are addressed by platform id narrowed to 32 bits. Joysticks::ConnectedEXT and DisconnectedEXT are raised from the event bridge, a connect only once per id and only when the service confirms the device, a disconnect only for an id that was announced; InputDevices raises the equivalent mouse and keyboard callbacks and enumerates mice, keyboards and touch devices.
Controller mappings
Which connected device counts as an XNA gamepad, and which of its buttons is A, is decided by SDL3’s gamepad layer, not by CNA. The SDL3 platform enumerates gamepads with SDL_GetGamepads and opens each as an SDL_Gamepad (Sdl3InputServices.cpp), so the translation from a physical device to the standard layout (face buttons, D-pad, two sticks, two triggers, shoulders, Start, Back and Guide) comes from SDL’s built-in and bundled mapping data. CNA ships and parses no mapping database of its own. A device SDL cannot map is not reported as a gamepad; it still appears in the raw Joysticks view above.
A missing or wrong mapping is therefore corrected at the SDL layer, for example through SDL’s own SDL_GAMECONTROLLERCONFIG environment variable or SDL’s mapping API, without any change to CNA (CNA’s platform-input-notes.md, “Gamepad backend & mapping”). The one CNA-owned translation point is Sdl3GamepadControls.hpp, which turns SDL’s buttons and axes into CNA’s vocabulary; when a pad first takes a slot, its SDL joystick type is cached and later reported as XNA’s GamePadType.
Evidence. The SDL-to-CNA translation is pinned by Sdl3EventMapperTests (GamepadAxesMapToCnaVocabularyAndBridgeNumerics, EverySupportedGamepadButtonMapsToCnaVocabulary, in Sdl3EventMapperTests.cpp), and the platform-to-XNA boundary by GamePadMappingTest.EveryButtonMapsToItsXnaFlag (GamePadMappingTests.cpp). Which devices SDL maps, and how, is SDL’s behaviour and is not tested by CNA. Checked by reading at c1c316b9; not executed.
Haptics beyond SetVibration
CNA::Input::Haptics reaches force-feedback hardware that GamePad::SetVibration cannot, such as a haptic mouse or a wheel (Haptics.cpp). GetHapticsEXT() enumerates devices; OpenEXT(id), OpenFromJoystickEXT(joystickId) and OpenFromMouseEXT() return an owning, move-only HapticDevice, closed (IsOpenEXT() false) when the platform has no haptics service or the device cannot be opened, in which case every member is a safe no-op. Its surface has two tiers:
- the simple trio
InitRumbleEXT(),PlayRumbleEXT(strength, lengthMs),StopRumbleEXT()for single-strength rumble, conceptuallySetVibrationfor non-gamepad hardware; - the full effect lifecycle
CreateEffectEXT,UpdateEffectEXT,RunEffectEXT,StopEffectEXT,DestroyEffectEXT,GetEffectStatusEXTandStopAllEffectsEXT, plus gain, autocenter, pause and resume. An effect is described by one flattenedHapticEffectEXTvalue (CNA/Input/HapticEffect.hpp): itstype, aHapticEffectTypeEXT(Constant,Sine,Square,Triangle,SawtoothUp,SawtoothDown,Ramp,Spring,Damper,Inertia,Friction,LeftRightorCustom), decides which of its other fields count, andlengthis in milliseconds orHapticEffectEXT::InfiniteLengthEXT.CreateEffectEXTreturns the new effect id, or -1 on failure;SetGainEXTandSetAutocenterEXTtake 0 to 100. Shaped feedback (constant, periodic, ramp, condition, left-right or custom effects) is available only whenIsEffectSupportedEXTaccepts that effect. The classes in these fragments live inCNA::Input(Haptics,HapticDevice,Power,PowerStateEXT), whileGamePadandPlayerIndexcome fromMicrosoft::Xna::Framework::InputandMicrosoft::Xna::Framework.
Subsystem ownership is handled here. Every enumeration and query acquires PlatformSubsystem::Haptic from the platform for the duration of the call and releases it; a successful open transfers that lease to the HapticDevice, which closes the native handle before releasing it in Dispose() or its destructor. The fake-backend tests in SdlHapticBackendTests.cpp assert a balanced lease count. An older revision assumed the application had initialized the SDL haptic subsystem and could fail to enumerate anything; that is no longer the case.
// A haptic-capable mouse is outside GamePad::SetVibration's scope.
if (Haptics::IsMouseHapticEXT())
{
HapticDevice mouseHaptic = Haptics::OpenFromMouseEXT();
if (mouseHaptic.IsOpenEXT() && mouseHaptic.InitRumbleEXT())
{
mouseHaptic.PlayRumbleEXT(0.5f, 150); // half strength, 150 ms
}
} // mouseHaptic closes the device and releases its lease here
Syntax-checked against the TARGET headers (not executed). The SDL3 platform provides the haptics service behind this class; the Headless and Terminal platforms provide none, so every open returns a closed device there.
Host battery and host motion sensors
Two more static classes in CNA::Input read the host device rather than a controller. They are compiled unconditionally, with no option and no #ifdef, so they exist even when CNA_DEVICES is off. In a default build Power is therefore the only host-battery API (and CNA::Input::Clipboard the only clipboard API), because the overlapping CNA::Devices layer is compiled out.
Power::GetInfoEXT(secondsLeft, percent)returns aPowerStateEXTand fills both outputs from one platform power reading, so state, percentage and remaining time always come from the same instant (Power.cpp).Sensors::GetSensorsEXT(),GetAccelerometerEXT(v)andGetGyroscopeEXT(v)are stateless: each call acquires the platform's sensor subsystem, starts the sensor, takes one reading, stops it and releases the subsystem in that order (Sensors.cpp). They return an empty list orfalsewhen the platform has no sensor service, the subsystem cannot start or the sensor is absent. An earlier revision never initialized SDL's sensor subsystem, so enumeration was silently empty unless something else had started it; at this snapshotCnaInputSensorsTestcounts the acquisitions and asserts a zero balance after each call. For event-driven, throttled sensor objects use Microsoft::Devices::Sensors instead.
// Host and controller batteries use separate queries.
int hostSecondsLeft = 0, hostPercent = 0;
if (Power::GetInfoEXT(hostSecondsLeft, hostPercent) == PowerStateEXT::OnBattery
&& hostPercent < 15)
{
ShowLowBatteryWarning("this device");
}
int padPercent = 0;
if (GamePad::GetPowerInfoEXT(PlayerIndex::One, padPercent) == PowerStateEXT::OnBattery
&& padPercent < 15)
{
ShowLowBatteryWarning("controller 1");
}
Syntax-checked against the TARGET headers (not executed); ShowLowBatteryWarning is the game's own function.
Evidence
The controller, joystick, haptic, power and sensor classes are covered by unit tests against canned or fake platform services (for example GamePadInputTests.cpp, JoystickTests.cpp, PowerTests.cpp, SensorsTests.cpp and the haptic backend suite). Those establish argument translation, slot handling, lease balance and failure shapes. They do not establish behaviour on physical controllers, haptic devices or host sensors: no demo-driven or manual hardware pass is recorded for the joystick, sensor and power extensions, and the gamepad extensions are recorded as hardware-unverified. Nothing on this page was executed.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Deep dives
- The input model · Sensors and vibration · Host devices