macOS wired driver
Summary
Wired Xbox One, Series X|S and Elite Series 2 controllers get no rumble on macOS through the system driver, and apps built on it (GameController, SDL, browsers, CrossOver/Wine) lose it too. Apple’s wired driver and the HID service plugin it feeds never implement rumble for these controllers, so no rumble packet is ever sent; the same controller rumbles over Bluetooth and over a direct USB connection. The controller itself is fine, and updating its firmware does not change this.
The driver was analysed and the results below were measured on macOS 15.7 (Darwin 24.6, driver version 12.6.1, arm64e). Other macOS versions may differ.
Components
| Piece | Path | Role |
|---|---|---|
XboxGamepad.dext |
/System/Library/DriverExtensions/ |
DriverKit driver. Matches USB 045e:028e (360), 02ea (One S), 0b00 (Elite 2) and 0b12 (Series X|S), exposes a HID device |
XboxGamepadHIDServicePlugin |
/System/Library/HIDPlugins/ServicePlugins/ |
Turns HID reports into GameController events and, for some controllers, drives haptics |
gamecontrollerd |
/usr/libexec/ |
Serves GCController, including haptics |
Interface class 255, subclass 71 (0x47, GIP) is matched for the Xbox One
family; the 360 uses subclass 93.
The dext
XboxHIDDeviceis anIOUserHIDDevicesubclass. OneXboxUSBDeviceservice attaches to the USB device and anXboxSeriesXGamepad/XboxOneSGamepad/XboxEliteV2GamepadHID service attaches to the interface.- The HID report ID is the GIP command byte. The HID report descriptors
for the three controllers expose only:
- output
0x01, 12 bytes (13 on the wire with the ID) - output
0x05, 4 bytes (the 5-byte Start packet05 20 01 01 00) - input
0x07(guide button) and0x20(input state)
There is no output report
0x09(rumble). - output
setReportis a raw pass-through.XboxHIDDevice::setReportignores the report ID and type and calls_outPipe->AsyncIO(buffer, length). It fails only when there is no completion action, the pipes are not ready, no send action is free or the device is stopping. Nothing in the dext translates or filters report contents.- The dext never sends the Start packet (
05 20 01 01 00does not occur in the binary). - The dext itself is signed with
com.apple.developer.driverkit.builtinand lives on the sealed system volume.
The HID service plugin
Haptics are implemented in the shared base class GCGamepadHIDServicePlugin,
with empty defaults that each controller class overrides:
| Hook | Base class | Xbox 360 plugin | One S, Series X|S, Elite 2 plugins |
|---|---|---|---|
dispatchHapticEvent |
ret (no-op) |
implemented | not overridden |
defaultHapticMotors |
empty NSArray |
implemented | not overridden |
updateHaptics: |
ret (no-op) |
not overridden | not overridden |
The 360 plugin builds the classic 8-byte report 00 08 00 <left> <right> 00 00 00
and sends it with setReport:reportLength:withIdentifier:forType:error:. The
One S, Series X|S and Elite 2 plugins each implement only
handleInputPayload:withData:timestamp: (Elite 2 also
dispatchPowerOnMessageCompletion:). So nothing in these plugins builds a GIP
rumble packet (command 0x09).
This also explains the reported symptoms:
GCController.hapticsadvertises motors but nothing moves. The advertised capabilities do not come from the plugin: its motor list is empty and its dispatch is a no-op, sogamecontrollerdplays patterns with no error.- Output report
0x09returnskIOReturnAborted(reproduced).0x09is not in the descriptor, and HIDDriverKit rejects it before the dext sends anything; see Measured on hardware.
The Bluetooth plugin (XboxOneHIDServicePlugin)
A second plugin, /System/Library/HIDPlugins/ServicePlugins/XboxOneHIDServicePlugin.plugin,
handles Bluetooth controllers and does implement rumble. Findings:
- Its
matchService:options:score:accepts vendor045eand a list of PIDs (02ea,02fd,02e0,0b20,0b02,0b05,0b22,0b0c,0b21,0b13, …), but returns no match when theTransportproperty equalsUSB. The PID list also includes the USB PID02ea, so the transport check is what excludes wired controllers. - Its
dispatchHapticEventbuilds a 9-byte report with ID0x03: a motor enable mask (0x0f), four magnitudes scaled to 0-100 (trigger left and right, then the two handles), a duration byte and two zero bytes. This is the Bluetooth HID output report, not GIP. - The
XboxWirelessGamepadclass in the dext takes anIOUSBHostDeviceprovider, but no personality in the dext’sInfo.plistinstantiates it and its entitlement only lists the four controller PIDs, so it is not reachable on 15.7. The matchingXboxWirelessGamepadHIDServicePluginhas no haptics.
Measured over Bluetooth Low Energy (same Series X|S, firmware 5.23.6.0, macOS
15.7): rumble works in the browser Gamepad API. The controller appears as
product ID 0b13 (the plugin’s list includes it), as an IOHIDUserDevice
with Transport "Bluetooth Low Energy" and MaxOutputReportSize 9, which is
the nine-byte report 0x03 the plugin builds. The descriptor has an output
report with ID 3. Apple’s wired dext is not involved, so rumble goes through
the plugin’s dispatchHapticEvent. gamecontrollerd still lists the same
synthetic Xbox 360 pad as in the wired case.
Forcing the Bluetooth plugin onto a wired controller would not work even if the
Transport check could be spoofed: the dext forwards report bytes untouched
to the USB OUT endpoint, where byte 0 is the GIP command. A 03 0f ... packet
is GIP command 0x03, not rumble (0x09).
Measured on hardware
| Series X | S, firmware 5.23 (bcdDevice 0x0517), wired, macOS 15.7. |
Registry. The USB device’s UsbExclusiveOwner is XboxUSBDevice. The dext
publishes one HID device, XboxSeriesXGamepad (AppleUserHIDDevice), with
Transport "USB", MaxOutputReportSize 13, MaxInputReportSize 19,
ReportInterval 4000 and the descriptor extracted from the binary. gamecontrollerd
adds an AppleGCSyntheticDevice (“GamePad-1”) that looks like an Xbox 360 pad
(product ID 028e) with MaxOutputReportSize 0. That synthetic device is what
apps see, and it has no output at all.
Output reports through IOHIDDeviceSetReport. For ID 0x09 and 0x00 the
buffer was the 13-byte GIP rumble packet, for the others it was zeros after the
report ID:
| report ID | length | in descriptor | result |
|---|---|---|---|
0x05 |
5 | yes | success |
0x01 |
13 | yes | success |
0x01 |
5 | wrong length | kIOReturnAborted |
0x00, 0x02, 0x03, 0x09 |
13 | no | kIOReturnAborted |
The dext’s own log shows where the rejection happens: IOHIDDevice.cpp
ProcessReport … ret:0xe00002c2 (kIOReturnBadArgument) for each rejected
report, and no AsyncIO line. HIDDriverKit inside the dext checks the report
ID and length against the descriptor before XboxHIDDevice::setReport runs,
and the user-level result is kIOReturnAborted. Reports that pass are sent to
the controller unchanged, with the report ID as the first byte on the wire, so
only commands 0x01 and 0x05 can ever be sent through the HID device.
Rumble (0x09) cannot.
Checking the controller’s rumble
xboxctl rumble talks to the controller directly over USB and shows whether the
motors work regardless of the system driver; see
Testing rumble.
Reports
- Wired rumble not working on macOS (27.0.1) was mentioned in issue #8.
- It has been reported to Apple as FB25254769. Filing a duplicate that mentions that number helps Apple prioritise it.