diff --git a/README.md b/README.md index b08354c..67e7220 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,9 @@ behind backend implementations. - Gamepad profiles for generic HID, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Nintendo Switch Pro-style controllers. -- Descriptor-driven Linux gamepads through `uhid`, plus keyboard, mouse, - touchscreen, trackpad, and pen tablet devices through `uinput`. +- Descriptor-driven Linux gamepads through `uhid`; Xbox Series gamepads plus + keyboard, mouse, touchscreen, trackpad, and pen tablet devices through + `uinput`. - Windows gamepads through a user-mode UMDF2 control driver backed by Virtual HID Framework, with keyboard and mouse support through normal Win32 APIs. - Output callbacks for profile-specific feedback such as rumble, LEDs, diff --git a/docs/platform-support.md b/docs/platform-support.md index 96abb61..022d9c8 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -46,15 +46,36 @@ and signing details. The Linux backend uses standard user-space kernel interfaces: - `uhid` for descriptor-driven HID gamepads. -- `uinput` for keyboard, mouse, touchscreen, trackpad, and pen tablet devices. +- `uinput` for Generic, Xbox 360, Xbox One, and Xbox Series gamepads, plus + keyboard, mouse, touchscreen, trackpad, and pen tablet devices. - `libevdev` internally for uinput device construction. - X11/XTest only as a keyboard and mouse fallback when `uinput` cannot be used and an X11 session is available. -Gamepad support prefers `uhid` because descriptors, raw HID identity, feature -reports, and output reports matter for controller compatibility. Keyboard and -pointer devices prefer `uinput` because those devices map naturally to Linux -input devices. +Gamepad support normally prefers `uhid` because descriptors, raw HID identity, +feature reports, and output reports matter for controller compatibility. +Generic and Xbox-family profiles instead use `uinput` so SDL, Steam, and other +evdev consumers receive canonical Linux gamepad events without interpreting a +standard or Xbox GIP descriptor. Face buttons, shoulders, menu buttons, stick +clicks, and Guide use their native evdev codes; sticks and the directional pad +use absolute axes; triggers remain independent analog `ABS_Z` and `ABS_RZ` +axes. Generic also emits the directional pad through `BTN_DPAD_*` alongside its +hat axes for consumers that prefer digital D-pad events. Profiles with rumble +support normalize force-feedback effects back into the public callback. + +Xbox One and Xbox Series retain their public USB identities. On Linux, their +uinput devices use the corresponding Bluetooth product identities (`0x0B20` +and `0x0B13`, respectively), whose standard consumer mappings match the events +that uinput exposes. Generic, Xbox One, and Xbox Series preserve the sparse +15-slot Linux gamepad button sequence: unused `BTN_C`, `BTN_Z`, `BTN_TL2`, and +`BTN_TR2` slots are advertised but never pressed, keeping face buttons, +shoulders, menu buttons, Guide, L3, and R3 at their expected indices. Xbox 360 +uses the compact active-button sequence associated with its USB identity. + +Descriptor-driven profiles remain on `uhid`. The Switch Pro profile keeps its +Nintendo identity but uses the virtual UHID bus on Linux, preventing +`hid-nintendo` from claiming the descriptor-only device and waiting for +physical-controller initialization handshakes. The optional `virtualhid_control` diagnostic UI uses SDL3 and Dear ImGui through the repository CPM lockfile. It is intended to stay on the same UI framework for @@ -87,7 +108,7 @@ KERNEL=="hidraw*", ATTRS{name}=="Your App Controller*", GROUP="input", MODE="066 SUBSYSTEMS=="input", ATTRS{name}=="Your App Controller*", GROUP="input", MODE="0660", TAG+="uaccess" ``` -For `uhid` gamepad support, install a modules-load entry such as +For descriptor-driven `uhid` gamepad support, install a modules-load entry such as `/etc/modules-load.d/60-libvirtualhid.conf`: ```text diff --git a/docs/usage.md b/docs/usage.md index f956279..bf99d3c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -178,3 +178,6 @@ Profiles advertise support for features such as rumble, trigger rumble, RGB 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. +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/src/core/backend.cpp b/src/core/backend.cpp index 930c88b..1476cba 100644 --- a/src/core/backend.cpp +++ b/src/core/backend.cpp @@ -19,7 +19,10 @@ namespace lvh::detail { */ class FakeGamepad final: public BackendGamepad { public: - OperationStatus submit(const std::vector & /*report*/) override { + OperationStatus submit( + const GamepadState & /*state*/, + const std::vector & /*report*/ + ) override { return OperationStatus::success(); } diff --git a/src/core/backend.hpp b/src/core/backend.hpp index 1896a7f..d4a4553 100644 --- a/src/core/backend.hpp +++ b/src/core/backend.hpp @@ -30,12 +30,16 @@ namespace lvh::detail { virtual ~BackendGamepad() = default; /** - * @brief Submit a packed input report to the backend. + * @brief Submit normalized state and its packed input report to the backend. * + * HID backends consume @p report, while native platform input backends may + * consume @p state. + * + * @param state Normalized gamepad state. * @param report Packed HID input report. * @return Submit status. */ - virtual OperationStatus submit(const std::vector &report) = 0; + virtual OperationStatus submit(const GamepadState &state, const std::vector &report) = 0; /** * @brief Get platform-visible nodes associated with this backend device. diff --git a/src/core/gamepad_adapter.cpp b/src/core/gamepad_adapter.cpp index 394c7fb..d7360e5 100644 --- a/src/core/gamepad_adapter.cpp +++ b/src/core/gamepad_adapter.cpp @@ -44,12 +44,12 @@ namespace lvh { using enum GamepadProfileKind; case generic: - case xbox_360: - case xbox_one: case xbox_series: case dualsense: case switch_pro: return true; + case xbox_360: + case xbox_one: case dualshock4: return false; } diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index 9305536..c9dee61 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -18,8 +18,6 @@ namespace lvh::profiles { constexpr std::uint8_t common_button_count = 12; - constexpr std::uint8_t standard_button_count = 16; - constexpr std::uint8_t common_axis_count = 6; constexpr std::size_t common_button_bytes = 2; @@ -86,18 +84,87 @@ namespace lvh::profiles { return descriptor; } + void append_common_gamepad_buttons(std::vector &descriptor, bool include_misc_button) { + descriptor.insert( + descriptor.end(), + { + 0x05, + 0x09, // Usage Page (Button) + 0x09, + 0x01, // Usage (Button 1 / BTN_SOUTH) + 0x09, + 0x02, // Usage (Button 2 / BTN_EAST) + 0x09, + 0x04, // Usage (Button 4 / BTN_NORTH) + 0x09, + 0x05, // Usage (Button 5 / BTN_WEST) + 0x09, + 0x07, // Usage (Button 7 / BTN_TL) + 0x09, + 0x08, // Usage (Button 8 / BTN_TR) + 0x09, + 0x0B, // Usage (Button 11 / BTN_SELECT) + 0x09, + 0x0C, // Usage (Button 12 / BTN_START) + 0x09, + 0x0E, // Usage (Button 14 / BTN_THUMBL) + 0x09, + 0x0F, // Usage (Button 15 / BTN_THUMBR) + 0x09, + 0x0D, // Usage (Button 13 / BTN_MODE) + } + ); + if (include_misc_button) { + descriptor.insert( + descriptor.end(), + { + 0x09, + 0x06, // Usage (Button 6 / BTN_Z) + } + ); + } + descriptor.insert( + descriptor.end(), + { + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + static_cast(include_misc_button ? common_button_count : common_button_count - 1U), + 0x81, + 0x02, // Input (Data,Var,Abs) + } + ); + if (!include_misc_button) { + descriptor.insert( + descriptor.end(), + { + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Const,Var,Abs) + } + ); + } + } + std::vector make_xbox_gip_report_descriptor(bool include_share_button) { constexpr std::string_view xbox_one_descriptor = "05010905a101a10009300931150027ffff0000950275108102c0a10009330934150027ffff0000950275108102c0" "05010932150026ff039501750a81021500250075069501810305010935150026ff039501750a8102150025007506" - "9501810305091901290a950a750181021500250075069501810305010939150125083500463b0166140075049501" + "950181030509090109020904090509070908090b090c090e090f150025017501950a81021500250075069501810305010939150125083500463b0166140075049501" "814275049501150025003500450065008103a102050f099715002501750495019102150025009103097015002564" "7508950491020950660110550e26ff009501910209a7910265005500097c9102c005010980a100098515002501" "95017501810215002500750795018103c005060920150026ff00750895018102c0"; constexpr std::string_view xbox_series_descriptor = "05010905a101a10009300931150027ffff0000950275108102c0a10009330934150027ffff0000950275108102c0" "05010932150026ff039501750a81021500250075069501810305010935150026ff039501750a8102150025007506" - "9501810305091901290c950c750181021500250075049501810305010939150125083500463b0166140075049501" + "950181030509090109020904090509070908090b090c090e090f150025017501950a8102150025007501950181030906150025017501950181021500250075049501810305010939150125083500463b0166140075049501" "814275049501150025003500450065008103a102050f099715002501750495019102150025009103097015002564" "7508950491020950660110550e26ff009501910209a7910265005500097c9102c005010980a100098515002501" "95017501810215002500750795018103c005060920150026ff00750895018102c0"; @@ -107,9 +174,9 @@ namespace lvh::profiles { std::vector make_switch_pro_report_descriptor() { constexpr std::string_view descriptor = - "050115000904a1018530050105091901290a150025017501950a5500650081020509190b290e150025017501" + "050115000904a1018530050105090902090109040905090709080909090a090b090c150025017501950a5500650081020509090e090f090d0906150025017501" "950481027501950281030b01000100a1000b300001000b310001000b320001000b35000100150027ffff0000" - "751095048102c00b39000100150025073500463b0165147504950181020509190f291215002501750195048102" + "751095048102c00b39000100150025073500463b016514750495018102750495018103" "7508953481030600ff852109017508953f8103858109027508953f8103850109037508953f9183851009047508" "953f9183858009057508953f9183858209067508953f9183c0"; @@ -126,71 +193,61 @@ namespace lvh::profiles { 0x01, // Collection (Application) 0x85, report_id, // Report ID - 0x05, - 0x09, // Usage Page (Button) - 0x19, - 0x01, // Usage Minimum (Button 1) - 0x29, - common_button_count, // Usage Maximum - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x01, // Logical Maximum (1) - 0x75, - 0x01, // Report Size (1) - 0x95, - common_button_count, // Report Count - 0x81, - 0x02, // Input (Data,Var,Abs) - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x09, - 0x39, // Usage (Hat switch) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x07, // Logical Maximum (7) - 0x35, - 0x00, // Physical Minimum (0) - 0x46, - 0x3B, - 0x01, // Physical Maximum (315) - 0x65, - 0x14, // Unit (Eng Rot:Angular Pos) - 0x75, - 0x04, // Report Size (4) - 0x95, - 0x01, // Report Count (1) - 0x81, - 0x42, // Input (Data,Var,Abs,Null) - 0x65, - 0x00, // Unit (None) - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x00, // Logical Maximum (255) - 0x75, - 0x08, // Report Size (8) - 0x95, - common_axis_count, // Report Count - 0x09, - 0x30, // Usage (X) - 0x09, - 0x31, // Usage (Y) - 0x09, - 0x32, // Usage (Z) - 0x09, - 0x33, // Usage (Rx) - 0x09, - 0x34, // Usage (Ry) - 0x09, - 0x35, // Usage (Rz) - 0x81, - 0x02, // Input (Data,Var,Abs) }; + append_common_gamepad_buttons(descriptor, false); + descriptor.insert( + descriptor.end(), + { + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x39, // Usage (Hat switch) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x07, // Logical Maximum (7) + 0x35, + 0x00, // Physical Minimum (0) + 0x46, + 0x3B, + 0x01, // Physical Maximum (315) + 0x65, + 0x14, // Unit (Eng Rot:Angular Pos) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x42, // Input (Data,Var,Abs,Null) + 0x65, + 0x00, // Unit (None) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + common_axis_count, // Report Count + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x09, + 0x32, // Usage (Z) + 0x09, + 0x33, // Usage (Rx) + 0x09, + 0x34, // Usage (Ry) + 0x09, + 0x35, // Usage (Rz) + 0x81, + 0x02, // Input (Data,Var,Abs) + } + ); if (supports_rumble) { descriptor.insert( @@ -233,48 +290,61 @@ namespace lvh::profiles { 0x01, // Collection (Application) 0x85, report_id, // Report ID - 0x05, - 0x09, // Usage Page (Button) - 0x19, - 0x01, // Usage Minimum (Button 1) - 0x29, - standard_button_count, // Usage Maximum - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x01, // Logical Maximum (1) - 0x75, - 0x01, // Report Size (1) - 0x95, - standard_button_count, // Report Count - 0x81, - 0x02, // Input (Data,Var,Abs) - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x00, // Logical Maximum (255) - 0x75, - 0x08, // Report Size (8) - 0x95, - common_axis_count, // Report Count - 0x09, - 0x30, // Usage (X) - 0x09, - 0x31, // Usage (Y) - 0x09, - 0x33, // Usage (Rx) - 0x09, - 0x34, // Usage (Ry) - 0x09, - 0x32, // Usage (Z) - 0x09, - 0x35, // Usage (Rz) - 0x81, - 0x02, // Input (Data,Var,Abs) }; + append_common_gamepad_buttons(descriptor, true); + descriptor.insert( + descriptor.end(), + { + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x39, // Usage (Hat switch) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x07, // Logical Maximum (7) + 0x35, + 0x00, // Physical Minimum (0) + 0x46, + 0x3B, + 0x01, // Physical Maximum (315) + 0x65, + 0x14, // Unit (Eng Rot:Angular Pos) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x42, // Input (Data,Var,Abs,Null) + 0x65, + 0x00, // Unit (None) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + common_axis_count, // Report Count + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x09, + 0x33, // Usage (Rx) + 0x09, + 0x34, // Usage (Ry) + 0x09, + 0x32, // Usage (Z) + 0x09, + 0x35, // Usage (Rz) + 0x81, + 0x02, // Input (Data,Var,Abs) + } + ); if (supports_rumble) { descriptor.insert( diff --git a/src/core/report.cpp b/src/core/report.cpp index b4d546e..7876baa 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -214,17 +214,6 @@ namespace lvh::reports { }; } - constexpr auto standard_dpad_button_map() { - using enum GamepadButton; - - return std::array { - ButtonBit {12U, dpad_up}, - ButtonBit {13U, dpad_down}, - ButtonBit {14U, dpad_left}, - ButtonBit {15U, dpad_right}, - }; - } - constexpr auto xbox_extra_button_map() { using enum GamepadButton; @@ -264,13 +253,6 @@ namespace lvh::reports { ); } - std::uint16_t standard_gamepad_button_bits(const ButtonSet &buttons) { - return static_cast( - common_button_bits(buttons) | - button_bits(standard_dpad_button_map(), buttons) - ); - } - std::uint16_t xbox_gip_button_bits(const ButtonSet &buttons) { return static_cast( button_bits(face_shoulder_button_map(), buttons) | @@ -846,7 +828,7 @@ namespace lvh::reports { std::vector report; report.reserve(standard_report_size); report.push_back(profile.report_id); - append_u16(report, standard_gamepad_button_bits(normalized.buttons)); + append_u16(report, static_cast(common_button_bits(normalized.buttons) | dpad_hat_bits(normalized.buttons))); report.push_back(normalize_u8_axis(normalized.left_stick.x)); report.push_back(normalize_u8_axis(-normalized.left_stick.y)); report.push_back(normalize_u8_axis(normalized.right_stick.x)); diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 4a59f8e..3717a45 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -388,13 +388,14 @@ namespace lvh { return OperationStatus::failure(ErrorCode::backend_failure, "failed to pack gamepad input report"); } + const auto normalized = reports::normalize_state(state); if (device.backend) { - if (const auto status = device.backend->submit(report); !status.ok()) { + if (const auto status = device.backend->submit(normalized, report); !status.ok()) { return status; } } - device.last_state = reports::normalize_state(state); + device.last_state = normalized; device.last_report = std::move(report); ++device.submitted_reports; return OperationStatus::success(); diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 0e44dec..e08ac37 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -77,6 +78,12 @@ namespace lvh::detail { constexpr auto tablet_distance_max = 1024; constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; + constexpr auto xbox_trigger_max = 255; + // The Xbox Bluetooth identities select the sparse evdev mappings that match + // the button capabilities exposed by these uinput devices. + constexpr auto xbox_wireless_uinput_bus = BUS_BLUETOOTH; + constexpr std::uint16_t xbox_one_uinput_product_id = 0x0B20; + constexpr std::uint16_t xbox_series_uinput_product_id = 0x0B13; constexpr auto dualshock4_usb_calibration_report = 0x02; constexpr auto dualshock4_bluetooth_calibration_report = 0x05; constexpr auto dualshock4_pairing_report = 0x12; @@ -249,6 +256,7 @@ namespace lvh::detail { 0x00, 0x00, 0x00, + 0x00, 0x10, 0x27, 0xF0, @@ -490,6 +498,36 @@ namespace lvh::detail { return kind == GamepadProfileKind::dualshock4 || kind == GamepadProfileKind::dualsense; } + bool uses_uinput_gamepad_profile(GamepadProfileKind kind) { + switch (kind) { + using enum GamepadProfileKind; + + case generic: + case xbox_360: + case xbox_one: + case xbox_series: + return true; + case dualshock4: + case dualsense: + case switch_pro: + return false; + } + + return false; + } + + bool has_uinput_misc1_button(GamepadProfileKind kind) { + return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_series; + } + + bool uses_sparse_uinput_button_slots(GamepadProfileKind kind) { + return kind == GamepadProfileKind::generic || kind == GamepadProfileKind::xbox_one || kind == GamepadProfileKind::xbox_series; + } + + bool has_uinput_dpad_buttons(GamepadProfileKind kind) { + return kind == GamepadProfileKind::generic; + } + std::uint16_t to_uhid_bus(BusType bus_type) { if (bus_type == BusType::bluetooth) { return BUS_BLUETOOTH; @@ -497,6 +535,13 @@ namespace lvh::detail { return BUS_USB; } + std::uint16_t to_uhid_bus(const DeviceProfile &profile) { + if (profile.gamepad_kind == GamepadProfileKind::switch_pro) { + return BUS_VIRTUAL; + } + return to_uhid_bus(profile.bus_type); + } + std::uint16_t to_uinput_bus(BusType bus_type) { return to_uhid_bus(bus_type); } @@ -1273,8 +1318,93 @@ namespace lvh::detail { return enable_evdev_property(device, INPUT_PROP_DIRECT, "tablet direct"); } - OperationStatus configure_evdev_device(libevdev *device, DeviceType device_type) { - switch (device_type) { + OperationStatus configure_evdev_gamepad(libevdev *device, const DeviceProfile &profile) { + if (const auto status = enable_evdev_type(device, EV_KEY, "Xbox gamepad button events"); !status.ok()) { + return status; + } + if (const auto status = enable_evdev_type(device, EV_ABS, "Xbox gamepad absolute events"); !status.ok()) { + return status; + } + if (profile.capabilities.supports_rumble) { + if (const auto status = enable_evdev_type(device, EV_FF, "Xbox gamepad force-feedback events"); !status.ok()) { + return status; + } + } + + constexpr std::array active_buttons { + BTN_SOUTH, + BTN_EAST, + BTN_NORTH, + BTN_WEST, + BTN_TL, + BTN_TR, + BTN_SELECT, + BTN_START, + BTN_MODE, + BTN_THUMBL, + BTN_THUMBR, + }; + for (const auto button : active_buttons) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "Xbox gamepad button"); !status.ok()) { + return status; + } + } + + if (uses_sparse_uinput_button_slots(profile.gamepad_kind)) { + // Steam's Generic and Xbox Series mappings use the sparse Linux gamepad + // sequence. These unused capabilities preserve the active button indices. + constexpr std::array reserved_buttons {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}; + for (const auto button : reserved_buttons) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "reserved gamepad button slot"); !status.ok()) { + return status; + } + } + } + if (has_uinput_misc1_button(profile.gamepad_kind)) { + if (const auto status = enable_evdev_code(device, EV_KEY, KEY_RECORD, "gamepad share button"); !status.ok()) { + return status; + } + } + if (has_uinput_dpad_buttons(profile.gamepad_kind)) { + for (const auto button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "gamepad directional button"); !status.ok()) { + return status; + } + } + } + + auto dpad = make_absinfo(-1, 1); + for (const auto code : {ABS_HAT0X, ABS_HAT0Y}) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad directional axis", &dpad); !status.ok()) { + return status; + } + } + + auto stick = make_absinfo(-32768, 32767, 16, 128); + for (const auto code : {ABS_X, ABS_Y, ABS_RX, ABS_RY}) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad stick axis", &stick); !status.ok()) { + return status; + } + } + + auto trigger = make_absinfo(0, xbox_trigger_max); + for (const auto code : {ABS_Z, ABS_RZ}) { + if (const auto status = enable_evdev_code(device, EV_ABS, code, "Xbox gamepad trigger axis", &trigger); !status.ok()) { + return status; + } + } + + if (profile.capabilities.supports_rumble) { + if (const auto status = enable_evdev_code(device, EV_FF, FF_RUMBLE, "Xbox gamepad force-feedback effect"); !status.ok()) { + return status; + } + return enable_evdev_code(device, EV_FF, FF_GAIN, "Xbox gamepad force-feedback gain"); + } + return OperationStatus::success(); + } + + OperationStatus configure_evdev_device(libevdev *device, const DeviceProfile &profile) { + switch (profile.device_type) { using enum DeviceType; case keyboard: @@ -1288,7 +1418,10 @@ namespace lvh::detail { case pen_tablet: return configure_evdev_pen_tablet(device); case gamepad: - return OperationStatus::failure(ErrorCode::unsupported_profile, "gamepads are created through UHID, not uinput"); + if (uses_uinput_gamepad_profile(profile.gamepad_kind)) { + return configure_evdev_gamepad(device, profile); + } + return OperationStatus::failure(ErrorCode::unsupported_profile, "gamepad profile is not supported through uinput"); } return OperationStatus::failure(ErrorCode::unsupported_profile, "unsupported uinput device type"); @@ -1305,12 +1438,24 @@ namespace lvh::detail { } libevdev_set_name(device.get(), profile.name.c_str()); - libevdev_set_id_bustype(device.get(), to_uinput_bus(profile.bus_type)); + const auto xbox_one = profile.gamepad_kind == GamepadProfileKind::xbox_one; + const auto xbox_series = profile.gamepad_kind == GamepadProfileKind::xbox_series; + const auto xbox_wireless = xbox_one || xbox_series; + auto product_id = profile.product_id; + if (xbox_one) { + product_id = xbox_one_uinput_product_id; + } else if (xbox_series) { + product_id = xbox_series_uinput_product_id; + } + libevdev_set_id_bustype( + device.get(), + xbox_wireless ? xbox_wireless_uinput_bus : to_uinput_bus(profile.bus_type) + ); libevdev_set_id_vendor(device.get(), profile.vendor_id); - libevdev_set_id_product(device.get(), profile.product_id); + libevdev_set_id_product(device.get(), product_id); libevdev_set_id_version(device.get(), profile.version); - if (const auto status = configure_evdev_device(device.get(), profile.device_type); !status.ok()) { + if (const auto status = configure_evdev_device(device.get(), profile); !status.ok()) { return {status, nullptr}; } @@ -2232,6 +2377,316 @@ namespace lvh::detail { } #endif + struct UinputRumbleEffect { + std::uint16_t weak_magnitude = 0; + std::uint16_t strong_magnitude = 0; + std::chrono::milliseconds length {0}; + std::chrono::milliseconds delay {0}; + std::chrono::steady_clock::time_point start {}; + std::chrono::steady_clock::time_point end {}; + bool active = false; + }; + + /** + * @brief Standard gamepad exposed through canonical Linux input events. + */ + class UinputGamepad final: public BackendGamepad, private UinputDevice { + public: + UinputGamepad(int file_descriptor, GamepadProfileKind profile_kind): + UinputDevice {file_descriptor}, + profile_kind_ {profile_kind} {} + + ~UinputGamepad() override = default; + + OperationStatus create(DeviceId id, const CreateGamepadOptions &options) { + if (options.profile.gamepad_kind != profile_kind_ || !uses_uinput_gamepad_profile(profile_kind_)) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "gamepad profile is not supported through uinput"); + } + + device_name_ = options.profile.name; + if (const auto status = create_uinput_device(options.profile, id); !status.ok()) { + return status; + } + + if (options.profile.capabilities.supports_rumble) { + running_ = true; + reader_ = std::jthread {[this](std::stop_token stop_token) { + read_output_loop(stop_token); + }}; + } + return OperationStatus::success(); + } + + OperationStatus submit( + const GamepadState &state, + const std::vector & /*report*/ + ) override { + using enum GamepadButton; + + if (!is_open()) { + return OperationStatus::failure(ErrorCode::device_closed, "uinput Xbox gamepad is closed"); + } + + const auto normalized = reports::normalize_state(state); + std::lock_guard lock {submit_mutex_}; + + constexpr std::array button_map { + std::pair {a, BTN_SOUTH}, + std::pair {b, BTN_EAST}, + std::pair {x, BTN_NORTH}, + std::pair {y, BTN_WEST}, + std::pair {left_shoulder, BTN_TL}, + std::pair {right_shoulder, BTN_TR}, + std::pair {back, BTN_SELECT}, + std::pair {start, BTN_START}, + std::pair {guide, BTN_MODE}, + std::pair {left_stick, BTN_THUMBL}, + std::pair {right_stick, BTN_THUMBR}, + }; + for (const auto &[button, code] : button_map) { + if (const auto status = emit_event(EV_KEY, code, normalized.buttons.test(button) ? 1 : 0); !status.ok()) { + return status; + } + } + if (has_uinput_misc1_button(profile_kind_)) { + if (const auto status = emit_event(EV_KEY, KEY_RECORD, normalized.buttons.test(misc1) ? 1 : 0); !status.ok()) { + return status; + } + } + if (has_uinput_dpad_buttons(profile_kind_)) { + constexpr std::array dpad_button_map { + std::pair {dpad_up, BTN_DPAD_UP}, + std::pair {dpad_down, BTN_DPAD_DOWN}, + std::pair {dpad_left, BTN_DPAD_LEFT}, + std::pair {dpad_right, BTN_DPAD_RIGHT}, + }; + for (const auto &[button, code] : dpad_button_map) { + if (const auto status = emit_event(EV_KEY, code, normalized.buttons.test(button) ? 1 : 0); !status.ok()) { + return status; + } + } + } + + if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(normalized.buttons, dpad_left, dpad_right)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_HAT0Y, dpad_axis(normalized.buttons, dpad_up, dpad_down)); !status.ok()) { + return status; + } + + const std::array axes { + std::pair {ABS_X, static_cast(reports::normalize_axis(normalized.left_stick.x))}, + std::pair {ABS_Y, static_cast(reports::normalize_axis(-normalized.left_stick.y))}, + std::pair {ABS_RX, static_cast(reports::normalize_axis(normalized.right_stick.x))}, + std::pair {ABS_RY, static_cast(reports::normalize_axis(-normalized.right_stick.y))}, + std::pair {ABS_Z, static_cast(reports::normalize_trigger(normalized.left_trigger))}, + std::pair {ABS_RZ, static_cast(reports::normalize_trigger(normalized.right_trigger))}, + }; + for (const auto &[code, value] : axes) { + if (const auto status = emit_event(EV_ABS, code, value); !status.ok()) { + return status; + } + } + return sync(); + } + + void set_output_callback(OutputCallback callback) override { + std::lock_guard lock {callback_mutex_}; + output_callback_ = std::move(callback); + } + + std::vector device_nodes() const override { + return discover_input_nodes_by_name(device_name_); + } + + OperationStatus close() override { + running_ = false; + if (reader_.joinable()) { + reader_.request_stop(); + reader_.join(); + } + dispatch_rumble(0, 0); + return close_uinput("uinput gamepad"); + } + + private: + static int dpad_axis(const ButtonSet &buttons, GamepadButton negative, GamepadButton positive) { + const auto negative_pressed = buttons.test(negative); + if (const auto positive_pressed = buttons.test(positive); negative_pressed == positive_pressed) { + return 0; + } + return negative_pressed ? -1 : 1; + } + + void read_output_loop(std::stop_token stop_token) { + while (!stop_token.stop_requested() && running_) { + pollfd descriptor {}; + descriptor.fd = file_descriptor(); + descriptor.events = POLLIN; + + const auto poll_result = system_poll(&descriptor, 1, poll_timeout_ms); + if (poll_result < 0) { + if (errno == EINTR) { + continue; + } + return; + } + if (poll_result > 0) { + if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { + return; + } + if ((descriptor.revents & POLLIN) != 0 && !read_output_events()) { + return; + } + } + update_rumble(); + } + } + + bool read_output_events() { + std::array events {}; + const auto result = system_read(file_descriptor(), std::as_writable_bytes(std::span {events})); + if (result < 0) { + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; + } + if (result == 0) { + return false; + } + + const auto event_count = static_cast(result) / sizeof(input_event); + for (std::size_t index = 0; index < event_count; ++index) { + handle_output_event(events[index]); + } + return true; + } + + void handle_output_event(const input_event &event) { + if (event.type == EV_UINPUT && event.code == UI_FF_UPLOAD) { + upload_rumble_effect(event.value); + return; + } + if (event.type == EV_UINPUT && event.code == UI_FF_ERASE) { + erase_rumble_effect(event.value); + return; + } + if (event.type != EV_FF) { + return; + } + if (event.code == FF_GAIN) { + gain_ = static_cast(std::clamp(event.value, 0, static_cast(std::numeric_limits::max()))); + return; + } + + const auto effect = rumble_effects_.find(event.code); + if (effect == rumble_effects_.end()) { + return; + } + if (event.value <= 0) { + effect->second.active = false; + return; + } + + const auto now = std::chrono::steady_clock::now(); + effect->second.active = true; + effect->second.start = now + effect->second.delay; + effect->second.end = effect->second.start + effect->second.length * std::max(event.value, 1); + } + + void upload_rumble_effect(int request_id) { + uinput_ff_upload upload {}; + upload.request_id = request_id; + if (system_ioctl(file_descriptor(), UI_BEGIN_FF_UPLOAD, reinterpret_cast(&upload)) < 0) { + return; + } + + if (upload.effect.type == FF_RUMBLE) { + auto effect = UinputRumbleEffect { + .weak_magnitude = upload.effect.u.rumble.weak_magnitude, + .strong_magnitude = upload.effect.u.rumble.strong_magnitude, + .length = std::chrono::milliseconds {upload.effect.replay.length}, + .delay = std::chrono::milliseconds {upload.effect.replay.delay}, + }; + if (const auto existing = rumble_effects_.find(upload.effect.id); existing != rumble_effects_.end()) { + effect.start = existing->second.start; + effect.end = existing->second.end; + effect.active = existing->second.active; + } + rumble_effects_.insert_or_assign(upload.effect.id, effect); + } + + upload.retval = 0; + static_cast(system_ioctl(file_descriptor(), UI_END_FF_UPLOAD, reinterpret_cast(&upload))); + } + + void erase_rumble_effect(int request_id) { + uinput_ff_erase erase {}; + erase.request_id = request_id; + if (system_ioctl(file_descriptor(), UI_BEGIN_FF_ERASE, reinterpret_cast(&erase)) < 0) { + return; + } + + rumble_effects_.erase(erase.effect_id); + erase.retval = 0; + static_cast(system_ioctl(file_descriptor(), UI_END_FF_ERASE, reinterpret_cast(&erase))); + } + + void update_rumble() { + const auto now = std::chrono::steady_clock::now(); + auto weak = std::uint64_t {0}; + auto strong = std::uint64_t {0}; + for (auto &[id, effect] : rumble_effects_) { + static_cast(id); + if (!effect.active || now < effect.start) { + continue; + } + if (now >= effect.end) { + effect.active = false; + continue; + } + weak += effect.weak_magnitude; + strong += effect.strong_magnitude; + } + + constexpr auto maximum = std::numeric_limits::max(); + const auto scaled_weak = static_cast(std::min(weak, maximum) * gain_ / maximum); + const auto scaled_strong = static_cast(std::min(strong, maximum) * gain_ / maximum); + dispatch_rumble(scaled_strong, scaled_weak); + } + + void dispatch_rumble(std::uint16_t low_frequency, std::uint16_t high_frequency) { + if (last_low_frequency_ == low_frequency && last_high_frequency_ == high_frequency) { + return; + } + last_low_frequency_ = low_frequency; + last_high_frequency_ = high_frequency; + + OutputCallback callback; + { + std::lock_guard lock {callback_mutex_}; + callback = output_callback_; + } + if (callback) { + GamepadOutput output; + output.kind = GamepadOutputKind::rumble; + output.low_frequency_rumble = low_frequency; + output.high_frequency_rumble = high_frequency; + callback(output); + } + } + + std::string device_name_; + GamepadProfileKind profile_kind_; + std::atomic_bool running_ = false; + std::mutex submit_mutex_; + std::mutex callback_mutex_; + OutputCallback output_callback_; + std::map rumble_effects_; + std::uint16_t gain_ = std::numeric_limits::max(); + std::uint16_t last_low_frequency_ = 0; + std::uint16_t last_high_frequency_ = 0; + std::jthread reader_; + }; + /** * @brief Backend gamepad backed by one Linux UHID file descriptor. */ @@ -2268,7 +2723,7 @@ namespace lvh::detail { copy_string(request.phys, std::format("libvirtualhid/uhid/{}", id)); copy_string(request.uniq, unique_id); request.rd_size = static_cast(options.profile.report_descriptor.size()); - request.bus = to_uhid_bus(options.profile.bus_type); + request.bus = to_uhid_bus(options.profile); request.vendor = options.profile.vendor_id; request.product = options.profile.product_id; request.version = options.profile.version; @@ -2296,7 +2751,10 @@ namespace lvh::detail { return OperationStatus::success(); } - OperationStatus submit(const std::vector &report) override { + OperationStatus submit( + const GamepadState & /*state*/, + const std::vector &report + ) override { if (!open_) { return OperationStatus::failure(ErrorCode::device_closed, "UHID gamepad is closed"); } @@ -2453,7 +2911,7 @@ namespace lvh::detail { report = last_report_; } if (!report.empty()) { - static_cast(submit(report)); + static_cast(submit({}, report)); } } } @@ -2585,13 +3043,13 @@ namespace lvh::detail { const auto xtest_accessible = can_use_xtest(); capabilities_.backend_name = "linux-uhid-uinput"; capabilities_.supports_virtual_hid = uhid_accessible || uinput_accessible; - capabilities_.supports_gamepad = uhid_accessible; + capabilities_.supports_gamepad = uhid_accessible || uinput_accessible; capabilities_.supports_keyboard = uinput_accessible || xtest_accessible; capabilities_.supports_mouse = uinput_accessible || xtest_accessible; capabilities_.supports_touchscreen = uinput_accessible; capabilities_.supports_trackpad = uinput_accessible; capabilities_.supports_pen_tablet = uinput_accessible; - capabilities_.supports_output_reports = uhid_accessible; + capabilities_.supports_output_reports = uhid_accessible || uinput_accessible; capabilities_.supports_xtest_fallback = xtest_accessible; } @@ -2600,6 +3058,20 @@ namespace lvh::detail { } BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { + if (uses_uinput_gamepad_profile(options.profile.gamepad_kind)) { + const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; + } + + auto gamepad = std::make_unique(fd, options.profile.gamepad_kind); + if (const auto status = gamepad->create(id, options); !status.ok()) { + static_cast(gamepad->close()); + return {status, nullptr}; + } + return {OperationStatus::success(), std::move(gamepad)}; + } + const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index f388bd8..7b687b6 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -711,7 +711,10 @@ namespace lvh::detail { context_ {std::move(context)}, state_ {std::move(state)} {} - OperationStatus submit(const std::vector &report) override; + OperationStatus submit( + const GamepadState &state, + const std::vector &report + ) override; void set_output_callback(OutputCallback callback) override; std::vector device_nodes() const override; OperationStatus close() override; @@ -870,7 +873,10 @@ namespace lvh::detail { std::map> gamepads_; }; - OperationStatus WindowsGamepad::submit(const std::vector &report) { + OperationStatus WindowsGamepad::submit( + const GamepadState & /*state*/, + const std::vector &report + ) { using enum ErrorCode; { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 929ae67..81258e7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,8 +24,6 @@ set(TEST_BINARY test_libvirtualhid) set(LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_adapter.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_backend.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_consumers.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_profiles.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_report.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp" @@ -43,7 +41,9 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) list(APPEND LIBVIRTUALHID_TEST_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/linux_backend_test_hooks.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/linux_backend_test_hooks.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_backend.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_consumers.cpp") if(LIBVIRTUALHID_ENABLE_XTEST) find_package(X11 QUIET) diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 3c98cab..8330a9a 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -51,6 +51,31 @@ namespace lvh::detail::test { std::vector events; }; + /** + * @brief Result from a fake uinput Xbox force-feedback exchange. + */ + struct LinuxUinputRumbleResult { + /** + * @brief Create operation status. + */ + OperationStatus create_status; + + /** + * @brief Close operation status. + */ + OperationStatus close_status; + + /** + * @brief Number of normalized rumble callbacks received. + */ + std::size_t callback_count = 0; + + /** + * @brief Last normalized rumble callback. + */ + GamepadOutput last_output; + }; + /** * @brief Result from a socketpair-backed UHID lifecycle test. */ @@ -363,6 +388,14 @@ namespace lvh::detail::test { */ std::uint16_t linux_uhid_bus(BusType bus_type); + /** + * @brief Select the Linux UHID bus code for a built-in gamepad profile. + * + * @param kind Built-in gamepad profile kind. + * @return Linux UHID bus code. + */ + std::uint16_t linux_gamepad_uhid_bus(GamepadProfileKind kind); + /** * @brief Translate a bus type to a Linux uinput bus code. * @@ -567,6 +600,22 @@ namespace lvh::detail::test { */ LinuxInputSubmissionResult linux_uinput_keyboard_submit_pipe(const KeyboardEvent &event); + /** + * @brief Submit normalized state through a pipe-backed uinput gamepad. + * + * @param kind Gamepad profile kind. + * @param state Gamepad state to submit. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_gamepad_submit_pipe(GamepadProfileKind kind, const GamepadState &state); + + /** + * @brief Exercise Xbox Series uinput force-feedback upload and playback. + * + * @return Creation, callback, and close results. + */ + LinuxUinputRumbleResult linux_uinput_xbox_series_fake_rumble(); + /** * @brief Try creating a libevdev uinput mouse on an invalid file descriptor. * @@ -888,6 +937,14 @@ namespace lvh::detail::test { */ LinuxLibevdevCreationResult linux_uinput_create_fake_libevdev_device(DeviceType device_type); + /** + * @brief Create a uinput gamepad through the fake libevdev recorder. + * + * @param kind Gamepad profile kind to create. + * @return Recorded fake libevdev construction result. + */ + LinuxLibevdevCreationResult linux_uinput_create_fake_gamepad(GamepadProfileKind kind); + /** * @brief Try creating a uinput device while fake libevdev allocation fails. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index c93e11e..2295905 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -138,6 +138,9 @@ namespace lvh::detail::test { std::vector read_results; std::vector read_errors; uhid_event read_event {}; + std::vector read_input_events; + ff_effect uploaded_ff_effect {}; + bool provide_uploaded_ff_effect = false; bool override_xtest_query = false; bool xtest_query_result = true; bool override_x_keycode = false; @@ -237,6 +240,13 @@ int lvh_linux_test_ioctl(int fd, unsigned long request, unsigned long argument) errno = EINVAL; return -1; } + if ( + request == UI_BEGIN_FF_UPLOAD && + lvh::detail::test::active_test_syscalls()->provide_uploaded_ff_effect + ) { + auto *upload = std::bit_cast(argument); + upload->effect = lvh::detail::test::active_test_syscalls()->uploaded_ff_effect; + } return 0; } return ::ioctl(fd, request, argument); @@ -283,6 +293,13 @@ std::ptrdiff_t lvh_linux_test_read(int fd, std::byte *buffer, std::size_t size) } if (result > 0) { + if (const auto &input_events = lvh::detail::test::active_test_syscalls()->read_input_events; + call_index < input_events.size()) { + const auto bytes = std::min(static_cast(result), std::min(size, sizeof(input_event))); + std::memcpy(buffer, &input_events[call_index], bytes); + return static_cast(bytes); + } + const auto bytes = std::min(static_cast(result), std::min(size, sizeof(uhid_event))); std::memcpy(buffer, &lvh::detail::test::active_test_syscalls()->read_event, bytes); return static_cast(bytes); @@ -724,13 +741,17 @@ namespace lvh::detail::test { case DeviceType::pen_tablet: return profiles::pen_tablet(); case DeviceType::gamepad: - return profiles::generic_gamepad(); + return profiles::xbox_series(); } return profiles::mouse(); } - OperationStatus create_uinput_device_by_type(int fd, DeviceType device_type) { + OperationStatus create_uinput_device_by_type( + int fd, + DeviceType device_type, + std::optional gamepad_kind = std::nullopt + ) { switch (device_type) { case DeviceType::keyboard: { @@ -769,9 +790,10 @@ namespace lvh::detail::test { } case DeviceType::gamepad: { - auto status = create_libevdev_uinput_device(fd, profile_for_uinput_device_type(device_type), 1).status; - static_cast(system_close(fd)); - return status; + CreateGamepadOptions options; + options.profile = gamepad_kind ? *profiles::gamepad_profile(*gamepad_kind) : profile_for_uinput_device_type(device_type); + UinputGamepad gamepad {fd, options.profile.gamepad_kind}; + return gamepad.create(1, options); } } @@ -779,7 +801,11 @@ namespace lvh::detail::test { } template - LinuxLibevdevCreationResult create_fake_libevdev_device(DeviceType device_type, ConfigureFailure configure_failure) { + LinuxLibevdevCreationResult create_fake_libevdev_device( + DeviceType device_type, + ConfigureFailure configure_failure, + std::optional gamepad_kind = std::nullopt + ) { LinuxTestSyscalls syscalls; syscalls.override_libevdev = true; configure_failure(syscalls); @@ -792,7 +818,7 @@ namespace lvh::detail::test { return result; } - result.status = create_uinput_device_by_type(fd, device_type); + result.status = create_uinput_device_by_type(fd, device_type, gamepad_kind); if (!syscalls.libevdev_devices.empty()) { const auto &device = syscalls.libevdev_devices.back(); result.name = device.name; @@ -819,6 +845,10 @@ namespace lvh::detail::test { return create_fake_libevdev_device(device_type, keep_fake_libevdev_successful); } + LinuxLibevdevCreationResult create_fake_libevdev_gamepad(GamepadProfileKind kind) { + return create_fake_libevdev_device(DeviceType::gamepad, keep_fake_libevdev_successful, kind); + } + } // namespace std::string linux_copy_string_char_buffer(const std::string &source) { @@ -839,6 +869,11 @@ namespace lvh::detail::test { return to_uhid_bus(bus_type); } + std::uint16_t linux_gamepad_uhid_bus(GamepadProfileKind kind) { + const auto profile = profiles::gamepad_profile(kind); + return profile ? to_uhid_bus(*profile) : to_uhid_bus(BusType::unknown); + } + std::uint16_t linux_uinput_bus(BusType bus_type) { return to_uinput_bus(bus_type); } @@ -935,13 +970,13 @@ namespace lvh::detail::test { OperationStatus linux_uhid_submit_report_size(std::size_t report_size) { UhidGamepad gamepad {-1}; - return gamepad.submit(std::vector(report_size, 0)); + return gamepad.submit({}, std::vector(report_size, 0)); } OperationStatus linux_uhid_submit_after_close() { UhidGamepad gamepad {-1}; static_cast(gamepad.close()); - return gamepad.submit({0}); + return gamepad.submit({}, {0}); } OperationStatus linux_uinput_keyboard_create_invalid_fd() { @@ -982,6 +1017,85 @@ namespace lvh::detail::test { return {std::move(status), std::move(records)}; } + LinuxInputSubmissionResult linux_uinput_gamepad_submit_pipe(GamepadProfileKind kind, const GamepadState &state) { + std::array descriptors {-1, -1}; + if (::pipe(descriptors.data()) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + const auto profile = profiles::gamepad_profile(kind); + if (!profile) { + static_cast(::close(descriptors[0])); + static_cast(::close(descriptors[1])); + return {OperationStatus::failure(ErrorCode::unsupported_profile, "unknown gamepad profile"), {}}; + } + + UinputGamepad gamepad {descriptors[1], kind}; + auto status = gamepad.submit(state, reports::pack_input_report(*profile, state)); + static_cast(gamepad.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + + LinuxUinputRumbleResult linux_uinput_xbox_series_fake_rumble() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.override_poll = true; + syscalls.poll_results = {1, 1, 0}; + syscalls.poll_revents = {POLLIN, POLLIN, 0}; + syscalls.override_read = true; + syscalls.read_results = { + static_cast(sizeof(input_event)), + static_cast(sizeof(input_event)), + }; + syscalls.read_input_events = { + input_event {.type = EV_UINPUT, .code = UI_FF_UPLOAD, .value = 7}, + input_event {.type = EV_FF, .code = 3, .value = 1}, + }; + syscalls.provide_uploaded_ff_effect = true; + syscalls.uploaded_ff_effect.type = FF_RUMBLE; + syscalls.uploaded_ff_effect.id = 3; + syscalls.uploaded_ff_effect.u.rumble.weak_magnitude = 0x1234; + syscalls.uploaded_ff_effect.u.rumble.strong_magnitude = 0x5678; + syscalls.uploaded_ff_effect.replay.length = 1000; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUinputRumbleResult result; + const auto fd = open_test_fd(); + if (fd < 0) { + result.create_status = system_error_status(ErrorCode::backend_failure, "failed to open test file descriptor", errno); + result.close_status = result.create_status; + return result; + } + + std::atomic_size_t callback_count = 0; + std::mutex output_mutex; + GamepadOutput last_output; + UinputGamepad gamepad {fd, GamepadProfileKind::xbox_series}; + gamepad.set_output_callback([&callback_count, &last_output, &output_mutex](const GamepadOutput &output) { + if (output.low_frequency_rumble != 0 || output.high_frequency_rumble != 0) { + std::lock_guard lock {output_mutex}; + last_output = output; + } + ++callback_count; + }); + + CreateGamepadOptions options; + options.profile = profiles::xbox_series(); + result.create_status = gamepad.create(8, options); + for (auto attempt = 0; attempt < 100 && callback_count.load() == 0; ++attempt) { + std::this_thread::sleep_for(std::chrono::milliseconds {1}); + } + result.close_status = gamepad.close(); + result.callback_count = callback_count.load(); + { + std::lock_guard lock {output_mutex}; + result.last_output = last_output; + } + return result; + } + OperationStatus linux_uinput_user_device_invalid_fd() { return create_libevdev_uinput_device(-1, profiles::mouse(), 1).status; } @@ -1268,7 +1382,7 @@ namespace lvh::detail::test { lvh::GamepadState state; state.buttons.set(GamepadButton::a); const auto report = reports::pack_input_report(profile, state); - result.submit_status = gamepad.submit(report); + result.submit_status = gamepad.submit(state, report); if (read_uhid_event(descriptors[1], event)) { result.saw_input = event.type == UHID_INPUT2 && event.u.input2.size == report.size(); } @@ -1311,7 +1425,7 @@ namespace lvh::detail::test { event.u.get_report.rnum = 0x05; static_cast(write_uhid_event(descriptors[1], event)); if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) { - result.saw_dualsense_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 0 && + result.saw_dualsense_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 41U && event.u.get_report_reply.data[0] == 0x05; } @@ -1555,7 +1669,7 @@ namespace lvh::detail::test { result.capabilities = backend.capabilities(); CreateGamepadOptions gamepad_options; - gamepad_options.profile = profiles::xbox_360(); + gamepad_options.profile = profiles::xbox_series(); gamepad_options.metadata.stable_id = "fake-linux-gamepad"; auto gamepad = backend.create_gamepad(1, gamepad_options); result.gamepad_status = gamepad.status; @@ -1644,7 +1758,7 @@ namespace lvh::detail::test { LinuxUhidBackend backend; CreateGamepadOptions options; - options.profile = profiles::xbox_360(); + options.profile = profiles::dualshock4_usb(); return backend.create_gamepad(1, options).status; } @@ -1862,7 +1976,7 @@ namespace lvh::detail::test { ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; UhidGamepad gamepad {fake_fd}; - return gamepad.submit({0}); + return gamepad.submit({}, {0}); } OperationStatus linux_uhid_submit_fake_short_write() { @@ -1872,7 +1986,7 @@ namespace lvh::detail::test { ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; UhidGamepad gamepad {fake_fd}; - return gamepad.submit({0}); + return gamepad.submit({}, {0}); } OperationStatus linux_uhid_close_fake_write_failure() { @@ -1948,6 +2062,10 @@ namespace lvh::detail::test { return create_fake_libevdev_device(device_type); } + LinuxLibevdevCreationResult linux_uinput_create_fake_gamepad(GamepadProfileKind kind) { + return create_fake_libevdev_gamepad(kind); + } + OperationStatus linux_uinput_create_fake_libevdev_allocation_failure(DeviceType device_type) { return create_fake_libevdev_device(device_type, [](LinuxTestSyscalls &syscalls) { syscalls.libevdev_new_returns_null = true; diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index aaa6df9..71bccca 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -420,7 +420,7 @@ namespace lvh::detail { if (created) { result.device_nodes = created.gamepad->device_nodes(); std::vector report(options.profile.input_report_size, 0x7A); - result.submit_status = created.gamepad->submit(report); + result.submit_status = created.gamepad->submit({}, report); const auto driver_id = last_created_driver_id(command_state); enqueue_output_report(event_state, driver_id, options.profile); @@ -441,7 +441,7 @@ namespace lvh::detail { enqueue_output_report(event_state, driver_id, options.profile); wait_for_output_events_to_drain(event_state); result.second_close_status = created.gamepad->close(); - result.submit_after_close_status = created.gamepad->submit(report); + result.submit_after_close_status = created.gamepad->submit({}, report); result.create_requests = command_state->create_request_count(); result.submit_requests = command_state->submit_report_count(); @@ -511,8 +511,10 @@ namespace lvh::detail { result.empty_nodes_create_status = created.status; if (created) { result.empty_device_nodes = created.gamepad->device_nodes(); - result.oversized_submit_status = - created.gamepad->submit(std::vector(LVH_WINDOWS_MAX_INPUT_REPORT_SIZE + 1U, 0x7B)); + result.oversized_submit_status = created.gamepad->submit( + {}, + std::vector(LVH_WINDOWS_MAX_INPUT_REPORT_SIZE + 1U, 0x7B) + ); } } diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp index 9f207ab..72d03d8 100644 --- a/tests/unit/test_gamepad_adapter.cpp +++ b/tests/unit/test_gamepad_adapter.cpp @@ -14,6 +14,8 @@ 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 dualshock4 = lvh::profiles::dualshock4(); const auto dualsense = lvh::profiles::dualsense(); const auto switch_pro = lvh::profiles::switch_pro(); @@ -25,6 +27,9 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_TRUE(generic_support.supports_misc1_button); EXPECT_FALSE(generic_support.supports_trigger_rumble); + EXPECT_FALSE(lvh::gamepad_profile_support(xbox_360).supports_misc1_button); + EXPECT_FALSE(lvh::gamepad_profile_support(xbox_one).supports_misc1_button); + const auto dualshock4_support = lvh::gamepad_profile_support(dualshock4); EXPECT_TRUE(dualshock4_support.supports_rumble); EXPECT_TRUE(dualshock4_support.supports_rgb_led); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index a5b2c92..e59d966 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -5,19 +5,19 @@ // standard includes #include +#include #include #include #include #include +#include #include #include // platform includes -#if defined(__linux__) - #include - #if defined(LIBVIRTUALHID_HAVE_XTEST) - #include - #endif +#include +#if defined(LIBVIRTUALHID_HAVE_XTEST) + #include #endif // lib includes @@ -25,17 +25,13 @@ // local includes #include "fixtures/fixtures.hpp" - -#if defined(__linux__) - #include "fixtures/linux_backend_test_hooks.hpp" -#endif +#include "fixtures/linux_backend_test_hooks.hpp" /** * @brief Test fixture for Linux backend internals. */ class LinuxBackendTest: public LinuxTest {}; -#if defined(__linux__) TEST_F(LinuxBackendTest, TranslatesKeyboardKeys) { EXPECT_EQ(lvh::detail::test::linux_key_code(0x08), KEY_BACKSPACE); EXPECT_EQ(lvh::detail::test::linux_key_code(0x09), KEY_TAB); @@ -111,6 +107,8 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) { EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::unknown), BUS_USB); EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::usb), BUS_USB); EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH); + EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::xbox_series), BUS_USB); + EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::switch_pro), BUS_VIRTUAL); EXPECT_EQ(lvh::detail::test::linux_uinput_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH); EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::pen), BTN_TOOL_PEN); @@ -308,6 +306,138 @@ TEST_F(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) { EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_pipe().code(), lvh::ErrorCode::backend_failure); } +TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) { + using enum lvh::GamepadButton; + using enum lvh::GamepadProfileKind; + + struct ButtonCase { + lvh::GamepadButton button; + std::uint16_t linux_code; + }; + + constexpr std::array button_cases { + ButtonCase {a, BTN_SOUTH}, + ButtonCase {b, BTN_EAST}, + ButtonCase {x, BTN_NORTH}, + ButtonCase {y, BTN_WEST}, + ButtonCase {left_shoulder, BTN_TL}, + ButtonCase {right_shoulder, BTN_TR}, + ButtonCase {back, BTN_SELECT}, + ButtonCase {start, BTN_START}, + ButtonCase {guide, BTN_MODE}, + ButtonCase {left_stick, BTN_THUMBL}, + ButtonCase {right_stick, BTN_THUMBR}, + }; + constexpr std::array profile_kinds {generic, xbox_360, xbox_one, xbox_series}; + + for (const auto kind : profile_kinds) { + for (const auto &[button, linux_code] : button_cases) { + lvh::GamepadState state; + state.buttons.set(button); + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + + std::vector pressed_codes; + for (const auto &event : result.events) { + if (event.type == EV_KEY && event.value == 1) { + pressed_codes.push_back(event.code); + } + } + ASSERT_EQ(pressed_codes.size(), 1U) + << "profile " << static_cast(std::to_underlying(kind)) << " logical button " + << static_cast(std::to_underlying(button)); + EXPECT_EQ(pressed_codes.front(), linux_code) + << "profile " << static_cast(std::to_underlying(kind)) << " logical button " + << static_cast(std::to_underlying(button)); + } + } + + for (const auto kind : {generic, xbox_series}) { + lvh::GamepadState state; + state.buttons.set(misc1); + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + const auto pressed = std::ranges::find_if(result.events, [](const auto &event) { + return event.type == EV_KEY && event.code == KEY_RECORD && event.value == 1; + }); + EXPECT_NE(pressed, result.events.end()); + } + + for (const auto kind : profile_kinds) { + lvh::GamepadState state; + state.buttons.set(dpad_up); + state.buttons.set(dpad_right); + state.left_stick = {.x = -0.5F, .y = 0.25F}; + state.right_stick = {.x = 0.75F, .y = -1.0F}; + state.left_trigger = 0.25F; + state.right_trigger = 0.75F; + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + + const auto event_value = [&result](std::uint16_t type, std::uint16_t code) -> std::optional { + const auto event = std::ranges::find_if(result.events, [type, code](const auto &candidate) { + return candidate.type == type && candidate.code == code; + }); + if (event == result.events.end()) { + return std::nullopt; + } + return event->value; + }; + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0X), 1); + EXPECT_EQ(event_value(EV_ABS, ABS_HAT0Y), -1); + EXPECT_EQ(event_value(EV_ABS, ABS_X), lvh::reports::normalize_axis(-0.5F)); + EXPECT_EQ(event_value(EV_ABS, ABS_Y), lvh::reports::normalize_axis(-0.25F)); + EXPECT_EQ(event_value(EV_ABS, ABS_RX), lvh::reports::normalize_axis(0.75F)); + EXPECT_EQ(event_value(EV_ABS, ABS_RY), lvh::reports::normalize_axis(1.0F)); + EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F)); + EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F)); + + if (kind == generic) { + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_UP), 1); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_DOWN), 0); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_LEFT), 0); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_RIGHT), 1); + } else { + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_UP), std::nullopt); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_DOWN), std::nullopt); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_LEFT), std::nullopt); + EXPECT_EQ(event_value(EV_KEY, BTN_DPAD_RIGHT), std::nullopt); + } + } + + constexpr std::array generic_dpad_cases { + std::pair {dpad_up, BTN_DPAD_UP}, + std::pair {dpad_down, BTN_DPAD_DOWN}, + std::pair {dpad_left, BTN_DPAD_LEFT}, + std::pair {dpad_right, BTN_DPAD_RIGHT}, + }; + for (const auto &[logical_button, linux_code] : generic_dpad_cases) { + lvh::GamepadState state; + state.buttons.set(logical_button); + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(generic, state); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + + std::vector pressed_codes; + for (const auto &event : result.events) { + if (event.type == EV_KEY && event.value == 1) { + pressed_codes.push_back(event.code); + } + } + ASSERT_EQ(pressed_codes.size(), 1U); + EXPECT_EQ(pressed_codes.front(), linux_code); + } +} + +TEST_F(LinuxBackendTest, XboxSeriesUinputNormalizesForceFeedback) { + const auto result = lvh::detail::test::linux_uinput_xbox_series_fake_rumble(); + ASSERT_TRUE(result.create_status.ok()) << result.create_status.message(); + EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); + ASSERT_GE(result.callback_count, 1U); + EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); +} + TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) { EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_create_invalid_fd().code(), lvh::ErrorCode::backend_failure); @@ -402,13 +532,13 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { ASSERT_TRUE(result.status.ok()) << result.status.message(); ASSERT_EQ(result.events.size(), 2U); EXPECT_EQ(result.events[0].type, EV_REL); - #if defined(REL_WHEEL_HI_RES) +#if defined(REL_WHEEL_HI_RES) EXPECT_EQ(result.events[0].code, REL_WHEEL_HI_RES); EXPECT_EQ(result.events[0].value, 120); - #else +#else EXPECT_EQ(result.events[0].code, REL_WHEEL); EXPECT_EQ(result.events[0].value, 1); - #endif +#endif EXPECT_EQ(result.events[1].type, EV_SYN); event = {}; @@ -418,13 +548,13 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { ASSERT_TRUE(result.status.ok()) << result.status.message(); ASSERT_EQ(result.events.size(), 2U); EXPECT_EQ(result.events[0].type, EV_REL); - #if defined(REL_HWHEEL_HI_RES) +#if defined(REL_HWHEEL_HI_RES) EXPECT_EQ(result.events[0].code, REL_HWHEEL_HI_RES); EXPECT_EQ(result.events[0].value, -120); - #else +#else EXPECT_EQ(result.events[0].code, REL_HWHEEL); EXPECT_EQ(result.events[0].value, -1); - #endif +#endif EXPECT_EQ(result.events[1].type, EV_SYN); } @@ -593,15 +723,15 @@ TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { EXPECT_FALSE(unavailable.supports_virtual_hid); EXPECT_FALSE(unavailable.supports_gamepad); EXPECT_FALSE(unavailable.supports_output_reports); - #if defined(LIBVIRTUALHID_HAVE_XTEST) +#if defined(LIBVIRTUALHID_HAVE_XTEST) EXPECT_TRUE(unavailable.supports_keyboard); EXPECT_TRUE(unavailable.supports_mouse); EXPECT_TRUE(unavailable.supports_xtest_fallback); - #else +#else EXPECT_FALSE(unavailable.supports_keyboard); EXPECT_FALSE(unavailable.supports_mouse); EXPECT_FALSE(unavailable.supports_xtest_fallback); - #endif +#endif EXPECT_EQ(lvh::detail::test::linux_backend_gamepad_fake_open_failure().code(), lvh::ErrorCode::backend_unavailable); EXPECT_EQ(lvh::detail::test::linux_backend_gamepad_fake_create_failure().code(), lvh::ErrorCode::backend_failure); @@ -693,6 +823,69 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc EXPECT_NE(find_code(keyboard, EV_KEY, KEY_A), nullptr); EXPECT_EQ(keyboard.destroy_count, 1U); + struct GamepadCase { + lvh::GamepadProfileKind kind; + std::uint16_t bustype; + std::uint16_t product_id; + bool misc1; + bool sparse_button_slots; + bool dpad_buttons; + }; + + constexpr std::array gamepad_cases { + GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, true}, + GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_USB, 0x028E, false, false, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false}, + }; + constexpr std::array active_buttons { + BTN_SOUTH, + BTN_EAST, + BTN_NORTH, + BTN_WEST, + BTN_TL, + BTN_TR, + BTN_SELECT, + BTN_START, + BTN_MODE, + BTN_THUMBL, + BTN_THUMBR, + }; + constexpr std::array reserved_buttons {BTN_C, BTN_Z, BTN_TL2, BTN_TR2}; + constexpr std::array dpad_buttons {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}; + + for (const auto &[kind, bustype, product_id, misc1, sparse_button_slots, has_dpad_buttons] : gamepad_cases) { + const auto expected_profile = lvh::profiles::gamepad_profile(kind); + ASSERT_TRUE(expected_profile.has_value()); + const auto gamepad = lvh::detail::test::linux_uinput_create_fake_gamepad(kind); + ASSERT_TRUE(gamepad.status.ok()) << gamepad.status.message(); + EXPECT_EQ(gamepad.name, expected_profile->name); + EXPECT_EQ(gamepad.vendor, expected_profile->vendor_id); + EXPECT_EQ(gamepad.bustype, bustype); + EXPECT_EQ(gamepad.product, product_id); + EXPECT_EQ(gamepad.version, expected_profile->version); + EXPECT_TRUE(has_type(gamepad, EV_KEY)); + EXPECT_TRUE(has_type(gamepad, EV_ABS)); + EXPECT_EQ(has_type(gamepad, EV_FF), expected_profile->capabilities.supports_rumble); + for (const auto button : active_buttons) { + EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing canonical gamepad button " << button; + } + for (const auto button : reserved_buttons) { + EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, sparse_button_slots) + << "unexpected reserved gamepad button slot state for " << button; + } + for (const auto button : dpad_buttons) { + EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, has_dpad_buttons) + << "unexpected directional gamepad button state for " << button; + } + EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, misc1); + const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); + ASSERT_NE(left_trigger, nullptr); + EXPECT_EQ(left_trigger->minimum, 0); + EXPECT_EQ(left_trigger->maximum, 255); + EXPECT_EQ(find_code(gamepad, EV_FF, FF_RUMBLE) != nullptr, expected_profile->capabilities.supports_rumble); + } + const auto mouse = lvh::detail::test::linux_uinput_create_fake_libevdev_device(lvh::DeviceType::mouse); ASSERT_TRUE(mouse.status.ok()) << mouse.status.message(); EXPECT_TRUE(has_type(mouse, EV_KEY)); @@ -761,11 +954,6 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc lvh::detail::test::linux_uinput_create_fake_libevdev_create_failure(lvh::DeviceType::pen_tablet).code(), lvh::ErrorCode::backend_failure ); - EXPECT_EQ( - lvh::detail::test::linux_uinput_create_fake_libevdev_device(lvh::DeviceType::gamepad).status.code(), - lvh::ErrorCode::unsupported_profile - ); - EXPECT_EQ( lvh::detail::test::linux_uinput_keyboard_submit_fake_write_failure().code(), lvh::ErrorCode::backend_failure @@ -828,7 +1016,7 @@ TEST_F(LinuxBackendTest, XTestFallbackCoversKeyboardAndMousePaths) { const auto mouse_closed_status = lvh::detail::test::linux_xtest_mouse_submit_closed(); EXPECT_TRUE(mouse_closed_status.code() == lvh::ErrorCode::device_closed || mouse_closed_status.code() == lvh::ErrorCode::backend_unavailable); - #if defined(LIBVIRTUALHID_HAVE_XTEST) +#if defined(LIBVIRTUALHID_HAVE_XTEST) EXPECT_EQ(lvh::detail::test::linux_xtest_keysym(0x08), XK_BackSpace); EXPECT_EQ(lvh::detail::test::linux_xtest_keysym(0x09), XK_Tab); EXPECT_EQ(lvh::detail::test::linux_xtest_keysym(0x0D), XK_Return); @@ -887,10 +1075,10 @@ TEST_F(LinuxBackendTest, XTestFallbackCoversKeyboardAndMousePaths) { EXPECT_EQ(lvh::detail::test::linux_xtest_mouse_button(lvh::MouseButton::side), 8); EXPECT_EQ(lvh::detail::test::linux_xtest_mouse_button(lvh::MouseButton::extra), 9); EXPECT_EQ(lvh::detail::test::linux_xtest_mouse_button(static_cast(255)), 1); - #else +#else EXPECT_EQ(lvh::detail::test::linux_xtest_keysym(0x41), 0UL); EXPECT_EQ(lvh::detail::test::linux_xtest_mouse_button(lvh::MouseButton::left), 1); - #endif +#endif } TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) { @@ -916,44 +1104,3 @@ TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNode EXPECT_EQ(mouse.status.code(), lvh::ErrorCode::backend_unavailable); } } -#else -TEST_F(LinuxBackendTest, TranslatesKeyboardKeys) {} - -TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {} - -TEST_F(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) {} - -TEST_F(LinuxBackendTest, DecodesTextHelpers) {} - -TEST_F(LinuxBackendTest, CoversLinuxDiscoveryAndIdentityHelpers) {} - -TEST_F(LinuxBackendTest, HandlesUhidInvalidFileDescriptorPaths) {} - -TEST_F(LinuxBackendTest, HandlesUinputKeyboardInvalidFileDescriptorPaths) {} - -TEST_F(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) {} - -TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) {} - -TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) {} - -TEST_F(LinuxBackendTest, PipeBackedUinputTouchDevicesEmitEvents) {} - -TEST_F(LinuxBackendTest, PipeBackedUinputTouchDevicesCoverStateTransitions) {} - -TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) {} - -TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {} - -TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) {} - -TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) {} - -TEST_F(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) {} - -TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranches) {} - -TEST_F(LinuxBackendTest, XTestFallbackCoversKeyboardAndMousePaths) {} - -TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) {} -#endif diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 7d684a4..242332e 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -5,6 +5,7 @@ // standard includes #include +#include #include #include #include @@ -26,19 +27,15 @@ #include // platform includes -#if defined(__linux__) - #include - #include - #include - #include -#endif +#include +#include +#include +#include // lib includes -#if defined(__linux__) - #include - #include -#endif +#include #include +#include // local includes #include "fixtures/fixtures.hpp" @@ -50,11 +47,9 @@ class LinuxConsumerTest: public LinuxTest {}; namespace { -#if defined(__linux__) using LibinputContext = std::unique_ptr; using LibinputEvent = std::unique_ptr; using SdlGameController = std::unique_ptr; - using SdlJoystick = std::unique_ptr; /** * @brief SDL-visible gamepad case. @@ -63,6 +58,7 @@ namespace { lvh::DeviceProfile profile; std::string_view name_suffix; std::string_view stable_id; + std::optional expected_product_id; int minimum_buttons = 1; int minimum_axes = 2; bool expect_live_input = true; @@ -244,22 +240,6 @@ namespace { return false; } - bool wait_for_sdl_gamepad_input(SDL_Joystick *joystick) { - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; - - while (std::chrono::steady_clock::now() < deadline) { - pump_sdl_events(); - - if (sdl_joystick_has_pressed_button(joystick) && sdl_joystick_has_moved_axis(joystick)) { - return true; - } - - std::this_thread::sleep_for(std::chrono::milliseconds {50}); - } - - return false; - } - bool sdl_controller_has_pressed_button(SDL_GameController *controller) { for (int button = SDL_CONTROLLER_BUTTON_A; button < SDL_CONTROLLER_BUTTON_MAX; ++button) { if (SDL_GameControllerGetButton(controller, static_cast(button)) != 0) { @@ -323,6 +303,22 @@ namespace { return false; } + bool wait_for_sdl_controller_button( + SDL_GameController *controller, + SDL_GameControllerButton expected_button + ) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + while (std::chrono::steady_clock::now() < deadline) { + SDL_GameControllerUpdate(); + pump_sdl_events(); + if (SDL_GameControllerGetButton(controller, expected_button) != 0) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds {20}); + } + return false; + } + void configure_sdl_hidapi_hints() { SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); @@ -355,6 +351,9 @@ namespace { const auto expected_profile = [&test_case]() { auto profile = test_case.profile; profile.name = unique_device_name(test_case.name_suffix); + if (test_case.expected_product_id) { + profile.product_id = *test_case.expected_product_id; + } return profile; }(); @@ -423,36 +422,93 @@ namespace { } } - void run_sdl_uhid_joystick_test(const SdlGamepadConsumerCase &test_case) { + void run_sdl_playstation_controller_test(const SdlGamepadConsumerCase &test_case) { run_sdl_gamepad_test( test_case, - SDL_INIT_JOYSTICK | SDL_INIT_EVENTS, + SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS, [&test_case](const auto &expected_profile, int joystick_index, lvh::Gamepad &gamepad) { - SdlJoystick joystick {SDL_JoystickOpen(joystick_index), &SDL_JoystickClose}; - ASSERT_NE(joystick.get(), nullptr) << SDL_GetError(); - expect_sdl_joystick_profile( - joystick.get(), - expected_profile, - test_case.minimum_buttons, - test_case.minimum_axes - ); - - lvh::GamepadState state; - state.buttons.set(lvh::GamepadButton::a); - state.left_stick = {0.75F, -0.5F}; - ASSERT_TRUE(gamepad.submit(state).ok()); - - EXPECT_TRUE(wait_for_sdl_gamepad_input(joystick.get())) << describe_sdl_state(joystick.get()); + exercise_sdl_playstation_controller(test_case, expected_profile, joystick_index, gamepad); } ); } - void run_sdl_playstation_controller_test(const SdlGamepadConsumerCase &test_case) { + void exercise_sdl_canonical_gamepad_controller( + const SdlGamepadConsumerCase &test_case, + const lvh::DeviceProfile &expected_profile, + int joystick_index, + lvh::Gamepad &gamepad + ) { + using enum lvh::GamepadButton; + + ASSERT_EQ(SDL_IsGameController(joystick_index), SDL_TRUE) << SDL_GetError(); + SdlGameController controller {SDL_GameControllerOpen(joystick_index), &SDL_GameControllerClose}; + ASSERT_NE(controller.get(), nullptr) << SDL_GetError(); + + auto *joystick = SDL_GameControllerGetJoystick(controller.get()); + ASSERT_NE(joystick, nullptr) << SDL_GetError(); + expect_sdl_joystick_profile(joystick, expected_profile, test_case.minimum_buttons, test_case.minimum_axes); + + struct ButtonCase { + lvh::GamepadButton logical_button; + SDL_GameControllerButton sdl_button; + }; + + constexpr std::array button_cases { + ButtonCase {a, SDL_CONTROLLER_BUTTON_A}, + ButtonCase {b, SDL_CONTROLLER_BUTTON_B}, + ButtonCase {x, SDL_CONTROLLER_BUTTON_X}, + ButtonCase {y, SDL_CONTROLLER_BUTTON_Y}, + ButtonCase {left_shoulder, SDL_CONTROLLER_BUTTON_LEFTSHOULDER}, + ButtonCase {right_shoulder, SDL_CONTROLLER_BUTTON_RIGHTSHOULDER}, + ButtonCase {back, SDL_CONTROLLER_BUTTON_BACK}, + ButtonCase {start, SDL_CONTROLLER_BUTTON_START}, + ButtonCase {guide, SDL_CONTROLLER_BUTTON_GUIDE}, + ButtonCase {left_stick, SDL_CONTROLLER_BUTTON_LEFTSTICK}, + ButtonCase {right_stick, SDL_CONTROLLER_BUTTON_RIGHTSTICK}, + ButtonCase {dpad_up, SDL_CONTROLLER_BUTTON_DPAD_UP}, + ButtonCase {dpad_down, SDL_CONTROLLER_BUTTON_DPAD_DOWN}, + ButtonCase {dpad_left, SDL_CONTROLLER_BUTTON_DPAD_LEFT}, + ButtonCase {dpad_right, SDL_CONTROLLER_BUTTON_DPAD_RIGHT}, + }; + + for (const auto &[logical_button, sdl_button] : button_cases) { + lvh::GamepadState state; + state.buttons.set(logical_button); + ASSERT_TRUE(gamepad.submit(state).ok()); + ASSERT_TRUE(wait_for_sdl_controller_button(controller.get(), sdl_button)) + << "logical button " << static_cast(std::to_underlying(logical_button)) << " " + << describe_sdl_controller_state(controller.get()); + for (const auto &[other_logical_button, other_sdl_button] : button_cases) { + if (other_logical_button != logical_button) { + EXPECT_EQ(SDL_GameControllerGetButton(controller.get(), other_sdl_button), 0) + << "logical button " << static_cast(std::to_underlying(logical_button)); + } + } + } + + lvh::GamepadState trigger_state; + trigger_state.left_trigger = 0.25F; + trigger_state.right_trigger = 0.75F; + ASSERT_TRUE(gamepad.submit(trigger_state).ok()); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + while ( + std::chrono::steady_clock::now() < deadline && + SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT) < 16000 + ) { + SDL_GameControllerUpdate(); + pump_sdl_events(); + std::this_thread::sleep_for(std::chrono::milliseconds {20}); + } + EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERLEFT), 0); + EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT), 16000); + } + + void run_sdl_canonical_gamepad_test(const SdlGamepadConsumerCase &test_case) { run_sdl_gamepad_test( test_case, SDL_INIT_GAMECONTROLLER | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS, [&test_case](const auto &expected_profile, int joystick_index, lvh::Gamepad &gamepad) { - exercise_sdl_playstation_controller(test_case, expected_profile, joystick_index, gamepad); + exercise_sdl_canonical_gamepad_controller(test_case, expected_profile, joystick_index, gamepad); } ); } @@ -472,7 +528,7 @@ namespace { int open_restricted(const char *path, int flags, void *user_data) { // NOSONAR(cpp:S5008): libinput_interface is a C callback ABI with void* user data. static_cast(user_data); - const auto fd = ::open(path, flags); + const auto fd = ::openat(AT_FDCWD, path, flags); return fd < 0 ? -errno : fd; } @@ -526,21 +582,59 @@ namespace { return LibinputEvent {nullptr, destroy_libinput_event}; } -#endif // defined(__linux__) - } // namespace -#if defined(__linux__) -TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) { - ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); +TEST_F(LinuxConsumerTest, SdlSeesGenericCanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); - run_sdl_uhid_joystick_test({ + run_sdl_canonical_gamepad_test({ .profile = lvh::profiles::generic_gamepad(), - .name_suffix = "SDL Gamepad", + .name_suffix = "SDL Generic Gamepad", .stable_id = "libvirtualhid-sdl-gamepad-test", + .minimum_buttons = 20, + .minimum_axes = 6, + }); +} + +TEST_F(LinuxConsumerTest, SdlSeesXbox360CanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + run_sdl_canonical_gamepad_test({ + .profile = lvh::profiles::xbox_360(), + .name_suffix = "SDL Xbox 360", + .stable_id = "libvirtualhid-sdl-xbox-360-test", + .minimum_buttons = 11, + .minimum_axes = 6, + }); +} + +TEST_F(LinuxConsumerTest, SdlSeesXboxOneCanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + run_sdl_canonical_gamepad_test({ + .profile = lvh::profiles::xbox_one(), + .name_suffix = "SDL Xbox One", + .stable_id = "libvirtualhid-sdl-xbox-one-test", + .expected_product_id = 0x0B20, + .minimum_buttons = 15, + .minimum_axes = 6, }); } +TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + const SdlGamepadConsumerCase test_case { + .profile = lvh::profiles::xbox_series(), + .name_suffix = "SDL Xbox Series", + .stable_id = "libvirtualhid-sdl-xbox-series-test", + .expected_product_id = 0x0B13, + .minimum_buttons = 16, + .minimum_axes = 6, + }; + run_sdl_canonical_gamepad_test(test_case); +} + TEST_F(LinuxConsumerTest, SdlSeesDualSenseUsbControllerBehavior) { ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); @@ -816,24 +910,3 @@ TEST_F(LinuxConsumerTest, LibinputSeesUinputPenTabletTool) { ASSERT_NE(event.get(), nullptr); ASSERT_NE(libinput_event_get_tablet_tool_event(event.get()), nullptr); } -#else -TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) {} - -TEST_F(LinuxConsumerTest, SdlSeesDualSenseUsbControllerBehavior) {} - -TEST_F(LinuxConsumerTest, SdlSeesDualShock4UsbControllerBehavior) {} - -TEST_F(LinuxConsumerTest, SdlSeesDualShock4BluetoothControllerDiscovery) {} - -TEST_F(LinuxConsumerTest, SdlSeesDualSenseBluetoothControllerDiscovery) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputKeyboardKeys) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputMouseMotionAndButtons) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputTouchscreenContacts) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputTrackpadButton) {} - -TEST_F(LinuxConsumerTest, LibinputSeesUinputPenTabletTool) {} -#endif diff --git a/tests/unit/test_macos_backend.cpp b/tests/unit/test_macos_backend.cpp index 866aa0a..452956b 100644 --- a/tests/unit/test_macos_backend.cpp +++ b/tests/unit/test_macos_backend.cpp @@ -8,20 +8,16 @@ // local includes #include "fixtures/fixtures.hpp" - -#if defined(__APPLE__) && defined(__MACH__) - #include "fixtures/macos_backend_test_hooks.hpp" +#include "fixtures/macos_backend_test_hooks.hpp" // platform includes - #include -#endif +#include /** * @brief Test fixture for macOS backend internals. */ class MacosBackendTest: public MacOSTest {}; -#if defined(__APPLE__) && defined(__MACH__) TEST_F(MacosBackendTest, TranslatesKeyboardKeys) { EXPECT_EQ(lvh::detail::test::macos_backend_key_code(0x08), kVK_Delete); EXPECT_EQ(lvh::detail::test::macos_backend_key_code(0x09), kVK_Tab); @@ -138,4 +134,3 @@ TEST_F(MacosBackendTest, ReportsCapabilitiesAndUnsupportedDevices) { EXPECT_EQ(result.trackpad_status.code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(result.pen_tablet_status.code(), lvh::ErrorCode::unsupported_profile); } -#endif diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 8baa564..8b620da 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -49,17 +49,37 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(xbox_series.report_id, 0); EXPECT_EQ(xbox_series.input_report_size, 17U); - const std::array xbox_gip_button_descriptor { + const std::array xbox_gip_button_descriptor { 0x05, 0x09, - 0x19, + 0x09, + 0x01, + 0x09, + 0x02, + 0x09, + 0x04, + 0x09, + 0x05, + 0x09, + 0x07, + 0x09, + 0x08, + 0x09, + 0x0B, + 0x09, + 0x0C, + 0x09, + 0x0E, + 0x09, + 0x0F, + 0x15, + 0x00, + 0x25, 0x01, - 0x29, - 0x0A, - 0x95, - 0x0A, 0x75, 0x01, + 0x95, + 0x0A, 0x81, 0x02, }; @@ -182,19 +202,43 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_TRUE(switch_pro.capabilities.supports_battery); const auto generic = lvh::profiles::generic_gamepad(); - const std::array standard_button_descriptor { + const std::array standard_button_descriptor { 0x05, 0x09, - 0x19, + 0x09, 0x01, - 0x29, - 0x10, + 0x09, + 0x02, + 0x09, + 0x04, + 0x09, + 0x05, + 0x09, + 0x07, + 0x09, + 0x08, + 0x09, + 0x0B, + 0x09, + 0x0C, + 0x09, + 0x0E, + 0x09, + 0x0F, + 0x09, + 0x0D, + 0x09, + 0x06, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, + 0x95, + 0x0C, + 0x81, + 0x02, }; EXPECT_TRUE( std::ranges::search(generic.report_descriptor, standard_button_descriptor).begin() != generic.report_descriptor.end() @@ -225,11 +269,29 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { switch_pro.report_descriptor.end() ); - const std::array switch_pro_button_descriptor { - 0x19, + const std::array switch_pro_button_descriptor { + 0x05, + 0x09, + 0x09, + 0x02, + 0x09, 0x01, - 0x29, + 0x09, + 0x04, + 0x09, + 0x05, + 0x09, + 0x07, + 0x09, + 0x08, + 0x09, + 0x09, + 0x09, 0x0A, + 0x09, + 0x0B, + 0x09, + 0x0C, 0x15, 0x00, 0x25, @@ -238,11 +300,44 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { 0x01, 0x95, 0x0A, + 0x55, + 0x00, + 0x65, + 0x00, + 0x81, + 0x02, }; EXPECT_TRUE( std::ranges::search(switch_pro.report_descriptor, switch_pro_button_descriptor).begin() != switch_pro.report_descriptor.end() ); + + const std::array switch_pro_system_button_descriptor { + 0x05, + 0x09, + 0x09, + 0x0E, + 0x09, + 0x0F, + 0x09, + 0x0D, + 0x09, + 0x06, + 0x15, + 0x00, + 0x25, + 0x01, + 0x75, + 0x01, + 0x95, + 0x04, + 0x81, + 0x02, + }; + EXPECT_TRUE( + std::ranges::search(switch_pro.report_descriptor, switch_pro_system_button_descriptor).begin() != + switch_pro.report_descriptor.end() + ); } TEST(ProfileTest, RumbleProfilesExposeOutputReports) { diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index f4f7a06..a93d749 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -124,7 +124,7 @@ TEST(ReportTest, PacksStandardGamepadReport) { ASSERT_EQ(report.size(), profile.input_report_size); EXPECT_EQ(report[0], profile.report_id); EXPECT_EQ(report[1], 0x81); // A and Start. - EXPECT_EQ(report[2], 0x4C); // Guide, Misc/share, and D-pad-left button. + EXPECT_EQ(report[2], 0x6C); // Guide, Misc/share, and D-pad-left hat value. EXPECT_EQ(report[3], 255); // Left stick X. EXPECT_EQ(report[4], 255); // Left stick Y. EXPECT_EQ(report[5], 191); // Right stick X.