Gamepads, joysticks, haptics and host power
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, the SDL3 gamepad and haptic services and the named tests at 009d40f5; 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 and, through the Linux evdev hub, the native X11 and Wayland platforms provide a gamepad service; SDL2, Win32, Terminal and Headless have none, and every GamePad query there reports a disconnected pad.
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.
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. |
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 on the native Linux platforms
On the native X11 and WAYLAND platforms, controllers come from the kernel's evdev nodes and not from SDL. That makes one question CNA's own responsibility: which evdev device counts as an XNA gamepad, and which of its buttons is A. The SDL3 platform leaves this question to SDL's gamepad layer, and CNA adds nothing there. The Linux sources are compiled only for a Linux target (src/Linux in the platform CMake file).
How a node is classified
The controller hub opens each node under /dev/input and classifies it with ClassifyEvdevDevice (EvdevLayout.cpp):
- A node with
INPUT_PROP_ACCELEROMETERis never a controller. It is the motion-sensor half of a pad, and the hub attaches it to the pad it belongs to. - Any button in the kernel's gamepad range (
BTN_GAMEPADonwards) makes the node a gamepad. - Buttons only in the joystick range (or
BTN_TRIGGER_HAPPY), together with sticks or a hat, make it a joystick. - Anything else, such as a keyboard, a mouse or a touchpad, is closed at once.
Many generic USB and Bluetooth HID pads describe themselves with only the joystick range. On their own they therefore appear only in the raw Joysticks view above, never as a GamePad.
The mapping database
When the hub starts (on the first gamepad acquisition), ControllerMappingDatabase::FromEnvironment() reads the file named by CNA_GAMECONTROLLERCONFIG_FILE, then the entries in CNA_GAMECONTROLLERCONFIG, one per line (EvdevMapping.cpp). The format is the community controller database's, the gamecontrollerdb.txt format that SDL-based games use. CNA ships no database of its own; choosing one is left to the user or the game.
CNA_GAMECONTROLLERCONFIG_FILE=~/gamecontrollerdb.txt ./my-game
A usable mapping decides over what the driver reports (EvdevControllers.cpp). A mapping turns a joystick-range pad into a gamepad that takes a free player slot. It also overrides a pad that the kernel's gamepad API already describes, which is how a user corrects one. A mapping whose elements name no input that the device has is ignored.
A mapping is matched by a GUID made from the device's bus, vendor, product and version (EvdevMapping.hpp). The exact version is tried first, then any version of the device. When an entry states a crc: field, the CRC-16/ARC of the device name must also match.
The reader follows the database's rules:
- Entries for another
platform:are skipped. - Comments, blank lines and malformed lines are not entries.
- An element the reader does not know is skipped, and the rest of the entry still applies.
- A later entry for the same device replaces an earlier one.
- Of the
hint:conditions, the two button-label hints turn a Nintendo-style labelled entry into a positional one by swapping the face buttons. Any other hint takes the default that the entry states. - Button, axis and hat indices are numbered as the database numbers them.
- Half axes, inverted axes, buttons on axes and hat directions are honoured.
- Values are converted to CNA's scale, on which a stick pushed up is positive.
Evidence. The LinuxEvdevMapping suite (LinuxEvdevMappingTests.cpp) consists of pure-function tests. It runs through the CnaX11MappingTests and CnaWaylandMappingTests entries, and needs no device and no display (UnitTests.cmake). Its test against a real database file, EveryEntryOfADatabaseFileIsRead, skips unless CNA_TEST_GAMECONTROLLERDB names a file. No workflow sets that variable, so acceptance of the full community file is not a CI result. Checked by reading at 009d40f5; 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; X11 and Wayland provide an evdev-based one when the backend was built with evdev and controllers exist; the other 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