From dad1edd2eaf655a41c35206a6b6ef8b1d89bf5f6 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Sun, 30 Aug 2026 16:58:54 -0400
Subject: [PATCH 1/5] fix: battery reporting improvements
---
docs/end-user-gamepad-guide.md | 2 +-
docs/platform-support.md | 24 ++++--
docs/usage.md | 7 +-
docs/windows-driver.md | 14 +++-
src/core/profiles.cpp | 2 +-
src/platform/linux/uhid_backend.cpp | 60 +++++++++++++-
.../windows/driver/libvirtualhid_umdf.cpp | 58 +++++++++++++
tests/consumers/linux_sdl3_xbox_consumer.cpp | 33 ++++++++
.../fixtures/linux_backend_test_hooks.hpp | 7 +-
tests/fixtures/linux_backend_test_hooks.cpp | 40 +++++----
tests/unit/test_gamepad_adapter.cpp | 31 +++++++
tests/unit/test_linux_backend.cpp | 2 +
tests/unit/test_linux_consumers.cpp | 26 ++++++
tests/unit/test_profiles.cpp | 2 +
tests/unit/test_windows_consumers.cpp | 81 +++++++++++++++++++
15 files changed, 355 insertions(+), 34 deletions(-)
diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md
index aa1a04e..953bb03 100644
--- a/docs/end-user-gamepad-guide.md
+++ b/docs/end-user-gamepad-guide.md
@@ -163,7 +163,7 @@ that prevents the feature from working end to end.
| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) |
| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) |
| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) |
-| 4 | Host profile | The Xbox One profile rejects battery updates independently of the client. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) |
+| 4 | Host profile and backend | The tested release rejected Xbox One battery updates and did not expose Xbox battery through the Linux Bluetooth or Windows synchronous HID paths. Current source fixes those host-side defects; the compatibility row remains the last end-to-end result until the complete Moonlight-Sunshine path is retested with a driver build containing the fix. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) |
| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) |
| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) |
| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
diff --git a/docs/platform-support.md b/docs/platform-support.md
index 078bed7..c57e8e1 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -91,6 +91,12 @@ 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.
+
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
@@ -127,8 +133,11 @@ 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 300-byte BLE
+descriptor, sparse input bitmap, four-motor output framing, and the native
+report-ID `0x04` battery notification. The descriptor exposes that notification
+through the standard HID Battery Strength usage so SDL's descriptor path can
+publish the native categorical 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,
@@ -171,8 +180,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),
@@ -183,8 +194,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
diff --git a/docs/usage.md b/docs/usage.md
index a2f34ba..df8bfbe 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -233,9 +233,10 @@ 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 `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.
diff --git a/docs/windows-driver.md b/docs/windows-driver.md
index 5420216..c7b685e 100644
--- a/docs/windows-driver.md
+++ b/docs/windows-driver.md
@@ -93,6 +93,12 @@ 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.
+
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
@@ -420,10 +426,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
diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp
index abb71ed..6b08624 100644
--- a/src/core/profiles.cpp
+++ b/src/core/profiles.cpp
@@ -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;
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index 1192447..55ab89d 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -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 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.
+ // 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 descriptor {
+ // these usages to ABS_RX/ABS_RY and ABS_Z/ABS_RZ for Steam. Report 4 is
+ // the native two-byte Xbox Bluetooth battery notification, advertised
+ // through the standard Battery Strength usage recognized by HIDAPI.
+ constexpr std::array descriptor {
0x05,
0x01, // Usage Page (Generic Desktop)
0x09,
@@ -409,6 +413,23 @@ namespace lvh::detail {
0x91,
0x02, // Output (Data, Variable, Absolute)
0xC0, // End Collection
+ 0x05,
+ 0x06, // Usage Page (Generic Device Controls)
+ 0x09,
+ 0x20, // Usage (Battery Strength)
+ 0x85,
+ xbox_bluetooth_battery_report_id, // Report ID (4)
+ 0x15,
+ 0x00, // Logical Minimum (0)
+ 0x26,
+ 0xFF,
+ 0x00, // Logical Maximum (255)
+ 0x75,
+ 0x08, // Report Size (8)
+ 0x95,
+ 0x01, // Report Count (1)
+ 0x81,
+ 0x02, // Input (Data, Variable, Absolute)
0xC0, // End Collection
};
return {descriptor.begin(), descriptor.end()};
@@ -653,6 +674,33 @@ namespace lvh::detail {
return report;
}
+ std::vector make_xbox_bluetooth_battery_report(const GamepadState &state) {
+ 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;
+
+ auto level = full_battery_level;
+ if (state.battery) {
+ const auto percentage = std::min(state.battery->percentage, 100U);
+ 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;
+ } else {
+ level = full_battery_level;
+ }
+ }
+
+ std::vector report(xbox_bluetooth_battery_report_size);
+ report[0] = xbox_bluetooth_battery_report_id;
+ report[1] = std::to_integer(wireless_battery_source | static_cast(level));
+ return report;
+ }
+
#endif
std::optional uinput_misc1_button(GamepadProfileKind kind) {
@@ -3294,6 +3342,9 @@ namespace lvh::detail {
) :
report;
auto status = write_input_report(transport_report);
+ if (status.ok() && is_xbox_uhid_profile(profile_.gamepad_kind)) {
+ status = write_input_report(make_xbox_bluetooth_battery_report(state));
+ }
if (status.ok()) {
last_state_ = state;
}
@@ -3661,6 +3712,7 @@ namespace lvh::detail {
return std::nullopt;
}
effective_profile.capabilities.supports_trigger_rumble = false;
+ effective_profile.capabilities.supports_battery = false;
return effective_profile;
#endif
}
diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp
index 5c1fe8a..74abfc1 100644
--- a/src/platform/windows/driver/libvirtualhid_umdf.cpp
+++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp
@@ -73,6 +73,7 @@ EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL LvhEvtIoDeviceControl;
EVT_WDF_OBJECT_CONTEXT_CLEANUP LvhEvtDeviceCleanup;
EVT_WDF_REQUEST_CANCEL LvhEvtOutputReadCanceled;
EVT_VHF_ASYNC_OPERATION LvhEvtVhfGetFeature;
+EVT_VHF_ASYNC_OPERATION LvhEvtVhfGetInputReport;
EVT_VHF_READY_FOR_NEXT_READ_REPORT LvhEvtVhfReadyForNextReadReport;
EVT_VHF_ASYNC_OPERATION LvhEvtVhfSetFeature;
EVT_VHF_ASYNC_OPERATION LvhEvtVhfWriteReport;
@@ -120,6 +121,7 @@ namespace {
std::wstring hardware_ids;
lvh::detail::windows::GenericPidFeatureState generic_pid_feature_state;
lvh::detail::windows::VhfInputReportQueue pending_input_reports;
+ std::map> latest_input_reports;
std::shared_ptr> in_flight_input_report;
std::size_t active_input_submissions {};
std::uint8_t switch_pro_packet_timer {};
@@ -307,6 +309,9 @@ namespace {
if (record.shutting_down || record.vhf_handle == nullptr) {
return STATUS_OBJECT_NAME_NOT_FOUND;
}
+ const auto configured_report_id = record.request.hardware_ids.report_id;
+ const auto report_id = configured_report_id == 0U || report.empty() ? configured_report_id : report.front();
+ record.latest_input_reports.insert_or_assign(report_id, report);
record.pending_input_reports.push(std::move(report));
}
@@ -421,6 +426,7 @@ namespace {
record->vhf_handle = nullptr;
record->vhf_ready_for_input_report = false;
record->pending_input_reports.clear();
+ record->latest_input_reports.clear();
record->submissions_drained.wait(lock, [record] {
return record->active_input_submissions == 0U;
});
@@ -561,6 +567,7 @@ namespace {
vhf_config.HardwareIDs = record->hardware_ids.data();
vhf_config.EvtVhfReadyForNextReadReport = LvhEvtVhfReadyForNextReadReport;
vhf_config.EvtVhfAsyncOperationGetFeature = LvhEvtVhfGetFeature;
+ vhf_config.EvtVhfAsyncOperationGetInputReport = LvhEvtVhfGetInputReport;
vhf_config.EvtVhfAsyncOperationSetFeature = LvhEvtVhfSetFeature;
vhf_config.EvtVhfAsyncOperationWriteReport = LvhEvtVhfWriteReport;
@@ -923,6 +930,36 @@ namespace {
return STATUS_SUCCESS;
}
+ NTSTATUS copy_vhf_input_report(DeviceRecord &record, HID_XFER_PACKET &packet) {
+ auto report_number = packet.reportId;
+ if (
+ report_number == 0U && record.request.hardware_ids.report_id != 0U &&
+ packet.reportBufferLen > 0U
+ ) {
+ report_number = packet.reportBuffer[0];
+ }
+
+ auto report = std::vector {};
+ {
+ std::lock_guard lock {record.mutex};
+ const auto iter = record.latest_input_reports.find(report_number);
+ if (iter == record.latest_input_reports.end()) {
+ return STATUS_NOT_SUPPORTED;
+ }
+ report = iter->second;
+ }
+
+ const auto unnumbered_report = report_number == 0U;
+ const auto leading_report_id_size = unnumbered_report && packet.reportBufferLen > report.size() ? 1U : 0U;
+ if (packet.reportBufferLen < report.size() + leading_report_id_size) {
+ return STATUS_BUFFER_TOO_SMALL;
+ }
+
+ std::fill_n(packet.reportBuffer, packet.reportBufferLen, UCHAR {});
+ std::copy(report.begin(), report.end(), packet.reportBuffer + leading_report_id_size);
+ return STATUS_SUCCESS;
+ }
+
bool handle_vhf_set_feature(DeviceRecord &record, const HID_XFER_PACKET &packet) {
if (
record.request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD &&
@@ -1326,6 +1363,27 @@ void LvhEvtVhfGetFeature(
static_cast(VhfAsyncOperationComplete(vhf_operation_handle, status));
}
+void LvhEvtVhfGetInputReport(
+ VhfContext vhf_client_context,
+ VHFOPERATIONHANDLE vhf_operation_handle,
+ VhfContext vhf_operation_context,
+ PHID_XFER_PACKET hid_transfer_packet
+) {
+ UNREFERENCED_PARAMETER(vhf_operation_context);
+
+ auto *record = static_cast(vhf_client_context);
+ if (record == nullptr || hid_transfer_packet == nullptr || hid_transfer_packet->reportBuffer == nullptr) {
+ static_cast(VhfAsyncOperationComplete(vhf_operation_handle, STATUS_INVALID_PARAMETER));
+ return;
+ }
+
+ const auto status = copy_vhf_input_report(*record, *hid_transfer_packet);
+ if (!NT_SUCCESS(status)) {
+ trace_status("EvtVhfGetInputReport complete", status);
+ }
+ static_cast(VhfAsyncOperationComplete(vhf_operation_handle, status));
+}
+
void LvhEvtVhfSetFeature(
VhfContext vhf_client_context,
VHFOPERATIONHANDLE vhf_operation_handle,
diff --git a/tests/consumers/linux_sdl3_xbox_consumer.cpp b/tests/consumers/linux_sdl3_xbox_consumer.cpp
index 357d1e7..8a7f0b8 100644
--- a/tests/consumers/linux_sdl3_xbox_consumer.cpp
+++ b/tests/consumers/linux_sdl3_xbox_consumer.cpp
@@ -281,6 +281,32 @@ namespace {
return false;
}
+ bool validate_battery(lvh::Gamepad &virtual_gamepad, SDL_Gamepad *gamepad) {
+ lvh::GamepadState state;
+ state.battery = lvh::GamepadBattery {
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ };
+ if (!virtual_gamepad.submit(state).ok()) {
+ return false;
+ }
+
+ const auto deadline = std::chrono::steady_clock::now() + 3s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ SDL_UpdateGamepads();
+ SDL_PumpEvents();
+ if (
+ auto percentage = -1;
+ SDL_GetGamepadPowerInfo(gamepad, &percentage) == SDL_POWERSTATE_ON_BATTERY &&
+ percentage == 40
+ ) {
+ return true;
+ }
+ std::this_thread::sleep_for(20ms);
+ }
+ return false;
+ }
+
void capture_output(const std::shared_ptr &output, const lvh::GamepadOutput &gamepad_output) {
std::lock_guard lock {output->mutex};
if (
@@ -422,6 +448,9 @@ namespace {
if (validate_trigger_inputs(virtual_gamepad, gamepad.get()) != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
+ if (!validate_battery(virtual_gamepad, gamepad.get())) {
+ return fail("SDL3 did not expose the Xbox battery state");
+ }
return validate_rumble(gamepad.get(), output);
}
@@ -450,6 +479,7 @@ namespace {
lvh::CreateGamepadOptions options;
options.profile = test_case.profile;
options.profile.name = std::format("libvirtualhid SDL3 {} {}", test_case.identity_token, ::getpid());
+ options.metadata.has_battery = true;
options.metadata.stable_id = std::format("libvirtualhid-sdl3-xbox-{:04x}-{}", test_case.product_id, ::getpid());
auto created = runtime->create_gamepad(options);
if (!created) {
@@ -458,6 +488,9 @@ namespace {
if (!created.gamepad->profile().capabilities.supports_trigger_rumble) {
return fail("Xbox creation did not select the trigger-rumble-capable UHID backend");
}
+ if (!created.gamepad->profile().capabilities.supports_battery) {
+ return fail("Xbox creation did not select the battery-capable UHID backend");
+ }
if (!wait_for_accessible_hidraw_node(*created.gamepad)) {
log_device_nodes(*created.gamepad);
log_kernel_hid_state();
diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
index 179fbaf..67197f5 100644
--- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
@@ -182,7 +182,7 @@ namespace lvh::detail::test {
*/
struct LinuxUhidXboxObservation {
/**
- * @brief Whether UHID creation used the 283-byte Linux Xbox BLE transport descriptor.
+ * @brief Whether UHID creation used the Linux Xbox BLE transport descriptor.
*/
bool saw_transport_descriptor = false;
@@ -206,6 +206,11 @@ namespace lvh::detail::test {
*/
bool saw_input = false;
+ /**
+ * @brief Whether submitted battery state was carried in a native Bluetooth battery report.
+ */
+ bool saw_battery_input = false;
+
/**
* @brief Whether the Guide button was carried in the native button bitmap.
*/
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index 7686553..f7fbf01 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -100,19 +100,6 @@ namespace lvh::detail::test {
return opaque_test_handle(device);
}
- std::uint16_t xbox_descriptor_crc16(std::span descriptor) {
- std::uint16_t crc = 0;
- for (const auto byte : descriptor) {
- crc = static_cast(crc ^ byte);
- for (auto bit = 0; bit < 8; ++bit) {
- crc = static_cast(
- (crc & 1U) != 0U ? (crc >> 1U) ^ 0xA001U : crc >> 1U
- );
- }
- }
- return crc;
- }
-
libevdev_uinput *libevdev_uinput_handle(FakeLibevdevUinput *device) noexcept {
return opaque_test_handle(device);
}
@@ -1640,8 +1627,22 @@ namespace lvh::detail::test {
event.u.create2.rd_data,
event.u.create2.rd_size,
};
+ constexpr std::array battery_report_descriptor {
+ 0x05U,
+ 0x06U,
+ 0x09U,
+ 0x20U,
+ 0x85U,
+ xbox_bluetooth_battery_report_id,
+ 0x15U,
+ 0x00U,
+ 0x26U,
+ 0xFFU,
+ 0x00U,
+ };
result.xbox.saw_transport_descriptor =
- descriptor.size() == 283U && xbox_descriptor_crc16(descriptor) == 0xDB7DU;
+ descriptor.size() == 300U &&
+ std::ranges::search(descriptor, battery_report_descriptor).begin() != descriptor.end();
constexpr std::array right_stick_usages {
0x09U,
0x33U,
@@ -1707,6 +1708,10 @@ namespace lvh::detail::test {
state.right_stick = {.x = 0.5F, .y = -0.5F};
state.left_trigger = 0.25F;
state.right_trigger = 0.75F;
+ state.battery = GamepadBattery {
+ .state = GamepadBatteryState::discharging,
+ .percentage = 50,
+ };
const auto report = reports::pack_input_report(profile, state);
result.submit_status = gamepad.submit(state, report);
@@ -1725,6 +1730,13 @@ namespace lvh::detail::test {
}
result.saw_input = result.xbox.saw_input;
+ if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
+ const auto input = std::span {event.u.input2.data, event.u.input2.size};
+ result.xbox.saw_battery_input =
+ input.size() == xbox_bluetooth_battery_report_size && input[0] == xbox_bluetooth_battery_report_id &&
+ input[1] == 0x05U;
+ }
+
constexpr std::array motor_report {
xbox_bluetooth_rumble_report_id,
0x0F,
diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp
index f94c24b..c792b61 100644
--- a/tests/unit/test_gamepad_adapter.cpp
+++ b/tests/unit/test_gamepad_adapter.cpp
@@ -16,6 +16,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) {
const auto generic = lvh::profiles::generic_gamepad();
const auto xbox_360 = lvh::profiles::xbox_360();
const auto xbox_one = lvh::profiles::xbox_one();
+ const auto xbox_series = lvh::profiles::xbox_series();
const auto dualshock4 = lvh::profiles::dualshock4();
const auto dualsense = lvh::profiles::dualsense();
const auto switch_pro = lvh::profiles::switch_pro();
@@ -30,6 +31,8 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) {
EXPECT_FALSE(lvh::gamepad_profile_support(xbox_360).supports_misc1_button);
EXPECT_FALSE(lvh::gamepad_profile_support(xbox_one).supports_misc1_button);
EXPECT_TRUE(lvh::gamepad_profile_support(xbox_one).supports_trigger_rumble);
+ EXPECT_TRUE(lvh::gamepad_profile_support(xbox_one).supports_battery);
+ EXPECT_TRUE(lvh::gamepad_profile_support(xbox_series).supports_battery);
const auto dualshock4_support = lvh::gamepad_profile_support(dualshock4);
EXPECT_TRUE(dualshock4_support.supports_rumble);
@@ -110,6 +113,34 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) {
EXPECT_FALSE(lvh::supports_gamepad_output(generic, static_cast(255)));
}
+TEST(GamepadAdapterTest, XboxProfilesAcceptAndClearBatteryUpdates) {
+ auto runtime = lvh::Runtime::create();
+ ASSERT_NE(runtime, nullptr);
+
+ for (const auto &profile : {lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) {
+ SCOPED_TRACE(profile.name);
+ lvh::CreateGamepadOptions options;
+ options.profile = profile;
+ options.metadata.has_battery = true;
+ options.metadata.stable_id = profile.name;
+
+ auto created = lvh::GamepadStateAdapter::create(*runtime, options);
+ ASSERT_TRUE(created) << created.status.message();
+ ASSERT_TRUE(created.adapter->set_battery({
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ })
+ .ok());
+ ASSERT_EQ(created.adapter->gamepad()->last_input_report().size(), 17U);
+ EXPECT_EQ(created.adapter->gamepad()->last_input_report()[16], 128U);
+
+ ASSERT_TRUE(created.adapter->clear_battery().ok());
+ ASSERT_EQ(created.adapter->gamepad()->last_input_report().size(), 17U);
+ EXPECT_EQ(created.adapter->gamepad()->last_input_report()[16], 0xFFU);
+ EXPECT_TRUE(created.adapter->close().ok());
+ }
+}
+
TEST(GamepadAdapterTest, CachesAndSubmitsPartialUpdates) {
auto runtime = lvh::Runtime::create();
diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp
index ac00bd0..a80d86c 100644
--- a/tests/unit/test_linux_backend.cpp
+++ b/tests/unit/test_linux_backend.cpp
@@ -845,6 +845,7 @@ TEST_F(LinuxBackendTest, XboxOneAndSeriesPreferBluetoothUhidWithUinputFallback)
const auto effective_profile = lvh::detail::test::linux_uinput_effective_gamepad_profile(kind);
EXPECT_TRUE(effective_profile.capabilities.supports_rumble);
EXPECT_FALSE(effective_profile.capabilities.supports_trigger_rumble);
+ EXPECT_FALSE(effective_profile.capabilities.supports_battery);
const auto result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind);
EXPECT_TRUE(result.create_status.ok()) << result.create_status.message();
@@ -857,6 +858,7 @@ TEST_F(LinuxBackendTest, XboxOneAndSeriesPreferBluetoothUhidWithUinputFallback)
EXPECT_TRUE(result.xbox.saw_gamepad_application_usage);
EXPECT_TRUE(result.xbox.saw_bluetooth_identity);
EXPECT_TRUE(result.xbox.saw_input);
+ EXPECT_TRUE(result.xbox.saw_battery_input);
EXPECT_TRUE(result.xbox.saw_guide);
EXPECT_TRUE(result.xbox.saw_profile_consumer_button);
EXPECT_TRUE(result.saw_destroy);
diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp
index 461e518..8ef7337 100644
--- a/tests/unit/test_linux_consumers.cpp
+++ b/tests/unit/test_linux_consumers.cpp
@@ -69,6 +69,7 @@ namespace {
int minimum_axes = 2;
bool require_sdl_rumble = false;
bool require_trigger_rumble = false;
+ bool require_battery = false;
bool require_motion = false;
bool expect_live_input = true;
};
@@ -505,6 +506,7 @@ namespace {
lvh::CreateGamepadOptions options;
options.profile = test_case.profile;
options.profile.name = unique_device_name(test_case.name_suffix);
+ options.metadata.has_battery = test_case.require_battery;
options.metadata.stable_id = std::string {test_case.stable_id};
return runtime.create_gamepad(options);
@@ -638,6 +640,25 @@ namespace {
<< acceleration[2] << " gyro=" << gyroscope[0] << "," << gyroscope[1] << "," << gyroscope[2];
}
+ void expect_sdl_battery_input(SDL_Joystick *joystick, lvh::Gamepad &gamepad) {
+ lvh::GamepadState state;
+ state.battery = lvh::GamepadBattery {
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ };
+ ASSERT_TRUE(gamepad.submit(state).ok());
+
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3};
+ while (
+ std::chrono::steady_clock::now() < deadline &&
+ SDL_JoystickCurrentPowerLevel(joystick) != SDL_JOYSTICK_POWER_MEDIUM) {
+ SDL_GameControllerUpdate();
+ pump_sdl_events();
+ std::this_thread::sleep_for(std::chrono::milliseconds {20});
+ }
+ EXPECT_EQ(SDL_JoystickCurrentPowerLevel(joystick), SDL_JOYSTICK_POWER_MEDIUM);
+ }
+
void expect_hidraw_rumble_callback(const lvh::DeviceProfile &profile, lvh::Gamepad &gamepad) {
const auto rumble = observe_rumble(gamepad);
const auto hidraw_node = wait_for_hidraw_node(gamepad);
@@ -804,6 +825,9 @@ namespace {
if (test_case.require_motion) {
expect_sdl_motion_input(controller.get(), gamepad);
}
+ if (test_case.require_battery) {
+ expect_sdl_battery_input(joystick, gamepad);
+ }
expect_sdl_rumble_callback(controller.get(), gamepad, test_case.require_trigger_rumble);
}
@@ -983,6 +1007,7 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxOneCanonicalButtons) {
.minimum_axes = 6,
.require_sdl_rumble = true,
.require_trigger_rumble = true,
+ .require_battery = true,
});
}
@@ -998,6 +1023,7 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) {
.minimum_axes = 6,
.require_sdl_rumble = true,
.require_trigger_rumble = true,
+ .require_battery = true,
};
run_sdl_canonical_gamepad_test(test_case);
}
diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp
index 0a7c22c..9b6b208 100644
--- a/tests/unit/test_profiles.cpp
+++ b/tests/unit/test_profiles.cpp
@@ -99,6 +99,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
EXPECT_EQ(xbox_one.manufacturer, "Microsoft");
EXPECT_TRUE(xbox_one.capabilities.supports_rumble);
EXPECT_TRUE(xbox_one.capabilities.supports_trigger_rumble);
+ EXPECT_TRUE(xbox_one.capabilities.supports_battery);
EXPECT_EQ(xbox_one.report_id, 0);
EXPECT_EQ(xbox_one.input_report_size, 17U);
EXPECT_EQ(xbox_one.output_report_size, 8U);
@@ -112,6 +113,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
EXPECT_EQ(xbox_series.input_report_size, 17U);
EXPECT_EQ(xbox_series.output_report_size, 8U);
EXPECT_TRUE(xbox_series.capabilities.supports_trigger_rumble);
+ EXPECT_TRUE(xbox_series.capabilities.supports_battery);
const std::array xbox_gip_stick_axis_descriptor {
0x15,
diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp
index cd0f9e9..ee37b73 100644
--- a/tests/unit/test_windows_consumers.cpp
+++ b/tests/unit/test_windows_consumers.cpp
@@ -1030,6 +1030,87 @@ TEST_F(WindowsConsumerTest, NativeXboxPidRumbleWritesAreNormalized) {
ASSERT_TRUE(created.adapter->close().ok());
}
+TEST_F(WindowsConsumerTest, BatteryStateIsAvailableThroughGetInputReport) {
+ const std::array profiles {
+ lvh::profiles::xbox_one(),
+ lvh::profiles::xbox_series(),
+ lvh::profiles::dualshock4(),
+ lvh::profiles::dualsense(),
+ lvh::profiles::switch_pro(),
+ };
+
+ lvh::RuntimeOptions runtime_options;
+ runtime_options.backend = lvh::BackendKind::platform_default;
+ auto runtime = lvh::Runtime::create(runtime_options);
+ ASSERT_NE(runtime, nullptr);
+ ASSERT_TRUE(runtime->capabilities().supports_gamepad)
+ << "The installed libvirtualhid Windows driver is required for this integration test";
+
+ for (const auto &profile : profiles) {
+ SCOPED_TRACE(profile.name);
+ const auto previous_paths = current_gamepad_interface_paths();
+
+ lvh::CreateGamepadOptions options;
+ options.profile = profile;
+ options.metadata.has_battery = true;
+ options.metadata.stable_id = profile.name;
+ auto created = lvh::GamepadStateAdapter::create(*runtime, options);
+ ASSERT_TRUE(created) << created.status.message();
+ ASSERT_TRUE(created.adapter->set_battery({
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ })
+ .ok());
+
+ const auto &effective_profile = created.adapter->gamepad()->profile();
+ const auto hid_interface =
+ wait_for_new_interface(previous_paths, effective_profile.vendor_id, effective_profile.product_id);
+ ASSERT_TRUE(hid_interface.has_value()) << "The VHF " << profile.name << " HID interface was not enumerated";
+
+ Handle hid {CreateFileW(
+ hid_interface->path.c_str(),
+ GENERIC_READ | GENERIC_WRITE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE,
+ nullptr,
+ OPEN_EXISTING,
+ FILE_ATTRIBUTE_NORMAL,
+ nullptr
+ )};
+ ASSERT_TRUE(hid) << "Unable to open the VHF " << profile.name << " HID interface: " << GetLastError();
+
+ auto expected_report = created.adapter->gamepad()->last_input_report();
+ if (effective_profile.report_id == 0U) {
+ expected_report.insert(expected_report.begin(), 0U);
+ }
+
+ std::vector queried_report(hid_interface->input_report_size, 0);
+ DWORD last_get_input_report_error = ERROR_SUCCESS;
+ auto received_expected_report = false;
+ const auto deadline = std::chrono::steady_clock::now() + 3s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ std::ranges::fill(queried_report, std::uint8_t {});
+ queried_report[0] = effective_profile.report_id;
+ if (HidD_GetInputReport(hid.get(), queried_report.data(), static_cast(queried_report.size())) == FALSE) {
+ last_get_input_report_error = GetLastError();
+ std::this_thread::sleep_for(10ms);
+ continue;
+ }
+
+ last_get_input_report_error = ERROR_SUCCESS;
+ if (queried_report == expected_report) {
+ received_expected_report = true;
+ break;
+ }
+ std::this_thread::sleep_for(10ms);
+ }
+
+ ASSERT_TRUE(received_expected_report)
+ << profile.name << " did not return the current input report before the deadline; last error: "
+ << last_get_input_report_error;
+ EXPECT_TRUE(created.adapter->close().ok());
+ }
+}
+
TEST_F(WindowsConsumerTest, XboxSeriesNativeInputCarriesShareButton) {
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
From 1cfe98473f0eecf4348f9edd8ac29bd782a62972 Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Sun, 30 Aug 2026 20:26:54 -0400
Subject: [PATCH 2/5] docs: Document and test Windows battery limitations
Expand platform docs and windows-driver docs to explain that XInput returns BATTERY_TYPE_DISCONNECTED for VHF Xbox devices and that Steam hides battery indicators for wired virtual transports. Update compatibility-note numbering in the end-user gamepad guide, fix battery state cells to reference note 4 consistently, and mark Xbox Series Share button support correctly for Linux and Android. Add two integration tests: one asserting SDL exposes submitted battery for non-Xbox profiles, and one asserting XInput does not expose submitted Xbox battery.
---
docs/end-user-gamepad-guide.md | 69 +++++-----
docs/platform-support.md | 13 ++
docs/usage.md | 7 +-
docs/windows-driver.md | 14 ++
tests/unit/test_windows_consumers.cpp | 187 ++++++++++++++++++++++++++
5 files changed, 254 insertions(+), 36 deletions(-)
diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md
index 953bb03..f7ea02e 100644
--- a/docs/end-user-gamepad-guide.md
+++ b/docs/end-user-gamepad-guide.md
@@ -113,34 +113,34 @@ columns compare the existing Windows Virtual HID Driver results.
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Impulse Triggers | ✅ | ✅ | ✅ | ✅15 | ✅13 | ✅ |
+| Impulse Triggers | ✅ | ✅ | ✅ | ✅14 | ✅12 | ✅ |
| Battery state | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 |
| **Xbox Series** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Impulse Triggers | ✅ | ✅ | ✅ | ✅15 | ✅13 | ✅ |
-| Battery state | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
-| Share button | ❌3 | ❌8 | ❌3 | ❌3 | ❌3 | ❌3 |
+| Impulse Triggers | ✅ | ✅ | ✅ | ✅14 | ✅12 | ✅ |
+| Battery state | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 |
+| Share button | ❌3 | ✅ | ❌3 | ✅ | ❌3 | ❌3 |
| **DualShock 4** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Basic rumble | ✅ | ✅ | ✅ | ✅5 | ✅ | ➖ |
-| Motion/gyro | ✅ | ✅ | ✅ | ✅1 | ✅13 | ➖ |
-| Touchpad position | ✅ | ✅ | ✅ | ❌2 | ✅13 | ➖ |
-| Touchpad click | ✅ | ✅ | ✅ | ❌2 | ✅13 | ➖ |
-| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅6 | ✅13 | ➖ |
-| Battery state | ❌ | ✅ | ✅ | ✅ | ✅ | ➖ |
+| Motion/gyro | ✅ | ✅ | ✅ | ✅1 | ✅12 | ➖ |
+| Touchpad position | ✅ | ✅ | ✅ | ❌2 | ✅12 | ➖ |
+| Touchpad click | ✅ | ✅ | ✅ | ❌2 | ✅12 | ➖ |
+| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅6 | ✅12 | ➖ |
+| Battery state | ❌4 | ✅ | ✅ | ✅ | ✅ | ➖ |
| **DualSense** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Basic rumble | ✅ | ✅ | ✅ | ✅5 | ✅ | ➖ |
| Motion/gyro | ✅ | ✅ | ✅ | ✅1 | ❌ | ➖ |
-| Touchpad position | ✅ | ✅ | ✅ | ❌2 | ✅13 | ➖ |
-| Touchpad click | ✅ | ✅ | ✅ | ❌2 | ✅13 | ➖ |
-| Light bar (RGB) | ✅ | ✅ | ✅ | ✅6 | ✅13 | ➖ |
-| Battery state | ❌ | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Adaptive triggers | ❓10 | ✅ | ❓10 | ❌2 | ❓ | ➖ |
+| Touchpad position | ✅ | ✅ | ✅ | ❌2 | ✅12 | ➖ |
+| Touchpad click | ✅ | ✅ | ✅ | ❌2 | ✅12 | ➖ |
+| Light bar (RGB) | ✅ | ✅ | ✅ | ✅6 | ✅12 | ➖ |
+| Battery state | ❌4 | ✅ | ✅ | ✅ | ✅ | ➖ |
+| Adaptive triggers | ❓9 | ✅ | ❓9 | ❌2 | ❓ | ➖ |
| Player indicator | ❌7 | ❌7 | ❌7 | ❌7 | ❌ | ➖ |
| MUTE button | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ |
| MUTE button LED | ❌7 | ❌7 | ❌7 | ❌7 | ❌ | ➖ |
@@ -149,32 +149,31 @@ columns compare the existing Windows Virtual HID Driver results.
| Digital trigger input (0 or 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Basic rumble | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ |
| Motion/gyro | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Battery state | ❌ | ✅ | ✅ | ✅ | ❌ | ➖ |
-| HOME LED | ❌14 | ❌14 | ❌14 | ❌ | ❌ | ➖ |
-| Player LED | ❌12 | ❌12 | ❌12 | ❌12 | ❌12 | ➖ |
-| Capture button | ✅ | ✅ | ✅ | ❌9 | ✅13 | ➖ |
+| Battery state | ❌4 | ✅ | ✅ | ✅ | ❌ | ➖ |
+| HOME LED | ❌13 | ❌13 | ❌13 | ❌ | ❌ | ➖ |
+| Player LED | ❌11 | ❌11 | ❌11 | ❌11 | ❌11 | ➖ |
+| Capture button | ✅ | ✅ | ✅ | ❌8 | ✅12 | ➖ |
When a backend is marked ❌, that path cannot establish whether an additional
client-side limitation exists. The owner below identifies the first known layer
that prevents the feature from working end to end.
-| Note | Owner | Limitation or status | Tracker or reference |
-|-------------------------------------------------|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) |
-| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) |
-| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) |
-| 4 | Host profile and backend | The tested release rejected Xbox One battery updates and did not expose Xbox battery through the Linux Bluetooth or Windows synchronous HID paths. Current source fixes those host-side defects; the compatibility row remains the last end-to-end result until the complete Moonlight-Sunshine path is retested with a driver build containing the fix. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) |
-| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) |
-| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) |
-| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
-| 8 | Linux host backend | Steam exposes the Xbox Series Share button through the Linux virtual controller, but pressing it does not change the button state. | [libvirtualhid issue #110](https://github.com/LizardByte/libvirtualhid/issues/110) |
-| 9 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) |
-| 10 | Client and protocol; resolved upstream, unreleased | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) |
-| 11 | Linux host backend | The Linux backend uses descriptor-driven UHID for Switch Pro, advertises a backend-only Bluetooth transport identity that SDL2 HIDAPI accepts for virtual devices, answers its initialization protocol, and carries live native motion reports. Motion and battery were validated end to end from Moonlight Qt v6.1.0 on Windows through Sunshine on Linux into Steam. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) |
-| 12 | Streaming host and client output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. Released moonlight-common-c and Moonlight clients do not consume that extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged on both host backends. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
-| 13 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) |
-| 14 | Client capability and output pipeline | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) |
-| 15 | Client connection and settings | Xbox Series Impulse Triggers worked when the physical gamepad was connected to the Android client over USB and **Override native Xbox gamepad support** was enabled. They did not work with the same gamepad connected over Bluetooth. The successful USB override path does not establish a minimum Android version. For the native input-device path, Moonlight's four-vibrator detection requires Android 12 (API level 31) or later. | [Moonlight Android USB override setting](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/res/values/strings.xml#L182-L185), [trigger-rumble routing](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L2112-L2141), [Xbox USB output packet](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java#L185-L213), and [native four-vibrator gate](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1921-L1977) |
+| Note | Owner | Limitation or status | Tracker or reference |
+|-------------------------------------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) |
+| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) |
+| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) |
+| 4 | Host profile, backend, and external consumer | Current source carries battery updates through Linux UHID and Windows HID input reports. SDL3 consumer tests receive the Linux Xbox value and the Windows DualShock 4, DualSense, and Switch Pro values. The current Steam client hides its battery indicator for Windows VHF devices because it only renders the indicator for controllers classified as Bluetooth or wireless, while VHF exposes a wired virtual transport. For Xbox, `XInputGetBatteryInformation` returns disconnected/empty even while `XInputGetState` receives the controller. Steam also did not show battery for the tested physical Xbox Series controller; Windows Game Bar showed its Bluetooth battery but not the virtual controller. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107), [XInput battery information](https://learn.microsoft.com/en-us/windows/win32/api/xinput/nf-xinput-xinputgetbatteryinformation), and [Virtual HID Framework configuration](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/vhf/ns-vhf-_vhf_config) |
+| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) |
+| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) |
+| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
+| 8 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) |
+| 9 | Client and protocol; resolved upstream, unreleased | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) |
+| 10 | Linux host backend | The Linux backend uses descriptor-driven UHID for Switch Pro, advertises a backend-only Bluetooth transport identity that SDL2 HIDAPI accepts for virtual devices, answers its initialization protocol, and carries live native motion reports. Motion and battery were validated end to end from Moonlight Qt v6.1.0 on Windows through Sunshine on Linux into Steam. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) |
+| 11 | Streaming host and client output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. Released moonlight-common-c and Moonlight clients do not consume that extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged on both host backends. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
+| 12 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) |
+| 13 | Client capability and output pipeline | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) |
+| 14 | Client connection and settings | Xbox Series Impulse Triggers worked when the physical gamepad was connected to the Android client over USB and **Override native Xbox gamepad support** was enabled. They did not work with the same gamepad connected over Bluetooth. The successful USB override path does not establish a minimum Android version. For the native input-device path, Moonlight's four-vibrator detection requires Android 12 (API level 31) or later. | [Moonlight Android USB override setting](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/res/values/strings.xml#L182-L185), [trigger-rumble routing](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L2112-L2141), [Xbox USB output packet](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java#L185-L213), and [native four-vibrator gate](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1921-L1977) |
Analog trigger input reports intermediate values between 0 and 1. Switch Pro
ZL/ZR input is digital and reports only 0 or 1. Trigger input is also separate
diff --git a/docs/platform-support.md b/docs/platform-support.md
index c57e8e1..d4dd27a 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -97,6 +97,19 @@ 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. `XInputGetBatteryInformation` returns `BATTERY_TYPE_DISCONNECTED` and
+`BATTERY_LEVEL_EMPTY` for the virtual Xbox controller even while its input is
+available through `XInputGetState`. 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
diff --git a/docs/usage.md b/docs/usage.md
index df8bfbe..01ea2f3 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -236,7 +236,12 @@ profile and backend capabilities before warning users about unsupported client
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.
+rumble. On Windows, the Xbox HID report carries battery strength, but consumers
+that prefer XInput receive a disconnected battery result for the VHF controller
+instead. 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.
diff --git a/docs/windows-driver.md b/docs/windows-driver.md
index c7b685e..102dfa2 100644
--- a/docs/windows-driver.md
+++ b/docs/windows-driver.md
@@ -99,6 +99,20 @@ 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. `XInputGetBatteryInformation` returns
+`BATTERY_TYPE_DISCONNECTED` and `BATTERY_LEVEL_EMPTY` for the VHF Xbox device,
+even while `XInputGetState` receives its input and `GetInputReport` contains the
+submitted value. 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
diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp
index ee37b73..5c08025 100644
--- a/tests/unit/test_windows_consumers.cpp
+++ b/tests/unit/test_windows_consumers.cpp
@@ -20,6 +20,7 @@
#include
#include
#include
+#include
#if defined(LIBVIRTUALHID_TEST_HAS_SDL3)
#include
@@ -28,6 +29,7 @@
// standard includes
#include
#include
+#include
#include
#include
#include
@@ -115,6 +117,79 @@ namespace {
HANDLE value_;
};
+ class XInputApi {
+ public:
+ using GetState = DWORD(WINAPI *)(DWORD, XINPUT_STATE *);
+ using GetBatteryInformation = DWORD(WINAPI *)(DWORD, BYTE, XINPUT_BATTERY_INFORMATION *);
+
+ XInputApi():
+ module_ {LoadLibraryW(L"xinput1_4.dll")} {
+ if (module_ != nullptr) {
+ get_state_ = std::bit_cast(GetProcAddress(module_, "XInputGetState"));
+ get_battery_information_ = std::bit_cast(
+ GetProcAddress(module_, "XInputGetBatteryInformation")
+ );
+ }
+ }
+
+ XInputApi(const XInputApi &) = delete;
+ XInputApi &operator=(const XInputApi &) = delete;
+
+ ~XInputApi() {
+ if (module_ != nullptr) {
+ static_cast(FreeLibrary(module_));
+ }
+ }
+
+ explicit operator bool() const {
+ return get_state_ != nullptr && get_battery_information_ != nullptr;
+ }
+
+ DWORD state(DWORD slot, XINPUT_STATE *state) const {
+ return get_state_(slot, state);
+ }
+
+ DWORD battery_information(
+ DWORD slot,
+ BYTE device_type,
+ XINPUT_BATTERY_INFORMATION *battery
+ ) const {
+ return get_battery_information_(slot, device_type, battery);
+ }
+
+ private:
+ HMODULE module_ = nullptr;
+ GetState get_state_ = nullptr;
+ GetBatteryInformation get_battery_information_ = nullptr;
+ };
+
+ std::set current_xinput_slots(const XInputApi &xinput) {
+ std::set result;
+ for (auto slot = DWORD {0}; slot < XUSER_MAX_COUNT; ++slot) {
+ XINPUT_STATE state {};
+ if (xinput.state(slot, &state) == ERROR_SUCCESS) {
+ result.insert(slot);
+ }
+ }
+ return result;
+ }
+
+ std::optional wait_for_new_xinput_slot(
+ const XInputApi &xinput,
+ const std::set &previous_slots
+ ) {
+ const auto deadline = std::chrono::steady_clock::now() + 10s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ for (const auto slot : current_xinput_slots(xinput)) {
+ if (!previous_slots.contains(slot)) {
+ return slot;
+ }
+ }
+ std::this_thread::sleep_for(20ms);
+ }
+ return std::nullopt;
+ }
+
template
struct ComReleaser {
void operator()(Interface *value) const {
@@ -517,6 +592,19 @@ namespace {
return {nullptr, &SDL_CloseGamepad};
}
+ bool wait_for_sdl_gamepad_removal(SDL_JoystickID gamepad_id) {
+ const auto deadline = std::chrono::steady_clock::now() + 10s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ SDL_UpdateGamepads();
+ SDL_PumpEvents();
+ if (!current_sdl_gamepads().contains(gamepad_id)) {
+ return true;
+ }
+ std::this_thread::sleep_for(20ms);
+ }
+ return false;
+ }
+
bool wait_for_switch_pro_motion(SDL_Gamepad *gamepad) {
std::array acceleration {};
std::array gyroscope {};
@@ -626,8 +714,107 @@ TEST_F(WindowsConsumerTest, SdlHidapiOutputReachesDefaultPlayStationAndSwitchCal
ASSERT_TRUE(created.adapter->close().ok());
}
}
+
+TEST_F(WindowsConsumerTest, SdlExposesSubmittedBatteryStateForNonXboxProfiles) {
+ SdlGamepadSubsystem sdl;
+ ASSERT_TRUE(sdl.initialized()) << SDL_GetError();
+
+ lvh::RuntimeOptions runtime_options;
+ runtime_options.backend = lvh::BackendKind::platform_default;
+ auto runtime = lvh::Runtime::create(runtime_options);
+ ASSERT_NE(runtime, nullptr);
+ ASSERT_TRUE(runtime->capabilities().supports_gamepad)
+ << "The installed libvirtualhid Windows driver is required for this integration test";
+
+ struct BatteryTestCase {
+ lvh::DeviceProfile profile;
+ int expected_percentage;
+ };
+
+ const std::array test_cases {
+ BatteryTestCase {lvh::profiles::dualshock4(), 55},
+ BatteryTestCase {lvh::profiles::dualsense(), 55},
+ BatteryTestCase {lvh::profiles::switch_pro(), 50},
+ };
+ for (const auto &[profile, expected_percentage] : test_cases) {
+ SCOPED_TRACE(profile.name);
+ const auto previous_gamepads = current_sdl_gamepads();
+
+ lvh::CreateGamepadOptions options;
+ options.profile = profile;
+ options.metadata.stable_id = "02:11:22:33:44:55";
+ options.metadata.has_battery = true;
+ auto created = lvh::GamepadStateAdapter::create(*runtime, options);
+ ASSERT_TRUE(created) << created.status.message();
+ ASSERT_NE(created.adapter->gamepad(), nullptr);
+
+ auto gamepad = wait_for_new_sdl_gamepad(previous_gamepads, profile.vendor_id, profile.product_id);
+ ASSERT_NE(gamepad.get(), nullptr) << SDL_GetError();
+ ASSERT_TRUE(created.adapter->set_battery({
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ })
+ .ok());
+
+ auto power_state = SDL_POWERSTATE_UNKNOWN;
+ auto percentage = -1;
+ const auto deadline = std::chrono::steady_clock::now() + 3s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ SDL_UpdateGamepads();
+ SDL_PumpEvents();
+ power_state = SDL_GetGamepadPowerInfo(gamepad.get(), &percentage);
+ if (power_state != SDL_POWERSTATE_UNKNOWN && percentage >= 0) {
+ break;
+ }
+ std::this_thread::sleep_for(20ms);
+ }
+
+ EXPECT_EQ(power_state, SDL_POWERSTATE_ON_BATTERY) << "percentage=" << percentage;
+ EXPECT_EQ(percentage, expected_percentage);
+ const auto gamepad_id = SDL_GetGamepadID(gamepad.get());
+ gamepad.reset();
+ ASSERT_TRUE(created.adapter->close().ok());
+ ASSERT_TRUE(wait_for_sdl_gamepad_removal(gamepad_id));
+ }
+}
#endif
+TEST_F(WindowsConsumerTest, XInputDoesNotExposeSubmittedXboxBattery) {
+ XInputApi xinput;
+ ASSERT_TRUE(xinput);
+ const auto previous_slots = current_xinput_slots(xinput);
+
+ lvh::RuntimeOptions runtime_options;
+ runtime_options.backend = lvh::BackendKind::platform_default;
+ auto runtime = lvh::Runtime::create(runtime_options);
+ ASSERT_NE(runtime, nullptr);
+ ASSERT_TRUE(runtime->capabilities().supports_gamepad)
+ << "The installed libvirtualhid Windows driver is required for this integration test";
+
+ lvh::CreateGamepadOptions options;
+ options.profile = lvh::profiles::xbox_series();
+ options.metadata.stable_id = "xinput-battery-test";
+ options.metadata.has_battery = true;
+ auto created = lvh::GamepadStateAdapter::create(*runtime, options);
+ ASSERT_TRUE(created) << created.status.message();
+ ASSERT_TRUE(created.adapter->set_battery({
+ .state = lvh::GamepadBatteryState::discharging,
+ .percentage = 50,
+ })
+ .ok());
+ ASSERT_TRUE(created.adapter->set_button(lvh::GamepadButton::a, true).ok());
+
+ const auto slot = wait_for_new_xinput_slot(xinput, previous_slots);
+ ASSERT_TRUE(slot.has_value());
+
+ XINPUT_BATTERY_INFORMATION battery {};
+ ASSERT_EQ(xinput.battery_information(*slot, BATTERY_DEVTYPE_GAMEPAD, &battery), ERROR_SUCCESS);
+ EXPECT_EQ(battery.BatteryType, BATTERY_TYPE_DISCONNECTED);
+ EXPECT_EQ(battery.BatteryLevel, BATTERY_LEVEL_EMPTY);
+
+ ASSERT_TRUE(created.adapter->close().ok());
+}
+
TEST_F(WindowsConsumerTest, NativePlayStationFeatureAndOutputReportsReachOwningRuntime) {
struct NativePlayStationCase {
lvh::DeviceProfile profile;
From 6d7a26bb9c03b2458eab8e347866740f137bc2ec Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Sun, 30 Aug 2026 22:55:07 -0400
Subject: [PATCH 3/5] Gate Xbox battery by client capability
Omit the Linux UHID Xbox battery descriptor and reports when the client declares no battery support (`has_battery = false`). This prevents phantom Linux power devices and false charge readings for clients like Moonlight Xbox that never send battery data.
Also narrows the battery HID field to a two-bit categorical value matching the native Xbox Bluetooth level, updates related docs and compatibility matrix footnotes, and skips the XInput battery test when the VHF device is not enumerated in headless CI.
---
docs/end-user-gamepad-guide.md | 46 +++++-----
docs/platform-support.md | 28 +++---
docs/usage.md | 13 ++-
docs/windows-driver.md | 14 +--
src/platform/linux/uhid_backend.cpp | 92 ++++++++++---------
.../fixtures/linux_backend_test_hooks.hpp | 13 ++-
tests/fixtures/linux_backend_test_hooks.cpp | 42 ++++++---
tests/unit/test_linux_backend.cpp | 25 ++++-
tests/unit/test_windows_consumers.cpp | 5 +-
9 files changed, 179 insertions(+), 99 deletions(-)
diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md
index f7ea02e..36d61d8 100644
--- a/docs/end-user-gamepad-guide.md
+++ b/docs/end-user-gamepad-guide.md
@@ -100,8 +100,9 @@ does not support that virtual profile.
### Compatibility Matrix
-The backend columns use Moonlight Qt as the common test client. The client
-columns compare the existing Windows Virtual HID Driver results.
+The backend columns summarize host-backend capability across the recorded
+tests. The client columns record client-specific results; footnotes identify
+platform, version, connection, or host-backend qualifiers.
| Feature | Backend: Windows via Virtual HID Driver | Backend: Linux via `libvirtualhid` | Client: [Moonlight Qt](https://github.com/moonlight-stream/moonlight-qt) | Client: [Moonlight Android](https://github.com/moonlight-stream/moonlight-android) | Client: [Moonlight iOS](https://github.com/moonlight-stream/moonlight-ios) | Client: [Moonlight Xbox](https://github.com/TheElixZammuto/moonlight-xbox) |
|-------------------------------------|------------------------------------------------------|------------------------------------------------------|--------------------------------------------------------------------------|------------------------------------------------------------------------------------|----------------------------------------------------------------------------|----------------------------------------------------------------------------|
@@ -114,14 +115,14 @@ columns compare the existing Windows Virtual HID Driver results.
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Impulse Triggers | ✅ | ✅ | ✅ | ✅14 | ✅12 | ✅ |
-| Battery state | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 |
+| Battery state | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 | ❌17 |
| **Xbox Series** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Impulse Triggers | ✅ | ✅ | ✅ | ✅14 | ✅12 | ✅ |
-| Battery state | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 |
-| Share button | ❌3 | ✅ | ❌3 | ✅ | ❌3 | ❌3 |
+| Battery state | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 | ❌17 |
+| Share button | ❌3 | ✅ | ❌15 | ✅ | ❌ | ❌16 |
| **DualShock 4** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
@@ -158,22 +159,25 @@ When a backend is marked ❌, that path cannot establish whether an additional
client-side limitation exists. The owner below identifies the first known layer
that prevents the feature from working end to end.
-| Note | Owner | Limitation or status | Tracker or reference |
-|-------------------------------------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) |
-| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) |
-| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) |
-| 4 | Host profile, backend, and external consumer | Current source carries battery updates through Linux UHID and Windows HID input reports. SDL3 consumer tests receive the Linux Xbox value and the Windows DualShock 4, DualSense, and Switch Pro values. The current Steam client hides its battery indicator for Windows VHF devices because it only renders the indicator for controllers classified as Bluetooth or wireless, while VHF exposes a wired virtual transport. For Xbox, `XInputGetBatteryInformation` returns disconnected/empty even while `XInputGetState` receives the controller. Steam also did not show battery for the tested physical Xbox Series controller; Windows Game Bar showed its Bluetooth battery but not the virtual controller. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107), [XInput battery information](https://learn.microsoft.com/en-us/windows/win32/api/xinput/nf-xinput-xinputgetbatteryinformation), and [Virtual HID Framework configuration](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/vhf/ns-vhf-_vhf_config) |
-| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) |
-| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) |
-| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
-| 8 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) |
-| 9 | Client and protocol; resolved upstream, unreleased | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) |
-| 10 | Linux host backend | The Linux backend uses descriptor-driven UHID for Switch Pro, advertises a backend-only Bluetooth transport identity that SDL2 HIDAPI accepts for virtual devices, answers its initialization protocol, and carries live native motion reports. Motion and battery were validated end to end from Moonlight Qt v6.1.0 on Windows through Sunshine on Linux into Steam. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) |
-| 11 | Streaming host and client output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. Released moonlight-common-c and Moonlight clients do not consume that extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged on both host backends. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
-| 12 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) |
-| 13 | Client capability and output pipeline | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) |
-| 14 | Client connection and settings | Xbox Series Impulse Triggers worked when the physical gamepad was connected to the Android client over USB and **Override native Xbox gamepad support** was enabled. They did not work with the same gamepad connected over Bluetooth. The successful USB override path does not establish a minimum Android version. For the native input-device path, Moonlight's four-vibrator detection requires Android 12 (API level 31) or later. | [Moonlight Android USB override setting](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/res/values/strings.xml#L182-L185), [trigger-rumble routing](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L2112-L2141), [Xbox USB output packet](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java#L185-L213), and [native four-vibrator gate](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1921-L1977) |
+| Note | Owner | Limitation or status | Tracker or reference |
+|-------------------------------------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) |
+| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) |
+| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) |
+| 4 | Host profile, backend, and external consumer | Current source carries battery updates through Linux UHID and Windows HID input reports. SDL3 consumer tests receive the Linux Xbox value and the Windows DualShock 4, DualSense, and Switch Pro values. The current Steam client hides its battery indicator for Windows VHF devices because it only renders the indicator for controllers classified as Bluetooth or wireless, while VHF exposes a wired virtual transport. For Xbox, a tested Windows desktop returned disconnected/empty from `XInputGetBatteryInformation` even while `XInputGetState` received the controller, while headless Windows CI did not expose an XInput slot for the same device; neither path exposes the submitted remote battery through XInput. Steam also did not show battery for the tested physical Xbox Series controller; Windows Game Bar showed its Bluetooth battery but not the virtual controller. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107), [XInput battery information](https://learn.microsoft.com/en-us/windows/win32/api/xinput/nf-xinput-xinputgetbatteryinformation), and [Virtual HID Framework configuration](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/vhf/ns-vhf-_vhf_config) |
+| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) |
+| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) |
+| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
+| 8 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) |
+| 9 | Client and protocol; resolved upstream, unreleased | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) |
+| 10 | Linux host backend | The Linux backend uses descriptor-driven UHID for Switch Pro, advertises a backend-only Bluetooth transport identity that SDL2 HIDAPI accepts for virtual devices, answers its initialization protocol, and carries live native motion reports. Motion and battery were validated end to end from Moonlight Qt v6.1.0 on Windows through Sunshine on Linux into Steam. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) |
+| 11 | Streaming host and client output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. Released moonlight-common-c and Moonlight clients do not consume that extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged on both host backends. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
+| 12 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) |
+| 13 | Client capability and output pipeline | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) |
+| 14 | Client connection and settings | Xbox Series Impulse Triggers worked when the physical gamepad was connected to the Android client over USB and **Override native Xbox gamepad support** was enabled. They did not work with the same gamepad connected over Bluetooth. The successful USB override path does not establish a minimum Android version. For the native input-device path, Moonlight's four-vibrator detection requires Android 12 (API level 31) or later. | [Moonlight Android USB override setting](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/res/values/strings.xml#L182-L185), [trigger-rumble routing](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L2112-L2141), [Xbox USB output packet](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java#L185-L213), and [native four-vibrator gate](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1921-L1977) |
+| 15 | Client platform, version, and connection | Moonlight Qt v6.1.0 on Windows did not forward Share from the tested Xbox Series controller to a Linux host, while Moonlight Android forwarded Share through the same host backend. The Bluetooth test log identified the controller as `045e:0b13`, but its SDL mapping omitted `misc1`. Moonlight Qt already maps `SDL_CONTROLLER_BUTTON_MISC1` to the Moonlight `MISC_FLAG`, so the observed failure is in the released client's SDL2 Windows controller mapping or input path, not in the streaming protocol, Sunshine, or the Linux `libvirtualhid` backend. Moonlight Qt master has moved to SDL3 through sdl2-compat, but that unreleased path has not been tested here. | [Moonlight Qt v6.1.0 gamepad mapping](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp#L26-L35), [Moonlight Qt v6.1.0 release](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0), and [SDL Xbox Series Share mapping issue](https://github.com/libsdl-org/SDL/issues/5101) |
+| 16 | Client platform | On the tested Xbox console, the Xbox operating system captured the Share button instead of delivering it to Moonlight Xbox. The client reads `Windows.Gaming.Input.GamepadReading`, whose `GamepadButtons` bitfield has no Share value, so Moonlight Xbox cannot forward this input to Sunshine. | [Moonlight Xbox pull request #289](https://github.com/TheElixZammuto/moonlight-xbox/pull/289), [Moonlight Xbox gamepad-reading path](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L529-L682), and [Windows `GamepadButtons`](https://learn.microsoft.com/en-us/uwp/api/windows.gaming.input.gamepadbuttons) |
+| 17 | Client and Linux host battery reporting | Moonlight Xbox does not advertise the controller-battery capability or send controller-battery events. Older `libvirtualhid` builds nevertheless advertised and emitted a default Xbox battery byte, which Linux misread as 2%. Current source omits the Linux Xbox battery descriptor and report when the client declares no battery support, and battery-capable clients expose only the native two-bit categorical level to Linux power consumers. Moonlight Xbox therefore no longer creates the false 2% reading, but it still cannot provide the controller's real battery level. | [Moonlight Xbox arrival capabilities](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L775-L783), [`libvirtualhid` Xbox battery transport](https://github.com/LizardByte/libvirtualhid/blob/fix/battery-state/src/platform/linux/uhid_backend.cpp), and [Linux HID battery scaling](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-input.c) |
Analog trigger input reports intermediate values between 0 and 1. Switch Pro
ZL/ZR input is digital and reports only 0 or 1. Trigger input is also separate
diff --git a/docs/platform-support.md b/docs/platform-support.md
index d4dd27a..d6fb357 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -98,12 +98,15 @@ 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. `XInputGetBatteryInformation` returns `BATTERY_TYPE_DISCONNECTED` and
-`BATTERY_LEVEL_EMPTY` for the virtual Xbox controller even while its input is
-available through `XInputGetState`. 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.
+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
@@ -146,11 +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 300-byte BLE
-descriptor, sparse input bitmap, four-motor output framing, and the native
-report-ID `0x04` battery notification. The descriptor exposes that notification
-through the standard HID Battery Strength usage so SDL's descriptor path can
-publish the native categorical charge level. The normal input 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 two-bit categorical charge field
+through the standard HID Battery Strength usage 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,
diff --git a/docs/usage.md b/docs/usage.md
index 01ea2f3..d8a9ff4 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -236,11 +236,14 @@ profile and backend capabilities before warning users about unsupported client
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. On Windows, the Xbox HID report carries battery strength, but consumers
-that prefer XInput receive a disconnected battery result for the VHF controller
-instead. 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.
+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
diff --git a/docs/windows-driver.md b/docs/windows-driver.md
index 102dfa2..b059c32 100644
--- a/docs/windows-driver.md
+++ b/docs/windows-driver.md
@@ -100,12 +100,14 @@ 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. `XInputGetBatteryInformation` returns
-`BATTERY_TYPE_DISCONNECTED` and `BATTERY_LEVEL_EMPTY` for the VHF Xbox device,
-even while `XInputGetState` receives its input and `GetInputReport` contains the
-submitted value. 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`.
+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
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index 55ab89d..d26fa02 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -123,14 +123,14 @@ namespace lvh::detail {
constexpr std::uint8_t xbox_bluetooth_battery_report_id = 0x04;
constexpr std::size_t xbox_bluetooth_battery_report_size = 2;
- std::vector make_xbox_bluetooth_report_descriptor() {
+ std::vector 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. Report 4 is
- // the native two-byte Xbox Bluetooth battery notification, advertised
- // through the standard Battery Strength usage recognized by HIDAPI.
- constexpr std::array 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 descriptor {
0x05,
0x01, // Usage Page (Generic Desktop)
0x09,
@@ -413,26 +413,38 @@ namespace lvh::detail {
0x91,
0x02, // Output (Data, Variable, Absolute)
0xC0, // End Collection
- 0x05,
- 0x06, // Usage Page (Generic Device Controls)
- 0x09,
- 0x20, // Usage (Battery Strength)
- 0x85,
- xbox_bluetooth_battery_report_id, // Report ID (4)
- 0x15,
- 0x00, // Logical Minimum (0)
- 0x26,
- 0xFF,
- 0x00, // Logical Maximum (255)
- 0x75,
- 0x08, // Report Size (8)
- 0x95,
- 0x01, // Report Count (1)
- 0x81,
- 0x02, // Input (Data, Variable, Absolute)
- 0xC0, // End Collection
};
- return {descriptor.begin(), descriptor.end()};
+
+ if (include_battery) {
+ constexpr std::array battery_descriptor {
+ 0x05,
+ 0x06, // Usage Page (Generic Device Controls)
+ 0x09,
+ 0x20, // Usage (Battery Strength)
+ 0x85,
+ xbox_bluetooth_battery_report_id, // Report ID (4)
+ 0x15,
+ 0x00, // Logical Minimum (0)
+ 0x25,
+ 0x03, // Logical Maximum (3)
+ 0x75,
+ 0x02, // Report Size (2)
+ 0x95,
+ 0x01, // Report Count (1)
+ 0x81,
+ 0x02, // Input (Data, Variable, Absolute)
+ 0x75,
+ 0x06, // Report Size (6)
+ 0x95,
+ 0x01, // Report Count (1)
+ 0x81,
+ 0x03, // Input (Constant, Variable, Absolute)
+ };
+ descriptor.insert(descriptor.end(), battery_descriptor.begin(), battery_descriptor.end());
+ }
+
+ descriptor.push_back(0xC0); // End Collection
+ return descriptor;
}
#endif
@@ -617,7 +629,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;
@@ -627,7 +639,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;
}
@@ -674,25 +686,21 @@ namespace lvh::detail {
return report;
}
- std::vector make_xbox_bluetooth_battery_report(const GamepadState &state) {
+ std::vector 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(battery.percentage, 100U);
auto level = full_battery_level;
- if (state.battery) {
- const auto percentage = std::min(state.battery->percentage, 100U);
- 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;
- } else {
- 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 report(xbox_bluetooth_battery_report_size);
@@ -3261,7 +3269,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");
@@ -3296,6 +3304,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_ = {};
@@ -3342,8 +3351,8 @@ namespace lvh::detail {
) :
report;
auto status = write_input_report(transport_report);
- if (status.ok() && is_xbox_uhid_profile(profile_.gamepad_kind)) {
- status = write_input_report(make_xbox_bluetooth_battery_report(state));
+ 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;
@@ -3680,6 +3689,7 @@ namespace lvh::detail {
std::string unique_id_;
std::array playstation_mac_address_ {};
GamepadState last_state_;
+ bool supports_battery_ = false;
std::atomic_bool open_ = true;
std::atomic_bool running_ = false;
std::jthread reader_;
diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
index 67197f5..f26ea1c 100644
--- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
@@ -186,6 +186,11 @@ namespace lvh::detail::test {
*/
bool saw_transport_descriptor = false;
+ /**
+ * @brief Whether the transport descriptor advertised a two-bit Xbox battery level.
+ */
+ bool saw_battery_descriptor = false;
+
/**
* @brief Whether the descriptor maps sticks and triggers to canonical evdev axes.
*/
@@ -981,9 +986,15 @@ namespace lvh::detail::test {
* @brief Exercise an Xbox profile's Bluetooth input and four-motor output through a socketpair.
*
* @param kind Xbox One or Xbox Series profile kind.
+ * @param advertises_battery Whether client metadata advertises battery support.
+ * @param submits_battery Whether submitted state includes battery data.
* @return Captured lifecycle and protocol observations.
*/
- LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(GamepadProfileKind kind);
+ LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(
+ GamepadProfileKind kind,
+ bool advertises_battery,
+ bool submits_battery
+ );
/**
* @brief Check whether the Linux backend prefers UHID for a gamepad profile.
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index f7fbf01..ed55991 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -734,7 +734,7 @@ namespace lvh::detail::test {
result.creation.waited_for_start
);
if (result.creation.saw_create) {
- const auto transport_profile = uhid_transport_profile(options.profile);
+ const auto transport_profile = uhid_transport_profile(options.profile, options.metadata.has_battery);
result.creation.saw_create = event.u.create2.vendor == transport_profile.vendor_id &&
event.u.create2.product == transport_profile.product_id &&
event.u.create2.version == transport_profile.version &&
@@ -1604,7 +1604,11 @@ namespace lvh::detail::test {
return result;
}
- LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(GamepadProfileKind kind) {
+ LinuxUhidRoundTripResult linux_xbox_bluetooth_uhid_socketpair_reports(
+ GamepadProfileKind kind,
+ bool advertises_battery,
+ bool submits_battery
+ ) {
LinuxUhidRoundTripResult result;
std::array descriptors {-1, -1};
if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors.data()) != 0) {
@@ -1618,6 +1622,7 @@ namespace lvh::detail::test {
CreateGamepadOptions options;
options.profile = profile;
options.metadata.stable_id = "xbox-bluetooth-uhid-roundtrip";
+ options.metadata.has_battery = advertises_battery;
UhidGamepad gamepad {descriptors[0]};
auto event = create_started_profile_uhid_gamepad(gamepad, 13, options, descriptors[1], BUS_BLUETOOTH, result);
@@ -1627,7 +1632,7 @@ namespace lvh::detail::test {
event.u.create2.rd_data,
event.u.create2.rd_size,
};
- constexpr std::array battery_report_descriptor {
+ constexpr std::array battery_report_descriptor {
0x05U,
0x06U,
0x09U,
@@ -1636,12 +1641,23 @@ namespace lvh::detail::test {
xbox_bluetooth_battery_report_id,
0x15U,
0x00U,
- 0x26U,
- 0xFFU,
- 0x00U,
+ 0x25U,
+ 0x03U,
+ 0x75U,
+ 0x02U,
+ 0x95U,
+ 0x01U,
+ 0x81U,
+ 0x02U,
+ 0x75U,
+ 0x06U,
+ 0x95U,
+ 0x01U,
+ 0x81U,
+ 0x03U,
};
- result.xbox.saw_transport_descriptor =
- descriptor.size() == 300U &&
+ result.xbox.saw_transport_descriptor = descriptor.size() == (advertises_battery ? 305U : 283U);
+ result.xbox.saw_battery_descriptor =
std::ranges::search(descriptor, battery_report_descriptor).begin() != descriptor.end();
constexpr std::array right_stick_usages {
0x09U,
@@ -1708,10 +1724,12 @@ namespace lvh::detail::test {
state.right_stick = {.x = 0.5F, .y = -0.5F};
state.left_trigger = 0.25F;
state.right_trigger = 0.75F;
- state.battery = GamepadBattery {
- .state = GamepadBatteryState::discharging,
- .percentage = 50,
- };
+ if (submits_battery) {
+ state.battery = GamepadBattery {
+ .state = GamepadBatteryState::discharging,
+ .percentage = 50,
+ };
+ }
const auto report = reports::pack_input_report(profile, state);
result.submit_status = gamepad.submit(state, report);
diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp
index a80d86c..e3761f3 100644
--- a/tests/unit/test_linux_backend.cpp
+++ b/tests/unit/test_linux_backend.cpp
@@ -847,13 +847,14 @@ TEST_F(LinuxBackendTest, XboxOneAndSeriesPreferBluetoothUhidWithUinputFallback)
EXPECT_FALSE(effective_profile.capabilities.supports_trigger_rumble);
EXPECT_FALSE(effective_profile.capabilities.supports_battery);
- const auto result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind);
+ const auto result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind, true, true);
EXPECT_TRUE(result.create_status.ok()) << result.create_status.message();
EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message();
EXPECT_TRUE(result.close_status.ok()) << result.close_status.message();
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_TRUE(result.xbox.saw_transport_descriptor);
+ EXPECT_TRUE(result.xbox.saw_battery_descriptor);
EXPECT_TRUE(result.xbox.saw_canonical_evdev_axes);
EXPECT_TRUE(result.xbox.saw_gamepad_application_usage);
EXPECT_TRUE(result.xbox.saw_bluetooth_identity);
@@ -868,6 +869,28 @@ TEST_F(LinuxBackendTest, XboxOneAndSeriesPreferBluetoothUhidWithUinputFallback)
ASSERT_TRUE(result.output.trigger_rumble.has_value());
EXPECT_EQ(result.output.trigger_rumble->left_trigger_rumble, 16384U);
EXPECT_EQ(result.output.trigger_rumble->right_trigger_rumble, 32768U);
+
+ const auto no_battery_result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind, false, true);
+ EXPECT_TRUE(no_battery_result.create_status.ok()) << no_battery_result.create_status.message();
+ EXPECT_TRUE(no_battery_result.submit_status.ok()) << no_battery_result.submit_status.message();
+ EXPECT_TRUE(no_battery_result.close_status.ok()) << no_battery_result.close_status.message();
+ EXPECT_TRUE(no_battery_result.creation.saw_create);
+ EXPECT_TRUE(no_battery_result.xbox.saw_transport_descriptor);
+ EXPECT_FALSE(no_battery_result.xbox.saw_battery_descriptor);
+ EXPECT_TRUE(no_battery_result.xbox.saw_input);
+ EXPECT_FALSE(no_battery_result.xbox.saw_battery_input);
+ EXPECT_TRUE(no_battery_result.saw_destroy);
+
+ const auto missing_battery_result = lvh::detail::test::linux_xbox_bluetooth_uhid_socketpair_reports(kind, true, false);
+ EXPECT_TRUE(missing_battery_result.create_status.ok()) << missing_battery_result.create_status.message();
+ EXPECT_TRUE(missing_battery_result.submit_status.ok()) << missing_battery_result.submit_status.message();
+ EXPECT_TRUE(missing_battery_result.close_status.ok()) << missing_battery_result.close_status.message();
+ EXPECT_TRUE(missing_battery_result.creation.saw_create);
+ EXPECT_TRUE(missing_battery_result.xbox.saw_transport_descriptor);
+ EXPECT_TRUE(missing_battery_result.xbox.saw_battery_descriptor);
+ EXPECT_TRUE(missing_battery_result.xbox.saw_input);
+ EXPECT_FALSE(missing_battery_result.xbox.saw_battery_input);
+ EXPECT_TRUE(missing_battery_result.saw_destroy);
}
}
diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp
index 5c08025..2b99db7 100644
--- a/tests/unit/test_windows_consumers.cpp
+++ b/tests/unit/test_windows_consumers.cpp
@@ -805,7 +805,10 @@ TEST_F(WindowsConsumerTest, XInputDoesNotExposeSubmittedXboxBattery) {
ASSERT_TRUE(created.adapter->set_button(lvh::GamepadButton::a, true).ok());
const auto slot = wait_for_new_xinput_slot(xinput, previous_slots);
- ASSERT_TRUE(slot.has_value());
+ if (!slot.has_value()) {
+ ASSERT_TRUE(created.adapter->close().ok());
+ GTEST_SKIP() << "XInput did not enumerate the VHF gamepad in this Windows environment";
+ }
XINPUT_BATTERY_INFORMATION battery {};
ASSERT_EQ(xinput.battery_information(*slot, BATTERY_DEVTYPE_GAMEPAD, &battery), ERROR_SUCCESS);
From a8d93e84b3f017044b7cccde2484129dae86a45f Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Mon, 31 Aug 2026 11:27:05 -0400
Subject: [PATCH 4/5] Align Linux Xbox battery HID field
Adjust the Linux UHID Xbox battery descriptor to use a byte-sized Battery Strength field with the native wireless charge values (4-7) instead of a packed two-bit field. This keeps SDL/Linux descriptor consumers compatible, preserves HIDAPI's expected layout, avoids the false 2% battery reading for clients without battery support, and updates the fixture and docs to match the new descriptor size and behavior.
---
docs/end-user-gamepad-guide.md | 2 +-
docs/platform-support.md | 10 +++++-----
src/platform/linux/uhid_backend.cpp | 18 ++++++++----------
tests/fixtures/linux_backend_test_hooks.cpp | 16 +++++-----------
4 files changed, 19 insertions(+), 27 deletions(-)
diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md
index 36d61d8..aa49ffe 100644
--- a/docs/end-user-gamepad-guide.md
+++ b/docs/end-user-gamepad-guide.md
@@ -177,7 +177,7 @@ that prevents the feature from working end to end.
| 14 | Client connection and settings | Xbox Series Impulse Triggers worked when the physical gamepad was connected to the Android client over USB and **Override native Xbox gamepad support** was enabled. They did not work with the same gamepad connected over Bluetooth. The successful USB override path does not establish a minimum Android version. For the native input-device path, Moonlight's four-vibrator detection requires Android 12 (API level 31) or later. | [Moonlight Android USB override setting](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/res/values/strings.xml#L182-L185), [trigger-rumble routing](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L2112-L2141), [Xbox USB output packet](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java#L185-L213), and [native four-vibrator gate](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1921-L1977) |
| 15 | Client platform, version, and connection | Moonlight Qt v6.1.0 on Windows did not forward Share from the tested Xbox Series controller to a Linux host, while Moonlight Android forwarded Share through the same host backend. The Bluetooth test log identified the controller as `045e:0b13`, but its SDL mapping omitted `misc1`. Moonlight Qt already maps `SDL_CONTROLLER_BUTTON_MISC1` to the Moonlight `MISC_FLAG`, so the observed failure is in the released client's SDL2 Windows controller mapping or input path, not in the streaming protocol, Sunshine, or the Linux `libvirtualhid` backend. Moonlight Qt master has moved to SDL3 through sdl2-compat, but that unreleased path has not been tested here. | [Moonlight Qt v6.1.0 gamepad mapping](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp#L26-L35), [Moonlight Qt v6.1.0 release](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0), and [SDL Xbox Series Share mapping issue](https://github.com/libsdl-org/SDL/issues/5101) |
| 16 | Client platform | On the tested Xbox console, the Xbox operating system captured the Share button instead of delivering it to Moonlight Xbox. The client reads `Windows.Gaming.Input.GamepadReading`, whose `GamepadButtons` bitfield has no Share value, so Moonlight Xbox cannot forward this input to Sunshine. | [Moonlight Xbox pull request #289](https://github.com/TheElixZammuto/moonlight-xbox/pull/289), [Moonlight Xbox gamepad-reading path](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L529-L682), and [Windows `GamepadButtons`](https://learn.microsoft.com/en-us/uwp/api/windows.gaming.input.gamepadbuttons) |
-| 17 | Client and Linux host battery reporting | Moonlight Xbox does not advertise the controller-battery capability or send controller-battery events. Older `libvirtualhid` builds nevertheless advertised and emitted a default Xbox battery byte, which Linux misread as 2%. Current source omits the Linux Xbox battery descriptor and report when the client declares no battery support, and battery-capable clients expose only the native two-bit categorical level to Linux power consumers. Moonlight Xbox therefore no longer creates the false 2% reading, but it still cannot provide the controller's real battery level. | [Moonlight Xbox arrival capabilities](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L775-L783), [`libvirtualhid` Xbox battery transport](https://github.com/LizardByte/libvirtualhid/blob/fix/battery-state/src/platform/linux/uhid_backend.cpp), and [Linux HID battery scaling](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-input.c) |
+| 17 | Client and Linux host battery reporting | Moonlight Xbox does not advertise the controller-battery capability or send controller-battery events. Older `libvirtualhid` builds nevertheless advertised and emitted a default Xbox battery byte, which Linux misread as 2%. Current source omits the Linux Xbox battery descriptor and report when the client declares no battery support, and battery-capable clients expose the native four-level wireless charge value through a byte-aligned HID field. Moonlight Xbox therefore no longer creates the false 2% reading, but it still cannot provide the controller's real battery level. | [Moonlight Xbox arrival capabilities](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L775-L783), [`libvirtualhid` Xbox battery transport](https://github.com/LizardByte/libvirtualhid/blob/fix/battery-state/src/platform/linux/uhid_backend.cpp), and [Linux HID battery scaling](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-input.c) |
Analog trigger input reports intermediate values between 0 and 1. Switch Pro
ZL/ZR input is digital and reports only 0 or 1. Trigger input is also separate
diff --git a/docs/platform-support.md b/docs/platform-support.md
index d6fb357..f3efa99 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -152,11 +152,11 @@ feature reports, and output reports matter for controller compatibility. Xbox
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 two-bit categorical charge field
-through the standard HID Battery Strength usage 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 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,
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index d26fa02..8f01d32 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -416,7 +416,11 @@ namespace lvh::detail {
};
if (include_battery) {
- constexpr std::array battery_descriptor {
+ // 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 battery_descriptor {
0x05,
0x06, // Usage Page (Generic Device Controls)
0x09,
@@ -424,21 +428,15 @@ namespace lvh::detail {
0x85,
xbox_bluetooth_battery_report_id, // Report ID (4)
0x15,
- 0x00, // Logical Minimum (0)
+ 0x04, // Logical Minimum (wireless, empty)
0x25,
- 0x03, // Logical Maximum (3)
+ 0x07, // Logical Maximum (wireless, full)
0x75,
- 0x02, // Report Size (2)
+ 0x08, // Report Size (8)
0x95,
0x01, // Report Count (1)
0x81,
0x02, // Input (Data, Variable, Absolute)
- 0x75,
- 0x06, // Report Size (6)
- 0x95,
- 0x01, // Report Count (1)
- 0x81,
- 0x03, // Input (Constant, Variable, Absolute)
};
descriptor.insert(descriptor.end(), battery_descriptor.begin(), battery_descriptor.end());
}
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index ed55991..48639db 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -1632,7 +1632,7 @@ namespace lvh::detail::test {
event.u.create2.rd_data,
event.u.create2.rd_size,
};
- constexpr std::array battery_report_descriptor {
+ constexpr std::array battery_report_descriptor {
0x05U,
0x06U,
0x09U,
@@ -1640,23 +1640,17 @@ namespace lvh::detail::test {
0x85U,
xbox_bluetooth_battery_report_id,
0x15U,
- 0x00U,
+ 0x04U,
0x25U,
- 0x03U,
+ 0x07U,
0x75U,
- 0x02U,
+ 0x08U,
0x95U,
0x01U,
0x81U,
0x02U,
- 0x75U,
- 0x06U,
- 0x95U,
- 0x01U,
- 0x81U,
- 0x03U,
};
- result.xbox.saw_transport_descriptor = descriptor.size() == (advertises_battery ? 305U : 283U);
+ result.xbox.saw_transport_descriptor = descriptor.size() == (advertises_battery ? 299U : 283U);
result.xbox.saw_battery_descriptor =
std::ranges::search(descriptor, battery_report_descriptor).begin() != descriptor.end();
constexpr std::array right_stick_usages {
From 360e51d6107db011b32759f3303deb376871890e Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Mon, 31 Aug 2026 14:49:06 -0400
Subject: [PATCH 5/5] Refresh gamepad compatibility matrix notes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Update the end-user gamepad guide with revised compatibility statuses across clients and backends, including new 🟡 “dev/future release” semantics. The matrix now reflects current findings for Xbox battery/share support, PlayStation touchpad/adaptive-trigger behavior, and Switch Pro LED/capture paths, with a fully reorganized and expanded note table (1–23) to clarify ownership, limitations, and tracking links.
---
docs/end-user-gamepad-guide.md | 96 ++++++++++++++++++----------------
1 file changed, 52 insertions(+), 44 deletions(-)
diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md
index aa49ffe..a37148b 100644
--- a/docs/end-user-gamepad-guide.md
+++ b/docs/end-user-gamepad-guide.md
@@ -96,7 +96,8 @@ The feature rows include the controller capabilities relevant to streaming,
including manufacturer-specific features that are not yet implemented end to
end. ✅ means that the complete path was observed working, ❌ means that it
did not work, ❓ means that it was not tested, and ➖ means that the client
-does not support that virtual profile.
+does not support that virtual profile. A 🟡 means the feature may be supported
+in a dev build or future release of the client.
### Compatibility Matrix
@@ -114,70 +115,77 @@ platform, version, connection, or host-backend qualifiers.
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Impulse Triggers | ✅ | ✅ | ✅ | ✅14 | ✅12 | ✅ |
-| Battery state | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 | ❌17 |
+| Impulse Triggers | ✅ | ✅ | ✅ | ✅1 | ✅2 | ✅ |
+| Battery state | ❌8 | ✅3 | ❌9 | ❌9 | ❌10 | 🟡20 |
| **Xbox Series** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Impulse Triggers | ✅ | ✅ | ✅ | ✅14 | ✅12 | ✅ |
-| Battery state | ❌4 | ❌4 | ❌4 | ❌4 | ❌4 | ❌17 |
-| Share button | ❌3 | ✅ | ❌15 | ✅ | ❌ | ❌16 |
+| Impulse Triggers | ✅ | ✅ | ✅ | ✅1 | ✅2 | ✅ |
+| Battery state | ❌8 | ✅3 | ❌9 | ❌9 | ❌10 | 🟡20 |
+| Share button | ❌11 | ✅ | 🟡21 | ✅ | ✅ | 🟡22 |
| **DualShock 4** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Basic rumble | ✅ | ✅ | ✅ | ✅5 | ✅ | ➖ |
-| Motion/gyro | ✅ | ✅ | ✅ | ✅1 | ✅12 | ➖ |
-| Touchpad position | ✅ | ✅ | ✅ | ❌2 | ✅12 | ➖ |
-| Touchpad click | ✅ | ✅ | ✅ | ❌2 | ✅12 | ➖ |
-| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅6 | ✅12 | ➖ |
-| Battery state | ❌4 | ✅ | ✅ | ✅ | ✅ | ➖ |
+| Basic rumble | ✅4 | ✅4 | ✅ | ✅5 | ✅ | ➖ |
+| Motion/gyro | ✅ | ✅ | ✅ | ✅6 | ✅2 | ➖ |
+| Touchpad position | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ |
+| Touchpad click | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ |
+| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅7 | ✅2 | ➖ |
+| Battery state | ❌8 | ✅ | ✅ | ✅ | ✅ | ➖ |
| **DualSense** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Basic rumble | ✅ | ✅ | ✅ | ✅5 | ✅ | ➖ |
-| Motion/gyro | ✅ | ✅ | ✅ | ✅1 | ❌ | ➖ |
-| Touchpad position | ✅ | ✅ | ✅ | ❌2 | ✅12 | ➖ |
-| Touchpad click | ✅ | ✅ | ✅ | ❌2 | ✅12 | ➖ |
-| Light bar (RGB) | ✅ | ✅ | ✅ | ✅6 | ✅12 | ➖ |
-| Battery state | ❌4 | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Adaptive triggers | ❓9 | ✅ | ❓9 | ❌2 | ❓ | ➖ |
-| Player indicator | ❌7 | ❌7 | ❌7 | ❌7 | ❌ | ➖ |
+| Basic rumble | ✅4 | ✅4 | ✅ | ✅5 | ✅ | ➖ |
+| Motion/gyro | ✅ | ✅ | ✅ | ✅6 | ❌ | ➖ |
+| Touchpad position | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ |
+| Touchpad click | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ |
+| Light bar (RGB) | ✅ | ✅ | ✅ | ✅7 | ✅2 | ➖ |
+| Battery state | ❌8 | ✅ | ✅ | ✅ | ✅ | ➖ |
+| Adaptive triggers | ❓24 | ✅ | 🟡23 | ❌12 | ❓ | ➖ |
+| Player indicator | ❌13 | ❌13 | ❌ | ❌ | ❌ | ➖ |
| MUTE button | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ |
-| MUTE button LED | ❌7 | ❌7 | ❌7 | ❌7 | ❌ | ➖ |
+| MUTE button LED | ❌13 | ❌13 | ❌ | ❌14 | ❌ | ➖ |
| **Nintendo Switch Pro Controller** | | | | | | |
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Digital trigger input (0 or 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Basic rumble | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ |
| Motion/gyro | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
-| Battery state | ❌4 | ✅ | ✅ | ✅ | ❌ | ➖ |
-| HOME LED | ❌13 | ❌13 | ❌13 | ❌ | ❌ | ➖ |
-| Player LED | ❌11 | ❌11 | ❌11 | ❌11 | ❌11 | ➖ |
-| Capture button | ✅ | ✅ | ✅ | ❌8 | ✅12 | ➖ |
+| Battery state | ❌8 | ✅ | ✅ | ✅ | ❌ | ➖ |
+| HOME LED | ❌15 | ❌15 | ❌16 | ❌ | ❌ | ➖ |
+| Player LED | ❌17 | ❌17 | ❌18 | ❌18 | ❌18 | ➖ |
+| Capture button | ✅ | ✅ | ✅ | ❌19 | ✅2 | ➖ |
When a backend is marked ❌, that path cannot establish whether an additional
client-side limitation exists. The owner below identifies the first known layer
that prevents the feature from working end to end.
-| Note | Owner | Limitation or status | Tracker or reference |
-|-------------------------------------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) |
-| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) |
-| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) |
-| 4 | Host profile, backend, and external consumer | Current source carries battery updates through Linux UHID and Windows HID input reports. SDL3 consumer tests receive the Linux Xbox value and the Windows DualShock 4, DualSense, and Switch Pro values. The current Steam client hides its battery indicator for Windows VHF devices because it only renders the indicator for controllers classified as Bluetooth or wireless, while VHF exposes a wired virtual transport. For Xbox, a tested Windows desktop returned disconnected/empty from `XInputGetBatteryInformation` even while `XInputGetState` received the controller, while headless Windows CI did not expose an XInput slot for the same device; neither path exposes the submitted remote battery through XInput. Steam also did not show battery for the tested physical Xbox Series controller; Windows Game Bar showed its Bluetooth battery but not the virtual controller. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107), [XInput battery information](https://learn.microsoft.com/en-us/windows/win32/api/xinput/nf-xinput-xinputgetbatteryinformation), and [Virtual HID Framework configuration](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/vhf/ns-vhf-_vhf_config) |
-| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) |
-| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) |
-| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
-| 8 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) |
-| 9 | Client and protocol; resolved upstream, unreleased | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) |
-| 10 | Linux host backend | The Linux backend uses descriptor-driven UHID for Switch Pro, advertises a backend-only Bluetooth transport identity that SDL2 HIDAPI accepts for virtual devices, answers its initialization protocol, and carries live native motion reports. Motion and battery were validated end to end from Moonlight Qt v6.1.0 on Windows through Sunshine on Linux into Steam. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) |
-| 11 | Streaming host and client output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. Released moonlight-common-c and Moonlight clients do not consume that extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged on both host backends. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
-| 12 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) |
-| 13 | Client capability and output pipeline | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) |
-| 14 | Client connection and settings | Xbox Series Impulse Triggers worked when the physical gamepad was connected to the Android client over USB and **Override native Xbox gamepad support** was enabled. They did not work with the same gamepad connected over Bluetooth. The successful USB override path does not establish a minimum Android version. For the native input-device path, Moonlight's four-vibrator detection requires Android 12 (API level 31) or later. | [Moonlight Android USB override setting](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/res/values/strings.xml#L182-L185), [trigger-rumble routing](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L2112-L2141), [Xbox USB output packet](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java#L185-L213), and [native four-vibrator gate](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1921-L1977) |
-| 15 | Client platform, version, and connection | Moonlight Qt v6.1.0 on Windows did not forward Share from the tested Xbox Series controller to a Linux host, while Moonlight Android forwarded Share through the same host backend. The Bluetooth test log identified the controller as `045e:0b13`, but its SDL mapping omitted `misc1`. Moonlight Qt already maps `SDL_CONTROLLER_BUTTON_MISC1` to the Moonlight `MISC_FLAG`, so the observed failure is in the released client's SDL2 Windows controller mapping or input path, not in the streaming protocol, Sunshine, or the Linux `libvirtualhid` backend. Moonlight Qt master has moved to SDL3 through sdl2-compat, but that unreleased path has not been tested here. | [Moonlight Qt v6.1.0 gamepad mapping](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp#L26-L35), [Moonlight Qt v6.1.0 release](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0), and [SDL Xbox Series Share mapping issue](https://github.com/libsdl-org/SDL/issues/5101) |
-| 16 | Client platform | On the tested Xbox console, the Xbox operating system captured the Share button instead of delivering it to Moonlight Xbox. The client reads `Windows.Gaming.Input.GamepadReading`, whose `GamepadButtons` bitfield has no Share value, so Moonlight Xbox cannot forward this input to Sunshine. | [Moonlight Xbox pull request #289](https://github.com/TheElixZammuto/moonlight-xbox/pull/289), [Moonlight Xbox gamepad-reading path](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L529-L682), and [Windows `GamepadButtons`](https://learn.microsoft.com/en-us/uwp/api/windows.gaming.input.gamepadbuttons) |
-| 17 | Client and Linux host battery reporting | Moonlight Xbox does not advertise the controller-battery capability or send controller-battery events. Older `libvirtualhid` builds nevertheless advertised and emitted a default Xbox battery byte, which Linux misread as 2%. Current source omits the Linux Xbox battery descriptor and report when the client declares no battery support, and battery-capable clients expose the native four-level wireless charge value through a byte-aligned HID field. Moonlight Xbox therefore no longer creates the false 2% reading, but it still cannot provide the controller's real battery level. | [Moonlight Xbox arrival capabilities](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L775-L783), [`libvirtualhid` Xbox battery transport](https://github.com/LizardByte/libvirtualhid/blob/fix/battery-state/src/platform/linux/uhid_backend.cpp), and [Linux HID battery scaling](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-input.c) |
+| Note | Owner | Limitation or status | Tracker or reference |
+|-------------------------------------------------|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 1 | Client connection and settings | Xbox Series Impulse Triggers worked when the physical gamepad was connected to the Android client over USB and **Override native Xbox gamepad support** was enabled. They did not work with the same gamepad connected over Bluetooth. The successful USB override path does not establish a minimum Android version. For the native input-device path, Moonlight's four-vibrator detection requires Android 12 (API level 31) or later. | [Moonlight Android USB override setting](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/res/values/strings.xml#L182-L185), [trigger-rumble routing](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L2112-L2141), [Xbox USB output packet](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java#L185-L213), and [native four-vibrator gate](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1921-L1977) |
+| 2 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) |
+| 3 | Linux host backend and external consumer | Testing with the battery-capable Moonlight Xbox build through Sunshine on Linux confirmed that the virtual Xbox controller exposes the reported battery level through the Linux power-supply interface. The tested Steam client still did not display that battery level. | |
+| 4 | Host and external consumer | Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80) and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) |
+| 5 | Client platform | Android rumble depends on device vibration APIs and compatible motors. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419) |
+| 6 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) |
+| 7 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) |
+| 8 | Windows host backend and external consumers | Current source carries battery updates through Windows HID input reports, and SDL3 consumer tests receive the Windows DualShock 4, DualSense, and Switch Pro values. The current Steam client hides its battery indicator for Windows VHF devices because it only renders the indicator for controllers classified as Bluetooth or wireless, while VHF exposes a wired virtual transport. For Xbox, a tested Windows desktop returned disconnected/empty from `XInputGetBatteryInformation` even while `XInputGetState` received the controller, while headless Windows CI did not expose an XInput slot for the same device; neither path exposes the submitted remote battery through XInput. Steam also did not show battery for the tested physical Xbox Series controller; Windows Game Bar showed its Bluetooth battery but not the virtual controller. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107), [XInput battery information](https://learn.microsoft.com/en-us/windows/win32/api/xinput/nf-xinput-xinputgetbatteryinformation), and [Virtual HID Framework configuration](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/vhf/ns-vhf-_vhf_config) |
+| 9 | Client battery reporting | Moonlight Qt and Android did not forward Xbox One or Xbox Series battery state in the recorded tests. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) |
+| 10 | Client platform | In the recorded iOS tests, Xbox One and Xbox Series battery reporting remained at 100% even when the physical controller's actual charge was substantially lower. | [Moonlight iOS battery reporting](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L403-L440) |
+| 11 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) |
+| 12 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) |
+| 13 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
+| 14 | Client platform | When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
+| 15 | Host output callback | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. End-to-end output remains unavailable because no tested client advertised compatible HOME-light support to Sunshine. | [libvirtualhid Switch Pro output handling](platform-support.md#linux) |
+| 16 | Client capability and output pipeline | Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) |
+| 17 | Host output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. The full host-to-client path is not available in released software. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
+| 18 | Client output protocol | Released moonlight-common-c and Moonlight clients do not consume the Switch Pro player-light extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
+| 19 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) |
+| 20 | Client battery reporting | The changes in Moonlight Xbox pull request #291 forward the physical controller's battery capability and charge updates. The released client does not yet include this support. | [Moonlight Xbox pull request #291](https://github.com/TheElixZammuto/moonlight-xbox/pull/291) |
+| 21 | Client platform, version, and connection | Moonlight Qt v6.1.0 on Windows did not forward Share from the tested Xbox Series controller to a Linux host, while Moonlight Android forwarded Share through the same host backend. The Bluetooth test log identified the controller as `045e:0b13`, but its SDL mapping omitted `misc1`. Moonlight Qt already maps `SDL_CONTROLLER_BUTTON_MISC1` to the Moonlight `MISC_FLAG`, so the observed failure is in the released client's SDL2 Windows controller mapping or input path, not in the streaming protocol, Sunshine, or the Linux `libvirtualhid` backend. Moonlight Qt master has moved to SDL3 through sdl2-compat, but that unreleased path has not been tested here. | [Moonlight Qt v6.1.0 gamepad mapping](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp#L26-L35), [Moonlight Qt v6.1.0 release](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0), and [SDL Xbox Series Share mapping issue](https://github.com/libsdl-org/SDL/issues/5101) |
+| 22 | Client platform | On the tested Xbox console, the Xbox operating system captured the Share button instead of delivering it to Moonlight Xbox. The client reads `Windows.Gaming.Input.GamepadReading`, whose `GamepadButtons` bitfield has no Share value, so Moonlight Xbox cannot forward this input to Sunshine. | [Moonlight Xbox pull request #289](https://github.com/TheElixZammuto/moonlight-xbox/pull/289), [Moonlight Xbox gamepad-reading path](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L529-L682), and [Windows GamepadButtons](https://learn.microsoft.com/en-us/uwp/api/windows.gaming.input.gamepadbuttons) |
+| 23 | Client release | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) |
+| 24 | Windows host backend | Adaptive triggers have not been validated end to end through the Windows backend with the merged Moonlight and Sunshine protocol path. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), and [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738) |
Analog trigger input reports intermediate values between 0 and 1. Switch Pro
ZL/ZR input is digital and reports only 0 or 1. Trigger input is also separate