Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 55 additions & 44 deletions docs/end-user-gamepad-guide.md

Large diffs are not rendered by default.

43 changes: 37 additions & 6 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ streams native `0x30` reports every 15 milliseconds. This coalesces separate
acceleration and gyroscope API updates into the three-sample report cadence used
by a physical USB controller.

For every gamepad report ID, the VHF driver caches the newest complete input
report and answers synchronous `GetInputReport` requests from that cache. This
lets Windows HID consumers retrieve the current battery state for Xbox One,
Xbox Series, DualShock 4, DualSense, and Switch Pro instead of relying only on
the asynchronous input stream.

That HID report does not change the XInput battery classification of the VHF
device. On a Windows desktop where XInput enumerated the virtual Xbox
controller, `XInputGetBatteryInformation` returned `BATTERY_TYPE_DISCONNECTED`
and `BATTERY_LEVEL_EMPTY` even while its input was available through
`XInputGetState`. Headless Windows CI did not expose an XInput slot for the same
device. Neither path exposes the remote battery through XInput. Consumers that
prefer XInput, including SDL's correlated Windows Xbox path and Windows Game
Bar, therefore do not receive the remote Xbox battery value. DualShock 4,
DualSense, and Switch Pro battery state is independently covered through SDL's
HID path.

The current Steam client displays its controller battery indicator only when it
classifies the device as Bluetooth or wireless. Because VHF exposes a wired
virtual transport, Steam can hide the battery indicator for every Windows
profile even when another HID consumer can retrieve the submitted value.

Windows VHF devices do not expose a Bluetooth transport identity to HIDAPI.
The Windows backend therefore reports DualShock 4 and DualSense requests as
effective USB profiles through `Gamepad::profile()` and uses the matching USB
Expand Down Expand Up @@ -127,8 +149,14 @@ reserving the physical middle button for button scrolling.

Gamepad support normally prefers `uhid` because descriptors, raw HID identity,
feature reports, and output reports matter for controller compatibility. Xbox
One and Xbox Series use backend-only Bluetooth identities with a 283-byte BLE
descriptor, sparse input bitmap, and four-motor output framing. The report keeps
One and Xbox Series use backend-only Bluetooth identities with a BLE descriptor,
sparse input bitmap, four-motor output framing, and the native report-ID `0x04`
battery notification. When `CreateGamepadOptions::metadata.has_battery` is true,
the descriptor exposes the notification's four categorical wireless charge
levels through a byte-aligned standard HID Battery Strength field and the
backend emits it only when the submitted state contains battery data. Clients
without battery support therefore do not create a phantom Linux power device
or receive a fabricated charge level. The normal input report keeps
the native byte layout used by HIDAPI while advertising `Rx`/`Ry` for the right
stick and `Z`/`Rz` for the triggers, so Linux evdev exposes the canonical
`ABS_RX`/`ABS_RY` and `ABS_Z`/`ABS_RZ` axes expected by Steam. This keeps the bus,
Expand Down Expand Up @@ -171,8 +199,10 @@ Xbox 360 retains its `0x045E:0x028E` identity, while its Linux uinput device use
the Bluetooth bus, so consumers select the sparse button mapping. The Xbox One
and Xbox Series UHID transports use the native Bluetooth product identities
`0x045E:0x0B20` and `0x045E:0x0B13`, respectively. Their Bluetooth HID reports
carry canonical gamepad input and four-motor output, which the backend decodes
into ordinary and independent trigger-rumble callbacks.
carry canonical gamepad input, coarse battery levels, and four-motor output,
which the backend decodes into ordinary and independent trigger-rumble
callbacks. The backend maps the continuous percentage to the nearest native
Xbox level exposed by SDL: 10, 40, 70, or 100 percent.

If UHID is unavailable, the Xbox One and Xbox Series uinput fallbacks use the
corresponding Bluetooth product identities (`0x0B20` and `0x0B13`, respectively),
Expand All @@ -183,8 +213,9 @@ pressed, keeping face buttons, shoulders, menu buttons, Guide, L3, and R3 at
their expected indices. D-pad directions are reported through the hat axes and
exposed as logical buttons by standard gamepad consumers. The fallback retains
all of those controls, analog trigger input, and ordinary force feedback, but
Linux uinput cannot expose independent trigger motors, so its effective profile
clears trigger-rumble support.
Linux uinput cannot expose independent trigger motors or native Xbox battery
notifications, so its effective profile clears trigger-rumble and battery
support.

DualShock 4 and DualSense remain on `uhid` so their descriptors, motion,
touchpad, battery, feature reports, and profile-specific output reports stay
Expand Down
15 changes: 12 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,18 @@ Profiles advertise support for features such as rumble, trigger rumble, RGB and
player LEDs, adaptive triggers, motion sensors, touchpads, battery state,
profile-specific buttons, and raw output reports. Consumers should query
profile and backend capabilities before warning users about unsupported client
features. Xbox One and Xbox Series advertise `supports_trigger_rumble`; the
Linux UHID Bluetooth transport preserves that capability, while the uinput
fallback clears it and retains ordinary rumble.
features. Xbox One and Xbox Series advertise `supports_trigger_rumble` and
`supports_battery`; the Linux UHID Bluetooth transport preserves both
capabilities, while the uinput fallback clears them and retains ordinary
rumble. The Linux Xbox transport includes its battery descriptor only when
`CreateGamepadOptions::metadata.has_battery` is true, and it emits battery
reports only for submitted states that contain battery data. On Windows, the
Xbox HID report carries battery strength, but consumers
that prefer XInput do not receive the submitted remote value. The current Steam
client also hides its controller battery indicator for devices it does not
classify as Bluetooth or wireless; Windows VHF exposes a wired virtual transport
for every profile.

The `misc1` button represents Share/Capture/Mic Mute-style controls and is
available on the generic, Xbox Series, DualSense, and Switch Pro profiles; Xbox
360 and Xbox One do not advertise that extra button.
30 changes: 26 additions & 4 deletions docs/windows-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@ backpressure does not turn relative movement into a replaceable absolute state.
Profile initialization replies are prioritized over pending controller states
so the Switch Pro handshake remains responsive.

The driver also caches the newest complete input report for each report ID and
answers VHF `GetInputReport` requests from that cache. Synchronous HID consumers
can therefore query the current controller and battery state even when they do
not consume the streaming read queue. Unnumbered reports are returned with the
leading zero report-ID byte expected by Windows HID APIs.

For Xbox profiles, this HID input value is separate from the battery result
returned by XInput. On a Windows desktop where XInput enumerated the VHF Xbox
device, `XInputGetBatteryInformation` returned `BATTERY_TYPE_DISCONNECTED` and
`BATTERY_LEVEL_EMPTY` even while `XInputGetState` received its input and
`GetInputReport` contained the submitted value. Headless Windows CI did not
expose an XInput slot for the same device. Neither path exposes the remote
battery through XInput. SDL's Windows Xbox path and Windows Game Bar therefore
have no XInput battery value to display. VHF does not expose a
wireless-transport or XInput battery-type setting in `VHF_CONFIG`.

The current Steam client also renders its controller battery indicator only for
devices it classifies as Bluetooth or wireless. All Windows VHF profiles use a
wired virtual transport, so this UI policy can hide battery values that remain
available to HID consumers. SDL's HID path independently receives battery state
for the Windows DualShock 4, DualSense, and Switch Pro profiles.

The driver rejects virtual HID create, destroy, and broker-instance reset IOCTLs
unless the requestor token contains the `NT SERVICE\libvirtualhid_broker`
service SID. On the first boot after installation, before Windows applies a
Expand Down Expand Up @@ -420,10 +442,10 @@ Xbox Series profile remains `VID_045E&PID_0B12`; the Windows transport presents
it with release `0x0509` and the `VID_045E&PID_0B12&IG_00` XInputHID match ID
observed from physical Xbox Series USB and Xbox Wireless Adapter connections.
The VHF child preserves the native 17-byte GIP-shaped input report, and the
report parser accepts the native eight-byte four-motor Xbox payload when a
consumer delivers it. The Xbox 360 profile is rejected by the UMDF/VHF backend
because a real Xbox 360 controller is an XUSB device rather than a VHF HID
gamepad.
last byte carries battery strength for both Xbox One and Xbox Series. The report
parser accepts the native eight-byte four-motor Xbox payload when a consumer
delivers it. The Xbox 360 profile is rejected by the UMDF/VHF backend because a
real Xbox 360 controller is an XUSB device rather than a VHF HID gamepad.

DualShock 4 and DualSense answer the calibration, pairing, and firmware feature
requests used by their Windows HIDAPI initialization paths. Switch Pro answers
Expand Down
2 changes: 1 addition & 1 deletion src/core/profiles.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1961,7 +1961,7 @@ namespace lvh::profiles {
profile.capabilities = {
.supports_rumble = true,
.supports_trigger_rumble = true,
.supports_battery = include_share_button,
.supports_battery = true,
};
profile.report_descriptor = make_xbox_gip_report_descriptor(include_share_button);
return profile;
Expand Down
80 changes: 70 additions & 10 deletions src/platform/linux/uhid_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,17 @@ namespace lvh::detail {
constexpr std::size_t xbox_bluetooth_input_report_size = 17;
constexpr std::uint8_t xbox_bluetooth_rumble_report_id = 0x03;
constexpr std::size_t xbox_bluetooth_rumble_report_size = 9;
constexpr std::uint8_t xbox_bluetooth_battery_report_id = 0x04;
constexpr std::size_t xbox_bluetooth_battery_report_size = 2;

std::vector<std::uint8_t> make_xbox_bluetooth_report_descriptor() {
// Preserve the native 283-byte Xbox BLE report layout while advertising
// the conventional Linux evdev usages for the right stick and triggers.
std::vector<std::uint8_t> make_xbox_bluetooth_report_descriptor(bool include_battery) {
// Preserve the native Xbox BLE report layout while advertising the
// conventional Linux evdev usages for the right stick and triggers.
// HIDAPI consumes the same byte offsets directly, while the kernel maps
// these usages to ABS_RX/ABS_RY and ABS_Z/ABS_RZ for Steam.
constexpr std::array<std::uint8_t, 283> descriptor {
// these usages to ABS_RX/ABS_RY and ABS_Z/ABS_RZ for Steam. Battery-capable
// clients add the native two-byte Xbox Bluetooth battery notification as
// report 4 through the standard Battery Strength usage.
std::vector<std::uint8_t> descriptor {
0x05,
0x01, // Usage Page (Generic Desktop)
0x09,
Expand Down Expand Up @@ -409,9 +413,36 @@ namespace lvh::detail {
0x91,
0x02, // Output (Data, Variable, Absolute)
0xC0, // End Collection
0xC0, // End Collection
};
return {descriptor.begin(), descriptor.end()};

if (include_battery) {
// SDL's Linux Xbox descriptor reader requires byte-sized fields. Keep
// the native wireless/source flag in the byte while narrowing its
// logical range to the four values this backend emits, so Linux power
// consumers can scale the categorical levels without breaking HIDAPI.
constexpr std::array<std::uint8_t, 16> battery_descriptor {
0x05,
0x06, // Usage Page (Generic Device Controls)
0x09,
0x20, // Usage (Battery Strength)
0x85,
xbox_bluetooth_battery_report_id, // Report ID (4)
0x15,
0x04, // Logical Minimum (wireless, empty)
0x25,
0x07, // Logical Maximum (wireless, full)
0x75,
0x08, // Report Size (8)
0x95,
0x01, // Report Count (1)
0x81,
0x02, // Input (Data, Variable, Absolute)
};
descriptor.insert(descriptor.end(), battery_descriptor.begin(), battery_descriptor.end());
}

descriptor.push_back(0xC0); // End Collection
return descriptor;
}
#endif

Expand Down Expand Up @@ -596,7 +627,7 @@ namespace lvh::detail {
return false;
}

DeviceProfile uhid_transport_profile(const DeviceProfile &requested_profile) {
DeviceProfile uhid_transport_profile(const DeviceProfile &requested_profile, bool include_battery) {
auto transport_profile = requested_profile;
if (is_xbox_uhid_profile(requested_profile.gamepad_kind)) {
const auto series = requested_profile.gamepad_kind == GamepadProfileKind::xbox_series;
Expand All @@ -606,7 +637,7 @@ namespace lvh::detail {
transport_profile.report_id = xbox_bluetooth_input_report_id;
transport_profile.input_report_size = xbox_bluetooth_input_report_size;
transport_profile.output_report_size = xbox_bluetooth_rumble_report_size;
transport_profile.report_descriptor = make_xbox_bluetooth_report_descriptor();
transport_profile.report_descriptor = make_xbox_bluetooth_report_descriptor(include_battery);
}
return transport_profile;
}
Expand Down Expand Up @@ -653,6 +684,29 @@ namespace lvh::detail {
return report;
}

std::vector<std::uint8_t> make_xbox_bluetooth_battery_report(const GamepadBattery &battery) {
constexpr auto wireless_battery_source = std::byte {0x04};
constexpr std::uint8_t ten_percent_battery_level = 0;
constexpr std::uint8_t forty_percent_battery_level = 1;
constexpr std::uint8_t seventy_percent_battery_level = 2;
constexpr std::uint8_t full_battery_level = 3;

const auto percentage = std::min<std::uint8_t>(battery.percentage, 100U);
auto level = full_battery_level;
if (percentage <= 25U) {
level = ten_percent_battery_level;
} else if (percentage <= 55U) {
level = forty_percent_battery_level;
} else if (percentage <= 85U) {
level = seventy_percent_battery_level;
}

std::vector<std::uint8_t> report(xbox_bluetooth_battery_report_size);
report[0] = xbox_bluetooth_battery_report_id;
report[1] = std::to_integer<std::uint8_t>(wireless_battery_source | static_cast<std::byte>(level));
return report;
}

#endif

std::optional<int> uinput_misc1_button(GamepadProfileKind kind) {
Expand Down Expand Up @@ -3213,7 +3267,7 @@ namespace lvh::detail {
OperationStatus create(DeviceId id, const CreateGamepadOptions &options) {
uhid_event event {};
auto &request = event.u.create2;
const auto transport_profile = uhid_transport_profile(options.profile);
const auto transport_profile = uhid_transport_profile(options.profile, options.metadata.has_battery);

if (transport_profile.report_descriptor.size() > sizeof(request.rd_data)) {
return OperationStatus::failure(ErrorCode::unsupported_profile, "HID report descriptor is too large for UHID");
Expand Down Expand Up @@ -3248,6 +3302,7 @@ namespace lvh::detail {
transport_profile.report_descriptor.size()
);
profile_ = options.profile;
supports_battery_ = options.metadata.has_battery;
{
std::lock_guard lock {state_mutex_};
last_state_ = {};
Expand Down Expand Up @@ -3294,6 +3349,9 @@ namespace lvh::detail {
) :
report;
auto status = write_input_report(transport_report);
if (status.ok() && supports_battery_ && state.battery && is_xbox_uhid_profile(profile_.gamepad_kind)) {
status = write_input_report(make_xbox_bluetooth_battery_report(*state.battery));
}
if (status.ok()) {
last_state_ = state;
}
Expand Down Expand Up @@ -3629,6 +3687,7 @@ namespace lvh::detail {
std::string unique_id_;
std::array<std::uint8_t, 6> playstation_mac_address_ {};
GamepadState last_state_;
bool supports_battery_ = false;
std::atomic_bool open_ = true;
std::atomic_bool running_ = false;
std::jthread reader_;
Expand Down Expand Up @@ -3661,6 +3720,7 @@ namespace lvh::detail {
return std::nullopt;
}
effective_profile.capabilities.supports_trigger_rumble = false;
effective_profile.capabilities.supports_battery = false;
return effective_profile;
#endif
}
Expand Down
Loading
Loading