mirror of
https://github.com/LizardByte/libvirtualhid.git
synced 2026-11-02 10:06:44 +09:00
624 lines
22 KiB
C++
624 lines
22 KiB
C++
/**
|
|
* @file tests/consumers/linux_sdl3_xbox_consumer.cpp
|
|
* @brief SDL3 consumer probe for Linux Xbox UHID gamepads.
|
|
*/
|
|
|
|
// standard includes
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <format>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <system_error>
|
|
#include <thread>
|
|
#include <utility>
|
|
|
|
// platform includes
|
|
#include <fcntl.h>
|
|
#include <linux/hidraw.h>
|
|
#include <linux/input.h>
|
|
#include <sys/ioctl.h>
|
|
#include <unistd.h>
|
|
|
|
// lib includes
|
|
#include <libvirtualhid/libvirtualhid.hpp>
|
|
#include <SDL3/SDL.h>
|
|
#include <SDL3/SDL_hidapi.h>
|
|
|
|
namespace {
|
|
|
|
using namespace std::chrono_literals;
|
|
using SdlGamepad = std::unique_ptr<SDL_Gamepad, void (*)(SDL_Gamepad *)>;
|
|
|
|
struct ProfileCase {
|
|
lvh::DeviceProfile profile;
|
|
std::uint16_t product_id;
|
|
std::string_view identity_token;
|
|
};
|
|
|
|
struct OutputCapture {
|
|
std::mutex mutex;
|
|
std::atomic_bool ordinary_rumble = false;
|
|
std::atomic_bool trigger_rumble = false;
|
|
std::uint16_t low_frequency = 0;
|
|
std::uint16_t high_frequency = 0;
|
|
std::uint16_t left_trigger = 0;
|
|
std::uint16_t right_trigger = 0;
|
|
};
|
|
|
|
struct ButtonCase {
|
|
lvh::GamepadButton logical_button;
|
|
SDL_GamepadButton sdl_button;
|
|
};
|
|
|
|
using enum lvh::GamepadButton;
|
|
constexpr std::array button_cases {
|
|
ButtonCase {a, SDL_GAMEPAD_BUTTON_SOUTH},
|
|
ButtonCase {b, SDL_GAMEPAD_BUTTON_EAST},
|
|
ButtonCase {x, SDL_GAMEPAD_BUTTON_WEST},
|
|
ButtonCase {y, SDL_GAMEPAD_BUTTON_NORTH},
|
|
ButtonCase {left_shoulder, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER},
|
|
ButtonCase {right_shoulder, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER},
|
|
ButtonCase {back, SDL_GAMEPAD_BUTTON_BACK},
|
|
ButtonCase {start, SDL_GAMEPAD_BUTTON_START},
|
|
ButtonCase {guide, SDL_GAMEPAD_BUTTON_GUIDE},
|
|
ButtonCase {left_stick, SDL_GAMEPAD_BUTTON_LEFT_STICK},
|
|
ButtonCase {right_stick, SDL_GAMEPAD_BUTTON_RIGHT_STICK},
|
|
ButtonCase {dpad_up, SDL_GAMEPAD_BUTTON_DPAD_UP},
|
|
ButtonCase {dpad_down, SDL_GAMEPAD_BUTTON_DPAD_DOWN},
|
|
ButtonCase {dpad_left, SDL_GAMEPAD_BUTTON_DPAD_LEFT},
|
|
ButtonCase {dpad_right, SDL_GAMEPAD_BUTTON_DPAD_RIGHT},
|
|
};
|
|
|
|
class SdlSubsystem {
|
|
public:
|
|
SdlSubsystem() {
|
|
SDL_SetLogPriority(SDL_LOG_CATEGORY_INPUT, SDL_LOG_PRIORITY_DEBUG);
|
|
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
|
|
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1");
|
|
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_XBOX, "1");
|
|
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_XBOX_ONE, "1");
|
|
initialized_ = SDL_Init(SDL_INIT_GAMEPAD | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS);
|
|
}
|
|
|
|
SdlSubsystem(const SdlSubsystem &) = delete;
|
|
SdlSubsystem &operator=(const SdlSubsystem &) = delete;
|
|
|
|
~SdlSubsystem() {
|
|
if (initialized_) {
|
|
SDL_Quit();
|
|
}
|
|
}
|
|
|
|
bool initialized() const {
|
|
return initialized_;
|
|
}
|
|
|
|
private:
|
|
bool initialized_ = false;
|
|
};
|
|
|
|
int fail(std::string_view message) {
|
|
std::cerr << message;
|
|
if (const auto *error = SDL_GetError(); error != nullptr && *error != '\0') {
|
|
std::cerr << ": " << error;
|
|
}
|
|
std::cerr << '\n';
|
|
return EXIT_FAILURE;
|
|
}
|
|
|
|
std::string_view nullable_text(const char *text) {
|
|
return text == nullptr ? "<null>" : text;
|
|
}
|
|
|
|
void log_discovery_state() {
|
|
int joystick_count = 0;
|
|
auto *joysticks = SDL_GetJoysticks(&joystick_count);
|
|
std::cerr << "SDL3 joystick count: " << joystick_count << '\n';
|
|
for (int index = 0; index < joystick_count; ++index) {
|
|
const auto id = joysticks[index];
|
|
std::cerr << std::format(
|
|
" id={} {:04x}:{:04x} gamepad={} name={} path={}\n",
|
|
id,
|
|
SDL_GetJoystickVendorForID(id),
|
|
SDL_GetJoystickProductForID(id),
|
|
SDL_IsGamepad(id),
|
|
nullable_text(SDL_GetJoystickNameForID(id)),
|
|
nullable_text(SDL_GetJoystickPathForID(id))
|
|
);
|
|
}
|
|
SDL_free(joysticks);
|
|
|
|
auto *devices = SDL_hid_enumerate(0x045E, 0);
|
|
std::cerr << "SDL3 Microsoft HIDAPI devices:\n";
|
|
for (auto *device = devices; device != nullptr; device = device->next) {
|
|
std::cerr << std::format(
|
|
" {:04x}:{:04x} bus={} usage={:04x}:{:04x} path={}\n",
|
|
device->vendor_id,
|
|
device->product_id,
|
|
std::to_underlying(device->bus_type),
|
|
device->usage_page,
|
|
device->usage,
|
|
nullable_text(device->path)
|
|
);
|
|
}
|
|
SDL_hid_free_enumeration(devices);
|
|
}
|
|
|
|
void log_device_nodes(const lvh::Gamepad &gamepad) {
|
|
std::cerr << "libvirtualhid device nodes:\n";
|
|
for (const auto &node : gamepad.device_nodes()) {
|
|
errno = 0;
|
|
const auto accessible = ::access(node.path.c_str(), R_OK | W_OK) == 0;
|
|
const auto access_error = errno;
|
|
std::cerr << std::format(
|
|
" kind={} path={} read-write={} errno={}\n",
|
|
std::to_underlying(node.kind),
|
|
node.path,
|
|
accessible,
|
|
access_error
|
|
);
|
|
}
|
|
}
|
|
|
|
void log_sysfs_files(
|
|
std::string_view heading,
|
|
const std::filesystem::path &root,
|
|
const std::filesystem::path &relative_path
|
|
) {
|
|
std::cerr << heading << ":\n";
|
|
std::error_code error;
|
|
for (std::filesystem::directory_iterator it {root, error}, end; !error && it != end; it.increment(error)) {
|
|
const auto path = it->path() / relative_path;
|
|
std::ifstream file {path};
|
|
if (!file) {
|
|
continue;
|
|
}
|
|
|
|
std::cerr << " " << path << ":\n";
|
|
std::string line;
|
|
while (std::getline(file, line)) {
|
|
std::cerr << " " << line << '\n';
|
|
}
|
|
}
|
|
if (error) {
|
|
std::cerr << " scan failed: " << error.message() << '\n';
|
|
}
|
|
}
|
|
|
|
void log_kernel_hid_state() {
|
|
log_sysfs_files("Kernel HID devices", "/sys/bus/hid/devices", "uevent");
|
|
log_sysfs_files("Kernel hidraw devices", "/sys/class/hidraw", "device/uevent");
|
|
log_sysfs_files("Kernel input device names", "/sys/class/input", "device/name");
|
|
}
|
|
|
|
bool wait_for_accessible_hidraw_node(const lvh::Gamepad &gamepad) {
|
|
const auto deadline = std::chrono::steady_clock::now() + 5s;
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
for (const auto &node : gamepad.device_nodes()) {
|
|
if (
|
|
node.kind == lvh::DeviceNodeKind::hidraw &&
|
|
::access(node.path.c_str(), R_OK | W_OK) == 0
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
std::this_thread::sleep_for(50ms);
|
|
}
|
|
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) {
|
|
SDL_UpdateGamepads();
|
|
SDL_PumpEvents();
|
|
|
|
int count = 0;
|
|
auto *gamepads = SDL_GetGamepads(&count);
|
|
for (int index = 0; index < count; ++index) {
|
|
const auto id = gamepads[index];
|
|
if (
|
|
SDL_GetGamepadVendorForID(id) == 0x045EU &&
|
|
SDL_GetGamepadProductForID(id) == test_case.product_id
|
|
) {
|
|
SDL_free(gamepads);
|
|
return id;
|
|
}
|
|
}
|
|
SDL_free(gamepads);
|
|
std::this_thread::sleep_for(50ms);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
bool wait_for_button(SDL_Gamepad *gamepad, SDL_GamepadButton button, bool pressed) {
|
|
const auto deadline = std::chrono::steady_clock::now() + 3s;
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
SDL_UpdateGamepads();
|
|
SDL_PumpEvents();
|
|
if (SDL_GetGamepadButton(gamepad, button) == pressed) {
|
|
return true;
|
|
}
|
|
std::this_thread::sleep_for(20ms);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool wait_for_axis(
|
|
SDL_Gamepad *gamepad,
|
|
SDL_GamepadAxis axis,
|
|
std::int16_t expected,
|
|
std::int16_t tolerance
|
|
) {
|
|
const auto deadline = std::chrono::steady_clock::now() + 3s;
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
SDL_UpdateGamepads();
|
|
SDL_PumpEvents();
|
|
if (std::abs(static_cast<int>(SDL_GetGamepadAxis(gamepad, axis)) - expected) <= tolerance) {
|
|
return true;
|
|
}
|
|
std::this_thread::sleep_for(20ms);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool wait_for_output(const std::atomic_bool &observed) {
|
|
const auto deadline = std::chrono::steady_clock::now() + 3s;
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
SDL_UpdateGamepads();
|
|
SDL_PumpEvents();
|
|
if (observed.load()) {
|
|
return true;
|
|
}
|
|
std::this_thread::sleep_for(20ms);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool validate_battery(lvh::Gamepad &virtual_gamepad, SDL_Gamepad *gamepad) {
|
|
lvh::GamepadState state;
|
|
state.battery = lvh::GamepadBattery {
|
|
.state = lvh::GamepadBatteryState::discharging,
|
|
.percentage = 50,
|
|
};
|
|
if (!virtual_gamepad.submit(state).ok()) {
|
|
return false;
|
|
}
|
|
|
|
const auto deadline = std::chrono::steady_clock::now() + 3s;
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
SDL_UpdateGamepads();
|
|
SDL_PumpEvents();
|
|
if (
|
|
auto percentage = -1;
|
|
SDL_GetGamepadPowerInfo(gamepad, &percentage) == SDL_POWERSTATE_ON_BATTERY &&
|
|
percentage == 40
|
|
) {
|
|
return true;
|
|
}
|
|
std::this_thread::sleep_for(20ms);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void capture_output(const std::shared_ptr<OutputCapture> &output, const lvh::GamepadOutput &gamepad_output) {
|
|
std::lock_guard lock {output->mutex};
|
|
if (
|
|
gamepad_output.kind == lvh::GamepadOutputKind::rumble &&
|
|
gamepad_output.low_frequency_rumble > 0U && gamepad_output.high_frequency_rumble > 0U
|
|
) {
|
|
output->low_frequency = gamepad_output.low_frequency_rumble;
|
|
output->high_frequency = gamepad_output.high_frequency_rumble;
|
|
output->ordinary_rumble = true;
|
|
} else if (
|
|
gamepad_output.kind == lvh::GamepadOutputKind::trigger_rumble &&
|
|
gamepad_output.left_trigger_rumble > 0U && gamepad_output.right_trigger_rumble > 0U
|
|
) {
|
|
output->left_trigger = gamepad_output.left_trigger_rumble;
|
|
output->right_trigger = gamepad_output.right_trigger_rumble;
|
|
output->trigger_rumble = true;
|
|
}
|
|
}
|
|
|
|
int validate_identity(SDL_JoystickID gamepad_id, const ProfileCase &test_case) {
|
|
// Linux hidapi leaves the release number unset for Bluetooth devices.
|
|
// Validate it when the SDL backend exposes the UHID version.
|
|
if (
|
|
const auto product_version = SDL_GetGamepadProductVersionForID(gamepad_id);
|
|
product_version != 0U && product_version != 0x0513U
|
|
) {
|
|
return fail("SDL3 observed the wrong Xbox Bluetooth product version");
|
|
}
|
|
if (
|
|
const auto *name = SDL_GetGamepadNameForID(gamepad_id);
|
|
name == nullptr || !std::string_view {name}.contains(test_case.identity_token)
|
|
) {
|
|
return fail(std::format("SDL3 exposed the wrong Xbox identity name: {}", name == nullptr ? "<null>" : name));
|
|
}
|
|
return EXIT_SUCCESS;
|
|
}
|
|
|
|
int validate_capabilities(SDL_Gamepad *gamepad) {
|
|
if (SDL_GetGamepadType(gamepad) != SDL_GAMEPAD_TYPE_XBOXONE) {
|
|
return fail("SDL3 did not classify the device as an Xbox One-family gamepad");
|
|
}
|
|
|
|
const auto properties = SDL_GetGamepadProperties(gamepad);
|
|
if (properties == 0U) {
|
|
return fail("SDL3 did not expose gamepad properties");
|
|
}
|
|
if (!SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false)) {
|
|
return fail("SDL3 did not advertise ordinary rumble");
|
|
}
|
|
if (!SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN, false)) {
|
|
return fail("SDL3 did not advertise independent trigger rumble");
|
|
}
|
|
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;
|
|
state.buttons.set(logical_button);
|
|
if (!virtual_gamepad.submit(state).ok() || !wait_for_button(gamepad, sdl_button, true)) {
|
|
return fail(std::format("SDL3 did not map logical button {}", std::to_underlying(logical_button)));
|
|
}
|
|
for (const auto &[other_logical_button, other_sdl_button] : button_cases) {
|
|
if (other_logical_button != logical_button && SDL_GetGamepadButton(gamepad, other_sdl_button)) {
|
|
return fail(std::format("SDL3 mapped logical button {} to multiple controls", std::to_underlying(logical_button)));
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
if (!virtual_gamepad.submit(state).ok() || !wait_for_button(gamepad, SDL_GAMEPAD_BUTTON_MISC1, true)) {
|
|
return fail("SDL3 did not map the Xbox Series Share button");
|
|
}
|
|
}
|
|
return EXIT_SUCCESS;
|
|
}
|
|
|
|
int validate_trigger_inputs(lvh::Gamepad &virtual_gamepad, SDL_Gamepad *gamepad) {
|
|
constexpr auto trigger_tolerance = std::int16_t {256};
|
|
constexpr auto left_trigger_expected = std::int16_t {SDL_JOYSTICK_AXIS_MAX / 4};
|
|
constexpr auto right_trigger_expected = std::int16_t {(SDL_JOYSTICK_AXIS_MAX * 3) / 4};
|
|
lvh::GamepadState trigger_state;
|
|
trigger_state.left_trigger = 0.25F;
|
|
trigger_state.right_trigger = 0.75F;
|
|
if (!virtual_gamepad.submit(trigger_state).ok()) {
|
|
return fail("Submitting Xbox trigger input failed");
|
|
}
|
|
if (!wait_for_axis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, left_trigger_expected, trigger_tolerance)) {
|
|
return fail(std::format("SDL3 mapped the left trigger to an unexpected value: {}", SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER)));
|
|
}
|
|
if (!wait_for_axis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, right_trigger_expected, trigger_tolerance)) {
|
|
return fail(std::format("SDL3 mapped the right trigger to an unexpected value: {}", SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)));
|
|
}
|
|
return EXIT_SUCCESS;
|
|
}
|
|
|
|
int validate_rumble(SDL_Gamepad *gamepad, const std::shared_ptr<OutputCapture> &output) {
|
|
if (!SDL_RumbleGamepad(gamepad, 0x5678U, 0x1234U, 1000U)) {
|
|
return fail("SDL3 ordinary rumble request failed");
|
|
}
|
|
if (!wait_for_output(output->ordinary_rumble)) {
|
|
return fail("SDL3 ordinary rumble did not reach the normalized output callback");
|
|
}
|
|
if (!SDL_RumbleGamepadTriggers(gamepad, 0x3456U, 0x789AU, 1000U)) {
|
|
return fail("SDL3 trigger-rumble request failed");
|
|
}
|
|
if (!wait_for_output(output->trigger_rumble)) {
|
|
return fail("SDL3 trigger rumble did not reach the normalized output callback");
|
|
}
|
|
return EXIT_SUCCESS;
|
|
}
|
|
|
|
int exercise_sdl_profile(
|
|
const ProfileCase &test_case,
|
|
lvh::Gamepad &virtual_gamepad,
|
|
const std::shared_ptr<OutputCapture> &output
|
|
) {
|
|
const auto gamepad_id = wait_for_gamepad(test_case);
|
|
if (gamepad_id == 0) {
|
|
log_discovery_state();
|
|
return fail(std::format("SDL3 did not discover product 045e:{:04x}", test_case.product_id));
|
|
}
|
|
if (validate_identity(gamepad_id, test_case) != EXIT_SUCCESS) {
|
|
return EXIT_FAILURE;
|
|
}
|
|
|
|
SdlGamepad gamepad {SDL_OpenGamepad(gamepad_id), &SDL_CloseGamepad};
|
|
if (gamepad == nullptr) {
|
|
return fail("SDL3 could not open the Xbox gamepad");
|
|
}
|
|
if (validate_capabilities(gamepad.get()) != EXIT_SUCCESS) {
|
|
return EXIT_FAILURE;
|
|
}
|
|
if (validate_buttons(virtual_gamepad, gamepad.get(), test_case) != EXIT_SUCCESS) {
|
|
return EXIT_FAILURE;
|
|
}
|
|
if (validate_trigger_inputs(virtual_gamepad, gamepad.get()) != EXIT_SUCCESS) {
|
|
return EXIT_FAILURE;
|
|
}
|
|
if (!validate_battery(virtual_gamepad, gamepad.get())) {
|
|
return fail("SDL3 did not expose the Xbox battery state");
|
|
}
|
|
return validate_rumble(gamepad.get(), output);
|
|
}
|
|
|
|
int run_sdl_profile(
|
|
const ProfileCase &test_case,
|
|
lvh::Gamepad &virtual_gamepad,
|
|
const std::shared_ptr<OutputCapture> &output
|
|
) {
|
|
// Initialize SDL only after udev has applied the hidraw access rules. If
|
|
// SDL3 sees the add event before that point, its HIDAPI driver can fail the
|
|
// initial open while still suppressing the duplicate evdev joystick.
|
|
if (SdlSubsystem sdl; sdl.initialized()) {
|
|
return exercise_sdl_profile(test_case, virtual_gamepad, output);
|
|
}
|
|
return fail("SDL3 initialization failed");
|
|
}
|
|
|
|
int exercise_profile(const ProfileCase &test_case) {
|
|
lvh::RuntimeOptions runtime_options;
|
|
runtime_options.backend = lvh::BackendKind::platform_default;
|
|
auto runtime = lvh::Runtime::create(runtime_options);
|
|
if (runtime == nullptr || !runtime->capabilities().supports_gamepad) {
|
|
return fail("The Linux backend does not report gamepad support");
|
|
}
|
|
|
|
lvh::CreateGamepadOptions options;
|
|
options.profile = test_case.profile;
|
|
options.profile.name = std::format("libvirtualhid SDL3 {} {}", test_case.identity_token, ::getpid());
|
|
options.metadata.has_battery = true;
|
|
options.metadata.stable_id = std::format("libvirtualhid-sdl3-xbox-{:04x}-{}", test_case.product_id, ::getpid());
|
|
auto created = runtime->create_gamepad(options);
|
|
if (!created) {
|
|
return fail(std::format("Gamepad creation failed: {}", created.status.message()));
|
|
}
|
|
if (!created.gamepad->profile().capabilities.supports_trigger_rumble) {
|
|
return fail("Xbox creation did not select the trigger-rumble-capable UHID backend");
|
|
}
|
|
if (!created.gamepad->profile().capabilities.supports_battery) {
|
|
return fail("Xbox creation did not select the battery-capable UHID backend");
|
|
}
|
|
if (!wait_for_accessible_hidraw_node(*created.gamepad)) {
|
|
log_device_nodes(*created.gamepad);
|
|
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) {
|
|
capture_output(output, gamepad_output);
|
|
});
|
|
return run_sdl_profile(test_case, *created.gamepad, output);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char **argv) {
|
|
if (argc != 2) {
|
|
std::cerr << "Usage: linux_sdl3_xbox_consumer <xone|xseries>\n";
|
|
return EXIT_FAILURE;
|
|
}
|
|
|
|
const std::string_view profile_name {argv[1]};
|
|
if (profile_name == "xone") {
|
|
return exercise_profile({lvh::profiles::xbox_one(), 0x0B20, "Xbox One"});
|
|
}
|
|
if (profile_name == "xseries") {
|
|
return exercise_profile({lvh::profiles::xbox_series(), 0x0B13, "Xbox Series"});
|
|
}
|
|
|
|
std::cerr << "Unknown profile: " << profile_name << '\n';
|
|
return EXIT_FAILURE;
|
|
}
|