Files
libvirtualhid/tests/unit/test_windows_consumers.cpp

1577 lines
58 KiB
C++

/**
* @file tests/unit/test_windows_consumers.cpp
* @brief Windows consumer integration tests for installed VHF gamepads.
*/
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef DIRECTINPUT_VERSION
#define DIRECTINPUT_VERSION 0x0800
#endif
// Windows base types must be declared before the HID headers.
#include <Windows.h>
// platform includes
#include <dinput.h>
#include <hidsdi.h>
#include <SetupAPI.h>
#include <Xinput.h>
#if defined(LIBVIRTUALHID_TEST_HAS_SDL3)
#include <SDL3/SDL.h>
#endif
// standard includes
#include <algorithm>
#include <array>
#include <bit>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
#include <set>
#include <span>
#include <string>
#include <thread>
#include <utility>
#include <vector>
// local includes
#include "fixtures/fixtures.hpp"
#include "platform/windows/control_protocol.hpp"
#include <libvirtualhid/libvirtualhid.hpp>
namespace {
using namespace std::chrono_literals;
using HidInterfacePaths = std::set<std::wstring, std::less<>>;
class WindowsConsumerTest: public WindowsTest {};
struct HidGamepadInterface {
std::wstring path;
std::uint16_t vendor_id = 0;
std::uint16_t product_id = 0;
std::uint16_t version_number = 0;
std::uint16_t usage = 0;
std::uint16_t input_report_size = 0;
std::uint16_t output_report_size = 0;
std::uint16_t feature_report_size = 0;
};
class DeviceInfoSet {
public:
explicit DeviceInfoSet(HDEVINFO value):
value_ {value} {}
DeviceInfoSet(const DeviceInfoSet &) = delete;
DeviceInfoSet &operator=(const DeviceInfoSet &) = delete;
~DeviceInfoSet() {
if (value_ != INVALID_HANDLE_VALUE) {
static_cast<void>(SetupDiDestroyDeviceInfoList(value_));
}
}
HDEVINFO get() const {
return value_;
}
private:
HDEVINFO value_;
};
class Handle {
public:
explicit Handle(HANDLE value = INVALID_HANDLE_VALUE):
value_ {value} {}
Handle(const Handle &) = delete;
Handle &operator=(const Handle &) = delete;
~Handle() {
if (value_ != INVALID_HANDLE_VALUE) {
static_cast<void>(CloseHandle(value_));
}
}
HANDLE get() const {
return value_;
}
explicit operator bool() const {
return value_ != INVALID_HANDLE_VALUE;
}
private:
HANDLE value_;
};
class XInputApi {
public:
using GetState = DWORD(WINAPI *)(DWORD, XINPUT_STATE *);
using SetState = DWORD(WINAPI *)(DWORD, XINPUT_VIBRATION *);
using GetBatteryInformation = DWORD(WINAPI *)(DWORD, BYTE, XINPUT_BATTERY_INFORMATION *);
XInputApi():
module_ {LoadLibraryW(L"xinput1_4.dll")} {
if (module_ != nullptr) {
get_state_ = std::bit_cast<GetState>(GetProcAddress(module_, "XInputGetState"));
set_state_ = std::bit_cast<SetState>(GetProcAddress(module_, "XInputSetState"));
get_battery_information_ = std::bit_cast<GetBatteryInformation>(
GetProcAddress(module_, "XInputGetBatteryInformation")
);
}
}
XInputApi(const XInputApi &) = delete;
XInputApi &operator=(const XInputApi &) = delete;
~XInputApi() {
if (module_ != nullptr) {
static_cast<void>(FreeLibrary(module_));
}
}
explicit operator bool() const {
return get_state_ != nullptr && set_state_ != nullptr && get_battery_information_ != nullptr;
}
DWORD state(DWORD slot, XINPUT_STATE *state) const {
return get_state_(slot, state);
}
DWORD set_state(DWORD slot, XINPUT_VIBRATION *vibration) const {
return set_state_(slot, vibration);
}
DWORD battery_information(
DWORD slot,
BYTE device_type,
XINPUT_BATTERY_INFORMATION *battery
) const {
return get_battery_information_(slot, device_type, battery);
}
private:
HMODULE module_ = nullptr;
GetState get_state_ = nullptr;
SetState set_state_ = nullptr;
GetBatteryInformation get_battery_information_ = nullptr;
};
std::set<DWORD> current_xinput_slots(const XInputApi &xinput) {
std::set<DWORD> result;
for (auto slot = DWORD {0}; slot < XUSER_MAX_COUNT; ++slot) {
XINPUT_STATE state {};
if (xinput.state(slot, &state) == ERROR_SUCCESS) {
result.insert(slot);
}
}
return result;
}
std::optional<DWORD> wait_for_new_xinput_slot(
const XInputApi &xinput,
const std::set<DWORD> &previous_slots
) {
const auto deadline = std::chrono::steady_clock::now() + 10s;
while (std::chrono::steady_clock::now() < deadline) {
for (const auto slot : current_xinput_slots(xinput)) {
if (!previous_slots.contains(slot)) {
return slot;
}
}
std::this_thread::sleep_for(20ms);
}
return std::nullopt;
}
template<typename Interface>
struct ComReleaser {
void operator()(Interface *value) const {
if (value != nullptr) {
value->Release();
}
}
};
template<typename Interface>
using ComInterface = std::unique_ptr<Interface, ComReleaser<Interface>>;
struct DirectInputDeviceMatch {
std::uint16_t vendor_id = 0;
std::uint16_t product_id = 0;
GUID instance_guid {};
bool found = false;
};
BOOL CALLBACK find_directinput_device(const DIDEVICEINSTANCEW *instance, void *context) {
auto &match = *static_cast<DirectInputDeviceMatch *>(context);
const auto vendor_id = static_cast<std::uint16_t>(LOWORD(instance->guidProduct.Data1));
if (const auto product_id = static_cast<std::uint16_t>(HIWORD(instance->guidProduct.Data1)); vendor_id != match.vendor_id || product_id != match.product_id) {
return DIENUM_CONTINUE;
}
match.instance_guid = instance->guidInstance;
match.found = true;
return DIENUM_STOP;
}
std::optional<GUID> wait_for_directinput_device(
IDirectInput8W &direct_input,
std::uint16_t vendor_id,
std::uint16_t product_id
) {
const auto deadline = std::chrono::steady_clock::now() + 10s;
while (std::chrono::steady_clock::now() < deadline) {
DirectInputDeviceMatch match {.vendor_id = vendor_id, .product_id = product_id};
if (const auto status = direct_input.EnumDevices(DI8DEVCLASS_GAMECTRL, find_directinput_device, &match, DIEDFL_ATTACHEDONLY); SUCCEEDED(status) && match.found) {
return match.instance_guid;
}
std::this_thread::sleep_for(100ms);
}
return std::nullopt;
}
BOOL CALLBACK find_constant_force_effect(const DIEFFECTINFOW *effect, void *context) {
auto &found = *static_cast<bool *>(context);
if (IsEqualGUID(effect->guid, GUID_ConstantForce) != FALSE) {
found = true;
return DIENUM_STOP;
}
return DIENUM_CONTINUE;
}
BOOL CALLBACK collect_force_feedback_axes(const DIDEVICEOBJECTINSTANCEW *object, void *context) {
if ((object->dwType & DIDFT_FFACTUATOR) != 0U) {
static_cast<std::vector<DWORD> *>(context)->push_back(object->dwOfs);
}
return DIENUM_CONTINUE;
}
std::vector<HidGamepadInterface> enumerate_gamepad_interfaces() {
GUID hid_guid {};
HidD_GetHidGuid(&hid_guid);
DeviceInfoSet devices {SetupDiGetClassDevsW(
&hid_guid,
nullptr,
nullptr,
DIGCF_PRESENT | DIGCF_DEVICEINTERFACE
)};
if (devices.get() == INVALID_HANDLE_VALUE) {
return {};
}
std::vector<HidGamepadInterface> interfaces;
for (DWORD index = 0;; ++index) {
SP_DEVICE_INTERFACE_DATA interface_data {};
interface_data.cbSize = sizeof(interface_data);
if (SetupDiEnumDeviceInterfaces(devices.get(), nullptr, &hid_guid, index, &interface_data) == FALSE) {
if (GetLastError() == ERROR_NO_MORE_ITEMS) {
break;
}
continue;
}
DWORD required_size = 0;
static_cast<void>(SetupDiGetDeviceInterfaceDetailW(
devices.get(),
&interface_data,
nullptr,
0,
&required_size,
nullptr
));
if (required_size < sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W)) {
continue;
}
const auto detail_count =
(required_size + sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W) - 1U) / sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W);
std::vector<SP_DEVICE_INTERFACE_DETAIL_DATA_W> detail_storage(detail_count);
auto *detail = detail_storage.data();
detail->cbSize = sizeof(*detail);
if (SetupDiGetDeviceInterfaceDetailW(devices.get(), &interface_data, detail, required_size, nullptr, nullptr) == FALSE) {
continue;
}
Handle handle {CreateFileW(
detail->DevicePath,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
nullptr
)};
if (!handle) {
continue;
}
HIDD_ATTRIBUTES attributes {};
attributes.Size = sizeof(attributes);
PHIDP_PREPARSED_DATA preparsed_data = nullptr;
HIDP_CAPS capabilities {};
if (HidD_GetAttributes(handle.get(), &attributes) == FALSE || HidD_GetPreparsedData(handle.get(), &preparsed_data) == FALSE) {
continue;
}
const auto caps_status = HidP_GetCaps(preparsed_data, &capabilities);
static_cast<void>(HidD_FreePreparsedData(preparsed_data));
if (
caps_status != HIDP_STATUS_SUCCESS || capabilities.UsagePage != 0x01U ||
(capabilities.Usage != 0x04U && capabilities.Usage != 0x05U)
) {
continue;
}
interfaces.push_back({
.path = detail->DevicePath,
.vendor_id = attributes.VendorID,
.product_id = attributes.ProductID,
.version_number = attributes.VersionNumber,
.usage = capabilities.Usage,
.input_report_size = capabilities.InputReportByteLength,
.output_report_size = capabilities.OutputReportByteLength,
.feature_report_size = capabilities.FeatureReportByteLength,
});
}
return interfaces;
}
std::optional<HidGamepadInterface> wait_for_new_interface(
const HidInterfacePaths &previous_paths,
std::uint16_t vendor_id,
std::uint16_t product_id
) {
const auto deadline = std::chrono::steady_clock::now() + 10s;
while (std::chrono::steady_clock::now() < deadline) {
const auto interfaces = enumerate_gamepad_interfaces();
if (const auto position = std::ranges::find_if(interfaces, [vendor_id, product_id, &previous_paths](const auto &hid_interface) {
return hid_interface.vendor_id == vendor_id && hid_interface.product_id == product_id &&
!previous_paths.contains(hid_interface.path);
});
position != interfaces.end()) {
return *position;
}
std::this_thread::sleep_for(100ms);
}
return std::nullopt;
}
std::optional<std::vector<std::uint8_t>> read_hid_report_with_timeout(
HANDLE hid,
std::size_t report_size,
std::chrono::milliseconds timeout
) {
Handle event {CreateEventW(nullptr, TRUE, FALSE, nullptr)};
if (!event) {
return std::nullopt;
}
std::vector<std::uint8_t> report(report_size, 0);
OVERLAPPED overlapped {};
overlapped.hEvent = event.get();
DWORD bytes_read = 0;
if (const auto started = ReadFile(hid, report.data(), static_cast<DWORD>(report.size()), &bytes_read, &overlapped); started == FALSE && GetLastError() != ERROR_IO_PENDING) {
return std::nullopt;
}
if (const auto wait_result = WaitForSingleObject(event.get(), static_cast<DWORD>(timeout.count())); wait_result != WAIT_OBJECT_0) {
static_cast<void>(CancelIoEx(hid, &overlapped));
static_cast<void>(WaitForSingleObject(event.get(), INFINITE));
static_cast<void>(GetOverlappedResult(hid, &overlapped, &bytes_read, FALSE));
return std::nullopt;
}
if (GetOverlappedResult(hid, &overlapped, &bytes_read, FALSE) == FALSE) {
return std::nullopt;
}
report.resize(bytes_read);
return report;
}
template<typename Predicate>
std::optional<std::vector<std::uint8_t>> read_hid_report_matching(
HANDLE hid,
std::size_t report_size,
std::chrono::milliseconds timeout,
Predicate predicate
) {
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
const auto remaining = std::chrono::duration_cast<std::chrono::milliseconds>(
deadline - std::chrono::steady_clock::now()
);
auto report = read_hid_report_with_timeout(hid, report_size, std::max(remaining, 1ms));
if (!report.has_value()) {
return std::nullopt;
}
if (predicate(*report)) {
return report;
}
}
return std::nullopt;
}
std::optional<std::vector<std::uint8_t>> send_switch_proprietary_command(
const HidGamepadInterface &hid_interface,
HANDLE reader,
HANDLE writer,
std::uint8_t command
) {
std::vector<std::uint8_t> output(hid_interface.output_report_size, 0);
output[0] = 0x80;
output[1] = command;
DWORD bytes_written = 0;
EXPECT_TRUE(WriteFile(
writer,
output.data(),
static_cast<DWORD>(output.size()),
&bytes_written,
nullptr
)) << "Switch proprietary WriteFile failed: "
<< GetLastError();
EXPECT_EQ(bytes_written, output.size());
return read_hid_report_matching(
reader,
hid_interface.input_report_size,
5s,
[command](const auto &report) {
return report.size() >= 2U && report[0] == 0x81U && report[1] == command;
}
);
}
HidInterfacePaths current_gamepad_interface_paths() {
HidInterfacePaths paths;
for (const auto &hid_interface : enumerate_gamepad_interfaces()) {
paths.insert(hid_interface.path);
}
return paths;
}
class GamepadOutputCapture {
public:
void attach(lvh::GamepadStateAdapter &adapter) {
adapter.set_output_callback([this](const lvh::GamepadOutput &output) {
{
std::lock_guard lock {mutex_};
outputs_.push_back(output);
}
ready_.notify_all();
});
}
std::optional<lvh::GamepadOutput> wait_for_rumble(bool expect_nonzero) {
return wait_for([expect_nonzero](const lvh::GamepadOutput &output) {
const auto has_strength = output.low_frequency_rumble > 0U || output.high_frequency_rumble > 0U;
return output.kind == lvh::GamepadOutputKind::rumble && has_strength == expect_nonzero;
});
}
std::optional<lvh::GamepadOutput> wait_for_rgb_led(
std::uint8_t red,
std::uint8_t green,
std::uint8_t blue
) {
return wait_for([red, green, blue](const lvh::GamepadOutput &output) {
return output.kind == lvh::GamepadOutputKind::rgb_led && output.red == red && output.green == green &&
output.blue == blue;
});
}
std::optional<lvh::GamepadOutput> wait_for_player_leds(
const std::array<bool, 4> &solid,
const std::array<bool, 4> &flashing
) {
return wait_for([&solid, &flashing](const lvh::GamepadOutput &output) {
return output.kind == lvh::GamepadOutputKind::player_leds && output.player_leds == solid &&
output.flashing_player_leds == flashing;
});
}
private:
template<typename Predicate>
std::optional<lvh::GamepadOutput> wait_for(Predicate matches) {
const auto find_next_match = [this, &matches] {
return std::find_if(
outputs_.begin() + static_cast<std::ptrdiff_t>(next_output_),
outputs_.end(),
matches
);
};
if (std::unique_lock lock {mutex_}; ready_.wait_for(lock, 5s, [this, &find_next_match] {
return find_next_match() != outputs_.end();
})) {
const auto match = find_next_match();
next_output_ = static_cast<std::size_t>(std::distance(outputs_.begin(), match)) + 1U;
return *match;
}
return std::nullopt;
}
std::mutex mutex_;
std::condition_variable ready_;
std::vector<lvh::GamepadOutput> outputs_;
std::size_t next_output_ = 0;
};
#if defined(LIBVIRTUALHID_TEST_HAS_SDL3)
using SdlGamepad = std::unique_ptr<SDL_Gamepad, void (*)(SDL_Gamepad *)>;
class SdlGamepadSubsystem {
public:
SdlGamepadSubsystem() {
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5, "1");
SDL_SetHint(SDL_HINT_JOYSTICK_ENHANCED_REPORTS, "1");
initialized_ = SDL_Init(SDL_INIT_GAMEPAD | SDL_INIT_JOYSTICK | SDL_INIT_EVENTS);
}
SdlGamepadSubsystem(const SdlGamepadSubsystem &) = delete;
SdlGamepadSubsystem &operator=(const SdlGamepadSubsystem &) = delete;
~SdlGamepadSubsystem() {
if (initialized_) {
SDL_Quit();
}
}
bool initialized() const {
return initialized_;
}
private:
bool initialized_ = false;
};
std::set<SDL_JoystickID> current_sdl_gamepads() {
auto count = 0;
auto *gamepads = SDL_GetGamepads(&count);
std::set<SDL_JoystickID> result;
if (gamepads != nullptr) {
result.insert(gamepads, gamepads + count);
SDL_free(gamepads);
}
return result;
}
SdlGamepad wait_for_new_sdl_gamepad(
const std::set<SDL_JoystickID> &previous_gamepads,
std::uint16_t vendor_id,
std::uint16_t product_id
) {
const auto deadline = std::chrono::steady_clock::now() + 10s;
while (std::chrono::steady_clock::now() < deadline) {
SDL_UpdateGamepads();
auto count = 0;
auto *gamepads = SDL_GetGamepads(&count);
for (auto index = 0; index < count; ++index) {
const auto gamepad_id = gamepads[index];
if (
!previous_gamepads.contains(gamepad_id) && SDL_GetGamepadVendorForID(gamepad_id) == vendor_id &&
SDL_GetGamepadProductForID(gamepad_id) == product_id
) {
auto *opened = SDL_OpenGamepad(gamepad_id);
SDL_free(gamepads);
return {opened, &SDL_CloseGamepad};
}
}
SDL_free(gamepads);
std::this_thread::sleep_for(100ms);
}
return {nullptr, &SDL_CloseGamepad};
}
bool wait_for_sdl_gamepad_removal(SDL_JoystickID gamepad_id) {
const auto deadline = std::chrono::steady_clock::now() + 10s;
while (std::chrono::steady_clock::now() < deadline) {
SDL_UpdateGamepads();
SDL_PumpEvents();
if (!current_sdl_gamepads().contains(gamepad_id)) {
return true;
}
std::this_thread::sleep_for(20ms);
}
return false;
}
bool wait_for_switch_pro_motion(SDL_Gamepad *gamepad) {
std::array<float, 3> acceleration {};
std::array<float, 3> gyroscope {};
const auto deadline = std::chrono::steady_clock::now() + 5s;
while (std::chrono::steady_clock::now() < deadline) {
SDL_UpdateGamepads();
SDL_PumpEvents();
if (
SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_ACCEL, acceleration.data(), acceleration.size()) &&
SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_GYRO, gyroscope.data(), gyroscope.size()) &&
std::abs(acceleration[1]) > 15.0F && std::abs(gyroscope[2]) > 0.25F
) {
return true;
}
std::this_thread::sleep_for(20ms);
}
return false;
}
#endif
} // namespace
#if defined(LIBVIRTUALHID_TEST_HAS_SDL3)
TEST_F(WindowsConsumerTest, SdlHidapiOutputReachesDefaultPlayStationAndSwitchCallbacks) {
SdlGamepadSubsystem sdl;
ASSERT_TRUE(sdl.initialized()) << SDL_GetError();
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
const std::array profiles {
lvh::profiles::dualshock4(),
lvh::profiles::dualsense(),
lvh::profiles::switch_pro(),
};
for (const auto &profile : profiles) {
SCOPED_TRACE(profile.name);
const auto previous_gamepads = current_sdl_gamepads();
lvh::CreateGamepadOptions options;
options.profile = profile;
options.metadata.stable_id = "02:11:22:33:44:55";
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
ASSERT_NE(created.adapter->gamepad(), nullptr);
if (
profile.gamepad_kind == lvh::GamepadProfileKind::dualshock4 ||
profile.gamepad_kind == lvh::GamepadProfileKind::dualsense
) {
EXPECT_EQ(created.adapter->gamepad()->profile().bus_type, lvh::BusType::usb);
}
GamepadOutputCapture output_capture;
output_capture.attach(*created.adapter);
auto gamepad = wait_for_new_sdl_gamepad(previous_gamepads, profile.vendor_id, profile.product_id);
ASSERT_NE(gamepad.get(), nullptr) << SDL_GetError();
auto *joystick = SDL_GetGamepadJoystick(gamepad.get());
ASSERT_NE(joystick, nullptr) << SDL_GetError();
const auto properties = SDL_GetGamepadProperties(gamepad.get());
ASSERT_NE(properties, 0U) << SDL_GetError();
if (
profile.gamepad_kind == lvh::GamepadProfileKind::dualshock4 ||
profile.gamepad_kind == lvh::GamepadProfileKind::dualsense
) {
ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false)) << SDL_GetError();
ASSERT_TRUE(SDL_SetGamepadLED(gamepad.get(), 0x12U, 0x34U, 0x56U)) << SDL_GetError();
const auto rgb_led = output_capture.wait_for_rgb_led(0x12U, 0x34U, 0x56U);
ASSERT_TRUE(rgb_led.has_value()) << "No normalized RGB LED callback followed SDL HIDAPI LED output";
}
if (profile.gamepad_kind == lvh::GamepadProfileKind::switch_pro) {
ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_MONO_LED_BOOLEAN, false)) << SDL_GetError();
ASSERT_TRUE(SDL_SetGamepadLED(gamepad.get(), 0xFFU, 0xFFU, 0xFFU)) << SDL_GetError();
const auto home_led = output_capture.wait_for_rgb_led(0xFFU, 0xFFU, 0xFFU);
ASSERT_TRUE(home_led.has_value()) << "No normalized HOME LED callback followed SDL HIDAPI LED output";
ASSERT_TRUE(SDL_GamepadHasSensor(gamepad.get(), SDL_SENSOR_ACCEL)) << SDL_GetError();
ASSERT_TRUE(SDL_GamepadHasSensor(gamepad.get(), SDL_SENSOR_GYRO)) << SDL_GetError();
ASSERT_TRUE(SDL_SetGamepadSensorEnabled(gamepad.get(), SDL_SENSOR_ACCEL, true)) << SDL_GetError();
ASSERT_TRUE(SDL_SetGamepadSensorEnabled(gamepad.get(), SDL_SENSOR_GYRO, true)) << SDL_GetError();
ASSERT_TRUE(created.adapter->set_motion(
lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F},
lvh::Vector3 {.x = 10.0F, .y = 20.0F, .z = -30.0F}
)
.ok());
ASSERT_TRUE(wait_for_switch_pro_motion(gamepad.get())) << "SDL HIDAPI did not receive live Switch Pro motion";
ASSERT_TRUE(SDL_SetGamepadPlayerIndex(gamepad.get(), 2)) << SDL_GetError();
const auto player_leds = output_capture.wait_for_player_leds(
std::array {true, true, true, false},
std::array {false, false, false, false}
);
ASSERT_TRUE(player_leds.has_value()) << "No normalized player LED callback followed SDL HIDAPI player-index output";
}
ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false)) << SDL_GetError();
ASSERT_TRUE(SDL_RumbleGamepad(gamepad.get(), 0x5678U, 0x1234U, 1000U)) << SDL_GetError();
const auto rumble = output_capture.wait_for_rumble(true);
ASSERT_TRUE(rumble.has_value()) << "No normalized rumble callback followed SDL HIDAPI rumble";
EXPECT_GT(rumble->low_frequency_rumble, 0U);
EXPECT_GT(rumble->high_frequency_rumble, 0U);
gamepad.reset();
ASSERT_TRUE(created.adapter->close().ok());
}
}
TEST_F(WindowsConsumerTest, SdlExposesSubmittedBatteryStateForNonXboxProfiles) {
SdlGamepadSubsystem sdl;
ASSERT_TRUE(sdl.initialized()) << SDL_GetError();
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
struct BatteryTestCase {
lvh::DeviceProfile profile;
int expected_percentage;
};
const std::array test_cases {
BatteryTestCase {lvh::profiles::dualshock4(), 55},
BatteryTestCase {lvh::profiles::dualsense(), 55},
BatteryTestCase {lvh::profiles::switch_pro(), 50},
};
for (const auto &[profile, expected_percentage] : test_cases) {
SCOPED_TRACE(profile.name);
const auto previous_gamepads = current_sdl_gamepads();
lvh::CreateGamepadOptions options;
options.profile = profile;
options.metadata.stable_id = "02:11:22:33:44:55";
options.metadata.has_battery = true;
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
ASSERT_NE(created.adapter->gamepad(), nullptr);
auto gamepad = wait_for_new_sdl_gamepad(previous_gamepads, profile.vendor_id, profile.product_id);
ASSERT_NE(gamepad.get(), nullptr) << SDL_GetError();
ASSERT_TRUE(created.adapter->set_battery({
.state = lvh::GamepadBatteryState::discharging,
.percentage = 50,
})
.ok());
auto power_state = SDL_POWERSTATE_UNKNOWN;
auto percentage = -1;
const auto deadline = std::chrono::steady_clock::now() + 3s;
while (std::chrono::steady_clock::now() < deadline) {
SDL_UpdateGamepads();
SDL_PumpEvents();
power_state = SDL_GetGamepadPowerInfo(gamepad.get(), &percentage);
if (power_state == SDL_POWERSTATE_ON_BATTERY && percentage == expected_percentage) {
break;
}
std::this_thread::sleep_for(20ms);
}
EXPECT_EQ(power_state, SDL_POWERSTATE_ON_BATTERY) << "percentage=" << percentage;
EXPECT_EQ(percentage, expected_percentage);
const auto gamepad_id = SDL_GetGamepadID(gamepad.get());
gamepad.reset();
ASSERT_TRUE(created.adapter->close().ok());
ASSERT_TRUE(wait_for_sdl_gamepad_removal(gamepad_id));
}
}
#endif
TEST_F(WindowsConsumerTest, XInputDoesNotExposeSubmittedXboxBattery) {
XInputApi xinput;
ASSERT_TRUE(xinput);
const auto previous_slots = current_xinput_slots(xinput);
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
lvh::CreateGamepadOptions options;
options.profile = lvh::profiles::xbox_series();
options.metadata.stable_id = "xinput-battery-test";
options.metadata.has_battery = true;
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
ASSERT_TRUE(created.adapter->set_battery({
.state = lvh::GamepadBatteryState::discharging,
.percentage = 50,
})
.ok());
ASSERT_TRUE(created.adapter->set_button(lvh::GamepadButton::a, true).ok());
const auto slot = wait_for_new_xinput_slot(xinput, previous_slots);
if (!slot.has_value()) {
ASSERT_TRUE(created.adapter->close().ok());
GTEST_SKIP() << "XInput did not enumerate the VHF gamepad in this Windows environment";
}
XINPUT_BATTERY_INFORMATION battery {};
ASSERT_EQ(xinput.battery_information(*slot, BATTERY_DEVTYPE_GAMEPAD, &battery), ERROR_SUCCESS);
EXPECT_EQ(battery.BatteryType, BATTERY_TYPE_DISCONNECTED);
EXPECT_EQ(battery.BatteryLevel, BATTERY_LEVEL_EMPTY);
ASSERT_TRUE(created.adapter->close().ok());
}
TEST_F(WindowsConsumerTest, Xbox360PublishesXInputStateAndRumble) {
XInputApi xinput;
ASSERT_TRUE(xinput);
const auto previous_slots = current_xinput_slots(xinput);
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
lvh::CreateGamepadOptions options;
options.profile = lvh::profiles::xbox_360();
options.metadata.stable_id = "xinput-xbox360-test";
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
ASSERT_NE(created.adapter->gamepad(), nullptr);
GamepadOutputCapture output_capture;
output_capture.attach(*created.adapter);
const auto slot = wait_for_new_xinput_slot(xinput, previous_slots);
ASSERT_TRUE(slot.has_value()) << "XInput did not enumerate the Xbox 360 software device";
ASSERT_TRUE(created.adapter->set_button(lvh::GamepadButton::a, true).ok());
ASSERT_TRUE(created.adapter->set_button(lvh::GamepadButton::dpad_right, true).ok());
ASSERT_TRUE(created.adapter->set_left_trigger(0.5F).ok());
ASSERT_TRUE(created.adapter->set_right_stick({.x = 0.25F, .y = -0.75F}).ok());
XINPUT_STATE state {};
const auto input_deadline = std::chrono::steady_clock::now() + 5s;
while (std::chrono::steady_clock::now() < input_deadline) {
ASSERT_EQ(xinput.state(*slot, &state), ERROR_SUCCESS);
if ((state.Gamepad.wButtons & (XINPUT_GAMEPAD_A | XINPUT_GAMEPAD_DPAD_RIGHT)) == (XINPUT_GAMEPAD_A | XINPUT_GAMEPAD_DPAD_RIGHT) && state.Gamepad.bLeftTrigger >= 127U && state.Gamepad.sThumbRX > 0 && state.Gamepad.sThumbRY < 0) {
break;
}
std::this_thread::sleep_for(20ms);
}
EXPECT_EQ(
state.Gamepad.wButtons & (XINPUT_GAMEPAD_A | XINPUT_GAMEPAD_DPAD_RIGHT),
XINPUT_GAMEPAD_A | XINPUT_GAMEPAD_DPAD_RIGHT
);
EXPECT_GE(state.Gamepad.bLeftTrigger, 127U);
EXPECT_GT(state.Gamepad.sThumbRX, 0);
EXPECT_LT(state.Gamepad.sThumbRY, 0);
XINPUT_VIBRATION vibration {
.wLeftMotorSpeed = 0x5678U,
.wRightMotorSpeed = 0x1234U,
};
ASSERT_EQ(xinput.set_state(*slot, &vibration), ERROR_SUCCESS);
const auto rumble = output_capture.wait_for_rumble(true);
ASSERT_TRUE(rumble.has_value()) << "No normalized rumble callback followed XInputSetState";
EXPECT_NEAR(rumble->low_frequency_rumble, 0x5656U, 0x0101U);
EXPECT_NEAR(rumble->high_frequency_rumble, 0x1212U, 0x0101U);
ASSERT_TRUE(created.adapter->close().ok());
}
TEST_F(WindowsConsumerTest, NativePlayStationFeatureAndOutputReportsReachOwningRuntime) {
struct NativePlayStationCase {
lvh::DeviceProfile profile;
std::uint8_t calibration_report_id;
std::uint8_t pairing_report_id;
std::uint8_t firmware_report_id;
std::array<std::uint8_t, 3> firmware_prefix;
std::uint8_t output_report_id;
std::size_t valid_flags_offset;
std::size_t right_motor_offset;
std::size_t left_motor_offset;
std::size_t minimum_feature_report_size;
};
const std::array test_cases {
NativePlayStationCase {
.profile = lvh::profiles::dualshock4(),
.calibration_report_id = 0x02,
.pairing_report_id = 0x12,
.firmware_report_id = 0xA3,
.firmware_prefix = {'A', 'u', 'g'},
.output_report_id = 0x05,
.valid_flags_offset = 1U,
.right_motor_offset = 4U,
.left_motor_offset = 5U,
.minimum_feature_report_size = 49U,
},
NativePlayStationCase {
.profile = lvh::profiles::dualsense(),
.calibration_report_id = 0x05,
.pairing_report_id = 0x09,
.firmware_report_id = 0x20,
.firmware_prefix = {'J', 'u', 'l'},
.output_report_id = 0x02,
.valid_flags_offset = 1U,
.right_motor_offset = 3U,
.left_motor_offset = 4U,
.minimum_feature_report_size = 64U,
},
};
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto decoy_runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(decoy_runtime, nullptr);
ASSERT_TRUE(decoy_runtime->capabilities().supports_gamepad);
std::this_thread::sleep_for(100ms);
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
for (const auto &test_case : test_cases) {
const auto &profile = test_case.profile;
const auto previous_paths = current_gamepad_interface_paths();
lvh::CreateGamepadOptions options;
options.profile = profile;
options.metadata.stable_id = "02:11:22:33:44:55";
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << profile.name << ": " << created.status.message();
ASSERT_NE(created.adapter->gamepad(), nullptr);
const auto &effective_profile = created.adapter->gamepad()->profile();
ASSERT_EQ(effective_profile.bus_type, lvh::BusType::usb);
GamepadOutputCapture output_capture;
output_capture.attach(*created.adapter);
const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id);
ASSERT_TRUE(hid_interface.has_value()) << "The VHF " << profile.name << " HID interface was not enumerated";
ASSERT_EQ(hid_interface->output_report_size, effective_profile.output_report_size);
Handle hid {CreateFileW(
hid_interface->path.c_str(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
nullptr
)};
ASSERT_TRUE(hid) << "Unable to open the VHF " << profile.name << " HID interface: " << GetLastError();
ASSERT_GE(hid_interface->feature_report_size, test_case.minimum_feature_report_size);
std::vector<std::uint8_t> calibration(hid_interface->feature_report_size, 0);
calibration[0] = test_case.calibration_report_id;
ASSERT_TRUE(HidD_GetFeature(hid.get(), calibration.data(), static_cast<ULONG>(calibration.size())))
<< profile.name << " calibration GetFeature failed: " << GetLastError();
EXPECT_EQ(calibration[0], test_case.calibration_report_id);
EXPECT_EQ(calibration[7], 0x10U);
EXPECT_EQ(calibration[8], 0x27U);
std::vector<std::uint8_t> pairing(hid_interface->feature_report_size, 0);
pairing[0] = test_case.pairing_report_id;
ASSERT_TRUE(HidD_GetFeature(hid.get(), pairing.data(), static_cast<ULONG>(pairing.size())))
<< profile.name << " pairing GetFeature failed: " << GetLastError();
EXPECT_EQ(pairing[0], test_case.pairing_report_id);
EXPECT_EQ(pairing[1], 0x55U);
EXPECT_EQ(pairing[2], 0x44U);
EXPECT_EQ(pairing[3], 0x33U);
EXPECT_EQ(pairing[4], 0x22U);
EXPECT_EQ(pairing[5], 0x11U);
EXPECT_EQ(pairing[6], 0x02U);
std::vector<std::uint8_t> firmware(hid_interface->feature_report_size, 0);
firmware[0] = test_case.firmware_report_id;
ASSERT_TRUE(HidD_GetFeature(hid.get(), firmware.data(), static_cast<ULONG>(firmware.size())))
<< profile.name << " firmware GetFeature failed: " << GetLastError();
EXPECT_EQ(firmware[0], test_case.firmware_report_id);
EXPECT_TRUE(std::ranges::equal(test_case.firmware_prefix, std::span {firmware}.subspan(1U, 3U)));
if (profile.gamepad_kind == lvh::GamepadProfileKind::dualsense) {
EXPECT_EQ(firmware[22], 0x04U);
EXPECT_EQ(firmware[23], 0x00U);
EXPECT_EQ(firmware[44], 0x30U);
EXPECT_EQ(firmware[45], 0x06U);
}
std::vector<std::uint8_t> report(hid_interface->output_report_size, 0);
report[0] = test_case.output_report_id;
report[test_case.valid_flags_offset] = 0x01;
report[test_case.right_motor_offset] = 0x80;
report[test_case.left_motor_offset] = 0x40;
DWORD bytes_written = 0;
ASSERT_TRUE(WriteFile(
hid.get(),
report.data(),
static_cast<DWORD>(report.size()),
&bytes_written,
nullptr
)) << profile.name
<< " WriteFile failed: "
<< GetLastError();
ASSERT_EQ(bytes_written, report.size());
const auto rumble = output_capture.wait_for_rumble(true);
ASSERT_TRUE(rumble.has_value())
<< "No normalized rumble callback followed the native " << profile.name << " HID output write";
EXPECT_EQ(rumble->low_frequency_rumble, 16448U);
EXPECT_EQ(rumble->high_frequency_rumble, 32896U);
EXPECT_EQ(rumble->raw_report, report);
ASSERT_TRUE(created.adapter->close().ok());
}
}
TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
const auto profile = lvh::profiles::switch_pro();
const auto previous_paths = current_gamepad_interface_paths();
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
lvh::CreateGamepadOptions options;
options.profile = profile;
options.metadata.stable_id = "02:00:00:00:00:05";
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
GamepadOutputCapture output_capture;
output_capture.attach(*created.adapter);
const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id);
ASSERT_TRUE(hid_interface.has_value()) << "The VHF Switch Pro HID interface was not enumerated";
ASSERT_EQ(hid_interface->version_number, profile.version);
ASSERT_EQ(hid_interface->input_report_size, profile.input_report_size);
ASSERT_EQ(hid_interface->output_report_size, profile.output_report_size);
Handle writer {CreateFileW(
hid_interface->path.c_str(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
nullptr
)};
ASSERT_TRUE(writer) << "Unable to open the VHF Switch Pro HID interface for writes: " << GetLastError();
Handle reader {CreateFileW(
hid_interface->path.c_str(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
nullptr
)};
ASSERT_TRUE(reader) << "Unable to open the VHF Switch Pro HID interface for reads: " << GetLastError();
const auto status_reply = send_switch_proprietary_command(*hid_interface, reader.get(), writer.get(), 0x01);
ASSERT_TRUE(status_reply.has_value()) << "No 0x81 Switch status reply reached the HID client";
ASSERT_GE(status_reply->size(), 10U);
EXPECT_EQ(status_reply->at(0), 0x81U);
EXPECT_EQ(status_reply->at(1), 0x01U);
EXPECT_EQ(status_reply->at(3), 0x03U); // Pro Controller.
const auto handshake_reply = send_switch_proprietary_command(*hid_interface, reader.get(), writer.get(), 0x02);
ASSERT_TRUE(handshake_reply.has_value()) << "No 0x81 Switch handshake reply reached the HID client";
ASSERT_GE(handshake_reply->size(), 2U);
EXPECT_EQ(handshake_reply->at(0), 0x81U);
EXPECT_EQ(handshake_reply->at(1), 0x02U);
std::vector<std::uint8_t> rumble_report(hid_interface->output_report_size, 0);
constexpr std::array native_rumble {
std::uint8_t {0x10},
std::uint8_t {0x07},
std::uint8_t {0x74},
std::uint8_t {0x1A},
std::uint8_t {0x3D},
std::uint8_t {0x59},
std::uint8_t {0x74},
std::uint8_t {0x1A},
std::uint8_t {0x3D},
std::uint8_t {0x59},
};
std::ranges::copy(native_rumble, rumble_report.begin());
DWORD bytes_written = 0;
ASSERT_TRUE(WriteFile(
writer.get(),
rumble_report.data(),
static_cast<DWORD>(rumble_report.size()),
&bytes_written,
nullptr
)) << "Switch rumble WriteFile failed: "
<< GetLastError();
ASSERT_EQ(bytes_written, rumble_report.size());
const auto rumble = output_capture.wait_for_rumble(true);
ASSERT_TRUE(rumble.has_value()) << "No normalized rumble callback followed the native Switch 0x10 output write";
EXPECT_EQ(rumble->low_frequency_rumble, 22251U);
EXPECT_EQ(rumble->high_frequency_rumble, 5213U);
EXPECT_EQ(rumble->raw_report, rumble_report);
std::vector<std::uint8_t> player_lights_report(hid_interface->output_report_size, 0);
player_lights_report[0] = 0x01;
player_lights_report[1] = 0x08;
constexpr std::array neutral_rumble {
std::uint8_t {0x00},
std::uint8_t {0x01},
std::uint8_t {0x40},
std::uint8_t {0x40},
std::uint8_t {0x00},
std::uint8_t {0x01},
std::uint8_t {0x40},
std::uint8_t {0x40},
};
std::ranges::copy(neutral_rumble, player_lights_report.begin() + 2);
player_lights_report[10] = 0x30;
player_lights_report[11] = 0xA5;
bytes_written = 0;
ASSERT_TRUE(WriteFile(
writer.get(),
player_lights_report.data(),
static_cast<DWORD>(player_lights_report.size()),
&bytes_written,
nullptr
)) << "Switch player-light WriteFile failed: "
<< GetLastError();
ASSERT_EQ(bytes_written, player_lights_report.size());
const auto player_lights_reply = read_hid_report_matching(
reader.get(),
hid_interface->input_report_size,
5s,
[](const auto &report) {
return report.size() >= 15U && report[0] == 0x21U && report[14] == 0x30U;
}
);
ASSERT_TRUE(player_lights_reply.has_value()) << "No Switch player-light acknowledgement reached the HID client";
ASSERT_GE(player_lights_reply->size(), 15U);
EXPECT_EQ(player_lights_reply->at(0), 0x21U);
EXPECT_EQ(player_lights_reply->at(13), 0x80U);
EXPECT_EQ(player_lights_reply->at(14), 0x30U);
const auto player_lights = output_capture.wait_for_player_leds(
{true, false, true, false},
{false, true, false, true}
);
ASSERT_TRUE(player_lights.has_value())
<< "No normalized player-light callback followed the native Switch 0x30 output write";
EXPECT_EQ(player_lights->raw_report, player_lights_report);
lvh::GamepadState state;
state.buttons.set(lvh::GamepadButton::a);
state.buttons.set(lvh::GamepadButton::guide);
state.buttons.set(lvh::GamepadButton::dpad_left);
state.buttons.set(lvh::GamepadButton::right_stick);
state.left_stick = {.x = 1.0F, .y = 0.0F};
state.right_stick = {.x = 0.0F, .y = -1.0F};
state.acceleration = lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F};
state.gyroscope = lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = -3.0F};
ASSERT_TRUE(created.adapter->set_state(state).ok());
const auto input = read_hid_report_matching(
reader.get(),
hid_interface->input_report_size,
5s,
[](const auto &report) {
return report.size() >= 6U && report[0] == 0x30U && report[3] == 0x08U && report[4] == 0x14U && report[5] == 0x08U;
}
);
ASSERT_TRUE(input.has_value()) << "No native Switch 0x30 input report reached the HID client";
ASSERT_EQ(input->size(), profile.input_report_size);
EXPECT_EQ(input->at(0), 0x30U);
EXPECT_EQ(input->at(3), 0x08U); // Nintendo A in the native right-button byte.
EXPECT_EQ(input->at(4), 0x14U); // R3 and Home.
EXPECT_EQ(input->at(5), 0x08U); // D-pad left.
EXPECT_EQ(input->at(6), 0xFFU);
EXPECT_EQ(input->at(7), 0x0FU);
EXPECT_EQ(input->at(8), 0x80U);
EXPECT_EQ(input->at(9), 0x00U);
EXPECT_EQ(input->at(10), 0x08U);
EXPECT_EQ(input->at(11), 0x00U);
const auto read_i16 = [&input](std::size_t offset) {
return static_cast<std::int16_t>(
static_cast<std::uint16_t>(input->at(offset)) |
(static_cast<std::uint16_t>(input->at(offset + 1U)) << 8U)
);
};
for (const auto offset : {13U, 25U, 37U}) {
EXPECT_EQ(read_i16(offset), 4096);
EXPECT_EQ(read_i16(offset + 2U), -4096);
EXPECT_EQ(read_i16(offset + 4U), 8192);
EXPECT_EQ(read_i16(offset + 6U), 43);
EXPECT_EQ(read_i16(offset + 8U), -14);
EXPECT_EQ(read_i16(offset + 10U), 29);
}
state.gyroscope = lvh::Vector3 {.x = -4.0F, .y = 5.0F, .z = 6.0F};
ASSERT_TRUE(created.adapter->set_state(state).ok());
const auto next_input = read_hid_report_matching(
reader.get(),
hid_interface->input_report_size,
5s,
[&input](const auto &report) {
return report.size() > 19U && report[0] == 0x30U && report[19] != input->at(19);
}
);
ASSERT_TRUE(next_input.has_value()) << "No second native Switch motion report reached the HID client";
ASSERT_EQ(next_input->size(), profile.input_report_size);
EXPECT_NE(next_input->at(1), input->at(1));
EXPECT_NE(next_input->at(19), input->at(19));
ASSERT_TRUE(created.adapter->close().ok());
}
TEST_F(WindowsConsumerTest, NativeXboxPidRumbleWritesAreNormalized) {
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
const auto profile = lvh::profiles::xbox_one();
const auto previous_paths = current_gamepad_interface_paths();
lvh::CreateGamepadOptions options;
options.profile = profile;
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
GamepadOutputCapture output_capture;
output_capture.attach(*created.adapter);
const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id);
ASSERT_TRUE(hid_interface.has_value()) << "The VHF Xbox HID interface was not enumerated";
if (hid_interface->output_report_size == 0U) {
GTEST_SKIP() << "The enumerated Xbox HID game-controller child is input-only on this host";
}
ASSERT_EQ(hid_interface->output_report_size, profile.output_report_size + 1U);
Handle hid {CreateFileW(
hid_interface->path.c_str(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
nullptr
)};
ASSERT_TRUE(hid) << "Unable to open the VHF Xbox HID interface: " << GetLastError();
const std::vector<std::uint8_t> report {0, 0x0F, 25, 50, 75, 100, 10, 0, 0};
DWORD bytes_written = 0;
ASSERT_TRUE(WriteFile(
hid.get(),
report.data(),
static_cast<DWORD>(report.size()),
&bytes_written,
nullptr
)) << "WriteFile failed: "
<< GetLastError();
ASSERT_EQ(bytes_written, report.size());
const auto rumble = output_capture.wait_for_rumble(true);
ASSERT_TRUE(rumble.has_value()) << "No normalized Xbox rumble callback followed the native HID output write";
EXPECT_EQ(rumble->low_frequency_rumble, 49151U);
EXPECT_EQ(rumble->high_frequency_rumble, 65535U);
ASSERT_TRUE(created.adapter->close().ok());
}
TEST_F(WindowsConsumerTest, XboxNativeInputSuppressesRepeatedHeldShoulderReports) {
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
auto profile = lvh::profiles::xbox_one();
// Avoid a report descriptor cached for an older installed-driver identity.
profile.vendor_id = 0x1234U;
profile.product_id = 0x5670U;
profile.version = 0x0001U;
const auto previous_paths = current_gamepad_interface_paths();
lvh::CreateGamepadOptions options;
options.profile = profile;
options.metadata.stable_id = "native-shoulder-hold-test";
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id);
ASSERT_TRUE(hid_interface.has_value()) << "The VHF Xbox HID interface was not enumerated";
ASSERT_EQ(hid_interface->input_report_size, profile.input_report_size + 1U);
Handle reader {CreateFileW(
hid_interface->path.c_str(),
GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
nullptr
)};
ASSERT_TRUE(reader) << "Unable to open the VHF Xbox HID interface: " << GetLastError();
lvh::GamepadState state;
state.buttons.set(lvh::GamepadButton::left_shoulder);
ASSERT_TRUE(created.adapter->set_state(state).ok());
const auto left_pressed = read_hid_report_matching(
reader.get(),
hid_interface->input_report_size,
5s,
[](const auto &report) {
return report.size() > 13U && (report[13] & 0x30U) == 0x10U;
}
);
ASSERT_TRUE(left_pressed.has_value()) << "No Xbox LB press reached the native HID client";
for (auto repeat = 0U; repeat < 32U; ++repeat) {
ASSERT_TRUE(created.adapter->set_state(state).ok());
}
EXPECT_FALSE(read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 200ms).has_value())
<< "An unchanged held LB state was resubmitted to the native HID client";
state.buttons.reset(lvh::GamepadButton::left_shoulder);
state.buttons.set(lvh::GamepadButton::right_shoulder);
ASSERT_TRUE(created.adapter->set_state(state).ok());
const auto right_pressed = read_hid_report_matching(
reader.get(),
hid_interface->input_report_size,
5s,
[](const auto &report) {
return report.size() > 13U && (report[13] & 0x30U) == 0x20U;
}
);
ASSERT_TRUE(right_pressed.has_value()) << "No Xbox RB press reached the native HID client";
state.buttons.reset(lvh::GamepadButton::right_shoulder);
ASSERT_TRUE(created.adapter->set_state(state).ok());
const auto released = read_hid_report_matching(
reader.get(),
hid_interface->input_report_size,
5s,
[](const auto &report) {
return report.size() > 13U && (report[13] & 0x30U) == 0U;
}
);
ASSERT_TRUE(released.has_value()) << "No Xbox shoulder release reached the native HID client";
ASSERT_TRUE(created.adapter->close().ok());
}
TEST_F(WindowsConsumerTest, BatteryStateIsAvailableThroughGetInputReport) {
const std::array profiles {
lvh::profiles::xbox_one(),
lvh::profiles::xbox_series(),
lvh::profiles::dualshock4(),
lvh::profiles::dualsense(),
lvh::profiles::switch_pro(),
};
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
for (const auto &profile : profiles) {
SCOPED_TRACE(profile.name);
const auto previous_paths = current_gamepad_interface_paths();
lvh::CreateGamepadOptions options;
options.profile = profile;
options.metadata.has_battery = true;
options.metadata.stable_id = profile.name;
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
ASSERT_TRUE(created.adapter->set_battery({
.state = lvh::GamepadBatteryState::discharging,
.percentage = 50,
})
.ok());
const auto &effective_profile = created.adapter->gamepad()->profile();
const auto hid_interface =
wait_for_new_interface(previous_paths, effective_profile.vendor_id, effective_profile.product_id);
ASSERT_TRUE(hid_interface.has_value()) << "The VHF " << profile.name << " HID interface was not enumerated";
Handle hid {CreateFileW(
hid_interface->path.c_str(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
nullptr
)};
ASSERT_TRUE(hid) << "Unable to open the VHF " << profile.name << " HID interface: " << GetLastError();
auto expected_report = created.adapter->gamepad()->last_input_report();
if (effective_profile.report_id == 0U) {
expected_report.insert(expected_report.begin(), 0U);
}
std::vector<std::uint8_t> queried_report(hid_interface->input_report_size, 0);
DWORD last_get_input_report_error = ERROR_SUCCESS;
auto received_expected_report = false;
const auto deadline = std::chrono::steady_clock::now() + 3s;
while (std::chrono::steady_clock::now() < deadline) {
std::ranges::fill(queried_report, std::uint8_t {});
queried_report[0] = effective_profile.report_id;
if (HidD_GetInputReport(hid.get(), queried_report.data(), static_cast<ULONG>(queried_report.size())) == FALSE) {
last_get_input_report_error = GetLastError();
std::this_thread::sleep_for(10ms);
continue;
}
last_get_input_report_error = ERROR_SUCCESS;
if (queried_report == expected_report) {
received_expected_report = true;
break;
}
std::this_thread::sleep_for(10ms);
}
ASSERT_TRUE(received_expected_report)
<< profile.name << " did not return the current input report before the deadline; last error: "
<< last_get_input_report_error;
EXPECT_TRUE(created.adapter->close().ok());
}
}
TEST_F(WindowsConsumerTest, XboxSeriesNativeInputCarriesShareButton) {
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
const auto profile = lvh::profiles::xbox_series();
const auto previous_paths = current_gamepad_interface_paths();
lvh::CreateGamepadOptions options;
options.profile = profile;
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
const auto hid_interface =
wait_for_new_interface(previous_paths, profile.vendor_id, lvh::detail::windows::xbox_series_windows_product_id);
ASSERT_TRUE(hid_interface.has_value()) << "The VHF Xbox Series HID interface was not enumerated";
ASSERT_EQ(hid_interface->input_report_size, profile.input_report_size + 1U);
ASSERT_EQ(hid_interface->output_report_size, profile.output_report_size + 1U);
Handle hid {CreateFileW(
hid_interface->path.c_str(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
nullptr
)};
ASSERT_TRUE(hid) << "Unable to open the VHF Xbox Series HID interface: " << GetLastError();
lvh::GamepadState state;
state.buttons.set(lvh::GamepadButton::a);
state.buttons.set(lvh::GamepadButton::back);
state.buttons.set(lvh::GamepadButton::start);
state.buttons.set(lvh::GamepadButton::guide);
state.buttons.set(lvh::GamepadButton::misc1);
ASSERT_TRUE(created.adapter->set_state(state).ok());
const auto input = read_hid_report_with_timeout(
hid.get(),
hid_interface->input_report_size,
5s
);
ASSERT_TRUE(input.has_value()) << "No Xbox Series input report reached the HID client";
ASSERT_EQ(input->size(), hid_interface->input_report_size);
EXPECT_EQ(input->at(0), 0U); // Unnumbered HID read buffers carry a leading zero.
EXPECT_NE(input->at(13) & 0x01U, 0U); // A
EXPECT_NE(input->at(13) & 0x40U, 0U); // Back/View
EXPECT_NE(input->at(13) & 0x80U, 0U); // Start/Menu
EXPECT_NE(input->at(14) & 0x08U, 0U); // Share / misc1
EXPECT_NE(input->at(16) & 0x01U, 0U); // Guide
ASSERT_TRUE(created.adapter->close().ok());
}
TEST_F(WindowsConsumerTest, NativeGenericPidInterfaceIsAdvertisedToDirectInput) {
const auto profile = lvh::profiles::generic_gamepad();
const auto previous_paths = current_gamepad_interface_paths();
lvh::RuntimeOptions runtime_options;
runtime_options.backend = lvh::BackendKind::platform_default;
auto runtime = lvh::Runtime::create(runtime_options);
ASSERT_NE(runtime, nullptr);
ASSERT_TRUE(runtime->capabilities().supports_gamepad)
<< "The installed libvirtualhid Windows driver is required for this integration test";
lvh::CreateGamepadOptions options;
options.profile = profile;
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id);
ASSERT_TRUE(hid_interface.has_value()) << "The VHF Generic PID HID interface was not enumerated";
EXPECT_EQ(hid_interface->usage, 0x04U); // DirectInput PID requires a Joystick TLC.
ASSERT_EQ(hid_interface->output_report_size, lvh::detail::windows::generic_pid_output_report_size);
void *raw_direct_input = nullptr;
ASSERT_TRUE(SUCCEEDED(DirectInput8Create(
GetModuleHandleW(nullptr),
DIRECTINPUT_VERSION,
IID_IDirectInput8W,
&raw_direct_input,
nullptr
)));
ComInterface<IDirectInput8W> direct_input {static_cast<IDirectInput8W *>(raw_direct_input)};
const auto direct_input_guid = wait_for_directinput_device(
*direct_input,
profile.vendor_id,
profile.product_id
);
ASSERT_TRUE(direct_input_guid.has_value()) << "DirectInput did not enumerate the VHF Generic Controller";
IDirectInputDevice8W *raw_direct_input_device = nullptr;
ASSERT_TRUE(SUCCEEDED(direct_input->CreateDevice(
*direct_input_guid,
&raw_direct_input_device,
nullptr
)));
ComInterface<IDirectInputDevice8W> direct_input_device {raw_direct_input_device};
DIDEVCAPS direct_input_caps {};
direct_input_caps.dwSize = sizeof(direct_input_caps);
ASSERT_TRUE(SUCCEEDED(direct_input_device->GetCapabilities(&direct_input_caps)));
EXPECT_NE(direct_input_caps.dwFlags & DIDC_FORCEFEEDBACK, 0U);
bool has_constant_force = false;
ASSERT_TRUE(SUCCEEDED(direct_input_device->EnumEffects(
find_constant_force_effect,
&has_constant_force,
DIEFT_ALL
)));
ASSERT_TRUE(has_constant_force) << "DirectInput did not advertise GUID_ConstantForce";
std::vector<DWORD> force_feedback_axes;
ASSERT_TRUE(SUCCEEDED(direct_input_device->EnumObjects(
collect_force_feedback_axes,
&force_feedback_axes,
DIDFT_AXIS
)));
ASSERT_FALSE(force_feedback_axes.empty()) << "DirectInput did not expose a force-feedback actuator axis";
}