Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ sudo udevadm trigger --subsystem-match=input
The consuming user may need membership in the `input` group and a new login
session. Desktop logins may instead receive access through `uaccess`.

Virtual Xbox One and Series controllers use `uhid` with a kernel-only vendor
marker so physical-controller drivers such as `xpadneo` cannot intercept
Guide-button chords. SDL and the evdev/hidraw interfaces retain the Xbox
vendor and product IDs. Trigger rumble and battery reporting remain available;
no change to xpadneo's physical-controller settings is required.

## FreeBSD

The `uinput` kernel module and a writable `/dev/input/uinput` or
Expand Down
21 changes: 20 additions & 1 deletion src/platform/linux/uhid_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,25 @@ namespace lvh::detail {
}

#if defined(__linux__)
/**
* @brief Select a kernel HID vendor identity that keeps virtual Xbox devices on hid-generic.
*
* Linux matches HID drivers and names their sysfs devices using 32-bit vendor
* IDs. Mark the upper word so physical Xbox drivers and xpadneo's udev rules
* cannot claim these devices and consume Guide chords. The evdev and hidraw
* ABIs, including SDL HIDAPI's uevent parser, expose the original lower
* 16-bit vendor ID, preserving Xbox discovery, rumble, and battery reports.
*
* @param profile UHID transport profile.
* @return Kernel vendor ID with the virtual marker for Xbox One and Series.
*/
std::uint32_t uhid_vendor_id(const DeviceProfile &profile) {
constexpr std::uint32_t virtual_xbox_vendor_marker = 0x00010000U;
return is_xbox_uhid_profile(profile.gamepad_kind) ?
virtual_xbox_vendor_marker | profile.vendor_id :
profile.vendor_id;
}

std::uint16_t to_uhid_bus(const DeviceProfile &profile) {
// Linux SDL2 HIDAPI requires BUS_USB hidraw devices to have a physical USB
// parent in sysfs. UHID devices do not, so expose transports handled by
Expand Down Expand Up @@ -3162,7 +3181,7 @@ namespace lvh::detail {
copy_string(request.uniq, unique_id_);
request.rd_size = static_cast<std::uint16_t>(transport_profile.report_descriptor.size());
request.bus = to_uhid_bus(transport_profile);
request.vendor = transport_profile.vendor_id;
request.vendor = uhid_vendor_id(transport_profile);
request.product = transport_profile.product_id;
request.version = transport_profile.version;
std::memcpy(
Expand Down
98 changes: 98 additions & 0 deletions tests/consumers/linux_sdl3_xbox_consumer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
#include <utility>

// platform includes
#include <fcntl.h>
#include <linux/hidraw.h>
#include <linux/input.h>
#include <sys/ioctl.h>
#include <unistd.h>

// lib includes
Expand Down Expand Up @@ -213,6 +217,43 @@ namespace {
return false;
}

/**
* @brief Verify kernel driver isolation and the consumer-visible Xbox HID identity.
*
* @param gamepad Virtual gamepad with an accessible hidraw node.
* @param test_case Expected Xbox transport identity.
* @return Whether hid-generic owns the marked device and hidraw retains the Xbox IDs.
*/
bool validate_kernel_identity(const lvh::Gamepad &gamepad, const ProfileCase &test_case) {
for (const auto &node : gamepad.device_nodes()) {
if (node.kind != lvh::DeviceNodeKind::hidraw) {
continue;
}
const auto fd = ::open(node.path.c_str(), O_RDONLY | O_CLOEXEC);
if (fd < 0) {
return false;
}
hidraw_devinfo info {};
const auto status = ::ioctl(fd, HIDIOCGRAWINFO, &info);
::close(fd);
if (status < 0 || info.bustype != BUS_BLUETOOTH || info.vendor != 0x045E || info.product != test_case.product_id) {
return false;
}

std::error_code error;
const auto hid_device = std::filesystem::canonical(
std::filesystem::path {"/sys/class/hidraw"} / std::filesystem::path {node.path}.filename() / "device",
error
);
if (error || !hid_device.filename().string().starts_with("0005:1045E:")) {
return false;
}
const auto driver = std::filesystem::canonical(hid_device / "driver", error);
return !error && driver.filename() == "hid-generic";
}
return false;
}

SDL_JoystickID wait_for_gamepad(const ProfileCase &test_case) {
const auto deadline = std::chrono::steady_clock::now() + 5s;
while (std::chrono::steady_clock::now() < deadline) {
Expand Down Expand Up @@ -362,6 +403,53 @@ namespace {
return EXIT_SUCCESS;
}

/**
* @brief Check that SDL receives both chord buttons and their independent releases.
*
* @param virtual_gamepad Virtual Xbox controller submitting input.
* @param gamepad SDL consumer of the virtual controller.
* @param logical_button Button held together with Guide.
* @param sdl_button Corresponding SDL button.
* @return EXIT_SUCCESS when the complete chord sequence is observed.
*/
int validate_guide_chord(
lvh::Gamepad &virtual_gamepad,
SDL_Gamepad *gamepad,
lvh::GamepadButton logical_button,
SDL_GamepadButton sdl_button
) {
lvh::GamepadState state;
state.buttons.set(guide);
if (!virtual_gamepad.submit(state).ok() || !wait_for_button(gamepad, SDL_GAMEPAD_BUTTON_GUIDE, true)) {
return fail("SDL3 did not receive the held Guide button");
}

state.buttons.set(logical_button);
if (!virtual_gamepad.submit(state).ok() || !wait_for_button(gamepad, sdl_button, true) || !SDL_GetGamepadButton(gamepad, SDL_GAMEPAD_BUTTON_GUIDE)) {
return fail("SDL3 did not receive both Guide chord buttons");
}

state.buttons.set(guide, false);
if (!virtual_gamepad.submit(state).ok() || !wait_for_button(gamepad, SDL_GAMEPAD_BUTTON_GUIDE, false) || !SDL_GetGamepadButton(gamepad, sdl_button)) {
return fail("SDL3 did not preserve the other button when Guide was released");
}

state.buttons.set(guide);
if (!virtual_gamepad.submit(state).ok() || !wait_for_button(gamepad, SDL_GAMEPAD_BUTTON_GUIDE, true) || !SDL_GetGamepadButton(gamepad, sdl_button)) {
return fail("SDL3 did not receive Guide while the other chord button was held");
}

state.buttons.set(logical_button, false);
if (!virtual_gamepad.submit(state).ok() || !wait_for_button(gamepad, sdl_button, false) || !SDL_GetGamepadButton(gamepad, SDL_GAMEPAD_BUTTON_GUIDE)) {
return fail("SDL3 did not preserve Guide when the other chord button was released");
}

if (!virtual_gamepad.submit({}).ok() || !wait_for_button(gamepad, SDL_GAMEPAD_BUTTON_GUIDE, false)) {
return fail("SDL3 did not release Guide at the end of the chord");
}
return EXIT_SUCCESS;
}

int validate_buttons(lvh::Gamepad &virtual_gamepad, SDL_Gamepad *gamepad, const ProfileCase &test_case) {
for (const auto &[logical_button, sdl_button] : button_cases) {
lvh::GamepadState state;
Expand All @@ -376,6 +464,12 @@ namespace {
}
}

for (const auto &[logical_button, sdl_button] : button_cases) {
if (logical_button != guide && validate_guide_chord(virtual_gamepad, gamepad, logical_button, sdl_button) != EXIT_SUCCESS) {
return fail(std::format("SDL3 Guide chord failed for logical button {}", std::to_underlying(logical_button)));
}
}

if (test_case.profile.gamepad_kind == lvh::GamepadProfileKind::xbox_series) {
lvh::GamepadState state;
state.buttons.set(misc1);
Expand Down Expand Up @@ -496,6 +590,10 @@ namespace {
log_kernel_hid_state();
return fail("The Xbox UHID device did not expose an accessible hidraw node");
}
if (!validate_kernel_identity(*created.gamepad, test_case)) {
log_kernel_hid_state();
return fail("The virtual Xbox device did not retain hid-generic and the consumer HID identity");
}

const auto output = std::make_shared<OutputCapture>();
created.gamepad->set_output_callback([output](const lvh::GamepadOutput &gamepad_output) {
Expand Down
8 changes: 4 additions & 4 deletions tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,14 @@ namespace lvh::detail::test {
std::uint16_t bus = 0;

/**
* @brief USB vendor id carried by the observed create event.
* @brief Full kernel HID vendor id carried by the observed create event.
*/
std::uint16_t vendor_id = 0;
std::uint32_t vendor_id = 0;

/**
* @brief USB product id carried by the observed create event.
* @brief Full kernel HID product id carried by the observed create event.
*/
std::uint16_t product_id = 0;
std::uint32_t product_id = 0;

/**
* @brief Device version carried by the observed create event.
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/linux_backend_test_hooks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,7 @@ namespace lvh::detail::test {
);
if (result.creation.saw_create) {
const auto transport_profile = uhid_transport_profile(options.profile, options.metadata.has_battery);
result.creation.saw_create = event.u.create2.vendor == transport_profile.vendor_id &&
result.creation.saw_create = event.u.create2.vendor == uhid_vendor_id(transport_profile) &&
event.u.create2.product == transport_profile.product_id &&
event.u.create2.version == transport_profile.version &&
event.u.create2.bus == expected_bus;
Expand Down Expand Up @@ -1790,7 +1790,7 @@ namespace lvh::detail::test {
event.u.create2.rd_data[1] == 0x01U && event.u.create2.rd_data[2] == 0x09U &&
event.u.create2.rd_data[3] == 0x05U;
result.xbox.saw_bluetooth_identity =
event.u.create2.vendor == 0x045EU &&
static_cast<std::uint16_t>(event.u.create2.vendor) == 0x045EU &&
event.u.create2.product == (series ? xbox_series_uinput_product_id : xbox_wireless_uinput_product_id) &&
event.u.create2.version == xbox_bluetooth_version && event.u.create2.bus == BUS_BLUETOOTH;
}
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/test_linux_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,12 @@ TEST_F(LinuxBackendTest, XboxOneAndSeriesPreferBluetoothUhidWithUinputFallback)
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_TRUE(result.xbox.saw_transport_descriptor);
// The kernel must not match Microsoft's physical-device driver IDs, while
// the 16-bit input and hidraw identities must still identify an Xbox.
EXPECT_EQ(result.creation.vendor_id, 0x0001045EU);
EXPECT_NE(result.creation.vendor_id, 0x045EU);
EXPECT_EQ(static_cast<std::uint16_t>(result.creation.vendor_id), 0x045EU);
EXPECT_EQ(result.creation.product_id, kind == xbox_series ? 0x0B13U : 0x0B20U);
EXPECT_TRUE(result.xbox.saw_battery_descriptor);
EXPECT_TRUE(result.xbox.saw_canonical_evdev_axes);
EXPECT_TRUE(result.xbox.saw_gamepad_application_usage);
Expand Down Expand Up @@ -1003,6 +1009,8 @@ TEST_F(LinuxBackendTest, SocketpairBackedSwitchProUsesNativeUhidProtocol) {
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, lvh::profiles::switch_pro().name);
EXPECT_EQ(result.creation.vendor_id, 0x057EU);
EXPECT_EQ(result.creation.product_id, 0x2009U);
EXPECT_EQ(result.creation.physical_id, "libvirtualhid/uhid/057e:2009/12");
EXPECT_EQ(result.creation.unique_id, "057e:2009/streaming-host-gamepad-0");
EXPECT_TRUE(result.switch_pro.saw_subcommand_reply);
Expand Down
98 changes: 96 additions & 2 deletions tests/unit/test_linux_consumers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ namespace {
using SdlGameController = std::unique_ptr<SDL_GameController, void (*)(SDL_GameController *)>;
constexpr std::string_view playstation_uhid_name = "Wireless Controller";

/**
* @brief Check the xpadneo prerequisite for native Guide-chord tests.
*
* @return Whether the loaded driver has Guide shift mode enabled.
*/
bool has_xpadneo_guide_shift_mode() {
std::ifstream parameter {"/sys/module/hid_xpadneo/parameters/disable_shift_mode"};
std::string disabled;
parameter >> disabled;
return disabled == "N" || disabled == "0";
}

/**
* @brief SDL-visible gamepad case.
*/
Expand Down Expand Up @@ -474,15 +486,24 @@ namespace {
return false;
}

/**
* @brief Wait for SDL to observe a controller button press or release.
*
* @param controller Controller receiving virtual input.
* @param expected_button Button to inspect.
* @param pressed Expected button state.
* @return Whether SDL observed the expected state before the timeout.
*/
bool wait_for_sdl_controller_button(
SDL_GameController *controller,
SDL_GameControllerButton expected_button
SDL_GameControllerButton expected_button,
bool pressed = true
) {
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) {
if ((SDL_GameControllerGetButton(controller, expected_button) != 0) == pressed) {
return true;
}
std::this_thread::sleep_for(std::chrono::milliseconds {20});
Expand Down Expand Up @@ -754,6 +775,49 @@ namespace {
);
}

/**
* @brief Verify that a Guide chord preserves both buttons through independent releases.
*
* @param controller SDL controller receiving virtual input.
* @param gamepad Virtual gamepad submitting the chord.
* @param logical_button Button held together with Guide.
* @param sdl_button Corresponding SDL button.
*/
void expect_sdl_guide_chord(
SDL_GameController *controller,
lvh::Gamepad &gamepad,
lvh::GamepadButton logical_button,
SDL_GameControllerButton sdl_button
) {
lvh::GamepadState state;
state.buttons.set(lvh::GamepadButton::guide);
ASSERT_TRUE(gamepad.submit(state).ok());
ASSERT_TRUE(wait_for_sdl_controller_button(controller, SDL_CONTROLLER_BUTTON_GUIDE));

state.buttons.set(logical_button);
ASSERT_TRUE(gamepad.submit(state).ok());
ASSERT_TRUE(wait_for_sdl_controller_button(controller, sdl_button));
EXPECT_NE(SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_GUIDE), 0);

state.buttons.set(lvh::GamepadButton::guide, false);
ASSERT_TRUE(gamepad.submit(state).ok());
ASSERT_TRUE(wait_for_sdl_controller_button(controller, SDL_CONTROLLER_BUTTON_GUIDE, false));
EXPECT_NE(SDL_GameControllerGetButton(controller, sdl_button), 0);

state.buttons.set(lvh::GamepadButton::guide);
ASSERT_TRUE(gamepad.submit(state).ok());
ASSERT_TRUE(wait_for_sdl_controller_button(controller, SDL_CONTROLLER_BUTTON_GUIDE));
EXPECT_NE(SDL_GameControllerGetButton(controller, sdl_button), 0);

state.buttons.set(logical_button, false);
ASSERT_TRUE(gamepad.submit(state).ok());
ASSERT_TRUE(wait_for_sdl_controller_button(controller, sdl_button, false));
EXPECT_NE(SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_GUIDE), 0);

ASSERT_TRUE(gamepad.submit({}).ok());
ASSERT_TRUE(wait_for_sdl_controller_button(controller, SDL_CONTROLLER_BUTTON_GUIDE, false));
}

void exercise_sdl_canonical_gamepad_controller(
const SdlGamepadConsumerCase &test_case,
const lvh::DeviceProfile &expected_profile,
Expand Down Expand Up @@ -808,6 +872,13 @@ namespace {
}
}

for (const auto &[logical_button, sdl_button] : button_cases) {
if (logical_button != guide) {
SCOPED_TRACE(std::format("Guide chord with button {}", std::to_underlying(logical_button)));
ASSERT_NO_FATAL_FAILURE(expect_sdl_guide_chord(controller.get(), gamepad, logical_button, sdl_button));
}
}

lvh::GamepadState trigger_state;
trigger_state.left_trigger = 0.25F;
trigger_state.right_trigger = 0.75F;
Expand Down Expand Up @@ -1036,6 +1107,29 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxOneCanonicalButtons) {
});
}

TEST_F(LinuxConsumerTest, SdlSeesXboxGuideChordsWithXpadneoShiftMode) {
ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));
if (!has_xpadneo_guide_shift_mode()) {
GTEST_SKIP() << "This test requires loaded xpadneo with Guide shift mode enabled";
}

for (const auto kind : {lvh::GamepadProfileKind::xbox_one, lvh::GamepadProfileKind::xbox_series}) {
const auto series = kind == lvh::GamepadProfileKind::xbox_series;
const SdlGamepadConsumerCase test_case {
.profile = *lvh::profiles::gamepad_profile(kind),
.name_suffix = "SDL Xbox Guide Chords",
.stable_id = "libvirtualhid-sdl-xpadneo-guide-test",
.expected_product_id = static_cast<std::uint16_t>(series ? 0x0B13 : 0x0B20),
.minimum_buttons = series ? 16 : 15,
.minimum_axes = 6,
.require_sdl_rumble = true,
.require_trigger_rumble = true,
.require_battery = true,
};
ASSERT_NO_FATAL_FAILURE(run_sdl_canonical_gamepad_test(test_case));
}
}

TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) {
ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));

Expand Down
Loading