From 1261c338d364cbc00c529e1a46f4b1bdfe75b4e0 Mon Sep 17 00:00:00 2001 From: IronicDeGawd Date: Sat, 22 Aug 2026 01:07:32 +0530 Subject: [PATCH 1/8] feat(windows): back the virtual mouse with a real HID device The Windows mouse backend synthesised input with SendInput, which travels the Win32 cursor pipeline. Applications that read mouse input through the Raw Input API observe nothing on that path, so relative-motion controls such as camera look and click-drag are unusable while the mouse still appears to move on screen. Create the mouse as a HID device through the UMDF control channel, the same way gamepads are created, and deliver relative motion, buttons and scrolling as HID input reports. Raw Input consumers then see the device as they would a physical mouse. - Add a five-button mouse report descriptor with 16-bit relative axes, a wheel and an AC Pan axis. - Accumulate sub-detent high-resolution scroll so precision is preserved across events. - Delegate absolute motion to the existing injection path, which has no relative HID equivalent. - Fall back to the previous SendInput mouse whenever the driver is unavailable, so mouse input keeps working without the driver package. --- src/platform/windows/windows_backend.cpp | 315 ++++++++++++++++++++++- 1 file changed, 314 insertions(+), 1 deletion(-) diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index a9c2a92..074dab3 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -924,6 +924,7 @@ namespace lvh::detail { private: friend class WindowsBackendContext; friend class WindowsGamepad; + friend class WindowsHidMouse; std::mutex output_dispatch_mutex_; mutable std::mutex mutex_; @@ -1039,6 +1040,18 @@ namespace lvh::detail { return {OperationStatus::success(), std::move(gamepad)}; } + /** + * @brief Create a driver-backed HID mouse. + * + * Defined out of line because it constructs `WindowsHidMouse`, which is + * declared after this class. + * + * @param id Client device identity. + * @param options Mouse creation options. + * @return Creation result; the device is null when the driver declines. + */ + BackendMouseCreationResult create_hid_mouse(DeviceId id, const CreateMouseOptions &options); + OperationStatus submit_gamepad_report( const std::shared_ptr &state, const std::vector &report @@ -1420,6 +1433,296 @@ namespace lvh::detail { bool open_ = true; }; + /** + * @brief Report descriptor for the driver-backed relative mouse. + * + * Five buttons, 16-bit relative X/Y, an 8-bit wheel and an 8-bit AC Pan + * axis. No report ID is declared, matching the seven byte input report + * emitted by `WindowsHidMouse`. + * + * @return Report descriptor bytes. + */ + std::vector make_mouse_report_descriptor() { + return { + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xA1, 0x01, // Collection (Application) + 0x09, 0x01, // Usage (Pointer) + 0xA1, 0x00, // Collection (Physical) + 0x05, 0x09, // Usage Page (Button) + 0x19, 0x01, // Usage Minimum (Button 1) + 0x29, 0x05, // Usage Maximum (Button 5) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95, 0x05, // Report Count (5) + 0x81, 0x02, // Input (Data,Var,Abs) + 0x75, 0x03, // Report Size (3) + 0x95, 0x01, // Report Count (1) + 0x81, 0x03, // Input (Cnst,Var,Abs) - padding + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x16, 0x00, 0x80, // Logical Minimum (-32768) + 0x26, 0xFF, 0x7F, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x02, // Report Count (2) + 0x81, 0x06, // Input (Data,Var,Rel) + 0x09, 0x38, // Usage (Wheel) + 0x15, 0x81, // Logical Minimum (-127) + 0x25, 0x7F, // Logical Maximum (127) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x06, // Input (Data,Var,Rel) + 0x05, 0x0C, // Usage Page (Consumer) + 0x0A, 0x38, 0x02, // Usage (AC Pan) + 0x15, 0x81, // Logical Minimum (-127) + 0x25, 0x7F, // Logical Maximum (127) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x06, // Input (Data,Var,Rel) + 0xC0, // End Collection + 0xC0, // End Collection + }; + } + + /** + * @brief Size of the input report emitted by `WindowsHidMouse`. + */ + constexpr std::size_t mouse_input_report_size = 7U; + + /** + * @brief High-resolution scroll units that make up a single wheel detent. + */ + constexpr int mouse_scroll_units_per_detent = 120; + + /** + * @brief Device profile describing the driver-backed mouse. + * + * @return Mouse device profile. + */ + DeviceProfile make_hid_mouse_profile() { + DeviceProfile profile; + profile.device_type = DeviceType::mouse; + profile.gamepad_kind = GamepadProfileKind::generic; + profile.bus_type = BusType::usb; + profile.vendor_id = 0x1209; + profile.product_id = 0x0003; + profile.version = 0x0001; + profile.report_id = 0; + profile.name = "libvirtualhid Mouse"; + profile.manufacturer = "LizardByte"; + profile.report_descriptor = make_mouse_report_descriptor(); + profile.input_report_size = mouse_input_report_size; + profile.output_report_size = 0; + profile.capabilities = {}; + return profile; + } + + /** + * @brief Map a mouse button onto its HID button bit. + * + * HID orders the primary buttons left, right, middle, which differs from + * the `MouseButton` declaration order. + * + * @param button Mouse button. + * @return Bit index, or `std::nullopt` when the button is unmapped. + */ + std::optional hid_mouse_button_bit(MouseButton button) { + switch (button) { + using enum MouseButton; + + case left: + return 0U; + case right: + return 1U; + case middle: + return 2U; + case side: + return 3U; + case extra: + return 4U; + } + + return std::nullopt; + } + + /** + * @brief Mouse backed by a real HID device created through the UMDF driver. + * + * Relative motion, buttons and scrolling are delivered as HID input + * reports, so applications reading the Raw Input API observe them exactly + * as they would a physical mouse. Absolute motion has no relative HID + * equivalent and is delegated to the Win32 injection path. + */ + class WindowsHidMouse final: public BackendMouse { + public: + WindowsHidMouse( + std::shared_ptr context, + std::shared_ptr state + ): + context_ {std::move(context)}, + state_ {std::move(state)} {} + + OperationStatus submit(const MouseEvent &event) override { + using enum ErrorCode; + + if (!open_) { + return OperationStatus::failure(device_closed, "Windows HID mouse is closed"); + } + + switch (event.kind) { + using enum MouseEventKind; + + case relative_motion: + return emit(clamp_axis(event.x), clamp_axis(event.y), 0, 0); + case absolute_motion: + // Relative HID reports cannot express absolute positioning. + return fallback_.submit(event); + case button: + return submit_button(event); + case vertical_scroll: + return emit(0, 0, accumulate_scroll(vertical_scroll_remainder_, event.high_resolution_scroll), 0); + case horizontal_scroll: + return emit(0, 0, 0, accumulate_scroll(horizontal_scroll_remainder_, event.high_resolution_scroll)); + } + + return OperationStatus::success(); + } + + std::vector device_nodes() const override { + return {DeviceNode {.path = state_->path}}; + } + + OperationStatus close() override { + if (!open_) { + return OperationStatus::success(); + } + + open_ = false; + return context_->close_gamepad(state_); + } + + private: + /** + * @brief Clamp a motion delta into the descriptor's 16-bit range. + * + * @param value Incoming delta. + * @return Clamped delta. + */ + static std::int16_t clamp_axis(std::int32_t value) { + return static_cast( + std::clamp( + value, + static_cast(std::numeric_limits::min()), + static_cast(std::numeric_limits::max()) + ) + ); + } + + /** + * @brief Convert high-resolution scroll units into whole wheel detents. + * + * The descriptor exposes a detent-based wheel, so sub-detent movement is + * carried in @p remainder until it accumulates into a full step. + * + * @param remainder Running sub-detent remainder for this axis. + * @param high_resolution_scroll Incoming high-resolution distance. + * @return Whole detents to report, clamped to the descriptor range. + */ + static std::int8_t accumulate_scroll(int &remainder, int high_resolution_scroll) { + remainder += high_resolution_scroll; + const auto detents = remainder / mouse_scroll_units_per_detent; + remainder -= detents * mouse_scroll_units_per_detent; + return static_cast(std::clamp(detents, -127, 127)); + } + + /** + * @brief Track a button transition and emit the updated button state. + * + * @param event Mouse event describing the transition. + * @return Submission status. + */ + OperationStatus submit_button(const MouseEvent &event) { + const auto bit = hid_mouse_button_bit(event.button); + if (!bit) { + return OperationStatus::success(); + } + + const auto mask = static_cast(1U << *bit); + if (event.pressed) { + buttons_ = static_cast(buttons_ | mask); + } else { + buttons_ = static_cast(buttons_ & ~mask); + } + + return emit(0, 0, 0, 0); + } + + /** + * @brief Pack and submit a single HID input report. + * + * @param x Relative X delta. + * @param y Relative Y delta. + * @param wheel Vertical wheel detents. + * @param pan Horizontal wheel detents. + * @return Submission status. + */ + OperationStatus emit(std::int16_t x, std::int16_t y, std::int8_t wheel, std::int8_t pan) { + std::vector report(mouse_input_report_size, 0U); + report[0] = buttons_; + report[1] = static_cast(static_cast(x) & 0xFFU); + report[2] = static_cast((static_cast(x) >> 8U) & 0xFFU); + report[3] = static_cast(static_cast(y) & 0xFFU); + report[4] = static_cast((static_cast(y) >> 8U) & 0xFFU); + report[5] = static_cast(wheel); + report[6] = static_cast(pan); + return context_->submit_gamepad_report(state_, report); + } + + std::shared_ptr context_; + std::shared_ptr state_; + WindowsMouse fallback_; + std::uint8_t buttons_ = 0U; + int vertical_scroll_remainder_ = 0; + int horizontal_scroll_remainder_ = 0; + bool open_ = true; + }; + + BackendMouseCreationResult WindowsBackendContext::create_hid_mouse( + DeviceId id, + const CreateMouseOptions &options + ) { + CreateGamepadOptions gamepad_options; + gamepad_options.profile = make_hid_mouse_profile(); + gamepad_options.metadata.stable_id = options.stable_id; + + auto request = windows::make_create_gamepad_request(id, gamepad_options); + LvhWindowsCreateGamepadResponse response {}; + response.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; + response.size = sizeof(response); + + if (const auto status = command_channel_->create_gamepad(request, response); !status.ok()) { + return {status, nullptr}; + } + + auto state = std::make_shared( + id, + response.driver_device_id, + response.session_token, + gamepad_options.profile, + response.device_path[0] == '\0' ? command_channel_->path() : std::string {response.device_path.data()} + ); + + { + std::lock_guard lock {devices_mutex_}; + gamepads_[state->driver_id] = state; + } + notify_pid_timer(); + + return {OperationStatus::success(), std::make_unique(shared_from_this(), std::move(state))}; + } + /** * @brief Shared lifecycle and refresh support for Windows synthetic pointer devices. */ @@ -2033,11 +2336,21 @@ namespace lvh::detail { return {OperationStatus::success(), std::make_unique()}; } - BackendMouseCreationResult create_mouse(DeviceId /*id*/, const CreateMouseOptions &options) override { + BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) override { if (options.profile.device_type != DeviceType::mouse) { return {unsupported_profile_status("Windows mouse backend requires a mouse profile"), nullptr}; } + // A driver-backed HID mouse is visible to the Raw Input API, which the + // Win32 injection fallback below is not. Fall back whenever the driver + // is unavailable so mouse input keeps working without it. + if (context_) { + auto hid = context_->create_hid_mouse(id, options); + if (hid.mouse) { + return hid; + } + } + return {OperationStatus::success(), std::make_unique()}; } From f64667da37a0cedbe2e3b67912459de40416945b Mon Sep 17 00:00:00 2001 From: IronicDeGawd Date: Sat, 22 Aug 2026 01:15:01 +0530 Subject: [PATCH 2/8] style(windows): use std::byte for mouse button state Address static analysis findings: test the optional with has_value() and hold the HID button bitmask as std::byte rather than a raw integer. --- src/platform/windows/windows_backend.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index 074dab3..0735d68 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -1645,16 +1645,12 @@ namespace lvh::detail { */ OperationStatus submit_button(const MouseEvent &event) { const auto bit = hid_mouse_button_bit(event.button); - if (!bit) { + if (!bit.has_value()) { return OperationStatus::success(); } - const auto mask = static_cast(1U << *bit); - if (event.pressed) { - buttons_ = static_cast(buttons_ | mask); - } else { - buttons_ = static_cast(buttons_ & ~mask); - } + const auto mask = std::byte {1} << *bit; + buttons_ = event.pressed ? (buttons_ | mask) : (buttons_ & ~mask); return emit(0, 0, 0, 0); } @@ -1670,7 +1666,7 @@ namespace lvh::detail { */ OperationStatus emit(std::int16_t x, std::int16_t y, std::int8_t wheel, std::int8_t pan) { std::vector report(mouse_input_report_size, 0U); - report[0] = buttons_; + report[0] = std::to_integer(buttons_); report[1] = static_cast(static_cast(x) & 0xFFU); report[2] = static_cast((static_cast(x) >> 8U) & 0xFFU); report[3] = static_cast(static_cast(y) & 0xFFU); @@ -1683,7 +1679,7 @@ namespace lvh::detail { std::shared_ptr context_; std::shared_ptr state_; WindowsMouse fallback_; - std::uint8_t buttons_ = 0U; + std::byte buttons_ {}; int vertical_scroll_remainder_ = 0; int horizontal_scroll_remainder_ = 0; bool open_ = true; From 17ce8d1b4748b957e2718ae247956ab4bf68a0af Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:03:13 -0400 Subject: [PATCH 3/8] Add Windows VHF mouse support via generalized device protocol Extends the Windows driver path to support Raw Input-visible mice alongside gamepads. Key changes: - Bumps control protocol to v3 and broker protocol to v4, renaming gamepad-specific structs/IOCTLs to generic device equivalents (e.g. `LvhWindowsCreateDeviceRequest`, `LVH_WINDOWS_IOCTL_CREATE_DEVICE`) - Adds `LVH_WINDOWS_DEVICE_GAMEPAD`/`LVH_WINDOWS_DEVICE_MOUSE` device type field to the create request - Introduces `mouse_protocol.hpp` with a canonical 7-byte five-button relative descriptor shared across backend, broker, and driver - `VhfInputReportQueue` gains a mouse accumulation path that batches relative motion/scroll by button state and emits descriptor-sized chunks, preventing VHF backpressure from discarding relative movement - `WindowsHidMouse` preserves caller-supplied bus type, VID, PID, version, name, manufacturer, and stable ID; falls back to `SendInput` only for license/availability errors, not protocol failures - Broker and driver validation enforce mouse-specific field constraints (fixed descriptor, no report ID, no output reports, no gamepad flags) - Renames internal gamepad-scoped state and methods to device-scoped equivalents (`WindowsVhfDeviceState`, `devices_` map, etc.) --- docs/windows-driver.md | 128 +++++---- .../broker/broker_request_validation.hpp | 30 +- .../windows/broker/libvirtualhid_broker.cpp | 78 +++--- src/platform/windows/control_protocol.hpp | 73 +++-- .../windows/driver/libvirtualhid_umdf.cpp | 85 ++++-- .../shared/lvh_windows_broker_protocol.h | 12 +- .../windows/shared/lvh_windows_protocol.h | 31 +- .../windows/shared/mouse_protocol.hpp | 104 +++++++ .../shared/playstation_feature_protocol.hpp | 6 +- .../windows/shared/vhf_input_report_queue.hpp | 121 +++++++- .../shared/windows_device_identity.hpp | 2 +- src/platform/windows/windows_backend.cpp | 265 +++++++++--------- .../fixtures/windows_backend_test_hooks.hpp | 29 ++ .../windows_broker_service_test_hooks.hpp | 4 +- tests/fixtures/windows_backend_test_hooks.cpp | 122 +++++++- .../windows_broker_service_test_hooks.cpp | 10 +- tests/unit/test_license.cpp | 6 +- tests/unit/test_windows_backend.cpp | 52 ++++ tests/unit/test_windows_broker_service.cpp | 10 +- tests/unit/test_windows_broker_validation.cpp | 92 ++++-- tests/unit/test_windows_driver_protocol.cpp | 10 +- tests/unit/test_windows_protocol.cpp | 63 ++++- .../test_windows_vhf_input_report_queue.cpp | 72 ++++- 23 files changed, 1032 insertions(+), 373 deletions(-) create mode 100644 src/platform/windows/shared/mouse_protocol.hpp diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 96aea99..7f7e7ce 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -1,9 +1,10 @@ # Windows Driver Package -Windows gamepad support uses a user-mode UMDF2 control driver backed by Virtual -HID Framework. The driver package is separate from the normal C++ library build: -the library remains consumable from MSVC and MinGW/UCRT64, while the driver -package is built with the Microsoft SDK/WDK toolchain. +Windows virtual HID gamepad and Raw Input mouse support uses a user-mode UMDF2 +control driver backed by Virtual HID Framework. The driver package is separate +from the normal C++ library build: the library remains consumable from MSVC and +MinGW/UCRT64, while the driver package is built with the Microsoft SDK/WDK +toolchain. Windows 11 version 21H2 and later is the supported driver target. The INF also provides a best-effort compatibility path for Windows 10 version 2004 and later @@ -18,27 +19,27 @@ so Store listing copy should describe the installed driver component. ### Short Description ```text -User-mode virtual HID driver package that enables compatible apps to create virtual gamepads on Windows. +User-mode virtual HID driver package that enables compatible apps to create virtual gamepads and Raw Input mice on Windows. ``` ### Description ```text Virtual HID Driver installs the user-mode driver component used by compatible -applications to create virtual HID gamepads on Windows. +applications to create virtual HID gamepads and mice on Windows. 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. +gamepads. Compatible applications can also request virtual HID gamepads or mice, +and Windows applications that understand standard HID devices can discover them. ``` ## Architecture -Windows gamepad creation is brokered by `libvirtualhid_broker`. The normal C++ -backend asks the broker service to create and destroy gamepads through a local -named pipe, while input reports stay on the direct driver path after creation. -This keeps license and active-device checks outside the input hot path. +Windows driver-device creation is brokered by `libvirtualhid_broker`. The normal +C++ backend asks the broker service to create and destroy virtual HID devices +through a local named pipe, while input reports stay on the direct driver path +after creation. This keeps license and active-device checks outside the input hot +path. The UMDF service runs in a dedicated high-priority host process, as recommended for response-sensitive input drivers. This isolates its VHF input work from @@ -53,8 +54,8 @@ while keeping broker ownership and privileged device operations in the Windows service. Status, current-license validation, activation, replacement, deactivation, -gamepad creation, and owned-device destruction are available to authenticated -local users without elevation. Before sending any request, clients compare the +virtual HID device creation, and owned-device destruction are available to +authenticated local users without elevation. Before sending any request, clients compare the named-pipe server PID to the SCM-registered, currently running `libvirtualhid_broker` service. This prevents another local process from impersonating an unavailable broker and collecting a license key. The service @@ -79,15 +80,18 @@ and HID output writes are normalized back to the C++ output callback path. The driver owns the VHF input buffering policy instead of allowing VHF to build the default HID report backlog. VHF readiness notifications permit one report at -a time; while a consumer is not ready, the driver replaces superseded axis, -trigger, motion, battery, and touch-position states with the newest report. +a time; while a gamepad consumer is not ready, the driver replaces superseded +axis, trigger, motion, battery, and touch-position states with the newest report. Button, D-pad, trigger-threshold, report-ID, and touch-contact lifecycle changes remain ordered in a bounded transition queue. This keeps continuously moving controls close to the latest submitted state while preserving ordinary button -press and release transitions. Profile initialization replies are prioritized -over pending controller states so the Switch Pro handshake remains responsive. +press and release transitions. Relative mouse motion and wheel values are +accumulated by button state and emitted in descriptor-sized chunks, so VHF +backpressure does not turn relative movement into a replaceable absolute state. +Profile initialization replies are prioritized over pending controller states +so the Switch Pro handshake remains responsive. -The driver rejects gamepad create, destroy, and broker-instance reset IOCTLs +The driver rejects virtual HID create, destroy, and broker-instance reset IOCTLs unless the requestor token contains the `NT SERVICE\libvirtualhid_broker` service SID. On the first boot after installation, before Windows applies a newly configured service SID to the process token, the driver instead requires @@ -98,35 +102,37 @@ they are not a separate runtime bypass for creating or destroying virtual devices. The library and installed driver must use the same control-protocol version. -Protocol version 2 expands the report-descriptor capacity to 2048 bytes for the -complete DirectInput PID descriptor; a version mismatch is rejected rather -than interpreting a differently sized request. +Control protocol version 3 adds an explicit device type to the common create +request while retaining the 2048-byte report-descriptor capacity needed by the +complete DirectInput PID descriptor. A version mismatch is rejected rather than +interpreting a differently sized request. Each backend runtime uses one control-file handle for commands and its pending -output read. Broker protocol version 2 preserves that association by duplicating -the handle only for the authorized create IOCTL. The driver associates output -events with that file object, so feedback from a virtual gamepad is delivered -only to the runtime that created it instead of being consumed by another -libvirtualhid client. Because the shared handle is opened for overlapped I/O, -command IOCTLs also supply a valid `OVERLAPPED` event and explicitly wait for -pending completion instead of mixing synchronous calls with an asynchronous -handle. Each caller thread reuses its event to avoid creating a kernel handle -for every input report. - -The driver opens a separate VHF source target for each virtual gamepad and +output read. Broker protocol version 4 carries the generalized device create +request while continuing to duplicate the handle only for the authorized create +IOCTL. The driver associates output events with that file object, so feedback +from a virtual gamepad is delivered only to the runtime that created it instead +of being consumed by another libvirtualhid client. Because the shared handle is +opened for overlapped I/O, command IOCTLs also supply a valid `OVERLAPPED` event +and explicitly wait for pending completion instead of mixing synchronous calls +with an asynchronous handle. Each caller thread reuses its event to avoid +creating a kernel handle for every input report. + +The driver opens a separate VHF source target for each virtual HID device and parents that target to the control-file handle that created it. If the creating -process exits or crashes, Windows cleans up gamepads that were not explicitly +process exits or crashes, Windows cleans up devices that were not explicitly destroyed. In brokered driver packages, the broker owns that control-file handle. The broker tracks the requesting client process for each created device and destroys broker-owned devices when that client process exits unexpectedly. A new -broker process first asks the driver to remove every gamepad left by the previous +broker process first asks the driver to remove every device left by the previous broker instance and refuses new creation until that reset succeeds. Clients must -recreate their gamepads after the broker service restarts. +recreate their devices after the broker service restarts. The backend reports `requires_installed_driver = true` and only advertises gamepad/output-report support when the broker is reachable and the control -device can be opened. Keyboard and mouse support do not require the driver -package. +device can be opened. Keyboard injection and the mouse `SendInput` fallback do +not require the driver package; a Raw Input-visible mouse does require the +driver and the same broker license as a gamepad. ## Build @@ -233,7 +239,7 @@ diagnostics. On Windows, the UI also shows broker license status. It can activate a license key, refresh validation, deactivate the current machine, and open compiled purchase or account-management URLs. License management and normal -virtual-gamepad use do not require elevation. +virtual HID device use do not require elevation. ## Installation Notes @@ -308,18 +314,20 @@ opens the shared persistent Polar Checkout Link. Account management opens the [LizardByte LLC Polar customer portal](https://polar.sh/lizardbyte-llc/portal), where customers can manage their five allowed machine activations. -Normal Windows UMDF gamepad creation requires a current machine authorization, -but controller creation itself does not contact Polar. The broker validates the +Normal Windows UMDF virtual HID device creation requires a current machine +authorization, but device creation itself does not contact Polar. A mouse does +not consume another Polar machine activation; it is another active device under +the existing machine license. The broker validates the saved activation immediately after service startup and then once per day in the background. If validation cannot complete because of a temporary network or -provider failure, the broker retries every 60 seconds. Controllers that already +provider failure, the broker retries every 60 seconds. Devices that already exist are retained for one hour unless the broker service restarts, but no -additional controller can be created while at least one licensed controller +additional device can be created while at least one licensed device remains active. When the outage reaches one hour, the broker removes excess -licensed controllers and retains at most one. A yearly subscription authorization +licensed devices and retains at most one. A yearly subscription authorization is current for at most the daily validation interval plus that one-hour outage allowance; after 25 hours without successful validation, the remaining licensed -controller is also removed. A lifetime license can retain the one-controller +device is also removed. A lifetime license can retain the one-device fallback until online validation succeeds. Failed driver destruction requests remain tracked and are retried instead of being treated as successful revocations. @@ -337,8 +345,8 @@ The broker advances Polar's trusted timestamp using Windows uptime and stores a random marker in a volatile registry key for the current boot session. This works across broker service restarts and includes sleep or hibernation, but never consults the user-adjustable Windows date. After Windows restarts, the marker -changes, so a yearly subscription must reconnect to Polar before gamepad creation; -a lifetime license can use the one-gamepad outage fallback. Explicit validation +changes, so a yearly subscription must reconnect to Polar before virtual HID +device creation; a lifetime license can use the one-device outage fallback. Explicit validation requests always contact the provider. The sole exception to normal licensing is for CI runners where the broker service itself has the `GITHUB_ACTIONS` environment marker. That environment receives one machine-scoped five-minute @@ -354,9 +362,9 @@ same full local access when the provider reports the key status as `granted`. Polar revokes a subscription benefit when its entitlement ends. Licensed access has no local active-device cap after successful validation. A definitive missing activation, revoked or disabled key, activation mismatch, disallowed benefit, or -explicit deactivation prevents new gamepads and causes the broker to destroy -existing licensed gamepads. A timeout or other -transient provider failure starts the one-hour retention period and one-gamepad +explicit deactivation prevents new virtual HID devices and causes the broker to +destroy existing licensed devices. A timeout or other transient provider failure +starts the one-hour retention period and one-device creation limit instead of immediately revoking existing controllers. A yearly subscription that cannot validate for 25 hours is also denied until it reconnects. WinHTTP resolve, connect, send, and receive operations have explicit timeouts of @@ -364,6 +372,16 @@ WinHTTP resolve, connect, send, and receive operations have explicit timeouts of ## Profile Compatibility +For a mouse, the Windows backend preserves the requested bus type, VID, PID, +version, name, manufacturer, and stable ID. The Windows transport owns the HID +framing: it uses a report-ID-free seven-byte descriptor with five buttons, +16-bit relative X/Y, an 8-bit wheel, and an 8-bit AC Pan axis. Relative motion, +buttons, and scrolling use the licensed VHF device so Raw Input clients can see +them. Absolute positioning cannot be represented by that relative descriptor +and continues through `SendInput`. If the driver, broker, or license is +unavailable, mouse creation retains the existing `SendInput` fallback; malformed +requests and unexpected driver failures are returned to the caller. + The Windows backend publishes HID gamepads through VHF. DirectInput, SDL/HIDAPI, Windows.Gaming.Input/GameInput, and browser Gamepad API clients should see standard HID devices after the driver is installed. @@ -409,10 +427,10 @@ label because VHF does not provide a product/manufacturer string callback. packages require a Microsoft dashboard signing path that is not part of the current Azure Trusted Signing workflow. - A temporary Polar outage limits a previously activated machine to one active - licensed gamepad. Yearly subscriptions must reconnect within 25 hours of their - last successful validation; lifetime licenses can retain one gamepad until - validation succeeds. Definitive invalidation prevents new gamepads and removes - active licensed gamepads. + licensed virtual HID device. Yearly subscriptions must reconnect within 25 + hours of their last successful validation; lifetime licenses can retain one + device until validation succeeds. Definitive invalidation prevents new devices + and removes active licensed devices. ## Signing diff --git a/src/platform/windows/broker/broker_request_validation.hpp b/src/platform/windows/broker/broker_request_validation.hpp index 155c97f..19266b9 100644 --- a/src/platform/windows/broker/broker_request_validation.hpp +++ b/src/platform/windows/broker/broker_request_validation.hpp @@ -18,6 +18,7 @@ // local includes #include "lvh_windows_broker_protocol.h" +#include "mouse_protocol.hpp" namespace lvh::windows::broker_validation { @@ -75,18 +76,37 @@ namespace lvh::windows::broker_validation { header.reserved0 == 0U; } - inline bool valid_gamepad_request(const LvhWindowsCreateGamepadRequest &request) { + inline bool valid_device_request(const LvhWindowsCreateDeviceRequest &request) { const auto &sizes = request.report_sizes; + const auto known_device = request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD || + request.device_type == LVH_WINDOWS_DEVICE_MOUSE; const auto known_bus = request.bus_type == LVH_WINDOWS_BUS_UNKNOWN || request.bus_type == LVH_WINDOWS_BUS_USB || request.bus_type == LVH_WINDOWS_BUS_BLUETOOTH; const auto known_profile = request.gamepad_kind <= LVH_WINDOWS_GAMEPAD_DUALSHOCK4; + const auto valid_mouse_descriptor = + request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD || + (sizes.report_descriptor_size == lvh::detail::windows::mouse_report_descriptor.size() && + std::equal( + lvh::detail::windows::mouse_report_descriptor.begin(), + lvh::detail::windows::mouse_report_descriptor.end(), + request.report_descriptor.begin() + )); + const auto valid_device_fields = request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD || + (request.gamepad_kind == LVH_WINDOWS_GAMEPAD_GENERIC && + request.flags == 0U && + request.hardware_ids.report_id == 0U && + sizes.input_report_size == LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE && + sizes.output_report_size == 0U); return request.version == LVH_WINDOWS_CONTROL_PROTOCOL_VERSION && request.size == sizeof(request) && request.client_device_id != 0U && + known_device && known_bus && known_profile && + valid_device_fields && + valid_mouse_descriptor && (request.flags & ~known_gamepad_flags) == 0U && all_zero(request.hardware_ids.reserved0) && sizes.input_report_size > 0U && @@ -121,14 +141,14 @@ namespace lvh::windows::broker_validation { ); } - inline bool valid_request(const LvhWindowsBrokerCreateGamepadRequest &request) { + inline bool valid_request(const LvhWindowsBrokerCreateDeviceRequest &request) { return valid_header( request.header, - LvhWindowsBrokerRequestType::create_gamepad, + LvhWindowsBrokerRequestType::create_device, sizeof(request) ) && request.client_control_handle != 0U && - valid_gamepad_request(request.gamepad); + valid_device_request(request.device); } inline bool valid_request(const LvhWindowsBrokerDestroyDeviceRequest &request) { @@ -158,7 +178,7 @@ namespace lvh::windows::broker_validation { case deactivate_license: return all_zero(request.license_key) && all_zero(request.instance_name); case status: - case create_gamepad: + case create_device: case destroy_device: return false; } diff --git a/src/platform/windows/broker/libvirtualhid_broker.cpp b/src/platform/windows/broker/libvirtualhid_broker.cpp index 10c4690..d256c72 100644 --- a/src/platform/windows/broker/libvirtualhid_broker.cpp +++ b/src/platform/windows/broker/libvirtualhid_broker.cpp @@ -85,7 +85,7 @@ namespace lvh::detail::windows_broker_service { constexpr auto license_outage_device_retention = std::chrono::hours {1}; constexpr auto subscription_validation_max_age = license_validation_interval + license_outage_device_retention; - constexpr std::size_t unvalidated_active_gamepad_limit = 1U; + constexpr std::size_t unvalidated_active_device_limit = 1U; constexpr auto boot_session_registry_path = L"SYSTEM\\CurrentControlSet\\Services\\libvirtualhid_broker\\Runtime"; constexpr auto boot_session_registry_value = L"BootMarker"; @@ -247,10 +247,10 @@ namespace lvh::detail::windows_broker_service { effective_timestamp - validated_at < maximum_age; } - bool unvalidated_gamepad_creation_allowed( - std::size_t active_licensed_gamepads + bool unvalidated_device_creation_allowed( + std::size_t active_licensed_devices ) { - return active_licensed_gamepads < unvalidated_active_gamepad_limit; + return active_licensed_devices < unvalidated_active_device_limit; } bool license_outage_retention_elapsed( @@ -259,14 +259,14 @@ namespace lvh::detail::windows_broker_service { return elapsed >= license_outage_device_retention; } - bool license_outage_gamepad_should_be_revoked( + bool license_outage_device_should_be_revoked( bool github_actions_evaluation, bool limit_licensed_devices, std::size_t licensed_devices_kept ) { return limit_licensed_devices && !github_actions_evaluation && - licensed_devices_kept >= unvalidated_active_gamepad_limit; + licensed_devices_kept >= unvalidated_active_device_limit; } bool broker_device_should_be_revoked( @@ -1203,10 +1203,10 @@ namespace lvh::detail::windows_broker_service { return false; } - bool create_gamepad( + bool create_device( HANDLE control_handle, - LvhWindowsCreateGamepadRequest request, - LvhWindowsCreateGamepadResponse &response, + LvhWindowsCreateDeviceRequest request, + LvhWindowsCreateDeviceResponse &response, LvhWindowsBrokerStatusCode &status, std::string &message ) const { @@ -1226,26 +1226,26 @@ namespace lvh::detail::windows_broker_service { OVERLAPPED overlapped {}; overlapped.hEvent = operation_event.get(); DWORD bytes_returned = 0; - if (::DeviceIoControl(control_handle, LVH_WINDOWS_IOCTL_CREATE_GAMEPAD, &request, sizeof(request), &response, sizeof(response), nullptr, &overlapped) == FALSE && ::GetLastError() != ERROR_IO_PENDING) { + if (::DeviceIoControl(control_handle, LVH_WINDOWS_IOCTL_CREATE_DEVICE, &request, sizeof(request), &response, sizeof(response), nullptr, &overlapped) == FALSE && ::GetLastError() != ERROR_IO_PENDING) { status = LvhWindowsBrokerStatusCode::backend_failure; - message = "create Windows gamepad: " + windows_error_message(::GetLastError()); + message = "create Windows virtual HID device: " + windows_error_message(::GetLastError()); return false; } if (::GetOverlappedResult(control_handle, &overlapped, &bytes_returned, TRUE) == FALSE) { status = LvhWindowsBrokerStatusCode::backend_failure; - message = "create Windows gamepad: " + windows_error_message(::GetLastError()); + message = "create Windows virtual HID device: " + windows_error_message(::GetLastError()); return false; } if (bytes_returned < sizeof(response)) { status = LvhWindowsBrokerStatusCode::backend_failure; - message = "Windows driver returned a truncated gamepad response"; + message = "Windows driver returned a truncated device response"; return false; } status = broker_status_from_protocol(response.status); if (status != LvhWindowsBrokerStatusCode::success) { - message = "Windows driver rejected gamepad creation"; + message = "Windows driver rejected virtual HID device creation"; return false; } @@ -1360,15 +1360,15 @@ namespace lvh::detail::windows_broker_service { return response; } - LvhWindowsBrokerCreateGamepadResponse handle_create( - const LvhWindowsBrokerCreateGamepadRequest &request, + LvhWindowsBrokerCreateDeviceResponse handle_create( + const LvhWindowsBrokerCreateDeviceRequest &request, DWORD client_process_id ) { - LvhWindowsBrokerCreateGamepadResponse response {}; + LvhWindowsBrokerCreateDeviceResponse response {}; response.version = LVH_WINDOWS_BROKER_PROTOCOL_VERSION; response.size = sizeof(response); - response.gamepad.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; - response.gamepad.size = sizeof(response.gamepad); + response.device.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; + response.device.size = sizeof(response.device); if (!lvh::windows::broker_validation::valid_request(request)) { response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); @@ -1404,7 +1404,7 @@ namespace lvh::detail::windows_broker_service { return response; } - const auto [authorization_status, github_actions_evaluation] = authorize_gamepad_create( + const auto [authorization_status, github_actions_evaluation] = authorize_device_create( response.license, response.message ); @@ -1414,7 +1414,7 @@ namespace lvh::detail::windows_broker_service { } auto status = LvhWindowsBrokerStatusCode::success; - if (std::string message; !driver_.create_gamepad(client_control_handle.get(), request.gamepad, response.gamepad, status, message)) { + if (std::string message; !driver_.create_device(client_control_handle.get(), request.device, response.device, status, message)) { response.status = std::to_underlying(status); fill_license_status(response.license); copy_c_string(response.message, message); @@ -1424,9 +1424,9 @@ namespace lvh::detail::windows_broker_service { { std::lock_guard lock {mutex_}; devices_.try_emplace( - response.gamepad.driver_device_id, + response.device.driver_device_id, DeviceRecord { - .session_token = response.gamepad.session_token, + .session_token = response.device.session_token, .owner_process_id = client_process_id, .owner_process = std::move(owner_process), .github_actions_evaluation = github_actions_evaluation, @@ -1439,8 +1439,8 @@ namespace lvh::detail::windows_broker_service { copy_c_string( response.message, github_actions_evaluation ? - "Created virtual gamepad using the GitHub Actions evaluation window." : - "Created virtual gamepad." + "Created virtual HID device using the GitHub Actions evaluation window." : + "Created virtual HID device." ); return response; } @@ -1531,7 +1531,7 @@ namespace lvh::detail::windows_broker_service { evaluation_expired, revoke_licensed_devices ); - if (const auto over_outage_limit = license_outage_gamepad_should_be_revoked(device.github_actions_evaluation, limit_licensed_devices, licensed_devices_kept); owner_gone || revoke_for_license || over_outage_limit) { + if (const auto over_outage_limit = license_outage_device_should_be_revoked(device.github_actions_evaluation, limit_licensed_devices, licensed_devices_kept); owner_gone || revoke_for_license || over_outage_limit) { cleanup_requests.push_back(make_destroy_device_request(driver_device_id, device.session_token)); continue; } @@ -1559,7 +1559,7 @@ namespace lvh::detail::windows_broker_service { { std::lock_guard lock {mutex_}; - if (revoke_licensed_devices_ && active_licensed_gamepad_count_locked() == 0U) { + if (revoke_licensed_devices_ && active_licensed_device_count_locked() == 0U) { revoke_licensed_devices_ = false; } } @@ -1970,7 +1970,7 @@ namespace lvh::detail::windows_broker_service { } } - std::size_t active_licensed_gamepad_count_locked() const { + std::size_t active_licensed_device_count_locked() const { return static_cast(std::ranges::count_if( devices_, [](const auto &entry) { @@ -2113,7 +2113,7 @@ namespace lvh::detail::windows_broker_service { }; } - std::pair authorize_gamepad_create( + std::pair authorize_device_create( LvhWindowsBrokerLicenseStatus &license, std::array &message ) { @@ -2122,7 +2122,7 @@ namespace lvh::detail::windows_broker_service { if (!license_state_) { if (!github_actions_) { fill_license_status_locked(license); - copy_c_string(message, "An active license is required to create virtual gamepads."); + copy_c_string(message, "An active license is required to create virtual HID devices."); return {LvhWindowsBrokerStatusCode::license_required, false}; } @@ -2169,14 +2169,14 @@ namespace lvh::detail::windows_broker_service { return {LvhWindowsBrokerStatusCode::success, false}; } - if (unvalidated_gamepad_creation_allowed(active_licensed_gamepad_count_locked())) { + if (unvalidated_device_creation_allowed(active_licensed_device_count_locked())) { fill_license_status_locked(license); - copy_c_string(message, "Polar validation is unavailable; one active gamepad is permitted."); + copy_c_string(message, "Polar validation is unavailable; one active device is permitted."); return {LvhWindowsBrokerStatusCode::success, false}; } fill_license_status_locked(license); - copy_c_string(message, "Polar validation is unavailable; the one-gamepad fallback is already in use."); + copy_c_string(message, "Polar validation is unavailable; the one-device fallback is already in use."); return {LvhWindowsBrokerStatusCode::network_unavailable, false}; } } @@ -2194,7 +2194,7 @@ namespace lvh::detail::windows_broker_service { copy_c_string(license.customer_email, ""); if (!github_actions_) { copy_c_string(license.plan_name, "Unlicensed"); - copy_c_string(license.message, "An active license is required to create gamepads."); + copy_c_string(license.message, "An active license is required to create virtual HID devices."); return; } @@ -2202,7 +2202,7 @@ namespace lvh::detail::windows_broker_service { if (!github_actions_evaluation_state_) { copy_c_string( license.message, - "Five-minute GitHub Actions evaluation starts with the first gamepad creation." + "Five-minute GitHub Actions evaluation starts with the first virtual HID device creation." ); return; } @@ -2249,7 +2249,7 @@ namespace lvh::detail::windows_broker_service { license.message, license_online_confirmed_ ? "Licensed." : - "Polar validation unavailable; existing gamepads are retained for one hour and new creation is limited to one active gamepad." + "Polar validation unavailable; existing devices are retained for one hour and new creation is limited to one active device." ); } else { license.state = std::to_underlying(LvhWindowsBrokerLicenseState::invalid); @@ -2355,9 +2355,9 @@ namespace lvh::detail::windows_broker_service { } break; - case LvhWindowsBrokerRequestType::create_gamepad: - if (header.size == sizeof(LvhWindowsBrokerCreateGamepadRequest)) { - const auto request = request_from_buffer(request_buffer); + case LvhWindowsBrokerRequestType::create_device: + if (header.size == sizeof(LvhWindowsBrokerCreateDeviceRequest)) { + const auto request = request_from_buffer(request_buffer); static_cast(send_response( broker_state().handle_create(request, pipe_client_process_id(pipe)) )); diff --git a/src/platform/windows/control_protocol.hpp b/src/platform/windows/control_protocol.hpp index e22a47b..43bf19b 100644 --- a/src/platform/windows/control_protocol.hpp +++ b/src/platform/windows/control_protocol.hpp @@ -73,6 +73,24 @@ namespace lvh::detail::windows { return LVH_WINDOWS_BUS_UNKNOWN; } + inline std::uint32_t protocol_device_type(DeviceType device_type) { + switch (device_type) { + using enum DeviceType; + + case gamepad: + return LVH_WINDOWS_DEVICE_GAMEPAD; + case mouse: + return LVH_WINDOWS_DEVICE_MOUSE; + case keyboard: + case touchscreen: + case trackpad: + case pen_tablet: + break; + } + + return 0U; + } + inline std::uint32_t protocol_gamepad_kind(GamepadProfileKind kind) { switch (kind) { using enum GamepadProfileKind; @@ -146,20 +164,22 @@ namespace lvh::detail::windows { return static_cast(copied); } - inline LvhWindowsCreateGamepadRequest make_create_gamepad_request( + inline LvhWindowsCreateDeviceRequest make_create_device_request( DeviceId device_id, - const CreateGamepadOptions &options + const DeviceProfile &profile, + std::string_view stable_id ) { - auto report_descriptor = options.profile.report_descriptor; - auto input_report_size = options.profile.input_report_size; - auto output_report_size = options.profile.output_report_size; - auto bus_type = options.profile.bus_type; - auto product_id = options.profile.product_id; - auto device_version = options.profile.version; - auto report_id = options.profile.report_id; + auto report_descriptor = profile.report_descriptor; + auto input_report_size = profile.input_report_size; + auto output_report_size = profile.output_report_size; + auto bus_type = profile.bus_type; + auto product_id = profile.product_id; + auto device_version = profile.version; + auto report_id = profile.report_id; if ( - options.profile.gamepad_kind == GamepadProfileKind::generic && - options.profile.capabilities.supports_rumble + profile.device_type == DeviceType::gamepad && + profile.gamepad_kind == GamepadProfileKind::generic && + profile.capabilities.supports_rumble ) { auto pid_descriptor = make_generic_pid_report_descriptor(report_descriptor); if (!pid_descriptor.empty()) { @@ -167,20 +187,21 @@ namespace lvh::detail::windows { output_report_size = generic_pid_output_report_size; } } - if (options.profile.gamepad_kind == GamepadProfileKind::xbox_series) { + if (profile.device_type == DeviceType::gamepad && profile.gamepad_kind == GamepadProfileKind::xbox_series) { input_report_size = xbox_series_windows_input_report_size; output_report_size = xbox_series_windows_output_report_size; product_id = xbox_series_windows_product_id; device_version = xbox_series_windows_device_version; } - LvhWindowsCreateGamepadRequest request {}; + LvhWindowsCreateDeviceRequest request {}; request.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; request.size = sizeof(request); request.client_device_id = device_id; + request.device_type = protocol_device_type(profile.device_type); request.bus_type = protocol_bus_type(bus_type); - request.gamepad_kind = protocol_gamepad_kind(options.profile.gamepad_kind); - request.flags = gamepad_flags(options.profile.capabilities); - request.hardware_ids.vendor_id = options.profile.vendor_id; + request.gamepad_kind = protocol_gamepad_kind(profile.gamepad_kind); + request.flags = profile.device_type == DeviceType::gamepad ? gamepad_flags(profile.capabilities) : 0U; + request.hardware_ids.vendor_id = profile.vendor_id; request.hardware_ids.product_id = product_id; request.hardware_ids.device_version = device_version; request.hardware_ids.report_id = report_id; @@ -192,13 +213,27 @@ namespace lvh::detail::windows { ); request.report_sizes.report_descriptor_size = copy_bytes(request.report_descriptor, report_descriptor); - request.report_sizes.name_size = copy_string(request.name, options.profile.name); - request.report_sizes.manufacturer_size = copy_string(request.manufacturer, options.profile.manufacturer); - request.report_sizes.stable_id_size = copy_string(request.stable_id, options.metadata.stable_id); + request.report_sizes.name_size = copy_string(request.name, profile.name); + request.report_sizes.manufacturer_size = copy_string(request.manufacturer, profile.manufacturer); + request.report_sizes.stable_id_size = copy_string(request.stable_id, stable_id); return request; } + inline LvhWindowsCreateDeviceRequest make_create_device_request( + DeviceId device_id, + const CreateGamepadOptions &options + ) { + return make_create_device_request(device_id, options.profile, options.metadata.stable_id); + } + + inline LvhWindowsCreateDeviceRequest make_create_device_request( + DeviceId device_id, + const CreateMouseOptions &options + ) { + return make_create_device_request(device_id, options.profile, options.stable_id); + } + inline LvhWindowsDestroyDeviceRequest make_destroy_device_request( std::uint64_t driver_device_id, const LvhWindowsSessionToken &session_token diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp index 76e3b84..08b0e51 100644 --- a/src/platform/windows/driver/libvirtualhid_umdf.cpp +++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp @@ -53,6 +53,7 @@ // local includes #include "generic_pid_protocol.hpp" #include "lvh_windows_protocol.h" +#include "mouse_protocol.hpp" #include "playstation_feature_protocol.hpp" #include "rotating_trace_log.hpp" #include "switch_pro_protocol.hpp" @@ -82,15 +83,23 @@ namespace { constexpr auto broker_service_name = L"libvirtualhid_broker"; constexpr auto broker_service_account_name = L"NT SERVICE\\libvirtualhid_broker"; constexpr auto trace_file_name = std::wstring_view {L"libvirtualhid-umdf-driver.log"}; + constexpr auto known_gamepad_flags = + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_RUMBLE | + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_MOTION | + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_TOUCHPAD | + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_RGB_LED | + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_BATTERY | + LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_ADAPTIVE_TRIGGERS; using UniqueServiceHandle = std::unique_ptr< std::remove_pointer_t, decltype(&::CloseServiceHandle)>; struct DeviceRecord { - explicit DeviceRecord(const LvhWindowsCreateGamepadRequest &create_request): + explicit DeviceRecord(const LvhWindowsCreateDeviceRequest &create_request): request {create_request}, pending_input_reports { + create_request.device_type, create_request.gamepad_kind, create_request.bus_type, create_request.hardware_ids.report_id, @@ -103,7 +112,7 @@ namespace { WDFDEVICE owner_device {}; WDFFILEOBJECT owner_file {}; WDFIOTARGET vhf_io_target {}; - LvhWindowsCreateGamepadRequest request {}; + LvhWindowsCreateDeviceRequest request {}; LvhWindowsSessionToken session_token {}; VHFHANDLE vhf_handle {}; std::vector report_descriptor; @@ -564,15 +573,40 @@ namespace { return status; } - bool valid_create_request(const LvhWindowsCreateGamepadRequest &request) { + bool valid_create_request(const LvhWindowsCreateDeviceRequest &request) { const auto descriptor_size = request.report_sizes.report_descriptor_size; const auto input_report_size = request.report_sizes.input_report_size; const auto output_report_size = request.report_sizes.output_report_size; - return valid_header(request.version, request.size, sizeof(request)) && descriptor_size > 0U && - descriptor_size <= LVH_WINDOWS_MAX_REPORT_DESCRIPTOR_SIZE && input_report_size > 0U && - input_report_size <= LVH_WINDOWS_MAX_INPUT_REPORT_SIZE && - output_report_size <= LVH_WINDOWS_MAX_OUTPUT_REPORT_SIZE; + const auto known_device = request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD || + request.device_type == LVH_WINDOWS_DEVICE_MOUSE; + const auto known_bus = request.bus_type == LVH_WINDOWS_BUS_UNKNOWN || + request.bus_type == LVH_WINDOWS_BUS_USB || + request.bus_type == LVH_WINDOWS_BUS_BLUETOOTH; + const auto known_profile = request.gamepad_kind <= LVH_WINDOWS_GAMEPAD_DUALSHOCK4; + const auto valid_mouse_descriptor = + request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD || + (descriptor_size == lvh::detail::windows::mouse_report_descriptor.size() && + std::equal( + lvh::detail::windows::mouse_report_descriptor.begin(), + lvh::detail::windows::mouse_report_descriptor.end(), + request.report_descriptor.begin() + )); + const auto valid_device_fields = request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD || + (request.gamepad_kind == LVH_WINDOWS_GAMEPAD_GENERIC && + request.flags == 0U && + request.hardware_ids.report_id == 0U && + input_report_size == LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE && + output_report_size == 0U); + + return valid_header(request.version, request.size, sizeof(request)) && + request.client_device_id != 0U && known_device && known_bus && known_profile && valid_device_fields && + valid_mouse_descriptor && + (request.flags & ~known_gamepad_flags) == 0U && + std::ranges::all_of(request.hardware_ids.reserved0, [](const auto value) { + return value == 0U; + }) && + descriptor_size > 0U && descriptor_size <= LVH_WINDOWS_MAX_REPORT_DESCRIPTOR_SIZE && input_report_size > 0U && input_report_size <= LVH_WINDOWS_MAX_INPUT_REPORT_SIZE && output_report_size <= LVH_WINDOWS_MAX_OUTPUT_REPORT_SIZE; } bool valid_submit_input_report_request(const LvhWindowsSubmitInputReportRequest &request) { @@ -778,6 +812,10 @@ namespace { const DeviceRecord &record, const LvhWindowsSubmitInputReportRequest &request ) { + if (request.report_size != record.request.report_sizes.input_report_size) { + return {}; + } + const auto report_id = record.request.hardware_ids.report_id; const auto report_begin = request.report.data(); const auto report_end = request.report.data() + request.report_size; @@ -843,7 +881,10 @@ namespace { } auto report = std::optional> {}; - if (record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_GENERIC) { + if ( + record.request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD && + record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_GENERIC + ) { std::lock_guard lock {record.mutex}; report = record.generic_pid_feature_state.get_feature_report(report_number); } else { @@ -862,7 +903,10 @@ namespace { } bool handle_vhf_set_feature(DeviceRecord &record, const HID_XFER_PACKET &packet) { - if (record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_GENERIC) { + if ( + record.request.device_type == LVH_WINDOWS_DEVICE_GAMEPAD && + record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_GENERIC + ) { auto event = make_output_event(record, packet); const auto report_id = packet.reportId == 0U && event.report_size > 0U ? event.report[0] : packet.reportId; std::lock_guard lock {record.mutex}; @@ -875,7 +919,10 @@ namespace { } void handle_vhf_output_report(DeviceRecord &record, const LvhWindowsOutputReportEvent &event) { - if (record.request.gamepad_kind != LVH_WINDOWS_GAMEPAD_GENERIC) { + if ( + record.request.device_type != LVH_WINDOWS_DEVICE_GAMEPAD || + record.request.gamepad_kind != LVH_WINDOWS_GAMEPAD_GENERIC + ) { return; } @@ -909,20 +956,20 @@ namespace { } } - void handle_create_gamepad_request(WDFDEVICE device, WDFREQUEST request) { + void handle_create_device_request(WDFDEVICE device, WDFREQUEST request) { if (!request_is_authorized_broker_service(request)) { complete_request(request, STATUS_ACCESS_DENIED); return; } - auto *create_request = static_cast(nullptr); + auto *create_request = static_cast(nullptr); auto status = retrieve_input_buffer(request, create_request); if (!NT_SUCCESS(status)) { complete_request(request, status); return; } - auto *create_response = static_cast(nullptr); + auto *create_response = static_cast(nullptr); status = retrieve_output_buffer(request, create_response); if (!NT_SUCCESS(status)) { complete_request(request, status); @@ -947,17 +994,17 @@ namespace { record->owner_file = WdfRequestGetFileObject(request); status = generate_session_token(record->session_token); if (!NT_SUCCESS(status)) { - trace_status("create_gamepad token failed", status); + trace_status("create_device token failed", status); create_response->status = LVH_WINDOWS_STATUS_BACKEND_FAILURE; complete_request(request, STATUS_SUCCESS, sizeof(*create_response)); return; } - trace_status("create_gamepad begin"); + trace_status("create_device begin"); status = create_vhf_device(device, record); if (!NT_SUCCESS(status)) { - trace_status("create_gamepad failed", status); + trace_status("create_device failed", status); create_response->status = LVH_WINDOWS_STATUS_BACKEND_FAILURE; complete_request(request, STATUS_SUCCESS, sizeof(*create_response)); return; @@ -971,7 +1018,7 @@ namespace { create_response->driver_device_id = driver_device_id; create_response->session_token = record->session_token; set_device_path(driver_device_id, create_response->device_path); - trace_status("create_gamepad success"); + trace_status("create_device success"); complete_request(request, STATUS_SUCCESS, sizeof(*create_response)); } @@ -1313,8 +1360,8 @@ void LvhEvtIoDeviceControl( UNREFERENCED_PARAMETER(input_buffer_length); switch (io_control_code) { - case LVH_WINDOWS_IOCTL_CREATE_GAMEPAD: - handle_create_gamepad_request(WdfIoQueueGetDevice(queue), request); + case LVH_WINDOWS_IOCTL_CREATE_DEVICE: + handle_create_device_request(WdfIoQueueGetDevice(queue), request); return; case LVH_WINDOWS_IOCTL_DESTROY_DEVICE: diff --git a/src/platform/windows/shared/lvh_windows_broker_protocol.h b/src/platform/windows/shared/lvh_windows_broker_protocol.h index 2f2f5fa..83e7d0d 100644 --- a/src/platform/windows/shared/lvh_windows_broker_protocol.h +++ b/src/platform/windows/shared/lvh_windows_broker_protocol.h @@ -10,7 +10,7 @@ #include #include -inline constexpr uint32_t LVH_WINDOWS_BROKER_PROTOCOL_VERSION = 3u; +inline constexpr uint32_t LVH_WINDOWS_BROKER_PROTOCOL_VERSION = 4u; inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE = 512u; inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_LICENSE_KEY_SIZE = 128u; inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_INSTANCE_NAME_SIZE = 128u; @@ -20,7 +20,7 @@ inline constexpr char LVH_WINDOWS_BROKER_PIPE_PATH[] = R"(\\.\pipe\libvirtualhid enum class LvhWindowsBrokerRequestType : uint32_t { status = 1, - create_gamepad = 2, + create_device = 2, destroy_device = 3, activate_license = 4, validate_license = 5, @@ -81,18 +81,18 @@ struct LvhWindowsBrokerStatusResponse { std::array message; }; -struct LvhWindowsBrokerCreateGamepadRequest { +struct LvhWindowsBrokerCreateDeviceRequest { LvhWindowsBrokerRequestHeader header; uint64_t client_control_handle; - LvhWindowsCreateGamepadRequest gamepad; + LvhWindowsCreateDeviceRequest device; }; -struct LvhWindowsBrokerCreateGamepadResponse { +struct LvhWindowsBrokerCreateDeviceResponse { uint32_t version; uint32_t size; uint32_t status; uint32_t reserved0; - LvhWindowsCreateGamepadResponse gamepad; + LvhWindowsCreateDeviceResponse device; LvhWindowsBrokerLicenseStatus license; std::array message; }; diff --git a/src/platform/windows/shared/lvh_windows_protocol.h b/src/platform/windows/shared/lvh_windows_protocol.h index f2a826b..a65edc2 100644 --- a/src/platform/windows/shared/lvh_windows_protocol.h +++ b/src/platform/windows/shared/lvh_windows_protocol.h @@ -8,7 +8,7 @@ #include #include -inline constexpr uint32_t LVH_WINDOWS_CONTROL_PROTOCOL_VERSION = 2u; +inline constexpr uint32_t LVH_WINDOWS_CONTROL_PROTOCOL_VERSION = 3u; inline constexpr char LVH_WINDOWS_CONTROL_DEVICE_PATH[] = R"(\\.\LibVirtualHid)"; inline constexpr char LVH_WINDOWS_GLOBAL_CONTROL_DEVICE_PATH[] = R"(\\.\Global\LibVirtualHid)"; @@ -35,7 +35,7 @@ constexpr uint32_t lvh_windows_ctl_code( return (device_type << 16u) | (access << 14u) | (function_code << 2u) | method; } -inline constexpr uint32_t LVH_WINDOWS_IOCTL_CREATE_GAMEPAD = lvh_windows_ctl_code( +inline constexpr uint32_t LVH_WINDOWS_IOCTL_CREATE_DEVICE = lvh_windows_ctl_code( LVH_WINDOWS_FILE_DEVICE_LIBVIRTUALHID, 0x800u, LVH_WINDOWS_METHOD_BUFFERED, @@ -87,6 +87,11 @@ enum class LvhWindowsBusType : uint32_t { bluetooth = 2, }; +enum class LvhWindowsDeviceType : uint32_t { + gamepad = 1, + mouse = 2, +}; + enum class LvhWindowsGamepadProfileKind : uint32_t { generic = 0, xbox_360 = 1, @@ -114,6 +119,10 @@ namespace lvh_windows_protocol_detail { inline constexpr uint32_t bus_usb = to_uint32(usb); inline constexpr uint32_t bus_bluetooth = to_uint32(bluetooth); + using enum LvhWindowsDeviceType; + inline constexpr uint32_t device_gamepad = to_uint32(gamepad); + inline constexpr uint32_t device_mouse = to_uint32(mouse); + using enum LvhWindowsGamepadProfileKind; inline constexpr uint32_t gamepad_generic = to_uint32(generic); inline constexpr uint32_t gamepad_xbox_360 = to_uint32(xbox_360); @@ -136,6 +145,11 @@ inline constexpr uint32_t LVH_WINDOWS_BUS_UNKNOWN = lvh_windows_protocol_detail: inline constexpr uint32_t LVH_WINDOWS_BUS_USB = lvh_windows_protocol_detail::bus_usb; inline constexpr uint32_t LVH_WINDOWS_BUS_BLUETOOTH = lvh_windows_protocol_detail::bus_bluetooth; +inline constexpr uint32_t LVH_WINDOWS_DEVICE_GAMEPAD = lvh_windows_protocol_detail::device_gamepad; +inline constexpr uint32_t LVH_WINDOWS_DEVICE_MOUSE = lvh_windows_protocol_detail::device_mouse; + +inline constexpr uint32_t LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE = 7u; + inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_GENERIC = lvh_windows_protocol_detail::gamepad_generic; inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_XBOX_360 = lvh_windows_protocol_detail::gamepad_xbox_360; inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_XBOX_ONE = lvh_windows_protocol_detail::gamepad_xbox_one; @@ -147,7 +161,7 @@ inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_DUALSHOCK4 = #pragma pack(push, 1) -struct LvhWindowsGamepadHardwareIds { +struct LvhWindowsDeviceHardwareIds { uint16_t vendor_id; uint16_t product_id; uint16_t device_version; @@ -155,7 +169,7 @@ struct LvhWindowsGamepadHardwareIds { std::array reserved0; }; -struct LvhWindowsGamepadReportSizes { +struct LvhWindowsDeviceReportSizes { uint32_t input_report_size; uint32_t output_report_size; uint32_t report_descriptor_size; @@ -168,22 +182,23 @@ struct LvhWindowsSessionToken { std::array bytes; }; -struct LvhWindowsCreateGamepadRequest { +struct LvhWindowsCreateDeviceRequest { uint32_t version; uint32_t size; uint64_t client_device_id; + uint32_t device_type; uint32_t bus_type; uint32_t gamepad_kind; uint32_t flags; - LvhWindowsGamepadHardwareIds hardware_ids; - LvhWindowsGamepadReportSizes report_sizes; + LvhWindowsDeviceHardwareIds hardware_ids; + LvhWindowsDeviceReportSizes report_sizes; std::array report_descriptor; std::array name; std::array manufacturer; std::array stable_id; }; -struct LvhWindowsCreateGamepadResponse { +struct LvhWindowsCreateDeviceResponse { uint32_t version; uint32_t size; uint32_t status; diff --git a/src/platform/windows/shared/mouse_protocol.hpp b/src/platform/windows/shared/mouse_protocol.hpp new file mode 100644 index 0000000..b558240 --- /dev/null +++ b/src/platform/windows/shared/mouse_protocol.hpp @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +/** + * @file src/platform/windows/shared/mouse_protocol.hpp + * @brief Canonical report framing for Windows driver-backed mice. + */ +#pragma once + +// standard includes +#include +#include + +namespace lvh::detail::windows { + + /** + * @brief Five-button relative mouse descriptor used by the Windows VHF path. + * + * The matching input report has no report-ID prefix and contains a button + * byte, signed 16-bit X/Y, signed 8-bit wheel, and signed 8-bit AC Pan. + */ + inline constexpr std::array mouse_report_descriptor { + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x02, // Usage (Mouse) + 0xA1, + 0x01, // Collection (Application) + 0x09, + 0x01, // Usage (Pointer) + 0xA1, + 0x00, // Collection (Physical) + 0x05, + 0x09, // Usage Page (Button) + 0x19, + 0x01, // Usage Minimum (Button 1) + 0x29, + 0x05, // Usage Maximum (Button 5) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x05, // Report Count (5) + 0x81, + 0x02, // Input (Data,Var,Abs) + 0x75, + 0x03, // Report Size (3) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Cnst,Var,Abs) - padding + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x16, + 0x00, + 0x80, // Logical Minimum (-32768) + 0x26, + 0xFF, + 0x7F, // Logical Maximum (32767) + 0x75, + 0x10, // Report Size (16) + 0x95, + 0x02, // Report Count (2) + 0x81, + 0x06, // Input (Data,Var,Rel) + 0x09, + 0x38, // Usage (Wheel) + 0x15, + 0x81, // Logical Minimum (-127) + 0x25, + 0x7F, // Logical Maximum (127) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x06, // Input (Data,Var,Rel) + 0x05, + 0x0C, // Usage Page (Consumer) + 0x0A, + 0x38, + 0x02, // Usage (AC Pan) + 0x15, + 0x81, // Logical Minimum (-127) + 0x25, + 0x7F, // Logical Maximum (127) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x06, // Input (Data,Var,Rel) + 0xC0, // End Collection + 0xC0, // End Collection + }; + +} // namespace lvh::detail::windows diff --git a/src/platform/windows/shared/playstation_feature_protocol.hpp b/src/platform/windows/shared/playstation_feature_protocol.hpp index c774e7b..2cdab90 100644 --- a/src/platform/windows/shared/playstation_feature_protocol.hpp +++ b/src/platform/windows/shared/playstation_feature_protocol.hpp @@ -78,7 +78,7 @@ namespace lvh::detail::windows { }; } - inline std::array request_mac_address(const LvhWindowsCreateGamepadRequest &request) { + inline std::array request_mac_address(const LvhWindowsCreateDeviceRequest &request) { const auto size = std::min(request.report_sizes.stable_id_size, sizeof(request.stable_id)); const auto stable_id = std::string_view {request.stable_id.data(), size}; return parse_mac_address(stable_id).value_or(generated_mac_address(request.client_device_id)); @@ -88,7 +88,7 @@ namespace lvh::detail::windows { return {payload.begin(), payload.end()}; } - inline void set_pairing_mac(std::vector &report, const LvhWindowsCreateGamepadRequest &request) { + inline void set_pairing_mac(std::vector &report, const LvhWindowsCreateDeviceRequest &request) { const auto mac = request_mac_address(request); std::copy(mac.rbegin(), mac.rend(), report.begin() + 1); } @@ -109,7 +109,7 @@ namespace lvh::detail::windows { } inline std::optional> make_playstation_feature_report( - const LvhWindowsCreateGamepadRequest &request, + const LvhWindowsCreateDeviceRequest &request, std::uint8_t report_number ) { using namespace playstation_feature_protocol_detail; diff --git a/src/platform/windows/shared/vhf_input_report_queue.hpp b/src/platform/windows/shared/vhf_input_report_queue.hpp index ee3d48e..50f7f06 100644 --- a/src/platform/windows/shared/vhf_input_report_queue.hpp +++ b/src/platform/windows/shared/vhf_input_report_queue.hpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -28,32 +29,46 @@ namespace lvh::detail::windows { inline constexpr std::size_t vhf_max_pending_input_reports = 32U; /** - * @brief A bounded queue that replaces superseded continuous gamepad states. + * @brief A bounded queue for state-based gamepads and relative mouse input. * * Axis, trigger, motion, battery, and touch-position changes can safely replace * the newest pending report when the report's discrete state is unchanged. * Button, D-pad, trigger-threshold, report-ID, and touch-contact lifecycle * transitions remain ordered so short presses and contacts are not silently - * coalesced away. + * coalesced away. Relative mouse reports are accumulated while their button + * state is unchanged so motion and scrolling are not discarded. */ class VhfInputReportQueue { public: - VhfInputReportQueue(std::uint32_t gamepad_kind, std::uint32_t bus_type, std::uint8_t input_report_id): + VhfInputReportQueue( + std::uint32_t device_type, + std::uint32_t gamepad_kind, + std::uint32_t bus_type, + std::uint8_t input_report_id + ): + device_type_ {device_type}, gamepad_kind_ {gamepad_kind}, bus_type_ {bus_type}, input_report_id_ {input_report_id} { } void push(std::vector report) { + if (device_type_ == LVH_WINDOWS_DEVICE_MOUSE && report.size() == LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE) { + push_mouse_report(report); + return; + } + if (is_protocol_report(report)) { make_room(); protocol_reports_.push_back(std::move(report)); return; } - if (!reports_.empty() && same_discrete_state(reports_.back(), report)) { - reports_.back() = std::move(report); - return; + if (!reports_.empty()) { + if (device_type_ == LVH_WINDOWS_DEVICE_GAMEPAD && same_discrete_state(reports_.back(), report)) { + reports_.back() = std::move(report); + return; + } } make_room(); @@ -66,6 +81,9 @@ namespace lvh::detail::windows { protocol_reports_.pop_front(); return report; } + if (!mouse_reports_.empty()) { + return pop_mouse_report(); + } if (reports_.empty()) { return std::nullopt; } @@ -77,20 +95,24 @@ namespace lvh::detail::windows { void clear() { protocol_reports_.clear(); + mouse_reports_.clear(); reports_.clear(); } [[nodiscard]] bool empty() const noexcept { - return protocol_reports_.empty() && reports_.empty(); + return protocol_reports_.empty() && mouse_reports_.empty() && reports_.empty(); } [[nodiscard]] std::size_t size() const noexcept { - return protocol_reports_.size() + reports_.size(); + return protocol_reports_.size() + mouse_reports_.size() + reports_.size(); } private: [[nodiscard]] bool is_protocol_report(const std::vector &report) const noexcept { - return input_report_id_ != 0U && !report.empty() && report.front() != input_report_id_; + return device_type_ == LVH_WINDOWS_DEVICE_GAMEPAD && + input_report_id_ != 0U && + !report.empty() && + report.front() != input_report_id_; } void make_room() { @@ -99,6 +121,8 @@ namespace lvh::detail::windows { } if (!reports_.empty()) { reports_.pop_front(); + } else if (!mouse_reports_.empty()) { + mouse_reports_.pop_front(); } else { protocol_reports_.pop_front(); } @@ -114,6 +138,83 @@ namespace lvh::detail::windows { }); } + static std::int32_t mouse_i16(const std::vector &report, std::size_t offset) { + const auto value = static_cast(report[offset]) | + (static_cast(report[offset + 1U]) << 8U); + return value >= 0x8000U ? static_cast(value) - 0x10000 : static_cast(value); + } + + static std::int32_t mouse_i8(const std::vector &report, std::size_t offset) { + const auto value = static_cast(report[offset]); + return value >= 0x80U ? static_cast(value) - 0x100 : static_cast(value); + } + + static void set_mouse_i16(std::vector &report, std::size_t offset, std::int64_t value) { + const auto encoded = static_cast(static_cast(value)); + report[offset] = static_cast(encoded & 0xFFU); + report[offset + 1U] = static_cast((encoded >> 8U) & 0xFFU); + } + + struct PendingMouseReport { + std::uint8_t buttons = 0U; + std::int64_t x = 0; + std::int64_t y = 0; + std::int64_t wheel = 0; + std::int64_t pan = 0; + }; + + void push_mouse_report(const std::vector &report) { + if (!mouse_reports_.empty() && mouse_reports_.back().buttons == report[0]) { + auto &pending = mouse_reports_.back(); + pending.x += mouse_i16(report, 1U); + pending.y += mouse_i16(report, 3U); + pending.wheel += mouse_i8(report, 5U); + pending.pan += mouse_i8(report, 6U); + return; + } + + make_room(); + mouse_reports_.push_back(PendingMouseReport { + .buttons = report[0], + .x = mouse_i16(report, 1U), + .y = mouse_i16(report, 3U), + .wheel = mouse_i8(report, 5U), + .pan = mouse_i8(report, 6U), + }); + } + + std::vector pop_mouse_report() { + auto &pending = mouse_reports_.front(); + const auto x = std::clamp( + pending.x, + std::numeric_limits::min(), + std::numeric_limits::max() + ); + const auto y = std::clamp( + pending.y, + std::numeric_limits::min(), + std::numeric_limits::max() + ); + const auto wheel = std::clamp(pending.wheel, -127, 127); + const auto pan = std::clamp(pending.pan, -127, 127); + + std::vector report(LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE, 0U); + report[0] = pending.buttons; + set_mouse_i16(report, 1U, x); + set_mouse_i16(report, 3U, y); + report[5] = static_cast(static_cast(wheel)); + report[6] = static_cast(static_cast(pan)); + + pending.x -= x; + pending.y -= y; + pending.wheel -= wheel; + pending.pan -= pan; + if (pending.x == 0 && pending.y == 0 && pending.wheel == 0 && pending.pan == 0) { + mouse_reports_.pop_front(); + } + return report; + } + [[nodiscard]] bool same_discrete_state( const std::vector &left, const std::vector &right @@ -173,10 +274,12 @@ namespace lvh::detail::windows { return false; } + std::uint32_t device_type_; std::uint32_t gamepad_kind_; std::uint32_t bus_type_; std::uint8_t input_report_id_; std::deque> protocol_reports_; + std::deque mouse_reports_; std::deque> reports_; }; diff --git a/src/platform/windows/shared/windows_device_identity.hpp b/src/platform/windows/shared/windows_device_identity.hpp index 0b13b3b..b805b26 100644 --- a/src/platform/windows/shared/windows_device_identity.hpp +++ b/src/platform/windows/shared/windows_device_identity.hpp @@ -67,7 +67,7 @@ namespace lvh::detail::windows { return is_xbox_gamepad(gamepad_kind); } - inline std::wstring make_hardware_ids(const LvhWindowsCreateGamepadRequest &request) { + inline std::wstring make_hardware_ids(const LvhWindowsCreateDeviceRequest &request) { const auto &ids = request.hardware_ids; std::wstring hardware_ids; if (uses_xinputhid_match_id(request.gamepad_kind)) { diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index 0735d68..f453f8a 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -30,6 +30,7 @@ #include "platform/windows/control_protocol.hpp" #include "platform/windows/keylayout.hpp" #include "platform/windows/shared/generic_pid_rumble.hpp" +#include "platform/windows/shared/mouse_protocol.hpp" #include "platform/windows/windows_broker_client.hpp" #include @@ -528,6 +529,21 @@ namespace lvh::detail { return OperationStatus::success(); } + bool mouse_driver_fallback_allowed(ErrorCode code) { + switch (code) { + using enum ErrorCode; + + case backend_unavailable: + case license_required: + case license_invalid: + case activation_limit_reached: + case network_unavailable: + return true; + default: + return false; + } + } + class WindowsControlChannel { public: WindowsControlChannel(const WindowsControlChannel &) = delete; @@ -543,9 +559,9 @@ namespace lvh::detail { return nullptr; } - virtual OperationStatus create_gamepad( - const LvhWindowsCreateGamepadRequest &request, - LvhWindowsCreateGamepadResponse &response + virtual OperationStatus create_device( + const LvhWindowsCreateDeviceRequest &request, + LvhWindowsCreateDeviceResponse &response ) const = 0; virtual OperationStatus destroy_device( @@ -606,23 +622,23 @@ namespace lvh::detail { return handle_->value.get(); } - OperationStatus create_gamepad( - const LvhWindowsCreateGamepadRequest &request, - LvhWindowsCreateGamepadResponse &response + OperationStatus create_device( + const LvhWindowsCreateDeviceRequest &request, + LvhWindowsCreateDeviceResponse &response ) const override { using enum ErrorCode; auto request_copy = request; DWORD bytes_returned = 0; - if (const auto status = device_io_control(LVH_WINDOWS_IOCTL_CREATE_GAMEPAD, request_copy, response, &bytes_returned, "create Windows gamepad"); !status.ok()) { + if (const auto status = device_io_control(LVH_WINDOWS_IOCTL_CREATE_DEVICE, request_copy, response, &bytes_returned, "create Windows virtual HID device"); !status.ok()) { return status; } if (bytes_returned < sizeof(response)) { - return OperationStatus::failure(backend_failure, "Windows driver returned a truncated gamepad response"); + return OperationStatus::failure(backend_failure, "Windows driver returned a truncated device response"); } - return protocol_status(response.status, "Windows driver rejected gamepad creation"); + return protocol_status(response.status, "Windows driver rejected virtual HID device creation"); } OperationStatus destroy_device( @@ -831,27 +847,27 @@ namespace lvh::detail { return direct_channel_->native_handle(); } - OperationStatus create_gamepad( - const LvhWindowsCreateGamepadRequest &request, - LvhWindowsCreateGamepadResponse &response + OperationStatus create_device( + const LvhWindowsCreateDeviceRequest &request, + LvhWindowsCreateDeviceResponse &response ) const override { - LvhWindowsBrokerCreateGamepadRequest broker_request {}; + LvhWindowsBrokerCreateDeviceRequest broker_request {}; broker_request.header = windows_broker::make_request_header( - LvhWindowsBrokerRequestType::create_gamepad, + LvhWindowsBrokerRequestType::create_device, sizeof(broker_request) ); broker_request.client_control_handle = static_cast( reinterpret_cast(direct_channel_->native_handle()) ); - broker_request.gamepad = request; + broker_request.device = request; - LvhWindowsBrokerCreateGamepadResponse broker_response {}; - if (const auto status = windows_broker::call(broker_request, broker_response, "create Windows gamepad through broker"); !status.ok()) { + LvhWindowsBrokerCreateDeviceResponse broker_response {}; + if (const auto status = windows_broker::call(broker_request, broker_response, "create Windows virtual HID device through broker"); !status.ok()) { return status; } - response = broker_response.gamepad; - return protocol_status(response.status, "Windows driver rejected gamepad creation"); + response = broker_response.device; + return protocol_status(response.status, "Windows driver rejected virtual HID device creation"); } OperationStatus destroy_device( @@ -902,9 +918,9 @@ namespace lvh::detail { return channels; } - class WindowsGamepadState { + class WindowsVhfDeviceState { public: - WindowsGamepadState( + WindowsVhfDeviceState( DeviceId client_device_id, std::uint64_t driver_device_id, const LvhWindowsSessionToken &session_token, @@ -941,7 +957,7 @@ namespace lvh::detail { class WindowsGamepad final: public BackendGamepad { public: - WindowsGamepad(std::shared_ptr context, std::shared_ptr state): + WindowsGamepad(std::shared_ptr context, std::shared_ptr state): context_ {std::move(context)}, state_ {std::move(state)} {} @@ -955,7 +971,7 @@ namespace lvh::detail { private: std::shared_ptr context_; - std::shared_ptr state_; + std::shared_ptr state_; }; class WindowsBackendContext: public std::enable_shared_from_this { @@ -1013,16 +1029,16 @@ namespace lvh::detail { } BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) { - auto request = windows::make_create_gamepad_request(id, options); - LvhWindowsCreateGamepadResponse response {}; + auto request = windows::make_create_device_request(id, options); + LvhWindowsCreateDeviceResponse response {}; response.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; response.size = sizeof(response); - if (const auto status = command_channel_->create_gamepad(request, response); !status.ok()) { + if (const auto status = command_channel_->create_device(request, response); !status.ok()) { return {status, nullptr}; } - auto state = std::make_shared( + auto state = std::make_shared( id, response.driver_device_id, response.session_token, @@ -1032,7 +1048,7 @@ namespace lvh::detail { { std::lock_guard lock {devices_mutex_}; - gamepads_[state->driver_id] = state; + devices_[state->driver_id] = state; } notify_pid_timer(); @@ -1052,8 +1068,8 @@ namespace lvh::detail { */ BackendMouseCreationResult create_hid_mouse(DeviceId id, const CreateMouseOptions &options); - OperationStatus submit_gamepad_report( - const std::shared_ptr &state, + OperationStatus submit_device_report( + const std::shared_ptr &state, const std::vector &report ) const { const auto driver_id = state->driver_id; @@ -1061,7 +1077,7 @@ namespace lvh::detail { return command_channel_->submit_input_report(driver_id, token, report); } - OperationStatus close_gamepad(const std::shared_ptr &state) { + OperationStatus close_device(const std::shared_ptr &state) { std::uint64_t driver_id = 0; LvhWindowsSessionToken token {}; { @@ -1077,7 +1093,7 @@ namespace lvh::detail { { std::lock_guard lock {devices_mutex_}; - gamepads_.erase(driver_id); + devices_.erase(driver_id); } notify_pid_timer(); @@ -1116,11 +1132,11 @@ namespace lvh::detail { } std::optional dispatch_pid_transitions() { - std::vector> states; + std::vector> states; { std::lock_guard lock {devices_mutex_}; - states.reserve(gamepads_.size()); - for (const auto &[driver_id, weak_state] : gamepads_) { + states.reserve(devices_.size()); + for (const auto &[driver_id, weak_state] : devices_) { static_cast(driver_id); if (auto state = weak_state.lock(); state) { states.push_back(std::move(state)); @@ -1167,10 +1183,10 @@ namespace lvh::detail { } void dispatch_output_report(const LvhWindowsOutputReportEvent &event) { - std::shared_ptr state; + std::shared_ptr state; { std::lock_guard lock {devices_mutex_}; - if (const auto iter = gamepads_.find(event.driver_device_id); iter != gamepads_.end()) { + if (const auto iter = devices_.find(event.driver_device_id); iter != devices_.end()) { state = iter->second.lock(); } } @@ -1228,7 +1244,7 @@ namespace lvh::detail { std::mutex pid_timer_mutex_; std::atomic_uint64_t pid_timer_generation_ = 0; std::mutex devices_mutex_; - std::map> gamepads_; + std::map> devices_; }; OperationStatus WindowsGamepad::submit( @@ -1248,11 +1264,11 @@ namespace lvh::detail { } if (state_->uses_generic_pid) { - return context_->submit_gamepad_report(state_, windows::make_generic_windows_input_report(report)); + return context_->submit_device_report(state_, windows::make_generic_windows_input_report(report)); } } - return context_->submit_gamepad_report(state_, report); + return context_->submit_device_report(state_, report); } void WindowsGamepad::set_output_callback(OutputCallback callback) { @@ -1270,7 +1286,7 @@ namespace lvh::detail { } OperationStatus WindowsGamepad::close() { - return context_->close_gamepad(state_); + return context_->close_device(state_); } class WindowsKeyboard final: public BackendKeyboard { @@ -1443,77 +1459,27 @@ namespace lvh::detail { * @return Report descriptor bytes. */ std::vector make_mouse_report_descriptor() { - return { - 0x05, 0x01, // Usage Page (Generic Desktop) - 0x09, 0x02, // Usage (Mouse) - 0xA1, 0x01, // Collection (Application) - 0x09, 0x01, // Usage (Pointer) - 0xA1, 0x00, // Collection (Physical) - 0x05, 0x09, // Usage Page (Button) - 0x19, 0x01, // Usage Minimum (Button 1) - 0x29, 0x05, // Usage Maximum (Button 5) - 0x15, 0x00, // Logical Minimum (0) - 0x25, 0x01, // Logical Maximum (1) - 0x75, 0x01, // Report Size (1) - 0x95, 0x05, // Report Count (5) - 0x81, 0x02, // Input (Data,Var,Abs) - 0x75, 0x03, // Report Size (3) - 0x95, 0x01, // Report Count (1) - 0x81, 0x03, // Input (Cnst,Var,Abs) - padding - 0x05, 0x01, // Usage Page (Generic Desktop) - 0x09, 0x30, // Usage (X) - 0x09, 0x31, // Usage (Y) - 0x16, 0x00, 0x80, // Logical Minimum (-32768) - 0x26, 0xFF, 0x7F, // Logical Maximum (32767) - 0x75, 0x10, // Report Size (16) - 0x95, 0x02, // Report Count (2) - 0x81, 0x06, // Input (Data,Var,Rel) - 0x09, 0x38, // Usage (Wheel) - 0x15, 0x81, // Logical Minimum (-127) - 0x25, 0x7F, // Logical Maximum (127) - 0x75, 0x08, // Report Size (8) - 0x95, 0x01, // Report Count (1) - 0x81, 0x06, // Input (Data,Var,Rel) - 0x05, 0x0C, // Usage Page (Consumer) - 0x0A, 0x38, 0x02, // Usage (AC Pan) - 0x15, 0x81, // Logical Minimum (-127) - 0x25, 0x7F, // Logical Maximum (127) - 0x75, 0x08, // Report Size (8) - 0x95, 0x01, // Report Count (1) - 0x81, 0x06, // Input (Data,Var,Rel) - 0xC0, // End Collection - 0xC0, // End Collection - }; + return {windows::mouse_report_descriptor.begin(), windows::mouse_report_descriptor.end()}; } - /** - * @brief Size of the input report emitted by `WindowsHidMouse`. - */ - constexpr std::size_t mouse_input_report_size = 7U; - /** * @brief High-resolution scroll units that make up a single wheel detent. */ - constexpr int mouse_scroll_units_per_detent = 120; + constexpr std::int64_t mouse_scroll_units_per_detent = 120; /** * @brief Device profile describing the driver-backed mouse. * - * @return Mouse device profile. + * @param requested Caller-supplied profile whose public identity is kept. + * @return Effective mouse device profile. */ - DeviceProfile make_hid_mouse_profile() { - DeviceProfile profile; + DeviceProfile make_hid_mouse_profile(const DeviceProfile &requested) { + auto profile = requested; profile.device_type = DeviceType::mouse; profile.gamepad_kind = GamepadProfileKind::generic; - profile.bus_type = BusType::usb; - profile.vendor_id = 0x1209; - profile.product_id = 0x0003; - profile.version = 0x0001; profile.report_id = 0; - profile.name = "libvirtualhid Mouse"; - profile.manufacturer = "LizardByte"; profile.report_descriptor = make_mouse_report_descriptor(); - profile.input_report_size = mouse_input_report_size; + profile.input_report_size = LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE; profile.output_report_size = 0; profile.capabilities = {}; return profile; @@ -1559,7 +1525,7 @@ namespace lvh::detail { public: WindowsHidMouse( std::shared_ptr context, - std::shared_ptr state + std::shared_ptr state ): context_ {std::move(context)}, state_ {std::move(state)} {} @@ -1575,16 +1541,16 @@ namespace lvh::detail { using enum MouseEventKind; case relative_motion: - return emit(clamp_axis(event.x), clamp_axis(event.y), 0, 0); + return submit_relative_motion(event.x, event.y); case absolute_motion: // Relative HID reports cannot express absolute positioning. return fallback_.submit(event); case button: return submit_button(event); case vertical_scroll: - return emit(0, 0, accumulate_scroll(vertical_scroll_remainder_, event.high_resolution_scroll), 0); + return submit_scroll(vertical_scroll_remainder_, event.high_resolution_scroll, true); case horizontal_scroll: - return emit(0, 0, 0, accumulate_scroll(horizontal_scroll_remainder_, event.high_resolution_scroll)); + return submit_scroll(horizontal_scroll_remainder_, event.high_resolution_scroll, false); } return OperationStatus::success(); @@ -1600,24 +1566,36 @@ namespace lvh::detail { } open_ = false; - return context_->close_gamepad(state_); + return context_->close_device(state_); } private: /** - * @brief Clamp a motion delta into the descriptor's 16-bit range. + * @brief Submit every part of a relative motion delta without clipping. * - * @param value Incoming delta. - * @return Clamped delta. + * @param x Incoming horizontal delta. + * @param y Incoming vertical delta. + * @return Submission status. */ - static std::int16_t clamp_axis(std::int32_t value) { - return static_cast( - std::clamp( - value, + OperationStatus submit_relative_motion(std::int32_t x, std::int32_t y) { + while (x != 0 || y != 0) { + const auto report_x = static_cast(std::clamp( + x, static_cast(std::numeric_limits::min()), static_cast(std::numeric_limits::max()) - ) - ); + )); + const auto report_y = static_cast(std::clamp( + y, + static_cast(std::numeric_limits::min()), + static_cast(std::numeric_limits::max()) + )); + if (const auto status = emit(report_x, report_y, 0, 0); !status.ok()) { + return status; + } + x -= report_x; + y -= report_y; + } + return OperationStatus::success(); } /** @@ -1628,13 +1606,27 @@ namespace lvh::detail { * * @param remainder Running sub-detent remainder for this axis. * @param high_resolution_scroll Incoming high-resolution distance. - * @return Whole detents to report, clamped to the descriptor range. + * @param vertical Whether to emit wheel rather than pan reports. + * @return Submission status. */ - static std::int8_t accumulate_scroll(int &remainder, int high_resolution_scroll) { - remainder += high_resolution_scroll; - const auto detents = remainder / mouse_scroll_units_per_detent; - remainder -= detents * mouse_scroll_units_per_detent; - return static_cast(std::clamp(detents, -127, 127)); + OperationStatus submit_scroll( + std::int64_t &remainder, + std::int32_t high_resolution_scroll, + bool vertical + ) { + const auto total = remainder + high_resolution_scroll; + auto detents = total / mouse_scroll_units_per_detent; + remainder = total % mouse_scroll_units_per_detent; + + while (detents != 0) { + const auto report_detents = static_cast(std::clamp(detents, -127, 127)); + const auto status = vertical ? emit(0, 0, report_detents, 0) : emit(0, 0, 0, report_detents); + if (!status.ok()) { + return status; + } + detents -= report_detents; + } + return OperationStatus::success(); } /** @@ -1665,7 +1657,7 @@ namespace lvh::detail { * @return Submission status. */ OperationStatus emit(std::int16_t x, std::int16_t y, std::int8_t wheel, std::int8_t pan) { - std::vector report(mouse_input_report_size, 0U); + std::vector report(LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE, 0U); report[0] = std::to_integer(buttons_); report[1] = static_cast(static_cast(x) & 0xFFU); report[2] = static_cast((static_cast(x) >> 8U) & 0xFFU); @@ -1673,15 +1665,15 @@ namespace lvh::detail { report[4] = static_cast((static_cast(y) >> 8U) & 0xFFU); report[5] = static_cast(wheel); report[6] = static_cast(pan); - return context_->submit_gamepad_report(state_, report); + return context_->submit_device_report(state_, report); } std::shared_ptr context_; - std::shared_ptr state_; + std::shared_ptr state_; WindowsMouse fallback_; std::byte buttons_ {}; - int vertical_scroll_remainder_ = 0; - int horizontal_scroll_remainder_ = 0; + std::int64_t vertical_scroll_remainder_ = 0; + std::int64_t horizontal_scroll_remainder_ = 0; bool open_ = true; }; @@ -1689,30 +1681,29 @@ namespace lvh::detail { DeviceId id, const CreateMouseOptions &options ) { - CreateGamepadOptions gamepad_options; - gamepad_options.profile = make_hid_mouse_profile(); - gamepad_options.metadata.stable_id = options.stable_id; + auto effective_options = options; + effective_options.profile = make_hid_mouse_profile(options.profile); - auto request = windows::make_create_gamepad_request(id, gamepad_options); - LvhWindowsCreateGamepadResponse response {}; + auto request = windows::make_create_device_request(id, effective_options); + LvhWindowsCreateDeviceResponse response {}; response.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; response.size = sizeof(response); - if (const auto status = command_channel_->create_gamepad(request, response); !status.ok()) { + if (const auto status = command_channel_->create_device(request, response); !status.ok()) { return {status, nullptr}; } - auto state = std::make_shared( + auto state = std::make_shared( id, response.driver_device_id, response.session_token, - gamepad_options.profile, + effective_options.profile, response.device_path[0] == '\0' ? command_channel_->path() : std::string {response.device_path.data()} ); { std::lock_guard lock {devices_mutex_}; - gamepads_[state->driver_id] = state; + devices_[state->driver_id] = state; } notify_pid_timer(); @@ -2337,14 +2328,18 @@ namespace lvh::detail { return {unsupported_profile_status("Windows mouse backend requires a mouse profile"), nullptr}; } - // A driver-backed HID mouse is visible to the Raw Input API, which the - // Win32 injection fallback below is not. Fall back whenever the driver - // is unavailable so mouse input keeps working without it. + // A driver-backed HID mouse is visible to Raw Input and requires the + // same machine license as every broker-created driver device. Preserve + // the SendInput behavior only when that licensed path is unavailable; + // protocol and driver failures must remain visible to the caller. if (context_) { auto hid = context_->create_hid_mouse(id, options); if (hid.mouse) { return hid; } + if (!mouse_driver_fallback_allowed(hid.status.code())) { + return hid; + } } return {OperationStatus::success(), std::make_unique()}; diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp index f5976cf..8e2f05d 100644 --- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp @@ -51,6 +51,34 @@ namespace lvh::detail::test { std::vector strengths; }; + struct WindowsHidMouseResult { + OperationStatus create_status; + OperationStatus motion_status; + OperationStatus large_scroll_status; + OperationStatus partial_scroll_status; + OperationStatus completed_scroll_status; + OperationStatus close_status; + OperationStatus protocol_failure_status; + OperationStatus license_fallback_create_status; + OperationStatus license_fallback_submit_status; + std::uint32_t device_type = 0; + std::uint32_t bus_type = 0; + std::uint32_t flags = 0; + std::uint16_t vendor_id = 0; + std::uint16_t product_id = 0; + std::uint16_t version = 0; + std::uint8_t report_id = 0; + std::uint32_t input_report_size = 0; + std::uint32_t output_report_size = 0; + std::string name; + std::string manufacturer; + std::string stable_id; + std::vector> reports; + std::size_t destroy_requests = 0; + std::size_t license_create_requests = 0; + std::size_t license_fallback_send_inputs = 0; + }; + struct WindowsPlayStationTransportResult { OperationStatus dualshock4_status; OperationStatus dualsense_status; @@ -173,6 +201,7 @@ namespace lvh::detail::test { WindowsBackendLifecycleResult windows_backend_fake_channel_lifecycle(); WindowsPlayStationTransportResult windows_backend_playstation_transport(); WindowsGenericPidOrderingResult windows_backend_generic_pid_callback_ordering(); + WindowsHidMouseResult windows_backend_hid_mouse(); WindowsBackendFailureResult windows_backend_fake_channel_failures(); WindowsBackendUtilityResult windows_backend_fake_channel_utilities(); WindowsOverlappedIoResult windows_backend_overlapped_device_io(); diff --git a/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp index 579bd93..816c487 100644 --- a/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp @@ -66,8 +66,8 @@ namespace lvh::detail::test { bool same_boot_anchor_is_accepted = false; bool changed_boot_anchor_is_rejected = false; bool uptime_rollback_is_rejected = false; - bool first_unvalidated_gamepad_is_allowed = false; - bool second_unvalidated_gamepad_is_rejected = false; + bool first_unvalidated_device_is_allowed = false; + bool second_unvalidated_device_is_rejected = false; bool existing_gamepads_are_retained_before_one_hour = false; bool outage_limit_applies_at_one_hour = false; bool first_gamepad_is_retained_after_one_hour = false; diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index b3442dc..5cc7417 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -41,9 +41,9 @@ namespace lvh::detail { create_protocol_status_ = status; } - OperationStatus create_gamepad( - const LvhWindowsCreateGamepadRequest &request, - LvhWindowsCreateGamepadResponse &response + OperationStatus create_device( + const LvhWindowsCreateDeviceRequest &request, + LvhWindowsCreateDeviceResponse &response ) { std::lock_guard lock {mutex_}; create_requests_.push_back(request); @@ -57,7 +57,7 @@ namespace lvh::detail { response.driver_device_id = next_driver_id_++; response.session_token = session_token_; windows::copy_string(response.device_path, response_device_path_); - return protocol_status(response.status, "Windows driver rejected gamepad creation"); + return protocol_status(response.status, "Windows driver rejected virtual HID device creation"); } OperationStatus destroy_device( @@ -131,6 +131,16 @@ namespace lvh::detail { return destroyed_ids_.size(); } + std::vector create_requests() const { + std::lock_guard lock {mutex_}; + return create_requests_; + } + + std::vector> submit_reports() const { + std::lock_guard lock {mutex_}; + return submit_reports_; + } + private: bool session_token_matches(const LvhWindowsSessionToken &session_token) const { return std::ranges::equal(session_token.bytes, session_token_.bytes); @@ -154,7 +164,7 @@ namespace lvh::detail { std::uint32_t create_protocol_status_ = LVH_WINDOWS_STATUS_SUCCESS; OperationStatus submit_status_ = OperationStatus::success(); OperationStatus destroy_status_ = OperationStatus::success(); - std::vector create_requests_; + std::vector create_requests_; std::vector> submit_reports_; std::vector destroyed_ids_; std::vector output_events_; @@ -169,11 +179,11 @@ namespace lvh::detail { return state_->path(); } - OperationStatus create_gamepad( - const LvhWindowsCreateGamepadRequest &request, - LvhWindowsCreateGamepadResponse &response + OperationStatus create_device( + const LvhWindowsCreateDeviceRequest &request, + LvhWindowsCreateDeviceResponse &response ) const override { - return state_->create_gamepad(request, response); + return state_->create_device(request, response); } OperationStatus destroy_device( @@ -494,6 +504,100 @@ namespace lvh::detail { return result; } + WindowsHidMouseResult windows_backend_hid_mouse() { + WindowsHidMouseResult result; + auto command_state = std::make_shared(); + auto backend = make_fake_windows_backend(command_state, std::make_shared()); + + CreateMouseOptions options; + options.profile = profiles::mouse(); + options.profile.bus_type = BusType::bluetooth; + options.profile.vendor_id = 0x1234; + options.profile.product_id = 0x5678; + options.profile.version = 0x4321; + options.profile.name = "Configured mouse"; + options.profile.manufacturer = "Configured manufacturer"; + options.stable_id = "configured-mouse"; + + auto created = backend->create_mouse(8U, options); + result.create_status = created.status; + if (created) { + result.motion_status = created.mouse->submit(MouseEvent { + .kind = MouseEventKind::relative_motion, + .x = 70000, + .y = -70000, + }); + result.large_scroll_status = created.mouse->submit(MouseEvent { + .kind = MouseEventKind::vertical_scroll, + .high_resolution_scroll = 120 * 132, + }); + result.partial_scroll_status = created.mouse->submit(MouseEvent { + .kind = MouseEventKind::horizontal_scroll, + .high_resolution_scroll = 60, + }); + result.completed_scroll_status = created.mouse->submit(MouseEvent { + .kind = MouseEventKind::horizontal_scroll, + .high_resolution_scroll = 60, + }); + result.close_status = created.mouse->close(); + } + + const auto requests = command_state->create_requests(); + if (!requests.empty()) { + const auto &request = requests.front(); + result.device_type = request.device_type; + result.bus_type = request.bus_type; + result.flags = request.flags; + result.vendor_id = request.hardware_ids.vendor_id; + result.product_id = request.hardware_ids.product_id; + result.version = request.hardware_ids.device_version; + result.report_id = request.hardware_ids.report_id; + result.input_report_size = request.report_sizes.input_report_size; + result.output_report_size = request.report_sizes.output_report_size; + result.name = request.name.data(); + result.manufacturer = request.manufacturer.data(); + result.stable_id = request.stable_id.data(); + } + result.reports = command_state->submit_reports(); + result.destroy_requests = command_state->destroy_request_count(); + + { + auto failure_state = std::make_shared(); + failure_state->set_create_protocol_status(LVH_WINDOWS_STATUS_BACKEND_FAILURE); + auto failure_backend = make_fake_windows_backend( + failure_state, + std::make_shared() + ); + result.protocol_failure_status = failure_backend->create_mouse(9U, options).status; + } + + { + FakeSendInputState send_input_state; + ScopedFakeSendInput fake_send_input {send_input_state}; + auto license_state = std::make_shared(); + license_state->set_create_transport_status( + OperationStatus::failure(ErrorCode::license_required, "license required") + ); + auto license_backend = make_fake_windows_backend( + license_state, + std::make_shared() + ); + auto fallback = license_backend->create_mouse(10U, options); + result.license_fallback_create_status = fallback.status; + if (fallback) { + result.license_fallback_submit_status = fallback.mouse->submit(MouseEvent { + .kind = MouseEventKind::relative_motion, + .x = 1, + .y = 2, + }); + } + result.license_create_requests = license_state->create_request_count(); + result.license_fallback_send_inputs = send_input_state.sent_inputs.size(); + } + + return result; + } + WindowsPlayStationTransportResult windows_backend_playstation_transport() { WindowsPlayStationTransportResult result; auto command_state = std::make_shared(); diff --git a/tests/fixtures/windows_broker_service_test_hooks.cpp b/tests/fixtures/windows_broker_service_test_hooks.cpp index 6ada0ca..4217b27 100644 --- a/tests/fixtures/windows_broker_service_test_hooks.cpp +++ b/tests/fixtures/windows_broker_service_test_hooks.cpp @@ -542,8 +542,8 @@ namespace lvh::detail::test { .same_boot_anchor_is_accepted = monotonic_timestamp.has_value(), .changed_boot_anchor_is_rejected = !changed_boot_timestamp.has_value(), .uptime_rollback_is_rejected = !uptime_rollback_timestamp.has_value(), - .first_unvalidated_gamepad_is_allowed = unvalidated_gamepad_creation_allowed(0U), - .second_unvalidated_gamepad_is_rejected = !unvalidated_gamepad_creation_allowed(1U), + .first_unvalidated_device_is_allowed = unvalidated_device_creation_allowed(0U), + .second_unvalidated_device_is_rejected = !unvalidated_device_creation_allowed(1U), .existing_gamepads_are_retained_before_one_hour = !license_outage_retention_elapsed( license_outage_device_retention - std::chrono::milliseconds {1} @@ -552,11 +552,11 @@ namespace lvh::detail::test { license_outage_device_retention ), .first_gamepad_is_retained_after_one_hour = - !license_outage_gamepad_should_be_revoked(false, true, 0U), + !license_outage_device_should_be_revoked(false, true, 0U), .excess_gamepad_is_revoked_after_one_hour = - license_outage_gamepad_should_be_revoked(false, true, 1U), + license_outage_device_should_be_revoked(false, true, 1U), .evaluation_gamepad_is_not_outage_limited = - !license_outage_gamepad_should_be_revoked(true, true, 1U), + !license_outage_device_should_be_revoked(true, true, 1U), .licensed_device_is_revoked = broker_device_should_be_revoked( false, false, diff --git a/tests/unit/test_license.cpp b/tests/unit/test_license.cpp index d31606b..c3fb322 100644 --- a/tests/unit/test_license.cpp +++ b/tests/unit/test_license.cpp @@ -79,7 +79,7 @@ TEST(WindowsBrokerClientTest, BuildsVersionedRequestHeader) { EXPECT_EQ(header.size, sizeof(LvhWindowsBrokerLicenseRequest)); EXPECT_EQ(header.type, static_cast(LvhWindowsBrokerRequestType::validate_license)); EXPECT_EQ(header.reserved0, 0U); - EXPECT_EQ(LVH_WINDOWS_BROKER_PROTOCOL_VERSION, 3U); + EXPECT_EQ(LVH_WINDOWS_BROKER_PROTOCOL_VERSION, 4U); } TEST(WindowsBrokerClientTest, PreservesFixedWireLayout) { @@ -87,8 +87,8 @@ TEST(WindowsBrokerClientTest, PreservesFixedWireLayout) { EXPECT_EQ(sizeof(LvhWindowsBrokerLicenseStatus), 796U); EXPECT_EQ(sizeof(LvhWindowsBrokerStatusRequest), 16U); EXPECT_EQ(sizeof(LvhWindowsBrokerStatusResponse), 1324U); - EXPECT_EQ(sizeof(LvhWindowsBrokerCreateGamepadRequest), 2528U); - EXPECT_EQ(sizeof(LvhWindowsBrokerCreateGamepadResponse), 1640U); + EXPECT_EQ(sizeof(LvhWindowsBrokerCreateDeviceRequest), 2528U); + EXPECT_EQ(sizeof(LvhWindowsBrokerCreateDeviceResponse), 1640U); EXPECT_EQ(sizeof(LvhWindowsBrokerDestroyDeviceRequest), 64U); EXPECT_EQ(sizeof(LvhWindowsBrokerDestroyDeviceResponse), 1324U); EXPECT_EQ(sizeof(LvhWindowsBrokerLicenseRequest), 272U); diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp index d2fc45e..c6a117a 100644 --- a/tests/unit/test_windows_backend.cpp +++ b/tests/unit/test_windows_backend.cpp @@ -27,6 +27,7 @@ // local includes #include "fixtures/fixtures.hpp" #include "fixtures/windows_backend_test_hooks.hpp" +#include "lvh_windows_protocol.h" // lib includes #include @@ -53,6 +54,13 @@ namespace { EXPECT_TRUE(status.ok()) << status.message(); } + std::int16_t mouse_report_i16(const std::vector &report, std::size_t offset) { + return static_cast( + static_cast(report[offset]) | + (static_cast(report[offset + 1U]) << 8U) + ); + } + template const lvh::detail::test::WindowsSyntheticPointerRecord *find_synthetic_pointer( const std::vector &records, @@ -115,6 +123,50 @@ TEST_F(WindowsBackendTest, PlayStationDefaultsUseEffectiveUsbProfiles) { EXPECT_EQ(result.dualsense_effective_profile.output_report_size, dualsense_usb.output_report_size); } +TEST_F(WindowsBackendTest, HidMousePreservesIdentityChunksInputAndUsesNarrowFallback) { + const auto result = lvh::detail::test::windows_backend_hid_mouse(); + + expect_ok(result.create_status); + EXPECT_EQ(result.device_type, LVH_WINDOWS_DEVICE_MOUSE); + EXPECT_EQ(result.bus_type, LVH_WINDOWS_BUS_BLUETOOTH); + EXPECT_EQ(result.flags, 0U); + EXPECT_EQ(result.vendor_id, 0x1234U); + EXPECT_EQ(result.product_id, 0x5678U); + EXPECT_EQ(result.version, 0x4321U); + EXPECT_EQ(result.report_id, 0U); + EXPECT_EQ(result.input_report_size, LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE); + EXPECT_EQ(result.output_report_size, 0U); + EXPECT_EQ(result.name, "Configured mouse"); + EXPECT_EQ(result.manufacturer, "Configured manufacturer"); + EXPECT_EQ(result.stable_id, "configured-mouse"); + + expect_ok(result.motion_status); + expect_ok(result.large_scroll_status); + expect_ok(result.partial_scroll_status); + expect_ok(result.completed_scroll_status); + expect_ok(result.close_status); + ASSERT_EQ(result.reports.size(), 6U); + for (const auto &report : result.reports) { + EXPECT_EQ(report.size(), LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE); + } + EXPECT_EQ(mouse_report_i16(result.reports[0], 1U), 32767); + EXPECT_EQ(mouse_report_i16(result.reports[0], 3U), -32768); + EXPECT_EQ(mouse_report_i16(result.reports[1], 1U), 32767); + EXPECT_EQ(mouse_report_i16(result.reports[1], 3U), -32768); + EXPECT_EQ(mouse_report_i16(result.reports[2], 1U), 4466); + EXPECT_EQ(mouse_report_i16(result.reports[2], 3U), -4464); + EXPECT_EQ(static_cast(result.reports[3][5]), 127); + EXPECT_EQ(static_cast(result.reports[4][5]), 5); + EXPECT_EQ(static_cast(result.reports[5][6]), 1); + EXPECT_EQ(result.destroy_requests, 1U); + + EXPECT_EQ(result.protocol_failure_status.code(), lvh::ErrorCode::backend_failure); + expect_ok(result.license_fallback_create_status); + expect_ok(result.license_fallback_submit_status); + EXPECT_EQ(result.license_create_requests, 1U); + EXPECT_EQ(result.license_fallback_send_inputs, 1U); +} + TEST_F(WindowsBackendTest, GenericPidTimerCannotDeliverStaleStopAfterNewStart) { const auto result = lvh::detail::test::windows_backend_generic_pid_callback_ordering(); diff --git a/tests/unit/test_windows_broker_service.cpp b/tests/unit/test_windows_broker_service.cpp index 664986b..48d6e05 100644 --- a/tests/unit/test_windows_broker_service.cpp +++ b/tests/unit/test_windows_broker_service.cpp @@ -329,15 +329,15 @@ TEST_F(WindowsBrokerServiceTest, DriverRejectsDirectGamepadCreation) { GTEST_SKIP() << "The installed libvirtualhid control device is unavailable."; } - LvhWindowsCreateGamepadRequest request {}; + LvhWindowsCreateDeviceRequest request {}; request.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; request.size = sizeof(request); - LvhWindowsCreateGamepadResponse response {}; + LvhWindowsCreateDeviceResponse response {}; DWORD bytes_returned = 0; const auto request_result = ::DeviceIoControl( control, - LVH_WINDOWS_IOCTL_CREATE_GAMEPAD, + LVH_WINDOWS_IOCTL_CREATE_DEVICE, &request, sizeof(request), &response, @@ -409,8 +409,8 @@ TEST(WindowsBrokerImplementationTest, EnforcesLimitedUnvalidatedFallback) { EXPECT_TRUE(policy.same_boot_anchor_is_accepted); EXPECT_TRUE(policy.changed_boot_anchor_is_rejected); EXPECT_TRUE(policy.uptime_rollback_is_rejected); - EXPECT_TRUE(policy.first_unvalidated_gamepad_is_allowed); - EXPECT_TRUE(policy.second_unvalidated_gamepad_is_rejected); + EXPECT_TRUE(policy.first_unvalidated_device_is_allowed); + EXPECT_TRUE(policy.second_unvalidated_device_is_rejected); EXPECT_TRUE(policy.existing_gamepads_are_retained_before_one_hour); EXPECT_TRUE(policy.outage_limit_applies_at_one_hour); EXPECT_TRUE(policy.first_gamepad_is_retained_after_one_hour); diff --git a/tests/unit/test_windows_broker_validation.cpp b/tests/unit/test_windows_broker_validation.cpp index 8ce35ba..bd12a79 100644 --- a/tests/unit/test_windows_broker_validation.cpp +++ b/tests/unit/test_windows_broker_validation.cpp @@ -5,6 +5,7 @@ // local includes #include "broker_request_validation.hpp" +#include "mouse_protocol.hpp" #include "platform/windows/control_protocol.hpp" // test includes @@ -32,7 +33,7 @@ namespace { }; } - LvhWindowsBrokerCreateGamepadRequest valid_create_request() { + LvhWindowsBrokerCreateDeviceRequest valid_create_request() { lvh::CreateGamepadOptions options; options.profile.device_type = lvh::DeviceType::gamepad; options.profile.gamepad_kind = lvh::GamepadProfileKind::generic; @@ -46,13 +47,13 @@ namespace { options.profile.manufacturer = "LizardByte"; options.profile.report_descriptor = {0x05, 0x01, 0x09, 0x05}; - LvhWindowsBrokerCreateGamepadRequest request {}; + LvhWindowsBrokerCreateDeviceRequest request {}; request.header = request_header( - LvhWindowsBrokerRequestType::create_gamepad, + LvhWindowsBrokerRequestType::create_device, sizeof(request) ); request.client_control_handle = 1U; - request.gamepad = lvh::detail::windows::make_create_gamepad_request(1U, options); + request.device = lvh::detail::windows::make_create_device_request(1U, options); return request; } @@ -115,85 +116,122 @@ TEST(WindowsBrokerValidationTest, RejectsMalformedCreateFields) { EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - ++request.gamepad.version; + ++request.device.version; + EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); + + request = valid; + request.device.client_device_id = 0U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.client_device_id = 0U; + ++request.device.size; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - ++request.gamepad.size; + request.device.device_type = 99U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.bus_type = LVH_WINDOWS_BUS_UNKNOWN; + request.device.bus_type = LVH_WINDOWS_BUS_UNKNOWN; EXPECT_TRUE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.bus_type = LVH_WINDOWS_BUS_BLUETOOTH; + request.device.bus_type = LVH_WINDOWS_BUS_BLUETOOTH; EXPECT_TRUE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.bus_type = 99U; + request.device.bus_type = 99U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.gamepad_kind = 99U; + request.device.gamepad_kind = 99U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.flags = 0x80000000U; + request.device.flags = 0x80000000U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.hardware_ids.reserved0[0] = 1U; + request.device.hardware_ids.reserved0[0] = 1U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.report_sizes.report_descriptor_size = 0U; + request.device.report_sizes.report_descriptor_size = 0U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.report_sizes.input_report_size = 0U; + request.device.report_sizes.input_report_size = 0U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.report_sizes.input_report_size = LVH_WINDOWS_MAX_INPUT_REPORT_SIZE + 1U; + request.device.report_sizes.input_report_size = LVH_WINDOWS_MAX_INPUT_REPORT_SIZE + 1U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.report_sizes.output_report_size = LVH_WINDOWS_MAX_OUTPUT_REPORT_SIZE + 1U; + request.device.report_sizes.output_report_size = LVH_WINDOWS_MAX_OUTPUT_REPORT_SIZE + 1U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.report_sizes.report_descriptor_size = LVH_WINDOWS_MAX_REPORT_DESCRIPTOR_SIZE + 1U; + request.device.report_sizes.report_descriptor_size = LVH_WINDOWS_MAX_REPORT_DESCRIPTOR_SIZE + 1U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.report_sizes.name_size = sizeof(request.gamepad.name); + request.device.report_sizes.name_size = sizeof(request.device.name); EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.name[1] = '\0'; + request.device.name[1] = '\0'; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.manufacturer[1] = '\0'; + request.device.manufacturer[1] = '\0'; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - std::memcpy(request.gamepad.stable_id.data(), "stable", sizeof("stable")); - request.gamepad.report_sizes.stable_id_size = sizeof("stable") - 1U; - request.gamepad.stable_id[1] = '\0'; + std::memcpy(request.device.stable_id.data(), "stable", sizeof("stable")); + request.device.report_sizes.stable_id_size = sizeof("stable") - 1U; + request.device.stable_id[1] = '\0'; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.name[request.gamepad.report_sizes.name_size] = 'x'; + request.device.name[request.device.report_sizes.name_size] = 'x'; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request = valid; - request.gamepad.report_descriptor[request.gamepad.report_sizes.report_descriptor_size] = 1U; + request.device.report_descriptor[request.device.report_sizes.report_descriptor_size] = 1U; + EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); +} + +TEST(WindowsBrokerValidationTest, ValidatesMouseCreateFields) { + auto request = valid_create_request(); + request.device.device_type = LVH_WINDOWS_DEVICE_MOUSE; + request.device.gamepad_kind = LVH_WINDOWS_GAMEPAD_GENERIC; + request.device.flags = 0U; + request.device.hardware_ids.report_id = 0U; + request.device.report_sizes.input_report_size = LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE; + request.device.report_sizes.output_report_size = 0U; + std::ranges::fill(request.device.report_descriptor, std::uint8_t {}); + std::ranges::copy( + lvh::detail::windows::mouse_report_descriptor, + request.device.report_descriptor.begin() + ); + request.device.report_sizes.report_descriptor_size = + static_cast(lvh::detail::windows::mouse_report_descriptor.size()); + EXPECT_TRUE(lvh::windows::broker_validation::valid_request(request)); + + request.device.flags = LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_RUMBLE; + EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); + request.device.flags = 0U; + request.device.hardware_ids.report_id = 1U; + EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); + request.device.hardware_ids.report_id = 0U; + request.device.report_sizes.input_report_size = LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE - 1U; + EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); + request.device.report_sizes.input_report_size = LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE; + request.device.report_sizes.output_report_size = 1U; + EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); + request.device.report_sizes.output_report_size = 0U; + request.device.report_descriptor[0] ^= 0x01U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); } @@ -273,7 +311,7 @@ TEST(WindowsBrokerValidationTest, RejectsInvalidLicenseHeadersAndRequestTypes) { for (const auto type : { status, - create_gamepad, + create_device, destroy_device, }) { request = valid_license_request(type); diff --git a/tests/unit/test_windows_driver_protocol.cpp b/tests/unit/test_windows_driver_protocol.cpp index 782e58d..587a564 100644 --- a/tests/unit/test_windows_driver_protocol.cpp +++ b/tests/unit/test_windows_driver_protocol.cpp @@ -33,8 +33,9 @@ namespace { return entries; } - LvhWindowsCreateGamepadRequest series_request() { - LvhWindowsCreateGamepadRequest request {}; + LvhWindowsCreateDeviceRequest series_request() { + LvhWindowsCreateDeviceRequest request {}; + request.device_type = LVH_WINDOWS_DEVICE_GAMEPAD; request.gamepad_kind = LVH_WINDOWS_GAMEPAD_XBOX_SERIES; request.hardware_ids.vendor_id = 0x045E; request.hardware_ids.product_id = 0x0B12; @@ -42,12 +43,13 @@ namespace { return request; } - LvhWindowsCreateGamepadRequest playstation_request( + LvhWindowsCreateDeviceRequest playstation_request( std::uint32_t gamepad_kind, std::uint32_t bus_type = LVH_WINDOWS_BUS_USB, std::string_view stable_id = "10:20:30:40:50:60" ) { - LvhWindowsCreateGamepadRequest request {}; + LvhWindowsCreateDeviceRequest request {}; + request.device_type = LVH_WINDOWS_DEVICE_GAMEPAD; request.client_device_id = 0x12345678U; request.gamepad_kind = gamepad_kind; request.bus_type = bus_type; diff --git a/tests/unit/test_windows_protocol.cpp b/tests/unit/test_windows_protocol.cpp index 052d7e4..73ee47d 100644 --- a/tests/unit/test_windows_protocol.cpp +++ b/tests/unit/test_windows_protocol.cpp @@ -60,18 +60,18 @@ TEST(WindowsProtocolTest, ExposesStableProtocolConstants) { EXPECT_STREQ(lvh::detail::windows::default_control_device_path.data(), R"(\\.\LibVirtualHid)"); EXPECT_STREQ(lvh::detail::windows::global_control_device_path.data(), R"(\\.\Global\LibVirtualHid)"); - EXPECT_EQ(LVH_WINDOWS_CONTROL_PROTOCOL_VERSION, 2U); - EXPECT_EQ(LVH_WINDOWS_IOCTL_CREATE_GAMEPAD, 0x8000E000U); + EXPECT_EQ(LVH_WINDOWS_CONTROL_PROTOCOL_VERSION, 3U); + EXPECT_EQ(LVH_WINDOWS_IOCTL_CREATE_DEVICE, 0x8000E000U); EXPECT_EQ(LVH_WINDOWS_IOCTL_DESTROY_DEVICE, 0x8000E004U); EXPECT_EQ(LVH_WINDOWS_IOCTL_SUBMIT_INPUT_REPORT, 0x8000E008U); EXPECT_EQ(LVH_WINDOWS_IOCTL_READ_OUTPUT_REPORT, 0x8000600CU); EXPECT_EQ(LVH_WINDOWS_IOCTL_RESET_DEVICES, 0x8000E010U); - EXPECT_EQ(sizeof(LvhWindowsGamepadHardwareIds), 14U); - EXPECT_EQ(sizeof(LvhWindowsGamepadReportSizes), 24U); - EXPECT_EQ(sizeof(LvhWindowsCreateGamepadRequest), 2498U); + EXPECT_EQ(sizeof(LvhWindowsDeviceHardwareIds), 14U); + EXPECT_EQ(sizeof(LvhWindowsDeviceReportSizes), 24U); + EXPECT_EQ(sizeof(LvhWindowsCreateDeviceRequest), 2502U); EXPECT_EQ(sizeof(LvhWindowsSessionToken), 32U); - EXPECT_EQ(sizeof(LvhWindowsCreateGamepadResponse), 316U); + EXPECT_EQ(sizeof(LvhWindowsCreateDeviceResponse), 316U); EXPECT_EQ(sizeof(LvhWindowsDestroyDeviceRequest), 48U); EXPECT_EQ(sizeof(LvhWindowsResetDevicesRequest), 8U); EXPECT_EQ(sizeof(LvhWindowsSubmitInputReportRequest), 312U); @@ -84,6 +84,10 @@ TEST(WindowsProtocolTest, MapsBusTypesAndGamepadKinds) { EXPECT_EQ(lvh::detail::windows::protocol_bus_type(lvh::BusType::bluetooth), LVH_WINDOWS_BUS_BLUETOOTH); EXPECT_EQ(lvh::detail::windows::protocol_bus_type(static_cast(255)), LVH_WINDOWS_BUS_UNKNOWN); + EXPECT_EQ(lvh::detail::windows::protocol_device_type(lvh::DeviceType::gamepad), LVH_WINDOWS_DEVICE_GAMEPAD); + EXPECT_EQ(lvh::detail::windows::protocol_device_type(lvh::DeviceType::mouse), LVH_WINDOWS_DEVICE_MOUSE); + EXPECT_EQ(lvh::detail::windows::protocol_device_type(lvh::DeviceType::keyboard), 0U); + EXPECT_EQ( lvh::detail::windows::protocol_gamepad_kind(lvh::GamepadProfileKind::generic), LVH_WINDOWS_GAMEPAD_GENERIC @@ -139,7 +143,7 @@ TEST(WindowsProtocolTest, BuildsCapabilityFlags) { } TEST(WindowsProtocolTest, CopyHelpersTruncateAndZeroFill) { - LvhWindowsCreateGamepadRequest create_request {}; + LvhWindowsCreateDeviceRequest create_request {}; create_request.name[0] = 'x'; create_request.name[1] = 'x'; create_request.name[2] = 'x'; @@ -176,11 +180,12 @@ TEST(WindowsProtocolTest, PacksGamepadCreateRequest) { options.profile = lvh::profiles::dualsense_bluetooth(); options.metadata.stable_id = "client-0-controller-1"; - const auto request = lvh::detail::windows::make_create_gamepad_request(42, options); + const auto request = lvh::detail::windows::make_create_device_request(42, options); EXPECT_EQ(request.version, LVH_WINDOWS_CONTROL_PROTOCOL_VERSION); EXPECT_EQ(request.size, sizeof(request)); EXPECT_EQ(request.client_device_id, 42U); + EXPECT_EQ(request.device_type, LVH_WINDOWS_DEVICE_GAMEPAD); EXPECT_EQ(request.bus_type, LVH_WINDOWS_BUS_BLUETOOTH); EXPECT_EQ(request.gamepad_kind, LVH_WINDOWS_GAMEPAD_DUALSENSE); EXPECT_EQ(request.hardware_ids.vendor_id, options.profile.vendor_id); @@ -200,6 +205,40 @@ TEST(WindowsProtocolTest, PacksGamepadCreateRequest) { EXPECT_NE(request.flags & LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_BATTERY, 0U); } +TEST(WindowsProtocolTest, PacksMouseCreateRequestWithoutGamepadPolicy) { + lvh::CreateMouseOptions options; + options.profile.device_type = lvh::DeviceType::mouse; + options.profile.gamepad_kind = lvh::GamepadProfileKind::generic; + options.profile.bus_type = lvh::BusType::bluetooth; + options.profile.vendor_id = 0x1234; + options.profile.product_id = 0x5678; + options.profile.version = 0x4321; + options.profile.report_id = 0U; + options.profile.input_report_size = LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE; + options.profile.output_report_size = 0U; + options.profile.name = "Configured mouse"; + options.profile.manufacturer = "Configured manufacturer"; + options.profile.report_descriptor = {0x05, 0x01, 0x09, 0x02}; + options.profile.capabilities.supports_rumble = true; + options.stable_id = "configured-mouse"; + + const auto request = lvh::detail::windows::make_create_device_request(43U, options); + + EXPECT_EQ(request.device_type, LVH_WINDOWS_DEVICE_MOUSE); + EXPECT_EQ(request.bus_type, LVH_WINDOWS_BUS_BLUETOOTH); + EXPECT_EQ(request.gamepad_kind, LVH_WINDOWS_GAMEPAD_GENERIC); + EXPECT_EQ(request.flags, 0U); + EXPECT_EQ(request.hardware_ids.vendor_id, 0x1234U); + EXPECT_EQ(request.hardware_ids.product_id, 0x5678U); + EXPECT_EQ(request.hardware_ids.device_version, 0x4321U); + EXPECT_EQ(request.hardware_ids.report_id, 0U); + EXPECT_EQ(request.report_sizes.input_report_size, LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE); + EXPECT_EQ(request.report_sizes.output_report_size, 0U); + EXPECT_STREQ(request.name.data(), "Configured mouse"); + EXPECT_STREQ(request.manufacturer.data(), "Configured manufacturer"); + EXPECT_STREQ(request.stable_id.data(), "configured-mouse"); +} + TEST(WindowsProtocolTest, UsesUsbFramingForPlayStationProfilesOnVhf) { const std::array requested_profiles { lvh::profiles::dualshock4(), @@ -239,7 +278,7 @@ TEST(WindowsProtocolTest, PacksGenericUnknownBusGamepadCreateRequestWithoutOptio options.profile.bus_type = lvh::BusType::unknown; options.profile.output_report_size = 0; - const auto request = lvh::detail::windows::make_create_gamepad_request(7, options); + const auto request = lvh::detail::windows::make_create_device_request(7, options); EXPECT_EQ(request.bus_type, LVH_WINDOWS_BUS_UNKNOWN); EXPECT_EQ(request.gamepad_kind, LVH_WINDOWS_GAMEPAD_GENERIC); @@ -254,7 +293,7 @@ TEST(WindowsProtocolTest, PresentsXboxSeriesNativeWindowsIdentityAndReportShape) lvh::CreateGamepadOptions options; options.profile = lvh::profiles::xbox_series(); - const auto request = lvh::detail::windows::make_create_gamepad_request(8, options); + const auto request = lvh::detail::windows::make_create_device_request(8, options); const std::vector descriptor( request.report_descriptor.data(), request.report_descriptor.data() + request.report_sizes.report_descriptor_size @@ -312,7 +351,7 @@ TEST(WindowsProtocolTest, PresentsBuiltInGenericControllerAsDirectInputPidJoysti lvh::CreateGamepadOptions options; options.profile = lvh::profiles::generic_gamepad(); - const auto request = lvh::detail::windows::make_create_gamepad_request(8, options); + const auto request = lvh::detail::windows::make_create_device_request(8, options); const std::vector descriptor( request.report_descriptor.data(), request.report_descriptor.data() + request.report_sizes.report_descriptor_size @@ -568,7 +607,7 @@ TEST(WindowsProtocolTest, TruncatesOversizedGamepadCreateRequestFields) { options.profile.manufacturer.assign(LVH_WINDOWS_MAX_MANUFACTURER_SIZE + 5U, 'm'); options.metadata.stable_id.assign(LVH_WINDOWS_MAX_STABLE_ID_SIZE + 5U, 's'); - const auto request = lvh::detail::windows::make_create_gamepad_request(9, options); + const auto request = lvh::detail::windows::make_create_device_request(9, options); EXPECT_EQ(request.report_sizes.input_report_size, LVH_WINDOWS_MAX_INPUT_REPORT_SIZE); EXPECT_EQ(request.report_sizes.output_report_size, LVH_WINDOWS_MAX_OUTPUT_REPORT_SIZE); diff --git a/tests/unit/test_windows_vhf_input_report_queue.cpp b/tests/unit/test_windows_vhf_input_report_queue.cpp index 716e7c9..6839771 100644 --- a/tests/unit/test_windows_vhf_input_report_queue.cpp +++ b/tests/unit/test_windows_vhf_input_report_queue.cpp @@ -32,8 +32,30 @@ namespace { return report; } + std::vector make_mouse_report( + std::uint8_t buttons, + std::int16_t x, + std::int16_t y, + std::int8_t wheel, + std::int8_t pan + ) { + const auto encode_i16 = [](std::vector &report, std::size_t offset, std::int16_t value) { + const auto encoded = static_cast(value); + report[offset] = static_cast(encoded & 0xFFU); + report[offset + 1U] = static_cast(encoded >> 8U); + }; + + std::vector report(LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE, 0U); + report[0] = buttons; + encode_i16(report, 1U, x); + encode_i16(report, 3U, y); + report[5] = static_cast(wheel); + report[6] = static_cast(pan); + return report; + } + TEST(WindowsVhfInputReportQueueTest, CollapsesAnAxisBurstToItsLatestState) { - VhfInputReportQueue queue {LVH_WINDOWS_GAMEPAD_GENERIC, LVH_WINDOWS_BUS_USB, 1U}; + VhfInputReportQueue queue {LVH_WINDOWS_DEVICE_GAMEPAD, LVH_WINDOWS_GAMEPAD_GENERIC, LVH_WINDOWS_BUS_USB, 1U}; auto latest = make_report(9U, 1U); for (std::uint8_t axis = 0U; axis < 64U; ++axis) { latest[3] = axis; @@ -46,7 +68,7 @@ namespace { } TEST(WindowsVhfInputReportQueueTest, PreservesButtonTransitionsWhileCoalescingTheirAxes) { - VhfInputReportQueue queue {LVH_WINDOWS_GAMEPAD_GENERIC, LVH_WINDOWS_BUS_USB, 1U}; + VhfInputReportQueue queue {LVH_WINDOWS_DEVICE_GAMEPAD, LVH_WINDOWS_GAMEPAD_GENERIC, LVH_WINDOWS_BUS_USB, 1U}; auto neutral = make_report(9U, 1U); neutral[3] = 10U; auto pressed = neutral; @@ -91,6 +113,7 @@ namespace { for (const auto &test_case : profiles) { SCOPED_TRACE(test_case.profile.name); VhfInputReportQueue queue { + LVH_WINDOWS_DEVICE_GAMEPAD, test_case.gamepad_kind, test_case.bus_type, test_case.profile.report_id, @@ -151,6 +174,7 @@ namespace { for (const auto &test_case : profiles) { SCOPED_TRACE(test_case.profile.name); VhfInputReportQueue queue { + LVH_WINDOWS_DEVICE_GAMEPAD, test_case.gamepad_kind, test_case.bus_type, test_case.profile.report_id, @@ -169,7 +193,7 @@ namespace { } TEST(WindowsVhfInputReportQueueTest, PrioritizesSwitchProtocolRepliesWithoutCoalescingThem) { - VhfInputReportQueue queue {LVH_WINDOWS_GAMEPAD_SWITCH_PRO, LVH_WINDOWS_BUS_USB, 0x30U}; + VhfInputReportQueue queue {LVH_WINDOWS_DEVICE_GAMEPAD, LVH_WINDOWS_GAMEPAD_SWITCH_PRO, LVH_WINDOWS_BUS_USB, 0x30U}; auto controller_state = make_report(64U, 0x30U); auto first_reply = make_report(64U, 0x21U); auto second_reply = first_reply; @@ -187,7 +211,7 @@ namespace { } TEST(WindowsVhfInputReportQueueTest, BoundsSwitchProtocolReplyHistory) { - VhfInputReportQueue queue {LVH_WINDOWS_GAMEPAD_SWITCH_PRO, LVH_WINDOWS_BUS_USB, 0x30U}; + VhfInputReportQueue queue {LVH_WINDOWS_DEVICE_GAMEPAD, LVH_WINDOWS_GAMEPAD_SWITCH_PRO, LVH_WINDOWS_BUS_USB, 0x30U}; constexpr auto submitted_reports = vhf_max_pending_input_reports + 8U; for (std::size_t index = 0U; index < submitted_reports; ++index) { auto reply = make_report(64U, 0x21U); @@ -202,7 +226,7 @@ namespace { } TEST(WindowsVhfInputReportQueueTest, KeepsOnlyTheNewestBoundedTransitionHistory) { - VhfInputReportQueue queue {LVH_WINDOWS_GAMEPAD_GENERIC, LVH_WINDOWS_BUS_USB, 1U}; + VhfInputReportQueue queue {LVH_WINDOWS_DEVICE_GAMEPAD, LVH_WINDOWS_GAMEPAD_GENERIC, LVH_WINDOWS_BUS_USB, 1U}; constexpr auto submitted_reports = vhf_max_pending_input_reports + 8U; for (std::size_t index = 0U; index < submitted_reports; ++index) { auto report = make_report(9U, 1U); @@ -225,7 +249,7 @@ namespace { } TEST(WindowsVhfInputReportQueueTest, KeepsMalformedAndUnknownReportsDistinctAndCanClearThem) { - VhfInputReportQueue malformed_queue {LVH_WINDOWS_GAMEPAD_GENERIC, LVH_WINDOWS_BUS_USB, 0U}; + VhfInputReportQueue malformed_queue {LVH_WINDOWS_DEVICE_GAMEPAD, LVH_WINDOWS_GAMEPAD_GENERIC, LVH_WINDOWS_BUS_USB, 0U}; malformed_queue.push({}); malformed_queue.push({}); malformed_queue.push({0U}); @@ -235,10 +259,44 @@ namespace { EXPECT_FALSE(malformed_queue.pop().has_value()); constexpr auto unknown_gamepad_kind = 0xFFFFFFFFU; - VhfInputReportQueue unknown_queue {unknown_gamepad_kind, LVH_WINDOWS_BUS_UNKNOWN, 0U}; + VhfInputReportQueue unknown_queue {LVH_WINDOWS_DEVICE_GAMEPAD, unknown_gamepad_kind, LVH_WINDOWS_BUS_UNKNOWN, 0U}; unknown_queue.push(make_report(9U, 0U)); unknown_queue.push(make_report(9U, 0U)); EXPECT_EQ(unknown_queue.size(), 2U); } + TEST(WindowsVhfInputReportQueueTest, AccumulatesRelativeMouseReportsWithoutLosingLargeDeltas) { + VhfInputReportQueue queue { + LVH_WINDOWS_DEVICE_MOUSE, + LVH_WINDOWS_GAMEPAD_GENERIC, + LVH_WINDOWS_BUS_USB, + 0U, + }; + queue.push(make_mouse_report(0U, 30000, -30000, 100, -100)); + queue.push(make_mouse_report(0U, 30000, -30000, 100, -100)); + + ASSERT_EQ(queue.size(), 1U); + EXPECT_EQ(queue.pop(), make_mouse_report(0U, 32767, -32768, 127, -127)); + EXPECT_EQ(queue.pop(), make_mouse_report(0U, 27233, -27232, 73, -73)); + EXPECT_TRUE(queue.empty()); + } + + TEST(WindowsVhfInputReportQueueTest, PreservesMouseButtonTransitions) { + VhfInputReportQueue queue { + LVH_WINDOWS_DEVICE_MOUSE, + LVH_WINDOWS_GAMEPAD_GENERIC, + LVH_WINDOWS_BUS_USB, + 0U, + }; + queue.push(make_mouse_report(0U, 5, 0, 0, 0)); + queue.push(make_mouse_report(1U, 0, 0, 0, 0)); + queue.push(make_mouse_report(1U, 7, 0, 0, 0)); + queue.push(make_mouse_report(0U, 0, 0, 0, 0)); + + ASSERT_EQ(queue.size(), 3U); + EXPECT_EQ(queue.pop(), make_mouse_report(0U, 5, 0, 0, 0)); + EXPECT_EQ(queue.pop(), make_mouse_report(1U, 7, 0, 0, 0)); + EXPECT_EQ(queue.pop(), make_mouse_report(0U, 0, 0, 0, 0)); + } + } // namespace From a9e27191aed0bafba3d210387173441627034205 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:09:17 -0400 Subject: [PATCH 4/8] Sonar fixes --- .../windows/shared/vhf_input_report_queue.hpp | 11 +-- src/platform/windows/windows_backend.cpp | 3 +- .../fixtures/windows_backend_test_hooks.hpp | 58 ++++++++------- tests/fixtures/windows_backend_test_hooks.cpp | 65 ++++++++-------- tests/unit/test_windows_backend.cpp | 74 +++++++++---------- tests/unit/test_windows_broker_validation.cpp | 2 +- 6 files changed, 111 insertions(+), 102 deletions(-) diff --git a/src/platform/windows/shared/vhf_input_report_queue.hpp b/src/platform/windows/shared/vhf_input_report_queue.hpp index 50f7f06..3446dab 100644 --- a/src/platform/windows/shared/vhf_input_report_queue.hpp +++ b/src/platform/windows/shared/vhf_input_report_queue.hpp @@ -64,11 +64,12 @@ namespace lvh::detail::windows { return; } - if (!reports_.empty()) { - if (device_type_ == LVH_WINDOWS_DEVICE_GAMEPAD && same_discrete_state(reports_.back(), report)) { - reports_.back() = std::move(report); - return; - } + if ( + !reports_.empty() && device_type_ == LVH_WINDOWS_DEVICE_GAMEPAD && + same_discrete_state(reports_.back(), report) + ) { + reports_.back() = std::move(report); + return; } make_room(); diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index f453f8a..fc34aac 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -1620,8 +1620,7 @@ namespace lvh::detail { while (detents != 0) { const auto report_detents = static_cast(std::clamp(detents, -127, 127)); - const auto status = vertical ? emit(0, 0, report_detents, 0) : emit(0, 0, 0, report_detents); - if (!status.ok()) { + if (const auto status = vertical ? emit(0, 0, report_detents, 0) : emit(0, 0, 0, report_detents); !status.ok()) { return status; } detents -= report_detents; diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp index 8e2f05d..91b31a7 100644 --- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp @@ -52,31 +52,39 @@ namespace lvh::detail::test { }; struct WindowsHidMouseResult { - OperationStatus create_status; - OperationStatus motion_status; - OperationStatus large_scroll_status; - OperationStatus partial_scroll_status; - OperationStatus completed_scroll_status; - OperationStatus close_status; - OperationStatus protocol_failure_status; - OperationStatus license_fallback_create_status; - OperationStatus license_fallback_submit_status; - std::uint32_t device_type = 0; - std::uint32_t bus_type = 0; - std::uint32_t flags = 0; - std::uint16_t vendor_id = 0; - std::uint16_t product_id = 0; - std::uint16_t version = 0; - std::uint8_t report_id = 0; - std::uint32_t input_report_size = 0; - std::uint32_t output_report_size = 0; - std::string name; - std::string manufacturer; - std::string stable_id; - std::vector> reports; - std::size_t destroy_requests = 0; - std::size_t license_create_requests = 0; - std::size_t license_fallback_send_inputs = 0; + struct OperationResults { + OperationStatus create_status; + OperationStatus motion_status; + OperationStatus large_scroll_status; + OperationStatus partial_scroll_status; + OperationStatus completed_scroll_status; + OperationStatus close_status; + OperationStatus protocol_failure_status; + OperationStatus license_fallback_create_status; + OperationStatus license_fallback_submit_status; + } operations; + + struct CreatedDevice { + std::uint32_t device_type = 0; + std::uint32_t bus_type = 0; + std::uint32_t flags = 0; + std::uint16_t vendor_id = 0; + std::uint16_t product_id = 0; + std::uint16_t version = 0; + std::uint8_t report_id = 0; + std::uint32_t input_report_size = 0; + std::uint32_t output_report_size = 0; + std::string name; + std::string manufacturer; + std::string stable_id; + } device; + + struct RequestObservations { + std::vector> reports; + std::size_t destroy_requests = 0; + std::size_t license_create_requests = 0; + std::size_t license_fallback_send_inputs = 0; + } observations; }; struct WindowsPlayStationTransportResult { diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index 5cc7417..be042b7 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -505,6 +505,8 @@ namespace lvh::detail { } WindowsHidMouseResult windows_backend_hid_mouse() { + using enum MouseEventKind; + WindowsHidMouseResult result; auto command_state = std::make_shared(); auto backend = make_fake_windows_backend(command_state, std::make_shared()); @@ -520,46 +522,45 @@ namespace lvh::detail { options.stable_id = "configured-mouse"; auto created = backend->create_mouse(8U, options); - result.create_status = created.status; + result.operations.create_status = created.status; if (created) { - result.motion_status = created.mouse->submit(MouseEvent { - .kind = MouseEventKind::relative_motion, + result.operations.motion_status = created.mouse->submit(MouseEvent { + .kind = relative_motion, .x = 70000, .y = -70000, }); - result.large_scroll_status = created.mouse->submit(MouseEvent { - .kind = MouseEventKind::vertical_scroll, + result.operations.large_scroll_status = created.mouse->submit(MouseEvent { + .kind = vertical_scroll, .high_resolution_scroll = 120 * 132, }); - result.partial_scroll_status = created.mouse->submit(MouseEvent { - .kind = MouseEventKind::horizontal_scroll, + result.operations.partial_scroll_status = created.mouse->submit(MouseEvent { + .kind = horizontal_scroll, .high_resolution_scroll = 60, }); - result.completed_scroll_status = created.mouse->submit(MouseEvent { - .kind = MouseEventKind::horizontal_scroll, + result.operations.completed_scroll_status = created.mouse->submit(MouseEvent { + .kind = horizontal_scroll, .high_resolution_scroll = 60, }); - result.close_status = created.mouse->close(); + result.operations.close_status = created.mouse->close(); } - const auto requests = command_state->create_requests(); - if (!requests.empty()) { + if (const auto requests = command_state->create_requests(); !requests.empty()) { const auto &request = requests.front(); - result.device_type = request.device_type; - result.bus_type = request.bus_type; - result.flags = request.flags; - result.vendor_id = request.hardware_ids.vendor_id; - result.product_id = request.hardware_ids.product_id; - result.version = request.hardware_ids.device_version; - result.report_id = request.hardware_ids.report_id; - result.input_report_size = request.report_sizes.input_report_size; - result.output_report_size = request.report_sizes.output_report_size; - result.name = request.name.data(); - result.manufacturer = request.manufacturer.data(); - result.stable_id = request.stable_id.data(); + result.device.device_type = request.device_type; + result.device.bus_type = request.bus_type; + result.device.flags = request.flags; + result.device.vendor_id = request.hardware_ids.vendor_id; + result.device.product_id = request.hardware_ids.product_id; + result.device.version = request.hardware_ids.device_version; + result.device.report_id = request.hardware_ids.report_id; + result.device.input_report_size = request.report_sizes.input_report_size; + result.device.output_report_size = request.report_sizes.output_report_size; + result.device.name = request.name.data(); + result.device.manufacturer = request.manufacturer.data(); + result.device.stable_id = request.stable_id.data(); } - result.reports = command_state->submit_reports(); - result.destroy_requests = command_state->destroy_request_count(); + result.observations.reports = command_state->submit_reports(); + result.observations.destroy_requests = command_state->destroy_request_count(); { auto failure_state = std::make_shared(); @@ -568,7 +569,7 @@ namespace lvh::detail { failure_state, std::make_shared() ); - result.protocol_failure_status = failure_backend->create_mouse(9U, options).status; + result.operations.protocol_failure_status = failure_backend->create_mouse(9U, options).status; } { @@ -583,16 +584,16 @@ namespace lvh::detail { std::make_shared() ); auto fallback = license_backend->create_mouse(10U, options); - result.license_fallback_create_status = fallback.status; + result.operations.license_fallback_create_status = fallback.status; if (fallback) { - result.license_fallback_submit_status = fallback.mouse->submit(MouseEvent { - .kind = MouseEventKind::relative_motion, + result.operations.license_fallback_submit_status = fallback.mouse->submit(MouseEvent { + .kind = relative_motion, .x = 1, .y = 2, }); } - result.license_create_requests = license_state->create_request_count(); - result.license_fallback_send_inputs = send_input_state.sent_inputs.size(); + result.observations.license_create_requests = license_state->create_request_count(); + result.observations.license_fallback_send_inputs = send_input_state.sent_inputs.size(); } return result; diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp index c6a117a..228d84a 100644 --- a/tests/unit/test_windows_backend.cpp +++ b/tests/unit/test_windows_backend.cpp @@ -126,45 +126,45 @@ TEST_F(WindowsBackendTest, PlayStationDefaultsUseEffectiveUsbProfiles) { TEST_F(WindowsBackendTest, HidMousePreservesIdentityChunksInputAndUsesNarrowFallback) { const auto result = lvh::detail::test::windows_backend_hid_mouse(); - expect_ok(result.create_status); - EXPECT_EQ(result.device_type, LVH_WINDOWS_DEVICE_MOUSE); - EXPECT_EQ(result.bus_type, LVH_WINDOWS_BUS_BLUETOOTH); - EXPECT_EQ(result.flags, 0U); - EXPECT_EQ(result.vendor_id, 0x1234U); - EXPECT_EQ(result.product_id, 0x5678U); - EXPECT_EQ(result.version, 0x4321U); - EXPECT_EQ(result.report_id, 0U); - EXPECT_EQ(result.input_report_size, LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE); - EXPECT_EQ(result.output_report_size, 0U); - EXPECT_EQ(result.name, "Configured mouse"); - EXPECT_EQ(result.manufacturer, "Configured manufacturer"); - EXPECT_EQ(result.stable_id, "configured-mouse"); - - expect_ok(result.motion_status); - expect_ok(result.large_scroll_status); - expect_ok(result.partial_scroll_status); - expect_ok(result.completed_scroll_status); - expect_ok(result.close_status); - ASSERT_EQ(result.reports.size(), 6U); - for (const auto &report : result.reports) { + expect_ok(result.operations.create_status); + EXPECT_EQ(result.device.device_type, LVH_WINDOWS_DEVICE_MOUSE); + EXPECT_EQ(result.device.bus_type, LVH_WINDOWS_BUS_BLUETOOTH); + EXPECT_EQ(result.device.flags, 0U); + EXPECT_EQ(result.device.vendor_id, 0x1234U); + EXPECT_EQ(result.device.product_id, 0x5678U); + EXPECT_EQ(result.device.version, 0x4321U); + EXPECT_EQ(result.device.report_id, 0U); + EXPECT_EQ(result.device.input_report_size, LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE); + EXPECT_EQ(result.device.output_report_size, 0U); + EXPECT_EQ(result.device.name, "Configured mouse"); + EXPECT_EQ(result.device.manufacturer, "Configured manufacturer"); + EXPECT_EQ(result.device.stable_id, "configured-mouse"); + + expect_ok(result.operations.motion_status); + expect_ok(result.operations.large_scroll_status); + expect_ok(result.operations.partial_scroll_status); + expect_ok(result.operations.completed_scroll_status); + expect_ok(result.operations.close_status); + ASSERT_EQ(result.observations.reports.size(), 6U); + for (const auto &report : result.observations.reports) { EXPECT_EQ(report.size(), LVH_WINDOWS_MOUSE_INPUT_REPORT_SIZE); } - EXPECT_EQ(mouse_report_i16(result.reports[0], 1U), 32767); - EXPECT_EQ(mouse_report_i16(result.reports[0], 3U), -32768); - EXPECT_EQ(mouse_report_i16(result.reports[1], 1U), 32767); - EXPECT_EQ(mouse_report_i16(result.reports[1], 3U), -32768); - EXPECT_EQ(mouse_report_i16(result.reports[2], 1U), 4466); - EXPECT_EQ(mouse_report_i16(result.reports[2], 3U), -4464); - EXPECT_EQ(static_cast(result.reports[3][5]), 127); - EXPECT_EQ(static_cast(result.reports[4][5]), 5); - EXPECT_EQ(static_cast(result.reports[5][6]), 1); - EXPECT_EQ(result.destroy_requests, 1U); - - EXPECT_EQ(result.protocol_failure_status.code(), lvh::ErrorCode::backend_failure); - expect_ok(result.license_fallback_create_status); - expect_ok(result.license_fallback_submit_status); - EXPECT_EQ(result.license_create_requests, 1U); - EXPECT_EQ(result.license_fallback_send_inputs, 1U); + EXPECT_EQ(mouse_report_i16(result.observations.reports[0], 1U), 32767); + EXPECT_EQ(mouse_report_i16(result.observations.reports[0], 3U), -32768); + EXPECT_EQ(mouse_report_i16(result.observations.reports[1], 1U), 32767); + EXPECT_EQ(mouse_report_i16(result.observations.reports[1], 3U), -32768); + EXPECT_EQ(mouse_report_i16(result.observations.reports[2], 1U), 4466); + EXPECT_EQ(mouse_report_i16(result.observations.reports[2], 3U), -4464); + EXPECT_EQ(static_cast(result.observations.reports[3][5]), 127); + EXPECT_EQ(static_cast(result.observations.reports[4][5]), 5); + EXPECT_EQ(static_cast(result.observations.reports[5][6]), 1); + EXPECT_EQ(result.observations.destroy_requests, 1U); + + EXPECT_EQ(result.operations.protocol_failure_status.code(), lvh::ErrorCode::backend_failure); + expect_ok(result.operations.license_fallback_create_status); + expect_ok(result.operations.license_fallback_submit_status); + EXPECT_EQ(result.observations.license_create_requests, 1U); + EXPECT_EQ(result.observations.license_fallback_send_inputs, 1U); } TEST_F(WindowsBackendTest, GenericPidTimerCannotDeliverStaleStopAfterNewStart) { diff --git a/tests/unit/test_windows_broker_validation.cpp b/tests/unit/test_windows_broker_validation.cpp index bd12a79..1532f0a 100644 --- a/tests/unit/test_windows_broker_validation.cpp +++ b/tests/unit/test_windows_broker_validation.cpp @@ -231,7 +231,7 @@ TEST(WindowsBrokerValidationTest, ValidatesMouseCreateFields) { request.device.report_sizes.output_report_size = 1U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); request.device.report_sizes.output_report_size = 0U; - request.device.report_descriptor[0] ^= 0x01U; + request.device.report_descriptor[0] = 0U; EXPECT_FALSE(lvh::windows::broker_validation::valid_request(request)); } From 4c5c21fb978526c92bda7723dee02b38ad08eff0 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:21:58 -0400 Subject: [PATCH 5/8] style(tests): separate nested result declarations --- .../include/fixtures/windows_backend_test_hooks.hpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp index 91b31a7..c7cc9c7 100644 --- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp @@ -62,7 +62,7 @@ namespace lvh::detail::test { OperationStatus protocol_failure_status; OperationStatus license_fallback_create_status; OperationStatus license_fallback_submit_status; - } operations; + }; struct CreatedDevice { std::uint32_t device_type = 0; @@ -77,14 +77,18 @@ namespace lvh::detail::test { std::string name; std::string manufacturer; std::string stable_id; - } device; + }; struct RequestObservations { std::vector> reports; std::size_t destroy_requests = 0; std::size_t license_create_requests = 0; std::size_t license_fallback_send_inputs = 0; - } observations; + }; + + OperationResults operations; + CreatedDevice device; + RequestObservations observations; }; struct WindowsPlayStationTransportResult { From 248a8e60d6a8f0bb816c99f69dd27a2c0808b1bb Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:20:55 -0400 Subject: [PATCH 6/8] Add mouse controls to virtualhid UI Extend the native control tool to create, inspect, reset, and remove virtual mice alongside gamepads. This adds keyboard-navigable mouse motion, button, and wheel actions, plus delayed browser-test scheduling for focus-sensitive mouse validation. Update the shared control model tests and refresh README and Windows/platform/store-review docs to reflect driver-backed mouse support and its licensing requirements. --- README.md | 9 +- docs/maintainer/store-review-validation.md | 22 +- docs/platform-support.md | 12 +- docs/windows-driver.md | 40 +- tests/unit/test_virtualhid_control_model.cpp | 58 ++ tools/virtualhid_control.cpp | 564 ++++++++++++++++--- tools/virtualhid_control_model.cpp | 55 ++ tools/virtualhid_control_model.hpp | 41 ++ 8 files changed, 710 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 9a03f91..b9ee91d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@

🎮 Windows Virtual HID Driver License

- A license is required to create virtual gamepads with the Windows driver.
+ A license is required to create virtual gamepads or Raw Input-visible mice with the Windows driver.
This requirement is Windows-only; non-Windows backends do not currently require a license.
Yearly and lifetime options are available.

@@ -48,12 +48,13 @@ behind backend implementations. - Descriptor-driven PlayStation gamepads through Linux `uhid`; Generic, Xbox, and Switch Pro gamepads plus keyboard, mouse, touchscreen, trackpad, and pen tablet devices through `uinput`. -- Windows gamepads through a user-mode UMDF2 control driver backed by Virtual - HID Framework, with keyboard and mouse support through normal Win32 APIs. +- Windows gamepads and Raw Input-visible mice through a user-mode UMDF2 control + driver backed by Virtual HID Framework, with keyboard and fallback 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, removing, - controlling, and inspecting test gamepads through the public C++ API. + controlling, and inspecting test gamepads and mice through the public C++ API. - CMake consumption through installed packages, vendored source, `add_subdirectory`, or `FetchContent`. diff --git a/docs/maintainer/store-review-validation.md b/docs/maintainer/store-review-validation.md index 88bc7b7..fe4d3a7 100644 --- a/docs/maintainer/store-review-validation.md +++ b/docs/maintainer/store-review-validation.md @@ -15,7 +15,7 @@ Paste this into the Partner Center certification notes field: ```text This package installs the libvirtualhid Windows user-mode UMDF/VHF virtual HID driver and local broker service. Applications consume it through the libvirtualhid client API, and the MSI includes a native diagnostic UI for local validation. -Every virtual gamepad creation requires an active license. A currently granted review license key with an available device activation is supplied separately in the Partner Center certification credentials or notes. The key is not embedded in the package or this document. +Every virtual gamepad or driver-backed Raw Input mouse creation requires an active license. A currently granted review license key with an available device activation is supplied separately in the Partner Center certification credentials or notes. The key is not embedded in the package or this document. Launch the validation tool below. @@ -31,10 +31,10 @@ Required validation: $installRoot = Join-Path $env:ProgramFiles "libvirtualhid" Start-Process "$installRoot\tools\windows\virtualhid_control.exe" -In the libvirtualhid control window, paste the supplied review key into the License key field and click Activate license. Confirm the status changes to Licensed. Then 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. +In the libvirtualhid control window, paste the supplied review key into the License key field and click Activate license. Confirm the status changes to Licensed. Then 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. Next, change Device type to Mouse and click Create. Use Tab or the arrow keys to highlight the mouse controls and Space or Enter to activate relative movement, momentary button, and wheel input without using the physical mouse. Expected result: -- The backend status reports windows-umdf with gamepad support available +- The backend status reports windows-umdf with gamepad and mouse support available - The libvirtualhid_broker service is running - License validation succeeds and the license status reports Licensed - A virtual HID gamepad is created and appears in the device list @@ -42,6 +42,9 @@ Expected result: HID\VID_045E&PID_0B12&IG_00 - Button, axis, and Share values in the UI can be pressed or moved without errors +- A driver-backed virtual HID mouse is created and appears in the device list +- Keyboard activation of the mouse controls moves the pointer, changes button + state, and scrolls without errors Optional browser validation: $installRoot = Join-Path $env:ProgramFiles "libvirtualhid" @@ -55,6 +58,12 @@ Use the libvirtualhid control window to press buttons or move axes while the bro Expected result: - The browser Gamepad API sees an Xbox-compatible controller - Button and axis values change while controls are used in the validation UI + +For a browser mouse-event tester, create a mouse in the validation UI and enable Delayed browser test. Leave the pointer over the browser test target, activate a movement, button, or wheel action with the keyboard, then switch to the browser before the displayed countdown expires. + +Expected result: +- The browser receives the queued mouse action while it owns focus +- A queued button action produces one press followed by one release ``` ## Manual Review Steps @@ -66,7 +75,8 @@ Expected result: 4. Run the required validation tool from the submission notes. 5. Activate the review key supplied through Partner Center. 6. Create the default gamepad and exercise its controls. -7. Optionally, run the browser validation steps. +7. Create a mouse and exercise its controls with keyboard navigation. +8. 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. @@ -84,5 +94,5 @@ 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, the running -`libvirtualhid_broker` service, and a started HID gamepad child device while -`virtualhid_control.exe` has a gamepad created. +`libvirtualhid_broker` service, and started HID child devices while +`virtualhid_control.exe` has a gamepad and mouse created. diff --git a/docs/platform-support.md b/docs/platform-support.md index d12c7eb..c70d6a1 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -25,8 +25,9 @@ Use capability queries for behavior such as: ## Windows The Windows backend keeps the normal C++ library buildable with MSVC and -MinGW/UCRT64. Keyboard and mouse input use Win32 APIs. Gamepad creation uses a -user-mode UMDF2 control driver and Windows Virtual HID Framework. +MinGW/UCRT64. Gamepad creation and Raw Input-visible relative mouse input use a +user-mode UMDF2 control driver and Windows Virtual HID Framework. Keyboard, +absolute mouse input, and the mouse fallback use Win32 APIs. The C++ library communicates with the driver through fixed-size protocol structures and `DeviceIoControl`, not C++ STL types. This keeps the public API @@ -199,6 +200,13 @@ 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. +The UI can create and exercise both gamepads and mice. Its mouse movement, +button, and wheel controls participate in Dear ImGui keyboard navigation; use +Tab or the arrow keys to highlight them and Space or Enter to activate them. +Mouse buttons are momentary. A delayed browser-test mode queues an action long +enough to switch focus to an external event tester, sending button actions as a +single press-and-release click. + ### Permissions Linux deployment requires both device-node permissions and the kernel modules diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 7f7e7ce..996c2b3 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -29,8 +29,9 @@ Virtual HID Driver installs the user-mode driver component used by compatible applications to create virtual HID gamepads and mice on Windows. The package includes a local diagnostic UI for creating and testing virtual -gamepads. Compatible applications can also request virtual HID gamepads or mice, -and Windows applications that understand standard HID devices can discover them. +gamepads and mice. Compatible applications can also request virtual HID gamepads +or mice, and Windows applications that understand standard HID devices can +discover them. ``` ## Architecture @@ -182,7 +183,7 @@ The WiX installer also places validation files under the default install root, 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. +gamepads and mice interactively. The install helper stages the INF with `pnputil`, updates an existing `ROOT\LIBVIRTUALHID` device when present, and creates that root-enumerated @@ -228,18 +229,33 @@ For interactive local validation, run: tools\windows\virtualhid_control.exe ``` -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. +The native UI can create, remove, control, and monitor gamepads and mice that it +owns. Gamepad buttons are momentary by default, with an explicit lock mode for +held inputs. Mouse controls provide relative movement, five momentary buttons, +vertical scrolling, and horizontal panning. Use Tab or the arrow keys to +highlight a mouse control and Space or Enter to activate it, avoiding use of the +physical mouse while testing the virtual device. A mouse button remains pressed +only while its activation key is held. + +For an external mouse-event tester, enable **Delayed browser test**, choose a +delay, and activate the desired action. Switch to the browser before the +countdown expires while leaving the pointer over its test target. Movement and +wheel actions are submitted once the browser owns focus; a delayed button action +submits one press-and-release click. The scheduler continues while the control +window is unfocused or minimized. + +The UI identifies a driver-backed HID mouse separately from the `SendInput` +fallback and also shows supported profile features, battery input state, device +nodes, and normalized gamepad 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. On Windows, the UI also shows broker license status. It can activate a license key, refresh validation, deactivate the current machine, and open -compiled purchase or account-management URLs. License management and normal -virtual HID device use do not require elevation. +compiled purchase or account-management URLs. The Create button is enabled for +both gamepads and mice only while the broker reports a current machine license. +License management and normal virtual HID device use do not require elevation. ## Installation Notes diff --git a/tests/unit/test_virtualhid_control_model.cpp b/tests/unit/test_virtualhid_control_model.cpp index e9c465e..720124d 100644 --- a/tests/unit/test_virtualhid_control_model.cpp +++ b/tests/unit/test_virtualhid_control_model.cpp @@ -100,6 +100,58 @@ TEST(VirtualHidControlModelTest, MapsProfileChoicesToProfiles) { EXPECT_FALSE(control::profile_for_choice(invalid).has_value()); } +TEST(VirtualHidControlModelTest, ExposesGamepadAndMouseDeviceChoices) { + ASSERT_EQ(control::device_type_choices.size(), 2U); + EXPECT_EQ(control::device_type_choices[0].type, lvh::DeviceType::gamepad); + EXPECT_EQ(control::device_type_choices[1].type, lvh::DeviceType::mouse); + + ASSERT_EQ(control::mouse_button_choices.size(), 5U); + EXPECT_EQ(control::mouse_button_choices[0].button, lvh::MouseButton::left); + EXPECT_EQ(control::mouse_button_choices[1].button, lvh::MouseButton::middle); + EXPECT_EQ(control::mouse_button_choices[2].button, lvh::MouseButton::right); + EXPECT_EQ(control::mouse_button_choices[3].button, lvh::MouseButton::side); + EXPECT_EQ(control::mouse_button_choices[4].button, lvh::MouseButton::extra); +} + +TEST(VirtualHidControlModelTest, MapsKeyboardMouseActionsToEvents) { + using enum control::MouseControlAction; + + const auto left = control::mouse_event_for_action(move_left, 25, 120); + EXPECT_EQ(left.kind, lvh::MouseEventKind::relative_motion); + EXPECT_EQ(left.x, -25); + EXPECT_EQ(left.y, 0); + + const auto right = control::mouse_event_for_action(move_right, 25, 120); + EXPECT_EQ(right.x, 25); + const auto up = control::mouse_event_for_action(move_up, 25, 120); + EXPECT_EQ(up.y, -25); + const auto down = control::mouse_event_for_action(move_down, 25, 120); + EXPECT_EQ(down.y, 25); + + const auto wheel_up_event = control::mouse_event_for_action(wheel_up, 25, 120); + EXPECT_EQ(wheel_up_event.kind, lvh::MouseEventKind::vertical_scroll); + EXPECT_EQ(wheel_up_event.high_resolution_scroll, 120); + const auto wheel_down_event = control::mouse_event_for_action(wheel_down, 25, 120); + EXPECT_EQ(wheel_down_event.high_resolution_scroll, -120); + const auto pan_left_event = control::mouse_event_for_action(pan_left, 25, 120); + EXPECT_EQ(pan_left_event.kind, lvh::MouseEventKind::horizontal_scroll); + EXPECT_EQ(pan_left_event.high_resolution_scroll, -120); + const auto pan_right_event = control::mouse_event_for_action(pan_right, 25, 120); + EXPECT_EQ(pan_right_event.high_resolution_scroll, 120); +} + +TEST(VirtualHidControlModelTest, BuildsMomentaryMouseButtonEvents) { + const auto press = control::mouse_button_event(lvh::MouseButton::side, true); + EXPECT_EQ(press.kind, lvh::MouseEventKind::button); + EXPECT_EQ(press.button, lvh::MouseButton::side); + EXPECT_TRUE(press.pressed); + + const auto release = control::mouse_button_event(lvh::MouseButton::side, false); + EXPECT_EQ(release.kind, lvh::MouseEventKind::button); + EXPECT_EQ(release.button, lvh::MouseButton::side); + EXPECT_FALSE(release.pressed); +} + TEST(VirtualHidControlModelTest, ConvertsSliderValues) { EXPECT_EQ(control::axis_to_slider(-2.0F), -control::slider_scale); EXPECT_EQ(control::axis_to_slider(-0.5F), -50); @@ -132,6 +184,7 @@ TEST(VirtualHidControlModelTest, FormatsRawHex) { TEST(VirtualHidControlModelTest, SummarizesProfileFeatures) { const auto generic = lvh::profiles::generic_gamepad(); const auto dualsense = lvh::profiles::dualsense(); + const auto mouse = lvh::profiles::mouse(); EXPECT_TRUE(control::supports_normalized_feedback(generic)); EXPECT_TRUE(control::supports_normalized_feedback(dualsense)); @@ -144,6 +197,11 @@ TEST(VirtualHidControlModelTest, SummarizesProfileFeatures) { control::profile_feature_summary(dualsense), L"Features: battery yes | rumble yes | trigger rumble no | RGB LED yes | adaptive triggers yes | raw output yes" ); + EXPECT_EQ( + control::device_feature_summary(mouse), + L"Features: relative motion | five buttons | vertical wheel | horizontal wheel" + ); + EXPECT_EQ(control::device_feature_summary(generic), control::profile_feature_summary(generic)); } TEST(VirtualHidControlModelTest, UpdatesVisibleControlsForProfiles) { diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index 06b7f4d..a2ed4d9 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -1,12 +1,13 @@ /** * @file tools/virtualhid_control.cpp - * @brief SDL3 and Dear ImGui UI for creating and testing libvirtualhid gamepads. + * @brief SDL3 and Dear ImGui UI for creating and testing libvirtualhid devices. */ // standard includes #include #include #include +#include #include #include #include @@ -37,7 +38,13 @@ namespace { 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_feature_summary; + using lvh::tools::virtualhid_control::device_type_choices; using lvh::tools::virtualhid_control::device_type_name; + using lvh::tools::virtualhid_control::mouse_button_choices; + using lvh::tools::virtualhid_control::mouse_button_event; + using lvh::tools::virtualhid_control::mouse_event_for_action; + using lvh::tools::virtualhid_control::MouseControlAction; using lvh::tools::virtualhid_control::node_kind_name; using lvh::tools::virtualhid_control::normalized_license_key; using lvh::tools::virtualhid_control::output_kind_name; @@ -45,7 +52,6 @@ namespace { using lvh::tools::virtualhid_control::OutputLogEntry; 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; @@ -57,6 +63,23 @@ namespace { std::unique_ptr adapter; }; + struct ControlledMouse { + std::string profile_label; + std::unique_ptr mouse; + std::array buttons {}; + }; + + struct DeviceHandles { + std::vector> gamepads; + std::vector> mice; + }; + + struct ScheduledMouseEvent { + lvh::DeviceId device_id = 0; + lvh::MouseEvent event; + std::chrono::steady_clock::time_point due_at; + }; + struct DeviceListItem { lvh::DeviceId id = 0; std::string label; @@ -65,10 +88,12 @@ namespace { struct SelectedSnapshot { bool has_device = false; lvh::DeviceId id = 0; + lvh::DeviceType device_type = lvh::DeviceType::gamepad; std::string profile_label; lvh::DeviceProfile profile; lvh::GamepadState state; lvh::GamepadProfileSupport support; + std::array mouse_buttons {}; std::uint64_t submit_count = 0; std::vector nodes; std::vector outputs; @@ -211,6 +236,7 @@ namespace { const auto &caps = runtime.capabilities(); auto text = std::string {"Backend: "} + caps.backend_name; text += caps.supports_gamepad ? " | gamepad available" : " | gamepad unavailable"; + text += caps.supports_mouse ? " | mouse available" : " | mouse unavailable"; text += caps.supports_output_reports ? " | output reports available" : " | output reports unavailable"; return text; } @@ -366,12 +392,12 @@ namespace { } template - void render_create_button(CreateHandler create_gamepad) const { + void render_create_button(CreateHandler create_device) const { #if defined(_WIN32) ScopedDisabled disabled {!snapshot_.broker_available || !snapshot_.licensed}; #endif if (ImGui::Button("Create", {-FLT_MIN, 0.0F})) { - create_gamepad(); + create_device(); } } @@ -473,15 +499,19 @@ namespace { try { close_all_devices(); } catch (const std::exception &ex) { - SDL_Log("Failed to close virtual gamepads: %s", ex.what()); + SDL_Log("Failed to close virtual devices: %s", ex.what()); } catch (...) { - SDL_Log("Failed to close virtual gamepads."); + SDL_Log("Failed to close virtual devices."); } } ControlApp(const ControlApp &) = delete; ControlApp &operator=(const ControlApp &) = delete; + void tick() { + dispatch_due_mouse_events(); + } + void render() { const auto devices = snapshot_devices(); const auto selected = snapshot_selected_device(); @@ -524,50 +554,90 @@ namespace { std::vector snapshot_devices() const { std::vector result; std::lock_guard lock {mutex_}; - result.reserve(devices_.size()); + result.reserve(devices_.size() + mice_.size()); for (const auto &[id, device] : devices_) { result.push_back({.id = id, .label = std::format("#{} {}", id, device.profile_label)}); } + for (const auto &[id, device] : mice_) { + result.push_back({.id = id, .label = std::format("#{} {}", id, device.profile_label)}); + } + std::ranges::sort(result, {}, &DeviceListItem::id); return result; } SelectedSnapshot snapshot_selected_device() const { std::lock_guard lock {mutex_}; SelectedSnapshot snapshot; - const auto *device = selected_device_locked(); - if (device == nullptr || device->adapter == nullptr || device->adapter->gamepad() == nullptr) { + if (const auto *device = selected_device_locked(); device != nullptr && device->adapter != nullptr && device->adapter->gamepad() != nullptr) { + const auto *gamepad = device->adapter->gamepad(); + snapshot.has_device = true; + snapshot.id = gamepad->device_id(); + snapshot.device_type = lvh::DeviceType::gamepad; + snapshot.profile_label = device->profile_label; + snapshot.profile = gamepad->profile(); + snapshot.state = device->adapter->state(); + snapshot.support = device->adapter->support(); + snapshot.submit_count = gamepad->submit_count(); + snapshot.nodes = gamepad->device_nodes(); + snapshot.outputs = device->outputs; + snapshot.output_text = to_utf8(output_summary(*device, snapshot.profile)); + + std::ostringstream state; + state << snapshot.profile_label << " #" << snapshot.id << "\n" + << to_utf8(device_type_name(snapshot.profile.device_type)) << " | " << snapshot.profile.name << "\n" + << "L(" << snapshot.state.left_stick.x << ", " << snapshot.state.left_stick.y << ") " + << "R(" << snapshot.state.right_stick.x << ", " << snapshot.state.right_stick.y << ") " + << "LT " << snapshot.state.left_trigger << " RT " << snapshot.state.right_trigger << "\n"; + if (snapshot.state.battery) { + state << "Battery " << to_utf8(battery_state_name(snapshot.state.battery->state)) << " " + << static_cast(snapshot.state.battery->percentage) << "% | "; + } else { + state << "Battery unset | "; + } + state << snapshot.submit_count << " submits"; + snapshot.state_text = state.str(); + return snapshot; + } + + const auto *device = selected_mouse_locked(); + if (device == nullptr || device->mouse == nullptr) { return snapshot; } - const auto *gamepad = device->adapter->gamepad(); snapshot.has_device = true; - snapshot.id = gamepad->device_id(); + snapshot.id = device->mouse->device_id(); + snapshot.device_type = lvh::DeviceType::mouse; snapshot.profile_label = device->profile_label; - snapshot.profile = gamepad->profile(); - snapshot.state = device->adapter->state(); - snapshot.support = device->adapter->support(); - snapshot.submit_count = gamepad->submit_count(); - snapshot.nodes = gamepad->device_nodes(); - snapshot.outputs = device->outputs; - snapshot.output_text = to_utf8(output_summary(*device, snapshot.profile)); + snapshot.profile = device->mouse->profile(); + snapshot.mouse_buttons = device->buttons; + snapshot.submit_count = device->mouse->submit_count(); + snapshot.nodes = device->mouse->device_nodes(); + snapshot.output_text = "Output: mouse is input-only."; std::ostringstream state; state << snapshot.profile_label << " #" << snapshot.id << "\n" << to_utf8(device_type_name(snapshot.profile.device_type)) << " | " << snapshot.profile.name << "\n" - << "L(" << snapshot.state.left_stick.x << ", " << snapshot.state.left_stick.y << ") " - << "R(" << snapshot.state.right_stick.x << ", " << snapshot.state.right_stick.y << ") " - << "LT " << snapshot.state.left_trigger << " RT " << snapshot.state.right_trigger << "\n"; - if (snapshot.state.battery) { - state << "Battery " << to_utf8(battery_state_name(snapshot.state.battery->state)) << " " - << static_cast(snapshot.state.battery->percentage) << "% | "; - } else { - state << "Battery unset | "; - } - state << snapshot.submit_count << " submits"; + << snapshot.submit_count << " submits"; +#if defined(_WIN32) + state << "\n" + << (snapshot.nodes.empty() ? "SendInput fallback (no HID device node)" : "Driver-backed HID mouse"); +#endif snapshot.state_text = state.str(); return snapshot; } + const lvh::tools::virtualhid_control::DeviceTypeChoice *current_device_type_choice() const { + if (device_type_index_ >= device_type_choices.size()) { + return nullptr; + } + return &device_type_choices[device_type_index_]; + } + + lvh::DeviceType current_device_type() const { + const auto *choice = current_device_type_choice(); + return choice == nullptr ? lvh::DeviceType::gamepad : choice->type; + } + const lvh::tools::virtualhid_control::ProfileChoice *current_profile_choice() const { if (profile_index_ >= profile_choices.size()) { return nullptr; @@ -587,6 +657,9 @@ namespace { if (selected.has_device) { return selected.profile; } + if (current_device_type() == lvh::DeviceType::mouse) { + return lvh::profiles::mouse(); + } return current_profile(); } @@ -596,14 +669,14 @@ namespace { }); ImGui::Separator(); - ImGui::TextUnformatted("Profile"); - const auto *choice = current_profile_choice(); - if (const auto preview = choice == nullptr ? std::string {"Select profile"} : to_utf8(choice->label); ImGui::BeginCombo("##profile", preview.c_str())) { - for (std::size_t index = 0; index < profile_choices.size(); ++index) { - const auto label = to_utf8(profile_choices[index].label); - const auto selected = index == profile_index_; + ImGui::TextUnformatted("Device type"); + const auto *device_type_choice = current_device_type_choice(); + if (const auto preview = device_type_choice == nullptr ? std::string {"Select device type"} : to_utf8(device_type_choice->label); ImGui::BeginCombo("##device-type", preview.c_str())) { + for (std::size_t index = 0; index < device_type_choices.size(); ++index) { + const auto label = to_utf8(device_type_choices[index].label); + const auto selected = index == device_type_index_; if (ImGui::Selectable(label.c_str(), selected)) { - profile_index_ = index; + device_type_index_ = index; } if (selected) { ImGui::SetItemDefaultFocus(); @@ -612,8 +685,28 @@ namespace { ImGui::EndCombo(); } + ImGui::TextUnformatted("Profile"); + if (current_device_type() == lvh::DeviceType::gamepad) { + const auto *choice = current_profile_choice(); + if (const auto preview = choice == nullptr ? std::string {"Select profile"} : to_utf8(choice->label); ImGui::BeginCombo("##profile", preview.c_str())) { + for (std::size_t index = 0; index < profile_choices.size(); ++index) { + const auto label = to_utf8(profile_choices[index].label); + const auto selected = index == profile_index_; + if (ImGui::Selectable(label.c_str(), selected)) { + profile_index_ = index; + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + } else { + ImGui::TextUnformatted("Generic five-button mouse"); + } + license_panel_.render_create_button([this] { - create_gamepad(); + create_selected_device(); }); ImGui::Spacing(); @@ -657,22 +750,148 @@ namespace { } if (profile) { - const auto feature_text = to_utf8(profile_feature_summary(*profile)); + const auto feature_text = to_utf8(device_feature_summary(*profile)); ImGui::TextWrapped("%s", feature_text.c_str()); } else { ImGui::TextDisabled("No profile selected."); } ImGui::Separator(); - render_button_controls(selected, profile); - ImGui::Separator(); - render_axis_controls(selected); - ImGui::Separator(); - render_battery_controls(selected, profile); + const auto device_type = selected.has_device ? selected.device_type : current_device_type(); + if (device_type == lvh::DeviceType::mouse) { + render_mouse_controls(selected); + } else { + render_button_controls(selected, profile); + ImGui::Separator(); + render_axis_controls(selected); + ImGui::Separator(); + render_battery_controls(selected, profile); + } ImGui::Separator(); render_output_controls(selected); } + void render_mouse_controls(const SelectedSnapshot &selected) { + ImGui::TextWrapped( + "Keyboard control: use Tab or arrow keys to highlight a control, then Space or Enter to activate it. " + "Mouse buttons remain pressed only while the activation key is held." + ); + + ImGui::SliderInt("Movement step", &mouse_motion_step_, 1, 500); + ImGui::SliderInt("Scroll step", &mouse_scroll_step_, 1, 1200); + + if (ImGui::Checkbox("Delayed browser test", &mouse_delayed_test_); ImGui::IsItemDeactivatedAfterEdit()) { + mouse_button_active_.fill(false); + cancel_all_scheduled_mouse_events(); + } + if (mouse_delayed_test_) { + ImGui::SliderInt("Delay (seconds)", &mouse_test_delay_seconds_, 1, 10); + ImGui::TextWrapped( + "Activate an action, then switch to the browser before the delay expires. Button actions send one " + "press-and-release click. Keep the pointer over the browser test target." + ); + render_scheduled_mouse_status(); + } + + ImGui::TextUnformatted("Relative movement"); + if (ImGui::BeginTable("mouse-motion", 4, ImGuiTableFlags_SizingStretchSame)) { + render_mouse_action_button(selected, "Move left", MouseControlAction::move_left); + render_mouse_action_button(selected, "Move up", MouseControlAction::move_up); + render_mouse_action_button(selected, "Move down", MouseControlAction::move_down); + render_mouse_action_button(selected, "Move right", MouseControlAction::move_right); + ImGui::EndTable(); + } + + ImGui::TextUnformatted("Buttons (momentary)"); + if (ImGui::BeginTable("mouse-buttons", 3, ImGuiTableFlags_SizingStretchSame)) { + for (std::size_t index = 0; index < mouse_button_choices.size(); ++index) { + render_mouse_button_control(selected, index); + } + ImGui::EndTable(); + } + + ImGui::TextUnformatted("Wheel"); + if (ImGui::BeginTable("mouse-wheel", 4, ImGuiTableFlags_SizingStretchSame)) { + render_mouse_action_button(selected, "Pan left", MouseControlAction::pan_left); + render_mouse_action_button(selected, "Wheel up", MouseControlAction::wheel_up); + render_mouse_action_button(selected, "Wheel down", MouseControlAction::wheel_down); + render_mouse_action_button(selected, "Pan right", MouseControlAction::pan_right); + ImGui::EndTable(); + } + } + + void render_scheduled_mouse_status() { + if (scheduled_mouse_events_.empty()) { + ImGui::TextDisabled("No browser-test action queued."); + return; + } + + const auto next = std::ranges::min_element(scheduled_mouse_events_, {}, &ScheduledMouseEvent::due_at); + const auto remaining = std::max( + std::chrono::duration {next->due_at - std::chrono::steady_clock::now()}.count(), + 0.0F + ); + ImGui::TextColored( + {1.0F, 0.8F, 0.2F, 1.0F}, + "Browser-test input queued: switch windows now (%.1f s).", + remaining + ); + if (ImGui::Button("Cancel queued input")) { + cancel_all_scheduled_mouse_events(); + } + } + + void render_mouse_action_button( + const SelectedSnapshot &selected, + const char *label, + MouseControlAction action + ) { + ImGui::TableNextColumn(); + ScopedDisabled disabled {!selected.has_device || selected.device_type != lvh::DeviceType::mouse}; + if (ImGui::Button(label, {-FLT_MIN, 30.0F})) { + const auto event = mouse_event_for_action(action, mouse_motion_step_, mouse_scroll_step_); + if (mouse_delayed_test_) { + schedule_mouse_event(selected.id, event); + } else { + submit_mouse_event(selected.id, event); + } + } + } + + void render_mouse_button_control(const SelectedSnapshot &selected, std::size_t index) { + ImGui::TableNextColumn(); + ImGui::PushID(static_cast(index)); + + const auto pressed = selected.has_device && selected.device_type == lvh::DeviceType::mouse && + selected.mouse_buttons[index]; + if (pressed) { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); + } + + auto clicked = false; + auto active = false; + { + ScopedDisabled disabled {!selected.has_device || selected.device_type != lvh::DeviceType::mouse}; + const auto label = to_utf8(mouse_button_choices[index].label); + clicked = ImGui::Button(label.c_str(), {-FLT_MIN, 30.0F}); + active = ImGui::IsItemActive(); + } + + if (mouse_delayed_test_) { + if (clicked) { + schedule_mouse_click(selected.id, mouse_button_choices[index].button); + } + } else if (mouse_button_active_[index] != active) { + mouse_button_active_[index] = active; + submit_mouse_event(selected.id, mouse_button_event(mouse_button_choices[index].button, active)); + } + + if (pressed) { + ImGui::PopStyleColor(); + } + ImGui::PopID(); + } + void render_button_controls( const SelectedSnapshot &selected, const std::optional &profile @@ -854,6 +1073,14 @@ namespace { } } + void create_selected_device() { + if (current_device_type() == lvh::DeviceType::mouse) { + create_mouse(); + } else { + create_gamepad(); + } + } + void create_gamepad() { const auto *choice = current_profile_choice(); if (choice == nullptr) { @@ -907,17 +1134,58 @@ namespace { license_panel_.refresh(); } + void create_mouse() { + lvh::CreateMouseOptions options; + options.profile = lvh::profiles::mouse(); + options.stable_id = std::format("libvirtualhid-control-mouse-{}", next_metadata_index_++); + + auto created = runtime_->create_mouse(options); + if (!created) { + show_error(created.status.message()); + return; + } + + const auto id = created.mouse->device_id(); + ControlledMouse device; + device.profile_label = "Mouse"; + device.mouse = std::move(created.mouse); + + { + std::lock_guard lock {mutex_}; + mice_[id] = std::move(device); + } + selected_id_ = id; + license_panel_.refresh(); + } + void reset_selected_device() { auto status = lvh::OperationStatus::success(); + auto reset_id = lvh::DeviceId {0}; { std::lock_guard lock {mutex_}; - auto *device = selected_device_locked(); - if (device == nullptr) { + reset_id = selected_id_; + if (auto *device = selected_device_locked(); device != nullptr) { + status = device->adapter->set_state({}); + } else if (auto *mouse = selected_mouse_locked(); mouse != nullptr) { + for (std::size_t index = 0; index < mouse->buttons.size(); ++index) { + if (!mouse->buttons[index]) { + continue; + } + + const auto release_status = mouse->mouse->button(mouse_button_choices[index].button, false); + if (release_status.ok()) { + mouse->buttons[index] = false; + } else if (status.ok()) { + status = release_status; + } + } + } else { return; } - status = device->adapter->set_state({}); } button_active_.fill(false); + mouse_button_active_.fill(false); + cancel_scheduled_mouse_events(reset_id); if (!status.ok()) { show_error(status.message()); } @@ -925,35 +1193,147 @@ namespace { void remove_selected_device() { std::unique_ptr adapter; + std::unique_ptr mouse; + auto removed_id = lvh::DeviceId {0}; { std::lock_guard lock {mutex_}; if (selected_id_ == 0) { return; } - const auto iter = devices_.find(selected_id_); - if (iter == devices_.end()) { + removed_id = selected_id_; + + if (const auto gamepad_iter = devices_.find(selected_id_); gamepad_iter != devices_.end()) { + adapter = std::move(gamepad_iter->second.adapter); + devices_.erase(gamepad_iter); + } else if (const auto mouse_iter = mice_.find(selected_id_); mouse_iter != mice_.end()) { + mouse = std::move(mouse_iter->second.mouse); + mice_.erase(mouse_iter); + } else { return; } - adapter = std::move(iter->second.adapter); - devices_.erase(iter); - selected_id_ = devices_.empty() ? 0 : devices_.begin()->first; + + selected_id_ = first_device_id_locked(); } button_active_.fill(false); - if (adapter) { - if (const auto status = adapter->close(); !status.ok()) { - show_error(status.message()); - } + mouse_button_active_.fill(false); + cancel_scheduled_mouse_events(removed_id); + + auto status = lvh::OperationStatus::success(); + if (adapter != nullptr) { + status = adapter->close(); + } else if (mouse != nullptr) { + status = mouse->close(); + } + if (!status.ok()) { + show_error(status.message()); } license_panel_.refresh(); } void remove_all_devices() { - const auto adapters = take_all_adapters(); + const auto handles = take_all_devices(); button_active_.fill(false); - close_adapters(adapters, true); + mouse_button_active_.fill(false); + scheduled_mouse_events_.clear(); + close_devices(handles, true); license_panel_.refresh(); } + void submit_mouse_event(lvh::DeviceId id, const lvh::MouseEvent &event) { + auto status = lvh::OperationStatus::success(); + { + std::lock_guard lock {mutex_}; + const auto iter = mice_.find(id); + if (iter == mice_.end() || iter->second.mouse == nullptr) { + return; + } + + auto &device = iter->second; + status = device.mouse->submit(event); + if (status.ok() && event.kind == lvh::MouseEventKind::button) { + for (std::size_t index = 0; index < mouse_button_choices.size(); ++index) { + if (mouse_button_choices[index].button == event.button) { + device.buttons[index] = event.pressed; + break; + } + } + } + } + if (!status.ok()) { + show_error(status.message()); + } + } + + void schedule_mouse_event(lvh::DeviceId id, const lvh::MouseEvent &event) { + if (id == 0) { + return; + } + scheduled_mouse_events_.push_back({ + .device_id = id, + .event = event, + .due_at = std::chrono::steady_clock::now() + std::chrono::seconds {mouse_test_delay_seconds_}, + }); + } + + void schedule_mouse_click(lvh::DeviceId id, lvh::MouseButton button) { + if (id == 0) { + return; + } + + const auto press_at = std::chrono::steady_clock::now() + std::chrono::seconds {mouse_test_delay_seconds_}; + scheduled_mouse_events_.push_back({ + .device_id = id, + .event = mouse_button_event(button, true), + .due_at = press_at, + }); + scheduled_mouse_events_.push_back({ + .device_id = id, + .event = mouse_button_event(button, false), + .due_at = press_at + std::chrono::milliseconds {100}, + }); + } + + void dispatch_due_mouse_events() { + const auto now = std::chrono::steady_clock::now(); + auto iter = scheduled_mouse_events_.begin(); + while (iter != scheduled_mouse_events_.end()) { + if (iter->due_at > now) { + ++iter; + continue; + } + + const auto event = *iter; + iter = scheduled_mouse_events_.erase(iter); + submit_mouse_event(event.device_id, event.event); + } + } + + void cancel_scheduled_mouse_events(lvh::DeviceId id) { + std::erase_if(scheduled_mouse_events_, [id](const ScheduledMouseEvent &event) { + return event.device_id == id; + }); + } + + void cancel_all_scheduled_mouse_events() { + scheduled_mouse_events_.clear(); + + std::vector> releases; + { + std::lock_guard lock {mutex_}; + for (const auto &[id, device] : mice_) { + for (std::size_t index = 0; index < device.buttons.size(); ++index) { + if (device.buttons[index]) { + releases.emplace_back(id, mouse_button_choices[index].button); + } + } + } + } + + for (const auto &[id, button] : releases) { + submit_mouse_event(id, mouse_button_event(button, false)); + } + } + void toggle_selected_button(std::size_t index) { auto pressed = false; { @@ -1105,39 +1485,57 @@ namespace { } void close_all_devices() { - const auto adapters = take_all_adapters(); - close_adapters(adapters, false); + const auto handles = take_all_devices(); + close_devices(handles, false); } - std::vector> take_all_adapters() { - std::vector> adapters; + DeviceHandles take_all_devices() { + DeviceHandles handles; std::lock_guard lock {mutex_}; for (auto &[id, device] : devices_) { - adapters.push_back(std::move(device.adapter)); + handles.gamepads.push_back(std::move(device.adapter)); + } + for (auto &[id, device] : mice_) { + handles.mice.push_back(std::move(device.mouse)); } devices_.clear(); + mice_.clear(); selected_id_ = 0; - return adapters; + return handles; } - void close_adapters( - const std::vector> &adapters, - bool report_errors - ) { + void close_devices(const DeviceHandles &handles, bool report_errors) { std::optional first_error; - for (const auto &adapter : adapters) { + for (const auto &adapter : handles.gamepads) { if (adapter) { if (const auto status = adapter->close(); !status.ok() && report_errors && !first_error) { first_error = std::string {status.message()}; } } } + for (const auto &mouse : handles.mice) { + if (mouse) { + if (const auto status = mouse->close(); !status.ok() && report_errors && !first_error) { + first_error = std::string {status.message()}; + } + } + } if (first_error) { show_error(*first_error); } } + lvh::DeviceId first_device_id_locked() const { + if (devices_.empty()) { + return mice_.empty() ? 0 : mice_.begin()->first; + } + if (mice_.empty()) { + return devices_.begin()->first; + } + return std::min(devices_.begin()->first, mice_.begin()->first); + } + ControlledGamepad *selected_device_locked() { if (selected_id_ == 0) { return nullptr; @@ -1160,6 +1558,28 @@ namespace { return &iter->second; } + ControlledMouse *selected_mouse_locked() { + if (selected_id_ == 0) { + return nullptr; + } + const auto iter = mice_.find(selected_id_); + if (iter == mice_.end()) { + return nullptr; + } + return &iter->second; + } + + const ControlledMouse *selected_mouse_locked() const { + if (selected_id_ == 0) { + return nullptr; + } + const auto iter = mice_.find(selected_id_); + if (iter == mice_.end()) { + return nullptr; + } + return &iter->second; + } + void show_error(std::string_view message) { error_message_ = message.empty() ? "Operation failed." : std::string {message}; open_error_popup_ = true; @@ -1168,10 +1588,18 @@ namespace { std::unique_ptr runtime_; mutable std::mutex mutex_; std::map devices_; + std::map mice_; lvh::DeviceId selected_id_ = 0; + std::size_t device_type_index_ = 0; std::size_t profile_index_ = 3; bool lock_buttons_ = false; std::array button_active_ {}; + std::array mouse_button_active_ {}; + int mouse_motion_step_ = 25; + int mouse_scroll_step_ = 120; + bool mouse_delayed_test_ = false; + int mouse_test_delay_seconds_ = 3; + std::vector scheduled_mouse_events_; int battery_state_index_ = battery_choice_index(lvh::GamepadBatteryState::full); int battery_percentage_ = 100; std::string error_message_; @@ -1258,6 +1686,8 @@ namespace { } } + app.tick(); + if ((SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) != 0U) { SDL_Delay(10); continue; diff --git a/tools/virtualhid_control_model.cpp b/tools/virtualhid_control_model.cpp index d5eb8af..d459b76 100644 --- a/tools/virtualhid_control_model.cpp +++ b/tools/virtualhid_control_model.cpp @@ -143,6 +143,54 @@ namespace lvh::tools::virtualhid_control { return std::nullopt; } + MouseEvent mouse_event_for_action( + MouseControlAction action, + std::int32_t motion_step, + std::int32_t scroll_step + ) { + using enum MouseControlAction; + + switch (action) { + case move_left: + return MouseEvent {.kind = MouseEventKind::relative_motion, .x = -motion_step}; + case move_right: + return MouseEvent {.kind = MouseEventKind::relative_motion, .x = motion_step}; + case move_up: + return MouseEvent {.kind = MouseEventKind::relative_motion, .y = -motion_step}; + case move_down: + return MouseEvent {.kind = MouseEventKind::relative_motion, .y = motion_step}; + case wheel_up: + return MouseEvent { + .kind = MouseEventKind::vertical_scroll, + .high_resolution_scroll = scroll_step, + }; + case wheel_down: + return MouseEvent { + .kind = MouseEventKind::vertical_scroll, + .high_resolution_scroll = -scroll_step, + }; + case pan_left: + return MouseEvent { + .kind = MouseEventKind::horizontal_scroll, + .high_resolution_scroll = -scroll_step, + }; + case pan_right: + return MouseEvent { + .kind = MouseEventKind::horizontal_scroll, + .high_resolution_scroll = scroll_step, + }; + } + return {}; + } + + MouseEvent mouse_button_event(MouseButton button, bool pressed) { + return MouseEvent { + .kind = MouseEventKind::button, + .button = button, + .pressed = pressed, + }; + } + int axis_to_slider(float value) { return static_cast(std::lround(std::clamp(value, -1.0F, 1.0F) * static_cast(slider_scale))); } @@ -191,6 +239,13 @@ namespace lvh::tools::virtualhid_control { return stream.str(); } + std::wstring device_feature_summary(const DeviceProfile &profile) { + if (profile.device_type == DeviceType::mouse) { + return L"Features: relative motion | five buttons | vertical wheel | horizontal wheel"; + } + return profile_feature_summary(profile); + } + bool append_latest_output_summary(std::wostringstream &stream, const OutputState &state) { auto wrote = false; if (state.latest_rumble) { diff --git a/tools/virtualhid_control_model.hpp b/tools/virtualhid_control_model.hpp index 502a160..9c97651 100644 --- a/tools/virtualhid_control_model.hpp +++ b/tools/virtualhid_control_model.hpp @@ -22,6 +22,11 @@ namespace lvh::tools::virtualhid_control { inline constexpr auto slider_scale = 100; + struct DeviceTypeChoice { + std::wstring_view label; + DeviceType type; + }; + struct ProfileChoice { std::wstring_view id; std::wstring_view label; @@ -34,6 +39,22 @@ namespace lvh::tools::virtualhid_control { GamepadButton button; }; + struct MouseButtonChoice { + std::wstring_view label; + MouseButton button; + }; + + enum class MouseControlAction { + move_left, + move_right, + move_up, + move_down, + wheel_up, + wheel_down, + pan_left, + pan_right, + }; + struct AxisChoice { std::wstring_view label; int minimum; @@ -59,6 +80,11 @@ namespace lvh::tools::virtualhid_control { std::optional latest_raw_report; }; + inline constexpr std::array device_type_choices { + DeviceTypeChoice {L"Gamepad", DeviceType::gamepad}, + DeviceTypeChoice {L"Mouse", DeviceType::mouse}, + }; + 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}, @@ -93,6 +119,14 @@ namespace lvh::tools::virtualhid_control { ButtonChoice {L"Paddle 4", GamepadButton::paddle4}, }; + inline constexpr std::array mouse_button_choices { + MouseButtonChoice {L"Left", MouseButton::left}, + MouseButtonChoice {L"Middle", MouseButton::middle}, + MouseButtonChoice {L"Right", MouseButton::right}, + MouseButtonChoice {L"Side", MouseButton::side}, + MouseButtonChoice {L"Extra", MouseButton::extra}, + }; + inline constexpr std::array axis_choices { AxisChoice {L"Left X", -slider_scale, slider_scale}, AxisChoice {L"Left Y", -slider_scale, slider_scale}, @@ -119,6 +153,12 @@ namespace lvh::tools::virtualhid_control { int battery_choice_index(GamepadBatteryState state); std::wstring raw_hex(const std::vector &bytes); std::optional profile_for_choice(const ProfileChoice &choice); + MouseEvent mouse_event_for_action( + MouseControlAction action, + std::int32_t motion_step, + std::int32_t scroll_step + ); + MouseEvent mouse_button_event(MouseButton button, bool pressed); int axis_to_slider(float value); int trigger_to_slider(float value); float slider_to_float(long value); @@ -134,6 +174,7 @@ namespace lvh::tools::virtualhid_control { bool supports_normalized_feedback(const DeviceProfile &profile); std::wstring profile_feature_summary(const DeviceProfile &profile); + std::wstring device_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( From 35fdc519006eee0d38218bd44ead7657ce29061d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:59:57 -0400 Subject: [PATCH 7/8] Sonar fixes --- tools/virtualhid_control.cpp | 940 +++++++++++++++++------------ tools/virtualhid_control_model.cpp | 17 +- 2 files changed, 553 insertions(+), 404 deletions(-) diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index a2ed4d9..db9abfa 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -101,6 +101,19 @@ namespace { std::string output_text; }; + lvh::DeviceId first_device_id( + const std::map &gamepads, + const std::map &mice + ) { + if (gamepads.empty()) { + return mice.empty() ? 0 : mice.begin()->first; + } + if (mice.empty()) { + return gamepads.begin()->first; + } + return std::min(gamepads.begin()->first, mice.begin()->first); + } + class ScopedDisabled { public: explicit ScopedDisabled(bool disabled): @@ -446,22 +459,480 @@ namespace { } } - template - void deactivate(ErrorHandler &show_error) { - std::string error; - apply_result(lvh::deactivate_license(), error); - if (!error.empty()) { - show_error(error); + template + void deactivate(ErrorHandler &show_error) { + std::string error; + apply_result(lvh::deactivate_license(), error); + if (!error.empty()) { + show_error(error); + } + } +#endif + + LicenseSnapshot snapshot_; +#if defined(_WIN32) + std::array license_key_input_ {}; +#endif + std::string buy_url_; + std::string manage_account_url_; + }; + + class ErrorPanel { + public: + void show(std::string_view message) { + message_ = message.empty() ? "Operation failed." : std::string {message}; + open_popup_ = true; + } + + void render() { + if (open_popup_) { + ImGui::OpenPopup("Error"); + open_popup_ = false; + } + + if (ImGui::BeginPopupModal("Error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextWrapped("%s", message_.c_str()); + if (ImGui::Button("OK", {120.0F, 0.0F})) { + message_.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } + + private: + std::string message_; + bool open_popup_ = false; + }; + + class DevicePanel { + public: + void refresh_license() { + license_panel_.refresh(); + } + + lvh::DeviceType current_device_type() const { + const auto *choice = current_device_type_choice(); + return choice == nullptr ? lvh::DeviceType::gamepad : choice->type; + } + + std::optional current_profile() const { + const auto *choice = current_profile_choice(); + if (choice == nullptr) { + return std::nullopt; + } + return profile_for_choice(*choice); + } + + const lvh::tools::virtualhid_control::ProfileChoice *selected_profile_choice() const { + return current_profile_choice(); + } + + std::optional control_profile(const SelectedSnapshot &selected) const { + if (selected.has_device) { + return selected.profile; + } + if (current_device_type() == lvh::DeviceType::mouse) { + return lvh::profiles::mouse(); + } + return current_profile(); + } + + template + void render( + const std::vector &devices, + lvh::DeviceId &selected_id, + CreateHandler create_device, + ResetHandler reset_device, + RemoveHandler remove_device, + RemoveAllHandler remove_all_devices, + ErrorHandler show_error + ) { + license_panel_.render(show_error); + ImGui::Separator(); + render_device_type_selector(); + render_profile_selector(); + license_panel_.render_create_button(create_device); + render_device_list(devices, selected_id); + render_device_actions(devices.empty(), selected_id, reset_device, remove_device, remove_all_devices); + } + + private: + const lvh::tools::virtualhid_control::DeviceTypeChoice *current_device_type_choice() const { + if (device_type_index_ >= device_type_choices.size()) { + return nullptr; + } + return &device_type_choices[device_type_index_]; + } + + const lvh::tools::virtualhid_control::ProfileChoice *current_profile_choice() const { + if (profile_index_ >= profile_choices.size()) { + return nullptr; + } + return &profile_choices[profile_index_]; + } + + void render_device_type_selector() { + ImGui::TextUnformatted("Device type"); + const auto *choice = current_device_type_choice(); + const auto preview = choice == nullptr ? std::string {"Select device type"} : to_utf8(choice->label); + if (!ImGui::BeginCombo("##device-type", preview.c_str())) { + return; + } + + for (std::size_t index = 0; index < device_type_choices.size(); ++index) { + render_device_type_option(index); + } + ImGui::EndCombo(); + } + + void render_device_type_option(std::size_t index) { + const auto label = to_utf8(device_type_choices[index].label); + const auto selected = index == device_type_index_; + if (ImGui::Selectable(label.c_str(), selected)) { + device_type_index_ = index; + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + + void render_profile_selector() { + ImGui::TextUnformatted("Profile"); + if (current_device_type() != lvh::DeviceType::gamepad) { + ImGui::TextUnformatted("Generic five-button mouse"); + return; + } + + const auto *choice = current_profile_choice(); + const auto preview = choice == nullptr ? std::string {"Select profile"} : to_utf8(choice->label); + if (!ImGui::BeginCombo("##profile", preview.c_str())) { + return; + } + + for (std::size_t index = 0; index < profile_choices.size(); ++index) { + render_profile_option(index); + } + ImGui::EndCombo(); + } + + void render_profile_option(std::size_t index) { + const auto label = to_utf8(profile_choices[index].label); + const auto selected = index == profile_index_; + if (ImGui::Selectable(label.c_str(), selected)) { + profile_index_ = index; + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + + void render_device_list(const std::vector &devices, lvh::DeviceId &selected_id) const { + ImGui::Spacing(); + ImGui::TextUnformatted("Devices"); + if (!ImGui::BeginListBox("##devices", {-FLT_MIN, 150.0F})) { + return; + } + + if (devices.empty()) { + ImGui::TextDisabled("No devices"); + } + for (const auto &device : devices) { + if (ImGui::Selectable(device.label.c_str(), selected_id == device.id)) { + selected_id = device.id; + } + } + ImGui::EndListBox(); + } + + template + void render_device_actions( + bool devices_empty, + lvh::DeviceId selected_id, + ResetHandler reset_device, + RemoveHandler remove_device, + RemoveAllHandler remove_all_devices + ) const { + { + ScopedDisabled disabled {selected_id == 0}; + if (ImGui::Button("Reset", {-FLT_MIN, 0.0F})) { + reset_device(); + } + if (ImGui::Button("Remove selected", {-FLT_MIN, 0.0F})) { + remove_device(); + } + } + { + ScopedDisabled disabled {devices_empty}; + if (ImGui::Button("Remove all", {-FLT_MIN, 0.0F})) { + remove_all_devices(); + } + } + } + + LicensePanel license_panel_; + std::size_t device_type_index_ = 0; + std::size_t profile_index_ = 3; + }; + + class MouseControlPanel { + public: + template + void tick(SubmitHandler submit_event) { + dispatch_due_events(submit_event); + } + + template + void render(const SelectedSnapshot &selected, SubmitHandler submit_event) { + using enum MouseControlAction; + + ImGui::TextWrapped( + "Keyboard control: use Tab or arrow keys to highlight a control, then Space or Enter to activate it. " + "Mouse buttons remain pressed only while the activation key is held." + ); + + ImGui::SliderInt("Movement step", &motion_step_, 1, 500); + ImGui::SliderInt("Scroll step", &scroll_step_, 1, 1200); + + ImGui::Checkbox("Delayed browser test", &delayed_test_); + if (ImGui::IsItemDeactivatedAfterEdit()) { + release_active_buttons(submit_event); + cancel_all_scheduled_events(submit_event); + } + if (delayed_test_) { + render_delayed_test_controls(submit_event); + } + + ImGui::TextUnformatted("Relative movement"); + if (ImGui::BeginTable("mouse-motion", 4, ImGuiTableFlags_SizingStretchSame)) { + render_action_button(selected, "Move left", move_left, submit_event); + render_action_button(selected, "Move up", move_up, submit_event); + render_action_button(selected, "Move down", move_down, submit_event); + render_action_button(selected, "Move right", move_right, submit_event); + ImGui::EndTable(); + } + + ImGui::TextUnformatted("Buttons (momentary)"); + if (ImGui::BeginTable("mouse-buttons", 3, ImGuiTableFlags_SizingStretchSame)) { + for (std::size_t index = 0; index < mouse_button_choices.size(); ++index) { + render_button_control(selected, index, submit_event); + } + ImGui::EndTable(); + } + + ImGui::TextUnformatted("Wheel"); + if (ImGui::BeginTable("mouse-wheel", 4, ImGuiTableFlags_SizingStretchSame)) { + render_action_button(selected, "Pan left", pan_left, submit_event); + render_action_button(selected, "Wheel up", wheel_up, submit_event); + render_action_button(selected, "Wheel down", wheel_down, submit_event); + render_action_button(selected, "Pan right", pan_right, submit_event); + ImGui::EndTable(); + } + } + + void forget_device(lvh::DeviceId id) { + std::erase_if(scheduled_events_, [id](const ScheduledMouseEvent &event) { + return event.device_id == id; + }); + for (std::size_t index = 0; index < active_device_ids_.size(); ++index) { + if (active_device_ids_[index] == id) { + button_active_[index] = false; + active_device_ids_[index] = 0; + } + } + } + + void reset() { + button_active_.fill(false); + active_device_ids_.fill(0); + scheduled_events_.clear(); + } + + private: + template + void render_delayed_test_controls(SubmitHandler submit_event) { + ImGui::SliderInt("Delay (seconds)", &delay_seconds_, 1, 10); + ImGui::TextWrapped( + "Activate an action, then switch to the browser before the delay expires. Button actions send one " + "press-and-release click. Keep the pointer over the browser test target." + ); + render_scheduled_status(submit_event); + } + + template + void render_scheduled_status(SubmitHandler submit_event) { + if (scheduled_events_.empty()) { + ImGui::TextDisabled("No browser-test action queued."); + return; + } + + const auto next = std::ranges::min_element(scheduled_events_, {}, &ScheduledMouseEvent::due_at); + const auto remaining = std::max( + std::chrono::duration {next->due_at - std::chrono::steady_clock::now()}.count(), + 0.0F + ); + ImGui::TextColored( + {1.0F, 0.8F, 0.2F, 1.0F}, + "Browser-test input queued: switch windows now (%.1f s).", + remaining + ); + if (ImGui::Button("Cancel queued input")) { + cancel_all_scheduled_events(submit_event); + } + } + + template + void render_action_button( + const SelectedSnapshot &selected, + const char *label, + MouseControlAction action, + SubmitHandler submit_event + ) { + ImGui::TableNextColumn(); + ScopedDisabled disabled {!selected.has_device || selected.device_type != lvh::DeviceType::mouse}; + if (!ImGui::Button(label, {-FLT_MIN, 30.0F})) { + return; + } + + const auto event = mouse_event_for_action(action, motion_step_, scroll_step_); + if (delayed_test_) { + schedule_event(selected.id, event); + } else { + submit_event(selected.id, event); + } + } + + template + void render_button_control(const SelectedSnapshot &selected, std::size_t index, SubmitHandler submit_event) { + ImGui::TableNextColumn(); + ImGui::PushID(static_cast(index)); + + const auto pressed = selected.has_device && selected.device_type == lvh::DeviceType::mouse && + selected.mouse_buttons[index]; + if (pressed) { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); + } + + auto clicked = false; + auto active = false; + { + ScopedDisabled disabled {!selected.has_device || selected.device_type != lvh::DeviceType::mouse}; + const auto label = to_utf8(mouse_button_choices[index].label); + clicked = ImGui::Button(label.c_str(), {-FLT_MIN, 30.0F}); + active = ImGui::IsItemActive(); + } + + if (delayed_test_ && clicked) { + schedule_click(selected.id, mouse_button_choices[index].button); + } else if (!delayed_test_) { + update_active_button(selected.id, index, active, submit_event); + } + + if (pressed) { + ImGui::PopStyleColor(); + } + ImGui::PopID(); + } + + template + void update_active_button(lvh::DeviceId selected_id, std::size_t index, bool active, SubmitHandler submit_event) { + if (button_active_[index] == active) { + return; + } + + button_active_[index] = active; + if (active) { + active_device_ids_[index] = selected_id; + submit_event(selected_id, mouse_button_event(mouse_button_choices[index].button, true)); + return; + } + + const auto device_id = active_device_ids_[index]; + active_device_ids_[index] = 0; + submit_event(device_id, mouse_button_event(mouse_button_choices[index].button, false)); + } + + void schedule_event(lvh::DeviceId id, const lvh::MouseEvent &event) { + if (id == 0) { + return; + } + scheduled_events_.push_back({ + .device_id = id, + .event = event, + .due_at = std::chrono::steady_clock::now() + std::chrono::seconds {delay_seconds_}, + }); + } + + void schedule_click(lvh::DeviceId id, lvh::MouseButton button) { + if (id == 0) { + return; + } + + const auto press_at = std::chrono::steady_clock::now() + std::chrono::seconds {delay_seconds_}; + scheduled_events_.push_back({ + .device_id = id, + .event = mouse_button_event(button, true), + .due_at = press_at, + }); + scheduled_events_.push_back({ + .device_id = id, + .event = mouse_button_event(button, false), + .due_at = press_at + std::chrono::milliseconds {100}, + }); + } + + template + void dispatch_due_events(SubmitHandler submit_event) { + const auto now = std::chrono::steady_clock::now(); + auto iter = scheduled_events_.begin(); + while (iter != scheduled_events_.end()) { + if (iter->due_at > now) { + ++iter; + continue; + } + + const auto event = *iter; + iter = scheduled_events_.erase(iter); + submit_event(event.device_id, event.event); + } + } + + template + void release_active_buttons(SubmitHandler submit_event) { + for (std::size_t index = 0; index < button_active_.size(); ++index) { + if (!button_active_[index]) { + continue; + } + submit_event( + active_device_ids_[index], + mouse_button_event(mouse_button_choices[index].button, false) + ); + button_active_[index] = false; + active_device_ids_[index] = 0; + } + } + + template + void cancel_all_scheduled_events(SubmitHandler submit_event) { + std::vector releases; + for (const auto &event : scheduled_events_) { + if (event.event.kind == lvh::MouseEventKind::button && !event.event.pressed) { + releases.push_back(event); + } + } + scheduled_events_.clear(); + for (const auto &release : releases) { + submit_event(release.device_id, release.event); } } -#endif - LicenseSnapshot snapshot_; -#if defined(_WIN32) - std::array license_key_input_ {}; -#endif - std::string buy_url_; - std::string manage_account_url_; + std::array button_active_ {}; + std::array active_device_ids_ {}; + int motion_step_ = 25; + int scroll_step_ = 120; + bool delayed_test_ = false; + int delay_seconds_ = 3; + std::vector scheduled_events_; }; int axis_position(const SelectedSnapshot &selected, std::size_t index) { @@ -509,7 +980,9 @@ namespace { ControlApp &operator=(const ControlApp &) = delete; void tick() { - dispatch_due_mouse_events(); + mouse_control_panel_.tick([this](lvh::DeviceId id, const lvh::MouseEvent &event) { + submit_mouse_event(id, event); + }); } void render() { @@ -528,8 +1001,30 @@ namespace { ImGui::TextWrapped("%s", backend.c_str()); ImGui::Separator(); + const auto render_devices = [this, &devices] { + device_panel_.render( + devices, + selected_id_, + [this] { + create_selected_device(); + }, + [this] { + reset_selected_device(); + }, + [this] { + remove_selected_device(); + }, + [this] { + remove_all_devices(); + }, + [this](std::string_view message) { + error_panel_.show(message); + } + ); + }; + if (ImGui::GetContentRegionAvail().x < 760.0F) { - render_device_panel(devices); + render_devices(); ImGui::Separator(); render_control_panel(selected); } else if (ImGui::BeginTable("control-layout", 2, ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV)) { @@ -538,7 +1033,7 @@ namespace { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - render_device_panel(devices); + render_devices(); ImGui::TableSetColumnIndex(1); render_control_panel(selected); @@ -546,7 +1041,7 @@ namespace { ImGui::EndTable(); } - render_error_popup(); + error_panel_.render(); ImGui::End(); } @@ -626,123 +1121,8 @@ namespace { return snapshot; } - const lvh::tools::virtualhid_control::DeviceTypeChoice *current_device_type_choice() const { - if (device_type_index_ >= device_type_choices.size()) { - return nullptr; - } - return &device_type_choices[device_type_index_]; - } - - lvh::DeviceType current_device_type() const { - const auto *choice = current_device_type_choice(); - return choice == nullptr ? lvh::DeviceType::gamepad : choice->type; - } - - const lvh::tools::virtualhid_control::ProfileChoice *current_profile_choice() const { - if (profile_index_ >= profile_choices.size()) { - return nullptr; - } - return &profile_choices[profile_index_]; - } - - std::optional current_profile() const { - const auto *choice = current_profile_choice(); - if (choice == nullptr) { - return std::nullopt; - } - return profile_for_choice(*choice); - } - - std::optional control_profile(const SelectedSnapshot &selected) const { - if (selected.has_device) { - return selected.profile; - } - if (current_device_type() == lvh::DeviceType::mouse) { - return lvh::profiles::mouse(); - } - return current_profile(); - } - - void render_device_panel(const std::vector &devices) { - license_panel_.render([this](std::string_view message) { - show_error(message); - }); - ImGui::Separator(); - - ImGui::TextUnformatted("Device type"); - const auto *device_type_choice = current_device_type_choice(); - if (const auto preview = device_type_choice == nullptr ? std::string {"Select device type"} : to_utf8(device_type_choice->label); ImGui::BeginCombo("##device-type", preview.c_str())) { - for (std::size_t index = 0; index < device_type_choices.size(); ++index) { - const auto label = to_utf8(device_type_choices[index].label); - const auto selected = index == device_type_index_; - if (ImGui::Selectable(label.c_str(), selected)) { - device_type_index_ = index; - } - if (selected) { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); - } - - ImGui::TextUnformatted("Profile"); - if (current_device_type() == lvh::DeviceType::gamepad) { - const auto *choice = current_profile_choice(); - if (const auto preview = choice == nullptr ? std::string {"Select profile"} : to_utf8(choice->label); ImGui::BeginCombo("##profile", preview.c_str())) { - for (std::size_t index = 0; index < profile_choices.size(); ++index) { - const auto label = to_utf8(profile_choices[index].label); - const auto selected = index == profile_index_; - if (ImGui::Selectable(label.c_str(), selected)) { - profile_index_ = index; - } - if (selected) { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); - } - } else { - ImGui::TextUnformatted("Generic five-button mouse"); - } - - license_panel_.render_create_button([this] { - create_selected_device(); - }); - - ImGui::Spacing(); - ImGui::TextUnformatted("Devices"); - if (ImGui::BeginListBox("##devices", {-FLT_MIN, 150.0F})) { - if (devices.empty()) { - ImGui::TextDisabled("No devices"); - } - for (const auto &device : devices) { - const auto selected = selected_id_ == device.id; - if (ImGui::Selectable(device.label.c_str(), selected)) { - selected_id_ = device.id; - } - } - ImGui::EndListBox(); - } - - { - ScopedDisabled disabled {selected_id_ == 0}; - if (ImGui::Button("Reset", {-FLT_MIN, 0.0F})) { - reset_selected_device(); - } - if (ImGui::Button("Remove selected", {-FLT_MIN, 0.0F})) { - remove_selected_device(); - } - } - { - ScopedDisabled disabled {devices.empty()}; - if (ImGui::Button("Remove all", {-FLT_MIN, 0.0F})) { - remove_all_devices(); - } - } - } - void render_control_panel(const SelectedSnapshot &selected) { - const auto profile = control_profile(selected); + const auto profile = device_panel_.control_profile(selected); if (selected.has_device) { ImGui::TextWrapped("%s", selected.state_text.c_str()); } else { @@ -757,9 +1137,10 @@ namespace { } ImGui::Separator(); - const auto device_type = selected.has_device ? selected.device_type : current_device_type(); - if (device_type == lvh::DeviceType::mouse) { - render_mouse_controls(selected); + if (const auto device_type = selected.has_device ? selected.device_type : device_panel_.current_device_type(); device_type == lvh::DeviceType::mouse) { + mouse_control_panel_.render(selected, [this](lvh::DeviceId id, const lvh::MouseEvent &event) { + submit_mouse_event(id, event); + }); } else { render_button_controls(selected, profile); ImGui::Separator(); @@ -771,127 +1152,6 @@ namespace { render_output_controls(selected); } - void render_mouse_controls(const SelectedSnapshot &selected) { - ImGui::TextWrapped( - "Keyboard control: use Tab or arrow keys to highlight a control, then Space or Enter to activate it. " - "Mouse buttons remain pressed only while the activation key is held." - ); - - ImGui::SliderInt("Movement step", &mouse_motion_step_, 1, 500); - ImGui::SliderInt("Scroll step", &mouse_scroll_step_, 1, 1200); - - if (ImGui::Checkbox("Delayed browser test", &mouse_delayed_test_); ImGui::IsItemDeactivatedAfterEdit()) { - mouse_button_active_.fill(false); - cancel_all_scheduled_mouse_events(); - } - if (mouse_delayed_test_) { - ImGui::SliderInt("Delay (seconds)", &mouse_test_delay_seconds_, 1, 10); - ImGui::TextWrapped( - "Activate an action, then switch to the browser before the delay expires. Button actions send one " - "press-and-release click. Keep the pointer over the browser test target." - ); - render_scheduled_mouse_status(); - } - - ImGui::TextUnformatted("Relative movement"); - if (ImGui::BeginTable("mouse-motion", 4, ImGuiTableFlags_SizingStretchSame)) { - render_mouse_action_button(selected, "Move left", MouseControlAction::move_left); - render_mouse_action_button(selected, "Move up", MouseControlAction::move_up); - render_mouse_action_button(selected, "Move down", MouseControlAction::move_down); - render_mouse_action_button(selected, "Move right", MouseControlAction::move_right); - ImGui::EndTable(); - } - - ImGui::TextUnformatted("Buttons (momentary)"); - if (ImGui::BeginTable("mouse-buttons", 3, ImGuiTableFlags_SizingStretchSame)) { - for (std::size_t index = 0; index < mouse_button_choices.size(); ++index) { - render_mouse_button_control(selected, index); - } - ImGui::EndTable(); - } - - ImGui::TextUnformatted("Wheel"); - if (ImGui::BeginTable("mouse-wheel", 4, ImGuiTableFlags_SizingStretchSame)) { - render_mouse_action_button(selected, "Pan left", MouseControlAction::pan_left); - render_mouse_action_button(selected, "Wheel up", MouseControlAction::wheel_up); - render_mouse_action_button(selected, "Wheel down", MouseControlAction::wheel_down); - render_mouse_action_button(selected, "Pan right", MouseControlAction::pan_right); - ImGui::EndTable(); - } - } - - void render_scheduled_mouse_status() { - if (scheduled_mouse_events_.empty()) { - ImGui::TextDisabled("No browser-test action queued."); - return; - } - - const auto next = std::ranges::min_element(scheduled_mouse_events_, {}, &ScheduledMouseEvent::due_at); - const auto remaining = std::max( - std::chrono::duration {next->due_at - std::chrono::steady_clock::now()}.count(), - 0.0F - ); - ImGui::TextColored( - {1.0F, 0.8F, 0.2F, 1.0F}, - "Browser-test input queued: switch windows now (%.1f s).", - remaining - ); - if (ImGui::Button("Cancel queued input")) { - cancel_all_scheduled_mouse_events(); - } - } - - void render_mouse_action_button( - const SelectedSnapshot &selected, - const char *label, - MouseControlAction action - ) { - ImGui::TableNextColumn(); - ScopedDisabled disabled {!selected.has_device || selected.device_type != lvh::DeviceType::mouse}; - if (ImGui::Button(label, {-FLT_MIN, 30.0F})) { - const auto event = mouse_event_for_action(action, mouse_motion_step_, mouse_scroll_step_); - if (mouse_delayed_test_) { - schedule_mouse_event(selected.id, event); - } else { - submit_mouse_event(selected.id, event); - } - } - } - - void render_mouse_button_control(const SelectedSnapshot &selected, std::size_t index) { - ImGui::TableNextColumn(); - ImGui::PushID(static_cast(index)); - - const auto pressed = selected.has_device && selected.device_type == lvh::DeviceType::mouse && - selected.mouse_buttons[index]; - if (pressed) { - ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); - } - - auto clicked = false; - auto active = false; - { - ScopedDisabled disabled {!selected.has_device || selected.device_type != lvh::DeviceType::mouse}; - const auto label = to_utf8(mouse_button_choices[index].label); - clicked = ImGui::Button(label.c_str(), {-FLT_MIN, 30.0F}); - active = ImGui::IsItemActive(); - } - - if (mouse_delayed_test_) { - if (clicked) { - schedule_mouse_click(selected.id, mouse_button_choices[index].button); - } - } else if (mouse_button_active_[index] != active) { - mouse_button_active_[index] = active; - submit_mouse_event(selected.id, mouse_button_event(mouse_button_choices[index].button, active)); - } - - if (pressed) { - ImGui::PopStyleColor(); - } - ImGui::PopID(); - } - void render_button_controls( const SelectedSnapshot &selected, const std::optional &profile @@ -1057,24 +1317,8 @@ namespace { } } - void render_error_popup() { - if (open_error_popup_) { - ImGui::OpenPopup("Error"); - open_error_popup_ = false; - } - - if (ImGui::BeginPopupModal("Error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::TextWrapped("%s", error_message_.c_str()); - if (ImGui::Button("OK", {120.0F, 0.0F})) { - error_message_.clear(); - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - } - void create_selected_device() { - if (current_device_type() == lvh::DeviceType::mouse) { + if (device_panel_.current_device_type() == lvh::DeviceType::mouse) { create_mouse(); } else { create_gamepad(); @@ -1082,15 +1326,15 @@ namespace { } void create_gamepad() { - const auto *choice = current_profile_choice(); + const auto *choice = device_panel_.selected_profile_choice(); if (choice == nullptr) { - show_error("Select a profile first."); + error_panel_.show("Select a profile first."); return; } const auto profile = profile_for_choice(*choice); if (!profile) { - show_error("Could not create the selected profile."); + error_panel_.show("Could not create the selected profile."); return; } @@ -1107,13 +1351,13 @@ namespace { auto created = lvh::GamepadStateAdapter::create(*runtime_, options); if (!created) { - show_error(created.status.message()); + error_panel_.show(created.status.message()); return; } const auto *gamepad = created.adapter->gamepad(); if (gamepad == nullptr) { - show_error("Created gamepad handle is missing."); + error_panel_.show("Created gamepad handle is missing."); return; } @@ -1131,7 +1375,7 @@ namespace { devices_[id] = std::move(device); } selected_id_ = id; - license_panel_.refresh(); + device_panel_.refresh_license(); } void create_mouse() { @@ -1141,7 +1385,7 @@ namespace { auto created = runtime_->create_mouse(options); if (!created) { - show_error(created.status.message()); + error_panel_.show(created.status.message()); return; } @@ -1155,7 +1399,7 @@ namespace { mice_[id] = std::move(device); } selected_id_ = id; - license_panel_.refresh(); + device_panel_.refresh_license(); } void reset_selected_device() { @@ -1184,10 +1428,9 @@ namespace { } } button_active_.fill(false); - mouse_button_active_.fill(false); - cancel_scheduled_mouse_events(reset_id); + mouse_control_panel_.forget_device(reset_id); if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1212,11 +1455,10 @@ namespace { return; } - selected_id_ = first_device_id_locked(); + selected_id_ = first_device_id(devices_, mice_); } button_active_.fill(false); - mouse_button_active_.fill(false); - cancel_scheduled_mouse_events(removed_id); + mouse_control_panel_.forget_device(removed_id); auto status = lvh::OperationStatus::success(); if (adapter != nullptr) { @@ -1225,18 +1467,17 @@ namespace { status = mouse->close(); } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } - license_panel_.refresh(); + device_panel_.refresh_license(); } void remove_all_devices() { const auto handles = take_all_devices(); button_active_.fill(false); - mouse_button_active_.fill(false); - scheduled_mouse_events_.clear(); + mouse_control_panel_.reset(); close_devices(handles, true); - license_panel_.refresh(); + device_panel_.refresh_license(); } void submit_mouse_event(lvh::DeviceId id, const lvh::MouseEvent &event) { @@ -1260,77 +1501,7 @@ namespace { } } if (!status.ok()) { - show_error(status.message()); - } - } - - void schedule_mouse_event(lvh::DeviceId id, const lvh::MouseEvent &event) { - if (id == 0) { - return; - } - scheduled_mouse_events_.push_back({ - .device_id = id, - .event = event, - .due_at = std::chrono::steady_clock::now() + std::chrono::seconds {mouse_test_delay_seconds_}, - }); - } - - void schedule_mouse_click(lvh::DeviceId id, lvh::MouseButton button) { - if (id == 0) { - return; - } - - const auto press_at = std::chrono::steady_clock::now() + std::chrono::seconds {mouse_test_delay_seconds_}; - scheduled_mouse_events_.push_back({ - .device_id = id, - .event = mouse_button_event(button, true), - .due_at = press_at, - }); - scheduled_mouse_events_.push_back({ - .device_id = id, - .event = mouse_button_event(button, false), - .due_at = press_at + std::chrono::milliseconds {100}, - }); - } - - void dispatch_due_mouse_events() { - const auto now = std::chrono::steady_clock::now(); - auto iter = scheduled_mouse_events_.begin(); - while (iter != scheduled_mouse_events_.end()) { - if (iter->due_at > now) { - ++iter; - continue; - } - - const auto event = *iter; - iter = scheduled_mouse_events_.erase(iter); - submit_mouse_event(event.device_id, event.event); - } - } - - void cancel_scheduled_mouse_events(lvh::DeviceId id) { - std::erase_if(scheduled_mouse_events_, [id](const ScheduledMouseEvent &event) { - return event.device_id == id; - }); - } - - void cancel_all_scheduled_mouse_events() { - scheduled_mouse_events_.clear(); - - std::vector> releases; - { - std::lock_guard lock {mutex_}; - for (const auto &[id, device] : mice_) { - for (std::size_t index = 0; index < device.buttons.size(); ++index) { - if (device.buttons[index]) { - releases.emplace_back(id, mouse_button_choices[index].button); - } - } - } - } - - for (const auto &[id, button] : releases) { - submit_mouse_event(id, mouse_button_event(button, false)); + error_panel_.show(status.message()); } } @@ -1358,7 +1529,7 @@ namespace { status = device->adapter->set_button(button_choices[index].button, pressed); } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1381,7 +1552,7 @@ namespace { status = device->adapter->set_state(state); } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1424,7 +1595,7 @@ namespace { } } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1450,7 +1621,7 @@ namespace { status = device->adapter->set_battery(battery); } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1465,7 +1636,7 @@ namespace { status = device->adapter->clear_battery(); } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1522,18 +1693,8 @@ namespace { } if (first_error) { - show_error(*first_error); - } - } - - lvh::DeviceId first_device_id_locked() const { - if (devices_.empty()) { - return mice_.empty() ? 0 : mice_.begin()->first; + error_panel_.show(*first_error); } - if (mice_.empty()) { - return devices_.begin()->first; - } - return std::min(devices_.begin()->first, mice_.begin()->first); } ControlledGamepad *selected_device_locked() { @@ -1580,33 +1741,20 @@ namespace { return &iter->second; } - void show_error(std::string_view message) { - error_message_ = message.empty() ? "Operation failed." : std::string {message}; - open_error_popup_ = true; - } - std::unique_ptr runtime_; mutable std::mutex mutex_; std::map devices_; std::map mice_; lvh::DeviceId selected_id_ = 0; - std::size_t device_type_index_ = 0; - std::size_t profile_index_ = 3; bool lock_buttons_ = false; std::array button_active_ {}; - std::array mouse_button_active_ {}; - int mouse_motion_step_ = 25; - int mouse_scroll_step_ = 120; - bool mouse_delayed_test_ = false; - int mouse_test_delay_seconds_ = 3; - std::vector scheduled_mouse_events_; int battery_state_index_ = battery_choice_index(lvh::GamepadBatteryState::full); int battery_percentage_ = 100; - std::string error_message_; - bool open_error_popup_ = false; std::uint64_t next_metadata_index_ = 0; std::uint64_t next_output_sequence_ = 1; - LicensePanel license_panel_; + DevicePanel device_panel_; + MouseControlPanel mouse_control_panel_; + ErrorPanel error_panel_; static constexpr std::size_t max_output_events_ = 50; }; diff --git a/tools/virtualhid_control_model.cpp b/tools/virtualhid_control_model.cpp index d459b76..4b57259 100644 --- a/tools/virtualhid_control_model.cpp +++ b/tools/virtualhid_control_model.cpp @@ -149,34 +149,35 @@ namespace lvh::tools::virtualhid_control { std::int32_t scroll_step ) { using enum MouseControlAction; + using enum MouseEventKind; switch (action) { case move_left: - return MouseEvent {.kind = MouseEventKind::relative_motion, .x = -motion_step}; + return MouseEvent {.kind = relative_motion, .x = -motion_step}; case move_right: - return MouseEvent {.kind = MouseEventKind::relative_motion, .x = motion_step}; + return MouseEvent {.kind = relative_motion, .x = motion_step}; case move_up: - return MouseEvent {.kind = MouseEventKind::relative_motion, .y = -motion_step}; + return MouseEvent {.kind = relative_motion, .y = -motion_step}; case move_down: - return MouseEvent {.kind = MouseEventKind::relative_motion, .y = motion_step}; + return MouseEvent {.kind = relative_motion, .y = motion_step}; case wheel_up: return MouseEvent { - .kind = MouseEventKind::vertical_scroll, + .kind = vertical_scroll, .high_resolution_scroll = scroll_step, }; case wheel_down: return MouseEvent { - .kind = MouseEventKind::vertical_scroll, + .kind = vertical_scroll, .high_resolution_scroll = -scroll_step, }; case pan_left: return MouseEvent { - .kind = MouseEventKind::horizontal_scroll, + .kind = horizontal_scroll, .high_resolution_scroll = -scroll_step, }; case pan_right: return MouseEvent { - .kind = MouseEventKind::horizontal_scroll, + .kind = horizontal_scroll, .high_resolution_scroll = scroll_step, }; } From 2b88b72d3ea424b776d30de67789c31b693a4888 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:19:10 -0400 Subject: [PATCH 8/8] refactor(ui): address remaining Sonar findings --- tools/virtualhid_control.cpp | 43 +++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index db9abfa..ad15531 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -575,8 +575,7 @@ namespace { void render_device_type_selector() { ImGui::TextUnformatted("Device type"); const auto *choice = current_device_type_choice(); - const auto preview = choice == nullptr ? std::string {"Select device type"} : to_utf8(choice->label); - if (!ImGui::BeginCombo("##device-type", preview.c_str())) { + if (const auto preview = choice == nullptr ? std::string {"Select device type"} : to_utf8(choice->label); !ImGui::BeginCombo("##device-type", preview.c_str())) { return; } @@ -605,8 +604,7 @@ namespace { } const auto *choice = current_profile_choice(); - const auto preview = choice == nullptr ? std::string {"Select profile"} : to_utf8(choice->label); - if (!ImGui::BeginCombo("##profile", preview.c_str())) { + if (const auto preview = choice == nullptr ? std::string {"Select profile"} : to_utf8(choice->label); !ImGui::BeginCombo("##profile", preview.c_str())) { return; } @@ -1001,25 +999,30 @@ namespace { ImGui::TextWrapped("%s", backend.c_str()); ImGui::Separator(); - const auto render_devices = [this, &devices] { + const auto create_device = [this] { + create_selected_device(); + }; + const auto reset_device = [this] { + reset_selected_device(); + }; + const auto remove_device = [this] { + remove_selected_device(); + }; + const auto remove_all = [this] { + remove_all_devices(); + }; + const auto show_error = [this](std::string_view message) { + error_panel_.show(message); + }; + const auto render_devices = [this, &devices, &create_device, &reset_device, &remove_device, &remove_all, &show_error] { device_panel_.render( devices, selected_id_, - [this] { - create_selected_device(); - }, - [this] { - reset_selected_device(); - }, - [this] { - remove_selected_device(); - }, - [this] { - remove_all_devices(); - }, - [this](std::string_view message) { - error_panel_.show(message); - } + create_device, + reset_device, + remove_device, + remove_all, + show_error ); };