diff --git a/README.md b/README.md index b08354c..f260a3b 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 PlayStation gamepads through Linux `uhid`; Generic, Xbox, + and Switch Pro 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..6c6e10e 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -46,15 +46,52 @@ 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, Xbox Series, and Switch Pro + 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, Xbox-family, and Switch Pro profiles instead use `uinput` so SDL, +Steam, browser Gamepad API implementations, and other evdev consumers receive +canonical Linux gamepad events. Face buttons, shoulders, menu buttons, stick +clicks, and Guide use their native evdev codes; sticks use absolute axes. Every +uinput gamepad exposes its directional pad only through `ABS_HAT0X` and +`ABS_HAT0Y`, giving consumers one unambiguous D-pad representation. Generic and +Xbox triggers remain independent analog `ABS_Z` and `ABS_RZ` axes. Switch Pro +uses the Nintendo face-button positions, button events for ZL/ZR, and `BTN_Z` +for Capture. Profiles with rumble support normalize rumble, constant, periodic, +and ramp uinput force-feedback effects back into the public callback. The Linux +backend lets a new uinput device settle before reading those effects so an early +poll error cannot disable feedback for the device lifetime. PlayStation rumble +is read from native UHID interrupt-channel output reports. + +Xbox 360 retains its `0x045E:0x028E` identity, while its Linux uinput device uses +the Bluetooth bus so consumers select the sparse button mapping. +Xbox One and Xbox Series retain their public USB identities, but their Linux +uinput devices use the corresponding Bluetooth product identities (`0x0B20` +and `0x0B13`, respectively), whose standard consumer mappings match the events +that uinput exposes. Those four sparse-layout profiles preserve the 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. D-pad directions are +reported through the hat axes and exposed as logical buttons by standard +gamepad consumers. + +DualShock 4 and DualSense remain on `uhid` so their descriptors, motion, +touchpad, battery, feature reports, and profile-specific output reports stay +available. The backend accepts PlayStation output through both UHID interrupt +and control channels. Numbered control-channel output is normalized before +parsing, whether the kernel includes the report number in the payload or +provides it separately on the UHID event. + +Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows +the evdev layout used by Linux-native virtual-controller implementations and +allows standard `FF_RUMBLE` effects without emulating the physical controller's +proprietary initialization handshake. 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 +124,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/streaming-host-integration.md b/docs/streaming-host-integration.md index fc86aa8..d0f8c65 100644 --- a/docs/streaming-host-integration.md +++ b/docs/streaming-host-integration.md @@ -51,7 +51,8 @@ The core API and adapter shape cover the major streaming-host requirements: - Rich controller metadata. - Gamepad output callbacks. - Keyboard and mouse input paths. -- Linux `uhid` gamepads and `uinput` keyboard/pointer devices. +- Linux PlayStation gamepads through `uhid`, Generic/Xbox/Switch Pro gamepads + through `uinput`, and `uinput` keyboard/pointer devices. - Linux DualSense and DualShock 4 USB/Bluetooth report handling. - Linux touchscreen, trackpad, and pen tablet device types. - Windows UMDF/VHF gamepad creation through an installed driver package. diff --git a/docs/usage.md b/docs/usage.md index f956279..939c6ed 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -164,17 +164,31 @@ touch, motion, battery, feedback, and lifecycle updates onto the platform-neutra ## Built-In Profiles -Built-in gamepad profiles include: - -- Generic HID gamepad. -- Xbox 360. -- Xbox One. -- Xbox Series. -- DualShock 4 USB and Bluetooth. -- DualSense USB and Bluetooth. -- Nintendo Switch Pro. +Built-in gamepad profiles and their platform-neutral default device names are: + +| Profile | Default device name | +| --- | --- | +| Generic HID gamepad | `(libvirtualhid) Generic Controller` | +| Xbox 360 | `(libvirtualhid) X-Box 360 Controller` | +| Xbox One | `(libvirtualhid) X-Box One Controller` | +| Xbox Series | `(libvirtualhid) X-Box Series Controller` | +| DualShock 4 USB and Bluetooth | `(libvirtualhid) PS4 Controller` | +| DualSense USB and Bluetooth | `(libvirtualhid) PS5 Controller` | +| Nintendo Switch Pro | `(libvirtualhid) Nintendo Pro Controller` | + +Consumers may replace `DeviceProfile::name` before creating a gamepad, for +example to prepend an application name while preserving the default controller +identity across platform backends. + +The platform-neutral Generic HID descriptor reports the D-pad as buttons 13 +through 16 in the input report. Linux may still route that profile through +`uinput`, where the backend exposes those same logical directions through the +standard `ABS_HAT0X` and `ABS_HAT0Y` axes expected by evdev consumers. 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..0f112ab 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -86,18 +86,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 +176,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 +195,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( @@ -1850,7 +1909,7 @@ namespace lvh::profiles { bus_type == BusType::bluetooth ? dualshock4_bluetooth_input_report_size : dualshock4_usb_input_report_size; profile.output_report_size = bus_type == BusType::bluetooth ? dualshock4_bluetooth_output_report_size : dualshock4_usb_output_report_size; - profile.name = "Wireless Controller"; + profile.name = "(libvirtualhid) PS4 Controller"; profile.manufacturer = "Sony Computer Entertainment"; profile.capabilities = { .supports_rumble = true, @@ -1877,7 +1936,7 @@ namespace lvh::profiles { bus_type == BusType::bluetooth ? dualsense_bluetooth_input_report_size : dualsense_usb_input_report_size; profile.output_report_size = bus_type == BusType::bluetooth ? dualsense_bluetooth_output_report_size : dualsense_usb_output_report_size; - profile.name = "Wireless Controller"; + profile.name = "(libvirtualhid) PS5 Controller"; profile.manufacturer = "Sony Interactive Entertainment"; profile.capabilities = { .supports_rumble = true, @@ -1903,9 +1962,9 @@ namespace lvh::profiles { profile.report_id = switch_pro_report_id; profile.input_report_size = switch_pro_input_report_size; profile.output_report_size = switch_pro_output_report_size; - profile.name = "Pro Controller"; + profile.name = "(libvirtualhid) Nintendo Pro Controller"; profile.manufacturer = "Nintendo Co., Ltd."; - profile.capabilities = {.supports_motion = true, .supports_battery = true}; + profile.capabilities = {.supports_rumble = true, .supports_motion = true, .supports_battery = true}; profile.report_descriptor = make_switch_pro_report_descriptor(); return profile; } @@ -1927,19 +1986,19 @@ namespace lvh::profiles { DeviceProfile generic_gamepad() { return make_standard_gamepad_profile( GamepadProfileKind::generic, - "libvirtualhid Generic Gamepad", + "(libvirtualhid) Generic Controller", "LizardByte", 0x1209, 0x0001, 0x0001, - {} + {.supports_rumble = true} ); } DeviceProfile xbox_360() { return make_gamepad_profile( GamepadProfileKind::xbox_360, - "Microsoft X-Box 360 pad", + "(libvirtualhid) X-Box 360 Controller", "Microsoft", 0x045E, 0x028E, @@ -1951,7 +2010,7 @@ namespace lvh::profiles { DeviceProfile xbox_one() { return make_xbox_gip_profile( GamepadProfileKind::xbox_one, - "Xbox One Controller", + "(libvirtualhid) X-Box One Controller", 0x02EA, 0x0408, false @@ -1961,7 +2020,7 @@ namespace lvh::profiles { DeviceProfile xbox_series() { return make_xbox_gip_profile( GamepadProfileKind::xbox_series, - "Xbox Controller", + "(libvirtualhid) X-Box Series Controller", 0x0B12, 0x0500, true @@ -1990,7 +2049,7 @@ namespace lvh::profiles { DeviceProfile dualsense_bluetooth() { auto profile = make_dualsense_profile(BusType::bluetooth); - profile.name = "Wireless Controller"; + profile.name = "(libvirtualhid) PS5 Controller"; return profile; } diff --git a/src/core/report.cpp b/src/core/report.cpp index b4d546e..9acc9eb 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -953,7 +953,8 @@ namespace lvh::reports { } if ( - profile.capabilities.supports_rumble && profile.output_report_size >= 5U && + profile.gamepad_kind != GamepadProfileKind::switch_pro && profile.capabilities.supports_rumble && + profile.output_report_size >= 5U && report.size() >= profile.output_report_size && report[0] == profile.report_id ) { GamepadOutput output; 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..ab04ce6 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,13 @@ namespace lvh::detail { constexpr auto tablet_distance_max = 1024; constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; + constexpr auto uinput_feedback_startup_delay = std::chrono::milliseconds {100}; + constexpr auto xbox_trigger_max = 255; + // The Bluetooth bus selects the sparse evdev mapping that matches the + // button capabilities exposed by these Xbox uinput devices. + constexpr auto xbox_sparse_uinput_bus = BUS_BLUETOOTH; + constexpr std::uint16_t xbox_wireless_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 +257,7 @@ namespace lvh::detail { 0x00, 0x00, 0x00, + 0x00, 0x10, 0x27, 0xF0, @@ -490,6 +499,49 @@ 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: + case switch_pro: + return true; + case dualshock4: + case dualsense: + return false; + } + + return false; + } + + std::optional uinput_misc1_button(GamepadProfileKind kind) { + switch (kind) { + using enum GamepadProfileKind; + + case generic: + case xbox_series: + return KEY_RECORD; + case switch_pro: + return BTN_Z; + case xbox_360: + case xbox_one: + case dualshock4: + case dualsense: + return std::nullopt; + } + + return std::nullopt; + } + + bool uses_sparse_uinput_button_slots(GamepadProfileKind kind) { + using enum GamepadProfileKind; + + return kind == generic || kind == xbox_360 || kind == xbox_one || kind == xbox_series; + } + std::uint16_t to_uhid_bus(BusType bus_type) { if (bus_type == BusType::bluetooth) { return BUS_BLUETOOTH; @@ -497,6 +549,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); } @@ -537,6 +596,16 @@ namespace lvh::detail { nodes.push_back({.kind = kind, .path = path.string()}); } + void append_node_if_missing(std::vector &nodes, DeviceNodeKind kind, const std::filesystem::path &path) { + const auto path_string = path.string(); + const auto existing = std::ranges::find_if(nodes, [kind, &path_string](const DeviceNode &node) { + return node.kind == kind && node.path == path_string; + }); + if (existing == nodes.end()) { + nodes.push_back({.kind = kind, .path = path_string}); + } + } + bool hidraw_name_matches(const std::filesystem::path &uevent_path, std::string_view name) { std::ifstream file {uevent_path}; if (!file) { @@ -554,6 +623,67 @@ namespace lvh::detail { return false; } + bool hidraw_metadata_matches( + const std::filesystem::path &uevent_path, + std::string_view name, + std::string_view physical_id, + std::string_view unique_id + ) { + std::ifstream file {uevent_path}; + if (!file) { + return false; + } + + std::string line; + while (std::getline(file, line)) { + constexpr std::string_view name_key {"HID_NAME="}; + if (!name.empty() && line.starts_with(name_key) && line.size() == name_key.size() + name.size() && line.ends_with(name)) { + return true; + } + + constexpr std::string_view phys_key {"HID_PHYS="}; + if ( + !physical_id.empty() && line.starts_with(phys_key) && + line.size() == phys_key.size() + physical_id.size() && line.ends_with(physical_id) + ) { + return true; + } + + constexpr std::string_view uniq_key {"HID_UNIQ="}; + if (!unique_id.empty() && line.starts_with(uniq_key) && line.size() == uniq_key.size() + unique_id.size() && line.ends_with(unique_id)) { + return true; + } + } + + return false; + } + + std::vector discover_hidraw_nodes_by_metadata( + std::string_view name, + std::string_view physical_id, + std::string_view unique_id, + const std::filesystem::path &hidraw_root = "/sys/class/hidraw" + ) { + using enum DeviceNodeKind; + + std::vector nodes; + std::error_code error; + if (!std::filesystem::exists(hidraw_root, error)) { + return nodes; + } + + for (std::filesystem::directory_iterator it {hidraw_root, error}, end; !error && it != end; it.increment(error)) { + if (!hidraw_metadata_matches(it->path() / "device" / "uevent", name, physical_id, unique_id)) { + continue; + } + + append_node(nodes, hidraw, std::filesystem::path {"/dev"} / it->path().filename()); + append_node(nodes, sysfs, it->path()); + } + + return nodes; + } + std::vector discover_input_nodes_by_name( const std::string &name, const std::filesystem::path &input_root, @@ -1273,8 +1403,121 @@ 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_event_types(libevdev *device, bool supports_rumble) { + if (const auto status = enable_evdev_type(device, EV_KEY, "gamepad button events"); !status.ok()) { + return status; + } + if (const auto status = enable_evdev_type(device, EV_ABS, "gamepad absolute events"); !status.ok()) { + return status; + } + if (!supports_rumble) { + return OperationStatus::success(); + } + return enable_evdev_type(device, EV_FF, "gamepad force-feedback events"); + } + + OperationStatus configure_evdev_gamepad_buttons(libevdev *device, GamepadProfileKind profile_kind) { + 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, "gamepad button"); !status.ok()) { + return status; + } + } + + if (uses_sparse_uinput_button_slots(profile_kind)) { + // Linux gamepad consumers use the sparse button 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 (profile_kind == GamepadProfileKind::switch_pro) { + for (const auto button : {BTN_TL2, BTN_TR2}) { + if (const auto status = enable_evdev_code(device, EV_KEY, button, "gamepad trigger button"); !status.ok()) { + return status; + } + } + } + if (const auto misc1_button = uinput_misc1_button(profile_kind); misc1_button.has_value()) { + if (const auto status = enable_evdev_code(device, EV_KEY, *misc1_button, "gamepad auxiliary button"); !status.ok()) { + return status; + } + } + return OperationStatus::success(); + } + + OperationStatus configure_evdev_gamepad_axes(libevdev *device, GamepadProfileKind profile_kind) { + 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, "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, "gamepad stick axis", &stick); !status.ok()) { + return status; + } + } + + if (profile_kind == GamepadProfileKind::switch_pro) { + return OperationStatus::success(); + } + + 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, "gamepad trigger axis", &trigger); !status.ok()) { + return status; + } + } + return OperationStatus::success(); + } + + OperationStatus configure_evdev_gamepad_force_feedback(libevdev *device, bool supports_rumble) { + if (!supports_rumble) { + return OperationStatus::success(); + } + constexpr std::array effect_codes {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_SINE, FF_RAMP}; + for (const auto code : effect_codes) { + if (const auto status = enable_evdev_code(device, EV_FF, code, "gamepad force-feedback effect"); !status.ok()) { + return status; + } + } + return enable_evdev_code(device, EV_FF, FF_GAIN, "gamepad force-feedback gain"); + } + + OperationStatus configure_evdev_gamepad(libevdev *device, const DeviceProfile &profile) { + const auto supports_rumble = profile.capabilities.supports_rumble; + if (const auto status = configure_evdev_gamepad_event_types(device, supports_rumble); !status.ok()) { + return status; + } + if (const auto status = configure_evdev_gamepad_buttons(device, profile.gamepad_kind); !status.ok()) { + return status; + } + if (const auto status = configure_evdev_gamepad_axes(device, profile.gamepad_kind); !status.ok()) { + return status; + } + return configure_evdev_gamepad_force_feedback(device, supports_rumble); + } + + OperationStatus configure_evdev_device(libevdev *device, const DeviceProfile &profile) { + switch (profile.device_type) { using enum DeviceType; case keyboard: @@ -1288,7 +1531,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 +1551,25 @@ 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_360 = profile.gamepad_kind == GamepadProfileKind::xbox_360; + const auto xbox_one = profile.gamepad_kind == GamepadProfileKind::xbox_one; + const auto xbox_series = profile.gamepad_kind == GamepadProfileKind::xbox_series; + const auto uses_sparse_xbox_mapping = xbox_360 || xbox_one || xbox_series; + auto product_id = profile.product_id; + if (xbox_one) { + product_id = xbox_wireless_uinput_product_id; + } else if (xbox_series) { + product_id = xbox_series_uinput_product_id; + } + libevdev_set_id_bustype( + device.get(), + uses_sparse_xbox_mapping ? xbox_sparse_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 +2491,449 @@ namespace lvh::detail { } #endif + struct UinputRumbleEffect { + std::uint16_t start_weak_magnitude = 0; + std::uint16_t start_strong_magnitude = 0; + std::uint16_t end_weak_magnitude = 0; + std::uint16_t end_strong_magnitude = 0; + ff_envelope envelope {}; + 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 { + 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_}; + if (const auto status = emit_button_state(normalized.buttons); !status.ok()) { + return status; + } + if (const auto status = emit_axis_state(normalized); !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: + template + OperationStatus emit_button_map( + const ButtonSet &buttons, + const std::array, Size> &button_map + ) { + for (const auto &[button, code] : button_map) { + if (const auto status = emit_event(EV_KEY, code, buttons.test(button) ? 1 : 0); !status.ok()) { + return status; + } + } + return OperationStatus::success(); + } + + OperationStatus emit_button_state(const ButtonSet &buttons) { + using enum GamepadButton; + + constexpr std::array standard_face_button_map { + std::pair {a, BTN_SOUTH}, + std::pair {b, BTN_EAST}, + std::pair {x, BTN_NORTH}, + std::pair {y, BTN_WEST}, + }; + constexpr std::array switch_face_button_map { + std::pair {a, BTN_EAST}, + std::pair {b, BTN_SOUTH}, + std::pair {x, BTN_NORTH}, + std::pair {y, BTN_WEST}, + }; + constexpr std::array common_button_map { + 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}, + }; + + const auto &face_button_map = + profile_kind_ == GamepadProfileKind::switch_pro ? switch_face_button_map : standard_face_button_map; + if (const auto status = emit_button_map(buttons, face_button_map); !status.ok()) { + return status; + } + if (const auto status = emit_button_map(buttons, common_button_map); !status.ok()) { + return status; + } + if (const auto misc1_button = uinput_misc1_button(profile_kind_); misc1_button.has_value()) { + if (const auto status = emit_event(EV_KEY, *misc1_button, buttons.test(misc1) ? 1 : 0); !status.ok()) { + return status; + } + } + return OperationStatus::success(); + } + + OperationStatus emit_axis_state(const GamepadState &state) { + using enum GamepadButton; + + if (const auto status = emit_event(EV_ABS, ABS_HAT0X, dpad_axis(state.buttons, dpad_left, dpad_right)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_HAT0Y, dpad_axis(state.buttons, dpad_up, dpad_down)); !status.ok()) { + return status; + } + + const std::array stick_axes { + std::pair {ABS_X, static_cast(reports::normalize_axis(state.left_stick.x))}, + std::pair {ABS_Y, static_cast(reports::normalize_axis(-state.left_stick.y))}, + std::pair {ABS_RX, static_cast(reports::normalize_axis(state.right_stick.x))}, + std::pair {ABS_RY, static_cast(reports::normalize_axis(-state.right_stick.y))}, + }; + for (const auto &[code, value] : stick_axes) { + if (const auto status = emit_event(EV_ABS, code, value); !status.ok()) { + return status; + } + } + + if (profile_kind_ == GamepadProfileKind::switch_pro) { + if (const auto status = emit_event(EV_KEY, BTN_TL2, state.left_trigger > 0.0F ? 1 : 0); !status.ok()) { + return status; + } + return emit_event(EV_KEY, BTN_TR2, state.right_trigger > 0.0F ? 1 : 0); + } + + const std::array trigger_axes { + std::pair {ABS_Z, static_cast(reports::normalize_trigger(state.left_trigger))}, + std::pair {ABS_RZ, static_cast(reports::normalize_trigger(state.right_trigger))}, + }; + for (const auto &[code, value] : trigger_axes) { + if (const auto status = emit_event(EV_ABS, code, value); !status.ok()) { + return status; + } + } + return OperationStatus::success(); + } + + 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) { + std::this_thread::sleep_for(uinput_feedback_startup_delay); + 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 (auto effect = normalize_rumble_effect(upload.effect); effect.has_value()) { + 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; + } + const auto [effect_weak, effect_strong] = rumble_magnitudes(effect, now); + weak += effect_weak; + strong += effect_strong; + } + + 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); + } + + static std::uint16_t scale_signed_magnitude(std::int16_t value) { + const auto magnitude = static_cast(std::abs(static_cast(value))); + return static_cast(std::min(magnitude * 2U, std::numeric_limits::max())); + } + + static std::optional normalize_rumble_effect(const ff_effect &source) { + auto effect = UinputRumbleEffect { + .length = std::chrono::milliseconds {source.replay.length}, + .delay = std::chrono::milliseconds {source.replay.delay}, + }; + + switch (source.type) { + case FF_RUMBLE: + effect.start_weak_magnitude = source.u.rumble.weak_magnitude; + effect.start_strong_magnitude = source.u.rumble.strong_magnitude; + effect.end_weak_magnitude = effect.start_weak_magnitude; + effect.end_strong_magnitude = effect.start_strong_magnitude; + break; + case FF_CONSTANT: + { + const auto magnitude = scale_signed_magnitude(source.u.constant.level); + effect.start_weak_magnitude = magnitude; + effect.start_strong_magnitude = magnitude; + effect.end_weak_magnitude = magnitude; + effect.end_strong_magnitude = magnitude; + effect.envelope = source.u.constant.envelope; + break; + } + case FF_PERIODIC: + { + const auto magnitude = scale_signed_magnitude(source.u.periodic.magnitude); + effect.start_weak_magnitude = magnitude; + effect.start_strong_magnitude = magnitude; + effect.end_weak_magnitude = magnitude; + effect.end_strong_magnitude = magnitude; + effect.envelope = source.u.periodic.envelope; + break; + } + case FF_RAMP: + effect.start_weak_magnitude = scale_signed_magnitude(source.u.ramp.start_level); + effect.start_strong_magnitude = effect.start_weak_magnitude; + effect.end_weak_magnitude = scale_signed_magnitude(source.u.ramp.end_level); + effect.end_strong_magnitude = effect.end_weak_magnitude; + effect.envelope = source.u.ramp.envelope; + break; + default: + return std::nullopt; + } + + return effect; + } + + static std::uint16_t interpolate_magnitude(std::uint16_t start, std::uint16_t end, std::uint64_t elapsed, std::uint64_t length) { + if (length == 0U || elapsed >= length) { + return end; + } + const auto weighted = static_cast(start) * (length - elapsed) + static_cast(end) * elapsed; + return static_cast(weighted / length); + } + + static std::uint16_t apply_envelope( + std::uint16_t magnitude, + const ff_envelope &envelope, + std::uint64_t elapsed, + std::uint64_t remaining + ) { + if (envelope.attack_length > 0U && elapsed < envelope.attack_length) { + return interpolate_magnitude(envelope.attack_level, magnitude, elapsed, envelope.attack_length); + } + if (envelope.fade_length > 0U && remaining < envelope.fade_length) { + return interpolate_magnitude(envelope.fade_level, magnitude, remaining, envelope.fade_length); + } + return magnitude; + } + + static std::pair rumble_magnitudes( + const UinputRumbleEffect &effect, + std::chrono::steady_clock::time_point now + ) { + const auto elapsed = static_cast(std::max( + std::chrono::duration_cast(now - effect.start).count(), + 0 + )); + const auto length = static_cast(std::max(effect.length.count(), 0)); + const auto remaining = elapsed >= length ? 0U : length - elapsed; + const auto weak = interpolate_magnitude(effect.start_weak_magnitude, effect.end_weak_magnitude, elapsed, length); + const auto strong = interpolate_magnitude(effect.start_strong_magnitude, effect.end_strong_magnitude, elapsed, length); + return { + apply_envelope(weak, effect.envelope, elapsed, remaining), + apply_envelope(strong, effect.envelope, elapsed, remaining), + }; + } + + 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. */ @@ -2258,17 +2960,18 @@ namespace lvh::detail { } event.type = UHID_CREATE2; - auto unique_id = options.metadata.stable_id.empty() ? std::to_string(id) : options.metadata.stable_id; + unique_id_ = options.metadata.stable_id.empty() ? std::to_string(id) : options.metadata.stable_id; if (is_playstation_profile(options.profile.gamepad_kind)) { playstation_mac_address_ = parse_mac_address(options.metadata.stable_id).value_or(generated_mac_address(id)); - unique_id = format_mac_address(playstation_mac_address_); + unique_id_ = format_mac_address(playstation_mac_address_); } + physical_id_ = std::format("libvirtualhid/uhid/{}", id); copy_string(request.name, options.profile.name); - copy_string(request.phys, std::format("libvirtualhid/uhid/{}", id)); - copy_string(request.uniq, unique_id); + copy_string(request.phys, physical_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 +2999,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"); } @@ -2323,7 +3029,11 @@ namespace lvh::detail { } std::vector device_nodes() const override { - return discover_input_nodes_by_name(device_name_); + auto nodes = discover_input_nodes_by_name(device_name_); + for (const auto &node : discover_hidraw_nodes_by_metadata(device_name_, physical_id_, unique_id_)) { + append_node_if_missing(nodes, node.kind, node.path); + } + return nodes; } OperationStatus close() override { @@ -2432,7 +3142,7 @@ namespace lvh::detail { send_get_report_reply(event.u.get_report.id, event.u.get_report.rnum); break; case UHID_SET_REPORT: - dispatch_output_report(event.u.set_report.data, event.u.set_report.size); + dispatch_set_report(event.u.set_report.rnum, event.u.set_report.data, event.u.set_report.size); send_set_report_reply(event.u.set_report.id, 0); break; default: @@ -2453,12 +3163,27 @@ namespace lvh::detail { report = last_report_; } if (!report.empty()) { - static_cast(submit(report)); + static_cast(submit({}, report)); } } } void dispatch_output_report(const __u8 *data, std::size_t report_size) { + const auto size = std::min(report_size, UHID_DATA_MAX); + std::vector report(data, data + size); + dispatch_output_report(report); + } + + void dispatch_set_report(std::uint8_t report_number, const __u8 *data, std::size_t report_size) { + const auto size = std::min(report_size, UHID_DATA_MAX); + std::vector report(data, data + size); + if (report_number != 0 && (report.empty() || report.front() != report_number)) { + report.insert(report.begin(), report_number); + } + dispatch_output_report(report); + } + + void dispatch_output_report(const std::vector &report) { OutputCallback callback; { std::lock_guard lock {callback_mutex_}; @@ -2469,8 +3194,6 @@ namespace lvh::detail { return; } - const auto size = std::min(report_size, UHID_DATA_MAX); - std::vector report(data, data + size); for (const auto &output : reports::parse_output_reports(profile_, report)) { callback(output); } @@ -2562,6 +3285,8 @@ namespace lvh::detail { int fd_ = -1; DeviceProfile profile_; std::string device_name_; + std::string physical_id_; + std::string unique_id_; std::array playstation_mac_address_ {}; std::vector last_report_; std::atomic_bool open_ = true; @@ -2585,13 +3310,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,7 +3325,21 @@ namespace lvh::detail { } BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { - const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + 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); 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..ea750b6 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. * @@ -463,6 +496,22 @@ namespace lvh::detail::test { */ bool linux_hidraw_name_matches(std::string_view path, std::string_view name); + /** + * @brief Discover hidraw nodes using UHID metadata. + * + * @param name Expected HID name. + * @param physical_id Expected HID physical path. + * @param unique_id Expected HID unique id. + * @param hidraw_root Test hidraw sysfs root. + * @return Matching hidraw device nodes. + */ + std::vector linux_discover_hidraw_nodes_by_metadata( + std::string_view name, + std::string_view physical_id, + std::string_view unique_id, + const std::string &hidraw_root + ); + /** * @brief Discover Linux input nodes for a device name. * @@ -567,6 +616,27 @@ 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 uinput gamepad force-feedback upload and playback. + * + * @param kind Gamepad profile kind. + * @param effect_type Linux force-feedback effect type. + * @return Creation, callback, and close results. + */ + LinuxUinputRumbleResult linux_uinput_gamepad_fake_rumble( + GamepadProfileKind kind, + std::uint16_t effect_type + ); + /** * @brief Try creating a libevdev uinput mouse on an invalid file descriptor. * @@ -888,6 +958,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..7f9605f 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); } @@ -888,6 +923,15 @@ namespace lvh::detail::test { return hidraw_name_matches(std::filesystem::path {std::string {path}}, name); } + std::vector linux_discover_hidraw_nodes_by_metadata( + std::string_view name, + std::string_view physical_id, + std::string_view unique_id, + const std::string &hidraw_root + ) { + return discover_hidraw_nodes_by_metadata(name, physical_id, unique_id, hidraw_root); + } + std::vector linux_discover_nodes_by_name(const std::string &name) { return discover_input_nodes_by_name(name); } @@ -935,13 +979,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 +1026,112 @@ 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_gamepad_fake_rumble( + GamepadProfileKind kind, + std::uint16_t effect_type + ) { + 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 = effect_type; + syscalls.uploaded_ff_effect.id = 3; + switch (effect_type) { + case FF_RUMBLE: + syscalls.uploaded_ff_effect.u.rumble.weak_magnitude = 0x1234; + syscalls.uploaded_ff_effect.u.rumble.strong_magnitude = 0x5678; + break; + case FF_CONSTANT: + syscalls.uploaded_ff_effect.u.constant.level = 0x1234; + break; + case FF_PERIODIC: + syscalls.uploaded_ff_effect.u.periodic.waveform = FF_SINE; + syscalls.uploaded_ff_effect.u.periodic.magnitude = 0x1234; + break; + case FF_RAMP: + syscalls.uploaded_ff_effect.u.ramp.start_level = 0x1234; + syscalls.uploaded_ff_effect.u.ramp.end_level = 0x2345; + break; + default: + break; + } + 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, kind}; + 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; + const auto profile = profiles::gamepad_profile(kind); + if (!profile.has_value()) { + result.create_status = OperationStatus::failure(ErrorCode::unsupported_profile, "unknown gamepad profile"); + result.close_status = result.create_status; + static_cast(system_close(fd)); + return result; + } + options.profile = *profile; + result.create_status = gamepad.create(8, options); + for (auto attempt = 0; attempt < 500 && 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; } @@ -1250,6 +1400,8 @@ namespace lvh::detail::test { event = {}; event.type = UHID_SET_REPORT; event.u.set_report.id = 10; + event.u.set_report.rnum = profile.report_id; + event.u.set_report.rtype = UHID_OUTPUT_REPORT; event.u.set_report.size = static_cast<__u16>(profile.output_report_size); event.u.set_report.data[0] = profile.report_id; event.u.set_report.data[1] = 0x78; @@ -1268,7 +1420,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(); } @@ -1305,13 +1457,44 @@ namespace lvh::detail::test { event.u.create2.product == options.profile.product_id; } + gamepad.set_output_callback([&result](const GamepadOutput &output) { + if (output.kind == GamepadOutputKind::rumble) { + ++result.output_callback_count; + result.last_output = output; + } + }); + + event = {}; + event.type = UHID_OUTPUT; + event.u.output.rtype = UHID_OUTPUT_REPORT; + event.u.output.size = 63; + event.u.output.data[0] = 0x02; + event.u.output.data[1] = 0x03; + event.u.output.data[3] = 0x12; + event.u.output.data[4] = 0x56; + static_cast(write_uhid_event(descriptors[1], event)); + + event = {}; + event.type = UHID_SET_REPORT; + event.u.set_report.id = 20; + event.u.set_report.rnum = 0x02; + event.u.set_report.rtype = UHID_OUTPUT_REPORT; + event.u.set_report.size = 62; + event.u.set_report.data[0] = 0x03; + event.u.set_report.data[2] = 0x12; + event.u.set_report.data[3] = 0x56; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event_type(descriptors[1], UHID_SET_REPORT_REPLY, event)) { + result.saw_set_report_reply = event.u.set_report_reply.id == 20 && event.u.set_report_reply.err == 0; + } + event = {}; event.type = UHID_GET_REPORT; event.u.get_report.id = 11; 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; } @@ -1435,6 +1618,27 @@ namespace lvh::detail::test { event.u.create2.product == options.profile.product_id; } + gamepad.set_output_callback([&result](const GamepadOutput &output) { + if (output.kind == GamepadOutputKind::rumble) { + ++result.output_callback_count; + result.last_output = output; + } + }); + + event = {}; + event.type = UHID_SET_REPORT; + event.u.set_report.id = 21; + event.u.set_report.rnum = 0x05; + event.u.set_report.rtype = UHID_OUTPUT_REPORT; + event.u.set_report.size = 31; + event.u.set_report.data[0] = 0x03; + event.u.set_report.data[3] = 0x12; + event.u.set_report.data[4] = 0x56; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event_type(descriptors[1], UHID_SET_REPORT_REPLY, event)) { + result.saw_set_report_reply = event.u.set_report_reply.id == 21 && event.u.set_report_reply.err == 0; + } + event = {}; event.type = UHID_GET_REPORT; event.u.get_report.id = 16; @@ -1555,7 +1759,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 +1848,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 +2066,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 +2076,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 +2152,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..b94d1fe 100644 --- a/tests/unit/test_gamepad_adapter.cpp +++ b/tests/unit/test_gamepad_adapter.cpp @@ -14,17 +14,22 @@ 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(); const auto keyboard = lvh::profiles::keyboard(); const auto generic_support = lvh::gamepad_profile_support(generic); - EXPECT_FALSE(generic_support.supports_rumble); + EXPECT_TRUE(generic_support.supports_rumble); EXPECT_FALSE(generic_support.supports_touchpad); 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); @@ -46,7 +51,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_EQ(dualsense_support.supported_rear_paddle_count, 0U); const auto switch_pro_support = lvh::gamepad_profile_support(switch_pro); - EXPECT_FALSE(switch_pro_support.supports_rumble); + EXPECT_TRUE(switch_pro_support.supports_rumble); EXPECT_TRUE(switch_pro_support.supports_motion); EXPECT_TRUE(switch_pro_support.supports_battery); EXPECT_TRUE(switch_pro_support.supports_misc1_button); @@ -91,9 +96,9 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) { EXPECT_FALSE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::trigger_rumble)); EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::raw_report)); EXPECT_TRUE(lvh::supports_gamepad_output(dualsense, lvh::GamepadOutputKind::adaptive_triggers)); - EXPECT_FALSE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::rumble)); + EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::rumble)); EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::raw_report)); - EXPECT_FALSE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::raw_report)); + EXPECT_TRUE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::raw_report)); EXPECT_FALSE(lvh::supports_gamepad_output(keyboard, lvh::GamepadOutputKind::rumble)); EXPECT_FALSE(lvh::supports_gamepad_output(generic, static_cast(255))); } diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index a5b2c92..77fd6fd 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,27 @@ // 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__) +namespace { + std::vector pressed_key_codes( + const std::vector &events + ) { + std::vector pressed_codes; + for (const auto &event : events) { + if (event.type == EV_KEY && event.value == 1) { + pressed_codes.push_back(event.code); + } + } + return pressed_codes; + } +} // namespace + 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 +121,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); @@ -207,6 +219,8 @@ TEST_F(LinuxBackendTest, CoversLinuxDiscoveryAndIdentityHelpers) { std::filesystem::create_directories(hidraw_root / "hidraw0" / "device"); std::filesystem::create_directories(hidraw_root / "hidraw1" / "device"); std::filesystem::create_directories(hidraw_root / "hidraw2" / "device"); + std::filesystem::create_directories(hidraw_root / "hidraw3" / "device"); + std::filesystem::create_directories(hidraw_root / "hidraw4" / "device"); { std::ofstream file {input_root / "event0" / "device" / "name"}; file << "libvirtualhid device\n"; @@ -235,6 +249,18 @@ TEST_F(LinuxBackendTest, CoversLinuxDiscoveryAndIdentityHelpers) { std::ofstream file {hidraw_root / "hidraw2" / "device" / "uevent"}; file << "HID_ID=0003:0000:0000\n"; } + { + std::ofstream file {hidraw_root / "hidraw3" / "device" / "uevent"}; + file << "HID_NAME=kernel renamed controller\n" + << "HID_PHYS=libvirtualhid/uhid/42\n" + << "HID_UNIQ=02:00:00:00:00:01\n"; + } + { + std::ofstream file {hidraw_root / "hidraw4" / "device" / "uevent"}; + file << "HID_NAME=other device\n" + << "HID_PHYS=other/physical/path\n" + << "HID_UNIQ=02:00:00:00:00:02\n"; + } const auto nodes = lvh::detail::test::linux_discover_nodes_by_name( "libvirtualhid device", input_root.string(), @@ -253,6 +279,22 @@ TEST_F(LinuxBackendTest, CoversLinuxDiscoveryAndIdentityHelpers) { return node.kind == lvh::DeviceNodeKind::sysfs && node.path.starts_with(sysfs_root.string()); })); + const auto metadata_nodes = lvh::detail::test::linux_discover_hidraw_nodes_by_metadata( + "missing hidraw name", + "libvirtualhid/uhid/42", + "02:00:00:00:00:01", + hidraw_root.string() + ); + EXPECT_TRUE(std::ranges::any_of(metadata_nodes, [](const auto &node) { + return node.kind == lvh::DeviceNodeKind::hidraw && node.path == "/dev/hidraw3"; + })); + EXPECT_TRUE(std::ranges::any_of(metadata_nodes, [hidraw_root](const auto &node) { + return node.kind == lvh::DeviceNodeKind::sysfs && node.path == (hidraw_root / "hidraw3").string(); + })); + EXPECT_FALSE(std::ranges::any_of(metadata_nodes, [](const auto &node) { + return node.path.ends_with("hidraw4"); + })); + std::filesystem::remove_all(temp_dir); } @@ -308,6 +350,136 @@ 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; + std::uint16_t switch_code; + }; + + constexpr std::array button_cases { + ButtonCase {a, BTN_SOUTH, BTN_EAST}, + ButtonCase {b, BTN_EAST, BTN_SOUTH}, + ButtonCase {x, BTN_NORTH, BTN_NORTH}, + ButtonCase {y, BTN_WEST, BTN_WEST}, + ButtonCase {left_shoulder, BTN_TL, BTN_TL}, + ButtonCase {right_shoulder, BTN_TR, BTN_TR}, + ButtonCase {back, BTN_SELECT, BTN_SELECT}, + ButtonCase {start, BTN_START, BTN_START}, + ButtonCase {guide, BTN_MODE, BTN_MODE}, + ButtonCase {left_stick, BTN_THUMBL, BTN_THUMBL}, + ButtonCase {right_stick, BTN_THUMBR, BTN_THUMBR}, + }; + constexpr std::array profile_kinds {generic, xbox_360, xbox_one, xbox_series, switch_pro}; + + for (const auto kind : profile_kinds) { + for (const auto &[button, linux_code, switch_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(); + + const auto pressed_codes = pressed_key_codes(result.events); + 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(), kind == switch_pro ? switch_code : 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()); + } + + { + lvh::GamepadState state; + state.buttons.set(misc1); + const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(switch_pro, 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 == BTN_Z && 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); + for (const auto dpad_button : {BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT}) { + EXPECT_EQ(event_value(EV_KEY, dpad_button), std::nullopt); + } + 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)); + if (kind == switch_pro) { + EXPECT_EQ(event_value(EV_ABS, ABS_Z), std::nullopt); + EXPECT_EQ(event_value(EV_ABS, ABS_RZ), std::nullopt); + EXPECT_EQ(event_value(EV_KEY, BTN_TL2), 1); + EXPECT_EQ(event_value(EV_KEY, BTN_TR2), 1); + } else { + 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)); + } + } +} + +TEST_F(LinuxBackendTest, UinputGamepadsNormalizeForceFeedback) { + for (const auto kind : { + lvh::GamepadProfileKind::generic, + lvh::GamepadProfileKind::xbox_360, + lvh::GamepadProfileKind::xbox_one, + lvh::GamepadProfileKind::xbox_series, + lvh::GamepadProfileKind::switch_pro, + }) { + for (const auto effect_type : {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_RAMP}) { + const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(kind, effect_type); + 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); + if (effect_type == FF_RUMBLE) { + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); + } else { + EXPECT_GT(result.last_output.low_frequency_rumble, 0); + EXPECT_GT(result.last_output.high_frequency_rumble, 0); + } + } + } +} + TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) { EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_create_invalid_fd().code(), lvh::ErrorCode::backend_failure); @@ -402,13 +574,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 +590,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); } @@ -555,6 +727,11 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) { EXPECT_TRUE(result.saw_dualsense_calibration); EXPECT_TRUE(result.saw_dualsense_pairing); EXPECT_TRUE(result.saw_dualsense_firmware); + EXPECT_TRUE(result.saw_set_report_reply); + ASSERT_GE(result.output_callback_count, 1U); + EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5656); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1212); } TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) { @@ -575,6 +752,11 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) { EXPECT_TRUE(result.saw_dualshock4_calibration); EXPECT_TRUE(result.saw_dualshock4_pairing); EXPECT_TRUE(result.saw_dualshock4_firmware); + EXPECT_TRUE(result.saw_set_report_reply); + ASSERT_GE(result.output_callback_count, 1U); + EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5656); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1212); } TEST_F(LinuxBackendTest, SocketpairBackedDualShock4BluetoothFramesReports) { @@ -593,15 +775,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 +875,115 @@ 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 key_record; + bool sparse_button_slots; + bool switch_controls; + }; + + constexpr std::array gamepad_cases { + GamepadCase {lvh::GamepadProfileKind::generic, BUS_USB, 0x0001, true, true, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_360, BUS_BLUETOOTH, 0x028E, false, true, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_one, BUS_BLUETOOTH, 0x0B20, false, true, false}, + GamepadCase {lvh::GamepadProfileKind::xbox_series, BUS_BLUETOOTH, 0x0B13, true, true, false}, + GamepadCase {lvh::GamepadProfileKind::switch_pro, BUS_USB, 0x2009, false, false, true}, + }; + 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}; + + constexpr std::array feedback_codes {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_SINE, FF_RAMP, FF_GAIN}; + + for (const auto &[kind, bustype, product_id, key_record, sparse_button_slots, switch_controls] : 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) { + const auto expected = sparse_button_slots || (switch_controls && button != BTN_C); + EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, expected) + << "unexpected reserved gamepad button slot state for " << button; + } + for (const auto button : dpad_buttons) { + EXPECT_EQ(find_code(gamepad, EV_KEY, button), nullptr) + << "unexpected directional gamepad button capability for " << button; + } + EXPECT_NE(find_code(gamepad, EV_ABS, ABS_HAT0X), nullptr); + EXPECT_NE(find_code(gamepad, EV_ABS, ABS_HAT0Y), nullptr); + EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, key_record); + const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z); + if (switch_controls) { + EXPECT_EQ(left_trigger, nullptr); + EXPECT_NE(find_code(gamepad, EV_KEY, BTN_TL2), nullptr); + EXPECT_NE(find_code(gamepad, EV_KEY, BTN_TR2), nullptr); + EXPECT_NE(find_code(gamepad, EV_KEY, BTN_Z), nullptr); + } else { + ASSERT_NE(left_trigger, nullptr); + EXPECT_EQ(left_trigger->minimum, 0); + EXPECT_EQ(left_trigger->maximum, 255); + } + for (const auto code : feedback_codes) { + EXPECT_EQ(find_code(gamepad, EV_FF, code) != nullptr, expected_profile->capabilities.supports_rumble); + } + } + + const auto xbox_360 = lvh::detail::test::linux_uinput_create_fake_gamepad(lvh::GamepadProfileKind::xbox_360); + ASSERT_TRUE(xbox_360.status.ok()) << xbox_360.status.message(); + std::vector xbox_360_button_slots; + for (const auto &event_code : xbox_360.event_codes) { + if (event_code.type == EV_KEY && event_code.code >= BTN_SOUTH && event_code.code <= BTN_THUMBR) { + xbox_360_button_slots.push_back(event_code.code); + } + } + std::ranges::sort(xbox_360_button_slots); + constexpr std::array expected_xbox_360_button_slots { + BTN_SOUTH, + BTN_EAST, + BTN_C, + BTN_NORTH, + BTN_WEST, + BTN_Z, + BTN_TL, + BTN_TR, + BTN_TL2, + BTN_TR2, + BTN_SELECT, + BTN_START, + BTN_MODE, + BTN_THUMBL, + BTN_THUMBR, + }; + ASSERT_EQ(xbox_360_button_slots.size(), expected_xbox_360_button_slots.size()); + for (std::size_t index = 0; index < expected_xbox_360_button_slots.size(); ++index) { + EXPECT_EQ(xbox_360_button_slots[index], expected_xbox_360_button_slots[index]) << "button slot " << index; + } + 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 +1052,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 +1114,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 +1173,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 +1202,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..14d3ef1 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -5,9 +5,12 @@ // standard includes #include +#include +#include #include #include #include +#include #include #include #include @@ -26,19 +29,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 +49,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 +60,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; @@ -178,6 +176,69 @@ namespace { } } + struct RumbleState { + std::atomic_uint16_t low_frequency {0}; + std::atomic_uint16_t high_frequency {0}; + std::atomic_bool observed {false}; + }; + + std::shared_ptr observe_rumble(lvh::Gamepad &gamepad) { + const auto rumble = std::make_shared(); + gamepad.set_output_callback([rumble](const lvh::GamepadOutput &output) { + if ( + output.kind == lvh::GamepadOutputKind::rumble && + output.low_frequency_rumble > 0 && output.high_frequency_rumble > 0 + ) { + rumble->low_frequency = output.low_frequency_rumble; + rumble->high_frequency = output.high_frequency_rumble; + rumble->observed = true; + } + }); + return rumble; + } + + bool wait_for_rumble(const std::shared_ptr &rumble, bool pump_sdl) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + while (std::chrono::steady_clock::now() < deadline && !rumble->observed.load()) { + if (pump_sdl) { + pump_sdl_events(); + } + std::this_thread::sleep_for(std::chrono::milliseconds {20}); + } + return rumble->observed.load(); + } + + std::optional wait_for_hidraw_node(lvh::Gamepad &gamepad) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + while (std::chrono::steady_clock::now() < deadline) { + for (const auto &node : gamepad.device_nodes()) { + if (node.kind == lvh::DeviceNodeKind::hidraw && ::access(node.path.c_str(), W_OK) == 0) { + return std::filesystem::path {node.path}; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds {50}); + } + return std::nullopt; + } + + std::vector playstation_rumble_report(const lvh::DeviceProfile &profile) { + std::vector report(profile.output_report_size, 0); + if (profile.gamepad_kind == lvh::GamepadProfileKind::dualshock4 && report.size() >= 32U) { + report[0] = 0x05; + report[1] = 0x01; + report[4] = 0x12; + report[5] = 0x56; + } else if (profile.gamepad_kind == lvh::GamepadProfileKind::dualsense && report.size() >= 48U) { + report[0] = 0x02; + report[1] = 0x01; + report[3] = 0x12; + report[4] = 0x56; + } else { + report.clear(); + } + return report; + } + void dump_sdl_joysticks() { const auto joystick_count = SDL_NumJoysticks(); std::cout << "SDL joystick count: " << joystick_count << '\n'; @@ -244,22 +305,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,11 +368,29 @@ 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"); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4", "1"); + SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4_RUMBLE", "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", "1"); + SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5_RUMBLE", "1"); } lvh::GamepadCreationResult create_sdl_gamepad(lvh::Runtime &runtime, const SdlGamepadConsumerCase &test_case) { @@ -355,6 +418,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.has_value()) { + profile.product_id = *test_case.expected_product_id; + } return profile; }(); @@ -382,6 +448,36 @@ namespace { } } + void expect_sdl_rumble_callback(SDL_GameController *controller, lvh::Gamepad &gamepad) { + const auto rumble = observe_rumble(gamepad); + + ASSERT_EQ(SDL_GameControllerRumble(controller, 0x5678, 0x1234, 1000), 0) << SDL_GetError(); + EXPECT_TRUE(wait_for_rumble(rumble, true)); + EXPECT_GT(rumble->low_frequency.load(), 0); + EXPECT_GT(rumble->high_frequency.load(), 0); + } + + void expect_hidraw_rumble_callback(const lvh::DeviceProfile &profile, lvh::Gamepad &gamepad) { + const auto rumble = observe_rumble(gamepad); + const auto hidraw_node = wait_for_hidraw_node(gamepad); + ASSERT_TRUE(hidraw_node.has_value()); + + const auto report = playstation_rumble_report(profile); + ASSERT_FALSE(report.empty()); + + const auto descriptor = ::open(hidraw_node->c_str(), O_WRONLY | O_CLOEXEC); + ASSERT_GE(descriptor, 0) << std::strerror(errno); + ScopeExit close_descriptor {[descriptor]() { + static_cast(::close(descriptor)); + }}; + + ASSERT_EQ(::write(descriptor, report.data(), report.size()), static_cast(report.size())) + << std::strerror(errno); + EXPECT_TRUE(wait_for_rumble(rumble, false)); + EXPECT_GT(rumble->low_frequency.load(), 0); + EXPECT_GT(rumble->high_frequency.load(), 0); + } + void exercise_sdl_playstation_controller( const SdlGamepadConsumerCase &test_case, const lvh::DeviceProfile &expected_profile, @@ -420,39 +516,98 @@ namespace { expect_sdl_playstation_controller_profile(controller.get()); if (test_case.expect_live_input) { EXPECT_TRUE(wait_for_sdl_controller_input(controller.get())) << describe_sdl_controller_state(controller.get()); + expect_hidraw_rumble_callback(expected_profile, gamepad); } } - 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); + expect_sdl_rumble_callback(controller.get(), gamepad); + } + + 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 +627,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,18 +681,68 @@ 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 = 16, + .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 = 15, + .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, SdlSeesSwitchProCanonicalButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + run_sdl_canonical_gamepad_test({ + .profile = lvh::profiles::switch_pro(), + .name_suffix = "SDL Switch Pro", + .stable_id = "libvirtualhid-sdl-switch-pro-test", + .minimum_buttons = 14, + .minimum_axes = 4, }); } @@ -816,24 +1021,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..832a464 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -26,6 +26,16 @@ TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { } } +TEST(ProfileTest, BuiltInProfilesUseDefaultDeviceNames) { + EXPECT_EQ(lvh::profiles::generic_gamepad().name, "(libvirtualhid) Generic Controller"); + EXPECT_EQ(lvh::profiles::xbox_360().name, "(libvirtualhid) X-Box 360 Controller"); + EXPECT_EQ(lvh::profiles::xbox_one().name, "(libvirtualhid) X-Box One Controller"); + EXPECT_EQ(lvh::profiles::xbox_series().name, "(libvirtualhid) X-Box Series Controller"); + EXPECT_EQ(lvh::profiles::dualshock4().name, "(libvirtualhid) PS4 Controller"); + EXPECT_EQ(lvh::profiles::dualsense().name, "(libvirtualhid) PS5 Controller"); + EXPECT_EQ(lvh::profiles::switch_pro().name, "(libvirtualhid) Nintendo Pro Controller"); +} + TEST(ProfileTest, StreamingControllerProfilesArePresent) { const auto xbox_one = lvh::profiles::xbox_one(); const auto dualshock4 = lvh::profiles::dualshock4(); @@ -44,22 +54,41 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(xbox_series.vendor_id, 0x045E); EXPECT_EQ(xbox_series.product_id, 0x0B12); EXPECT_EQ(xbox_series.bus_type, lvh::BusType::usb); - EXPECT_EQ(xbox_series.name, "Xbox Controller"); EXPECT_EQ(xbox_series.manufacturer, "Microsoft"); 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, }; @@ -132,7 +161,6 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(dualshock4.vendor_id, 0x054C); EXPECT_EQ(dualshock4.product_id, 0x05C4); EXPECT_EQ(dualshock4.version, 0x0100); - EXPECT_EQ(dualshock4.name, "Wireless Controller"); EXPECT_EQ(dualshock4.input_report_size, 64U); EXPECT_EQ(dualshock4.output_report_size, 32U); EXPECT_TRUE(dualshock4.capabilities.supports_motion); @@ -145,7 +173,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { const auto dualshock4_bluetooth = lvh::profiles::dualshock4_bluetooth(); EXPECT_EQ(dualshock4_bluetooth.bus_type, lvh::BusType::bluetooth); EXPECT_EQ(dualshock4_bluetooth.version, 0x0100); - EXPECT_EQ(dualshock4_bluetooth.name, "Wireless Controller"); + EXPECT_EQ(dualshock4_bluetooth.name, "(libvirtualhid) PS4 Controller"); EXPECT_EQ(dualshock4_bluetooth.manufacturer, "Sony Computer Entertainment"); EXPECT_EQ(dualshock4_bluetooth.report_id, 0x11); EXPECT_EQ(dualshock4_bluetooth.input_report_size, 78U); @@ -153,7 +181,6 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_NE(dualshock4_bluetooth.report_descriptor, dualshock4.report_descriptor); EXPECT_EQ(dualsense.vendor_id, 0x054C); - EXPECT_EQ(dualsense.name, "Wireless Controller"); EXPECT_TRUE(dualsense.capabilities.supports_motion); EXPECT_TRUE(dualsense.capabilities.supports_touchpad); EXPECT_TRUE(dualsense.capabilities.supports_rgb_led); @@ -164,7 +191,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { const auto dualsense_bluetooth = lvh::profiles::dualsense_bluetooth(); EXPECT_EQ(dualsense_bluetooth.bus_type, lvh::BusType::bluetooth); - EXPECT_EQ(dualsense_bluetooth.name, "Wireless Controller"); + EXPECT_EQ(dualsense_bluetooth.name, "(libvirtualhid) PS5 Controller"); EXPECT_EQ(dualsense_bluetooth.report_id, 0x31); EXPECT_EQ(dualsense_bluetooth.input_report_size, 78U); EXPECT_EQ(dualsense_bluetooth.output_report_size, 78U); @@ -172,29 +199,32 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(switch_pro.vendor_id, 0x057E); EXPECT_EQ(switch_pro.product_id, 0x2009); - EXPECT_EQ(switch_pro.name, "Pro Controller"); EXPECT_EQ(switch_pro.manufacturer, "Nintendo Co., Ltd."); EXPECT_EQ(switch_pro.report_id, 0x30); EXPECT_EQ(switch_pro.input_report_size, 64U); EXPECT_EQ(switch_pro.output_report_size, 64U); - EXPECT_FALSE(switch_pro.capabilities.supports_rumble); + EXPECT_TRUE(switch_pro.capabilities.supports_rumble); EXPECT_TRUE(switch_pro.capabilities.supports_motion); 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, + 0x09, // Usage Page (Button) 0x19, - 0x01, + 0x01, // Usage Minimum (Button 1) 0x29, - 0x10, + 0x10, // Usage Maximum (Button 16) 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, + 0x95, + 0x10, + 0x81, + 0x02, }; EXPECT_TRUE( std::ranges::search(generic.report_descriptor, standard_button_descriptor).begin() != generic.report_descriptor.end() @@ -225,11 +255,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,19 +286,52 @@ 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) { const auto generic = lvh::profiles::generic_gamepad(); const auto xbox_360 = lvh::profiles::xbox_360(); - EXPECT_FALSE(generic.capabilities.supports_rumble); - EXPECT_EQ(generic.output_report_size, 0U); + EXPECT_TRUE(generic.capabilities.supports_rumble); + EXPECT_EQ(generic.output_report_size, 5U); EXPECT_TRUE(xbox_360.capabilities.supports_rumble); EXPECT_EQ(xbox_360.output_report_size, 5U); diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index f4f7a06..1f58f12 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -476,8 +476,21 @@ TEST(ReportTest, KeepsUnrecognizedOutputReportsRaw) { const auto generic_output = lvh::reports::parse_output_report(generic_profile, generic_report); - EXPECT_EQ(generic_output.kind, lvh::GamepadOutputKind::raw_report); - EXPECT_EQ(generic_output.low_frequency_rumble, 0U); - EXPECT_EQ(generic_output.high_frequency_rumble, 0U); + EXPECT_EQ(generic_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(generic_output.low_frequency_rumble, 0x1234); + EXPECT_EQ(generic_output.high_frequency_rumble, 0xABCD); EXPECT_EQ(generic_output.raw_report, generic_report); + + const auto switch_profile = lvh::profiles::switch_pro(); + std::vector switch_report(switch_profile.output_report_size, 0); + switch_report[0] = switch_profile.report_id; + switch_report[1] = 0x34; + switch_report[2] = 0x12; + switch_report[3] = 0xCD; + switch_report[4] = 0xAB; + + const auto switch_output = lvh::reports::parse_output_report(switch_profile, switch_report); + + EXPECT_EQ(switch_output.kind, lvh::GamepadOutputKind::raw_report); + EXPECT_EQ(switch_output.raw_report, switch_report); } diff --git a/tests/unit/test_virtualhid_control_model.cpp b/tests/unit/test_virtualhid_control_model.cpp index 9f47387..935dc9c 100644 --- a/tests/unit/test_virtualhid_control_model.cpp +++ b/tests/unit/test_virtualhid_control_model.cpp @@ -127,12 +127,12 @@ TEST(VirtualHidControlModelTest, SummarizesProfileFeatures) { const auto generic = lvh::profiles::generic_gamepad(); const auto dualsense = lvh::profiles::dualsense(); - EXPECT_FALSE(control::supports_normalized_feedback(generic)); + EXPECT_TRUE(control::supports_normalized_feedback(generic)); EXPECT_TRUE(control::supports_normalized_feedback(dualsense)); EXPECT_EQ( control::profile_feature_summary(generic), - L"Features: battery no | rumble no | trigger rumble no | RGB LED no | adaptive triggers no | raw output no" + L"Features: battery no | rumble yes | trigger rumble no | RGB LED no | adaptive triggers no | raw output yes" ); EXPECT_EQ( control::profile_feature_summary(dualsense), @@ -170,7 +170,7 @@ TEST(VirtualHidControlModelTest, SummarizesOutputState) { control::OutputState state; EXPECT_EQ( control::output_summary(state, generic), - L"Output: no reports received | profile has no normalized feedback categories" + L"Output: no reports received" ); EXPECT_EQ(control::output_summary(state, dualsense), L"Output: no reports received");