From b4fec822645f4980f25c5ab7e7e5aa402d01f2eb Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:17:23 -0400 Subject: [PATCH 01/12] feat: Add virtualhid_control diagnostic UI tool Introduces virtualhid_control, a native Windows UI for creating, controlling, and testing virtual gamepads. The tool provides interactive gamepad creation from built-in profiles, button and stick controls, device node inspection, and output report monitoring. Includes CMake build configuration with support for static runtime linking on Windows (MinGW/UCRT64 and MSVC) and platform-specific flags. Documentation covers platform limitations, build options, driver packaging integration, and usage instructions. Cross-platform stub provided for non-Windows platforms. --- CMakeLists.txt | 7 + README.md | 2 + cmake/packaging/windows.cmake | 10 + docs/platform-support.md | 9 + docs/usage.md | 27 + docs/windows-driver.md | 22 +- src/CMakeLists.txt | 2 +- tools/CMakeLists.txt | 48 ++ tools/virtualhid_control.cpp | 915 ++++++++++++++++++++++++++++++++++ 9 files changed, 1036 insertions(+), 6 deletions(-) create mode 100644 tools/CMakeLists.txt create mode 100644 tools/virtualhid_control.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1af5407..ccc9ad1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,9 @@ include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_version.cmake") option(BUILD_DOCS "Build documentation" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(BUILD_TESTS "Build tests" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(BUILD_EXAMPLES "Build examples" ${LIBVIRTUALHID_IS_TOP_LEVEL}) +option(LIBVIRTUALHID_BUILD_TOOLS "Build libvirtualhid diagnostic tools" ${LIBVIRTUALHID_IS_TOP_LEVEL}) +option(LIBVIRTUALHID_TOOLS_STATIC_RUNTIME "Link libvirtualhid tools to static compiler runtimes where supported" ON) +option(LIBVIRTUALHID_TOOLS_FULLY_STATIC "Attempt to link libvirtualhid tools as fully static binaries" OFF) option(LIBVIRTUALHID_ENABLE_XTEST "Enable X11/XTest keyboard and mouse fallback on Linux" ON) option(LIBVIRTUALHID_BUILD_WINDOWS_DRIVER "Build the Windows UMDF2 driver package with the WDK/MSVC toolchain" OFF) option(LIBVIRTUALHID_INSTALL @@ -107,6 +110,10 @@ if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) add_subdirectory(examples) endif() + if(LIBVIRTUALHID_BUILD_TOOLS) + add_subdirectory(tools) + endif() + if(BUILD_TESTS) enable_testing() add_subdirectory(tests) diff --git a/README.md b/README.md index 30081ea..d24419d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ behind backend implementations. HID Framework, with keyboard and mouse support through normal Win32 APIs. - Output callbacks for profile-specific feedback such as rumble, LEDs, adaptive triggers, and raw HID output reports when available. +- An optional `virtualhid_control` native UI tool for creating, controlling, and + inspecting test gamepads through the public C++ API. - CMake consumption through installed packages, vendored source, `add_subdirectory`, or `FetchContent`. diff --git a/cmake/packaging/windows.cmake b/cmake/packaging/windows.cmake index 5e539dd..ad8cc5e 100644 --- a/cmake/packaging/windows.cmake +++ b/cmake/packaging/windows.cmake @@ -30,10 +30,20 @@ if(NOT TARGET gamepad_adapter) "gamepad_adapter validation tool can be packaged.") endif() +if(NOT TARGET virtualhid_control) + message(FATAL_ERROR + "The Windows driver installer requires LIBVIRTUALHID_BUILD_TOOLS=ON " + "so the virtualhid_control UI tool can be packaged.") +endif() + install(TARGETS gamepad_adapter RUNTIME DESTINATION "tools/windows" COMPONENT driver) +install(TARGETS virtualhid_control + RUNTIME DESTINATION "tools/windows" + COMPONENT driver) + if(LIBVIRTUALHID_DRIVER_TEST_CERTIFICATE) install(FILES "${LIBVIRTUALHID_DRIVER_TEST_CERTIFICATE}" DESTINATION "certificates" diff --git a/docs/platform-support.md b/docs/platform-support.md index ca8e7d4..73c7346 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -56,6 +56,15 @@ reports, and output reports matter for controller compatibility. Keyboard and pointer devices prefer `uinput` because those devices map naturally to Linux input devices. +The optional `virtualhid_control` diagnostic UI is currently implemented with +native Windows controls. A future Linux UI should use a native toolkit that can +be packaged as a release asset. Static Linux linking is possible only when the +target distribution provides static archives for all selected backend and UI +dependencies, including `libevdev` and any enabled X11/XTest libraries. Many +distro toolchains intentionally omit some static archives, so release packaging +should keep full static linking as a packaging-mode choice rather than an +unconditional default. + ### Permissions Linux deployment is usually a permissions problem, not a kernel-module problem. diff --git a/docs/usage.md b/docs/usage.md index b0b5635..da34828 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -56,6 +56,15 @@ unless they explicitly enable additional options. level project. - `BUILD_DOCS`: build Doxygen documentation when this repository is the top level project. +- `LIBVIRTUALHID_BUILD_TOOLS`: build diagnostic tool binaries, including + `virtualhid_control`, when this repository is the top level project. +- `LIBVIRTUALHID_TOOLS_STATIC_RUNTIME`: link diagnostic tools against static + compiler runtimes where supported. This is enabled by default so the Windows + MinGW/UCRT64 `virtualhid_control.exe` does not need adjacent MinGW runtime + DLLs. +- `LIBVIRTUALHID_TOOLS_FULLY_STATIC`: pass full static link flags for + diagnostic tools. On Linux this also requires static archives for backend + dependencies such as `libevdev`, and may not be supported by every distro. - `LIBVIRTUALHID_INSTALL`: install targets, headers, and CMake package files. This defaults to on for direct builds and off when consumed by another CMake project. @@ -71,6 +80,24 @@ Linux consumers need the backend development packages used by the build, such as with MSVC or MinGW/UCRT64; the UMDF driver package is a separate WDK/MSVC build artifact. +## Diagnostic UI + +`virtualhid_control` is an optional native diagnostic UI binary: + +```bash +virtualhid_control +``` + +The current Windows UI can create gamepads from the built-in profiles, submit +buttons, sticks, and triggers, show backend capabilities, list device nodes +reported for UI-created devices, and display gamepad output reports delivered +through the normal callback path. + +External devices created by another process, such as Sunshine, are not +enumerated yet. That requires backend protocol support so the Windows driver or +Linux backend can expose cross-process device snapshots without letting two +processes race to control the same virtual device. + ## Public API Shape The API centers on portable device concepts: diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 3836445..d52c0b1 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -22,9 +22,10 @@ User-mode virtual HID driver package that enables compatible apps to create virt Virtual HID Driver installs the user-mode driver component used by compatible applications to create virtual HID gamepads on Windows. -The package has no standalone user interface. After installation, compatible -applications can request virtual HID gamepads, and Windows applications that -understand standard HID gamepads can discover those devices. +The package includes a local diagnostic UI for creating and testing virtual +gamepads. Compatible applications can also request virtual HID gamepads, and +Windows applications that understand standard HID gamepads can discover those +devices. ``` ## Architecture @@ -51,9 +52,9 @@ Build the UMDF package with a Visual Studio generator and the WDK installed: ```powershell cmake -S . -B cmake-build-windows-driver -G "Visual Studio 17 2022" -A x64 ` -DLIBVIRTUALHID_BUILD_WINDOWS_DRIVER=ON -DLIBVIRTUALHID_ENABLE_PACKAGING=ON ` - -DBUILD_TESTS=OFF -DBUILD_EXAMPLES=ON + -DBUILD_TESTS=OFF -DBUILD_EXAMPLES=ON -DLIBVIRTUALHID_BUILD_TOOLS=ON cmake --build cmake-build-windows-driver --config Release ` - --target libvirtualhid_windows_catalog gamepad_adapter + --target libvirtualhid_windows_catalog gamepad_adapter virtualhid_control cpack -G WIX -C Release --config .\cmake-build-windows-driver\CPackConfig.cmake ``` @@ -86,6 +87,7 @@ The WiX installer also places validation files under the default install root, - `scripts\windows\test-installed-driver.ps1` - `scripts\windows\test-browser-gamepad.ps1` - `tools\windows\gamepad_adapter.exe` +- `tools\windows\virtualhid_control.exe` The install helper stages the INF with `pnputil`, updates an existing `ROOT\LIBVIRTUALHID` device when present, and creates that root-enumerated @@ -109,6 +111,16 @@ Then open `https://hardwaretester.com/gamepad` in a normal desktop browser and press one of the held virtual buttons if the browser requires a gamepad activation event. +For interactive local validation, run: + +```powershell +tools\windows\virtualhid_control.exe +``` + +The native UI can create, control, and monitor gamepads that it owns. Devices +created by another process are not listed yet; that requires a future Windows +control-protocol extension for cross-process diagnostics. + ## Installation Notes The driver binary is a user-mode UMDF DLL installed through the Windows Driver diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 51542ff..ceaaacf 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -78,7 +78,7 @@ target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_23) set_target_properties(${PROJECT_NAME} PROPERTIES EXPORT_NAME libvirtualhid OUTPUT_NAME virtualhid) -if(MSVC AND LIBVIRTUALHID_BUILD_WINDOWS_DRIVER) +if(MSVC AND (LIBVIRTUALHID_BUILD_WINDOWS_DRIVER OR (LIBVIRTUALHID_BUILD_TOOLS AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME))) set_property(TARGET ${PROJECT_NAME} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") endif() diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt new file mode 100644 index 0000000..7a785df --- /dev/null +++ b/tools/CMakeLists.txt @@ -0,0 +1,48 @@ +add_executable(virtualhid_control + "${CMAKE_CURRENT_SOURCE_DIR}/virtualhid_control.cpp") + +target_link_libraries(virtualhid_control + PRIVATE + libvirtualhid::libvirtualhid) + +if(WIN32) + set_target_properties(virtualhid_control PROPERTIES + WIN32_EXECUTABLE TRUE) + target_compile_definitions(virtualhid_control + PRIVATE + NOMINMAX + WIN32_LEAN_AND_MEAN + _WIN32_WINNT=0x0600) + target_link_libraries(virtualhid_control + PRIVATE + comctl32 + gdi32 + user32) +endif() + +if(MSVC AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME) + set_property(TARGET virtualhid_control PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +endif() + +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME) + target_link_options(virtualhid_control + PRIVATE + -static-libgcc + -static-libstdc++) + + if(WIN32) + target_link_options(virtualhid_control + PRIVATE + -static) + endif() +endif() + +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND LIBVIRTUALHID_TOOLS_FULLY_STATIC) + target_link_options(virtualhid_control + PRIVATE + -static) +endif() + +install(TARGETS virtualhid_control + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp new file mode 100644 index 0000000..b419019 --- /dev/null +++ b/tools/virtualhid_control.cpp @@ -0,0 +1,915 @@ +/** + * @file tools/virtualhid_control.cpp + * @brief Native UI for creating and testing libvirtualhid gamepads. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include + +#if defined(_WIN32) + + #ifndef NOMINMAX + #define NOMINMAX + #endif + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef UNICODE + #define UNICODE + #endif + #ifndef _UNICODE + #define _UNICODE + #endif + + #include + #include + +namespace { + + constexpr auto output_changed_message = WM_APP + 1U; + constexpr auto slider_scale = 100; + + enum ControlId : int { + profile_combo_id = 1000, + create_button_id, + device_list_id, + reset_button_id, + close_button_id, + state_text_id, + node_list_id, + output_list_id, + button_base_id = 2000, + axis_base_id = 3000, + }; + + struct ProfileChoice { + std::wstring_view id; + std::wstring_view label; + lvh::GamepadProfileKind kind; + lvh::ClientControllerType client_type; + }; + + struct ButtonChoice { + std::wstring_view label; + lvh::GamepadButton button; + }; + + struct AxisChoice { + std::wstring_view label; + int minimum; + int maximum; + }; + + constexpr std::array profile_choices { + ProfileChoice {L"generic", L"Generic HID", lvh::GamepadProfileKind::generic, lvh::ClientControllerType::unknown}, + ProfileChoice {L"x360", L"Xbox 360", lvh::GamepadProfileKind::xbox_360, lvh::ClientControllerType::xbox}, + ProfileChoice {L"xone", L"Xbox One", lvh::GamepadProfileKind::xbox_one, lvh::ClientControllerType::xbox}, + ProfileChoice {L"xseries", L"Xbox Series", lvh::GamepadProfileKind::xbox_series, lvh::ClientControllerType::xbox}, + ProfileChoice {L"ds4", L"DualShock 4", lvh::GamepadProfileKind::dualshock4, lvh::ClientControllerType::playstation}, + ProfileChoice {L"ds5", L"DualSense", lvh::GamepadProfileKind::dualsense, lvh::ClientControllerType::playstation}, + ProfileChoice {L"switch", L"Switch Pro", lvh::GamepadProfileKind::switch_pro, lvh::ClientControllerType::nintendo}, + }; + + constexpr std::array button_choices { + ButtonChoice {L"A", lvh::GamepadButton::a}, + ButtonChoice {L"B", lvh::GamepadButton::b}, + ButtonChoice {L"X", lvh::GamepadButton::x}, + ButtonChoice {L"Y", lvh::GamepadButton::y}, + ButtonChoice {L"Back", lvh::GamepadButton::back}, + ButtonChoice {L"Start", lvh::GamepadButton::start}, + ButtonChoice {L"Guide", lvh::GamepadButton::guide}, + ButtonChoice {L"L3", lvh::GamepadButton::left_stick}, + ButtonChoice {L"R3", lvh::GamepadButton::right_stick}, + ButtonChoice {L"LB", lvh::GamepadButton::left_shoulder}, + ButtonChoice {L"RB", lvh::GamepadButton::right_shoulder}, + ButtonChoice {L"D-pad Up", lvh::GamepadButton::dpad_up}, + ButtonChoice {L"D-pad Down", lvh::GamepadButton::dpad_down}, + ButtonChoice {L"D-pad Left", lvh::GamepadButton::dpad_left}, + ButtonChoice {L"D-pad Right", lvh::GamepadButton::dpad_right}, + ButtonChoice {L"Misc", lvh::GamepadButton::misc1}, + ButtonChoice {L"Touchpad", lvh::GamepadButton::touchpad}, + ButtonChoice {L"Paddle 1", lvh::GamepadButton::paddle1}, + ButtonChoice {L"Paddle 2", lvh::GamepadButton::paddle2}, + ButtonChoice {L"Paddle 3", lvh::GamepadButton::paddle3}, + ButtonChoice {L"Paddle 4", lvh::GamepadButton::paddle4}, + }; + + constexpr std::array axis_choices { + AxisChoice {L"Left X", -slider_scale, slider_scale}, + AxisChoice {L"Left Y", -slider_scale, slider_scale}, + AxisChoice {L"Right X", -slider_scale, slider_scale}, + AxisChoice {L"Right Y", -slider_scale, slider_scale}, + AxisChoice {L"Left Trigger", 0, slider_scale}, + AxisChoice {L"Right Trigger", 0, slider_scale}, + }; + + struct OutputLogEntry { + std::uint64_t sequence = 0; + lvh::GamepadOutput output; + }; + + struct ControlledGamepad { + std::wstring profile_label; + std::unique_ptr adapter; + std::vector outputs; + }; + + std::wstring widen(std::string_view value) { + if (value.empty()) { + return {}; + } + + const auto required = ::MultiByteToWideChar( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + nullptr, + 0 + ); + if (required <= 0) { + return std::wstring {value.begin(), value.end()}; + } + + std::wstring result(static_cast(required), L'\0'); + const auto copied = ::MultiByteToWideChar( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + result.data(), + required + ); + if (copied <= 0) { + return std::wstring {value.begin(), value.end()}; + } + return result; + } + + std::wstring device_type_name(lvh::DeviceType type) { + switch (type) { + using enum lvh::DeviceType; + + case gamepad: + return L"gamepad"; + case keyboard: + return L"keyboard"; + case mouse: + return L"mouse"; + case touchscreen: + return L"touchscreen"; + case trackpad: + return L"trackpad"; + case pen_tablet: + return L"pen tablet"; + } + return L"unknown"; + } + + std::wstring node_kind_name(lvh::DeviceNodeKind kind) { + switch (kind) { + using enum lvh::DeviceNodeKind; + + case input_event: + return L"input"; + case joystick: + return L"joystick"; + case hidraw: + return L"hidraw"; + case sysfs: + return L"sysfs"; + case other: + return L"other"; + } + return L"other"; + } + + std::wstring output_kind_name(lvh::GamepadOutputKind kind) { + switch (kind) { + using enum lvh::GamepadOutputKind; + + case rumble: + return L"rumble"; + case rgb_led: + return L"rgb led"; + case adaptive_triggers: + return L"adaptive triggers"; + case raw_report: + return L"raw report"; + case trigger_rumble: + return L"trigger rumble"; + } + return L"raw report"; + } + + std::wstring raw_hex(const std::vector &bytes) { + std::wostringstream stream; + stream << std::hex << std::setfill(L'0'); + for (const auto value : bytes) { + stream << std::setw(2) << static_cast(value); + } + return stream.str(); + } + + std::optional profile_for_choice(const ProfileChoice &choice) { + switch (choice.kind) { + using enum lvh::GamepadProfileKind; + + case generic: + return lvh::profiles::generic_gamepad(); + case xbox_360: + return lvh::profiles::xbox_360(); + case xbox_one: + return lvh::profiles::xbox_one(); + case xbox_series: + return lvh::profiles::xbox_series(); + case dualshock4: + return lvh::profiles::dualshock4(); + case dualsense: + return lvh::profiles::dualsense(); + case switch_pro: + return lvh::profiles::switch_pro(); + } + return std::nullopt; + } + + int axis_to_slider(float value) { + return static_cast(std::lround(std::clamp(value, -1.0F, 1.0F) * static_cast(slider_scale))); + } + + int trigger_to_slider(float value) { + return static_cast(std::lround(std::clamp(value, 0.0F, 1.0F) * static_cast(slider_scale))); + } + + float slider_to_float(LRESULT value) { + return static_cast(value) / static_cast(slider_scale); + } + + class ControlWindow { + public: + explicit ControlWindow(HINSTANCE instance): + instance_ {instance} { + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + runtime_ = lvh::Runtime::create(options); + } + + int run() { + INITCOMMONCONTROLSEX controls {}; + controls.dwSize = sizeof(controls); + controls.dwICC = ICC_BAR_CLASSES | ICC_STANDARD_CLASSES; + static_cast(::InitCommonControlsEx(&controls)); + + WNDCLASSEXW window_class {}; + window_class.cbSize = sizeof(window_class); + window_class.hInstance = instance_; + window_class.lpfnWndProc = &ControlWindow::window_proc; + window_class.lpszClassName = L"LibVirtualHidControlWindow"; + window_class.hCursor = ::LoadCursorW(nullptr, IDC_ARROW); + window_class.hIcon = ::LoadIconW(nullptr, IDI_APPLICATION); + window_class.hIconSm = window_class.hIcon; + window_class.hbrBackground = reinterpret_cast(COLOR_WINDOW + 1); + + if (::RegisterClassExW(&window_class) == 0U) { + return 1; + } + + window_ = ::CreateWindowExW( + 0, + window_class.lpszClassName, + L"libvirtualhid control", + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, + CW_USEDEFAULT, + 980, + 700, + nullptr, + nullptr, + instance_, + this + ); + if (window_ == nullptr) { + return 1; + } + + ::ShowWindow(window_, SW_SHOW); + ::UpdateWindow(window_); + + MSG message {}; + while (::GetMessageW(&message, nullptr, 0, 0) > 0) { + ::TranslateMessage(&message); + ::DispatchMessageW(&message); + } + return static_cast(message.wParam); + } + + private: + static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + auto *self = reinterpret_cast(::GetWindowLongPtrW(window, GWLP_USERDATA)); + if (message == WM_NCCREATE) { + const auto *create = reinterpret_cast(lparam); + self = static_cast(create->lpCreateParams); + self->window_ = window; + ::SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(self)); + } + + if (self != nullptr) { + return self->handle_message(message, wparam, lparam); + } + return ::DefWindowProcW(window, message, wparam, lparam); + } + + LRESULT handle_message(UINT message, WPARAM wparam, LPARAM lparam) { + switch (message) { + case WM_CREATE: + create_controls(); + refresh_all(); + return 0; + case WM_SIZE: + layout_controls(LOWORD(lparam), HIWORD(lparam)); + return 0; + case WM_COMMAND: + handle_command(LOWORD(wparam), HIWORD(wparam)); + return 0; + case WM_HSCROLL: + handle_slider(reinterpret_cast(lparam)); + return 0; + case output_changed_message: + refresh_selected_device(); + return 0; + case WM_DESTROY: + close_all_devices(); + ::PostQuitMessage(0); + return 0; + default: + break; + } + return ::DefWindowProcW(window_, message, wparam, lparam); + } + + HWND create_child( + const wchar_t *class_name, + const wchar_t *text, + DWORD style, + int id + ) { + auto *font = reinterpret_cast(::GetStockObject(DEFAULT_GUI_FONT)); + auto *control = ::CreateWindowExW( + 0, + class_name, + text, + WS_CHILD | WS_VISIBLE | style, + 0, + 0, + 10, + 10, + window_, + reinterpret_cast(static_cast(id)), + instance_, + nullptr + ); + if (control != nullptr) { + ::SendMessageW(control, WM_SETFONT, reinterpret_cast(font), TRUE); + } + return control; + } + + void create_controls() { + backend_text_ = create_child(L"STATIC", L"", SS_LEFT, 0); + profile_combo_ = create_child(WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL, profile_combo_id); + create_button_ = create_child(L"BUTTON", L"Create", BS_PUSHBUTTON, create_button_id); + device_list_ = create_child(L"LISTBOX", L"", LBS_NOTIFY | WS_BORDER | WS_VSCROLL, device_list_id); + reset_button_ = create_child(L"BUTTON", L"Reset", BS_PUSHBUTTON, reset_button_id); + close_button_ = create_child(L"BUTTON", L"Close", BS_PUSHBUTTON, close_button_id); + state_text_ = create_child(L"STATIC", L"No device selected.", SS_LEFT, state_text_id); + nodes_list_ = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, node_list_id); + output_list_ = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, output_list_id); + + for (const auto &choice : profile_choices) { + ::SendMessageW(profile_combo_, CB_ADDSTRING, 0, reinterpret_cast(choice.label.data())); + } + ::SendMessageW(profile_combo_, CB_SETCURSEL, 4, 0); + + for (std::size_t index = 0; index < button_choices.size(); ++index) { + button_controls_[index] = create_child( + L"BUTTON", + button_choices[index].label.data(), + BS_AUTOCHECKBOX | BS_PUSHLIKE, + button_base_id + static_cast(index) + ); + } + + for (std::size_t index = 0; index < axis_choices.size(); ++index) { + axis_labels_[index] = create_child(L"STATIC", axis_choices[index].label.data(), SS_LEFT, 0); + axis_sliders_[index] = create_child( + TRACKBAR_CLASSW, + L"", + TBS_AUTOTICKS, + axis_base_id + static_cast(index) + ); + ::SendMessageW( + axis_sliders_[index], + TBM_SETRANGE, + TRUE, + MAKELPARAM(axis_choices[index].minimum, axis_choices[index].maximum) + ); + ::SendMessageW(axis_sliders_[index], TBM_SETPOS, TRUE, 0); + } + } + + void layout_controls(int width, int height) { + constexpr auto margin = 12; + constexpr auto gap = 10; + constexpr auto row = 28; + constexpr auto left_width = 280; + constexpr auto top_height = 72; + constexpr auto button_width = 112; + constexpr auto button_height = 28; + constexpr auto button_columns = 4; + + const auto right_x = margin + left_width + gap; + const auto right_width = std::max(320, width - right_x - margin); + const auto list_top = margin + top_height; + const auto left_list_height = std::max(120, height - list_top - margin - row - gap); + + move(profile_combo_, margin, margin, 170, 200); + move(create_button_, margin + 180, margin, 90, row); + move(backend_text_, margin, margin + 38, left_width, 28); + move(device_list_, margin, list_top, left_width, left_list_height); + move(reset_button_, margin, height - margin - row, 90, row); + move(close_button_, margin + 100, height - margin - row, 90, row); + + move(state_text_, right_x, margin, right_width, 86); + + auto buttons_top = margin + 96; + for (std::size_t index = 0; index < button_controls_.size(); ++index) { + const auto column = static_cast(index % button_columns); + const auto row_index = static_cast(index / button_columns); + move( + button_controls_[index], + right_x + column * (button_width + gap), + buttons_top + row_index * (button_height + gap), + button_width, + button_height + ); + } + + const auto slider_top = buttons_top + 6 * (button_height + gap) + gap; + const auto slider_width = std::max(180, (right_width - gap) / 2); + for (std::size_t index = 0; index < axis_sliders_.size(); ++index) { + const auto column = static_cast(index % 2U); + const auto row_index = static_cast(index / 2U); + const auto x = right_x + column * (slider_width + gap); + const auto y = slider_top + row_index * 54; + move(axis_labels_[index], x, y, slider_width, 18); + move(axis_sliders_[index], x, y + 18, slider_width, 34); + } + + const auto bottom_top = slider_top + 3 * 54 + gap; + const auto bottom_height = std::max(110, height - bottom_top - margin); + move(nodes_list_, right_x, bottom_top, (right_width - gap) / 2, bottom_height); + move(output_list_, right_x + (right_width + gap) / 2, bottom_top, (right_width - gap) / 2, bottom_height); + } + + static void move(HWND control, int x, int y, int width, int height) { + if (control != nullptr) { + ::SetWindowPos(control, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE); + } + } + + void handle_command(int id, int notification) { + if (id == create_button_id && notification == BN_CLICKED) { + create_gamepad(); + return; + } + if (id == reset_button_id && notification == BN_CLICKED) { + reset_selected_device(); + return; + } + if (id == close_button_id && notification == BN_CLICKED) { + close_selected_device(); + return; + } + if (id == device_list_id && notification == LBN_SELCHANGE) { + const auto selection = ::SendMessageW(device_list_, LB_GETCURSEL, 0, 0); + if (selection != LB_ERR) { + selected_id_ = static_cast(::SendMessageW(device_list_, LB_GETITEMDATA, static_cast(selection), 0)); + refresh_selected_device(); + } + return; + } + if (id >= button_base_id && id < button_base_id + static_cast(button_choices.size()) && notification == BN_CLICKED) { + set_selected_button(static_cast(id - button_base_id)); + } + } + + void handle_slider(HWND slider) { + for (std::size_t index = 0; index < axis_sliders_.size(); ++index) { + if (axis_sliders_[index] == slider) { + set_selected_axis(index); + return; + } + } + } + + void create_gamepad() { + const auto selection = ::SendMessageW(profile_combo_, CB_GETCURSEL, 0, 0); + if (selection == CB_ERR || selection < 0 || static_cast(selection) >= profile_choices.size()) { + show_error(L"Select a profile first."); + return; + } + + const auto &choice = profile_choices[static_cast(selection)]; + const auto profile = profile_for_choice(choice); + if (!profile) { + show_error(L"Could not create the selected profile."); + return; + } + + lvh::CreateGamepadOptions options; + options.profile = *profile; + options.metadata.global_index = static_cast(next_metadata_index_++); + options.metadata.client_relative_index = 0; + options.metadata.client_type = choice.client_type; + options.metadata.has_motion_sensors = profile->capabilities.supports_motion; + options.metadata.has_touchpad = profile->capabilities.supports_touchpad; + options.metadata.has_rgb_led = profile->capabilities.supports_rgb_led; + options.metadata.has_battery = profile->capabilities.supports_battery; + options.metadata.stable_id = "libvirtualhid-control-" + std::to_string(options.metadata.global_index); + + auto created = lvh::GamepadStateAdapter::create(*runtime_, options); + if (!created) { + show_error(widen(created.status.message())); + return; + } + + auto *gamepad = created.adapter->gamepad(); + if (gamepad == nullptr) { + show_error(L"Created gamepad handle is missing."); + return; + } + + const auto id = gamepad->device_id(); + created.adapter->set_output_callback([this, id](const lvh::GamepadOutput &output) { + record_output(id, output); + }); + + ControlledGamepad device; + device.profile_label = std::wstring {choice.label}; + device.adapter = std::move(created.adapter); + + { + std::lock_guard lock {mutex_}; + devices_[id] = std::move(device); + } + selected_id_ = id; + refresh_all(); + } + + void reset_selected_device() { + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + status = device->adapter->set_state({}); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void close_selected_device() { + std::unique_ptr adapter; + { + std::lock_guard lock {mutex_}; + if (selected_id_ == 0) { + return; + } + const auto iter = devices_.find(selected_id_); + if (iter == devices_.end()) { + return; + } + adapter = std::move(iter->second.adapter); + devices_.erase(iter); + selected_id_ = devices_.empty() ? 0 : devices_.begin()->first; + } + if (adapter) { + if (const auto status = adapter->close(); !status.ok()) { + show_error(widen(status.message())); + } + } + refresh_all(); + } + + void set_selected_button(std::size_t index) { + const auto control_id = button_base_id + static_cast(index); + const auto pressed = ::IsDlgButtonChecked(window_, control_id) == BST_CHECKED; + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + status = device->adapter->set_button(button_choices[index].button, pressed); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void set_selected_axis(std::size_t index) { + auto status = lvh::OperationStatus::success(); + const auto position = ::SendMessageW(axis_sliders_[index], TBM_GETPOS, 0, 0); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + auto state = device->adapter->state(); + const auto value = slider_to_float(position); + switch (index) { + case 0: + state.left_stick.x = value; + status = device->adapter->set_left_stick(state.left_stick); + break; + case 1: + state.left_stick.y = value; + status = device->adapter->set_left_stick(state.left_stick); + break; + case 2: + state.right_stick.x = value; + status = device->adapter->set_right_stick(state.right_stick); + break; + case 3: + state.right_stick.y = value; + status = device->adapter->set_right_stick(state.right_stick); + break; + case 4: + status = device->adapter->set_left_trigger(value); + break; + case 5: + status = device->adapter->set_right_trigger(value); + break; + default: + break; + } + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void refresh_all() { + refresh_backend_text(); + refresh_device_list(); + refresh_selected_device(); + } + + void refresh_backend_text() { + const auto &caps = runtime_->capabilities(); + std::wstring text = L"Backend: " + widen(caps.backend_name); + text += caps.supports_gamepad ? L" | gamepad available" : L" | gamepad unavailable"; + text += caps.supports_output_reports ? L" | output reports available" : L" | output reports unavailable"; + ::SetWindowTextW(backend_text_, text.c_str()); + } + + void refresh_device_list() { + ::SendMessageW(device_list_, LB_RESETCONTENT, 0, 0); + std::lock_guard lock {mutex_}; + for (const auto &[id, device] : devices_) { + std::wostringstream label; + label << L"#" << id << L" " << device.profile_label; + const auto index = ::SendMessageW(device_list_, LB_ADDSTRING, 0, reinterpret_cast(label.str().c_str())); + if (index != LB_ERR) { + ::SendMessageW(device_list_, LB_SETITEMDATA, static_cast(index), static_cast(id)); + if (id == selected_id_) { + ::SendMessageW(device_list_, LB_SETCURSEL, static_cast(index), 0); + } + } + } + } + + void refresh_selected_device() { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + const auto enabled = device != nullptr; + ::EnableWindow(reset_button_, enabled); + ::EnableWindow(close_button_, enabled); + for (auto *control : button_controls_) { + ::EnableWindow(control, enabled); + } + for (auto *slider : axis_sliders_) { + ::EnableWindow(slider, enabled); + } + + if (device == nullptr) { + ::SetWindowTextW(state_text_, L"No device selected. Create a gamepad to begin testing."); + ::SendMessageW(nodes_list_, LB_RESETCONTENT, 0, 0); + ::SendMessageW(output_list_, LB_RESETCONTENT, 0, 0); + for (std::size_t index = 0; index < button_controls_.size(); ++index) { + ::CheckDlgButton(window_, button_base_id + static_cast(index), BST_UNCHECKED); + } + reset_sliders(); + return; + } + + const auto *gamepad = device->adapter->gamepad(); + const auto &profile = gamepad->profile(); + const auto state = device->adapter->state(); + + std::wostringstream state_text; + state_text << device->profile_label << L" #" << gamepad->device_id() << L"\r\n" + << device_type_name(profile.device_type) << L" | " << widen(profile.name) << L"\r\n" + << L"L(" << state.left_stick.x << L", " << state.left_stick.y << L") " + << L"R(" << state.right_stick.x << L", " << state.right_stick.y << L") " + << L"LT " << state.left_trigger << L" RT " << state.right_trigger << L" | " + << gamepad->submit_count() << L" submits"; + ::SetWindowTextW(state_text_, state_text.str().c_str()); + + for (std::size_t index = 0; index < button_choices.size(); ++index) { + ::CheckDlgButton( + window_, + button_base_id + static_cast(index), + state.buttons.test(button_choices[index].button) ? BST_CHECKED : BST_UNCHECKED + ); + } + + ::SendMessageW(axis_sliders_[0], TBM_SETPOS, TRUE, axis_to_slider(state.left_stick.x)); + ::SendMessageW(axis_sliders_[1], TBM_SETPOS, TRUE, axis_to_slider(state.left_stick.y)); + ::SendMessageW(axis_sliders_[2], TBM_SETPOS, TRUE, axis_to_slider(state.right_stick.x)); + ::SendMessageW(axis_sliders_[3], TBM_SETPOS, TRUE, axis_to_slider(state.right_stick.y)); + ::SendMessageW(axis_sliders_[4], TBM_SETPOS, TRUE, trigger_to_slider(state.left_trigger)); + ::SendMessageW(axis_sliders_[5], TBM_SETPOS, TRUE, trigger_to_slider(state.right_trigger)); + + refresh_nodes(*gamepad); + refresh_outputs(*device); + } + + void reset_sliders() { + for (std::size_t index = 0; index < axis_sliders_.size(); ++index) { + ::SendMessageW(axis_sliders_[index], TBM_SETPOS, TRUE, 0); + } + } + + void refresh_nodes(const lvh::Gamepad &gamepad) { + ::SendMessageW(nodes_list_, LB_RESETCONTENT, 0, 0); + const auto nodes = gamepad.device_nodes(); + if (nodes.empty()) { + ::SendMessageW(nodes_list_, LB_ADDSTRING, 0, reinterpret_cast(L"No device nodes reported yet.")); + return; + } + + for (const auto &node : nodes) { + const auto line = node_kind_name(node.kind) + L": " + widen(node.path); + ::SendMessageW(nodes_list_, LB_ADDSTRING, 0, reinterpret_cast(line.c_str())); + } + } + + void refresh_outputs(const ControlledGamepad &device) { + ::SendMessageW(output_list_, LB_RESETCONTENT, 0, 0); + if (device.outputs.empty()) { + ::SendMessageW(output_list_, LB_ADDSTRING, 0, reinterpret_cast(L"No output reports received.")); + return; + } + + for (const auto &entry : device.outputs) { + const auto &output = entry.output; + std::wostringstream line; + line << L"#" << entry.sequence << L" " << output_kind_name(output.kind) + << L" low=" << output.low_frequency_rumble + << L" high=" << output.high_frequency_rumble + << L" rgb=" << static_cast(output.red) << L"," << static_cast(output.green) << L"," + << static_cast(output.blue); + if (!output.raw_report.empty()) { + line << L" raw=" << raw_hex(output.raw_report); + } + ::SendMessageW(output_list_, LB_ADDSTRING, 0, reinterpret_cast(line.str().c_str())); + } + } + + ControlledGamepad *selected_device_locked() { + if (selected_id_ == 0) { + return nullptr; + } + const auto iter = devices_.find(selected_id_); + if (iter == devices_.end()) { + return nullptr; + } + return &iter->second; + } + + void record_output(lvh::DeviceId id, const lvh::GamepadOutput &output) { + { + std::lock_guard lock {mutex_}; + const auto iter = devices_.find(id); + if (iter == devices_.end()) { + return; + } + + auto &outputs = iter->second.outputs; + outputs.push_back({.sequence = next_output_sequence_++, .output = output}); + if (outputs.size() > max_output_events_) { + outputs.erase(outputs.begin(), outputs.begin() + static_cast(outputs.size() - max_output_events_)); + } + } + if (window_ != nullptr) { + ::PostMessageW(window_, output_changed_message, 0, 0); + } + } + + void close_all_devices() { + std::vector> adapters; + { + std::lock_guard lock {mutex_}; + for (auto &[id, device] : devices_) { + adapters.push_back(std::move(device.adapter)); + } + devices_.clear(); + selected_id_ = 0; + } + + for (auto &adapter : adapters) { + if (adapter) { + static_cast(adapter->close()); + } + } + } + + void show_error(const std::wstring &message) const { + ::MessageBoxW(window_, message.c_str(), L"libvirtualhid control", MB_ICONERROR | MB_OK); + } + + HINSTANCE instance_ = nullptr; + HWND window_ = nullptr; + HWND backend_text_ = nullptr; + HWND profile_combo_ = nullptr; + HWND create_button_ = nullptr; + HWND device_list_ = nullptr; + HWND reset_button_ = nullptr; + HWND close_button_ = nullptr; + HWND state_text_ = nullptr; + HWND nodes_list_ = nullptr; + HWND output_list_ = nullptr; + std::array button_controls_ {}; + std::array axis_labels_ {}; + std::array axis_sliders_ {}; + std::unique_ptr runtime_; + std::mutex mutex_; + std::map devices_; + lvh::DeviceId selected_id_ = 0; + std::uint64_t next_metadata_index_ = 0; + std::uint64_t next_output_sequence_ = 1; + static constexpr std::size_t max_output_events_ = 50; + }; + +} // namespace + +/** + * @brief Run the native Windows libvirtualhid control UI. + * @param instance Module instance. + * @return Process exit code. + */ +int WINAPI WinMain(HINSTANCE instance, HINSTANCE, LPSTR, int) { + ControlWindow window {instance}; + return window.run(); +} + +#else + +// standard includes +#include + +/** + * @brief Report that the native UI is not implemented on this platform yet. + * @return Nonzero because no UI was shown. + */ +int main() { + std::cerr << "virtualhid_control native UI is currently implemented for Windows only.\n"; + return 1; +} + +#endif From 18a8517e18d5a35d471996ab36bd45dba564f8d7 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:48:35 -0400 Subject: [PATCH 02/12] Enhance Windows control UI Add Windows resources and wire the `virtualhid_control` tool to use an app icon. The UI now supports removing devices, locking buttons, battery controls, richer output summaries, and improved layout/visibility handling for supported controls. Update docs to reflect the expanded gamepad management and feedback features. --- .run/docs.run.xml | 2 +- README.md | 4 +- docs/usage.md | 12 +- docs/windows-driver.md | 10 +- tools/CMakeLists.txt | 6 + tools/virtualhid_control.cpp | 640 +++++++++++++++++++++++++--- tools/windows/libvirtualhid.ico | Bin 0 -> 14723 bytes tools/windows/resource.h | 3 + tools/windows/virtualhid_control.rc | 3 + 9 files changed, 609 insertions(+), 71 deletions(-) create mode 100644 tools/windows/libvirtualhid.ico create mode 100644 tools/windows/resource.h create mode 100644 tools/windows/virtualhid_control.rc diff --git a/.run/docs.run.xml b/.run/docs.run.xml index 3ce29f4..0af20da 100644 --- a/.run/docs.run.xml +++ b/.run/docs.run.xml @@ -1,5 +1,5 @@ - + diff --git a/README.md b/README.md index d24419d..b08354c 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ behind backend implementations. HID Framework, with keyboard and mouse support through normal Win32 APIs. - Output callbacks for profile-specific feedback such as rumble, LEDs, adaptive triggers, and raw HID output reports when available. -- An optional `virtualhid_control` native UI tool for creating, controlling, and - inspecting test gamepads through the public C++ API. +- An optional `virtualhid_control` native UI tool for creating, removing, + controlling, and inspecting test gamepads through the public C++ API. - CMake consumption through installed packages, vendored source, `add_subdirectory`, or `FetchContent`. diff --git a/docs/usage.md b/docs/usage.md index da34828..0e90c0d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -88,10 +88,14 @@ artifact. virtualhid_control ``` -The current Windows UI can create gamepads from the built-in profiles, submit -buttons, sticks, and triggers, show backend capabilities, list device nodes -reported for UI-created devices, and display gamepad output reports delivered -through the normal callback path. +The current Windows UI can create and remove gamepads from the built-in +profiles, submit buttons, sticks, triggers, and battery state, show backend and +profile capabilities, list device nodes reported for UI-created devices, and +display normalized gamepad output such as rumble, RGB LED, adaptive trigger, +trigger rumble, and raw report events delivered through the normal callback +path. Button controls are momentary by default so they behave like physical +gamepad buttons; enable `Lock buttons` to keep the old click-to-toggle behavior +for held inputs. External devices created by another process, such as Sunshine, are not enumerated yet. That requires backend protocol support so the Windows driver or diff --git a/docs/windows-driver.md b/docs/windows-driver.md index d52c0b1..1629fa5 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -117,9 +117,13 @@ For interactive local validation, run: tools\windows\virtualhid_control.exe ``` -The native UI can create, control, and monitor gamepads that it owns. Devices -created by another process are not listed yet; that requires a future Windows -control-protocol extension for cross-process diagnostics. +The native UI can create, remove, control, and monitor gamepads that it owns. +Buttons are momentary by default, with an explicit lock mode for held inputs. +The UI also shows supported profile features, battery input state, device nodes, +and normalized feedback reports such as rumble, RGB LED, adaptive trigger, and +raw output events. Devices created by another process are not listed yet; that +requires a future Windows control-protocol extension for cross-process +diagnostics. ## Installation Notes diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 7a785df..25e7d55 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -8,6 +8,12 @@ target_link_libraries(virtualhid_control if(WIN32) set_target_properties(virtualhid_control PROPERTIES WIN32_EXECUTABLE TRUE) + target_sources(virtualhid_control + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/windows/virtualhid_control.rc") + target_include_directories(virtualhid_control + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/windows") target_compile_definitions(virtualhid_control PRIVATE NOMINMAX diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index b419019..242b60e 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -40,9 +40,12 @@ #include #include + #include "resource.h" + namespace { constexpr auto output_changed_message = WM_APP + 1U; + constexpr auto button_subclass_id = 1U; constexpr auto slider_scale = 100; enum ControlId : int { @@ -50,8 +53,15 @@ namespace { create_button_id, device_list_id, reset_button_id, - close_button_id, + remove_selected_button_id, + remove_all_button_id, + lock_buttons_check_id, state_text_id, + feature_text_id, + battery_state_combo_id, + battery_slider_id, + clear_battery_button_id, + output_summary_text_id, node_list_id, output_list_id, button_base_id = 2000, @@ -76,6 +86,11 @@ namespace { int maximum; }; + struct BatteryChoice { + std::wstring_view label; + lvh::GamepadBatteryState state; + }; + constexpr std::array profile_choices { ProfileChoice {L"generic", L"Generic HID", lvh::GamepadProfileKind::generic, lvh::ClientControllerType::unknown}, ProfileChoice {L"x360", L"Xbox 360", lvh::GamepadProfileKind::xbox_360, lvh::ClientControllerType::xbox}, @@ -119,6 +134,16 @@ namespace { AxisChoice {L"Right Trigger", 0, slider_scale}, }; + constexpr std::array battery_choices { + BatteryChoice {L"Unknown", lvh::GamepadBatteryState::unknown}, + BatteryChoice {L"Discharging", lvh::GamepadBatteryState::discharging}, + BatteryChoice {L"Charging", lvh::GamepadBatteryState::charging}, + BatteryChoice {L"Full", lvh::GamepadBatteryState::full}, + BatteryChoice {L"Voltage/temperature error", lvh::GamepadBatteryState::voltage_or_temperature_error}, + BatteryChoice {L"Temperature error", lvh::GamepadBatteryState::temperature_error}, + BatteryChoice {L"Charging error", lvh::GamepadBatteryState::charging_error}, + }; + struct OutputLogEntry { std::uint64_t sequence = 0; lvh::GamepadOutput output; @@ -128,6 +153,11 @@ namespace { std::wstring profile_label; std::unique_ptr adapter; std::vector outputs; + std::optional latest_rumble; + std::optional latest_trigger_rumble; + std::optional latest_rgb_led; + std::optional latest_adaptive_triggers; + std::optional latest_raw_report; }; std::wstring widen(std::string_view value) { @@ -218,6 +248,37 @@ namespace { return L"raw report"; } + std::wstring battery_state_name(lvh::GamepadBatteryState state) { + switch (state) { + using enum lvh::GamepadBatteryState; + + case unknown: + return L"unknown"; + case discharging: + return L"discharging"; + case charging: + return L"charging"; + case full: + return L"full"; + case voltage_or_temperature_error: + return L"voltage/temperature error"; + case temperature_error: + return L"temperature error"; + case charging_error: + return L"charging error"; + } + return L"unknown"; + } + + int battery_choice_index(lvh::GamepadBatteryState state) { + for (std::size_t index = 0; index < battery_choices.size(); ++index) { + if (battery_choices[index].state == state) { + return static_cast(index); + } + } + return 0; + } + std::wstring raw_hex(const std::vector &bytes) { std::wostringstream stream; stream << std::hex << std::setfill(L'0'); @@ -261,6 +322,10 @@ namespace { return static_cast(value) / static_cast(slider_scale); } + std::wstring yes_no(bool value) { + return value ? L"yes" : L"no"; + } + class ControlWindow { public: explicit ControlWindow(HINSTANCE instance): @@ -282,8 +347,21 @@ namespace { window_class.lpfnWndProc = &ControlWindow::window_proc; window_class.lpszClassName = L"LibVirtualHidControlWindow"; window_class.hCursor = ::LoadCursorW(nullptr, IDC_ARROW); - window_class.hIcon = ::LoadIconW(nullptr, IDI_APPLICATION); - window_class.hIconSm = window_class.hIcon; + window_class.hIcon = ::LoadIconW(instance_, MAKEINTRESOURCEW(IDI_VIRTUALHID)); + if (window_class.hIcon == nullptr) { + window_class.hIcon = ::LoadIconW(nullptr, IDI_APPLICATION); + } + window_class.hIconSm = reinterpret_cast(::LoadImageW( + instance_, + MAKEINTRESOURCEW(IDI_VIRTUALHID), + IMAGE_ICON, + ::GetSystemMetrics(SM_CXSMICON), + ::GetSystemMetrics(SM_CYSMICON), + LR_DEFAULTCOLOR + )); + if (window_class.hIconSm == nullptr) { + window_class.hIconSm = window_class.hIcon; + } window_class.hbrBackground = reinterpret_cast(COLOR_WINDOW + 1); if (::RegisterClassExW(&window_class) == 0U) { @@ -335,6 +413,67 @@ namespace { return ::DefWindowProcW(window, message, wparam, lparam); } + static LRESULT CALLBACK button_subclass_proc(HWND control, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR, DWORD_PTR ref_data) { + auto *self = reinterpret_cast(ref_data); + if (self == nullptr) { + return ::DefSubclassProc(control, message, wparam, lparam); + } + + return self->handle_button_message(control, message, wparam, lparam); + } + + LRESULT handle_button_message(HWND control, UINT message, WPARAM wparam, LPARAM lparam) { + const auto id = ::GetDlgCtrlID(control); + if (id < button_base_id || id >= button_base_id + static_cast(button_choices.size())) { + return ::DefSubclassProc(control, message, wparam, lparam); + } + + const auto index = static_cast(id - button_base_id); + if (!buttons_locked()) { + switch (message) { + case WM_LBUTTONDOWN: + case WM_LBUTTONDBLCLK: + momentary_pointer_pressed_[index] = true; + set_selected_button(index, true); + break; + case WM_LBUTTONUP: + if (momentary_pointer_pressed_[index]) { + momentary_pointer_pressed_[index] = false; + set_selected_button(index, false); + } + break; + case WM_CAPTURECHANGED: + if (momentary_pointer_pressed_[index]) { + momentary_pointer_pressed_[index] = false; + set_selected_button(index, false); + } + break; + case WM_KEYDOWN: + if (wparam == VK_SPACE && !momentary_key_pressed_[index]) { + momentary_key_pressed_[index] = true; + set_selected_button(index, true); + } + break; + case WM_KEYUP: + if (wparam == VK_SPACE && momentary_key_pressed_[index]) { + momentary_key_pressed_[index] = false; + set_selected_button(index, false); + } + break; + case WM_KILLFOCUS: + if (momentary_key_pressed_[index]) { + momentary_key_pressed_[index] = false; + set_selected_button(index, false); + } + break; + default: + break; + } + } + + return ::DefSubclassProc(control, message, wparam, lparam); + } + LRESULT handle_message(UINT message, WPARAM wparam, LPARAM lparam) { switch (message) { case WM_CREATE: @@ -344,6 +483,9 @@ namespace { case WM_SIZE: layout_controls(LOWORD(lparam), HIWORD(lparam)); return 0; + case WM_GETMINMAXINFO: + handle_min_max_info(reinterpret_cast(lparam)); + return 0; case WM_COMMAND: handle_command(LOWORD(wparam), HIWORD(wparam)); return 0; @@ -396,23 +538,46 @@ namespace { create_button_ = create_child(L"BUTTON", L"Create", BS_PUSHBUTTON, create_button_id); device_list_ = create_child(L"LISTBOX", L"", LBS_NOTIFY | WS_BORDER | WS_VSCROLL, device_list_id); reset_button_ = create_child(L"BUTTON", L"Reset", BS_PUSHBUTTON, reset_button_id); - close_button_ = create_child(L"BUTTON", L"Close", BS_PUSHBUTTON, close_button_id); + remove_selected_button_ = create_child(L"BUTTON", L"Remove selected", BS_PUSHBUTTON, remove_selected_button_id); + remove_all_button_ = create_child(L"BUTTON", L"Remove all", BS_PUSHBUTTON, remove_all_button_id); + lock_buttons_check_ = create_child(L"BUTTON", L"Lock buttons", BS_AUTOCHECKBOX, lock_buttons_check_id); state_text_ = create_child(L"STATIC", L"No device selected.", SS_LEFT, state_text_id); + feature_text_ = create_child(L"STATIC", L"", SS_LEFT, feature_text_id); + battery_label_ = create_child(L"STATIC", L"Battery", SS_LEFT, 0); + battery_state_combo_ = create_child(WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL, battery_state_combo_id); + battery_slider_ = create_child(TRACKBAR_CLASSW, L"", TBS_AUTOTICKS, battery_slider_id); + clear_battery_button_ = create_child(L"BUTTON", L"Clear", BS_PUSHBUTTON, clear_battery_button_id); + output_summary_text_ = create_child(L"STATIC", L"", SS_LEFT, output_summary_text_id); + nodes_label_ = create_child(L"STATIC", L"Device nodes", SS_LEFT, 0); + output_label_ = create_child(L"STATIC", L"Output reports", SS_LEFT, 0); nodes_list_ = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, node_list_id); output_list_ = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, output_list_id); for (const auto &choice : profile_choices) { ::SendMessageW(profile_combo_, CB_ADDSTRING, 0, reinterpret_cast(choice.label.data())); } - ::SendMessageW(profile_combo_, CB_SETCURSEL, 4, 0); + ::SendMessageW(profile_combo_, CB_SETCURSEL, 3, 0); + + for (const auto &choice : battery_choices) { + ::SendMessageW(battery_state_combo_, CB_ADDSTRING, 0, reinterpret_cast(choice.label.data())); + } + ::SendMessageW(battery_state_combo_, CB_SETCURSEL, battery_choice_index(lvh::GamepadBatteryState::full), 0); + ::SendMessageW(battery_slider_, TBM_SETRANGE, TRUE, MAKELPARAM(0, 100)); + ::SendMessageW(battery_slider_, TBM_SETPOS, TRUE, 100); for (std::size_t index = 0; index < button_choices.size(); ++index) { button_controls_[index] = create_child( L"BUTTON", button_choices[index].label.data(), - BS_AUTOCHECKBOX | BS_PUSHLIKE, + BS_PUSHBUTTON | BS_NOTIFY, button_base_id + static_cast(index) ); + ::SetWindowSubclass( + button_controls_[index], + &ControlWindow::button_subclass_proc, + button_subclass_id, + reinterpret_cast(this) + ); } for (std::size_t index = 0; index < axis_choices.size(); ++index) { @@ -435,56 +600,105 @@ namespace { void layout_controls(int width, int height) { constexpr auto margin = 12; - constexpr auto gap = 10; + constexpr auto gap = 8; constexpr auto row = 28; - constexpr auto left_width = 280; - constexpr auto top_height = 72; - constexpr auto button_width = 112; + constexpr auto label_height = 18; + constexpr auto button_width = 108; constexpr auto button_height = 28; - constexpr auto button_columns = 4; + constexpr auto state_height = 72; + constexpr auto feature_height = 34; + constexpr auto output_summary_height = 34; + const auto left_width = std::clamp(width / 3, 250, 320); const auto right_x = margin + left_width + gap; - const auto right_width = std::max(320, width - right_x - margin); - const auto list_top = margin + top_height; - const auto left_list_height = std::max(120, height - list_top - margin - row - gap); - - move(profile_combo_, margin, margin, 170, 200); - move(create_button_, margin + 180, margin, 90, row); - move(backend_text_, margin, margin + 38, left_width, 28); + const auto right_width = std::max(360, width - right_x - margin); + const auto profile_width = std::max(120, left_width - 98); + const auto left_actions_top = std::max(margin + 140, height - margin - (row * 2) - gap); + const auto list_top = margin + row + 48; + const auto left_list_height = std::max(120, left_actions_top - list_top - gap); + + move(profile_combo_, margin, margin, profile_width, 200); + move(create_button_, margin + profile_width + gap, margin, left_width - profile_width - gap, row); + move(backend_text_, margin, margin + row + gap, left_width, 40); move(device_list_, margin, list_top, left_width, left_list_height); - move(reset_button_, margin, height - margin - row, 90, row); - move(close_button_, margin + 100, height - margin - row, 90, row); + move(reset_button_, margin, left_actions_top, 80, row); + move(remove_selected_button_, margin + 88, left_actions_top, left_width - 88, row); + move(remove_all_button_, margin, left_actions_top + row + gap, left_width, row); - move(state_text_, right_x, margin, right_width, 86); + move(state_text_, right_x, margin, right_width, state_height); + move(feature_text_, right_x, margin + state_height, right_width, feature_height); - auto buttons_top = margin + 96; + const auto buttons_header_top = margin + state_height + feature_height + gap; + move(lock_buttons_check_, right_x, buttons_header_top, 130, row); + + const auto button_columns = std::max(1, std::min(5, (right_width + gap) / (button_width + gap))); + const auto actual_button_width = std::max(button_width, (right_width - ((button_columns - 1) * gap)) / button_columns); + auto visible_button_count = 0; + const auto buttons_top = buttons_header_top + row + gap; for (std::size_t index = 0; index < button_controls_.size(); ++index) { - const auto column = static_cast(index % button_columns); - const auto row_index = static_cast(index / button_columns); + if (!visible_buttons_[index]) { + ::ShowWindow(button_controls_[index], SW_HIDE); + continue; + } + + ::ShowWindow(button_controls_[index], SW_SHOW); + const auto column = visible_button_count % button_columns; + const auto row_index = visible_button_count / button_columns; move( button_controls_[index], - right_x + column * (button_width + gap), + right_x + column * (actual_button_width + gap), buttons_top + row_index * (button_height + gap), - button_width, + actual_button_width, button_height ); + ++visible_button_count; } - const auto slider_top = buttons_top + 6 * (button_height + gap) + gap; - const auto slider_width = std::max(180, (right_width - gap) / 2); + const auto button_rows = std::max(1, (visible_button_count + button_columns - 1) / button_columns); + const auto slider_top = buttons_top + button_rows * (button_height + gap) + gap; + const auto slider_columns = right_width >= 520 ? 2 : 1; + const auto slider_width = std::max(180, (right_width - ((slider_columns - 1) * gap)) / slider_columns); for (std::size_t index = 0; index < axis_sliders_.size(); ++index) { - const auto column = static_cast(index % 2U); - const auto row_index = static_cast(index / 2U); + const auto column = static_cast(index % static_cast(slider_columns)); + const auto row_index = static_cast(index / static_cast(slider_columns)); const auto x = right_x + column * (slider_width + gap); const auto y = slider_top + row_index * 54; move(axis_labels_[index], x, y, slider_width, 18); move(axis_sliders_[index], x, y + 18, slider_width, 34); } - const auto bottom_top = slider_top + 3 * 54 + gap; - const auto bottom_height = std::max(110, height - bottom_top - margin); - move(nodes_list_, right_x, bottom_top, (right_width - gap) / 2, bottom_height); - move(output_list_, right_x + (right_width + gap) / 2, bottom_top, (right_width - gap) / 2, bottom_height); + const auto slider_rows = static_cast((axis_sliders_.size() + static_cast(slider_columns) - 1U) / static_cast(slider_columns)); + auto next_top = slider_top + slider_rows * 54 + gap; + const auto show_battery = battery_controls_visible_; + ::ShowWindow(battery_label_, show_battery ? SW_SHOW : SW_HIDE); + ::ShowWindow(battery_state_combo_, show_battery ? SW_SHOW : SW_HIDE); + ::ShowWindow(battery_slider_, show_battery ? SW_SHOW : SW_HIDE); + ::ShowWindow(clear_battery_button_, show_battery ? SW_SHOW : SW_HIDE); + if (show_battery) { + const auto clear_width = 70; + const auto combo_width = std::min(220, std::max(150, right_width / 3)); + move(battery_label_, right_x, next_top + 5, 58, label_height); + move(battery_state_combo_, right_x + 64, next_top, combo_width, 180); + move(clear_battery_button_, right_x + right_width - clear_width, next_top, clear_width, row); + move( + battery_slider_, + right_x + 64 + combo_width + gap, + next_top, + std::max(120, right_width - 64 - combo_width - gap - clear_width - gap), + row + ); + next_top += row + gap; + } + + move(output_summary_text_, right_x, next_top, right_width, output_summary_height); + next_top += output_summary_height + gap; + + const auto list_width = (right_width - gap) / 2; + const auto bottom_height = std::max(90, height - next_top - label_height - margin); + move(nodes_label_, right_x, next_top, list_width, label_height); + move(output_label_, right_x + list_width + gap, next_top, list_width, label_height); + move(nodes_list_, right_x, next_top + label_height, list_width, bottom_height); + move(output_list_, right_x + list_width + gap, next_top + label_height, list_width, bottom_height); } static void move(HWND control, int x, int y, int width, int height) { @@ -493,17 +707,59 @@ namespace { } } + static void handle_min_max_info(MINMAXINFO *info) { + if (info == nullptr) { + return; + } + + info->ptMinTrackSize.x = 900; + info->ptMinTrackSize.y = 660; + } + + bool buttons_locked() const { + return ::IsDlgButtonChecked(window_, lock_buttons_check_id) == BST_CHECKED; + } + + void layout_current_client() { + RECT rect {}; + if (::GetClientRect(window_, &rect) == FALSE) { + return; + } + layout_controls(rect.right - rect.left, rect.bottom - rect.top); + } + void handle_command(int id, int notification) { if (id == create_button_id && notification == BN_CLICKED) { create_gamepad(); return; } + if (id == profile_combo_id && notification == CBN_SELCHANGE) { + refresh_selected_device(); + layout_current_client(); + return; + } if (id == reset_button_id && notification == BN_CLICKED) { reset_selected_device(); return; } - if (id == close_button_id && notification == BN_CLICKED) { - close_selected_device(); + if (id == remove_selected_button_id && notification == BN_CLICKED) { + remove_selected_device(); + return; + } + if (id == remove_all_button_id && notification == BN_CLICKED) { + remove_all_devices(); + return; + } + if (id == lock_buttons_check_id && notification == BN_CLICKED) { + handle_button_lock_changed(); + return; + } + if (id == battery_state_combo_id && notification == CBN_SELCHANGE) { + set_selected_battery_from_controls(); + return; + } + if (id == clear_battery_button_id && notification == BN_CLICKED) { + clear_selected_battery(); return; } if (id == device_list_id && notification == LBN_SELCHANGE) { @@ -515,11 +771,18 @@ namespace { return; } if (id >= button_base_id && id < button_base_id + static_cast(button_choices.size()) && notification == BN_CLICKED) { - set_selected_button(static_cast(id - button_base_id)); + if (buttons_locked()) { + toggle_selected_button(static_cast(id - button_base_id)); + } } } void handle_slider(HWND slider) { + if (slider == battery_slider_) { + set_selected_battery_from_controls(); + return; + } + for (std::size_t index = 0; index < axis_sliders_.size(); ++index) { if (axis_sliders_[index] == slider) { set_selected_axis(index); @@ -598,7 +861,7 @@ namespace { refresh_selected_device(); } - void close_selected_device() { + void remove_selected_device() { std::unique_ptr adapter; { std::lock_guard lock {mutex_}; @@ -621,9 +884,31 @@ namespace { refresh_all(); } - void set_selected_button(std::size_t index) { - const auto control_id = button_base_id + static_cast(index); - const auto pressed = ::IsDlgButtonChecked(window_, control_id) == BST_CHECKED; + void remove_all_devices() { + std::vector> adapters; + { + std::lock_guard lock {mutex_}; + adapters = take_all_adapters_locked(); + } + + close_adapters(adapters, true); + refresh_all(); + } + + void toggle_selected_button(std::size_t index) { + auto pressed = false; + { + std::lock_guard lock {mutex_}; + const auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + pressed = !device->adapter->state().buttons.test(button_choices[index].button); + } + set_selected_button(index, pressed); + } + + void set_selected_button(std::size_t index, bool pressed) { auto status = lvh::OperationStatus::success(); { std::lock_guard lock {mutex_}; @@ -639,6 +924,31 @@ namespace { refresh_selected_device(); } + void handle_button_lock_changed() { + if (buttons_locked()) { + refresh_selected_device(); + return; + } + + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + refresh_selected_device(); + return; + } + + auto state = device->adapter->state(); + state.buttons.clear(); + status = device->adapter->set_state(state); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + void set_selected_axis(std::size_t index) { auto status = lvh::OperationStatus::success(); const auto position = ::SendMessageW(axis_sliders_[index], TBM_GETPOS, 0, 0); @@ -683,6 +993,47 @@ namespace { refresh_selected_device(); } + void set_selected_battery_from_controls() { + const auto state_selection = ::SendMessageW(battery_state_combo_, CB_GETCURSEL, 0, 0); + if (state_selection == CB_ERR || state_selection < 0 || static_cast(state_selection) >= battery_choices.size()) { + return; + } + + lvh::GamepadBattery battery; + battery.state = battery_choices[static_cast(state_selection)].state; + battery.percentage = static_cast(std::clamp(::SendMessageW(battery_slider_, TBM_GETPOS, 0, 0), 0, 100)); + + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + status = device->adapter->set_battery(battery); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + + void clear_selected_battery() { + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + auto *device = selected_device_locked(); + if (device == nullptr) { + return; + } + status = device->adapter->clear_battery(); + } + if (!status.ok()) { + show_error(widen(status.message())); + } + refresh_selected_device(); + } + void refresh_all() { refresh_backend_text(); refresh_device_list(); @@ -713,49 +1064,140 @@ namespace { } } + std::optional current_combo_profile() const { + const auto selection = ::SendMessageW(profile_combo_, CB_GETCURSEL, 0, 0); + if (selection == CB_ERR || selection < 0 || static_cast(selection) >= profile_choices.size()) { + return std::nullopt; + } + + return profile_for_choice(profile_choices[static_cast(selection)]); + } + + void update_visible_controls_for_profile(const lvh::DeviceProfile &profile) { + for (std::size_t index = 0; index < button_choices.size(); ++index) { + visible_buttons_[index] = lvh::supports_gamepad_button(profile, button_choices[index].button); + } + battery_controls_visible_ = profile.capabilities.supports_battery; + } + + std::wstring profile_feature_summary(const lvh::DeviceProfile &profile) const { + const auto support = lvh::gamepad_profile_support(profile); + std::wostringstream stream; + stream << L"Features: battery " << yes_no(support.supports_battery) + << L" | rumble " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::rumble)) + << L" | trigger rumble " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::trigger_rumble)) + << L" | RGB LED " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::rgb_led)) + << L" | adaptive triggers " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::adaptive_triggers)) + << L" | raw output " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::raw_report)); + return stream.str(); + } + + std::wstring output_summary(const ControlledGamepad &device, const lvh::DeviceProfile &profile) const { + std::wostringstream stream; + stream << L"Output: "; + if (device.outputs.empty()) { + stream << L"no reports received"; + } + else { + auto wrote = false; + if (device.latest_rumble) { + stream << L"rumble low=" << device.latest_rumble->low_frequency_rumble << L" high=" << device.latest_rumble->high_frequency_rumble; + wrote = true; + } + if (device.latest_trigger_rumble) { + stream << (wrote ? L" | " : L"") << L"trigger rumble L=" << device.latest_trigger_rumble->left_trigger_rumble << L" R=" << device.latest_trigger_rumble->right_trigger_rumble; + wrote = true; + } + if (device.latest_rgb_led) { + stream << (wrote ? L" | " : L"") << L"RGB " << static_cast(device.latest_rgb_led->red) << L"," << static_cast(device.latest_rgb_led->green) + << L"," << static_cast(device.latest_rgb_led->blue); + wrote = true; + } + if (device.latest_adaptive_triggers) { + stream << (wrote ? L" | " : L"") << L"adaptive flags=" << static_cast(device.latest_adaptive_triggers->adaptive_trigger_flags); + wrote = true; + } + if (!wrote && device.latest_raw_report) { + stream << L"raw report"; + wrote = true; + } + if (!wrote) { + stream << L"reports received"; + } + } + + if (!lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::rumble) && !lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::rgb_led) && + !lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::adaptive_triggers) && !lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::trigger_rumble)) { + stream << L" | profile has no normalized feedback categories"; + } + return stream.str(); + } + void refresh_selected_device() { std::lock_guard lock {mutex_}; auto *device = selected_device_locked(); const auto enabled = device != nullptr; ::EnableWindow(reset_button_, enabled); - ::EnableWindow(close_button_, enabled); - for (auto *control : button_controls_) { - ::EnableWindow(control, enabled); - } + ::EnableWindow(remove_selected_button_, enabled); + ::EnableWindow(remove_all_button_, !devices_.empty()); for (auto *slider : axis_sliders_) { ::EnableWindow(slider, enabled); } if (device == nullptr) { + if (const auto profile = current_combo_profile()) { + update_visible_controls_for_profile(*profile); + ::SetWindowTextW(feature_text_, profile_feature_summary(*profile).c_str()); + } + else { + visible_buttons_.fill(false); + battery_controls_visible_ = false; + ::SetWindowTextW(feature_text_, L""); + } + + for (std::size_t index = 0; index < button_controls_.size(); ++index) { + ::EnableWindow(button_controls_[index], FALSE); + set_button_visual(index, false); + } + ::EnableWindow(battery_state_combo_, FALSE); + ::EnableWindow(battery_slider_, FALSE); + ::EnableWindow(clear_battery_button_, FALSE); ::SetWindowTextW(state_text_, L"No device selected. Create a gamepad to begin testing."); + ::SetWindowTextW(output_summary_text_, L"Output: no selected device."); ::SendMessageW(nodes_list_, LB_RESETCONTENT, 0, 0); ::SendMessageW(output_list_, LB_RESETCONTENT, 0, 0); - for (std::size_t index = 0; index < button_controls_.size(); ++index) { - ::CheckDlgButton(window_, button_base_id + static_cast(index), BST_UNCHECKED); - } reset_sliders(); + refresh_battery_controls({}, false); + layout_current_client(); return; } const auto *gamepad = device->adapter->gamepad(); const auto &profile = gamepad->profile(); const auto state = device->adapter->state(); + update_visible_controls_for_profile(profile); std::wostringstream state_text; state_text << device->profile_label << L" #" << gamepad->device_id() << L"\r\n" << device_type_name(profile.device_type) << L" | " << widen(profile.name) << L"\r\n" << L"L(" << state.left_stick.x << L", " << state.left_stick.y << L") " << L"R(" << state.right_stick.x << L", " << state.right_stick.y << L") " - << L"LT " << state.left_trigger << L" RT " << state.right_trigger << L" | " + << L"LT " << state.left_trigger << L" RT " << state.right_trigger << L"\r\n"; + if (state.battery) { + state_text << L"Battery " << battery_state_name(state.battery->state) << L" " << static_cast(state.battery->percentage) << L"% | "; + } + else { + state_text << L"Battery unset | "; + } + state_text << gamepad->submit_count() << L" submits"; ::SetWindowTextW(state_text_, state_text.str().c_str()); + ::SetWindowTextW(feature_text_, profile_feature_summary(profile).c_str()); + ::SetWindowTextW(output_summary_text_, output_summary(*device, profile).c_str()); for (std::size_t index = 0; index < button_choices.size(); ++index) { - ::CheckDlgButton( - window_, - button_base_id + static_cast(index), - state.buttons.test(button_choices[index].button) ? BST_CHECKED : BST_UNCHECKED - ); + ::EnableWindow(button_controls_[index], visible_buttons_[index]); + set_button_visual(index, state.buttons.test(button_choices[index].button)); } ::SendMessageW(axis_sliders_[0], TBM_SETPOS, TRUE, axis_to_slider(state.left_stick.x)); @@ -764,9 +1206,11 @@ namespace { ::SendMessageW(axis_sliders_[3], TBM_SETPOS, TRUE, axis_to_slider(state.right_stick.y)); ::SendMessageW(axis_sliders_[4], TBM_SETPOS, TRUE, trigger_to_slider(state.left_trigger)); ::SendMessageW(axis_sliders_[5], TBM_SETPOS, TRUE, trigger_to_slider(state.right_trigger)); + refresh_battery_controls(state, device->adapter->support().supports_battery); refresh_nodes(*gamepad); refresh_outputs(*device); + layout_current_client(); } void reset_sliders() { @@ -775,6 +1219,29 @@ namespace { } } + void set_button_visual(std::size_t index, bool pressed) { + if (index >= button_controls_.size()) { + return; + } + ::SendMessageW(button_controls_[index], BM_SETSTATE, pressed ? TRUE : FALSE, 0); + } + + void refresh_battery_controls(const lvh::GamepadState &state, bool supported) { + const auto enabled = selected_device_locked() != nullptr && supported; + ::EnableWindow(battery_state_combo_, enabled); + ::EnableWindow(battery_slider_, enabled); + ::EnableWindow(clear_battery_button_, enabled && state.battery.has_value()); + + if (state.battery) { + ::SendMessageW(battery_state_combo_, CB_SETCURSEL, battery_choice_index(state.battery->state), 0); + ::SendMessageW(battery_slider_, TBM_SETPOS, TRUE, state.battery->percentage); + return; + } + + ::SendMessageW(battery_state_combo_, CB_SETCURSEL, battery_choice_index(lvh::GamepadBatteryState::full), 0); + ::SendMessageW(battery_slider_, TBM_SETPOS, TRUE, 100); + } + void refresh_nodes(const lvh::Gamepad &gamepad) { ::SendMessageW(nodes_list_, LB_RESETCONTENT, 0, 0); const auto nodes = gamepad.device_nodes(); @@ -835,6 +1302,26 @@ namespace { if (outputs.size() > max_output_events_) { outputs.erase(outputs.begin(), outputs.begin() + static_cast(outputs.size() - max_output_events_)); } + + switch (output.kind) { + using enum lvh::GamepadOutputKind; + + case rumble: + iter->second.latest_rumble = output; + break; + case trigger_rumble: + iter->second.latest_trigger_rumble = output; + break; + case rgb_led: + iter->second.latest_rgb_led = output; + break; + case adaptive_triggers: + iter->second.latest_adaptive_triggers = output; + break; + case raw_report: + iter->second.latest_raw_report = output; + break; + } } if (window_ != nullptr) { ::PostMessageW(window_, output_changed_message, 0, 0); @@ -845,18 +1332,35 @@ namespace { std::vector> adapters; { std::lock_guard lock {mutex_}; - for (auto &[id, device] : devices_) { - adapters.push_back(std::move(device.adapter)); - } - devices_.clear(); - selected_id_ = 0; + adapters = take_all_adapters_locked(); } + close_adapters(adapters, false); + } + + std::vector> take_all_adapters_locked() { + std::vector> adapters; + for (auto &[id, device] : devices_) { + adapters.push_back(std::move(device.adapter)); + } + devices_.clear(); + selected_id_ = 0; + return adapters; + } + + void close_adapters(std::vector> &adapters, bool report_errors) { + std::optional first_error; for (auto &adapter : adapters) { if (adapter) { - static_cast(adapter->close()); + if (const auto status = adapter->close(); !status.ok() && report_errors && !first_error) { + first_error = widen(status.message()); + } } } + + if (first_error) { + show_error(*first_error); + } } void show_error(const std::wstring &message) const { @@ -870,19 +1374,33 @@ namespace { HWND create_button_ = nullptr; HWND device_list_ = nullptr; HWND reset_button_ = nullptr; - HWND close_button_ = nullptr; + HWND remove_selected_button_ = nullptr; + HWND remove_all_button_ = nullptr; + HWND lock_buttons_check_ = nullptr; HWND state_text_ = nullptr; + HWND feature_text_ = nullptr; + HWND battery_label_ = nullptr; + HWND battery_state_combo_ = nullptr; + HWND battery_slider_ = nullptr; + HWND clear_battery_button_ = nullptr; + HWND output_summary_text_ = nullptr; + HWND nodes_label_ = nullptr; + HWND output_label_ = nullptr; HWND nodes_list_ = nullptr; HWND output_list_ = nullptr; std::array button_controls_ {}; std::array axis_labels_ {}; std::array axis_sliders_ {}; + std::array visible_buttons_ {}; + std::array momentary_pointer_pressed_ {}; + std::array momentary_key_pressed_ {}; std::unique_ptr runtime_; std::mutex mutex_; std::map devices_; lvh::DeviceId selected_id_ = 0; std::uint64_t next_metadata_index_ = 0; std::uint64_t next_output_sequence_ = 1; + bool battery_controls_visible_ = false; static constexpr std::size_t max_output_events_ = 50; }; diff --git a/tools/windows/libvirtualhid.ico b/tools/windows/libvirtualhid.ico new file mode 100644 index 0000000000000000000000000000000000000000..e7b9f5ceee549007dcf0d504a2c1839c3aef9356 GIT binary patch literal 14723 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 U3Y1oaZlZdj`to^|^6QWP2SdT>d;kCd literal 0 HcmV?d00001 diff --git a/tools/windows/resource.h b/tools/windows/resource.h new file mode 100644 index 0000000..4b1417a --- /dev/null +++ b/tools/windows/resource.h @@ -0,0 +1,3 @@ +#pragma once + +#define IDI_VIRTUALHID 101 diff --git a/tools/windows/virtualhid_control.rc b/tools/windows/virtualhid_control.rc new file mode 100644 index 0000000..711d534 --- /dev/null +++ b/tools/windows/virtualhid_control.rc @@ -0,0 +1,3 @@ +#include "resource.h" + +IDI_VIRTUALHID ICON "libvirtualhid.ico" From 56bc6994e2f4db72e3d386fb8caf1ed39f9727dc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:04:26 -0400 Subject: [PATCH 03/12] Fix UI rendering and layout issues in control panel Improve window clipping, redrawing, and layout handling: - Add WS_CLIPCHILDREN and WS_CLIPSIBLINGS window styles for proper child control rendering - Force window redraw on size changes and profile switching - Add SWP_NOCOPYBITS flag to prevent drawing artifacts during repositioning - Remove tick marks from trackbars for cleaner appearance - Extract layout dimensions into named constants for better maintainability - Refactor battery control positioning with configurable spacing - Increase minimum window size from 900x660 to 980x740 - Track visibility changes to optimize redraw operations - Add relayout_and_redraw() helper to ensure consistent UI updates --- tools/virtualhid_control.cpp | 71 +++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index 242b60e..4fa33c6 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -372,7 +372,7 @@ namespace { 0, window_class.lpszClassName, L"libvirtualhid control", - WS_OVERLAPPEDWINDOW, + WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN, CW_USEDEFAULT, CW_USEDEFAULT, 980, @@ -482,6 +482,7 @@ namespace { return 0; case WM_SIZE: layout_controls(LOWORD(lparam), HIWORD(lparam)); + redraw_window(); return 0; case WM_GETMINMAXINFO: handle_min_max_info(reinterpret_cast(lparam)); @@ -516,7 +517,7 @@ namespace { 0, class_name, text, - WS_CHILD | WS_VISIBLE | style, + WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS | style, 0, 0, 10, @@ -545,7 +546,7 @@ namespace { feature_text_ = create_child(L"STATIC", L"", SS_LEFT, feature_text_id); battery_label_ = create_child(L"STATIC", L"Battery", SS_LEFT, 0); battery_state_combo_ = create_child(WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL, battery_state_combo_id); - battery_slider_ = create_child(TRACKBAR_CLASSW, L"", TBS_AUTOTICKS, battery_slider_id); + battery_slider_ = create_child(TRACKBAR_CLASSW, L"", TBS_NOTICKS, battery_slider_id); clear_battery_button_ = create_child(L"BUTTON", L"Clear", BS_PUSHBUTTON, clear_battery_button_id); output_summary_text_ = create_child(L"STATIC", L"", SS_LEFT, output_summary_text_id); nodes_label_ = create_child(L"STATIC", L"Device nodes", SS_LEFT, 0); @@ -585,7 +586,7 @@ namespace { axis_sliders_[index] = create_child( TRACKBAR_CLASSW, L"", - TBS_AUTOTICKS, + TBS_NOTICKS, axis_base_id + static_cast(index) ); ::SendMessageW( @@ -603,6 +604,9 @@ namespace { constexpr auto gap = 8; constexpr auto row = 28; constexpr auto label_height = 18; + constexpr auto axis_label_height = 22; + constexpr auto axis_slider_height = 34; + constexpr auto axis_row_height = 64; constexpr auto button_width = 108; constexpr auto button_height = 28; constexpr auto state_height = 72; @@ -662,13 +666,13 @@ namespace { const auto column = static_cast(index % static_cast(slider_columns)); const auto row_index = static_cast(index / static_cast(slider_columns)); const auto x = right_x + column * (slider_width + gap); - const auto y = slider_top + row_index * 54; - move(axis_labels_[index], x, y, slider_width, 18); - move(axis_sliders_[index], x, y + 18, slider_width, 34); + const auto y = slider_top + row_index * axis_row_height; + move(axis_labels_[index], x, y, slider_width, axis_label_height); + move(axis_sliders_[index], x, y + axis_label_height, slider_width, axis_slider_height); } const auto slider_rows = static_cast((axis_sliders_.size() + static_cast(slider_columns) - 1U) / static_cast(slider_columns)); - auto next_top = slider_top + slider_rows * 54 + gap; + auto next_top = slider_top + slider_rows * axis_row_height + gap; const auto show_battery = battery_controls_visible_; ::ShowWindow(battery_label_, show_battery ? SW_SHOW : SW_HIDE); ::ShowWindow(battery_state_combo_, show_battery ? SW_SHOW : SW_HIDE); @@ -676,15 +680,16 @@ namespace { ::ShowWindow(clear_battery_button_, show_battery ? SW_SHOW : SW_HIDE); if (show_battery) { const auto clear_width = 70; + const auto battery_label_width = 84; const auto combo_width = std::min(220, std::max(150, right_width / 3)); - move(battery_label_, right_x, next_top + 5, 58, label_height); - move(battery_state_combo_, right_x + 64, next_top, combo_width, 180); + move(battery_label_, right_x, next_top + 5, battery_label_width, label_height); + move(battery_state_combo_, right_x + battery_label_width + gap, next_top, combo_width, 180); move(clear_battery_button_, right_x + right_width - clear_width, next_top, clear_width, row); move( battery_slider_, - right_x + 64 + combo_width + gap, + right_x + battery_label_width + gap + combo_width + gap, next_top, - std::max(120, right_width - 64 - combo_width - gap - clear_width - gap), + std::max(120, right_width - battery_label_width - combo_width - clear_width - (gap * 3)), row ); next_top += row + gap; @@ -703,7 +708,7 @@ namespace { static void move(HWND control, int x, int y, int width, int height) { if (control != nullptr) { - ::SetWindowPos(control, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE); + ::SetWindowPos(control, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOCOPYBITS); } } @@ -712,8 +717,8 @@ namespace { return; } - info->ptMinTrackSize.x = 900; - info->ptMinTrackSize.y = 660; + info->ptMinTrackSize.x = 980; + info->ptMinTrackSize.y = 740; } bool buttons_locked() const { @@ -728,6 +733,15 @@ namespace { layout_controls(rect.right - rect.left, rect.bottom - rect.top); } + void relayout_and_redraw() { + layout_current_client(); + redraw_window(); + } + + void redraw_window() { + ::RedrawWindow(window_, nullptr, nullptr, RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_ERASE); + } + void handle_command(int id, int notification) { if (id == create_button_id && notification == BN_CLICKED) { create_gamepad(); @@ -735,7 +749,7 @@ namespace { } if (id == profile_combo_id && notification == CBN_SELCHANGE) { refresh_selected_device(); - layout_current_client(); + relayout_and_redraw(); return; } if (id == reset_button_id && notification == BN_CLICKED) { @@ -1073,11 +1087,16 @@ namespace { return profile_for_choice(profile_choices[static_cast(selection)]); } - void update_visible_controls_for_profile(const lvh::DeviceProfile &profile) { + bool update_visible_controls_for_profile(const lvh::DeviceProfile &profile) { + auto changed = false; for (std::size_t index = 0; index < button_choices.size(); ++index) { - visible_buttons_[index] = lvh::supports_gamepad_button(profile, button_choices[index].button); + const auto visible = lvh::supports_gamepad_button(profile, button_choices[index].button); + changed = changed || visible_buttons_[index] != visible; + visible_buttons_[index] = visible; } + changed = changed || battery_controls_visible_ != profile.capabilities.supports_battery; battery_controls_visible_ = profile.capabilities.supports_battery; + return changed; } std::wstring profile_feature_summary(const lvh::DeviceProfile &profile) const { @@ -1136,6 +1155,7 @@ namespace { void refresh_selected_device() { std::lock_guard lock {mutex_}; auto *device = selected_device_locked(); + auto relayout_needed = false; const auto enabled = device != nullptr; ::EnableWindow(reset_button_, enabled); ::EnableWindow(remove_selected_button_, enabled); @@ -1146,10 +1166,13 @@ namespace { if (device == nullptr) { if (const auto profile = current_combo_profile()) { - update_visible_controls_for_profile(*profile); + relayout_needed = update_visible_controls_for_profile(*profile); ::SetWindowTextW(feature_text_, profile_feature_summary(*profile).c_str()); } else { + relayout_needed = std::any_of(visible_buttons_.begin(), visible_buttons_.end(), [](bool visible) { + return visible; + }) || battery_controls_visible_; visible_buttons_.fill(false); battery_controls_visible_ = false; ::SetWindowTextW(feature_text_, L""); @@ -1168,14 +1191,16 @@ namespace { ::SendMessageW(output_list_, LB_RESETCONTENT, 0, 0); reset_sliders(); refresh_battery_controls({}, false); - layout_current_client(); + if (relayout_needed) { + relayout_and_redraw(); + } return; } const auto *gamepad = device->adapter->gamepad(); const auto &profile = gamepad->profile(); const auto state = device->adapter->state(); - update_visible_controls_for_profile(profile); + relayout_needed = update_visible_controls_for_profile(profile); std::wostringstream state_text; state_text << device->profile_label << L" #" << gamepad->device_id() << L"\r\n" @@ -1210,7 +1235,9 @@ namespace { refresh_nodes(*gamepad); refresh_outputs(*device); - layout_current_client(); + if (relayout_needed) { + relayout_and_redraw(); + } } void reset_sliders() { From 1cfa3a2877c5245f3a297d5d481f6e9ce26d37a2 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:09:09 -0400 Subject: [PATCH 04/12] Build virtualhid_control in CI Update the Windows driver build step to include `virtualhid_control` alongside the existing catalog and adapter targets, so CI covers the control target as part of the driver pipeline. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e19605..eb95e0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -505,7 +505,7 @@ jobs: run: >- cmake --build cmake-build-driver --config ${{ env.DRIVER_BUILD_CONFIG }} - --target libvirtualhid_windows_catalog gamepad_adapter + --target libvirtualhid_windows_catalog gamepad_adapter virtualhid_control --parallel 2 - name: Validate Azure signing configuration From d444475e2004dc5bf7cb17713c258fb0ed3c4c28 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:16:35 -0400 Subject: [PATCH 05/12] Move icon file to project root Relocate libvirtualhid.ico from tools/windows/ to the project root directory. Update CMakeLists.txt to include the project source directory in the include path so the resource build can locate the icon file. --- .../windows/libvirtualhid.ico => libvirtualhid.ico | Bin tools/CMakeLists.txt | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) rename tools/windows/libvirtualhid.ico => libvirtualhid.ico (100%) diff --git a/tools/windows/libvirtualhid.ico b/libvirtualhid.ico similarity index 100% rename from tools/windows/libvirtualhid.ico rename to libvirtualhid.ico diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 25e7d55..8cf5bcb 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -13,7 +13,8 @@ if(WIN32) "${CMAKE_CURRENT_SOURCE_DIR}/windows/virtualhid_control.rc") target_include_directories(virtualhid_control PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/windows") + "${CMAKE_CURRENT_SOURCE_DIR}/windows" + "${PROJECT_SOURCE_DIR}") target_compile_definitions(virtualhid_control PRIVATE NOMINMAX From 0684ff9e0f1e323c409af464154cf0d29950c185 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:39:02 -0400 Subject: [PATCH 06/12] Fix clang-format --- tools/virtualhid_control.cpp | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index 4fa33c6..3442722 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -6,8 +6,8 @@ // standard includes #include #include -#include #include +#include #include #include #include @@ -37,11 +37,11 @@ #define _UNICODE #endif - #include - #include - #include "resource.h" + #include + #include + namespace { constexpr auto output_changed_message = WM_APP + 1U; @@ -1116,8 +1116,7 @@ namespace { stream << L"Output: "; if (device.outputs.empty()) { stream << L"no reports received"; - } - else { + } else { auto wrote = false; if (device.latest_rumble) { stream << L"rumble low=" << device.latest_rumble->low_frequency_rumble << L" high=" << device.latest_rumble->high_frequency_rumble; @@ -1168,11 +1167,11 @@ namespace { if (const auto profile = current_combo_profile()) { relayout_needed = update_visible_controls_for_profile(*profile); ::SetWindowTextW(feature_text_, profile_feature_summary(*profile).c_str()); - } - else { + } else { relayout_needed = std::any_of(visible_buttons_.begin(), visible_buttons_.end(), [](bool visible) { - return visible; - }) || battery_controls_visible_; + return visible; + }) || + battery_controls_visible_; visible_buttons_.fill(false); battery_controls_visible_ = false; ::SetWindowTextW(feature_text_, L""); @@ -1210,12 +1209,11 @@ namespace { << L"LT " << state.left_trigger << L" RT " << state.right_trigger << L"\r\n"; if (state.battery) { state_text << L"Battery " << battery_state_name(state.battery->state) << L" " << static_cast(state.battery->percentage) << L"% | "; - } - else { + } else { state_text << L"Battery unset | "; } state_text - << gamepad->submit_count() << L" submits"; + << gamepad->submit_count() << L" submits"; ::SetWindowTextW(state_text_, state_text.str().c_str()); ::SetWindowTextW(feature_text_, profile_feature_summary(profile).c_str()); ::SetWindowTextW(output_summary_text_, output_summary(*device, profile).c_str()); @@ -1445,8 +1443,8 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, LPSTR, int) { #else -// standard includes -#include + // standard includes + #include /** * @brief Report that the native UI is not implemented on this platform yet. From f99c5ddcc24441928231b37c738e5b355388a442 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:14:10 -0400 Subject: [PATCH 07/12] Fix MSCV build --- docs/usage.md | 4 +++- src/CMakeLists.txt | 2 +- tools/CMakeLists.txt | 2 +- tools/virtualhid_control.cpp | 6 ++++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 0e90c0d..c8b4d8e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -61,7 +61,9 @@ unless they explicitly enable additional options. - `LIBVIRTUALHID_TOOLS_STATIC_RUNTIME`: link diagnostic tools against static compiler runtimes where supported. This is enabled by default so the Windows MinGW/UCRT64 `virtualhid_control.exe` does not need adjacent MinGW runtime - DLLs. + DLLs. MSVC uses the static runtime for tools in the Windows driver package + build, where the packaged library, examples, and tools are all built with the + same runtime setting. - `LIBVIRTUALHID_TOOLS_FULLY_STATIC`: pass full static link flags for diagnostic tools. On Linux this also requires static archives for backend dependencies such as `libevdev`, and may not be supported by every distro. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ceaaacf..51542ff 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -78,7 +78,7 @@ target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_23) set_target_properties(${PROJECT_NAME} PROPERTIES EXPORT_NAME libvirtualhid OUTPUT_NAME virtualhid) -if(MSVC AND (LIBVIRTUALHID_BUILD_WINDOWS_DRIVER OR (LIBVIRTUALHID_BUILD_TOOLS AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME))) +if(MSVC AND LIBVIRTUALHID_BUILD_WINDOWS_DRIVER) set_property(TARGET ${PROJECT_NAME} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") endif() diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 8cf5bcb..b0ac1ff 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -27,7 +27,7 @@ if(WIN32) user32) endif() -if(MSVC AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME) +if(MSVC AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME AND LIBVIRTUALHID_BUILD_WINDOWS_DRIVER) set_property(TARGET virtualhid_control PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") endif() diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index 3442722..3bd969d 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -39,8 +39,10 @@ #include "resource.h" - #include - #include +// clang-format off + #include + #include +// clang-format on namespace { From 239dfb5b3f95a13b329926c30479288e7f2f96df Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:59:25 -0400 Subject: [PATCH 08/12] Sonar fixes --- tools/virtualhid_control.cpp | 786 +++++++++++++++------------- tools/windows/resource.h | 3 - tools/windows/virtualhid_control.rc | 4 +- 3 files changed, 431 insertions(+), 362 deletions(-) delete mode 100644 tools/windows/resource.h diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index 3bd969d..12ac2e8 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -37,7 +37,8 @@ #define _UNICODE #endif - #include "resource.h" + #include + #include // clang-format off #include @@ -48,28 +49,47 @@ namespace { constexpr auto output_changed_message = WM_APP + 1U; constexpr auto button_subclass_id = 1U; + constexpr auto icon_resource_id = 101; constexpr auto slider_scale = 100; - enum ControlId : int { - profile_combo_id = 1000, - create_button_id, - device_list_id, - reset_button_id, - remove_selected_button_id, - remove_all_button_id, - lock_buttons_check_id, - state_text_id, - feature_text_id, - battery_state_combo_id, - battery_slider_id, - clear_battery_button_id, - output_summary_text_id, - node_list_id, - output_list_id, - button_base_id = 2000, - axis_base_id = 3000, + enum class ControlId : int { + profile_combo = 1000, + create_button, + device_list, + reset_button, + remove_selected_button, + remove_all_button, + lock_buttons_check, + state_text, + feature_text, + battery_state_combo, + battery_slider, + clear_battery_button, + output_summary_text, + node_list, + output_list, + button_base = 2000, + axis_base = 3000, }; + constexpr auto profile_combo_id = static_cast(ControlId::profile_combo); + constexpr auto create_button_id = static_cast(ControlId::create_button); + constexpr auto device_list_id = static_cast(ControlId::device_list); + constexpr auto reset_button_id = static_cast(ControlId::reset_button); + constexpr auto remove_selected_button_id = static_cast(ControlId::remove_selected_button); + constexpr auto remove_all_button_id = static_cast(ControlId::remove_all_button); + constexpr auto lock_buttons_check_id = static_cast(ControlId::lock_buttons_check); + constexpr auto state_text_id = static_cast(ControlId::state_text); + constexpr auto feature_text_id = static_cast(ControlId::feature_text); + constexpr auto battery_state_combo_id = static_cast(ControlId::battery_state_combo); + constexpr auto battery_slider_id = static_cast(ControlId::battery_slider); + constexpr auto clear_battery_button_id = static_cast(ControlId::clear_battery_button); + constexpr auto output_summary_text_id = static_cast(ControlId::output_summary_text); + constexpr auto node_list_id = static_cast(ControlId::node_list); + constexpr auto output_list_id = static_cast(ControlId::output_list); + constexpr auto button_base_id = static_cast(ControlId::button_base); + constexpr auto axis_base_id = static_cast(ControlId::axis_base); + struct ProfileChoice { std::wstring_view id; std::wstring_view label; @@ -162,6 +182,56 @@ namespace { std::optional latest_raw_report; }; + struct MainControls { + HWND backend_text = nullptr; + HWND profile_combo = nullptr; + HWND create_button = nullptr; + HWND state_text = nullptr; + HWND feature_text = nullptr; + }; + + struct DeviceListControls { + HWND list = nullptr; + HWND reset_button = nullptr; + HWND remove_selected_button = nullptr; + HWND remove_all_button = nullptr; + }; + + struct ButtonControls { + HWND lock_check = nullptr; + std::array controls {}; + std::array visible {}; + std::array momentary_pointer_pressed {}; + std::array momentary_key_pressed {}; + }; + + struct AxisControls { + std::array labels {}; + std::array sliders {}; + }; + + struct BatteryControls { + HWND label = nullptr; + HWND state_combo = nullptr; + HWND slider = nullptr; + HWND clear_button = nullptr; + bool visible = false; + }; + + struct OutputControls { + HWND summary_text = nullptr; + HWND nodes_label = nullptr; + HWND output_label = nullptr; + HWND nodes_list = nullptr; + HWND output_list = nullptr; + }; + + template + Target win32_cast(Source value) { + static_assert(sizeof(Target) == sizeof(Source)); + return std::bit_cast(value); + } + std::wstring widen(std::string_view value) { if (value.empty()) { return {}; @@ -180,15 +250,15 @@ namespace { } std::wstring result(static_cast(required), L'\0'); - const auto copied = ::MultiByteToWideChar( - CP_UTF8, - 0, - value.data(), - static_cast(value.size()), - result.data(), - required - ); - if (copied <= 0) { + if (const auto copied = ::MultiByteToWideChar( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + result.data(), + required + ); + copied <= 0) { return std::wstring {value.begin(), value.end()}; } return result; @@ -328,6 +398,172 @@ namespace { return value ? L"yes" : L"no"; } + bool supports_normalized_feedback(const lvh::DeviceProfile &profile) { + using enum lvh::GamepadOutputKind; + + return lvh::supports_gamepad_output(profile, rumble) || + lvh::supports_gamepad_output(profile, rgb_led) || + lvh::supports_gamepad_output(profile, adaptive_triggers) || + lvh::supports_gamepad_output(profile, trigger_rumble); + } + + std::wstring profile_feature_summary(const lvh::DeviceProfile &profile) { + using enum lvh::GamepadOutputKind; + + const auto support = lvh::gamepad_profile_support(profile); + std::wostringstream stream; + stream << L"Features: battery " << yes_no(support.supports_battery) + << L" | rumble " << yes_no(lvh::supports_gamepad_output(profile, rumble)) + << L" | trigger rumble " << yes_no(lvh::supports_gamepad_output(profile, trigger_rumble)) + << L" | RGB LED " << yes_no(lvh::supports_gamepad_output(profile, rgb_led)) + << L" | adaptive triggers " << yes_no(lvh::supports_gamepad_output(profile, adaptive_triggers)) + << L" | raw output " << yes_no(lvh::supports_gamepad_output(profile, raw_report)); + return stream.str(); + } + + void append_summary_separator(std::wostringstream &stream, bool wrote) { + if (wrote) { + stream << L" | "; + } + } + + bool append_latest_output_summary(std::wostringstream &stream, const ControlledGamepad &device) { + auto wrote = false; + if (device.latest_rumble) { + stream << L"rumble low=" << device.latest_rumble->low_frequency_rumble << L" high=" << device.latest_rumble->high_frequency_rumble; + wrote = true; + } + if (device.latest_trigger_rumble) { + append_summary_separator(stream, wrote); + stream << L"trigger rumble L=" << device.latest_trigger_rumble->left_trigger_rumble << L" R=" << device.latest_trigger_rumble->right_trigger_rumble; + wrote = true; + } + if (device.latest_rgb_led) { + append_summary_separator(stream, wrote); + stream << L"RGB " << static_cast(device.latest_rgb_led->red) << L"," << static_cast(device.latest_rgb_led->green) + << L"," << static_cast(device.latest_rgb_led->blue); + wrote = true; + } + if (device.latest_adaptive_triggers) { + append_summary_separator(stream, wrote); + stream << L"adaptive flags=" << static_cast(device.latest_adaptive_triggers->adaptive_trigger_flags); + wrote = true; + } + if (!wrote && device.latest_raw_report) { + stream << L"raw report"; + wrote = true; + } + return wrote; + } + + std::wstring output_summary(const ControlledGamepad &device, const lvh::DeviceProfile &profile) { + std::wostringstream stream; + stream << L"Output: "; + if (device.outputs.empty()) { + stream << L"no reports received"; + } else if (!append_latest_output_summary(stream, device)) { + stream << L"reports received"; + } + + if (!supports_normalized_feedback(profile)) { + stream << L" | profile has no normalized feedback categories"; + } + return stream.str(); + } + + std::optional current_combo_profile(HWND profile_combo) { + const auto selection = ::SendMessageW(profile_combo, CB_GETCURSEL, 0, 0); + if (selection == CB_ERR || selection < 0 || static_cast(selection) >= profile_choices.size()) { + return std::nullopt; + } + + return profile_for_choice(profile_choices[static_cast(selection)]); + } + + bool update_visible_controls_for_profile( + const lvh::DeviceProfile &profile, + std::array &visible_buttons, + bool &battery_controls_visible + ) { + auto changed = false; + for (std::size_t index = 0; index < button_choices.size(); ++index) { + const auto visible = lvh::supports_gamepad_button(profile, button_choices[index].button); + changed = changed || visible_buttons[index] != visible; + visible_buttons[index] = visible; + } + changed = changed || battery_controls_visible != profile.capabilities.supports_battery; + battery_controls_visible = profile.capabilities.supports_battery; + return changed; + } + + void move_control(HWND control, int x, int y, int width, int height) { + if (control != nullptr) { + ::SetWindowPos(control, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOCOPYBITS); + } + } + + void handle_min_max_info(MINMAXINFO *info) { + if (info == nullptr) { + return; + } + + info->ptMinTrackSize.x = 980; + info->ptMinTrackSize.y = 740; + } + + bool buttons_locked(HWND window) { + return ::IsDlgButtonChecked(window, lock_buttons_check_id) == BST_CHECKED; + } + + void reset_sliders(const AxisControls &axes) { + for (auto *slider : axes.sliders) { + ::SendMessageW(slider, TBM_SETPOS, TRUE, 0); + } + } + + void set_button_visual(const ButtonControls &buttons, std::size_t index, bool pressed) { + if (index >= buttons.controls.size()) { + return; + } + ::SendMessageW(buttons.controls[index], BM_SETSTATE, pressed ? TRUE : FALSE, 0); + } + + void refresh_nodes(HWND nodes_list, const lvh::Gamepad &gamepad) { + ::SendMessageW(nodes_list, LB_RESETCONTENT, 0, 0); + const auto nodes = gamepad.device_nodes(); + if (nodes.empty()) { + ::SendMessageW(nodes_list, LB_ADDSTRING, 0, win32_cast(L"No device nodes reported yet.")); + return; + } + + for (const auto &node : nodes) { + const auto line = node_kind_name(node.kind) + L": " + widen(node.path); + ::SendMessageW(nodes_list, LB_ADDSTRING, 0, win32_cast(line.c_str())); + } + } + + void refresh_outputs(HWND output_list, const ControlledGamepad &device) { + ::SendMessageW(output_list, LB_RESETCONTENT, 0, 0); + if (device.outputs.empty()) { + ::SendMessageW(output_list, LB_ADDSTRING, 0, win32_cast(L"No output reports received.")); + return; + } + + for (const auto &entry : device.outputs) { + const auto &output = entry.output; + std::wostringstream line; + line << L"#" << entry.sequence << L" " << output_kind_name(output.kind) + << L" low=" << output.low_frequency_rumble + << L" high=" << output.high_frequency_rumble + << L" rgb=" << static_cast(output.red) << L"," << static_cast(output.green) << L"," + << static_cast(output.blue); + if (!output.raw_report.empty()) { + line << L" raw=" << raw_hex(output.raw_report); + } + ::SendMessageW(output_list, LB_ADDSTRING, 0, win32_cast(line.str().c_str())); + } + } + class ControlWindow { public: explicit ControlWindow(HINSTANCE instance): @@ -349,13 +585,13 @@ namespace { window_class.lpfnWndProc = &ControlWindow::window_proc; window_class.lpszClassName = L"LibVirtualHidControlWindow"; window_class.hCursor = ::LoadCursorW(nullptr, IDC_ARROW); - window_class.hIcon = ::LoadIconW(instance_, MAKEINTRESOURCEW(IDI_VIRTUALHID)); + window_class.hIcon = ::LoadIconW(instance_, MAKEINTRESOURCEW(icon_resource_id)); if (window_class.hIcon == nullptr) { window_class.hIcon = ::LoadIconW(nullptr, IDI_APPLICATION); } - window_class.hIconSm = reinterpret_cast(::LoadImageW( + window_class.hIconSm = win32_cast(::LoadImageW( instance_, - MAKEINTRESOURCEW(IDI_VIRTUALHID), + MAKEINTRESOURCEW(icon_resource_id), IMAGE_ICON, ::GetSystemMetrics(SM_CXSMICON), ::GetSystemMetrics(SM_CYSMICON), @@ -364,7 +600,7 @@ namespace { if (window_class.hIconSm == nullptr) { window_class.hIconSm = window_class.hIcon; } - window_class.hbrBackground = reinterpret_cast(COLOR_WINDOW + 1); + window_class.hbrBackground = ::GetSysColorBrush(COLOR_WINDOW); if (::RegisterClassExW(&window_class) == 0U) { return 1; @@ -401,12 +637,12 @@ namespace { private: static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { - auto *self = reinterpret_cast(::GetWindowLongPtrW(window, GWLP_USERDATA)); + auto *self = win32_cast(::GetWindowLongPtrW(window, GWLP_USERDATA)); if (message == WM_NCCREATE) { - const auto *create = reinterpret_cast(lparam); + const auto *create = win32_cast(lparam); self = static_cast(create->lpCreateParams); self->window_ = window; - ::SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(self)); + ::SetWindowLongPtrW(window, GWLP_USERDATA, win32_cast(self)); } if (self != nullptr) { @@ -416,7 +652,7 @@ namespace { } static LRESULT CALLBACK button_subclass_proc(HWND control, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR, DWORD_PTR ref_data) { - auto *self = reinterpret_cast(ref_data); + auto *self = win32_cast(ref_data); if (self == nullptr) { return ::DefSubclassProc(control, message, wparam, lparam); } @@ -431,40 +667,40 @@ namespace { } const auto index = static_cast(id - button_base_id); - if (!buttons_locked()) { + if (!buttons_locked(window_)) { switch (message) { case WM_LBUTTONDOWN: case WM_LBUTTONDBLCLK: - momentary_pointer_pressed_[index] = true; + buttons_.momentary_pointer_pressed[index] = true; set_selected_button(index, true); break; case WM_LBUTTONUP: - if (momentary_pointer_pressed_[index]) { - momentary_pointer_pressed_[index] = false; + if (buttons_.momentary_pointer_pressed[index]) { + buttons_.momentary_pointer_pressed[index] = false; set_selected_button(index, false); } break; case WM_CAPTURECHANGED: - if (momentary_pointer_pressed_[index]) { - momentary_pointer_pressed_[index] = false; + if (buttons_.momentary_pointer_pressed[index]) { + buttons_.momentary_pointer_pressed[index] = false; set_selected_button(index, false); } break; case WM_KEYDOWN: - if (wparam == VK_SPACE && !momentary_key_pressed_[index]) { - momentary_key_pressed_[index] = true; + if (wparam == VK_SPACE && !buttons_.momentary_key_pressed[index]) { + buttons_.momentary_key_pressed[index] = true; set_selected_button(index, true); } break; case WM_KEYUP: - if (wparam == VK_SPACE && momentary_key_pressed_[index]) { - momentary_key_pressed_[index] = false; + if (wparam == VK_SPACE && buttons_.momentary_key_pressed[index]) { + buttons_.momentary_key_pressed[index] = false; set_selected_button(index, false); } break; case WM_KILLFOCUS: - if (momentary_key_pressed_[index]) { - momentary_key_pressed_[index] = false; + if (buttons_.momentary_key_pressed[index]) { + buttons_.momentary_key_pressed[index] = false; set_selected_button(index, false); } break; @@ -487,13 +723,13 @@ namespace { redraw_window(); return 0; case WM_GETMINMAXINFO: - handle_min_max_info(reinterpret_cast(lparam)); + handle_min_max_info(win32_cast(lparam)); return 0; case WM_COMMAND: handle_command(LOWORD(wparam), HIWORD(wparam)); return 0; case WM_HSCROLL: - handle_slider(reinterpret_cast(lparam)); + handle_slider(lparam); return 0; case output_changed_message: refresh_selected_device(); @@ -514,7 +750,7 @@ namespace { DWORD style, int id ) { - auto *font = reinterpret_cast(::GetStockObject(DEFAULT_GUI_FONT)); + auto *font = win32_cast(::GetStockObject(DEFAULT_GUI_FONT)); auto *control = ::CreateWindowExW( 0, class_name, @@ -525,79 +761,79 @@ namespace { 10, 10, window_, - reinterpret_cast(static_cast(id)), + win32_cast(static_cast(id)), instance_, nullptr ); if (control != nullptr) { - ::SendMessageW(control, WM_SETFONT, reinterpret_cast(font), TRUE); + ::SendMessageW(control, WM_SETFONT, win32_cast(font), TRUE); } return control; } void create_controls() { - backend_text_ = create_child(L"STATIC", L"", SS_LEFT, 0); - profile_combo_ = create_child(WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL, profile_combo_id); - create_button_ = create_child(L"BUTTON", L"Create", BS_PUSHBUTTON, create_button_id); - device_list_ = create_child(L"LISTBOX", L"", LBS_NOTIFY | WS_BORDER | WS_VSCROLL, device_list_id); - reset_button_ = create_child(L"BUTTON", L"Reset", BS_PUSHBUTTON, reset_button_id); - remove_selected_button_ = create_child(L"BUTTON", L"Remove selected", BS_PUSHBUTTON, remove_selected_button_id); - remove_all_button_ = create_child(L"BUTTON", L"Remove all", BS_PUSHBUTTON, remove_all_button_id); - lock_buttons_check_ = create_child(L"BUTTON", L"Lock buttons", BS_AUTOCHECKBOX, lock_buttons_check_id); - state_text_ = create_child(L"STATIC", L"No device selected.", SS_LEFT, state_text_id); - feature_text_ = create_child(L"STATIC", L"", SS_LEFT, feature_text_id); - battery_label_ = create_child(L"STATIC", L"Battery", SS_LEFT, 0); - battery_state_combo_ = create_child(WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL, battery_state_combo_id); - battery_slider_ = create_child(TRACKBAR_CLASSW, L"", TBS_NOTICKS, battery_slider_id); - clear_battery_button_ = create_child(L"BUTTON", L"Clear", BS_PUSHBUTTON, clear_battery_button_id); - output_summary_text_ = create_child(L"STATIC", L"", SS_LEFT, output_summary_text_id); - nodes_label_ = create_child(L"STATIC", L"Device nodes", SS_LEFT, 0); - output_label_ = create_child(L"STATIC", L"Output reports", SS_LEFT, 0); - nodes_list_ = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, node_list_id); - output_list_ = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, output_list_id); + main_controls_.backend_text = create_child(L"STATIC", L"", SS_LEFT, 0); + main_controls_.profile_combo = create_child(WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL, profile_combo_id); + main_controls_.create_button = create_child(L"BUTTON", L"Create", BS_PUSHBUTTON, create_button_id); + device_controls_.list = create_child(L"LISTBOX", L"", LBS_NOTIFY | WS_BORDER | WS_VSCROLL, device_list_id); + device_controls_.reset_button = create_child(L"BUTTON", L"Reset", BS_PUSHBUTTON, reset_button_id); + device_controls_.remove_selected_button = create_child(L"BUTTON", L"Remove selected", BS_PUSHBUTTON, remove_selected_button_id); + device_controls_.remove_all_button = create_child(L"BUTTON", L"Remove all", BS_PUSHBUTTON, remove_all_button_id); + buttons_.lock_check = create_child(L"BUTTON", L"Lock buttons", BS_AUTOCHECKBOX, lock_buttons_check_id); + main_controls_.state_text = create_child(L"STATIC", L"No device selected.", SS_LEFT, state_text_id); + main_controls_.feature_text = create_child(L"STATIC", L"", SS_LEFT, feature_text_id); + battery_.label = create_child(L"STATIC", L"Battery", SS_LEFT, 0); + battery_.state_combo = create_child(WC_COMBOBOXW, L"", CBS_DROPDOWNLIST | WS_VSCROLL, battery_state_combo_id); + battery_.slider = create_child(TRACKBAR_CLASSW, L"", TBS_NOTICKS, battery_slider_id); + battery_.clear_button = create_child(L"BUTTON", L"Clear", BS_PUSHBUTTON, clear_battery_button_id); + output_controls_.summary_text = create_child(L"STATIC", L"", SS_LEFT, output_summary_text_id); + output_controls_.nodes_label = create_child(L"STATIC", L"Device nodes", SS_LEFT, 0); + output_controls_.output_label = create_child(L"STATIC", L"Output reports", SS_LEFT, 0); + output_controls_.nodes_list = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, node_list_id); + output_controls_.output_list = create_child(L"LISTBOX", L"", WS_BORDER | WS_VSCROLL, output_list_id); for (const auto &choice : profile_choices) { - ::SendMessageW(profile_combo_, CB_ADDSTRING, 0, reinterpret_cast(choice.label.data())); + ::SendMessageW(main_controls_.profile_combo, CB_ADDSTRING, 0, win32_cast(choice.label.data())); } - ::SendMessageW(profile_combo_, CB_SETCURSEL, 3, 0); + ::SendMessageW(main_controls_.profile_combo, CB_SETCURSEL, 3, 0); for (const auto &choice : battery_choices) { - ::SendMessageW(battery_state_combo_, CB_ADDSTRING, 0, reinterpret_cast(choice.label.data())); + ::SendMessageW(battery_.state_combo, CB_ADDSTRING, 0, win32_cast(choice.label.data())); } - ::SendMessageW(battery_state_combo_, CB_SETCURSEL, battery_choice_index(lvh::GamepadBatteryState::full), 0); - ::SendMessageW(battery_slider_, TBM_SETRANGE, TRUE, MAKELPARAM(0, 100)); - ::SendMessageW(battery_slider_, TBM_SETPOS, TRUE, 100); + ::SendMessageW(battery_.state_combo, CB_SETCURSEL, battery_choice_index(lvh::GamepadBatteryState::full), 0); + ::SendMessageW(battery_.slider, TBM_SETRANGE, TRUE, MAKELPARAM(0, 100)); + ::SendMessageW(battery_.slider, TBM_SETPOS, TRUE, 100); for (std::size_t index = 0; index < button_choices.size(); ++index) { - button_controls_[index] = create_child( + buttons_.controls[index] = create_child( L"BUTTON", button_choices[index].label.data(), BS_PUSHBUTTON | BS_NOTIFY, button_base_id + static_cast(index) ); ::SetWindowSubclass( - button_controls_[index], + buttons_.controls[index], &ControlWindow::button_subclass_proc, button_subclass_id, - reinterpret_cast(this) + win32_cast(this) ); } for (std::size_t index = 0; index < axis_choices.size(); ++index) { - axis_labels_[index] = create_child(L"STATIC", axis_choices[index].label.data(), SS_LEFT, 0); - axis_sliders_[index] = create_child( + axes_.labels[index] = create_child(L"STATIC", axis_choices[index].label.data(), SS_LEFT, 0); + axes_.sliders[index] = create_child( TRACKBAR_CLASSW, L"", TBS_NOTICKS, axis_base_id + static_cast(index) ); ::SendMessageW( - axis_sliders_[index], + axes_.sliders[index], TBM_SETRANGE, TRUE, MAKELPARAM(axis_choices[index].minimum, axis_choices[index].maximum) ); - ::SendMessageW(axis_sliders_[index], TBM_SETPOS, TRUE, 0); + ::SendMessageW(axes_.sliders[index], TBM_SETPOS, TRUE, 0); } } @@ -623,35 +859,35 @@ namespace { const auto list_top = margin + row + 48; const auto left_list_height = std::max(120, left_actions_top - list_top - gap); - move(profile_combo_, margin, margin, profile_width, 200); - move(create_button_, margin + profile_width + gap, margin, left_width - profile_width - gap, row); - move(backend_text_, margin, margin + row + gap, left_width, 40); - move(device_list_, margin, list_top, left_width, left_list_height); - move(reset_button_, margin, left_actions_top, 80, row); - move(remove_selected_button_, margin + 88, left_actions_top, left_width - 88, row); - move(remove_all_button_, margin, left_actions_top + row + gap, left_width, row); + move_control(main_controls_.profile_combo, margin, margin, profile_width, 200); + move_control(main_controls_.create_button, margin + profile_width + gap, margin, left_width - profile_width - gap, row); + move_control(main_controls_.backend_text, margin, margin + row + gap, left_width, 40); + move_control(device_controls_.list, margin, list_top, left_width, left_list_height); + move_control(device_controls_.reset_button, margin, left_actions_top, 80, row); + move_control(device_controls_.remove_selected_button, margin + 88, left_actions_top, left_width - 88, row); + move_control(device_controls_.remove_all_button, margin, left_actions_top + row + gap, left_width, row); - move(state_text_, right_x, margin, right_width, state_height); - move(feature_text_, right_x, margin + state_height, right_width, feature_height); + move_control(main_controls_.state_text, right_x, margin, right_width, state_height); + move_control(main_controls_.feature_text, right_x, margin + state_height, right_width, feature_height); const auto buttons_header_top = margin + state_height + feature_height + gap; - move(lock_buttons_check_, right_x, buttons_header_top, 130, row); + move_control(buttons_.lock_check, right_x, buttons_header_top, 130, row); const auto button_columns = std::max(1, std::min(5, (right_width + gap) / (button_width + gap))); const auto actual_button_width = std::max(button_width, (right_width - ((button_columns - 1) * gap)) / button_columns); auto visible_button_count = 0; const auto buttons_top = buttons_header_top + row + gap; - for (std::size_t index = 0; index < button_controls_.size(); ++index) { - if (!visible_buttons_[index]) { - ::ShowWindow(button_controls_[index], SW_HIDE); + for (std::size_t index = 0; index < buttons_.controls.size(); ++index) { + if (!buttons_.visible[index]) { + ::ShowWindow(buttons_.controls[index], SW_HIDE); continue; } - ::ShowWindow(button_controls_[index], SW_SHOW); + ::ShowWindow(buttons_.controls[index], SW_SHOW); const auto column = visible_button_count % button_columns; const auto row_index = visible_button_count / button_columns; - move( - button_controls_[index], + move_control( + buttons_.controls[index], right_x + column * (actual_button_width + gap), buttons_top + row_index * (button_height + gap), actual_button_width, @@ -664,31 +900,31 @@ namespace { const auto slider_top = buttons_top + button_rows * (button_height + gap) + gap; const auto slider_columns = right_width >= 520 ? 2 : 1; const auto slider_width = std::max(180, (right_width - ((slider_columns - 1) * gap)) / slider_columns); - for (std::size_t index = 0; index < axis_sliders_.size(); ++index) { + for (std::size_t index = 0; index < axes_.sliders.size(); ++index) { const auto column = static_cast(index % static_cast(slider_columns)); const auto row_index = static_cast(index / static_cast(slider_columns)); const auto x = right_x + column * (slider_width + gap); const auto y = slider_top + row_index * axis_row_height; - move(axis_labels_[index], x, y, slider_width, axis_label_height); - move(axis_sliders_[index], x, y + axis_label_height, slider_width, axis_slider_height); + move_control(axes_.labels[index], x, y, slider_width, axis_label_height); + move_control(axes_.sliders[index], x, y + axis_label_height, slider_width, axis_slider_height); } - const auto slider_rows = static_cast((axis_sliders_.size() + static_cast(slider_columns) - 1U) / static_cast(slider_columns)); + const auto slider_rows = static_cast((axes_.sliders.size() + static_cast(slider_columns) - 1U) / static_cast(slider_columns)); auto next_top = slider_top + slider_rows * axis_row_height + gap; - const auto show_battery = battery_controls_visible_; - ::ShowWindow(battery_label_, show_battery ? SW_SHOW : SW_HIDE); - ::ShowWindow(battery_state_combo_, show_battery ? SW_SHOW : SW_HIDE); - ::ShowWindow(battery_slider_, show_battery ? SW_SHOW : SW_HIDE); - ::ShowWindow(clear_battery_button_, show_battery ? SW_SHOW : SW_HIDE); + const auto show_battery = battery_.visible; + ::ShowWindow(battery_.label, show_battery ? SW_SHOW : SW_HIDE); + ::ShowWindow(battery_.state_combo, show_battery ? SW_SHOW : SW_HIDE); + ::ShowWindow(battery_.slider, show_battery ? SW_SHOW : SW_HIDE); + ::ShowWindow(battery_.clear_button, show_battery ? SW_SHOW : SW_HIDE); if (show_battery) { const auto clear_width = 70; const auto battery_label_width = 84; const auto combo_width = std::min(220, std::max(150, right_width / 3)); - move(battery_label_, right_x, next_top + 5, battery_label_width, label_height); - move(battery_state_combo_, right_x + battery_label_width + gap, next_top, combo_width, 180); - move(clear_battery_button_, right_x + right_width - clear_width, next_top, clear_width, row); - move( - battery_slider_, + move_control(battery_.label, right_x, next_top + 5, battery_label_width, label_height); + move_control(battery_.state_combo, right_x + battery_label_width + gap, next_top, combo_width, 180); + move_control(battery_.clear_button, right_x + right_width - clear_width, next_top, clear_width, row); + move_control( + battery_.slider, right_x + battery_label_width + gap + combo_width + gap, next_top, std::max(120, right_width - battery_label_width - combo_width - clear_width - (gap * 3)), @@ -697,34 +933,15 @@ namespace { next_top += row + gap; } - move(output_summary_text_, right_x, next_top, right_width, output_summary_height); + move_control(output_controls_.summary_text, right_x, next_top, right_width, output_summary_height); next_top += output_summary_height + gap; const auto list_width = (right_width - gap) / 2; const auto bottom_height = std::max(90, height - next_top - label_height - margin); - move(nodes_label_, right_x, next_top, list_width, label_height); - move(output_label_, right_x + list_width + gap, next_top, list_width, label_height); - move(nodes_list_, right_x, next_top + label_height, list_width, bottom_height); - move(output_list_, right_x + list_width + gap, next_top + label_height, list_width, bottom_height); - } - - static void move(HWND control, int x, int y, int width, int height) { - if (control != nullptr) { - ::SetWindowPos(control, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOCOPYBITS); - } - } - - static void handle_min_max_info(MINMAXINFO *info) { - if (info == nullptr) { - return; - } - - info->ptMinTrackSize.x = 980; - info->ptMinTrackSize.y = 740; - } - - bool buttons_locked() const { - return ::IsDlgButtonChecked(window_, lock_buttons_check_id) == BST_CHECKED; + move_control(output_controls_.nodes_label, right_x, next_top, list_width, label_height); + move_control(output_controls_.output_label, right_x + list_width + gap, next_top, list_width, label_height); + move_control(output_controls_.nodes_list, right_x, next_top + label_height, list_width, bottom_height); + move_control(output_controls_.output_list, right_x + list_width + gap, next_top + label_height, list_width, bottom_height); } void layout_current_client() { @@ -779,28 +996,27 @@ namespace { return; } if (id == device_list_id && notification == LBN_SELCHANGE) { - const auto selection = ::SendMessageW(device_list_, LB_GETCURSEL, 0, 0); - if (selection != LB_ERR) { - selected_id_ = static_cast(::SendMessageW(device_list_, LB_GETITEMDATA, static_cast(selection), 0)); + if (const auto selection = ::SendMessageW(device_controls_.list, LB_GETCURSEL, 0, 0); + selection != LB_ERR) { + selected_id_ = static_cast(::SendMessageW(device_controls_.list, LB_GETITEMDATA, static_cast(selection), 0)); refresh_selected_device(); } return; } - if (id >= button_base_id && id < button_base_id + static_cast(button_choices.size()) && notification == BN_CLICKED) { - if (buttons_locked()) { - toggle_selected_button(static_cast(id - button_base_id)); - } + if (id >= button_base_id && id < button_base_id + static_cast(button_choices.size()) && notification == BN_CLICKED && buttons_locked(window_)) { + toggle_selected_button(static_cast(id - button_base_id)); } } - void handle_slider(HWND slider) { - if (slider == battery_slider_) { + void handle_slider(LPARAM slider_param) { + const auto slider = win32_cast(slider_param); + if (slider == battery_.slider) { set_selected_battery_from_controls(); return; } - for (std::size_t index = 0; index < axis_sliders_.size(); ++index) { - if (axis_sliders_[index] == slider) { + for (std::size_t index = 0; index < axes_.sliders.size(); ++index) { + if (axes_.sliders[index] == slider) { set_selected_axis(index); return; } @@ -808,7 +1024,7 @@ namespace { } void create_gamepad() { - const auto selection = ::SendMessageW(profile_combo_, CB_GETCURSEL, 0, 0); + const auto selection = ::SendMessageW(main_controls_.profile_combo, CB_GETCURSEL, 0, 0); if (selection == CB_ERR || selection < 0 || static_cast(selection) >= profile_choices.size()) { show_error(L"Select a profile first."); return; @@ -830,7 +1046,7 @@ namespace { options.metadata.has_touchpad = profile->capabilities.supports_touchpad; options.metadata.has_rgb_led = profile->capabilities.supports_rgb_led; options.metadata.has_battery = profile->capabilities.supports_battery; - options.metadata.stable_id = "libvirtualhid-control-" + std::to_string(options.metadata.global_index); + options.metadata.stable_id = std::format("libvirtualhid-control-{}", options.metadata.global_index); auto created = lvh::GamepadStateAdapter::create(*runtime_, options); if (!created) { @@ -838,7 +1054,7 @@ namespace { return; } - auto *gamepad = created.adapter->gamepad(); + const auto *gamepad = created.adapter->gamepad(); if (gamepad == nullptr) { show_error(L"Created gamepad handle is missing."); return; @@ -941,7 +1157,7 @@ namespace { } void handle_button_lock_changed() { - if (buttons_locked()) { + if (buttons_locked(window_)) { refresh_selected_device(); return; } @@ -967,7 +1183,7 @@ namespace { void set_selected_axis(std::size_t index) { auto status = lvh::OperationStatus::success(); - const auto position = ::SendMessageW(axis_sliders_[index], TBM_GETPOS, 0, 0); + const auto position = ::SendMessageW(axes_.sliders[index], TBM_GETPOS, 0, 0); { std::lock_guard lock {mutex_}; auto *device = selected_device_locked(); @@ -1010,14 +1226,14 @@ namespace { } void set_selected_battery_from_controls() { - const auto state_selection = ::SendMessageW(battery_state_combo_, CB_GETCURSEL, 0, 0); + const auto state_selection = ::SendMessageW(battery_.state_combo, CB_GETCURSEL, 0, 0); if (state_selection == CB_ERR || state_selection < 0 || static_cast(state_selection) >= battery_choices.size()) { return; } lvh::GamepadBattery battery; battery.state = battery_choices[static_cast(state_selection)].state; - battery.percentage = static_cast(std::clamp(::SendMessageW(battery_slider_, TBM_GETPOS, 0, 0), 0, 100)); + battery.percentage = static_cast(std::clamp(::SendMessageW(battery_.slider, TBM_GETPOS, 0, 0), 0, 100)); auto status = lvh::OperationStatus::success(); { @@ -1061,136 +1277,63 @@ namespace { std::wstring text = L"Backend: " + widen(caps.backend_name); text += caps.supports_gamepad ? L" | gamepad available" : L" | gamepad unavailable"; text += caps.supports_output_reports ? L" | output reports available" : L" | output reports unavailable"; - ::SetWindowTextW(backend_text_, text.c_str()); + ::SetWindowTextW(main_controls_.backend_text, text.c_str()); } void refresh_device_list() { - ::SendMessageW(device_list_, LB_RESETCONTENT, 0, 0); + ::SendMessageW(device_controls_.list, LB_RESETCONTENT, 0, 0); std::lock_guard lock {mutex_}; for (const auto &[id, device] : devices_) { std::wostringstream label; label << L"#" << id << L" " << device.profile_label; - const auto index = ::SendMessageW(device_list_, LB_ADDSTRING, 0, reinterpret_cast(label.str().c_str())); + const auto index = ::SendMessageW(device_controls_.list, LB_ADDSTRING, 0, win32_cast(label.str().c_str())); if (index != LB_ERR) { - ::SendMessageW(device_list_, LB_SETITEMDATA, static_cast(index), static_cast(id)); + ::SendMessageW(device_controls_.list, LB_SETITEMDATA, static_cast(index), static_cast(id)); if (id == selected_id_) { - ::SendMessageW(device_list_, LB_SETCURSEL, static_cast(index), 0); + ::SendMessageW(device_controls_.list, LB_SETCURSEL, static_cast(index), 0); } } } } - std::optional current_combo_profile() const { - const auto selection = ::SendMessageW(profile_combo_, CB_GETCURSEL, 0, 0); - if (selection == CB_ERR || selection < 0 || static_cast(selection) >= profile_choices.size()) { - return std::nullopt; - } - - return profile_for_choice(profile_choices[static_cast(selection)]); - } - - bool update_visible_controls_for_profile(const lvh::DeviceProfile &profile) { - auto changed = false; - for (std::size_t index = 0; index < button_choices.size(); ++index) { - const auto visible = lvh::supports_gamepad_button(profile, button_choices[index].button); - changed = changed || visible_buttons_[index] != visible; - visible_buttons_[index] = visible; - } - changed = changed || battery_controls_visible_ != profile.capabilities.supports_battery; - battery_controls_visible_ = profile.capabilities.supports_battery; - return changed; - } - - std::wstring profile_feature_summary(const lvh::DeviceProfile &profile) const { - const auto support = lvh::gamepad_profile_support(profile); - std::wostringstream stream; - stream << L"Features: battery " << yes_no(support.supports_battery) - << L" | rumble " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::rumble)) - << L" | trigger rumble " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::trigger_rumble)) - << L" | RGB LED " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::rgb_led)) - << L" | adaptive triggers " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::adaptive_triggers)) - << L" | raw output " << yes_no(lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::raw_report)); - return stream.str(); - } - - std::wstring output_summary(const ControlledGamepad &device, const lvh::DeviceProfile &profile) const { - std::wostringstream stream; - stream << L"Output: "; - if (device.outputs.empty()) { - stream << L"no reports received"; - } else { - auto wrote = false; - if (device.latest_rumble) { - stream << L"rumble low=" << device.latest_rumble->low_frequency_rumble << L" high=" << device.latest_rumble->high_frequency_rumble; - wrote = true; - } - if (device.latest_trigger_rumble) { - stream << (wrote ? L" | " : L"") << L"trigger rumble L=" << device.latest_trigger_rumble->left_trigger_rumble << L" R=" << device.latest_trigger_rumble->right_trigger_rumble; - wrote = true; - } - if (device.latest_rgb_led) { - stream << (wrote ? L" | " : L"") << L"RGB " << static_cast(device.latest_rgb_led->red) << L"," << static_cast(device.latest_rgb_led->green) - << L"," << static_cast(device.latest_rgb_led->blue); - wrote = true; - } - if (device.latest_adaptive_triggers) { - stream << (wrote ? L" | " : L"") << L"adaptive flags=" << static_cast(device.latest_adaptive_triggers->adaptive_trigger_flags); - wrote = true; - } - if (!wrote && device.latest_raw_report) { - stream << L"raw report"; - wrote = true; - } - if (!wrote) { - stream << L"reports received"; - } - } - - if (!lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::rumble) && !lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::rgb_led) && - !lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::adaptive_triggers) && !lvh::supports_gamepad_output(profile, lvh::GamepadOutputKind::trigger_rumble)) { - stream << L" | profile has no normalized feedback categories"; - } - return stream.str(); - } - void refresh_selected_device() { std::lock_guard lock {mutex_}; auto *device = selected_device_locked(); auto relayout_needed = false; const auto enabled = device != nullptr; - ::EnableWindow(reset_button_, enabled); - ::EnableWindow(remove_selected_button_, enabled); - ::EnableWindow(remove_all_button_, !devices_.empty()); - for (auto *slider : axis_sliders_) { + ::EnableWindow(device_controls_.reset_button, enabled); + ::EnableWindow(device_controls_.remove_selected_button, enabled); + ::EnableWindow(device_controls_.remove_all_button, !devices_.empty()); + for (auto *slider : axes_.sliders) { ::EnableWindow(slider, enabled); } if (device == nullptr) { - if (const auto profile = current_combo_profile()) { - relayout_needed = update_visible_controls_for_profile(*profile); - ::SetWindowTextW(feature_text_, profile_feature_summary(*profile).c_str()); + if (const auto profile = current_combo_profile(main_controls_.profile_combo)) { + relayout_needed = update_visible_controls_for_profile(*profile, buttons_.visible, battery_.visible); + ::SetWindowTextW(main_controls_.feature_text, profile_feature_summary(*profile).c_str()); } else { - relayout_needed = std::any_of(visible_buttons_.begin(), visible_buttons_.end(), [](bool visible) { + relayout_needed = std::ranges::any_of(buttons_.visible, [](bool visible) { return visible; }) || - battery_controls_visible_; - visible_buttons_.fill(false); - battery_controls_visible_ = false; - ::SetWindowTextW(feature_text_, L""); + battery_.visible; + buttons_.visible.fill(false); + battery_.visible = false; + ::SetWindowTextW(main_controls_.feature_text, L""); } - for (std::size_t index = 0; index < button_controls_.size(); ++index) { - ::EnableWindow(button_controls_[index], FALSE); - set_button_visual(index, false); + for (std::size_t index = 0; index < buttons_.controls.size(); ++index) { + ::EnableWindow(buttons_.controls[index], FALSE); + set_button_visual(buttons_, index, false); } - ::EnableWindow(battery_state_combo_, FALSE); - ::EnableWindow(battery_slider_, FALSE); - ::EnableWindow(clear_battery_button_, FALSE); - ::SetWindowTextW(state_text_, L"No device selected. Create a gamepad to begin testing."); - ::SetWindowTextW(output_summary_text_, L"Output: no selected device."); - ::SendMessageW(nodes_list_, LB_RESETCONTENT, 0, 0); - ::SendMessageW(output_list_, LB_RESETCONTENT, 0, 0); - reset_sliders(); + ::EnableWindow(battery_.state_combo, FALSE); + ::EnableWindow(battery_.slider, FALSE); + ::EnableWindow(battery_.clear_button, FALSE); + ::SetWindowTextW(main_controls_.state_text, L"No device selected. Create a gamepad to begin testing."); + ::SetWindowTextW(output_controls_.summary_text, L"Output: no selected device."); + ::SendMessageW(output_controls_.nodes_list, LB_RESETCONTENT, 0, 0); + ::SendMessageW(output_controls_.output_list, LB_RESETCONTENT, 0, 0); + reset_sliders(axes_); refresh_battery_controls({}, false); if (relayout_needed) { relayout_and_redraw(); @@ -1201,7 +1344,7 @@ namespace { const auto *gamepad = device->adapter->gamepad(); const auto &profile = gamepad->profile(); const auto state = device->adapter->state(); - relayout_needed = update_visible_controls_for_profile(profile); + relayout_needed = update_visible_controls_for_profile(profile, buttons_.visible, battery_.visible); std::wostringstream state_text; state_text << device->profile_label << L" #" << gamepad->device_id() << L"\r\n" @@ -1216,93 +1359,44 @@ namespace { } state_text << gamepad->submit_count() << L" submits"; - ::SetWindowTextW(state_text_, state_text.str().c_str()); - ::SetWindowTextW(feature_text_, profile_feature_summary(profile).c_str()); - ::SetWindowTextW(output_summary_text_, output_summary(*device, profile).c_str()); + ::SetWindowTextW(main_controls_.state_text, state_text.str().c_str()); + ::SetWindowTextW(main_controls_.feature_text, profile_feature_summary(profile).c_str()); + ::SetWindowTextW(output_controls_.summary_text, output_summary(*device, profile).c_str()); for (std::size_t index = 0; index < button_choices.size(); ++index) { - ::EnableWindow(button_controls_[index], visible_buttons_[index]); - set_button_visual(index, state.buttons.test(button_choices[index].button)); + ::EnableWindow(buttons_.controls[index], buttons_.visible[index]); + set_button_visual(buttons_, index, state.buttons.test(button_choices[index].button)); } - ::SendMessageW(axis_sliders_[0], TBM_SETPOS, TRUE, axis_to_slider(state.left_stick.x)); - ::SendMessageW(axis_sliders_[1], TBM_SETPOS, TRUE, axis_to_slider(state.left_stick.y)); - ::SendMessageW(axis_sliders_[2], TBM_SETPOS, TRUE, axis_to_slider(state.right_stick.x)); - ::SendMessageW(axis_sliders_[3], TBM_SETPOS, TRUE, axis_to_slider(state.right_stick.y)); - ::SendMessageW(axis_sliders_[4], TBM_SETPOS, TRUE, trigger_to_slider(state.left_trigger)); - ::SendMessageW(axis_sliders_[5], TBM_SETPOS, TRUE, trigger_to_slider(state.right_trigger)); + ::SendMessageW(axes_.sliders[0], TBM_SETPOS, TRUE, axis_to_slider(state.left_stick.x)); + ::SendMessageW(axes_.sliders[1], TBM_SETPOS, TRUE, axis_to_slider(state.left_stick.y)); + ::SendMessageW(axes_.sliders[2], TBM_SETPOS, TRUE, axis_to_slider(state.right_stick.x)); + ::SendMessageW(axes_.sliders[3], TBM_SETPOS, TRUE, axis_to_slider(state.right_stick.y)); + ::SendMessageW(axes_.sliders[4], TBM_SETPOS, TRUE, trigger_to_slider(state.left_trigger)); + ::SendMessageW(axes_.sliders[5], TBM_SETPOS, TRUE, trigger_to_slider(state.right_trigger)); refresh_battery_controls(state, device->adapter->support().supports_battery); - refresh_nodes(*gamepad); - refresh_outputs(*device); + refresh_nodes(output_controls_.nodes_list, *gamepad); + refresh_outputs(output_controls_.output_list, *device); if (relayout_needed) { relayout_and_redraw(); } } - void reset_sliders() { - for (std::size_t index = 0; index < axis_sliders_.size(); ++index) { - ::SendMessageW(axis_sliders_[index], TBM_SETPOS, TRUE, 0); - } - } - - void set_button_visual(std::size_t index, bool pressed) { - if (index >= button_controls_.size()) { - return; - } - ::SendMessageW(button_controls_[index], BM_SETSTATE, pressed ? TRUE : FALSE, 0); - } - void refresh_battery_controls(const lvh::GamepadState &state, bool supported) { const auto enabled = selected_device_locked() != nullptr && supported; - ::EnableWindow(battery_state_combo_, enabled); - ::EnableWindow(battery_slider_, enabled); - ::EnableWindow(clear_battery_button_, enabled && state.battery.has_value()); + ::EnableWindow(battery_.state_combo, enabled); + ::EnableWindow(battery_.slider, enabled); + ::EnableWindow(battery_.clear_button, enabled && state.battery.has_value()); if (state.battery) { - ::SendMessageW(battery_state_combo_, CB_SETCURSEL, battery_choice_index(state.battery->state), 0); - ::SendMessageW(battery_slider_, TBM_SETPOS, TRUE, state.battery->percentage); - return; - } - - ::SendMessageW(battery_state_combo_, CB_SETCURSEL, battery_choice_index(lvh::GamepadBatteryState::full), 0); - ::SendMessageW(battery_slider_, TBM_SETPOS, TRUE, 100); - } - - void refresh_nodes(const lvh::Gamepad &gamepad) { - ::SendMessageW(nodes_list_, LB_RESETCONTENT, 0, 0); - const auto nodes = gamepad.device_nodes(); - if (nodes.empty()) { - ::SendMessageW(nodes_list_, LB_ADDSTRING, 0, reinterpret_cast(L"No device nodes reported yet.")); + ::SendMessageW(battery_.state_combo, CB_SETCURSEL, battery_choice_index(state.battery->state), 0); + ::SendMessageW(battery_.slider, TBM_SETPOS, TRUE, state.battery->percentage); return; } - for (const auto &node : nodes) { - const auto line = node_kind_name(node.kind) + L": " + widen(node.path); - ::SendMessageW(nodes_list_, LB_ADDSTRING, 0, reinterpret_cast(line.c_str())); - } - } - - void refresh_outputs(const ControlledGamepad &device) { - ::SendMessageW(output_list_, LB_RESETCONTENT, 0, 0); - if (device.outputs.empty()) { - ::SendMessageW(output_list_, LB_ADDSTRING, 0, reinterpret_cast(L"No output reports received.")); - return; - } - - for (const auto &entry : device.outputs) { - const auto &output = entry.output; - std::wostringstream line; - line << L"#" << entry.sequence << L" " << output_kind_name(output.kind) - << L" low=" << output.low_frequency_rumble - << L" high=" << output.high_frequency_rumble - << L" rgb=" << static_cast(output.red) << L"," << static_cast(output.green) << L"," - << static_cast(output.blue); - if (!output.raw_report.empty()) { - line << L" raw=" << raw_hex(output.raw_report); - } - ::SendMessageW(output_list_, LB_ADDSTRING, 0, reinterpret_cast(line.str().c_str())); - } + ::SendMessageW(battery_.state_combo, CB_SETCURSEL, battery_choice_index(lvh::GamepadBatteryState::full), 0); + ::SendMessageW(battery_.slider, TBM_SETPOS, TRUE, 100); } ControlledGamepad *selected_device_locked() { @@ -1375,9 +1469,9 @@ namespace { return adapters; } - void close_adapters(std::vector> &adapters, bool report_errors) { + void close_adapters(const std::vector> &adapters, bool report_errors) const { std::optional first_error; - for (auto &adapter : adapters) { + for (const auto &adapter : adapters) { if (adapter) { if (const auto status = adapter->close(); !status.ok() && report_errors && !first_error) { first_error = widen(status.message()); @@ -1396,38 +1490,18 @@ namespace { HINSTANCE instance_ = nullptr; HWND window_ = nullptr; - HWND backend_text_ = nullptr; - HWND profile_combo_ = nullptr; - HWND create_button_ = nullptr; - HWND device_list_ = nullptr; - HWND reset_button_ = nullptr; - HWND remove_selected_button_ = nullptr; - HWND remove_all_button_ = nullptr; - HWND lock_buttons_check_ = nullptr; - HWND state_text_ = nullptr; - HWND feature_text_ = nullptr; - HWND battery_label_ = nullptr; - HWND battery_state_combo_ = nullptr; - HWND battery_slider_ = nullptr; - HWND clear_battery_button_ = nullptr; - HWND output_summary_text_ = nullptr; - HWND nodes_label_ = nullptr; - HWND output_label_ = nullptr; - HWND nodes_list_ = nullptr; - HWND output_list_ = nullptr; - std::array button_controls_ {}; - std::array axis_labels_ {}; - std::array axis_sliders_ {}; - std::array visible_buttons_ {}; - std::array momentary_pointer_pressed_ {}; - std::array momentary_key_pressed_ {}; + MainControls main_controls_; + DeviceListControls device_controls_; + ButtonControls buttons_; + AxisControls axes_; + BatteryControls battery_; + OutputControls output_controls_; std::unique_ptr runtime_; std::mutex mutex_; std::map devices_; lvh::DeviceId selected_id_ = 0; std::uint64_t next_metadata_index_ = 0; std::uint64_t next_output_sequence_ = 1; - bool battery_controls_visible_ = false; static constexpr std::size_t max_output_events_ = 50; }; diff --git a/tools/windows/resource.h b/tools/windows/resource.h deleted file mode 100644 index 4b1417a..0000000 --- a/tools/windows/resource.h +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -#define IDI_VIRTUALHID 101 diff --git a/tools/windows/virtualhid_control.rc b/tools/windows/virtualhid_control.rc index 711d534..7346a4a 100644 --- a/tools/windows/virtualhid_control.rc +++ b/tools/windows/virtualhid_control.rc @@ -1,3 +1 @@ -#include "resource.h" - -IDI_VIRTUALHID ICON "libvirtualhid.ico" +101 ICON "libvirtualhid.ico" From 2bd54ff9f0cce5405390df03f940b86b6cf2edcc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:13:13 -0400 Subject: [PATCH 09/12] Unify CI example run and expand coverage Add a dedicated `run_gamepad_adapter_example` CMake target and use it in CI so the gamepad adapter runs from the correct generator-specific output path on all platforms. CI now also enables `LIBVIRTUALHID_BUILD_TOOLS`, and coverage collection was broadened to include `examples/`, `src/`, and `tools/` while excluding `tests/` and `third-party/` noise. Development docs were updated to document the new run target. --- .github/workflows/ci.yml | 30 +++++++++++++----------------- docs/development.md | 2 ++ examples/CMakeLists.txt | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb95e0a..df89196 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,7 @@ jobs: -DBUILD_EXAMPLES=ON \ -DBUILD_TESTS=ON \ -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_CONFIG} \ + -DLIBVIRTUALHID_BUILD_TOOLS=ON \ -B cmake-build-ci \ -G Ninja \ -S . @@ -226,6 +227,7 @@ jobs: -DBUILD_DOCS=OFF ` -DBUILD_EXAMPLES=ON ` -DBUILD_TESTS=ON ` + -DLIBVIRTUALHID_BUILD_TOOLS=ON ` -A x64 ` -B cmake-build-ci ` -G "Visual Studio 17 2022" ` @@ -339,7 +341,9 @@ jobs: } & $openCppCoverage ` + --sources "$env:GITHUB_WORKSPACE\examples" ` --sources "$env:GITHUB_WORKSPACE\src" ` + --sources "$env:GITHUB_WORKSPACE\tools" ` "--export_type=cobertura:$env:GITHUB_WORKSPACE\cmake-build-ci\reports\coverage.xml" ` --working_dir "$env:GITHUB_WORKSPACE\cmake-build-ci\tests" ` -- ` @@ -375,6 +379,9 @@ jobs: $coverage.Save($coveragePath) + - name: Run gamepad adapter example + run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --target run_gamepad_adapter_example + - name: Generate gcov report id: test_report if: >- @@ -386,8 +393,13 @@ jobs: GCOV_EXECUTABLE: ${{ matrix.gcov_executable }} MSYS2_PATH_TYPE: inherit run: | - uv run --project ../third-party/lizardbyte-common --locked --no-sync gcovr . -r ../src \ + uv run --project ../third-party/lizardbyte-common --locked --no-sync gcovr . -r .. \ + --filter ../examples/ \ + --filter ../src/ \ + --filter ../tools/ \ --gcov-executable "${GCOV_EXECUTABLE}" \ + --exclude ../tests/ \ + --exclude ../third-party/ \ --exclude-noncode-lines \ --exclude-throw-branches \ --exclude-unreachable-branches \ @@ -395,22 +407,6 @@ jobs: --xml-pretty \ -o reports/coverage.xml - - name: Run gamepad adapter example - if: runner.os == 'Linux' - run: | - ./cmake-build-ci/examples/gamepad_adapter - - - name: Run gamepad adapter example Windows - if: runner.os == 'Windows' - shell: pwsh - run: | - if ("${{ matrix.kind }}" -eq "msys2") { - $env:PATH = "C:\msys64\${{ matrix.msystem }}\bin;C:\msys64\usr\bin;$env:PATH" - & .\cmake-build-ci\examples\gamepad_adapter.exe - } else { - & ".\cmake-build-ci\examples\$env:CMAKE_BUILD_CONFIG\gamepad_adapter.exe" - } - - name: Uninstall Windows driver installer if: >- always() && diff --git a/docs/development.md b/docs/development.md index 4fb3a65..72e5034 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,6 +66,8 @@ code and tests provide a better source of truth. - `test_libvirtualhid`: shared GoogleTest binary under the build directory's `tests` directory. - `gamepad_adapter`: example executable for profile and lifecycle diagnostics. +- `run_gamepad_adapter_example`: CMake target that runs `gamepad_adapter` from + its generator-specific output path when a platform backend is available. - Windows driver helper scripts under `scripts/windows`. - Linux consumer tests through SDL2, libinput, `uhid`, and `uinput` where the host environment supports real virtual devices. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index a17e05b..23a2986 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -19,3 +19,20 @@ endif() libvirtualhid_copy_mingw_runtime(gamepad_adapter) libvirtualhid_copy_mingw_runtime(keyboard_mouse_adapter) + +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" OR WIN32) + set(RUN_GAMEPAD_EXAMPLE_COMMAND "$") + set(RUN_GAMEPAD_EXAMPLE_COMMENT "Running gamepad_adapter example") +else() + set(RUN_GAMEPAD_EXAMPLE_COMMAND + "${CMAKE_COMMAND}" -E echo + "Skipping gamepad_adapter example: no ${CMAKE_SYSTEM_NAME} backend") + set(RUN_GAMEPAD_EXAMPLE_COMMENT "Skipping gamepad_adapter example") +endif() + +add_custom_target(run_gamepad_adapter_example + COMMAND ${RUN_GAMEPAD_EXAMPLE_COMMAND} + COMMENT "${RUN_GAMEPAD_EXAMPLE_COMMENT}" + DEPENDS gamepad_adapter + VERBATIM + USES_TERMINAL) From 1cb5db849fb70d208a75521f8a13530187dff7f2 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:33:38 -0400 Subject: [PATCH 10/12] Additional sonar fixes --- tools/virtualhid_control.cpp | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index 12ac2e8..f16c00f 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -72,23 +72,23 @@ namespace { axis_base = 3000, }; - constexpr auto profile_combo_id = static_cast(ControlId::profile_combo); - constexpr auto create_button_id = static_cast(ControlId::create_button); - constexpr auto device_list_id = static_cast(ControlId::device_list); - constexpr auto reset_button_id = static_cast(ControlId::reset_button); - constexpr auto remove_selected_button_id = static_cast(ControlId::remove_selected_button); - constexpr auto remove_all_button_id = static_cast(ControlId::remove_all_button); - constexpr auto lock_buttons_check_id = static_cast(ControlId::lock_buttons_check); - constexpr auto state_text_id = static_cast(ControlId::state_text); - constexpr auto feature_text_id = static_cast(ControlId::feature_text); - constexpr auto battery_state_combo_id = static_cast(ControlId::battery_state_combo); - constexpr auto battery_slider_id = static_cast(ControlId::battery_slider); - constexpr auto clear_battery_button_id = static_cast(ControlId::clear_battery_button); - constexpr auto output_summary_text_id = static_cast(ControlId::output_summary_text); - constexpr auto node_list_id = static_cast(ControlId::node_list); - constexpr auto output_list_id = static_cast(ControlId::output_list); - constexpr auto button_base_id = static_cast(ControlId::button_base); - constexpr auto axis_base_id = static_cast(ControlId::axis_base); + constexpr auto profile_combo_id = std::to_underlying(ControlId::profile_combo); + constexpr auto create_button_id = std::to_underlying(ControlId::create_button); + constexpr auto device_list_id = std::to_underlying(ControlId::device_list); + constexpr auto reset_button_id = std::to_underlying(ControlId::reset_button); + constexpr auto remove_selected_button_id = std::to_underlying(ControlId::remove_selected_button); + constexpr auto remove_all_button_id = std::to_underlying(ControlId::remove_all_button); + constexpr auto lock_buttons_check_id = std::to_underlying(ControlId::lock_buttons_check); + constexpr auto state_text_id = std::to_underlying(ControlId::state_text); + constexpr auto feature_text_id = std::to_underlying(ControlId::feature_text); + constexpr auto battery_state_combo_id = std::to_underlying(ControlId::battery_state_combo); + constexpr auto battery_slider_id = std::to_underlying(ControlId::battery_slider); + constexpr auto clear_battery_button_id = std::to_underlying(ControlId::clear_battery_button); + constexpr auto output_summary_text_id = std::to_underlying(ControlId::output_summary_text); + constexpr auto node_list_id = std::to_underlying(ControlId::node_list); + constexpr auto output_list_id = std::to_underlying(ControlId::output_list); + constexpr auto button_base_id = std::to_underlying(ControlId::button_base); + constexpr auto axis_base_id = std::to_underlying(ControlId::axis_base); struct ProfileChoice { std::wstring_view id; From 3838275afa59eea40b823e6cb59f077a29192ce8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:06:59 -0400 Subject: [PATCH 11/12] Test coverage --- tests/CMakeLists.txt | 11 + tests/unit/test_virtualhid_control_model.cpp | 243 +++++++++++++ tools/CMakeLists.txt | 15 +- tools/virtualhid_control.cpp | 361 ++----------------- tools/virtualhid_control_model.cpp | 281 +++++++++++++++ tools/virtualhid_control_model.hpp | 142 ++++++++ 6 files changed, 711 insertions(+), 342 deletions(-) create mode 100644 tests/unit/test_virtualhid_control_model.cpp create mode 100644 tools/virtualhid_control_model.cpp create mode 100644 tools/virtualhid_control_model.hpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4802151..eb642bf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,6 +31,11 @@ set(LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_windows_protocol.cpp") +if(TARGET virtualhid_control_model) + list(APPEND LIBVIRTUALHID_TEST_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_virtualhid_control_model.cpp") +endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") find_package(PkgConfig REQUIRED) pkg_check_modules(LIBEVDEV REQUIRED IMPORTED_TARGET libevdev) @@ -64,6 +69,12 @@ target_link_libraries(${TEST_BINARY} libvirtualhid::libvirtualhid lizardbyte::test_support) +if(TARGET virtualhid_control_model) + target_link_libraries(${TEST_BINARY} + PRIVATE + virtualhid_control_model) +endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") target_link_libraries(${TEST_BINARY} PRIVATE diff --git a/tests/unit/test_virtualhid_control_model.cpp b/tests/unit/test_virtualhid_control_model.cpp new file mode 100644 index 0000000..26b2390 --- /dev/null +++ b/tests/unit/test_virtualhid_control_model.cpp @@ -0,0 +1,243 @@ +/** + * @file tests/unit/test_virtualhid_control_model.cpp + * @brief Unit tests for virtualhid_control model helpers. + */ + +// standard includes +#include +#include +#include +#include + +// third-party includes +#include + +// local includes +#include + +namespace { + namespace control = lvh::tools::virtualhid_control; + + bool visible_button( + const std::array &visible_buttons, + lvh::GamepadButton button + ) { + for (std::size_t index = 0; index < control::button_choices.size(); ++index) { + if (control::button_choices[index].button == button) { + return visible_buttons[index]; + } + } + return false; + } + + lvh::GamepadOutput output(lvh::GamepadOutputKind kind) { + lvh::GamepadOutput value; + value.kind = kind; + return value; + } +} // namespace + +TEST(VirtualHidControlModelTest, NamesKnownAndFallbackEnumValues) { + EXPECT_EQ(control::device_type_name(lvh::DeviceType::gamepad), L"gamepad"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::keyboard), L"keyboard"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::mouse), L"mouse"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::touchscreen), L"touchscreen"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::trackpad), L"trackpad"); + EXPECT_EQ(control::device_type_name(lvh::DeviceType::pen_tablet), L"pen tablet"); + EXPECT_EQ(control::device_type_name(static_cast(255)), L"unknown"); + + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::input_event), L"input"); + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::joystick), L"joystick"); + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::hidraw), L"hidraw"); + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::sysfs), L"sysfs"); + EXPECT_EQ(control::node_kind_name(lvh::DeviceNodeKind::other), L"other"); + EXPECT_EQ(control::node_kind_name(static_cast(255)), L"other"); + + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::rumble), L"rumble"); + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::rgb_led), L"rgb led"); + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::adaptive_triggers), L"adaptive triggers"); + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::raw_report), L"raw report"); + EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::trigger_rumble), L"trigger rumble"); + EXPECT_EQ(control::output_kind_name(static_cast(255)), L"raw report"); + + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::unknown), L"unknown"); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::discharging), L"discharging"); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::charging), L"charging"); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::full), L"full"); + EXPECT_EQ( + control::battery_state_name(lvh::GamepadBatteryState::voltage_or_temperature_error), + L"voltage/temperature error" + ); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::temperature_error), L"temperature error"); + EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::charging_error), L"charging error"); + EXPECT_EQ(control::battery_state_name(static_cast(255)), L"unknown"); + + EXPECT_EQ(control::yes_no(true), L"yes"); + EXPECT_EQ(control::yes_no(false), L"no"); +} + +TEST(VirtualHidControlModelTest, MapsProfileChoicesToProfiles) { + for (const auto &choice : control::profile_choices) { + const auto profile = control::profile_for_choice(choice); + ASSERT_TRUE(profile.has_value()) << std::string(choice.id.begin(), choice.id.end()); + EXPECT_EQ(profile->device_type, lvh::DeviceType::gamepad); + EXPECT_EQ(profile->gamepad_kind, choice.kind); + EXPECT_FALSE(profile->name.empty()); + } + + const control::ProfileChoice invalid { + L"invalid", + L"Invalid", + static_cast(255), + lvh::ClientControllerType::unknown, + }; + EXPECT_FALSE(control::profile_for_choice(invalid).has_value()); +} + +TEST(VirtualHidControlModelTest, ConvertsSliderValues) { + EXPECT_EQ(control::axis_to_slider(-2.0F), -control::slider_scale); + EXPECT_EQ(control::axis_to_slider(-0.5F), -50); + EXPECT_EQ(control::axis_to_slider(0.0F), 0); + EXPECT_EQ(control::axis_to_slider(0.5F), 50); + EXPECT_EQ(control::axis_to_slider(2.0F), control::slider_scale); + + EXPECT_EQ(control::trigger_to_slider(-1.0F), 0); + EXPECT_EQ(control::trigger_to_slider(0.25F), 25); + EXPECT_EQ(control::trigger_to_slider(1.0F), control::slider_scale); + EXPECT_EQ(control::trigger_to_slider(2.0F), control::slider_scale); + + EXPECT_FLOAT_EQ(control::slider_to_float(-25), -0.25F); + EXPECT_FLOAT_EQ(control::slider_to_float(0), 0.0F); + EXPECT_FLOAT_EQ(control::slider_to_float(75), 0.75F); +} + +TEST(VirtualHidControlModelTest, MapsBatteryComboChoices) { + for (std::size_t index = 0; index < control::battery_choices.size(); ++index) { + EXPECT_EQ(control::battery_choice_index(control::battery_choices[index].state), static_cast(index)); + } + EXPECT_EQ(control::battery_choice_index(static_cast(255)), 0); +} + +TEST(VirtualHidControlModelTest, FormatsRawHex) { + EXPECT_EQ(control::raw_hex({}), L""); + EXPECT_EQ(control::raw_hex({0x00, 0x0F, 0xA5, 0xFF}), L"000fa5ff"); +} + +TEST(VirtualHidControlModelTest, SummarizesProfileFeatures) { + const auto generic = lvh::profiles::generic_gamepad(); + const auto dualsense = lvh::profiles::dualsense(); + + EXPECT_FALSE(control::supports_normalized_feedback(generic)); + EXPECT_TRUE(control::supports_normalized_feedback(dualsense)); + + EXPECT_EQ( + control::profile_feature_summary(generic), + L"Features: battery no | rumble no | trigger rumble no | RGB LED no | adaptive triggers no | raw output no" + ); + EXPECT_EQ( + control::profile_feature_summary(dualsense), + L"Features: battery yes | rumble yes | trigger rumble no | RGB LED yes | adaptive triggers yes | raw output yes" + ); +} + +TEST(VirtualHidControlModelTest, UpdatesVisibleControlsForProfiles) { + std::array visible_buttons {}; + auto battery_visible = false; + + const auto dualshock4 = lvh::profiles::dualshock4(); + EXPECT_TRUE(control::update_visible_controls_for_profile(dualshock4, visible_buttons, battery_visible)); + EXPECT_TRUE(visible_button(visible_buttons, lvh::GamepadButton::a)); + EXPECT_TRUE(visible_button(visible_buttons, lvh::GamepadButton::touchpad)); + EXPECT_FALSE(visible_button(visible_buttons, lvh::GamepadButton::misc1)); + EXPECT_FALSE(visible_button(visible_buttons, lvh::GamepadButton::paddle1)); + EXPECT_TRUE(battery_visible); + + EXPECT_FALSE(control::update_visible_controls_for_profile(dualshock4, visible_buttons, battery_visible)); + + const auto generic = lvh::profiles::generic_gamepad(); + EXPECT_TRUE(control::update_visible_controls_for_profile(generic, visible_buttons, battery_visible)); + EXPECT_TRUE(visible_button(visible_buttons, lvh::GamepadButton::misc1)); + EXPECT_FALSE(visible_button(visible_buttons, lvh::GamepadButton::touchpad)); + EXPECT_FALSE(battery_visible); +} + +TEST(VirtualHidControlModelTest, SummarizesOutputState) { + const auto generic = lvh::profiles::generic_gamepad(); + const auto dualsense = lvh::profiles::dualsense(); + + control::OutputState state; + EXPECT_EQ( + control::output_summary(state, generic), + L"Output: no reports received | profile has no normalized feedback categories" + ); + EXPECT_EQ(control::output_summary(state, dualsense), L"Output: no reports received"); + + state.outputs.push_back({.sequence = 1, .output = output(lvh::GamepadOutputKind::raw_report)}); + EXPECT_EQ(control::output_summary(state, dualsense), L"Output: reports received"); + + state.latest_raw_report = output(lvh::GamepadOutputKind::raw_report); + EXPECT_EQ(control::output_summary(state, dualsense), L"Output: raw report"); + + state.latest_rumble = output(lvh::GamepadOutputKind::rumble); + state.latest_rumble->low_frequency_rumble = 10; + state.latest_rumble->high_frequency_rumble = 20; + state.latest_trigger_rumble = output(lvh::GamepadOutputKind::trigger_rumble); + state.latest_trigger_rumble->left_trigger_rumble = 30; + state.latest_trigger_rumble->right_trigger_rumble = 40; + state.latest_rgb_led = output(lvh::GamepadOutputKind::rgb_led); + state.latest_rgb_led->red = 1; + state.latest_rgb_led->green = 2; + state.latest_rgb_led->blue = 3; + state.latest_adaptive_triggers = output(lvh::GamepadOutputKind::adaptive_triggers); + state.latest_adaptive_triggers->adaptive_trigger_flags = 4; + + EXPECT_EQ( + control::output_summary(state, dualsense), + L"Output: rumble low=10 high=20 | trigger rumble L=30 R=40 | RGB 1,2,3 | adaptive flags=4" + ); +} + +TEST(VirtualHidControlModelTest, RecordsOutputsAndMaintainsLatestSummaryFields) { + control::OutputState state; + auto next_sequence = std::uint64_t {7}; + + auto rumble = output(lvh::GamepadOutputKind::rumble); + rumble.low_frequency_rumble = 100; + rumble.high_frequency_rumble = 200; + control::record_output(state, rumble, next_sequence, 3); + + auto trigger_rumble = output(lvh::GamepadOutputKind::trigger_rumble); + trigger_rumble.left_trigger_rumble = 300; + trigger_rumble.right_trigger_rumble = 400; + control::record_output(state, trigger_rumble, next_sequence, 3); + + auto rgb = output(lvh::GamepadOutputKind::rgb_led); + rgb.red = 5; + rgb.green = 6; + rgb.blue = 7; + control::record_output(state, rgb, next_sequence, 3); + + auto adaptive = output(lvh::GamepadOutputKind::adaptive_triggers); + adaptive.adaptive_trigger_flags = 8; + control::record_output(state, adaptive, next_sequence, 3); + + auto raw = output(lvh::GamepadOutputKind::raw_report); + raw.raw_report = {0x12, 0x34}; + control::record_output(state, raw, next_sequence, 3); + + ASSERT_EQ(state.outputs.size(), 3U); + EXPECT_EQ(state.outputs.front().sequence, 9U); + EXPECT_EQ(state.outputs.back().sequence, 11U); + EXPECT_EQ(next_sequence, 12U); + + ASSERT_TRUE(state.latest_rumble.has_value()); + EXPECT_EQ(state.latest_rumble->low_frequency_rumble, 100); + ASSERT_TRUE(state.latest_trigger_rumble.has_value()); + EXPECT_EQ(state.latest_trigger_rumble->right_trigger_rumble, 400); + ASSERT_TRUE(state.latest_rgb_led.has_value()); + EXPECT_EQ(state.latest_rgb_led->blue, 7); + ASSERT_TRUE(state.latest_adaptive_triggers.has_value()); + EXPECT_EQ(state.latest_adaptive_triggers->adaptive_trigger_flags, 8); + ASSERT_TRUE(state.latest_raw_report.has_value()); + EXPECT_EQ(state.latest_raw_report->raw_report, (std::vector {0x12, 0x34})); +} diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index b0ac1ff..38bbda3 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -1,9 +1,20 @@ +add_library(virtualhid_control_model STATIC + "${CMAKE_CURRENT_SOURCE_DIR}/virtualhid_control_model.cpp") + +target_include_directories(virtualhid_control_model + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}") + +target_link_libraries(virtualhid_control_model + PUBLIC + libvirtualhid::libvirtualhid) + add_executable(virtualhid_control "${CMAKE_CURRENT_SOURCE_DIR}/virtualhid_control.cpp") target_link_libraries(virtualhid_control PRIVATE - libvirtualhid::libvirtualhid) + virtualhid_control_model) if(WIN32) set_target_properties(virtualhid_control PROPERTIES @@ -28,7 +39,7 @@ if(WIN32) endif() if(MSVC AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME AND LIBVIRTUALHID_BUILD_WINDOWS_DRIVER) - set_property(TARGET virtualhid_control PROPERTY + set_property(TARGET virtualhid_control virtualhid_control_model PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") endif() diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index f16c00f..924d088 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -6,9 +6,7 @@ // standard includes #include #include -#include #include -#include #include #include #include @@ -20,7 +18,7 @@ #include // local includes -#include +#include "virtualhid_control_model.hpp" #if defined(_WIN32) @@ -46,11 +44,28 @@ // clang-format on namespace { + using lvh::tools::virtualhid_control::axis_choices; + using lvh::tools::virtualhid_control::axis_to_slider; + using lvh::tools::virtualhid_control::battery_choice_index; + using lvh::tools::virtualhid_control::battery_choices; + using lvh::tools::virtualhid_control::battery_state_name; + using lvh::tools::virtualhid_control::button_choices; + using lvh::tools::virtualhid_control::device_type_name; + using lvh::tools::virtualhid_control::node_kind_name; + using lvh::tools::virtualhid_control::output_kind_name; + using lvh::tools::virtualhid_control::output_summary; + using lvh::tools::virtualhid_control::OutputState; + using lvh::tools::virtualhid_control::profile_choices; + using lvh::tools::virtualhid_control::profile_feature_summary; + using lvh::tools::virtualhid_control::profile_for_choice; + using lvh::tools::virtualhid_control::raw_hex; + using lvh::tools::virtualhid_control::slider_to_float; + using lvh::tools::virtualhid_control::trigger_to_slider; + using lvh::tools::virtualhid_control::update_visible_controls_for_profile; constexpr auto output_changed_message = WM_APP + 1U; constexpr auto button_subclass_id = 1U; constexpr auto icon_resource_id = 101; - constexpr auto slider_scale = 100; enum class ControlId : int { profile_combo = 1000, @@ -90,96 +105,9 @@ namespace { constexpr auto button_base_id = std::to_underlying(ControlId::button_base); constexpr auto axis_base_id = std::to_underlying(ControlId::axis_base); - struct ProfileChoice { - std::wstring_view id; - std::wstring_view label; - lvh::GamepadProfileKind kind; - lvh::ClientControllerType client_type; - }; - - struct ButtonChoice { - std::wstring_view label; - lvh::GamepadButton button; - }; - - struct AxisChoice { - std::wstring_view label; - int minimum; - int maximum; - }; - - struct BatteryChoice { - std::wstring_view label; - lvh::GamepadBatteryState state; - }; - - constexpr std::array profile_choices { - ProfileChoice {L"generic", L"Generic HID", lvh::GamepadProfileKind::generic, lvh::ClientControllerType::unknown}, - ProfileChoice {L"x360", L"Xbox 360", lvh::GamepadProfileKind::xbox_360, lvh::ClientControllerType::xbox}, - ProfileChoice {L"xone", L"Xbox One", lvh::GamepadProfileKind::xbox_one, lvh::ClientControllerType::xbox}, - ProfileChoice {L"xseries", L"Xbox Series", lvh::GamepadProfileKind::xbox_series, lvh::ClientControllerType::xbox}, - ProfileChoice {L"ds4", L"DualShock 4", lvh::GamepadProfileKind::dualshock4, lvh::ClientControllerType::playstation}, - ProfileChoice {L"ds5", L"DualSense", lvh::GamepadProfileKind::dualsense, lvh::ClientControllerType::playstation}, - ProfileChoice {L"switch", L"Switch Pro", lvh::GamepadProfileKind::switch_pro, lvh::ClientControllerType::nintendo}, - }; - - constexpr std::array button_choices { - ButtonChoice {L"A", lvh::GamepadButton::a}, - ButtonChoice {L"B", lvh::GamepadButton::b}, - ButtonChoice {L"X", lvh::GamepadButton::x}, - ButtonChoice {L"Y", lvh::GamepadButton::y}, - ButtonChoice {L"Back", lvh::GamepadButton::back}, - ButtonChoice {L"Start", lvh::GamepadButton::start}, - ButtonChoice {L"Guide", lvh::GamepadButton::guide}, - ButtonChoice {L"L3", lvh::GamepadButton::left_stick}, - ButtonChoice {L"R3", lvh::GamepadButton::right_stick}, - ButtonChoice {L"LB", lvh::GamepadButton::left_shoulder}, - ButtonChoice {L"RB", lvh::GamepadButton::right_shoulder}, - ButtonChoice {L"D-pad Up", lvh::GamepadButton::dpad_up}, - ButtonChoice {L"D-pad Down", lvh::GamepadButton::dpad_down}, - ButtonChoice {L"D-pad Left", lvh::GamepadButton::dpad_left}, - ButtonChoice {L"D-pad Right", lvh::GamepadButton::dpad_right}, - ButtonChoice {L"Misc", lvh::GamepadButton::misc1}, - ButtonChoice {L"Touchpad", lvh::GamepadButton::touchpad}, - ButtonChoice {L"Paddle 1", lvh::GamepadButton::paddle1}, - ButtonChoice {L"Paddle 2", lvh::GamepadButton::paddle2}, - ButtonChoice {L"Paddle 3", lvh::GamepadButton::paddle3}, - ButtonChoice {L"Paddle 4", lvh::GamepadButton::paddle4}, - }; - - constexpr std::array axis_choices { - AxisChoice {L"Left X", -slider_scale, slider_scale}, - AxisChoice {L"Left Y", -slider_scale, slider_scale}, - AxisChoice {L"Right X", -slider_scale, slider_scale}, - AxisChoice {L"Right Y", -slider_scale, slider_scale}, - AxisChoice {L"Left Trigger", 0, slider_scale}, - AxisChoice {L"Right Trigger", 0, slider_scale}, - }; - - constexpr std::array battery_choices { - BatteryChoice {L"Unknown", lvh::GamepadBatteryState::unknown}, - BatteryChoice {L"Discharging", lvh::GamepadBatteryState::discharging}, - BatteryChoice {L"Charging", lvh::GamepadBatteryState::charging}, - BatteryChoice {L"Full", lvh::GamepadBatteryState::full}, - BatteryChoice {L"Voltage/temperature error", lvh::GamepadBatteryState::voltage_or_temperature_error}, - BatteryChoice {L"Temperature error", lvh::GamepadBatteryState::temperature_error}, - BatteryChoice {L"Charging error", lvh::GamepadBatteryState::charging_error}, - }; - - struct OutputLogEntry { - std::uint64_t sequence = 0; - lvh::GamepadOutput output; - }; - - struct ControlledGamepad { + struct ControlledGamepad: OutputState { std::wstring profile_label; std::unique_ptr adapter; - std::vector outputs; - std::optional latest_rumble; - std::optional latest_trigger_rumble; - std::optional latest_rgb_led; - std::optional latest_adaptive_triggers; - std::optional latest_raw_report; }; struct MainControls { @@ -264,213 +192,6 @@ namespace { return result; } - std::wstring device_type_name(lvh::DeviceType type) { - switch (type) { - using enum lvh::DeviceType; - - case gamepad: - return L"gamepad"; - case keyboard: - return L"keyboard"; - case mouse: - return L"mouse"; - case touchscreen: - return L"touchscreen"; - case trackpad: - return L"trackpad"; - case pen_tablet: - return L"pen tablet"; - } - return L"unknown"; - } - - std::wstring node_kind_name(lvh::DeviceNodeKind kind) { - switch (kind) { - using enum lvh::DeviceNodeKind; - - case input_event: - return L"input"; - case joystick: - return L"joystick"; - case hidraw: - return L"hidraw"; - case sysfs: - return L"sysfs"; - case other: - return L"other"; - } - return L"other"; - } - - std::wstring output_kind_name(lvh::GamepadOutputKind kind) { - switch (kind) { - using enum lvh::GamepadOutputKind; - - case rumble: - return L"rumble"; - case rgb_led: - return L"rgb led"; - case adaptive_triggers: - return L"adaptive triggers"; - case raw_report: - return L"raw report"; - case trigger_rumble: - return L"trigger rumble"; - } - return L"raw report"; - } - - std::wstring battery_state_name(lvh::GamepadBatteryState state) { - switch (state) { - using enum lvh::GamepadBatteryState; - - case unknown: - return L"unknown"; - case discharging: - return L"discharging"; - case charging: - return L"charging"; - case full: - return L"full"; - case voltage_or_temperature_error: - return L"voltage/temperature error"; - case temperature_error: - return L"temperature error"; - case charging_error: - return L"charging error"; - } - return L"unknown"; - } - - int battery_choice_index(lvh::GamepadBatteryState state) { - for (std::size_t index = 0; index < battery_choices.size(); ++index) { - if (battery_choices[index].state == state) { - return static_cast(index); - } - } - return 0; - } - - std::wstring raw_hex(const std::vector &bytes) { - std::wostringstream stream; - stream << std::hex << std::setfill(L'0'); - for (const auto value : bytes) { - stream << std::setw(2) << static_cast(value); - } - return stream.str(); - } - - std::optional profile_for_choice(const ProfileChoice &choice) { - switch (choice.kind) { - using enum lvh::GamepadProfileKind; - - case generic: - return lvh::profiles::generic_gamepad(); - case xbox_360: - return lvh::profiles::xbox_360(); - case xbox_one: - return lvh::profiles::xbox_one(); - case xbox_series: - return lvh::profiles::xbox_series(); - case dualshock4: - return lvh::profiles::dualshock4(); - case dualsense: - return lvh::profiles::dualsense(); - case switch_pro: - return lvh::profiles::switch_pro(); - } - return std::nullopt; - } - - int axis_to_slider(float value) { - return static_cast(std::lround(std::clamp(value, -1.0F, 1.0F) * static_cast(slider_scale))); - } - - int trigger_to_slider(float value) { - return static_cast(std::lround(std::clamp(value, 0.0F, 1.0F) * static_cast(slider_scale))); - } - - float slider_to_float(LRESULT value) { - return static_cast(value) / static_cast(slider_scale); - } - - std::wstring yes_no(bool value) { - return value ? L"yes" : L"no"; - } - - bool supports_normalized_feedback(const lvh::DeviceProfile &profile) { - using enum lvh::GamepadOutputKind; - - return lvh::supports_gamepad_output(profile, rumble) || - lvh::supports_gamepad_output(profile, rgb_led) || - lvh::supports_gamepad_output(profile, adaptive_triggers) || - lvh::supports_gamepad_output(profile, trigger_rumble); - } - - std::wstring profile_feature_summary(const lvh::DeviceProfile &profile) { - using enum lvh::GamepadOutputKind; - - const auto support = lvh::gamepad_profile_support(profile); - std::wostringstream stream; - stream << L"Features: battery " << yes_no(support.supports_battery) - << L" | rumble " << yes_no(lvh::supports_gamepad_output(profile, rumble)) - << L" | trigger rumble " << yes_no(lvh::supports_gamepad_output(profile, trigger_rumble)) - << L" | RGB LED " << yes_no(lvh::supports_gamepad_output(profile, rgb_led)) - << L" | adaptive triggers " << yes_no(lvh::supports_gamepad_output(profile, adaptive_triggers)) - << L" | raw output " << yes_no(lvh::supports_gamepad_output(profile, raw_report)); - return stream.str(); - } - - void append_summary_separator(std::wostringstream &stream, bool wrote) { - if (wrote) { - stream << L" | "; - } - } - - bool append_latest_output_summary(std::wostringstream &stream, const ControlledGamepad &device) { - auto wrote = false; - if (device.latest_rumble) { - stream << L"rumble low=" << device.latest_rumble->low_frequency_rumble << L" high=" << device.latest_rumble->high_frequency_rumble; - wrote = true; - } - if (device.latest_trigger_rumble) { - append_summary_separator(stream, wrote); - stream << L"trigger rumble L=" << device.latest_trigger_rumble->left_trigger_rumble << L" R=" << device.latest_trigger_rumble->right_trigger_rumble; - wrote = true; - } - if (device.latest_rgb_led) { - append_summary_separator(stream, wrote); - stream << L"RGB " << static_cast(device.latest_rgb_led->red) << L"," << static_cast(device.latest_rgb_led->green) - << L"," << static_cast(device.latest_rgb_led->blue); - wrote = true; - } - if (device.latest_adaptive_triggers) { - append_summary_separator(stream, wrote); - stream << L"adaptive flags=" << static_cast(device.latest_adaptive_triggers->adaptive_trigger_flags); - wrote = true; - } - if (!wrote && device.latest_raw_report) { - stream << L"raw report"; - wrote = true; - } - return wrote; - } - - std::wstring output_summary(const ControlledGamepad &device, const lvh::DeviceProfile &profile) { - std::wostringstream stream; - stream << L"Output: "; - if (device.outputs.empty()) { - stream << L"no reports received"; - } else if (!append_latest_output_summary(stream, device)) { - stream << L"reports received"; - } - - if (!supports_normalized_feedback(profile)) { - stream << L" | profile has no normalized feedback categories"; - } - return stream.str(); - } - std::optional current_combo_profile(HWND profile_combo) { const auto selection = ::SendMessageW(profile_combo, CB_GETCURSEL, 0, 0); if (selection == CB_ERR || selection < 0 || static_cast(selection) >= profile_choices.size()) { @@ -480,22 +201,6 @@ namespace { return profile_for_choice(profile_choices[static_cast(selection)]); } - bool update_visible_controls_for_profile( - const lvh::DeviceProfile &profile, - std::array &visible_buttons, - bool &battery_controls_visible - ) { - auto changed = false; - for (std::size_t index = 0; index < button_choices.size(); ++index) { - const auto visible = lvh::supports_gamepad_button(profile, button_choices[index].button); - changed = changed || visible_buttons[index] != visible; - visible_buttons[index] = visible; - } - changed = changed || battery_controls_visible != profile.capabilities.supports_battery; - battery_controls_visible = profile.capabilities.supports_battery; - return changed; - } - void move_control(HWND control, int x, int y, int width, int height) { if (control != nullptr) { ::SetWindowPos(control, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOCOPYBITS); @@ -1418,31 +1123,7 @@ namespace { return; } - auto &outputs = iter->second.outputs; - outputs.push_back({.sequence = next_output_sequence_++, .output = output}); - if (outputs.size() > max_output_events_) { - outputs.erase(outputs.begin(), outputs.begin() + static_cast(outputs.size() - max_output_events_)); - } - - switch (output.kind) { - using enum lvh::GamepadOutputKind; - - case rumble: - iter->second.latest_rumble = output; - break; - case trigger_rumble: - iter->second.latest_trigger_rumble = output; - break; - case rgb_led: - iter->second.latest_rgb_led = output; - break; - case adaptive_triggers: - iter->second.latest_adaptive_triggers = output; - break; - case raw_report: - iter->second.latest_raw_report = output; - break; - } + lvh::tools::virtualhid_control::record_output(iter->second, output, next_output_sequence_, max_output_events_); } if (window_ != nullptr) { ::PostMessageW(window_, output_changed_message, 0, 0); diff --git a/tools/virtualhid_control_model.cpp b/tools/virtualhid_control_model.cpp new file mode 100644 index 0000000..bdcd832 --- /dev/null +++ b/tools/virtualhid_control_model.cpp @@ -0,0 +1,281 @@ +/** + * @file tools/virtualhid_control_model.cpp + * @brief Testable model helper definitions for the native libvirtualhid control UI. + */ + +// standard includes +#include +#include +#include +#include + +// local includes +#include "virtualhid_control_model.hpp" + +namespace lvh::tools::virtualhid_control { + namespace { + + void append_summary_separator(std::wostringstream &stream, bool wrote) { + if (wrote) { + stream << L" | "; + } + } + + } // namespace + + std::wstring device_type_name(DeviceType type) { + switch (type) { + using enum DeviceType; + + case gamepad: + return L"gamepad"; + case keyboard: + return L"keyboard"; + case mouse: + return L"mouse"; + case touchscreen: + return L"touchscreen"; + case trackpad: + return L"trackpad"; + case pen_tablet: + return L"pen tablet"; + } + return L"unknown"; + } + + std::wstring node_kind_name(DeviceNodeKind kind) { + switch (kind) { + using enum DeviceNodeKind; + + case input_event: + return L"input"; + case joystick: + return L"joystick"; + case hidraw: + return L"hidraw"; + case sysfs: + return L"sysfs"; + case other: + return L"other"; + } + return L"other"; + } + + std::wstring output_kind_name(GamepadOutputKind kind) { + switch (kind) { + using enum GamepadOutputKind; + + case rumble: + return L"rumble"; + case rgb_led: + return L"rgb led"; + case adaptive_triggers: + return L"adaptive triggers"; + case raw_report: + return L"raw report"; + case trigger_rumble: + return L"trigger rumble"; + } + return L"raw report"; + } + + std::wstring battery_state_name(GamepadBatteryState state) { + switch (state) { + using enum GamepadBatteryState; + + case unknown: + return L"unknown"; + case discharging: + return L"discharging"; + case charging: + return L"charging"; + case full: + return L"full"; + case voltage_or_temperature_error: + return L"voltage/temperature error"; + case temperature_error: + return L"temperature error"; + case charging_error: + return L"charging error"; + } + return L"unknown"; + } + + int battery_choice_index(GamepadBatteryState state) { + for (std::size_t index = 0; index < battery_choices.size(); ++index) { + if (battery_choices[index].state == state) { + return static_cast(index); + } + } + return 0; + } + + std::wstring raw_hex(const std::vector &bytes) { + std::wostringstream stream; + stream << std::hex << std::setfill(L'0'); + for (const auto value : bytes) { + stream << std::setw(2) << static_cast(value); + } + return stream.str(); + } + + std::optional profile_for_choice(const ProfileChoice &choice) { + switch (choice.kind) { + using enum GamepadProfileKind; + + case generic: + return profiles::generic_gamepad(); + case xbox_360: + return profiles::xbox_360(); + case xbox_one: + return profiles::xbox_one(); + case xbox_series: + return profiles::xbox_series(); + case dualshock4: + return profiles::dualshock4(); + case dualsense: + return profiles::dualsense(); + case switch_pro: + return profiles::switch_pro(); + } + return std::nullopt; + } + + int axis_to_slider(float value) { + return static_cast(std::lround(std::clamp(value, -1.0F, 1.0F) * static_cast(slider_scale))); + } + + int trigger_to_slider(float value) { + return static_cast(std::lround(std::clamp(value, 0.0F, 1.0F) * static_cast(slider_scale))); + } + + float slider_to_float(long value) { + return static_cast(value) / static_cast(slider_scale); + } + + std::wstring yes_no(bool value) { + return value ? L"yes" : L"no"; + } + + bool supports_normalized_feedback(const DeviceProfile &profile) { + using enum GamepadOutputKind; + + return supports_gamepad_output(profile, rumble) || + supports_gamepad_output(profile, rgb_led) || + supports_gamepad_output(profile, adaptive_triggers) || + supports_gamepad_output(profile, trigger_rumble); + } + + std::wstring profile_feature_summary(const DeviceProfile &profile) { + using enum GamepadOutputKind; + + const auto support = gamepad_profile_support(profile); + std::wostringstream stream; + stream << L"Features: battery " << yes_no(support.supports_battery); + stream << L" | rumble " << yes_no(supports_gamepad_output(profile, rumble)); + stream << L" | trigger rumble " << yes_no(supports_gamepad_output(profile, trigger_rumble)); + stream << L" | RGB LED " << yes_no(supports_gamepad_output(profile, rgb_led)); + stream << L" | adaptive triggers " << yes_no(supports_gamepad_output(profile, adaptive_triggers)); + stream << L" | raw output " << yes_no(supports_gamepad_output(profile, raw_report)); + return stream.str(); + } + + bool append_latest_output_summary(std::wostringstream &stream, const OutputState &state) { + auto wrote = false; + if (state.latest_rumble) { + stream << L"rumble low=" << state.latest_rumble->low_frequency_rumble + << L" high=" << state.latest_rumble->high_frequency_rumble; + wrote = true; + } + if (state.latest_trigger_rumble) { + append_summary_separator(stream, wrote); + stream << L"trigger rumble L=" << state.latest_trigger_rumble->left_trigger_rumble + << L" R=" << state.latest_trigger_rumble->right_trigger_rumble; + wrote = true; + } + if (state.latest_rgb_led) { + append_summary_separator(stream, wrote); + stream << L"RGB " << static_cast(state.latest_rgb_led->red) << L"," + << static_cast(state.latest_rgb_led->green) << L"," + << static_cast(state.latest_rgb_led->blue); + wrote = true; + } + if (state.latest_adaptive_triggers) { + append_summary_separator(stream, wrote); + stream << L"adaptive flags=" << static_cast(state.latest_adaptive_triggers->adaptive_trigger_flags); + wrote = true; + } + if (!wrote && state.latest_raw_report) { + stream << L"raw report"; + wrote = true; + } + return wrote; + } + + std::wstring output_summary(const OutputState &state, const DeviceProfile &profile) { + std::wostringstream stream; + stream << L"Output: "; + if (state.outputs.empty()) { + stream << L"no reports received"; + } else if (!append_latest_output_summary(stream, state)) { + stream << L"reports received"; + } + + if (!supports_normalized_feedback(profile)) { + stream << L" | profile has no normalized feedback categories"; + } + return stream.str(); + } + + bool update_visible_controls_for_profile( + const DeviceProfile &profile, + std::array &visible_buttons, + bool &battery_controls_visible + ) { + auto changed = false; + for (std::size_t index = 0; index < button_choices.size(); ++index) { + const auto visible = supports_gamepad_button(profile, button_choices[index].button); + changed = changed || visible_buttons[index] != visible; + visible_buttons[index] = visible; + } + changed = changed || battery_controls_visible != profile.capabilities.supports_battery; + battery_controls_visible = profile.capabilities.supports_battery; + return changed; + } + + void record_output( + OutputState &state, + const GamepadOutput &output, + std::uint64_t &next_sequence, + std::size_t max_output_events + ) { + state.outputs.push_back({.sequence = next_sequence++, .output = output}); + if (state.outputs.size() > max_output_events) { + state.outputs.erase( + state.outputs.begin(), + state.outputs.begin() + static_cast(state.outputs.size() - max_output_events) + ); + } + + switch (output.kind) { + using enum GamepadOutputKind; + + case rumble: + state.latest_rumble = output; + break; + case trigger_rumble: + state.latest_trigger_rumble = output; + break; + case rgb_led: + state.latest_rgb_led = output; + break; + case adaptive_triggers: + state.latest_adaptive_triggers = output; + break; + case raw_report: + state.latest_raw_report = output; + break; + } + } + +} // namespace lvh::tools::virtualhid_control diff --git a/tools/virtualhid_control_model.hpp b/tools/virtualhid_control_model.hpp new file mode 100644 index 0000000..ca37476 --- /dev/null +++ b/tools/virtualhid_control_model.hpp @@ -0,0 +1,142 @@ +/** + * @file tools/virtualhid_control_model.hpp + * @brief Testable model helpers for the native libvirtualhid control UI. + */ + +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include + +namespace lvh::tools::virtualhid_control { + + inline constexpr auto slider_scale = 100; + + struct ProfileChoice { + std::wstring_view id; + std::wstring_view label; + GamepadProfileKind kind; + ClientControllerType client_type; + }; + + struct ButtonChoice { + std::wstring_view label; + GamepadButton button; + }; + + struct AxisChoice { + std::wstring_view label; + int minimum; + int maximum; + }; + + struct BatteryChoice { + std::wstring_view label; + GamepadBatteryState state; + }; + + struct OutputLogEntry { + std::uint64_t sequence = 0; + GamepadOutput output; + }; + + struct OutputState { + std::vector outputs; + std::optional latest_rumble; + std::optional latest_trigger_rumble; + std::optional latest_rgb_led; + std::optional latest_adaptive_triggers; + std::optional latest_raw_report; + }; + + inline constexpr std::array profile_choices { + ProfileChoice {L"generic", L"Generic HID", GamepadProfileKind::generic, ClientControllerType::unknown}, + ProfileChoice {L"x360", L"Xbox 360", GamepadProfileKind::xbox_360, ClientControllerType::xbox}, + ProfileChoice {L"xone", L"Xbox One", GamepadProfileKind::xbox_one, ClientControllerType::xbox}, + ProfileChoice {L"xseries", L"Xbox Series", GamepadProfileKind::xbox_series, ClientControllerType::xbox}, + ProfileChoice {L"ds4", L"DualShock 4", GamepadProfileKind::dualshock4, ClientControllerType::playstation}, + ProfileChoice {L"ds5", L"DualSense", GamepadProfileKind::dualsense, ClientControllerType::playstation}, + ProfileChoice {L"switch", L"Switch Pro", GamepadProfileKind::switch_pro, ClientControllerType::nintendo}, + }; + + inline constexpr std::array button_choices { + ButtonChoice {L"A", GamepadButton::a}, + ButtonChoice {L"B", GamepadButton::b}, + ButtonChoice {L"X", GamepadButton::x}, + ButtonChoice {L"Y", GamepadButton::y}, + ButtonChoice {L"Back", GamepadButton::back}, + ButtonChoice {L"Start", GamepadButton::start}, + ButtonChoice {L"Guide", GamepadButton::guide}, + ButtonChoice {L"L3", GamepadButton::left_stick}, + ButtonChoice {L"R3", GamepadButton::right_stick}, + ButtonChoice {L"LB", GamepadButton::left_shoulder}, + ButtonChoice {L"RB", GamepadButton::right_shoulder}, + ButtonChoice {L"D-pad Up", GamepadButton::dpad_up}, + ButtonChoice {L"D-pad Down", GamepadButton::dpad_down}, + ButtonChoice {L"D-pad Left", GamepadButton::dpad_left}, + ButtonChoice {L"D-pad Right", GamepadButton::dpad_right}, + ButtonChoice {L"Misc", GamepadButton::misc1}, + ButtonChoice {L"Touchpad", GamepadButton::touchpad}, + ButtonChoice {L"Paddle 1", GamepadButton::paddle1}, + ButtonChoice {L"Paddle 2", GamepadButton::paddle2}, + ButtonChoice {L"Paddle 3", GamepadButton::paddle3}, + ButtonChoice {L"Paddle 4", GamepadButton::paddle4}, + }; + + inline constexpr std::array axis_choices { + AxisChoice {L"Left X", -slider_scale, slider_scale}, + AxisChoice {L"Left Y", -slider_scale, slider_scale}, + AxisChoice {L"Right X", -slider_scale, slider_scale}, + AxisChoice {L"Right Y", -slider_scale, slider_scale}, + AxisChoice {L"Left Trigger", 0, slider_scale}, + AxisChoice {L"Right Trigger", 0, slider_scale}, + }; + + inline constexpr std::array battery_choices { + BatteryChoice {L"Unknown", GamepadBatteryState::unknown}, + BatteryChoice {L"Discharging", GamepadBatteryState::discharging}, + BatteryChoice {L"Charging", GamepadBatteryState::charging}, + BatteryChoice {L"Full", GamepadBatteryState::full}, + BatteryChoice {L"Voltage/temperature error", GamepadBatteryState::voltage_or_temperature_error}, + BatteryChoice {L"Temperature error", GamepadBatteryState::temperature_error}, + BatteryChoice {L"Charging error", GamepadBatteryState::charging_error}, + }; + + std::wstring device_type_name(DeviceType type); + std::wstring node_kind_name(DeviceNodeKind kind); + std::wstring output_kind_name(GamepadOutputKind kind); + std::wstring battery_state_name(GamepadBatteryState state); + int battery_choice_index(GamepadBatteryState state); + std::wstring raw_hex(const std::vector &bytes); + std::optional profile_for_choice(const ProfileChoice &choice); + int axis_to_slider(float value); + int trigger_to_slider(float value); + float slider_to_float(long value); + std::wstring yes_no(bool value); + bool supports_normalized_feedback(const DeviceProfile &profile); + std::wstring profile_feature_summary(const DeviceProfile &profile); + bool append_latest_output_summary(std::wostringstream &stream, const OutputState &state); + std::wstring output_summary(const OutputState &state, const DeviceProfile &profile); + bool update_visible_controls_for_profile( + const DeviceProfile &profile, + std::array &visible_buttons, + bool &battery_controls_visible + ); + void record_output( + OutputState &state, + const GamepadOutput &output, + std::uint64_t &next_sequence, + std::size_t max_output_events + ); + +} // namespace lvh::tools::virtualhid_control From b62cf3886079953162bed18312ff1d6f9e378b0b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:34:01 -0400 Subject: [PATCH 12/12] Refine Windows validation packaging docs Stop shipping the reviewer PowerShell validation scripts in the Windows package, and update the Windows driver and store review docs to point reviewers at the installed `virtualhid_control.exe` UI instead. The unit test is also cleaned up to use unqualified `GamepadOutputKind` enum values for readability. --- cmake/packaging/windows.cmake | 2 - docs/store-review-validation.md | 57 +++++++++++--------- docs/windows-driver.md | 9 ++-- tests/unit/test_virtualhid_control_model.cpp | 14 ++--- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/cmake/packaging/windows.cmake b/cmake/packaging/windows.cmake index ad8cc5e..ced7e13 100644 --- a/cmake/packaging/windows.cmake +++ b/cmake/packaging/windows.cmake @@ -18,8 +18,6 @@ set(LIBVIRTUALHID_DRIVER_LICENSE_FILE install(FILES "${PROJECT_SOURCE_DIR}/scripts/windows/libvirtualhid-driver-common.ps1" "${PROJECT_SOURCE_DIR}/scripts/windows/install-driver.ps1" - "${PROJECT_SOURCE_DIR}/scripts/windows/test-browser-gamepad.ps1" - "${PROJECT_SOURCE_DIR}/scripts/windows/test-installed-driver.ps1" "${PROJECT_SOURCE_DIR}/scripts/windows/uninstall-driver.ps1" DESTINATION "scripts/windows" COMPONENT driver) diff --git a/docs/store-review-validation.md b/docs/store-review-validation.md index 14a9a04..0594645 100644 --- a/docs/store-review-validation.md +++ b/docs/store-review-validation.md @@ -14,50 +14,55 @@ Paste this into the Partner Center certification notes field: ```text This package installs the libvirtualhid Windows user-mode UMDF/VHF virtual HID -driver. It has no standalone end-user UI; applications consume it through the -libvirtualhid client API. +driver. Applications consume it through the libvirtualhid client API, and the +MSI includes a native diagnostic UI for local validation. -A prebuilt validation tool and PowerShell validation scripts are installed by -the MSI so no programming, SDK setup, or driver build environment is required. -Please install the released, production-signed MSI, then run the validation -commands below from PowerShell. +A prebuilt native validation tool is installed by the MSI so no programming, +SDK setup, or driver build environment is required. Please install the released, +production-signed MSI, then launch the validation tool below. Default install root: C:\Program Files\libvirtualhid Installed validation files: -C:\Program Files\libvirtualhid\scripts\windows\test-installed-driver.ps1 -C:\Program Files\libvirtualhid\scripts\windows\test-browser-gamepad.ps1 +C:\Program Files\libvirtualhid\tools\windows\virtualhid_control.exe C:\Program Files\libvirtualhid\tools\windows\gamepad_adapter.exe Required validation: $installRoot = Join-Path $env:ProgramFiles "libvirtualhid" -powershell -ExecutionPolicy Bypass -File "$installRoot\scripts\windows\test-installed-driver.ps1" ` - -GamepadAdapterPath "$installRoot\tools\windows\gamepad_adapter.exe" ` - -GamepadProfile xseries ` - -Verbose +& "$installRoot\tools\windows\virtualhid_control.exe" + +In the libvirtualhid control window, leave the default Xbox Series profile +selected and click Create. Use the button and axis controls in the UI to submit +input to the virtual controller. Expected result: -- The command exits successfully. -- The ROOT\LIBVIRTUALHID control device reports Status: Started. -- The \\.\LibVirtualHid control device opens successfully. +- The UI launches without requiring SDK or WDK tools. +- The backend status reports windows-umdf with gamepad support available. +- A virtual HID gamepad is created and appears in the device list. - A virtual HID gamepad child device starts, matching either HID\VID_045E&PID_0B12&IG_00 or HID\VID_045E&PID_02FF&IG_00. +- Button and axis values in the UI can be pressed or moved without errors. Optional browser validation: $installRoot = Join-Path $env:ProgramFiles "libvirtualhid" -powershell -ExecutionPolicy Bypass -File "$installRoot\scripts\windows\test-browser-gamepad.ps1" ` - -GamepadAdapterPath "$installRoot\tools\windows\gamepad_adapter.exe" ` - -GamepadProfile xseries ` - -KeepBrowserOpen +& "$installRoot\tools\windows\virtualhid_control.exe" + +Create the default Xbox Series gamepad, then open: +https://hardwaretester.com/gamepad + +Use the libvirtualhid control window to press buttons or move axes while the +browser page is open. Expected result: -- Microsoft Edge or Google Chrome opens a gamepad test page. +- Microsoft Edge, Google Chrome, or another desktop browser opens a gamepad + test page. - The browser Gamepad API sees an Xbox-compatible controller. -- Button and axis values change while the validation adapter is running. +- Button and axis values change while controls are used in the validation UI. -The installed gamepad_adapter.exe is built with the static MSVC runtime, so it -does not require the Visual C++ Redistributable to be installed separately. +The installed virtualhid_control.exe and gamepad_adapter.exe binaries are built +with the static MSVC runtime, so they do not require the Visual C++ +Redistributable to be installed separately. ``` ## Manual Review Steps @@ -66,8 +71,8 @@ does not require the Visual C++ Redistributable to be installed separately. `libvirtualhid-Windows-Driver-installer.msi`. 2. Reboot only if Windows reports that a reboot is required. 3. Open PowerShell. -4. Run the required validation command from the submission notes. -5. Optionally run the browser validation command. +4. Run the required validation tool from the submission notes. +5. Optionally run the browser validation steps. If the default install location was changed during MSI installation, replace `$env:ProgramFiles\libvirtualhid` with the selected install directory. @@ -85,4 +90,4 @@ HID-only and intentionally does not emulate the Xbox 360 XUSB stack. The reviewer-visible success signal is the installed `ROOT\LIBVIRTUALHID` control device, the `\\.\LibVirtualHid` control path, and a started HID gamepad -child device while `gamepad_adapter.exe` is running. +child device while `virtualhid_control.exe` has a gamepad created. diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 1629fa5..504bf05 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -84,11 +84,14 @@ powershell -ExecutionPolicy Bypass -File .\scripts\windows\uninstall-driver.ps1 The WiX installer also places validation files under the default install root, `C:\Program Files\libvirtualhid`: -- `scripts\windows\test-installed-driver.ps1` -- `scripts\windows\test-browser-gamepad.ps1` - `tools\windows\gamepad_adapter.exe` - `tools\windows\virtualhid_control.exe` +The source-tree validation scripts remain developer and CI helpers. They are not +packaged as reviewer-facing MSI validation scripts because the native +`virtualhid_control.exe` tool can create, exercise, and inspect virtual +gamepads interactively. + The install helper stages the INF with `pnputil`, updates an existing `ROOT\LIBVIRTUALHID` device when present, and creates that root-enumerated device when it is missing. It uses SetupAPI/NewDev directly, so MSI installs do @@ -101,7 +104,7 @@ desktop browser at `https://hardwaretester.com/gamepad` and validates that the browser Gamepad API observes the held virtual controller. For manual browser validation, run the browser helper with `-KeepBrowserOpen`, -or run: +run the interactive UI, or run: ```powershell tools\windows\gamepad_adapter.exe xseries --hold-seconds 60 diff --git a/tests/unit/test_virtualhid_control_model.cpp b/tests/unit/test_virtualhid_control_model.cpp index 26b2390..9f47387 100644 --- a/tests/unit/test_virtualhid_control_model.cpp +++ b/tests/unit/test_virtualhid_control_model.cpp @@ -162,6 +162,8 @@ TEST(VirtualHidControlModelTest, UpdatesVisibleControlsForProfiles) { } TEST(VirtualHidControlModelTest, SummarizesOutputState) { + using enum lvh::GamepadOutputKind; + const auto generic = lvh::profiles::generic_gamepad(); const auto dualsense = lvh::profiles::dualsense(); @@ -172,23 +174,23 @@ TEST(VirtualHidControlModelTest, SummarizesOutputState) { ); EXPECT_EQ(control::output_summary(state, dualsense), L"Output: no reports received"); - state.outputs.push_back({.sequence = 1, .output = output(lvh::GamepadOutputKind::raw_report)}); + state.outputs.push_back({.sequence = 1, .output = output(raw_report)}); EXPECT_EQ(control::output_summary(state, dualsense), L"Output: reports received"); - state.latest_raw_report = output(lvh::GamepadOutputKind::raw_report); + state.latest_raw_report = output(raw_report); EXPECT_EQ(control::output_summary(state, dualsense), L"Output: raw report"); - state.latest_rumble = output(lvh::GamepadOutputKind::rumble); + state.latest_rumble = output(rumble); state.latest_rumble->low_frequency_rumble = 10; state.latest_rumble->high_frequency_rumble = 20; - state.latest_trigger_rumble = output(lvh::GamepadOutputKind::trigger_rumble); + state.latest_trigger_rumble = output(trigger_rumble); state.latest_trigger_rumble->left_trigger_rumble = 30; state.latest_trigger_rumble->right_trigger_rumble = 40; - state.latest_rgb_led = output(lvh::GamepadOutputKind::rgb_led); + state.latest_rgb_led = output(rgb_led); state.latest_rgb_led->red = 1; state.latest_rgb_led->green = 2; state.latest_rgb_led->blue = 3; - state.latest_adaptive_triggers = output(lvh::GamepadOutputKind::adaptive_triggers); + state.latest_adaptive_triggers = output(adaptive_triggers); state.latest_adaptive_triggers->adaptive_trigger_flags = 4; EXPECT_EQ(