From cb6f1a1f37d2aa64328a47ad96d73865ab0966df Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:37:54 -0400 Subject: [PATCH 01/10] fix(Linux): map UHID PlayStation USB to Bluetooth Linux UHID now rewrites requested PlayStation USB profiles (DualShock 4 and DualSense) to their Bluetooth transport variants so Steam's hidraw access rules can match virtual devices correctly. The backend preserves caller-provided identity/capability metadata while swapping bus and report framing fields, returns the effective profile from gamepad creation, and leaves non-PlayStation or already-Bluetooth requests unchanged. Added test hooks and unit coverage for the profile translation behavior, and documented the Linux-only transport adjustment. --- docs/platform-support.md | 9 ++++++ src/platform/linux/uhid_backend.cpp | 32 +++++++++++++++++-- .../fixtures/linux_backend_test_hooks.hpp | 8 +++++ tests/fixtures/linux_backend_test_hooks.cpp | 4 +++ tests/unit/test_linux_backend.cpp | 31 ++++++++++++++++++ 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 3f4c3f2..23487b3 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -138,6 +138,15 @@ 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. +On Linux, PlayStation profiles requested with the default USB transport are +instantiated as their Bluetooth UHID variants. Virtual UHID devices do not have +a physical USB ancestor, so the USB PlayStation rules shipped by +`steam-devices` cannot grant access to their `hidraw` nodes. The Bluetooth rules +match the virtual HID ancestor by bus, vendor, and product instead, allowing +Steam to open the controller. `Gamepad::profile()` reports this effective +Bluetooth transport and matching report framing. This adjustment is confined to +the Linux backend; Windows profile transport and VHF behavior are unchanged. + 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 diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 4756490..95861be 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -303,6 +303,28 @@ namespace lvh::detail { } return to_uhid_bus(profile.bus_type); } + + std::optional effective_uhid_gamepad_profile(const DeviceProfile &requested_profile) { + if (!is_playstation_profile(requested_profile.gamepad_kind) || requested_profile.bus_type == BusType::bluetooth) { + return std::nullopt; + } + + // A UHID device has no physical USB ancestor, so the USB-only PlayStation + // hidraw rules shipped by steam-devices cannot grant the session access. + // The Bluetooth rules match the virtual HID ancestor instead. Mirror the + // working inputtino transport while preserving consumer-supplied identity + // and capability metadata. + const auto transport_profile = requested_profile.gamepad_kind == GamepadProfileKind::dualshock4 ? + profiles::dualshock4_bluetooth() : + profiles::dualsense_bluetooth(); + auto effective_profile = requested_profile; + effective_profile.bus_type = transport_profile.bus_type; + effective_profile.report_id = transport_profile.report_id; + effective_profile.input_report_size = transport_profile.input_report_size; + effective_profile.output_report_size = transport_profile.output_report_size; + effective_profile.report_descriptor = transport_profile.report_descriptor; + return effective_profile; + } #endif std::uint16_t to_uinput_bus(BusType bus_type) { @@ -3190,13 +3212,19 @@ namespace lvh::detail { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; } + auto effective_options = options; + auto effective_profile = effective_uhid_gamepad_profile(options.profile); + if (effective_profile.has_value()) { + effective_options.profile = *effective_profile; + } + auto gamepad = std::make_unique(fd); - if (const auto status = gamepad->create(id, options); !status.ok()) { + if (const auto status = gamepad->create(id, effective_options); !status.ok()) { static_cast(gamepad->close()); return {status, nullptr}; } - return {OperationStatus::success(), std::move(gamepad)}; + return {OperationStatus::success(), std::move(gamepad), std::move(effective_profile)}; #else return { OperationStatus::failure(ErrorCode::unsupported_profile, "gamepad profile requires Linux UHID"), diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 5051ae1..e499650 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -422,6 +422,14 @@ namespace lvh::detail::test { */ std::uint16_t linux_gamepad_uhid_bus(GamepadProfileKind kind); + /** + * @brief Select the effective Linux UHID profile for a requested gamepad profile. + * + * @param profile Requested gamepad profile. + * @return Backend-adjusted gamepad profile. + */ + DeviceProfile linux_effective_uhid_gamepad_profile(const DeviceProfile &profile); + /** * @brief Translate a bus type to a Linux uinput bus code. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index f3aec9c..bc21aac 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -878,6 +878,10 @@ namespace lvh::detail::test { return profile ? to_uhid_bus(*profile) : to_uhid_bus(BusType::unknown); } + DeviceProfile linux_effective_uhid_gamepad_profile(const DeviceProfile &profile) { + return effective_uhid_gamepad_profile(profile).value_or(profile); + } + std::uint16_t linux_uinput_bus(BusType bus_type) { return to_uinput_bus(bus_type); } diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index e74bef0..d1c8037 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -125,6 +125,37 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) { 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); + auto dualshock4 = lvh::profiles::dualshock4_usb(); + dualshock4.name = "Streaming host DS4"; + const auto effective_dualshock4 = lvh::detail::test::linux_effective_uhid_gamepad_profile(dualshock4); + const auto dualshock4_bluetooth = lvh::profiles::dualshock4_bluetooth(); + EXPECT_EQ(effective_dualshock4.bus_type, lvh::BusType::bluetooth); + EXPECT_EQ(effective_dualshock4.report_id, dualshock4_bluetooth.report_id); + EXPECT_EQ(effective_dualshock4.input_report_size, dualshock4_bluetooth.input_report_size); + EXPECT_EQ(effective_dualshock4.output_report_size, dualshock4_bluetooth.output_report_size); + EXPECT_EQ(effective_dualshock4.report_descriptor, dualshock4_bluetooth.report_descriptor); + EXPECT_EQ(effective_dualshock4.name, dualshock4.name); + EXPECT_EQ(effective_dualshock4.vendor_id, dualshock4.vendor_id); + EXPECT_EQ(effective_dualshock4.product_id, dualshock4.product_id); + + auto dualsense = lvh::profiles::dualsense_usb(); + dualsense.manufacturer = "Streaming host"; + const auto effective_dualsense = lvh::detail::test::linux_effective_uhid_gamepad_profile(dualsense); + const auto dualsense_bluetooth = lvh::profiles::dualsense_bluetooth(); + EXPECT_EQ(effective_dualsense.bus_type, lvh::BusType::bluetooth); + EXPECT_EQ(effective_dualsense.report_id, dualsense_bluetooth.report_id); + EXPECT_EQ(effective_dualsense.input_report_size, dualsense_bluetooth.input_report_size); + EXPECT_EQ(effective_dualsense.output_report_size, dualsense_bluetooth.output_report_size); + EXPECT_EQ(effective_dualsense.report_descriptor, dualsense_bluetooth.report_descriptor); + EXPECT_EQ(effective_dualsense.manufacturer, dualsense.manufacturer); + EXPECT_EQ(effective_dualsense.version, dualsense.version); + + const auto requested_bluetooth = lvh::profiles::dualsense_bluetooth(); + EXPECT_EQ( + lvh::detail::test::linux_effective_uhid_gamepad_profile(requested_bluetooth).report_descriptor, + requested_bluetooth.report_descriptor + ); + EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::pen), BTN_TOOL_PEN); EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::eraser), BTN_TOOL_RUBBER); EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::brush), BTN_TOOL_BRUSH); From def6ff079db9d10149917daf9d1c1bf81b359db6 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:57:28 -0400 Subject: [PATCH 02/10] fix(Linux): force Sony name for UHID PlayStation Switch Linux UHID PlayStation handling from transport/profile rewriting to identity-only naming. DualShock 4 and DualSense now always emit `Wireless Controller` in UHID create events so Steam HID detection stays compatible, while keeping the requested USB/Bluetooth descriptor and report framing unchanged. Tests and fixtures were updated to capture/create-name assertions, and the old effective-profile remap helper/tests were removed. --- docs/platform-support.md | 14 +++---- src/platform/linux/uhid_backend.cpp | 40 +++++-------------- .../fixtures/linux_backend_test_hooks.hpp | 13 +++--- tests/fixtures/linux_backend_test_hooks.cpp | 13 ++++-- tests/unit/test_linux_backend.cpp | 36 +++-------------- 5 files changed, 36 insertions(+), 80 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 23487b3..dfbaa01 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -138,14 +138,12 @@ 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. -On Linux, PlayStation profiles requested with the default USB transport are -instantiated as their Bluetooth UHID variants. Virtual UHID devices do not have -a physical USB ancestor, so the USB PlayStation rules shipped by -`steam-devices` cannot grant access to their `hidraw` nodes. The Bluetooth rules -match the virtual HID ancestor by bus, vendor, and product instead, allowing -Steam to open the controller. `Gamepad::profile()` reports this effective -Bluetooth transport and matching report framing. This adjustment is confined to -the Linux backend; Windows profile transport and VHF behavior are unchanged. +On Linux, the UHID transport emits Sony's native `Wireless Controller` product +name for DualShock 4 and DualSense devices. This keeps Steam's PlayStation HID +detection compatible even when a streaming host customizes the public profile +name. The requested USB or Bluetooth descriptor and report framing are left +unchanged. This identity adjustment is confined to the Linux backend; Windows +profile names and VHF behavior are unchanged. Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows the evdev layout used by Linux-native virtual-controller implementations and diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 95861be..6bac8dd 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -304,26 +304,14 @@ namespace lvh::detail { return to_uhid_bus(profile.bus_type); } - std::optional effective_uhid_gamepad_profile(const DeviceProfile &requested_profile) { - if (!is_playstation_profile(requested_profile.gamepad_kind) || requested_profile.bus_type == BusType::bluetooth) { - return std::nullopt; + std::string_view uhid_gamepad_name(const DeviceProfile &profile) { + // Steam's PlayStation HID path expects Sony's native product name. Keep + // consumer branding out of the Linux transport identity while preserving + // custom names for every other profile and on every other backend. + if (is_playstation_profile(profile.gamepad_kind)) { + return "Wireless Controller"; } - - // A UHID device has no physical USB ancestor, so the USB-only PlayStation - // hidraw rules shipped by steam-devices cannot grant the session access. - // The Bluetooth rules match the virtual HID ancestor instead. Mirror the - // working inputtino transport while preserving consumer-supplied identity - // and capability metadata. - const auto transport_profile = requested_profile.gamepad_kind == GamepadProfileKind::dualshock4 ? - profiles::dualshock4_bluetooth() : - profiles::dualsense_bluetooth(); - auto effective_profile = requested_profile; - effective_profile.bus_type = transport_profile.bus_type; - effective_profile.report_id = transport_profile.report_id; - effective_profile.input_report_size = transport_profile.input_report_size; - effective_profile.output_report_size = transport_profile.output_report_size; - effective_profile.report_descriptor = transport_profile.report_descriptor; - return effective_profile; + return profile.name; } #endif @@ -2809,7 +2797,8 @@ namespace lvh::detail { } physical_id_ = std::format("libvirtualhid/uhid/{}", id); - copy_string(request.name, options.profile.name); + device_name_ = uhid_gamepad_name(options.profile); + copy_string(request.name, device_name_); copy_string(request.phys, physical_id_); copy_string(request.uniq, unique_id_); request.rd_size = static_cast(options.profile.report_descriptor.size()); @@ -2819,7 +2808,6 @@ namespace lvh::detail { request.version = options.profile.version; std::memcpy(request.rd_data, options.profile.report_descriptor.data(), options.profile.report_descriptor.size()); profile_ = options.profile; - device_name_ = options.profile.name; { std::lock_guard lock {report_mutex_}; last_report_ = reports::pack_input_report(profile_, {}); @@ -3212,19 +3200,13 @@ namespace lvh::detail { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; } - auto effective_options = options; - auto effective_profile = effective_uhid_gamepad_profile(options.profile); - if (effective_profile.has_value()) { - effective_options.profile = *effective_profile; - } - auto gamepad = std::make_unique(fd); - if (const auto status = gamepad->create(id, effective_options); !status.ok()) { + if (const auto status = gamepad->create(id, options); !status.ok()) { static_cast(gamepad->close()); return {status, nullptr}; } - return {OperationStatus::success(), std::move(gamepad), std::move(effective_profile)}; + return {OperationStatus::success(), std::move(gamepad)}; #else return { OperationStatus::failure(ErrorCode::unsupported_profile, "gamepad profile requires Linux UHID"), diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index e499650..ce0c0dc 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -126,6 +126,11 @@ namespace lvh::detail::test { */ bool saw_create = false; + /** + * @brief Product name carried by the observed create event. + */ + std::string created_name; + /** * @brief Whether the peer observed an input report event. */ @@ -422,14 +427,6 @@ namespace lvh::detail::test { */ std::uint16_t linux_gamepad_uhid_bus(GamepadProfileKind kind); - /** - * @brief Select the effective Linux UHID profile for a requested gamepad profile. - * - * @param profile Requested gamepad profile. - * @return Backend-adjusted gamepad profile. - */ - DeviceProfile linux_effective_uhid_gamepad_profile(const DeviceProfile &profile); - /** * @brief Translate a bus type to a Linux uinput bus code. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index bc21aac..dfdca17 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -878,10 +878,6 @@ namespace lvh::detail::test { return profile ? to_uhid_bus(*profile) : to_uhid_bus(BusType::unknown); } - DeviceProfile linux_effective_uhid_gamepad_profile(const DeviceProfile &profile) { - return effective_uhid_gamepad_profile(profile).value_or(profile); - } - std::uint16_t linux_uinput_bus(BusType bus_type) { return to_uinput_bus(bus_type); } @@ -1420,6 +1416,7 @@ namespace lvh::detail::test { if (read_uhid_event(descriptors[1], event)) { result.saw_create = event.type == UHID_CREATE2 && event.u.create2.vendor == profile.vendor_id && event.u.create2.product == profile.product_id; + result.created_name = reinterpret_cast(event.u.create2.name); } gamepad.set_output_callback([&result](const GamepadOutput &output) { @@ -1494,6 +1491,7 @@ namespace lvh::detail::test { CreateGamepadOptions options; options.profile = profiles::dualsense_usb(); + options.profile.name = "Sunshine (libvirtualhid) PS5 Controller"; options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; @@ -1503,6 +1501,7 @@ namespace lvh::detail::test { if (read_uhid_event_type(descriptors[1], UHID_CREATE2, event)) { result.saw_create = event.u.create2.vendor == options.profile.vendor_id && event.u.create2.product == options.profile.product_id; + result.created_name = reinterpret_cast(event.u.create2.name); } gamepad.set_output_callback([&result](const GamepadOutput &output) { @@ -1593,6 +1592,7 @@ namespace lvh::detail::test { CreateGamepadOptions options; options.profile = profiles::dualsense_bluetooth(); + options.profile.name = "Sunshine (libvirtualhid) PS5 Controller"; options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; @@ -1603,6 +1603,7 @@ namespace lvh::detail::test { result.saw_create = event.u.create2.vendor == options.profile.vendor_id && event.u.create2.product == options.profile.product_id && event.u.create2.bus == BUS_BLUETOOTH; + result.created_name = reinterpret_cast(event.u.create2.name); } if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) { @@ -1655,6 +1656,7 @@ namespace lvh::detail::test { CreateGamepadOptions options; options.profile = profiles::dualshock4_usb(); + options.profile.name = "Sunshine (libvirtualhid) PS4 Controller"; options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; @@ -1664,6 +1666,7 @@ namespace lvh::detail::test { if (read_uhid_event_type(descriptors[1], UHID_CREATE2, event)) { result.saw_create = event.u.create2.vendor == options.profile.vendor_id && event.u.create2.product == options.profile.product_id; + result.created_name = reinterpret_cast(event.u.create2.name); } gamepad.set_output_callback([&result](const GamepadOutput &output) { @@ -1737,6 +1740,7 @@ namespace lvh::detail::test { CreateGamepadOptions options; options.profile = profiles::dualshock4_bluetooth(); + options.profile.name = "Sunshine (libvirtualhid) PS4 Controller"; options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; @@ -1747,6 +1751,7 @@ namespace lvh::detail::test { result.saw_create = event.u.create2.vendor == options.profile.vendor_id && event.u.create2.product == options.profile.product_id && event.u.create2.bus == BUS_BLUETOOTH; + result.created_name = reinterpret_cast(event.u.create2.name); } if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) { diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index d1c8037..16f4cd7 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -125,37 +125,6 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) { 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); - auto dualshock4 = lvh::profiles::dualshock4_usb(); - dualshock4.name = "Streaming host DS4"; - const auto effective_dualshock4 = lvh::detail::test::linux_effective_uhid_gamepad_profile(dualshock4); - const auto dualshock4_bluetooth = lvh::profiles::dualshock4_bluetooth(); - EXPECT_EQ(effective_dualshock4.bus_type, lvh::BusType::bluetooth); - EXPECT_EQ(effective_dualshock4.report_id, dualshock4_bluetooth.report_id); - EXPECT_EQ(effective_dualshock4.input_report_size, dualshock4_bluetooth.input_report_size); - EXPECT_EQ(effective_dualshock4.output_report_size, dualshock4_bluetooth.output_report_size); - EXPECT_EQ(effective_dualshock4.report_descriptor, dualshock4_bluetooth.report_descriptor); - EXPECT_EQ(effective_dualshock4.name, dualshock4.name); - EXPECT_EQ(effective_dualshock4.vendor_id, dualshock4.vendor_id); - EXPECT_EQ(effective_dualshock4.product_id, dualshock4.product_id); - - auto dualsense = lvh::profiles::dualsense_usb(); - dualsense.manufacturer = "Streaming host"; - const auto effective_dualsense = lvh::detail::test::linux_effective_uhid_gamepad_profile(dualsense); - const auto dualsense_bluetooth = lvh::profiles::dualsense_bluetooth(); - EXPECT_EQ(effective_dualsense.bus_type, lvh::BusType::bluetooth); - EXPECT_EQ(effective_dualsense.report_id, dualsense_bluetooth.report_id); - EXPECT_EQ(effective_dualsense.input_report_size, dualsense_bluetooth.input_report_size); - EXPECT_EQ(effective_dualsense.output_report_size, dualsense_bluetooth.output_report_size); - EXPECT_EQ(effective_dualsense.report_descriptor, dualsense_bluetooth.report_descriptor); - EXPECT_EQ(effective_dualsense.manufacturer, dualsense.manufacturer); - EXPECT_EQ(effective_dualsense.version, dualsense.version); - - const auto requested_bluetooth = lvh::profiles::dualsense_bluetooth(); - EXPECT_EQ( - lvh::detail::test::linux_effective_uhid_gamepad_profile(requested_bluetooth).report_descriptor, - requested_bluetooth.report_descriptor - ); - EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::pen), BTN_TOOL_PEN); EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::eraser), BTN_TOOL_RUBBER); EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::brush), BTN_TOOL_BRUSH); @@ -795,6 +764,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_EQ(result.created_name, lvh::profiles::xbox_360().name); EXPECT_TRUE(result.saw_input); EXPECT_TRUE(result.saw_get_report_reply); EXPECT_TRUE(result.saw_set_report_reply); @@ -810,6 +780,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_EQ(result.created_name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualsense_calibration); EXPECT_TRUE(result.saw_dualsense_pairing); EXPECT_TRUE(result.saw_dualsense_firmware); @@ -825,6 +796,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_EQ(result.created_name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualsense_bluetooth_input); EXPECT_TRUE(result.saw_dualsense_pairing); EXPECT_TRUE(result.saw_dualsense_feature_crc); @@ -835,6 +807,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_EQ(result.created_name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualshock4_calibration); EXPECT_TRUE(result.saw_dualshock4_pairing); EXPECT_TRUE(result.saw_dualshock4_firmware); @@ -850,6 +823,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4BluetoothFramesReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_EQ(result.created_name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualshock4_bluetooth_input); EXPECT_TRUE(result.saw_dualshock4_calibration); EXPECT_TRUE(result.saw_dualshock4_pairing); From 580292918c9d28f1f4a2b337301cd657a76a941e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:30:23 -0400 Subject: [PATCH 03/10] Preserve DualShock 4 Linux UHID identity Keep the caller-requested DualShock 4 name and USB framing on the Linux UHID backend while still using Sony's native product name for DualSense Steam HID discovery. The tests and platform support docs were updated to assert the DualShock 4 create event and input reports stay on the expected USB path instead of being silently presented as Bluetooth. --- docs/platform-support.md | 13 +++++++------ src/platform/linux/uhid_backend.cpp | 8 ++++---- .../include/fixtures/linux_backend_test_hooks.hpp | 5 +++++ tests/fixtures/linux_backend_test_hooks.cpp | 9 ++++++++- tests/unit/test_linux_backend.cpp | 5 +++-- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index dfbaa01..1b3c1f7 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -138,12 +138,13 @@ 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. -On Linux, the UHID transport emits Sony's native `Wireless Controller` product -name for DualShock 4 and DualSense devices. This keeps Steam's PlayStation HID -detection compatible even when a streaming host customizes the public profile -name. The requested USB or Bluetooth descriptor and report framing are left -unchanged. This identity adjustment is confined to the Linux backend; Windows -profile names and VHF behavior are unchanged. +On Linux, DualShock 4 keeps the caller-requested name, bus, descriptor, and +report framing. In particular, the default profile remains the USB variant; +silently presenting it as Bluetooth changes the report contract used by native +HID consumers. DualSense emits Sony's native `Wireless Controller` product name +for Steam HID discovery while keeping its requested USB or Bluetooth framing. +These identity rules are confined to the Linux backend; Windows profile names +and VHF behavior are unchanged. Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows the evdev layout used by Linux-native virtual-controller implementations and diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 6bac8dd..cea9ac9 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -305,10 +305,10 @@ namespace lvh::detail { } std::string_view uhid_gamepad_name(const DeviceProfile &profile) { - // Steam's PlayStation HID path expects Sony's native product name. Keep - // consumer branding out of the Linux transport identity while preserving - // custom names for every other profile and on every other backend. - if (is_playstation_profile(profile.gamepad_kind)) { + // DualSense uses Sony's native product name for Steam HID discovery. + // DualShock 4 intentionally keeps the requested name: that is the identity + // used by the previously validated Sunshine/libvirtualhid USB path. + if (profile.gamepad_kind == GamepadProfileKind::dualsense) { return "Wireless Controller"; } return profile.name; diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index ce0c0dc..b4811cb 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -191,6 +191,11 @@ namespace lvh::detail::test { */ bool saw_dualshock4_bluetooth_input = false; + /** + * @brief Whether the peer observed a USB-framed DualShock 4 input report. + */ + bool saw_dualshock4_usb_input = false; + /** * @brief Whether the peer observed a set-report reply. */ diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index dfdca17..922aab4 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -1665,10 +1665,17 @@ namespace lvh::detail::test { uhid_event event {}; if (read_uhid_event_type(descriptors[1], UHID_CREATE2, event)) { result.saw_create = event.u.create2.vendor == options.profile.vendor_id && - event.u.create2.product == options.profile.product_id; + event.u.create2.product == options.profile.product_id && + event.u.create2.bus == BUS_USB && + event.u.create2.rd_size == options.profile.report_descriptor.size(); result.created_name = reinterpret_cast(event.u.create2.name); } + if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) { + result.saw_dualshock4_usb_input = + event.u.input2.size == options.profile.input_report_size && event.u.input2.data[0] == options.profile.report_id; + } + gamepad.set_output_callback([&result](const GamepadOutput &output) { if (output.kind == GamepadOutputKind::rumble) { ++result.output_callback_count; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 16f4cd7..70fd34b 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -807,7 +807,8 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); - EXPECT_EQ(result.created_name, "Wireless Controller"); + EXPECT_EQ(result.created_name, "Sunshine (libvirtualhid) PS4 Controller"); + EXPECT_TRUE(result.saw_dualshock4_usb_input); EXPECT_TRUE(result.saw_dualshock4_calibration); EXPECT_TRUE(result.saw_dualshock4_pairing); EXPECT_TRUE(result.saw_dualshock4_firmware); @@ -823,7 +824,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4BluetoothFramesReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); - EXPECT_EQ(result.created_name, "Wireless Controller"); + EXPECT_EQ(result.created_name, "Sunshine (libvirtualhid) PS4 Controller"); EXPECT_TRUE(result.saw_dualshock4_bluetooth_input); EXPECT_TRUE(result.saw_dualshock4_calibration); EXPECT_TRUE(result.saw_dualshock4_pairing); From 20078419769075b67e5e6e52cf95c38e5cc16401 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:07:41 -0400 Subject: [PATCH 04/10] Wait for UHID_START before reporting create Start the UHID reader before sending UHID_CREATE2 and block create() until UHID_START arrives (with timeout and reader-exit failure paths). This keeps control-channel handling active during registration and avoids exposing a gamepad as ready before the kernel HID device has started. Tests and Linux test hooks were updated to script/read UHID_START and assert that create waits for start, and platform docs now describe the new startup behavior. --- docs/platform-support.md | 6 + src/platform/linux/uhid_backend.cpp | 64 ++++++++-- .../fixtures/linux_backend_test_hooks.hpp | 5 + tests/fixtures/linux_backend_test_hooks.cpp | 114 +++++++++++++++--- tests/unit/test_linux_backend.cpp | 5 + 5 files changed, 168 insertions(+), 26 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 1b3c1f7..9d03fe9 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -138,6 +138,12 @@ 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. +The UHID event reader is active before device registration begins, and gamepad +creation does not report success until the kernel returns `UHID_START`. This +keeps control-channel initialization available throughout registration and +prevents streaming hosts from publishing a controller before its kernel HID +device has started. + On Linux, DualShock 4 keeps the caller-requested name, bus, descriptor, and report framing. In particular, the default profile remains the USB variant; silently presenting it as Bluetooth changes the report contract used by native diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index cea9ac9..6798662 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -99,6 +100,7 @@ namespace lvh::detail { #if defined(__linux__) namespace ps = playstation_feature_reports; constexpr auto playstation_periodic_report_ms = 10; + constexpr auto uhid_start_timeout = std::chrono::seconds {5}; #endif int system_access(const char *path, int mode) { @@ -2813,14 +2815,37 @@ namespace lvh::detail { last_report_ = reports::pack_input_report(profile_, {}); } - if (const auto status = write_event(event); !status.ok()) { - return status; + { + std::lock_guard lock {lifecycle_mutex_}; + started_ = false; + reader_exited_ = false; } - running_ = true; reader_ = std::jthread {[this](std::stop_token stop_token) { read_loop(stop_token); }}; + + if (const auto status = write_event(event); !status.ok()) { + stop_reader(); + return status; + } + + { + std::unique_lock lock {lifecycle_mutex_}; + if (!lifecycle_condition_.wait_for(lock, uhid_start_timeout, [this]() { + return started_ || reader_exited_; + })) { + lock.unlock(); + stop_reader(); + return OperationStatus::failure(ErrorCode::backend_failure, "timed out waiting for UHID_START"); + } + if (!started_) { + lock.unlock(); + stop_reader(); + return OperationStatus::failure(ErrorCode::backend_failure, "UHID reader stopped before UHID_START"); + } + } + if (is_playstation_profile(profile_.gamepad_kind)) { periodic_reporter_ = std::jthread {[this](std::stop_token stop_token) { periodic_report_loop(stop_token); @@ -2935,13 +2960,13 @@ namespace lvh::detail { if (errno == EINTR) { continue; } - return; + break; } if (result == 0) { continue; } if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { - return; + break; } if ((descriptor.revents & POLLIN) == 0) { continue; @@ -2953,18 +2978,31 @@ namespace lvh::detail { if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { continue; } - return; + break; } if (read_result == 0) { - return; + break; } handle_event(event); } + + { + std::lock_guard lock {lifecycle_mutex_}; + reader_exited_ = true; + } + lifecycle_condition_.notify_all(); } void handle_event(const uhid_event &event) { switch (event.type) { + case UHID_START: + { + std::lock_guard lock {lifecycle_mutex_}; + started_ = true; + } + lifecycle_condition_.notify_all(); + break; case UHID_OUTPUT: dispatch_output_report(event.u.output.data, event.u.output.size); break; @@ -2980,6 +3018,14 @@ namespace lvh::detail { } } + void stop_reader() { + running_ = false; + if (reader_.joinable()) { + reader_.request_stop(); + reader_.join(); + } + } + void periodic_report_loop(std::stop_token stop_token) { while (!stop_token.stop_requested() && running_) { std::this_thread::sleep_for(std::chrono::milliseconds {playstation_periodic_report_ms}); @@ -3123,6 +3169,10 @@ namespace lvh::detail { std::atomic_bool running_ = false; std::jthread reader_; std::jthread periodic_reporter_; + std::mutex lifecycle_mutex_; + std::condition_variable lifecycle_condition_; + bool started_ = false; + bool reader_exited_ = false; std::mutex write_mutex_; std::mutex report_mutex_; std::mutex callback_mutex_; diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index b4811cb..1cce712 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -126,6 +126,11 @@ namespace lvh::detail::test { */ bool saw_create = false; + /** + * @brief Whether create remained pending until the peer sent UHID_START. + */ + bool create_waited_for_start = false; + /** * @brief Product name carried by the observed create event. */ diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index 922aab4..9616967 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -138,6 +138,7 @@ namespace lvh::detail::test { std::vector read_results; std::vector read_errors; uhid_event read_event {}; + std::vector read_events; std::vector read_input_events; ff_effect uploaded_ff_effect {}; std::vector uploaded_ff_effects; @@ -305,7 +306,11 @@ std::ptrdiff_t lvh_linux_test_read(int fd, std::byte *buffer, std::size_t size) } 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); + const auto &read_events = lvh::detail::test::active_test_syscalls()->read_events; + const auto &event = call_index < read_events.size() ? + read_events[call_index] : + lvh::detail::test::active_test_syscalls()->read_event; + std::memcpy(buffer, &event, bytes); return static_cast(bytes); } @@ -679,6 +684,33 @@ namespace lvh::detail::test { return false; } + OperationStatus create_started_uhid_gamepad( + UhidGamepad &gamepad, + DeviceId id, + const CreateGamepadOptions &options, + int peer_fd, + uhid_event &create_event, + bool &saw_create, + bool &waited_for_start + ) { + auto create_status = OperationStatus::failure(ErrorCode::backend_failure, "UHID create thread did not run"); + std::atomic_bool create_returned = false; + std::jthread create_thread {[&]() { + create_status = gamepad.create(id, options); + create_returned = true; + }}; + + saw_create = read_uhid_event_type(peer_fd, UHID_CREATE2, create_event); + std::this_thread::sleep_for(std::chrono::milliseconds {20}); + waited_for_start = !create_returned; + + uhid_event start_event {}; + start_event.type = UHID_START; + static_cast(write_uhid_event(peer_fd, start_event)); + create_thread.join(); + return create_status; + } + std::uint32_t read_u32_le(const std::uint8_t *buffer) { return static_cast(buffer[0]) | (static_cast(buffer[1]) << 8U) | @@ -708,6 +740,16 @@ namespace lvh::detail::test { OperationStatus run_fake_uhid_read_loop(LinuxTestSyscalls &syscalls, int expected_poll_calls) { syscalls.override_write = true; + syscalls.override_poll = true; + syscalls.override_read = true; + syscalls.poll_results.insert(syscalls.poll_results.begin(), 1); + syscalls.poll_revents.insert(syscalls.poll_revents.begin(), POLLIN); + syscalls.poll_errors.insert(syscalls.poll_errors.begin(), 0); + syscalls.read_results.insert(syscalls.read_results.begin(), static_cast(sizeof(uhid_event))); + syscalls.read_errors.insert(syscalls.read_errors.begin(), 0); + uhid_event start_event {}; + start_event.type = UHID_START; + syscalls.read_events.insert(syscalls.read_events.begin(), start_event); ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; @@ -724,7 +766,7 @@ namespace lvh::detail::test { return status; } - const auto saw_expected_polls = wait_for_poll_calls(syscalls, expected_poll_calls); + const auto saw_expected_polls = wait_for_poll_calls(syscalls, expected_poll_calls + 1); const auto close_status = gamepad.close(); if (!saw_expected_polls) { return OperationStatus::failure(ErrorCode::backend_failure, "fake UHID read loop did not consume the scripted poll calls"); @@ -1410,12 +1452,18 @@ namespace lvh::detail::test { options.metadata.stable_id = "linux-uhid-roundtrip"; UhidGamepad gamepad {descriptors[0]}; - result.create_status = gamepad.create(7, options); - uhid_event event {}; - if (read_uhid_event(descriptors[1], event)) { - result.saw_create = - event.type == UHID_CREATE2 && event.u.create2.vendor == profile.vendor_id && event.u.create2.product == profile.product_id; + result.create_status = create_started_uhid_gamepad( + gamepad, + 7, + options, + descriptors[1], + event, + result.saw_create, + result.create_waited_for_start + ); + if (result.saw_create) { + result.saw_create = event.u.create2.vendor == profile.vendor_id && event.u.create2.product == profile.product_id; result.created_name = reinterpret_cast(event.u.create2.name); } @@ -1495,10 +1543,17 @@ namespace lvh::detail::test { options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; - result.create_status = gamepad.create(8, options); - uhid_event event {}; - if (read_uhid_event_type(descriptors[1], UHID_CREATE2, event)) { + result.create_status = create_started_uhid_gamepad( + gamepad, + 8, + options, + descriptors[1], + event, + result.saw_create, + result.create_waited_for_start + ); + if (result.saw_create) { result.saw_create = event.u.create2.vendor == options.profile.vendor_id && event.u.create2.product == options.profile.product_id; result.created_name = reinterpret_cast(event.u.create2.name); @@ -1596,10 +1651,17 @@ namespace lvh::detail::test { options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; - result.create_status = gamepad.create(9, options); - uhid_event event {}; - if (read_uhid_event_type(descriptors[1], UHID_CREATE2, event)) { + result.create_status = create_started_uhid_gamepad( + gamepad, + 9, + options, + descriptors[1], + event, + result.saw_create, + result.create_waited_for_start + ); + if (result.saw_create) { result.saw_create = event.u.create2.vendor == options.profile.vendor_id && event.u.create2.product == options.profile.product_id && event.u.create2.bus == BUS_BLUETOOTH; @@ -1660,10 +1722,17 @@ namespace lvh::detail::test { options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; - result.create_status = gamepad.create(10, options); - uhid_event event {}; - if (read_uhid_event_type(descriptors[1], UHID_CREATE2, event)) { + result.create_status = create_started_uhid_gamepad( + gamepad, + 10, + options, + descriptors[1], + event, + result.saw_create, + result.create_waited_for_start + ); + if (result.saw_create) { result.saw_create = event.u.create2.vendor == options.profile.vendor_id && event.u.create2.product == options.profile.product_id && event.u.create2.bus == BUS_USB && @@ -1751,10 +1820,17 @@ namespace lvh::detail::test { options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; - result.create_status = gamepad.create(11, options); - uhid_event event {}; - if (read_uhid_event_type(descriptors[1], UHID_CREATE2, event)) { + result.create_status = create_started_uhid_gamepad( + gamepad, + 11, + options, + descriptors[1], + event, + result.saw_create, + result.create_waited_for_start + ); + if (result.saw_create) { result.saw_create = event.u.create2.vendor == options.profile.vendor_id && event.u.create2.product == options.profile.product_id && event.u.create2.bus == BUS_BLUETOOTH; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 70fd34b..f2a3e98 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -764,6 +764,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_TRUE(result.create_waited_for_start); EXPECT_EQ(result.created_name, lvh::profiles::xbox_360().name); EXPECT_TRUE(result.saw_input); EXPECT_TRUE(result.saw_get_report_reply); @@ -780,6 +781,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_TRUE(result.create_waited_for_start); EXPECT_EQ(result.created_name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualsense_calibration); EXPECT_TRUE(result.saw_dualsense_pairing); @@ -796,6 +798,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_TRUE(result.create_waited_for_start); EXPECT_EQ(result.created_name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualsense_bluetooth_input); EXPECT_TRUE(result.saw_dualsense_pairing); @@ -807,6 +810,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_TRUE(result.create_waited_for_start); EXPECT_EQ(result.created_name, "Sunshine (libvirtualhid) PS4 Controller"); EXPECT_TRUE(result.saw_dualshock4_usb_input); EXPECT_TRUE(result.saw_dualshock4_calibration); @@ -824,6 +828,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4BluetoothFramesReports) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); + EXPECT_TRUE(result.create_waited_for_start); EXPECT_EQ(result.created_name, "Sunshine (libvirtualhid) PS4 Controller"); EXPECT_TRUE(result.saw_dualshock4_bluetooth_input); EXPECT_TRUE(result.saw_dualshock4_calibration); From 8dbde99abc4d86122faf064bce8f24f2cc680e0b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:08:09 -0400 Subject: [PATCH 05/10] Restore native DS4 Linux UHID name --- docs/platform-support.md | 13 ++++++------- src/platform/linux/uhid_backend.cpp | 8 ++++---- tests/unit/test_linux_backend.cpp | 4 ++-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 9d03fe9..dac869e 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -144,13 +144,12 @@ keeps control-channel initialization available throughout registration and prevents streaming hosts from publishing a controller before its kernel HID device has started. -On Linux, DualShock 4 keeps the caller-requested name, bus, descriptor, and -report framing. In particular, the default profile remains the USB variant; -silently presenting it as Bluetooth changes the report contract used by native -HID consumers. DualSense emits Sony's native `Wireless Controller` product name -for Steam HID discovery while keeping its requested USB or Bluetooth framing. -These identity rules are confined to the Linux backend; Windows profile names -and VHF behavior are unchanged. +On Linux, DualShock 4 and DualSense emit Sony's native `Wireless Controller` +product name for Steam HID discovery. The requested USB or Bluetooth bus, +descriptor, and report framing remain unchanged; in particular, the default +DualShock 4 profile stays on its USB report contract. This transport-only name +is confined to the Linux backend; public profile names, Windows names, and VHF +behavior are unchanged. Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows the evdev layout used by Linux-native virtual-controller implementations and diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 6798662..83ed42e 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -307,10 +307,10 @@ namespace lvh::detail { } std::string_view uhid_gamepad_name(const DeviceProfile &profile) { - // DualSense uses Sony's native product name for Steam HID discovery. - // DualShock 4 intentionally keeps the requested name: that is the identity - // used by the previously validated Sunshine/libvirtualhid USB path. - if (profile.gamepad_kind == GamepadProfileKind::dualsense) { + // Steam's PlayStation HID path expects Sony's native product name. Keep + // consumer branding out of the Linux transport identity while preserving + // the requested descriptor, bus, and report framing. + if (is_playstation_profile(profile.gamepad_kind)) { return "Wireless Controller"; } return profile.name; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index f2a3e98..8998024 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -811,7 +811,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) { EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); EXPECT_TRUE(result.create_waited_for_start); - EXPECT_EQ(result.created_name, "Sunshine (libvirtualhid) PS4 Controller"); + EXPECT_EQ(result.created_name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualshock4_usb_input); EXPECT_TRUE(result.saw_dualshock4_calibration); EXPECT_TRUE(result.saw_dualshock4_pairing); @@ -829,7 +829,7 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4BluetoothFramesReports) { EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); EXPECT_TRUE(result.saw_create); EXPECT_TRUE(result.create_waited_for_start); - EXPECT_EQ(result.created_name, "Sunshine (libvirtualhid) PS4 Controller"); + EXPECT_EQ(result.created_name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualshock4_bluetooth_input); EXPECT_TRUE(result.saw_dualshock4_calibration); EXPECT_TRUE(result.saw_dualshock4_pairing); From ba360a5078a6c6200d8b4d404cc88fdec938c30d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:56:39 -0400 Subject: [PATCH 06/10] Restore nonblocking Linux UHID registration --- docs/platform-support.md | 11 ++++++----- src/platform/linux/uhid_backend.cpp | 2 +- .../fixtures/linux_backend_test_hooks.hpp | 7 +++++++ tests/fixtures/linux_backend_test_hooks.cpp | 19 +++++++++++++++++++ tests/unit/test_linux_backend.cpp | 8 ++++++++ 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index dac869e..0695d4e 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -138,11 +138,12 @@ 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. -The UHID event reader is active before device registration begins, and gamepad -creation does not report success until the kernel returns `UHID_START`. This -keeps control-channel initialization available throughout registration and -prevents streaming hosts from publishing a controller before its kernel HID -device has started. +The backend opens `/dev/uhid` in nonblocking mode, matching the original +asynchronous gamepad registration path. Its event reader is active before +device registration begins, and creation does not report success until the +kernel returns `UHID_START`. This keeps control-channel initialization +available throughout registration and prevents streaming hosts from publishing +a controller before its kernel HID device has started. On Linux, DualShock 4 and DualSense emit Sony's native `Wireless Controller` product name for Steam HID discovery. The requested USB or Bluetooth bus, diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 83ed42e..709c092 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -3245,7 +3245,7 @@ namespace lvh::detail { } #if defined(__linux__) - const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC); + const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; } diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 1cce712..c6aede5 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -845,6 +845,13 @@ namespace lvh::detail::test { */ OperationStatus linux_backend_gamepad_fake_open_failure(); + /** + * @brief Capture the flags used to open UHID for a descriptor-driven gamepad. + * + * @return Flags passed to `open()` for `/dev/uhid`. + */ + int linux_backend_gamepad_open_flags(); + /** * @brief Try creating a Linux backend gamepad while fake UHID creation fails. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index 9616967..bf6f7e3 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -120,6 +120,7 @@ namespace lvh::detail::test { int access_result = 0; bool override_open = false; int open_result = 100000; + int last_open_flags = 0; bool override_write = false; std::atomic_int write_call_count = 0; int fail_write_call = -1; @@ -207,6 +208,9 @@ int lvh_linux_test_access(const char *path, int mode) { } int lvh_linux_test_open(const char *path, int flags) { + if (lvh::detail::test::active_test_syscalls() != nullptr) { + lvh::detail::test::active_test_syscalls()->last_open_flags = flags; + } if (lvh::detail::test::active_test_syscalls() != nullptr && lvh::detail::test::active_test_syscalls()->override_open) { if (lvh::detail::test::active_test_syscalls()->open_result < 0) { errno = ENOENT; @@ -1975,6 +1979,21 @@ namespace lvh::detail::test { return backend.create_gamepad(1, options).status; } + int linux_backend_gamepad_open_flags() { + LinuxTestSyscalls syscalls; + syscalls.override_access = true; + syscalls.override_open = true; + syscalls.open_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateGamepadOptions options; + options.profile = profiles::dualshock4_usb(); + static_cast(backend.create_gamepad(1, options)); + return syscalls.last_open_flags; + } + OperationStatus linux_backend_gamepad_fake_create_failure() { LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 8998024..f80c9c0 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -15,6 +15,7 @@ #include // platform includes +#include #include #if defined(LIBVIRTUALHID_HAVE_XTEST) #include @@ -908,6 +909,13 @@ TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { EXPECT_TRUE(result.pen_tablet_close_status.ok()) << result.pen_tablet_close_status.message(); } +TEST_F(LinuxBackendTest, OpensUhidGamepadsNonblocking) { + const auto flags = lvh::detail::test::linux_backend_gamepad_open_flags(); + EXPECT_EQ(flags & O_ACCMODE, O_RDWR); + EXPECT_NE(flags & O_CLOEXEC, 0); + EXPECT_NE(flags & O_NONBLOCK, 0); +} + TEST_F(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) { EXPECT_EQ(lvh::detail::test::linux_uhid_submit_fake_write_failure().code(), lvh::ErrorCode::backend_failure); EXPECT_EQ(lvh::detail::test::linux_uhid_submit_fake_short_write().code(), lvh::ErrorCode::backend_failure); From cdcec947b46af431407614793c6b4703a9343bc2 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:34:18 -0400 Subject: [PATCH 07/10] fix(linux): align PlayStation SDL integration tests Keep SDL Linux consumer coverage on the evdev path for UHID PlayStation profiles, allowing CI to discover native-named controllers without depending on unavailable hidraw hotplug support. Refactor UHID startup and reader handling and group fixture observations to clear the PR Sonar code smells and duplicated test setup. --- src/platform/linux/uhid_backend.cpp | 95 +++++++------- .../fixtures/linux_backend_test_hooks.hpp | 58 +++++--- tests/fixtures/linux_backend_test_hooks.cpp | 124 ++++++------------ tests/unit/test_linux_backend.cpp | 54 ++++---- tests/unit/test_linux_consumers.cpp | 23 +++- 5 files changed, 175 insertions(+), 179 deletions(-) diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 709c092..fc05cf6 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -2830,20 +2830,9 @@ namespace lvh::detail { return status; } - { - std::unique_lock lock {lifecycle_mutex_}; - if (!lifecycle_condition_.wait_for(lock, uhid_start_timeout, [this]() { - return started_ || reader_exited_; - })) { - lock.unlock(); - stop_reader(); - return OperationStatus::failure(ErrorCode::backend_failure, "timed out waiting for UHID_START"); - } - if (!started_) { - lock.unlock(); - stop_reader(); - return OperationStatus::failure(ErrorCode::backend_failure, "UHID reader stopped before UHID_START"); - } + if (const auto status = wait_for_start(); !status.ok()) { + stop_reader(); + return status; } if (is_playstation_profile(profile_.gamepad_kind)) { @@ -2949,42 +2938,47 @@ namespace lvh::detail { return OperationStatus::success(); } - void read_loop(std::stop_token stop_token) { - while (!stop_token.stop_requested() && running_) { - pollfd descriptor {}; - descriptor.fd = fd_; - descriptor.events = POLLIN; + enum class ReadEventResult { + event, + retry, + stop, + }; - const auto result = system_poll(&descriptor, 1, poll_timeout_ms); - if (result < 0) { - if (errno == EINTR) { - continue; - } - break; - } - if (result == 0) { - continue; - } - if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { - break; - } - if ((descriptor.revents & POLLIN) == 0) { - continue; - } + ReadEventResult read_event(uhid_event &event) { + pollfd descriptor {}; + descriptor.fd = fd_; + descriptor.events = POLLIN; + + const auto result = system_poll(&descriptor, 1, poll_timeout_ms); + if (result < 0) { + return errno == EINTR ? ReadEventResult::retry : ReadEventResult::stop; + } + if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { + return ReadEventResult::stop; + } + if (result == 0 || (descriptor.revents & POLLIN) == 0) { + return ReadEventResult::retry; + } + const auto result_read = system_read(fd_, std::as_writable_bytes(std::span {&event, 1U})); + if (result_read < 0) { + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR ? + ReadEventResult::retry : + ReadEventResult::stop; + } + return result_read == 0 ? ReadEventResult::stop : ReadEventResult::event; + } + + void read_loop(std::stop_token stop_token) { + while (!stop_token.stop_requested() && running_) { uhid_event event {}; - const auto read_result = system_read(fd_, std::as_writable_bytes(std::span {&event, 1U})); - if (read_result < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { - continue; - } + const auto result = read_event(event); + if (result == ReadEventResult::stop) { break; } - if (read_result == 0) { - break; + if (result == ReadEventResult::event) { + handle_event(event); } - - handle_event(event); } { @@ -3026,6 +3020,19 @@ namespace lvh::detail { } } + OperationStatus wait_for_start() { + std::unique_lock lock {lifecycle_mutex_}; + if (!lifecycle_condition_.wait_for(lock, uhid_start_timeout, [this]() { + return started_ || reader_exited_; + })) { + return OperationStatus::failure(ErrorCode::backend_failure, "timed out waiting for UHID_START"); + } + if (!started_) { + return OperationStatus::failure(ErrorCode::backend_failure, "UHID reader stopped before UHID_START"); + } + return OperationStatus::success(); + } + void periodic_report_loop(std::stop_token stop_token) { while (!stop_token.stop_requested() && running_) { std::this_thread::sleep_for(std::chrono::milliseconds {playstation_periodic_report_ms}); diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index c6aede5..0e00dec 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -102,6 +102,41 @@ namespace lvh::detail::test { std::uint64_t remaining = 0; }; + /** + * @brief UHID device-creation observations captured by a socketpair peer. + */ + struct LinuxUhidCreationObservation { + /** + * @brief Whether the peer observed a create event. + */ + bool saw_create = false; + + /** + * @brief Whether create remained pending until the peer sent UHID_START. + */ + bool waited_for_start = false; + + /** + * @brief Product name carried by the observed create event. + */ + std::string name; + }; + + /** + * @brief UHID output callback observations captured during a round trip. + */ + struct LinuxUhidOutputObservation { + /** + * @brief Number of output callbacks received. + */ + std::size_t callback_count = 0; + + /** + * @brief Last output callback payload. + */ + GamepadOutput last; + }; + /** * @brief Result from a socketpair-backed UHID lifecycle test. */ @@ -122,19 +157,9 @@ namespace lvh::detail::test { OperationStatus close_status; /** - * @brief Whether the peer observed a create event. - */ - bool saw_create = false; - - /** - * @brief Whether create remained pending until the peer sent UHID_START. - */ - bool create_waited_for_start = false; - - /** - * @brief Product name carried by the observed create event. + * @brief Device-creation observations. */ - std::string created_name; + LinuxUhidCreationObservation creation; /** * @brief Whether the peer observed an input report event. @@ -212,14 +237,9 @@ namespace lvh::detail::test { bool saw_destroy = false; /** - * @brief Number of output callbacks received. + * @brief Output callback observations. */ - std::size_t output_callback_count = 0; - - /** - * @brief Last output callback payload. - */ - GamepadOutput last_output; + LinuxUhidOutputObservation output; }; /** diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index bf6f7e3..df31b0e 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -715,6 +715,33 @@ namespace lvh::detail::test { return create_status; } + uhid_event create_started_profile_uhid_gamepad( + UhidGamepad &gamepad, + DeviceId id, + const CreateGamepadOptions &options, + int peer_fd, + std::uint16_t expected_bus, + LinuxUhidRoundTripResult &result + ) { + uhid_event event {}; + result.create_status = create_started_uhid_gamepad( + gamepad, + id, + options, + peer_fd, + event, + result.creation.saw_create, + result.creation.waited_for_start + ); + if (result.creation.saw_create) { + result.creation.saw_create = event.u.create2.vendor == options.profile.vendor_id && + event.u.create2.product == options.profile.product_id && + event.u.create2.bus == expected_bus; + result.creation.name = reinterpret_cast(event.u.create2.name); + } + return event; + } + std::uint32_t read_u32_le(const std::uint8_t *buffer) { return static_cast(buffer[0]) | (static_cast(buffer[1]) << 8U) | @@ -1456,24 +1483,11 @@ namespace lvh::detail::test { options.metadata.stable_id = "linux-uhid-roundtrip"; UhidGamepad gamepad {descriptors[0]}; - uhid_event event {}; - result.create_status = create_started_uhid_gamepad( - gamepad, - 7, - options, - descriptors[1], - event, - result.saw_create, - result.create_waited_for_start - ); - if (result.saw_create) { - result.saw_create = event.u.create2.vendor == profile.vendor_id && event.u.create2.product == profile.product_id; - result.created_name = reinterpret_cast(event.u.create2.name); - } + auto event = create_started_profile_uhid_gamepad(gamepad, 7, options, descriptors[1], BUS_USB, result); gamepad.set_output_callback([&result](const GamepadOutput &output) { - ++result.output_callback_count; - result.last_output = output; + ++result.output.callback_count; + result.output.last = output; }); event = {}; @@ -1547,26 +1561,12 @@ namespace lvh::detail::test { options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; - uhid_event event {}; - result.create_status = create_started_uhid_gamepad( - gamepad, - 8, - options, - descriptors[1], - event, - result.saw_create, - result.create_waited_for_start - ); - if (result.saw_create) { - result.saw_create = event.u.create2.vendor == options.profile.vendor_id && - event.u.create2.product == options.profile.product_id; - result.created_name = reinterpret_cast(event.u.create2.name); - } + auto event = create_started_profile_uhid_gamepad(gamepad, 8, options, descriptors[1], BUS_USB, result); gamepad.set_output_callback([&result](const GamepadOutput &output) { if (output.kind == GamepadOutputKind::rumble) { - ++result.output_callback_count; - result.last_output = output; + ++result.output.callback_count; + result.output.last = output; } }); @@ -1655,22 +1655,7 @@ namespace lvh::detail::test { options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; - uhid_event event {}; - result.create_status = create_started_uhid_gamepad( - gamepad, - 9, - options, - descriptors[1], - event, - result.saw_create, - result.create_waited_for_start - ); - if (result.saw_create) { - result.saw_create = event.u.create2.vendor == options.profile.vendor_id && - event.u.create2.product == options.profile.product_id && - event.u.create2.bus == BUS_BLUETOOTH; - result.created_name = reinterpret_cast(event.u.create2.name); - } + auto event = create_started_profile_uhid_gamepad(gamepad, 9, options, descriptors[1], BUS_BLUETOOTH, result); if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) { const auto report_size = static_cast(event.u.input2.size); @@ -1726,23 +1711,9 @@ namespace lvh::detail::test { options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; - uhid_event event {}; - result.create_status = create_started_uhid_gamepad( - gamepad, - 10, - options, - descriptors[1], - event, - result.saw_create, - result.create_waited_for_start - ); - if (result.saw_create) { - result.saw_create = event.u.create2.vendor == options.profile.vendor_id && - event.u.create2.product == options.profile.product_id && - event.u.create2.bus == BUS_USB && - event.u.create2.rd_size == options.profile.report_descriptor.size(); - result.created_name = reinterpret_cast(event.u.create2.name); - } + auto event = create_started_profile_uhid_gamepad(gamepad, 10, options, descriptors[1], BUS_USB, result); + result.creation.saw_create = result.creation.saw_create && + event.u.create2.rd_size == options.profile.report_descriptor.size(); if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) { result.saw_dualshock4_usb_input = @@ -1751,8 +1722,8 @@ namespace lvh::detail::test { gamepad.set_output_callback([&result](const GamepadOutput &output) { if (output.kind == GamepadOutputKind::rumble) { - ++result.output_callback_count; - result.last_output = output; + ++result.output.callback_count; + result.output.last = output; } }); @@ -1824,22 +1795,7 @@ namespace lvh::detail::test { options.metadata.stable_id = "02:03:04:05:06:07"; UhidGamepad gamepad {descriptors[0]}; - uhid_event event {}; - result.create_status = create_started_uhid_gamepad( - gamepad, - 11, - options, - descriptors[1], - event, - result.saw_create, - result.create_waited_for_start - ); - if (result.saw_create) { - result.saw_create = event.u.create2.vendor == options.profile.vendor_id && - event.u.create2.product == options.profile.product_id && - event.u.create2.bus == BUS_BLUETOOTH; - result.created_name = reinterpret_cast(event.u.create2.name); - } + auto event = create_started_profile_uhid_gamepad(gamepad, 11, options, descriptors[1], BUS_BLUETOOTH, result); if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) { const auto report_size = static_cast(event.u.input2.size); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index f80c9c0..0c44537 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -764,43 +764,43 @@ TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); - EXPECT_TRUE(result.saw_create); - EXPECT_TRUE(result.create_waited_for_start); - EXPECT_EQ(result.created_name, lvh::profiles::xbox_360().name); + EXPECT_TRUE(result.creation.saw_create); + EXPECT_TRUE(result.creation.waited_for_start); + EXPECT_EQ(result.creation.name, lvh::profiles::xbox_360().name); EXPECT_TRUE(result.saw_input); EXPECT_TRUE(result.saw_get_report_reply); EXPECT_TRUE(result.saw_set_report_reply); EXPECT_TRUE(result.saw_destroy); - EXPECT_GE(result.output_callback_count, 2U); - EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); - EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); - EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); + EXPECT_GE(result.output.callback_count, 2U); + EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.output.last.low_frequency_rumble, 0x5678); + EXPECT_EQ(result.output.last.high_frequency_rumble, 0x1234); } TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) { const auto result = lvh::detail::test::linux_dualsense_uhid_socketpair_reports(); EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); - EXPECT_TRUE(result.saw_create); - EXPECT_TRUE(result.create_waited_for_start); - EXPECT_EQ(result.created_name, "Wireless Controller"); + EXPECT_TRUE(result.creation.saw_create); + EXPECT_TRUE(result.creation.waited_for_start); + EXPECT_EQ(result.creation.name, "Wireless Controller"); 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); + ASSERT_GE(result.output.callback_count, 1U); + EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.output.last.low_frequency_rumble, 0x5656); + EXPECT_EQ(result.output.last.high_frequency_rumble, 0x1212); } TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) { const auto result = lvh::detail::test::linux_dualsense_bluetooth_uhid_socketpair_reports(); EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); - EXPECT_TRUE(result.saw_create); - EXPECT_TRUE(result.create_waited_for_start); - EXPECT_EQ(result.created_name, "Wireless Controller"); + EXPECT_TRUE(result.creation.saw_create); + EXPECT_TRUE(result.creation.waited_for_start); + EXPECT_EQ(result.creation.name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualsense_bluetooth_input); EXPECT_TRUE(result.saw_dualsense_pairing); EXPECT_TRUE(result.saw_dualsense_feature_crc); @@ -810,27 +810,27 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) { const auto result = lvh::detail::test::linux_dualshock4_uhid_socketpair_reports(); EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); - EXPECT_TRUE(result.saw_create); - EXPECT_TRUE(result.create_waited_for_start); - EXPECT_EQ(result.created_name, "Wireless Controller"); + EXPECT_TRUE(result.creation.saw_create); + EXPECT_TRUE(result.creation.waited_for_start); + EXPECT_EQ(result.creation.name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualshock4_usb_input); 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); + ASSERT_GE(result.output.callback_count, 1U); + EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.output.last.low_frequency_rumble, 0x5656); + EXPECT_EQ(result.output.last.high_frequency_rumble, 0x1212); } TEST_F(LinuxBackendTest, SocketpairBackedDualShock4BluetoothFramesReports) { const auto result = lvh::detail::test::linux_dualshock4_bluetooth_uhid_socketpair_reports(); EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); - EXPECT_TRUE(result.saw_create); - EXPECT_TRUE(result.create_waited_for_start); - EXPECT_EQ(result.created_name, "Wireless Controller"); + EXPECT_TRUE(result.creation.saw_create); + EXPECT_TRUE(result.creation.waited_for_start); + EXPECT_EQ(result.creation.name, "Wireless Controller"); EXPECT_TRUE(result.saw_dualshock4_bluetooth_input); EXPECT_TRUE(result.saw_dualshock4_calibration); EXPECT_TRUE(result.saw_dualshock4_pairing); diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 729b168..f0074d6 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -53,6 +53,7 @@ namespace { using LibinputContext = std::unique_ptr; using LibinputEvent = std::unique_ptr; using SdlGameController = std::unique_ptr; + constexpr std::string_view playstation_uhid_name = "Wireless Controller"; /** * @brief SDL-visible gamepad case. @@ -108,6 +109,10 @@ namespace { return std::format("libvirtualhid {} {}", suffix, ::getpid()); } + bool is_playstation_profile(lvh::GamepadProfileKind kind) { + return kind == lvh::GamepadProfileKind::dualshock4 || kind == lvh::GamepadProfileKind::dualsense; + } + std::optional read_first_line(const std::filesystem::path &path) { std::ifstream file {path}; if (!file) { @@ -467,12 +472,13 @@ namespace { return false; } - void configure_sdl_hidapi_hints() { + void configure_sdl_hidapi_hints(bool enable_playstation_hidapi) { SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); - SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4", "1"); + const auto *playstation_hidapi = enable_playstation_hidapi ? "1" : "0"; + SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4", playstation_hidapi); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4_RUMBLE", "1"); - SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", "1"); + SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", playstation_hidapi); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5_RUMBLE", "1"); } @@ -487,7 +493,12 @@ namespace { template void run_sdl_gamepad_test(const SdlGamepadConsumerCase &test_case, Uint32 init_flags, TestBody test_body) { - configure_sdl_hidapi_hints(); + const auto playstation_profile = is_playstation_profile(test_case.profile.gamepad_kind); + // GitHub's Linux runners expose UHID-created PlayStation input nodes through + // evdev but do not provide the hidraw hotplug path SDL's native driver needs. + // Keep this integration test on the same evdev route it covered before the + // backend began publishing Sony's native product name. + configure_sdl_hidapi_hints(!playstation_profile); ASSERT_EQ(SDL_Init(init_flags), 0) << SDL_GetError(); ScopeExit sdl_quit {[]() { SDL_Quit(); @@ -500,7 +511,9 @@ namespace { const auto expected_profile = [&test_case]() { auto profile = test_case.profile; - profile.name = unique_device_name(test_case.name_suffix); + profile.name = is_playstation_profile(profile.gamepad_kind) ? + std::string {playstation_uhid_name} : + unique_device_name(test_case.name_suffix); if (test_case.expected_vendor_id.has_value()) { profile.vendor_id = *test_case.expected_vendor_id; } From 222e00c5946b774a16f6612087902046254757d5 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:41:06 -0400 Subject: [PATCH 08/10] refactor(linux): satisfy UHID helper analysis Make the event reader const and scope the startup lock through an if initializer, preserving the existing synchronization while clearing the two remaining PR Sonar findings. --- src/platform/linux/uhid_backend.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index fc05cf6..ab85885 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -2944,7 +2944,7 @@ namespace lvh::detail { stop, }; - ReadEventResult read_event(uhid_event &event) { + ReadEventResult read_event(uhid_event &event) const { pollfd descriptor {}; descriptor.fd = fd_; descriptor.events = POLLIN; @@ -3021,13 +3021,11 @@ namespace lvh::detail { } OperationStatus wait_for_start() { - std::unique_lock lock {lifecycle_mutex_}; - if (!lifecycle_condition_.wait_for(lock, uhid_start_timeout, [this]() { + if (std::unique_lock lock {lifecycle_mutex_}; !lifecycle_condition_.wait_for(lock, uhid_start_timeout, [this]() { return started_ || reader_exited_; })) { return OperationStatus::failure(ErrorCode::backend_failure, "timed out waiting for UHID_START"); - } - if (!started_) { + } else if (!started_) { return OperationStatus::failure(ErrorCode::backend_failure, "UHID reader stopped before UHID_START"); } return OperationStatus::success(); From 10ce2948295bfe2c82a3c81a761d1065b5ca7553 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:04:40 -0400 Subject: [PATCH 09/10] ci(linux): permit native PlayStation HID access Match libvirtualhid UHID devices by their sysfs physical path so SDL HIDAPI can open controllers exposed with Sony's native product name. Restore PS4 and PS5 HIDAPI consumer coverage and grant the same scoped access to their input event nodes. --- .github/workflows/ci-build.yml | 3 ++- tests/unit/test_linux_consumers.cpp | 14 ++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 2ac6c6c..1473894 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -181,7 +181,8 @@ jobs: echo "::warning::${kernel_modules_package} is unavailable; relying on the runner image kernel modules." fi sudo tee /etc/udev/rules.d/99-libvirtualhid-ci.rules >/dev/null <<'EOF' - SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ENV{HID_PHYS}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess" + SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{phys}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess" + SUBSYSTEM=="input", KERNEL=="event*", ATTRS{phys}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess" SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess" SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess" SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess" diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index f0074d6..675fa4f 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -472,13 +472,12 @@ namespace { return false; } - void configure_sdl_hidapi_hints(bool enable_playstation_hidapi) { + void configure_sdl_hidapi_hints() { SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); - const auto *playstation_hidapi = enable_playstation_hidapi ? "1" : "0"; - SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4", playstation_hidapi); + SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4", "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4_RUMBLE", "1"); - SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", playstation_hidapi); + SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", "1"); SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5_RUMBLE", "1"); } @@ -493,12 +492,7 @@ namespace { template void run_sdl_gamepad_test(const SdlGamepadConsumerCase &test_case, Uint32 init_flags, TestBody test_body) { - const auto playstation_profile = is_playstation_profile(test_case.profile.gamepad_kind); - // GitHub's Linux runners expose UHID-created PlayStation input nodes through - // evdev but do not provide the hidraw hotplug path SDL's native driver needs. - // Keep this integration test on the same evdev route it covered before the - // backend began publishing Sony's native product name. - configure_sdl_hidapi_hints(!playstation_profile); + configure_sdl_hidapi_hints(); ASSERT_EQ(SDL_Init(init_flags), 0) << SDL_GetError(); ScopeExit sdl_quit {[]() { SDL_Quit(); From d1276519612fc154232b2fe502d629ef7a593ca7 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:07:12 -0400 Subject: [PATCH 10/10] docs(linux): document UHID permission matching Document the backend-owned physical path that remains stable when libvirtualhid is linked directly into a host, and recommend it for native-named hidraw and input event nodes. --- docs/platform-support.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/platform-support.md b/docs/platform-support.md index 0695d4e..d42cbb2 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -184,9 +184,19 @@ KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess" ``` -Consuming applications may also install name-matched rules for stable virtual -device names when generated `hidraw` or `input` nodes must be accessible to the -session user: +UHID gamepads use a stable `libvirtualhid/uhid/*` physical path even when the +library is compiled directly into a consuming application. Match that path for +generated `hidraw` and input event nodes because native profiles such as +DualShock 4 and DualSense intentionally do not retain the application's product +name: + +```udev +KERNEL=="hidraw*", ATTRS{phys}=="libvirtualhid/uhid/*", GROUP="input", MODE="0660", TAG+="uaccess" +SUBSYSTEM=="input", KERNEL=="event*", ATTRS{phys}=="libvirtualhid/uhid/*", GROUP="input", MODE="0660", TAG+="uaccess" +``` + +Consuming applications may additionally install name-matched rules for stable +virtual device names, including uinput-backed gamepads: ```udev KERNEL=="hidraw*", ATTRS{name}=="Your App Controller*", GROUP="input", MODE="0660", TAG+="uaccess"