Gamepad (Windows): fixes battery level detection

This commit is contained in:
李通洲
2026-09-24 00:02:27 +08:00
parent 1e7386788f
commit 982313063d
2 changed files with 261 additions and 29 deletions
+257 -29
View File
@@ -1,6 +1,7 @@
#include "gamepad.h"
#include "common/io.h"
#include "common/mallocHelper.h"
#include "common/time.h"
#include "common/windows/unicode.h"
#include <windows.h>
@@ -15,6 +16,8 @@ static const char* detectKnownDeviceName(uint32_t vendorId, uint32_t productId)
return "Nintendo Switch Joycon L";
case 0x2007:
return "Nintendo Switch Joycon R";
case 0x2008:
return "Nintendo Switch Joycon L+R";
case 0x2009:
return "Nintendo Switch Pro Controller";
case 0x200E:
@@ -22,6 +25,15 @@ static const char* detectKnownDeviceName(uint32_t vendorId, uint32_t productId)
case 0x2017:
return "Nintendo Switch SNES Controller";
case 0x2066:
return "Nintendo Switch 2 Joycon R";
case 0x2067:
return "Nintendo Switch 2 Joycon L";
case 0x2068:
return "Nintendo Switch 2 Joycon L+R";
case 0x2069:
return "Nintendo Switch 2 Pro Controller";
default:
return nullptr;
}
@@ -44,6 +56,8 @@ static const char* detectKnownDeviceName(uint32_t vendorId, uint32_t productId)
return "Sony DualSense";
case 0x0DF2:
return "Sony DualSense Edge";
case 0x0E5F:
return "Sony Access Controller";
default:
return nullptr;
@@ -77,6 +91,231 @@ static const char* detectKnownDeviceName(uint32_t vendorId, uint32_t productId)
}
}
// A HID read on Windows hands back as many bytes as the descriptor declares for the largest report,
// so the number of bytes a read returns says nothing about which report actually arrived. The report
// id, the first byte of the buffer, is what identifies it.
static bool readHidReport(HANDLE hHidFile, uint8_t* buffer, uint32_t length, DWORD timeoutMs, DWORD* nBytes) {
OVERLAPPED overlapped = {};
DWORD read;
if (ReadFile(hHidFile, buffer, length, &read, &overlapped)) {
*nBytes = read;
return true;
}
if (GetOverlappedResultEx(hHidFile, &overlapped, &read, timeoutMs, TRUE)) {
*nBytes = read;
return true;
}
CancelIo(hHidFile);
return false;
}
// The DualShock 4 sends a battery level in its state packet, but only in the extended report it
// switches to when a program asks it to. Until then it sends the plain HID gamepad report, which
// ends after the buttons.
typedef enum FFDs4ReportId: uint8_t {
FF_DS4_REPORT_ID_USB_STATE = 0x01,
FF_DS4_REPORT_ID_BLUETOOTH_STATE_FIRST = 0x11,
FF_DS4_REPORT_ID_BLUETOOTH_STATE_LAST = 0x19,
} FFDs4ReportId;
// Offset of the battery level in the state packet, which is what the DualShock 4 documents and
// what SDL reads: five pad bytes after the six IMU axes.
#define FF_DS4_STATE_PACKET_BATTERY_OFFSET 29
// The plain HID gamepad report is ten bytes long, report id included
#define FF_DS4_NON_EXTENDED_REPORT_LENGTH 10
// Reads the battery level out of one input report of a DualShock 4, and returns 0 when the report
// carries none. `report[0]` is the report id and `reportLength` the size of the buffer.
static uint8_t parseDs4Battery(const uint8_t* report, uint32_t reportLength) {
uint32_t batteryOffset;
if (report[0] == FF_DS4_REPORT_ID_USB_STATE) {
// A non-extended report stops after the buttons, so a non-zero byte past that can only come
// from an extended packet. A wired controller always sends the extended one, a Bluetooth one
// only once another program has enabled it.
bool extended = false;
for (uint32_t i = FF_DS4_NON_EXTENDED_REPORT_LENGTH; i < reportLength; ++i) {
if (report[i]) {
extended = true;
break;
}
}
if (!extended) {
return 0;
}
// On USB the state packet follows the report id directly
batteryOffset = 1 + FF_DS4_STATE_PACKET_BATTERY_OFFSET;
} else if (report[0] >= FF_DS4_REPORT_ID_BLUETOOTH_STATE_FIRST && report[0] <= FF_DS4_REPORT_ID_BLUETOOTH_STATE_LAST) {
// A Bluetooth state packet has two extra bytes in front, the first of which says whether the
// packet carries HID state at all
if (reportLength <= 1 || !(report[1] & 0x80)) {
return 0;
}
batteryOffset = 3 + FF_DS4_STATE_PACKET_BATTERY_OFFSET;
} else {
return 0;
}
if (batteryOffset >= reportLength) {
return 0;
}
uint8_t batteryInfo = report[batteryOffset];
uint8_t level = batteryInfo & 0x0f;
if ((batteryInfo & 0x10 /*charging?*/) && level > 11) {
return 0; // A charging controller reports 11 for a full battery; anything above is not a level
}
uint8_t battery = (uint8_t) (level * 10 + 5); // 0..11 maps onto the middle of eleven 10% steps
return battery > 100 ? 100 : battery;
}
// The DualSense uses one report id over USB and another over Bluetooth, and the state packet follows
// the id directly in the first case and one byte later in the second. The official controller sends
// SDL's PS5StatePacket_t; the shorter PS5StatePacketAlt_t belongs to third-party pads and is not read.
typedef enum FFDualSenseReportId: uint8_t {
FF_DUALSENSE_REPORT_ID_USB_STATE = 0x01,
FF_DUALSENSE_REPORT_ID_BLUETOOTH_STATE = 0x31,
} FFDualSenseReportId;
// Offset of the battery level in PS5StatePacket_t, and the length of a whole USB state report
#define FF_DUALSENSE_STATE_PACKET_BATTERY_OFFSET 52
#define FF_DUALSENSE_USB_REPORT_LENGTH 64
static uint8_t parseDualSenseBattery(const uint8_t* report, uint32_t reportLength) {
uint32_t batteryOffset;
if (report[0] == FF_DUALSENSE_REPORT_ID_USB_STATE) {
// Over Bluetooth the controller answers the same id with a short report that stops before the
// battery, so the USB report length is what tells the two apart
if (reportLength != FF_DUALSENSE_USB_REPORT_LENGTH) {
return 0;
}
batteryOffset = 1 + FF_DUALSENSE_STATE_PACKET_BATTERY_OFFSET;
} else if (report[0] == FF_DUALSENSE_REPORT_ID_BLUETOOTH_STATE) {
// The Bluetooth report has one extra byte in front of the state packet
batteryOffset = 2 + FF_DUALSENSE_STATE_PACKET_BATTERY_OFFSET;
} else {
return 0;
}
if (batteryOffset >= reportLength) {
return 0;
}
uint8_t batteryInfo = report[batteryOffset];
uint32_t status = (batteryInfo >> 4) & 0x0f;
if (status == 2) {
return 100; // Charged
}
if (status > 1) {
return 0; // Not a status the controller documents
}
uint32_t battery = (batteryInfo & 0x0f) * 10 + 5; // Discharging and charging share one scale
return (uint8_t) (battery > 100 ? 100 : battery);
}
// A Switch controller interleaves its full state report with subcommand replies, which carry no
// battery at all. SDL skips those replies and waits for the next full report, and so does this,
// inside the same one-second budget a single read of any other controller gets.
typedef enum FFSwitchReportId: uint8_t {
FF_SWITCH_REPORT_ID_FULL_STATE_FIRST = 0x30,
FF_SWITCH_REPORT_ID_FULL_STATE_LAST = 0x31,
} FFSwitchReportId;
// The controller state packet starts one byte after the report id, and its second byte carries the
// battery level in its top three bits and the charging flag in bit 4. Anything that is not a full
// state report carries no battery and answers 0.
#define FF_SWITCH_STATE_PACKET_BATTERY_OFFSET 2
static uint8_t parseSwitchBattery(const uint8_t* report, uint32_t reportLength) {
if (report[0] < FF_SWITCH_REPORT_ID_FULL_STATE_FIRST || report[0] > FF_SWITCH_REPORT_ID_FULL_STATE_LAST) {
return 0;
}
if (reportLength <= FF_SWITCH_STATE_PACKET_BATTERY_OFFSET) {
return 0;
}
uint8_t batteryInfo = report[FF_SWITCH_STATE_PACKET_BATTERY_OFFSET];
uint32_t battery = ((batteryInfo & 0xE0) >> 4) * 100 / 8; // A level of 8 is a full battery
if (battery == 0) {
return 1; // Keep a drained controller apart from the "unknown" 0 the module uses
}
return (uint8_t) (battery > 100 ? 100 : battery);
}
// The longest run of reports without a full state report measured on a Pro Controller was eleven
#define FF_SWITCH_BATTERY_MAX_READS 16
static uint8_t detectSwitchBattery(HANDLE hHidFile, uint8_t* reportBuffer, uint32_t reportLength) {
const double deadline = ffTimeGetTick() + (double) FF_IO_TERM_RESP_WAIT_MS;
for (uint32_t i = 0; i < FF_SWITCH_BATTERY_MAX_READS; ++i) {
double remaining = deadline - ffTimeGetTick();
if (remaining < 1.) {
break;
}
DWORD nBytes;
if (!readHidReport(hHidFile, reportBuffer, reportLength, (DWORD) remaining, &nBytes)) {
break;
}
uint8_t battery = parseSwitchBattery(reportBuffer, reportLength);
if (battery) {
return battery;
}
}
return 0;
}
// Which of the battery layouts a controller uses, if any. A controller that is not listed here keeps
// the module's "unknown" 0 without a single read being issued.
typedef enum FFGamepadBatteryKind: uint8_t {
FF_GAMEPAD_BATTERY_NONE,
FF_GAMEPAD_BATTERY_DS4,
FF_GAMEPAD_BATTERY_DUALSENSE,
FF_GAMEPAD_BATTERY_SWITCH,
} FFGamepadBatteryKind;
static FFGamepadBatteryKind detectBatteryKind(uint32_t vendorId, uint32_t productId) {
switch (vendorId) {
case 0x054C: // Sony
switch (productId) {
case 0x0CE6: // DualSense
case 0x0DF2: // DualSense Edge
case 0x0E5F: // Access Controller
return FF_GAMEPAD_BATTERY_DUALSENSE;
case 0x05C4: // DualShock 4 Gen1
case 0x09CC: // DualShock 4 Gen2
case 0x0BA0: // DualShock 4 USB receiver
return FF_GAMEPAD_BATTERY_DS4;
default:
return FF_GAMEPAD_BATTERY_NONE;
}
case 0x057E: // Nintendo
switch (productId) {
case 0x2006: // Joy-Con L
case 0x2007: // Joy-Con R
case 0x2008: // Joy-Con pair
case 0x2009: // Pro Controller
return FF_GAMEPAD_BATTERY_SWITCH;
default:
return FF_GAMEPAD_BATTERY_NONE;
}
default:
return FF_GAMEPAD_BATTERY_NONE;
}
}
const char* ffDetectGamepad(FFlist* devices /* List of FFGamepadDevice */) {
UINT nDevices = 0;
if (GetRawInputDeviceList(nullptr, &nDevices, sizeof(RAWINPUTDEVICELIST))) {
@@ -159,39 +398,28 @@ const char* ffDetectGamepad(FFlist* devices /* List of FFGamepadDevice */) {
continue;
}
if (
(rdi.hid.dwVendorId == 0x054C && (rdi.hid.dwProductId == 0x05C4 || // PS4 Gen1
rdi.hid.dwProductId == 0x09CC // PS4 Gen2
)) ||
(rdi.hid.dwVendorId == 0x057E && (rdi.hid.dwProductId == 0x2009 // NS Pro
))) {
// Controller must be connected by other programs
// Sony and Nintendo controllers report a battery level only in a report a program has to
// ask the controller for, so a controller that nothing else has opened reports none.
FFGamepadBatteryKind batteryKind = detectBatteryKind(rdi.hid.dwVendorId, rdi.hid.dwProductId);
if (batteryKind != FF_GAMEPAD_BATTERY_NONE) {
FF_AUTO_FREE uint8_t* reportBuffer = malloc(caps.InputReportByteLength);
OVERLAPPED overlapped = {};
DWORD nBytes;
if (ReadFile(hHidFile, reportBuffer, caps.InputReportByteLength, &nBytes, &overlapped) ||
GetOverlappedResultEx(hHidFile, &overlapped, &nBytes, FF_IO_TERM_RESP_WAIT_MS, TRUE)) {
if (rdi.hid.dwVendorId == 0x054C) {
if (nBytes > 31) {
uint8_t batteryInfo = reportBuffer[caps.InputReportByteLength == 64 /*USB?*/ ? 30 : 32];
device->battery = (uint8_t) ((batteryInfo & 0x0f) * 100 / (batteryInfo & 0x10 /*charging?*/ ? 11 /*BATTERY_MAX_USB*/ : 8 /*BATTERY_MAX*/));
if (device->battery > 100) {
device->battery = 100;
}
switch (batteryKind) {
case FF_GAMEPAD_BATTERY_DS4:
if (readHidReport(hHidFile, reportBuffer, caps.InputReportByteLength, FF_IO_TERM_RESP_WAIT_MS, &nBytes)) {
device->battery = parseDs4Battery(reportBuffer, caps.InputReportByteLength);
}
} else {
if (nBytes > 3 && reportBuffer[0] == 0x30) {
uint8_t batteryInfo = reportBuffer[2];
device->battery = (uint8_t) (((batteryInfo & 0xE0) >> 4) * 100 / 8);
if (device->battery == 0) {
device->battery = 1;
} else if (device->battery > 100) {
device->battery = 100;
}
break;
case FF_GAMEPAD_BATTERY_DUALSENSE:
if (readHidReport(hHidFile, reportBuffer, caps.InputReportByteLength, FF_IO_TERM_RESP_WAIT_MS, &nBytes)) {
device->battery = parseDualSenseBattery(reportBuffer, caps.InputReportByteLength);
}
}
} else {
CancelIo(hHidFile);
break;
case FF_GAMEPAD_BATTERY_SWITCH:
device->battery = detectSwitchBattery(hHidFile, reportBuffer, caps.InputReportByteLength);
break;
case FF_GAMEPAD_BATTERY_NONE:
break;
}
}
}