diff --git a/CHANGELOG.md b/CHANGELOG.md index e1bbd0c31..43a9b1d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,8 @@ Features: * An unpackaged player such as Chrome is now reported as `Google Chrome` instead of `Chrome`. * The `waitTime` option of `DiskIO`, `NetIO` and `Top` now defaults to `250` ms instead of `500`. (DiskIO / NetIO / Top) * This change improves the responsiveness while maintaining reasonably accurate measurements. +* Added battery level detection for the DualSense, the DualSense Edge and the Access Controller, and for the Nintendo Switch Joy-Con, to the `gamepad` module on Windows. (Gamepad, Windows) + * The module also names the Sony Access Controller and the Nintendo Switch 2 controllers, which it previously left to Windows' own naming. Bugfixes: * Fixed Base64 encoding producing incorrect output for some inputs. (General) @@ -157,6 +159,8 @@ Bugfixes: * Fixed the `display.temp` validation errors naming the long-removed `display.temperature` key, which the config parser rejects. (General) * Fixed a memory leak in the `wifi` module on Windows when the BSS list comes back empty. (Wifi, Windows) * Fixed `--gen-config` hanging forever when stdout is redirected to `NUL` or MSYS2's `/dev/null`. On Windows `isatty()` reports a character device rather than a console, so a redirected run was mistaken for an interactive one. (General, Windows) +* Fixed the `gamepad` module reading the DualShock 4 battery level on Windows out of a report layout it guessed from the buffer length. A controller that has not been switched to its extended report mode sends the plain HID gamepad report, which carries no battery level, so the guess read a zeroed region and reported 0%. The layout now follows the report id, and the level is scaled the way SDL scales it, so a level of 8 is 85% rather than 100%. (Gamepad, Windows) +* Fixed the `gamepad` module on Windows giving up on a Switch controller's battery after a single read. A Switch controller interleaves its full state report with the replies it sends to subcommands, and on the Pro Controller these made up four fifths of the traffic, so the one report the module read almost never carried a battery and the controller always reported 0%. It now keeps reading until a full state report arrives, within the same one-second budget. (Gamepad, Windows) * Some internal cleanups and optimizations. Logos: diff --git a/src/detection/gamepad/gamepad_windows.c b/src/detection/gamepad/gamepad_windows.c index b7f23e18c..73719411c 100644 --- a/src/detection/gamepad/gamepad_windows.c +++ b/src/detection/gamepad/gamepad_windows.c @@ -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 @@ -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; } } }