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 96aea99..996c2b3 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,28 @@ 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 and mice. 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 +55,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 +81,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 +103,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 @@ -176,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 @@ -222,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-gamepad 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 @@ -308,18 +330,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 +361,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 +378,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 +388,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 +443,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..3446dab 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,30 +29,45 @@ 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)) { + if ( + !reports_.empty() && device_type_ == LVH_WINDOWS_DEVICE_GAMEPAD && + same_discrete_state(reports_.back(), report) + ) { reports_.back() = std::move(report); return; } @@ -66,6 +82,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 +96,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 +122,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 +139,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 +275,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 a9c2a92..fc34aac 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, @@ -924,6 +940,7 @@ namespace lvh::detail { private: friend class WindowsBackendContext; friend class WindowsGamepad; + friend class WindowsHidMouse; std::mutex output_dispatch_mutex_; mutable std::mutex mutex_; @@ -940,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)} {} @@ -954,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 { @@ -1012,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, @@ -1031,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(); @@ -1039,8 +1056,20 @@ namespace lvh::detail { return {OperationStatus::success(), std::move(gamepad)}; } - OperationStatus submit_gamepad_report( - const std::shared_ptr &state, + /** + * @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_device_report( + const std::shared_ptr &state, const std::vector &report ) const { const auto driver_id = state->driver_id; @@ -1048,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 {}; { @@ -1064,7 +1093,7 @@ namespace lvh::detail { { std::lock_guard lock {devices_mutex_}; - gamepads_.erase(driver_id); + devices_.erase(driver_id); } notify_pid_timer(); @@ -1103,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)); @@ -1154,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(); } } @@ -1215,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( @@ -1235,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) { @@ -1257,7 +1286,7 @@ namespace lvh::detail { } OperationStatus WindowsGamepad::close() { - return context_->close_gamepad(state_); + return context_->close_device(state_); } class WindowsKeyboard final: public BackendKeyboard { @@ -1420,6 +1449,266 @@ 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 {windows::mouse_report_descriptor.begin(), windows::mouse_report_descriptor.end()}; + } + + /** + * @brief High-resolution scroll units that make up a single wheel detent. + */ + constexpr std::int64_t mouse_scroll_units_per_detent = 120; + + /** + * @brief Device profile describing the driver-backed mouse. + * + * @param requested Caller-supplied profile whose public identity is kept. + * @return Effective mouse device profile. + */ + DeviceProfile make_hid_mouse_profile(const DeviceProfile &requested) { + auto profile = requested; + profile.device_type = DeviceType::mouse; + profile.gamepad_kind = GamepadProfileKind::generic; + profile.report_id = 0; + profile.report_descriptor = make_mouse_report_descriptor(); + profile.input_report_size = LVH_WINDOWS_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 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 submit_scroll(vertical_scroll_remainder_, event.high_resolution_scroll, true); + case horizontal_scroll: + return submit_scroll(horizontal_scroll_remainder_, event.high_resolution_scroll, false); + } + + 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_device(state_); + } + + private: + /** + * @brief Submit every part of a relative motion delta without clipping. + * + * @param x Incoming horizontal delta. + * @param y Incoming vertical delta. + * @return Submission status. + */ + 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(); + } + + /** + * @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. + * @param vertical Whether to emit wheel rather than pan reports. + * @return Submission status. + */ + 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)); + if (const auto status = vertical ? emit(0, 0, report_detents, 0) : emit(0, 0, 0, report_detents); !status.ok()) { + return status; + } + detents -= report_detents; + } + return OperationStatus::success(); + } + + /** + * @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.has_value()) { + return OperationStatus::success(); + } + + const auto mask = std::byte {1} << *bit; + buttons_ = event.pressed ? (buttons_ | mask) : (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(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); + 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_device_report(state_, report); + } + + std::shared_ptr context_; + std::shared_ptr state_; + WindowsMouse fallback_; + std::byte buttons_ {}; + std::int64_t vertical_scroll_remainder_ = 0; + std::int64_t horizontal_scroll_remainder_ = 0; + bool open_ = true; + }; + + BackendMouseCreationResult WindowsBackendContext::create_hid_mouse( + DeviceId id, + const CreateMouseOptions &options + ) { + auto effective_options = options; + effective_options.profile = make_hid_mouse_profile(options.profile); + + 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_device(request, response); !status.ok()) { + return {status, nullptr}; + } + + auto state = std::make_shared( + id, + response.driver_device_id, + response.session_token, + effective_options.profile, + response.device_path[0] == '\0' ? command_channel_->path() : std::string {response.device_path.data()} + ); + + { + std::lock_guard lock {devices_mutex_}; + devices_[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 +2322,25 @@ 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 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..c7cc9c7 100644 --- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp @@ -51,6 +51,46 @@ namespace lvh::detail::test { std::vector strengths; }; + struct WindowsHidMouseResult { + 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; + }; + + 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; + }; + + 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; + }; + + OperationResults operations; + CreatedDevice device; + RequestObservations observations; + }; + struct WindowsPlayStationTransportResult { OperationStatus dualshock4_status; OperationStatus dualsense_status; @@ -173,6 +213,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..be042b7 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,101 @@ namespace lvh::detail { return result; } + 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()); + + 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.operations.create_status = created.status; + if (created) { + result.operations.motion_status = created.mouse->submit(MouseEvent { + .kind = relative_motion, + .x = 70000, + .y = -70000, + }); + result.operations.large_scroll_status = created.mouse->submit(MouseEvent { + .kind = vertical_scroll, + .high_resolution_scroll = 120 * 132, + }); + result.operations.partial_scroll_status = created.mouse->submit(MouseEvent { + .kind = horizontal_scroll, + .high_resolution_scroll = 60, + }); + result.operations.completed_scroll_status = created.mouse->submit(MouseEvent { + .kind = horizontal_scroll, + .high_resolution_scroll = 60, + }); + result.operations.close_status = created.mouse->close(); + } + + if (const auto requests = command_state->create_requests(); !requests.empty()) { + const auto &request = requests.front(); + 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.observations.reports = command_state->submit_reports(); + result.observations.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.operations.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.operations.license_fallback_create_status = fallback.status; + if (fallback) { + result.operations.license_fallback_submit_status = fallback.mouse->submit(MouseEvent { + .kind = relative_motion, + .x = 1, + .y = 2, + }); + } + result.observations.license_create_requests = license_state->create_request_count(); + result.observations.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_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/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp index d2fc45e..228d84a 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.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.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) { 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..1532f0a 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] = 0U; 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 diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index 06b7f4d..ad15531 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; @@ -76,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): @@ -211,6 +249,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 +405,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(); } } @@ -438,6 +477,462 @@ namespace { 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(); + if (const auto preview = choice == nullptr ? std::string {"Select device type"} : to_utf8(choice->label); !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(); + if (const auto preview = choice == nullptr ? std::string {"Select profile"} : to_utf8(choice->label); !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); + } + } + + 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) { if (!selected.has_device) { return 0; @@ -473,15 +968,21 @@ 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() { + mouse_control_panel_.tick([this](lvh::DeviceId id, const lvh::MouseEvent &event) { + submit_mouse_event(id, event); + }); + } + void render() { const auto devices = snapshot_devices(); const auto selected = snapshot_selected_device(); @@ -498,8 +999,35 @@ namespace { ImGui::TextWrapped("%s", backend.c_str()); ImGui::Separator(); + 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_, + create_device, + reset_device, + remove_device, + remove_all, + show_error + ); + }; + 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)) { @@ -508,7 +1036,7 @@ namespace { ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); - render_device_panel(devices); + render_devices(); ImGui::TableSetColumnIndex(1); render_control_panel(selected); @@ -516,7 +1044,7 @@ namespace { ImGui::EndTable(); } - render_error_popup(); + error_panel_.render(); ImGui::End(); } @@ -524,132 +1052,80 @@ 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::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; - } - 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("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_; - if (ImGui::Selectable(label.c_str(), selected)) { - profile_index_ = index; - } - if (selected) { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); - } - - license_panel_.render_create_button([this] { - create_gamepad(); - }); - - 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 { @@ -657,18 +1133,24 @@ 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); + 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(); + render_axis_controls(selected); + ImGui::Separator(); + render_battery_controls(selected, profile); + } ImGui::Separator(); render_output_controls(selected); } @@ -838,32 +1320,24 @@ 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 (device_panel_.current_device_type() == lvh::DeviceType::mouse) { + create_mouse(); + } else { + create_gamepad(); } } 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; } @@ -880,13 +1354,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; } @@ -904,54 +1378,134 @@ namespace { devices_[id] = std::move(device); } selected_id_ = id; - license_panel_.refresh(); + device_panel_.refresh_license(); + } + + 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) { + error_panel_.show(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; + device_panel_.refresh_license(); } 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_control_panel_.forget_device(reset_id); if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } 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(devices_, mice_); } button_active_.fill(false); - if (adapter) { - if (const auto status = adapter->close(); !status.ok()) { - show_error(status.message()); - } + mouse_control_panel_.forget_device(removed_id); + + auto status = lvh::OperationStatus::success(); + if (adapter != nullptr) { + status = adapter->close(); + } else if (mouse != nullptr) { + status = mouse->close(); } - license_panel_.refresh(); + if (!status.ok()) { + error_panel_.show(status.message()); + } + device_panel_.refresh_license(); } void remove_all_devices() { - const auto adapters = take_all_adapters(); + const auto handles = take_all_devices(); button_active_.fill(false); - close_adapters(adapters, true); - license_panel_.refresh(); + mouse_control_panel_.reset(); + close_devices(handles, true); + device_panel_.refresh_license(); + } + + 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()) { + error_panel_.show(status.message()); + } } void toggle_selected_button(std::size_t index) { @@ -978,7 +1532,7 @@ namespace { status = device->adapter->set_button(button_choices[index].button, pressed); } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1001,7 +1555,7 @@ namespace { status = device->adapter->set_state(state); } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1044,7 +1598,7 @@ namespace { } } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1070,7 +1624,7 @@ namespace { status = device->adapter->set_battery(battery); } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1085,7 +1639,7 @@ namespace { status = device->adapter->clear_battery(); } if (!status.ok()) { - show_error(status.message()); + error_panel_.show(status.message()); } } @@ -1105,36 +1659,44 @@ 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); + error_panel_.show(*first_error); } } @@ -1160,25 +1722,42 @@ namespace { return &iter->second; } - void show_error(std::string_view message) { - error_message_ = message.empty() ? "Operation failed." : std::string {message}; - open_error_popup_ = true; + 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; } std::unique_ptr runtime_; mutable std::mutex mutex_; std::map devices_; + std::map mice_; lvh::DeviceId selected_id_ = 0; - std::size_t profile_index_ = 3; bool lock_buttons_ = false; std::array button_active_ {}; 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; }; @@ -1258,6 +1837,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..4b57259 100644 --- a/tools/virtualhid_control_model.cpp +++ b/tools/virtualhid_control_model.cpp @@ -143,6 +143,55 @@ 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; + using enum MouseEventKind; + + switch (action) { + case move_left: + return MouseEvent {.kind = relative_motion, .x = -motion_step}; + case move_right: + return MouseEvent {.kind = relative_motion, .x = motion_step}; + case move_up: + return MouseEvent {.kind = relative_motion, .y = -motion_step}; + case move_down: + return MouseEvent {.kind = relative_motion, .y = motion_step}; + case wheel_up: + return MouseEvent { + .kind = vertical_scroll, + .high_resolution_scroll = scroll_step, + }; + case wheel_down: + return MouseEvent { + .kind = vertical_scroll, + .high_resolution_scroll = -scroll_step, + }; + case pan_left: + return MouseEvent { + .kind = horizontal_scroll, + .high_resolution_scroll = -scroll_step, + }; + case pan_right: + return MouseEvent { + .kind = 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 +240,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(