diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bf96bff..4a7185c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,6 +13,8 @@ concurrency:
cancel-in-progress: true
env:
+ CMAKE_BUILD_CONFIG: Debug
+ DRIVER_BUILD_CONFIG: Release
OPENCPPCOVERAGE_VERSION: '0.9.9.0'
PYTHON_VERSION: '3.14'
@@ -42,7 +44,9 @@ jobs:
build:
name: Build (${{ matrix.name }})
- needs: setup_release
+ needs:
+ - setup_release
+ - windows_driver
permissions:
contents: read
runs-on: ${{ matrix.os }}
@@ -206,7 +210,7 @@ jobs:
-DBUILD_DOCS=OFF \
-DBUILD_EXAMPLES=ON \
-DBUILD_TESTS=ON \
- -DCMAKE_BUILD_TYPE:STRING=Debug \
+ -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_CONFIG} \
-B cmake-build-ci \
-G Ninja \
-S .
@@ -228,72 +232,54 @@ jobs:
-S .
- name: Build
- if: matrix.kind != 'msvc'
- run: cmake --build cmake-build-ci -- -j2
+ run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --parallel 2
- - name: Build MSVC
- if: matrix.kind == 'msvc'
- run: cmake --build cmake-build-ci --config Debug --parallel 2
-
- - name: Configure Windows test driver package
- if: >-
- matrix.kind == 'msvc' &&
- github.event_name == 'pull_request'
- env:
- BRANCH: ${{ github.head_ref || github.ref_name }}
- BUILD_VERSION: ${{ needs.setup_release.outputs.release_version }}
- COMMIT: ${{ needs.setup_release.outputs.release_commit }}
- run: |
- $certificatePath = Join-Path `
- $env:GITHUB_WORKSPACE `
- "cmake-build-driver-test\certificates\libvirtualhid-ci-test.cer"
- cmake `
- -DBUILD_DOCS=OFF `
- -DBUILD_EXAMPLES=OFF `
- -DBUILD_TESTS=OFF `
- -DLIBVIRTUALHID_BUILD_WINDOWS_DRIVER=ON `
- -DLIBVIRTUALHID_ENABLE_PACKAGING=OFF `
- "-DLIBVIRTUALHID_DRIVER_TEST_CERTIFICATE=$certificatePath" `
- -A x64 `
- -B cmake-build-driver-test `
- -G "Visual Studio 17 2022" `
- -S .
-
- - name: Build Windows test driver package
- if: >-
- matrix.kind == 'msvc' &&
- github.event_name == 'pull_request'
- run: cmake --build cmake-build-driver-test --config Release --target libvirtualhid_windows_catalog --parallel 2
+ - name: Download Windows driver installer artifact
+ if: runner.os == 'Windows'
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: windows-driver-installer
+ path: windows-driver-installer
- - name: Sign Windows test driver package
- if: >-
- matrix.kind == 'msvc' &&
- github.event_name == 'pull_request'
+ - name: Install Windows driver installer
+ if: runner.os == 'Windows'
+ shell: pwsh
run: |
- $packagePath = Join-Path `
- $env:GITHUB_WORKSPACE `
- "cmake-build-driver-test\src\platform\windows\driver\package\Release"
- $certificatePath = Join-Path `
- $env:GITHUB_WORKSPACE `
- "cmake-build-driver-test\certificates\libvirtualhid-ci-test.cer"
- .\scripts\windows\sign-driver-package.ps1 `
- -PackagePath $packagePath `
- -CertificatePath $certificatePath
+ $installer = Get-ChildItem -LiteralPath .\windows-driver-installer -Filter *.msi | Select-Object -First 1
+ if (!$installer) {
+ throw "Windows driver installer artifact did not contain an MSI."
+ }
+ $logPath = Join-Path $env:RUNNER_TEMP "libvirtualhid-driver-install.log"
+ $process = Start-Process `
+ -FilePath msiexec.exe `
+ -ArgumentList @("/i", $installer.FullName, "/qn", "/norestart", "/L*v", $logPath) `
+ -Wait `
+ -PassThru `
+ -NoNewWindow
+ if ($process.ExitCode -notin @(0, 3010)) {
+ Get-Content -LiteralPath $logPath -ErrorAction SilentlyContinue
+ throw "Windows driver installer exited with code $($process.ExitCode)."
+ }
- - name: Install Windows test driver package
- if: >-
- matrix.kind == 'msvc' &&
- github.event_name == 'pull_request'
+ - name: Verify Windows test driver package
+ if: runner.os == 'Windows'
+ shell: pwsh
run: |
- $packagePath = Join-Path `
- $env:GITHUB_WORKSPACE `
- "cmake-build-driver-test\src\platform\windows\driver\package\Release"
- $certificatePath = Join-Path `
- $env:GITHUB_WORKSPACE `
- "cmake-build-driver-test\certificates\libvirtualhid-ci-test.cer"
- .\scripts\windows\install-driver.ps1 `
- -InfPath (Join-Path $packagePath "libvirtualhid.inf") `
- -CertificatePath $certificatePath
+ if ("${{ matrix.kind }}" -eq "msys2") {
+ $env:PATH = "C:\msys64\${{ matrix.msystem }}\bin;C:\msys64\usr\bin;$env:PATH"
+ $gamepadAdapterPath = "$env:GITHUB_WORKSPACE\cmake-build-ci\examples\gamepad_adapter.exe"
+ } else {
+ $gamepadAdapterPath = Join-Path `
+ "$env:GITHUB_WORKSPACE\cmake-build-ci\examples\$env:CMAKE_BUILD_CONFIG" `
+ "gamepad_adapter.exe"
+ }
+ $profiles = @("generic", "xone", "xseries", "ds4", "ds5", "switch")
+ foreach ($profile in $profiles) {
+ .\scripts\windows\test-installed-driver.ps1 `
+ -GamepadAdapterPath $gamepadAdapterPath `
+ -Profile $profile `
+ -Verbose
+ }
- name: Prepare report directory
run: cmake -E make_directory cmake-build-ci/reports
@@ -325,7 +311,7 @@ jobs:
"--export_type=cobertura:$env:GITHUB_WORKSPACE\cmake-build-ci\reports\coverage.xml" `
--working_dir "$env:GITHUB_WORKSPACE\cmake-build-ci\tests" `
-- `
- "$env:GITHUB_WORKSPACE\cmake-build-ci\tests\Debug\test_libvirtualhid.exe" `
+ "$env:GITHUB_WORKSPACE\cmake-build-ci\tests\$env:CMAKE_BUILD_CONFIG\test_libvirtualhid.exe" `
--gtest_color=yes `
"--gtest_output=xml:$env:GITHUB_WORKSPACE\cmake-build-ci\reports\junit.xml"
@@ -378,36 +364,47 @@ jobs:
-o reports/coverage.xml
- name: Run gamepad adapter example
- if: matrix.kind != 'msvc'
+ if: runner.os == 'Linux'
run: |
- if [[ "${RUNNER_OS}" == "Windows" ]]; then
- ./cmake-build-ci/examples/gamepad_adapter.exe
- else
- ./cmake-build-ci/examples/gamepad_adapter
- fi
+ ./cmake-build-ci/examples/gamepad_adapter
- - name: Run gamepad adapter example MSVC
- if: matrix.kind == 'msvc'
- run: .\cmake-build-ci\examples\Debug\gamepad_adapter.exe
+ - name: Run gamepad adapter example Windows
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ if ("${{ matrix.kind }}" -eq "msys2") {
+ $env:PATH = "C:\msys64\${{ matrix.msystem }}\bin;C:\msys64\usr\bin;$env:PATH"
+ & .\cmake-build-ci\examples\gamepad_adapter.exe
+ } else {
+ & ".\cmake-build-ci\examples\$env:CMAKE_BUILD_CONFIG\gamepad_adapter.exe"
+ }
- - name: Uninstall Windows test driver package
+ - name: Uninstall Windows driver installer
if: >-
always() &&
- matrix.kind == 'msvc' &&
- github.event_name == 'pull_request'
+ runner.os == 'Windows'
+ shell: pwsh
run: |
- .\scripts\windows\uninstall-driver.ps1 `
- -OriginalName "libvirtualhid.inf" `
- -RemoveCertificateSubject "CN=libvirtualhid CI Test Driver Signing" `
- -Force
+ $installer = Get-ChildItem -LiteralPath .\windows-driver-installer `
+ -Filter *.msi `
+ -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if ($installer) {
+ $logPath = Join-Path $env:RUNNER_TEMP "libvirtualhid-driver-uninstall.log"
+ $process = Start-Process `
+ -FilePath msiexec.exe `
+ -ArgumentList @("/x", $installer.FullName, "/qn", "/norestart", "/L*v", $logPath) `
+ -Wait `
+ -PassThru `
+ -NoNewWindow
+ if ($process.ExitCode -notin @(0, 3010)) {
+ Get-Content -LiteralPath $logPath -ErrorAction SilentlyContinue
+ throw "Windows driver installer uninstall exited with code $($process.ExitCode)."
+ }
+ }
- name: Install
- if: matrix.kind != 'msvc'
- run: cmake --install cmake-build-ci --prefix cmake-build-ci/install
-
- - name: Install MSVC
- if: matrix.kind == 'msvc'
- run: cmake --install cmake-build-ci --config Debug --prefix cmake-build-ci/install
+ run: cmake --install cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --prefix cmake-build-ci/install
- name: Upload install artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -469,7 +466,11 @@ jobs:
- name: Build Windows driver package
shell: pwsh
- run: cmake --build cmake-build-driver --config Release --target libvirtualhid_windows_catalog --parallel 2
+ run: >-
+ cmake --build cmake-build-driver
+ --config ${{ env.DRIVER_BUILD_CONFIG }}
+ --target libvirtualhid_windows_catalog
+ --parallel 2
- name: Validate Azure signing configuration
if: >-
@@ -484,7 +485,7 @@ jobs:
run: |
$packagePath = Join-Path `
$env:GITHUB_WORKSPACE `
- "cmake-build-driver\src\platform\windows\driver\package\Release"
+ "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG"
$certificatePath = Join-Path `
$env:GITHUB_WORKSPACE `
"cmake-build-driver\certificates\libvirtualhid-ci-test.cer"
@@ -492,6 +493,18 @@ jobs:
-PackagePath $packagePath `
-CertificatePath $certificatePath
+ - name: Locate Windows driver catalog
+ id: driver_catalog
+ if: >-
+ github.event_name == 'push' &&
+ vars.AZURE_SIGNING_ACCOUNT != ''
+ shell: pwsh
+ run: |
+ $catalogPath = Join-Path `
+ $env:GITHUB_WORKSPACE `
+ "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG\libvirtualhid.cat"
+ "path=$catalogPath" >> $env:GITHUB_OUTPUT
+
- name: Sign Windows driver package with Azure Trusted Signing
if: >-
github.event_name == 'push' &&
@@ -504,14 +517,14 @@ jobs:
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERT_PROFILE }}
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
files: |
- ${{ github.workspace }}/cmake-build-driver/src/platform/windows/driver/package/Release/libvirtualhid.cat
+ ${{ steps.driver_catalog.outputs.path }}
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }}
- name: Package Windows driver installer
shell: pwsh
run: |
Push-Location .\cmake-build-driver
- cpack -G WIX
+ cpack -G WIX -C $env:DRIVER_BUILD_CONFIG
$packageExitCode = $LASTEXITCODE
Pop-Location
if ($packageExitCode -ne 0) {
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6115e65..519120c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2,6 +2,9 @@
# Project configuration
#
cmake_minimum_required(VERSION 3.24)
+if(POLICY CMP0091)
+ cmake_policy(SET CMP0091 NEW)
+endif()
project(libvirtualhid VERSION 0.0.0
DESCRIPTION "Cross-platform virtual HID device library."
HOMEPAGE_URL "https://app.lizardbyte.dev"
diff --git a/README.md b/README.md
index d88c248..ec98ee1 100644
--- a/README.md
+++ b/README.md
@@ -100,14 +100,16 @@ or similar control channel over passing C++ STL types across that boundary.
The current Windows backend selects a UMDF control-channel implementation for
`BackendKind::platform_default`. It always exposes keyboard and mouse through
-Win32 `SendInput`, then probes `\\.\LibVirtualHid` for descriptor-driven virtual
-gamepads. It reports `requires_installed_driver = true`, and only advertises
-gamepad/output-report support when the driver package is installed and the
-control device can be opened. Touchscreen, trackpad, and pen tablet support are
-not implemented in the Windows backend yet. The client library stays buildable
-with MSVC and MinGW/UCRT64 because the gamepad path talks to the driver through
-fixed-size C protocol structures and Win32 `DeviceIoControl` calls. The default
-control device path can be overridden for diagnostics with
+Win32 `SendInput`, then probes the libvirtualhid control device interface for
+descriptor-driven virtual gamepads. It falls back to the legacy fixed
+`\\.\LibVirtualHid` and `\\.\Global\LibVirtualHid` links for diagnostics and
+older driver builds. It reports `requires_installed_driver = true`, and only
+advertises gamepad/output-report support when the driver package is installed
+and the control device can be opened. Touchscreen, trackpad, and pen tablet
+support are not implemented in the Windows backend yet. The client library
+stays buildable with MSVC and MinGW/UCRT64 because the gamepad path talks to the
+driver through fixed-size C protocol structures and Win32 `DeviceIoControl`
+calls. The default control device path can be overridden for diagnostics with
`LIBVIRTUALHID_WINDOWS_CONTROL_DEVICE`.
The UMDF driver uses Windows Virtual HID Framework (VHF) for OS-visible gamepad
@@ -118,6 +120,56 @@ callback path. DirectInput, SDL/HIDAPI, Windows.Gaming.Input/GameInput, and the
browser Gamepad API should therefore see standard HID gamepads after the driver
is installed. XInput is not a direct target for this HID-only backend because it
does not emulate the Xbox proprietary bus/API.
+The client protocol uses complete HID reports; numbered reports carry the report
+ID at byte 0 and unnumbered reports omit it. The UMDF driver passes that
+complete report buffer to VHF and also sets `HID_XFER_PACKET.reportId` for
+numbered reports. Output reports forwarded by VHF are normalized back to the
+same complete-report shape before delivery to the C++ backend. VHF exposes
+VID/PID/version, explicit
+`HID\VID_....&PID_....` hardware IDs, Xbox
+`HID\VID_....&PID_....&IG_00` hardware IDs where applicable, and the report
+descriptor for the child HID device so Windows and browser consumers can
+match the selected profile by HID attributes and report shape. VHF does not
+provide a product/manufacturer string callback, so consumers that display the
+raw HID product string may still show the Windows VHF product label even when
+the VID/PID and descriptor match the selected controller.
+The built-in Xbox One and Xbox Series profiles use an XboxGIP-shaped descriptor
+and unnumbered 17-byte input reports derived from HIDMaestro's USB Xbox
+profiles. The Xbox One profile uses `VID_045E&PID_02EA`, and the Xbox Series
+profile uses the physical USB identity `VID_045E&PID_0B12`. Bluetooth Xbox
+identities are intentionally not used for the built-in profiles. Windows'
+`xinputhid.inf` does not bind `HID\VID_045E&PID_0B12&IG_00` VHF children to
+XInput, so the UMDF driver also publishes HIDMaestro's GIP HID `driverPid`
+identity `HID\VID_045E&PID_02FF&IG_00` as a driver-matching hardware ID while
+keeping the public profile PID at `0B12`. The built-in generic profile uses a
+browser-standard generic gamepad
+descriptor: 16 one-bit digital buttons including the d-pad, followed by 8-bit
+`X`, `Y`, `Rx`, `Ry`, `Z`, and `Rz` values so the sticks occupy the first four
+axis slots and the analog triggers follow them. The Switch profile uses
+HIDMaestro's Nintendo Switch Pro Controller identity (`VID_057E&PID_2009`,
+product name `Pro Controller`) with Report ID `0x30`, a 64-byte input report, a
+hat d-pad, four 16-bit stick axes, and digital ZL/ZR trigger-click bits rather
+than analog trigger axes. The DualShock 4 profiles use the first-generation
+controller identity (`VID_054C&PID_05C4`, version `0100`, product name
+`Wireless Controller`, manufacturer `Sony Computer Entertainment`) to match the
+ViGEmBus DS4 target and HIDMaestro's DS4 v1 reference. The DualSense profiles
+use the standard `Wireless Controller` product name in the public profile and
+control protocol.
+The Xbox 360 HID profile
+keeps the legacy common descriptor with 12 one-bit digital buttons, a hat switch
+for the d-pad, and 8-bit `X`, `Y`, `Z`, `Rx`, `Ry`, and `Rz` values.
+On Windows, the UMDF/VHF backend rejects the Xbox 360 profile because a real
+Xbox 360 controller is an XUSB device rather than a VHF HID gamepad; consumers
+that still expose an Xbox 360 option should use their XUSB fallback for that
+profile.
+The UMDF driver opens a separate VHF source target for each virtual gamepad and
+parents that target to the control-file handle that created it, so process exits
+or crashes clean up any virtual gamepads that were not explicitly destroyed.
+During rapid development reinstalls, the fixed global control symbolic link can
+outlive the previous root device briefly; the driver treats that collision as
+non-fatal so stale object-manager state does not leave the control device in
+Code 31. Normal clients discover the PnP control device interface first, so a
+stale fixed link does not block the backend from reaching the current device.
Build the UMDF package separately with the Microsoft driver toolchain:
@@ -127,29 +179,77 @@ cmake -S . -B cmake-build-windows-driver -G "Visual Studio 17 2022" -A x64 `
-DBUILD_TESTS=OFF -DBUILD_EXAMPLES=OFF
cmake --build cmake-build-windows-driver --config Release --target libvirtualhid_umdf
cmake --build cmake-build-windows-driver --config Release --target libvirtualhid_windows_catalog
-cpack -G WIX --config .\cmake-build-windows-driver\CPackConfig.cmake
+cpack -G WIX -C Release --config .\cmake-build-windows-driver\CPackConfig.cmake
```
Developer install/uninstall helpers live under `scripts/windows`:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\windows\install-driver.ps1 `
- -InfPath .\cmake-build-windows-driver\src\platform\windows\driver\package\Release\libvirtualhid.inf
+ -InfPath .\cmake-build-windows-driver\src\platform\windows\driver\package\Release\libvirtualhid.inf `
+ -LogPath .\cmake-build-windows-driver\install-driver.log
+powershell -ExecutionPolicy Bypass -File .\scripts\windows\test-installed-driver.ps1 `
+ -GamepadAdapterPath .\cmake-build-ci\examples\Debug\gamepad_adapter.exe `
+ -GamepadProfile xseries
+powershell -ExecutionPolicy Bypass -File .\scripts\windows\test-browser-gamepad.ps1 `
+ -GamepadAdapterPath .\cmake-build-ci\examples\Debug\gamepad_adapter.exe `
+ -GamepadProfile xseries
powershell -ExecutionPolicy Bypass -File .\scripts\windows\uninstall-driver.ps1 `
-Force -RemoveCertificateSubject "CN=libvirtualhid CI Test Driver Signing"
```
The helper stages the INF with `pnputil`, updates an existing
`ROOT\LIBVIRTUALHID` device when present, and creates that root-enumerated
-device when it is missing. It uses `devcon.exe` when available, otherwise it
-uses SetupAPI/NewDev directly so MSI installs do not require the WDK tools on
-the target machine.
+device when it is missing. It uses SetupAPI/NewDev directly so MSI installs do
+not require the WDK tools on the target machine. Existing devices are detected
+by matching the `ROOT\LIBVIRTUALHID` hardware ID. The SetupAPI path creates a
+root-enumerated instance such as `ROOT\LIBVIRTUALHID\####`.
+The install and uninstall helpers also clean up malformed development devices
+left by earlier installer revisions, including root instances left in the
+failed `HIDClass` package shape. The WiX installer writes the helper transcript
+to `C:\ProgramData\libvirtualhid\install-driver.log`.
+The test helper fails if the root device is not reported as `Status: Started`,
+if `\\.\LibVirtualHid` cannot be opened, or if a held gamepad adapter instance
+does not produce a started HID child device such as
+`HID\VID_045E&PID_0B12&IG_00` or an Xbox Series-compatible HID child such as
+`HID\VID_045E&PID_02FF&IG_00`. That check is also run by the Windows CI legs
+for every Windows UMDF/VHF-supported `gamepad_adapter` profile
+after installing the Windows Driver Installer artifact. The browser helper is for manual
+diagnostics: it launches a normal desktop Edge or Chrome instance at
+`https://hardwaretester.com/gamepad`, holds a virtual gamepad, and fails if the
+browser Gamepad API does not report a controller matching the selected profile
+or does not observe changing button and axis input. For manual browser
+validation, run the browser helper with `-KeepBrowserOpen`, or run
+`examples/gamepad_adapter xseries --hold-seconds 60`, then open
+`https://hardwaretester.com/gamepad` in a normal desktop browser and press one
+of the held virtual buttons if the browser needs a gamepad activation event.
The driver binary is a UMDF DLL installed through the Windows Driver Store, not
a libvirtualhid `.sys` copied into `C:\Windows\System32\drivers`. Windows still
uses its built-in `WUDFRd.sys` and VHF components under `System32\drivers`; the
libvirtualhid-specific sign that installation completed is the
-`ROOT\LIBVIRTUALHID` device and the `\\.\LibVirtualHid` control device.
+`ROOT\LIBVIRTUALHID` device and the `\\.\LibVirtualHid` control device. The INF
+includes the built-in `WUDFRd` install sections for the root `System` control
+device, appends the VHF lower filter, sets `VhfMode=1` for the UMDF VHF source
+stack, grants non-admin user-mode clients read/write access to the control
+device, and disables UMDF host-process sharing so driver updates do not keep
+using an older in-process UMDF module during development. The installer also
+writes `VhfMode=1` onto the
+root device before starting the driver so root-enumerated development installs
+get the same VHF source mode as the INF hardware section. The UMDF control
+device is restarted after install or update so same-version development builds
+load the current UMDF module; if Windows cannot unload the old host, the
+installer reports the reboot requirement. The UMDF control device starts
+without opening VHF; each gamepad creation opens its own VHF target from the
+creating file handle so target-open failures are reported through the
+create-device response instead of making `\\.\LibVirtualHid` unavailable. The
+generated INF uses the same UMDF
+library version as the WDF headers and stub library selected by CMake. The
+package defaults to UMDF 2.15, matching the inbox VHF UMDF source driver while
+still exposing the framework APIs used by libvirtualhid. The driver target links
+the MSVC runtime statically to avoid requiring VC runtime DLLs in the UMDF host
+process. Development driver builds write a lightweight UMDF trace to
+`C:\Windows\Temp\libvirtualhid-umdf-driver.log`.
Windows driver packages require a signed catalog for normal installation. Pull
request builds generate a short-lived self-signed test certificate, sign
@@ -565,7 +665,9 @@ platform-specific calls.
`Runtime` and `Gamepad` APIs.
- [x] Preserve Sunshine's asynchronous event shape by caching per-controller
`GamepadState` and resubmitting after separate button, axis, trigger, touch,
- motion, and battery updates.
+ motion, and battery updates. Adapter creation submits one neutral input report
+ immediately so operating-system consumers can enumerate the virtual controller
+ before the first client input event arrives.
- [x] Expand or formally map the public button model so Sunshine's full
controller flag set is preserved, including guide/home, profile-specific
misc/share, and rear paddles where the emulated profile can expose them.
diff --git a/cmake/packaging/windows.cmake b/cmake/packaging/windows.cmake
index 0f671c4..197889a 100644
--- a/cmake/packaging/windows.cmake
+++ b/cmake/packaging/windows.cmake
@@ -14,6 +14,7 @@ set(LIBVIRTUALHID_DRIVER_TEST_CERTIFICATE "" CACHE FILEPATH
"Optional public test certificate to include in the Windows driver installer.")
install(FILES
+ "${PROJECT_SOURCE_DIR}/scripts/windows/libvirtualhid-driver-common.ps1"
"${PROJECT_SOURCE_DIR}/scripts/windows/install-driver.ps1"
"${PROJECT_SOURCE_DIR}/scripts/windows/uninstall-driver.ps1"
DESTINATION "scripts/windows"
diff --git a/cmake/packaging/windows_wix.cmake b/cmake/packaging/windows_wix.cmake
index 8779038..c604ef5 100644
--- a/cmake/packaging/windows_wix.cmake
+++ b/cmake/packaging/windows_wix.cmake
@@ -9,6 +9,7 @@ if(NOT DOTNET_EXECUTABLE)
endif()
set(CPACK_WIX_VERSION 4)
+set(CPACK_GENERATOR "WIX")
set(WIX_VERSION 4.0.4)
set(WIX_UI_VERSION 4.0.4) # extension versioning is independent of the WiX version
set(WIX_BUILD_PARENT_DIRECTORY "${CMAKE_BINARY_DIR}/wix_packaging")
@@ -26,25 +27,34 @@ if(NOT WIX_INSTALL_RESULT EQUAL 0)
message(FATAL_ERROR "Failed to install WiX tools locally: ${WIX_INSTALL_OUTPUT}")
endif()
-execute_process(
- COMMAND "${WIX_TOOL_PATH}/wix" extension add WixToolset.UI.wixext/${WIX_UI_VERSION}
- WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
- ERROR_VARIABLE WIX_UI_INSTALL_OUTPUT
- RESULT_VARIABLE WIX_UI_INSTALL_RESULT)
-
-if(NOT WIX_UI_INSTALL_RESULT EQUAL 0)
- message(FATAL_ERROR "Failed to install WiX UI extension: ${WIX_UI_INSTALL_OUTPUT}")
-endif()
-
-execute_process(
- COMMAND "${WIX_TOOL_PATH}/wix" extension add WixToolset.Util.wixext/${WIX_UI_VERSION}
- WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
- ERROR_VARIABLE WIX_UTIL_INSTALL_OUTPUT
- RESULT_VARIABLE WIX_UTIL_INSTALL_RESULT)
-
-if(NOT WIX_UTIL_INSTALL_RESULT EQUAL 0)
- message(FATAL_ERROR "Failed to install WiX Util extension: ${WIX_UTIL_INSTALL_OUTPUT}")
-endif()
+# Ensure a WiX extension is installed in the local tool cache.
+function(libvirtualhid_wix_ensure_extension extension_name extension_version)
+ execute_process(
+ COMMAND "${WIX_TOOL_PATH}/wix" extension list --global
+ WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
+ OUTPUT_VARIABLE WIX_EXTENSION_LIST_OUTPUT
+ ERROR_VARIABLE WIX_EXTENSION_LIST_ERROR
+ RESULT_VARIABLE WIX_EXTENSION_LIST_RESULT)
+
+ if(WIX_EXTENSION_LIST_RESULT EQUAL 0
+ AND WIX_EXTENSION_LIST_OUTPUT MATCHES "${extension_name}[ \t]+${extension_version}")
+ return()
+ endif()
+
+ execute_process(
+ COMMAND "${WIX_TOOL_PATH}/wix" extension add --global "${extension_name}/${extension_version}"
+ WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
+ ERROR_VARIABLE WIX_EXTENSION_INSTALL_OUTPUT
+ RESULT_VARIABLE WIX_EXTENSION_INSTALL_RESULT)
+
+ if(NOT WIX_EXTENSION_INSTALL_RESULT EQUAL 0)
+ message(FATAL_ERROR
+ "Failed to install WiX extension ${extension_name}/${extension_version}: "
+ "${WIX_EXTENSION_INSTALL_OUTPUT}${WIX_EXTENSION_LIST_ERROR}")
+ endif()
+endfunction()
+
+libvirtualhid_wix_ensure_extension(WixToolset.UI.wixext ${WIX_UI_VERSION})
set(CPACK_WIX_ROOT "${WIX_TOOL_PATH}")
set(CPACK_WIX_UPGRADE_GUID "71D7B738-9D83-4E57-82E3-C3106D9F8053")
@@ -52,16 +62,10 @@ set(CPACK_WIX_HELP_LINK "https://app.lizardbyte.dev/support")
set(CPACK_WIX_PRODUCT_URL "${CMAKE_PROJECT_HOMEPAGE_URL}")
set(CPACK_WIX_PROGRAM_MENU_FOLDER "LizardByte")
set(CPACK_WIX_EXTENSIONS
- "WixToolset.UI.wixext"
- "WixToolset.Util.wixext")
-
-message(STATUS "cpack package directory: ${CPACK_PACKAGE_DIRECTORY}")
-
-file(COPY "${CMAKE_CURRENT_LIST_DIR}/wix_resources/"
- DESTINATION "${WIX_BUILD_PARENT_DIRECTORY}/")
+ "WixToolset.UI.wixext")
-set(CPACK_WIX_EXTRA_SOURCES
- "${WIX_BUILD_PARENT_DIRECTORY}/libvirtualhid-driver-installer.wxs")
+set(CPACK_WIX_PATCH_FILE
+ "${CMAKE_CURRENT_LIST_DIR}/wix_resources/libvirtualhid-driver-installer-patch.xml")
file(COPY "${CMAKE_SOURCE_DIR}/LICENSE"
DESTINATION "${CMAKE_BINARY_DIR}")
diff --git a/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml b/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml
new file mode 100644
index 0000000..8387faf
--- /dev/null
+++ b/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmake/packaging/wix_resources/libvirtualhid-driver-installer.wxs b/cmake/packaging/wix_resources/libvirtualhid-driver-installer.wxs
deleted file mode 100644
index 984113f..0000000
--- a/cmake/packaging/wix_resources/libvirtualhid-driver-installer.wxs
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/examples/gamepad_adapter.cpp b/examples/gamepad_adapter.cpp
index 92a9d1a..3dedf70 100644
--- a/examples/gamepad_adapter.cpp
+++ b/examples/gamepad_adapter.cpp
@@ -4,19 +4,101 @@
*/
// standard includes
+#include
#include
+#include
+#include
+#include
+#include
// local includes
#include
-int main() {
- auto runtime = lvh::Runtime::create();
+namespace {
+ using namespace std::chrono_literals;
+
+ std::optional profile_for_name(std::string_view name) {
+ if (name == "generic") {
+ return lvh::profiles::generic_gamepad();
+ }
+ if (name == "x360") {
+ return lvh::profiles::xbox_360();
+ }
+ if (name == "xone") {
+ return lvh::profiles::xbox_one();
+ }
+ if (name == "xseries") {
+ return lvh::profiles::xbox_series();
+ }
+ if (name == "ds4") {
+ return lvh::profiles::dualshock4();
+ }
+ if (name == "ds5") {
+ return lvh::profiles::dualsense();
+ }
+ if (name == "switch") {
+ return lvh::profiles::switch_pro();
+ }
+
+ return std::nullopt;
+ }
+
+ lvh::ClientControllerType client_type_for_profile(lvh::GamepadProfileKind kind) {
+ switch (kind) {
+ using enum lvh::ClientControllerType;
+ using enum lvh::GamepadProfileKind;
+
+ case xbox_360:
+ case xbox_one:
+ case xbox_series:
+ return xbox;
+ case dualshock4:
+ case dualsense:
+ return playstation;
+ case switch_pro:
+ return nintendo;
+ case generic:
+ return unknown;
+ }
+
+ return lvh::ClientControllerType::unknown;
+ }
+} // namespace
+
+int main(int argc, char *argv[]) {
+ auto profile_name = std::string_view {"ds5"};
+ auto hold = false;
+ auto hold_seconds = 60;
+ auto index = 1;
+ while (index < argc) {
+ const auto argument = std::string_view {argv[index]};
+ ++index;
+ if (argument == "--hold") {
+ hold = true;
+ } else if (argument == "--hold-seconds" && index < argc) {
+ hold = true;
+ hold_seconds = std::stoi(argv[index]);
+ ++index;
+ } else {
+ profile_name = argument;
+ }
+ }
+
+ auto profile = profile_for_name(profile_name);
+ if (!profile) {
+ std::cerr << "Unknown profile: " << profile_name << '\n';
+ return 1;
+ }
+
+ lvh::RuntimeOptions runtime_options;
+ runtime_options.backend = lvh::BackendKind::platform_default;
+ auto runtime = lvh::Runtime::create(runtime_options);
lvh::CreateGamepadOptions options;
- options.profile = lvh::profiles::dualsense();
+ options.profile = *profile;
options.metadata.global_index = 0;
options.metadata.client_relative_index = 0;
- options.metadata.client_type = lvh::ClientControllerType::playstation;
+ options.metadata.client_type = client_type_for_profile(profile->gamepad_kind);
options.metadata.has_motion_sensors = true;
options.metadata.has_touchpad = true;
options.metadata.has_rgb_led = true;
@@ -44,6 +126,20 @@ int main() {
adapter.set_left_stick({0.25F, -0.5F});
adapter.set_right_trigger(1.0F);
+ if (hold) {
+ std::cout << "Holding " << profile->name << " for " << hold_seconds << " seconds\n";
+ for (auto step = 0; step < hold_seconds * 5; ++step) {
+ const auto direction = step % 40 < 20 ? 1.0F : -1.0F;
+ const auto sweep = static_cast(step % 20) / 19.0F;
+ adapter.set_left_stick({direction, direction * 0.5F});
+ adapter.set_right_stick({-direction * 0.5F, -direction});
+ adapter.set_left_trigger(sweep);
+ adapter.set_right_trigger(1.0F - sweep);
+ adapter.set_button(lvh::GamepadButton::a, step % 20 < 10);
+ std::this_thread::sleep_for(200ms);
+ }
+ }
+
lvh::GamepadOutput rumble;
rumble.kind = lvh::GamepadOutputKind::rumble;
rumble.low_frequency_rumble = 0x4000;
diff --git a/scripts/windows/install-driver.ps1 b/scripts/windows/install-driver.ps1
index 31ee9f6..1444501 100644
--- a/scripts/windows/install-driver.ps1
+++ b/scripts/windows/install-driver.ps1
@@ -11,10 +11,53 @@ param(
[string] $HardwareId = "ROOT\LIBVIRTUALHID",
+ [string] $LogPath,
+
[switch] $StageOnly
)
$ErrorActionPreference = "Stop"
+$script:LibVirtualHidTranscriptStarted = $false
+. (Join-Path $PSScriptRoot "libvirtualhid-driver-common.ps1")
+
+function Start-LibVirtualHidTranscript {
+ [CmdletBinding(SupportsShouldProcess)]
+ param([string] $Path)
+
+ if (-not $Path) {
+ return
+ }
+
+ try {
+ $logDirectory = Split-Path -Parent $Path
+ if ($logDirectory) {
+ New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null
+ }
+ if ($PSCmdlet.ShouldProcess($Path, "Start libvirtualhid install transcript")) {
+ Start-Transcript -Path $Path -Append | Out-Null
+ $script:LibVirtualHidTranscriptStarted = $true
+ }
+ } catch {
+ Write-Warning "Unable to start libvirtualhid install transcript: $($_.Exception.Message)"
+ }
+}
+
+function Stop-LibVirtualHidTranscript {
+ [CmdletBinding(SupportsShouldProcess)]
+ param()
+
+ if (-not $script:LibVirtualHidTranscriptStarted) {
+ return
+ }
+
+ try {
+ if ($PSCmdlet.ShouldProcess("libvirtualhid install transcript", "Stop transcript")) {
+ Stop-Transcript | Out-Null
+ }
+ } catch {
+ Write-Warning "Unable to stop libvirtualhid install transcript: $($_.Exception.Message)"
+ }
+}
function Invoke-CheckedCommand {
param(
@@ -22,38 +65,17 @@ function Invoke-CheckedCommand {
[string] $FilePath,
[Parameter(Mandatory = $true)]
- [string[]] $Arguments
+ [string[]] $Arguments,
+
+ [int[]] $SuccessExitCodes = @(0)
)
& $FilePath @Arguments
- if ($LASTEXITCODE -ne 0) {
+ if ($LASTEXITCODE -notin $SuccessExitCodes) {
throw "$FilePath exited with code $LASTEXITCODE"
}
}
-function Find-Devcon {
- if ($env:DEVCON_EXE -and (Test-Path -LiteralPath $env:DEVCON_EXE)) {
- return $env:DEVCON_EXE
- }
-
- $roots = @(
- $env:WDKContentRoot,
- $env:WindowsSdkDir,
- "${env:ProgramFiles(x86)}\Windows Kits\10"
- ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }
-
- foreach ($root in $roots) {
- $candidate = Get-ChildItem -LiteralPath $root -Recurse -Filter devcon.exe -ErrorAction SilentlyContinue |
- Where-Object { $_.FullName -match "\\x64\\devcon\.exe$" } |
- Select-Object -First 1
- if ($candidate) {
- return $candidate.FullName
- }
- }
-
- return $null
-}
-
function Import-DriverCertificate {
[CmdletBinding(SupportsShouldProcess)]
param([string] $Path)
@@ -99,56 +121,39 @@ namespace LibVirtualHid.SetupApi {
}
[DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern bool SetupDiGetINFClass(
- string infName,
- out Guid classGuid,
- StringBuilder className,
- uint classNameSize,
- out uint requiredSize);
+ private static extern bool SetupDiGetINFClass(string infName, out Guid classGuid, StringBuilder className, uint classNameSize, out uint requiredSize);
[DllImport("setupapi.dll", SetLastError = true)]
- private static extern IntPtr SetupDiCreateDeviceInfoList(
- ref Guid classGuid,
- IntPtr hwndParent);
+ private static extern IntPtr SetupDiCreateDeviceInfoList(ref Guid classGuid, IntPtr hwndParent);
[DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern bool SetupDiCreateDeviceInfo(
- IntPtr deviceInfoSet,
- string deviceName,
- ref Guid classGuid,
- string deviceDescription,
- IntPtr hwndParent,
- uint creationFlags,
- ref SpDevinfoData deviceInfoData);
+ private static extern bool SetupDiCreateDeviceInfo(IntPtr deviceInfoSet, string deviceName, ref Guid classGuid, string deviceDescription, IntPtr hwndParent, uint creationFlags, ref SpDevinfoData deviceInfoData);
- [DllImport("setupapi.dll", SetLastError = true)]
- private static extern bool SetupDiSetDeviceRegistryProperty(
- IntPtr deviceInfoSet,
- ref SpDevinfoData deviceInfoData,
- uint property,
- byte[] propertyBuffer,
- uint propertyBufferSize);
+ [DllImport("setupapi.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool SetupDiSetDeviceRegistryProperty(IntPtr deviceInfoSet, ref SpDevinfoData deviceInfoData, uint property, byte[] propertyBuffer, uint propertyBufferSize);
[DllImport("setupapi.dll", SetLastError = true)]
- private static extern bool SetupDiCallClassInstaller(
- uint installFunction,
- IntPtr deviceInfoSet,
- ref SpDevinfoData deviceInfoData);
+ private static extern bool SetupDiCallClassInstaller(uint installFunction, IntPtr deviceInfoSet, ref SpDevinfoData deviceInfoData);
[DllImport("setupapi.dll", SetLastError = true)]
private static extern bool SetupDiDestroyDeviceInfoList(IntPtr deviceInfoSet);
[DllImport("newdev.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern bool UpdateDriverForPlugAndPlayDevices(
- IntPtr hwndParent,
- string hardwareId,
- string fullInfPath,
- uint installFlags,
- out bool rebootRequired);
+ private static extern bool UpdateDriverForPlugAndPlayDevices(IntPtr hwndParent, string hardwareId, string fullInfPath, uint installFlags, out bool rebootRequired);
+
+ public static void Update(string infPath, string hardwareId, out bool rebootRequired) {
+ rebootRequired = false;
+
+ if (!UpdateDriverForPlugAndPlayDevices(IntPtr.Zero, hardwareId, infPath, InstallFlagForce | InstallFlagNonInteractive, out rebootRequired)) {
+ ThrowLastWin32Error("UpdateDriverForPlugAndPlayDevices");
+ }
+ }
public static void Install(string infPath, string hardwareId, out bool rebootRequired) {
rebootRequired = false;
+ string rootDeviceName = GetRootDeviceName(hardwareId);
+
Guid classGuid;
uint requiredSize;
var className = new StringBuilder(256);
@@ -162,27 +167,14 @@ namespace LibVirtualHid.SetupApi {
}
try {
- var deviceInfoData = new SpDevinfoData();
- deviceInfoData.cbSize = (uint) Marshal.SizeOf(typeof(SpDevinfoData));
-
- if (!SetupDiCreateDeviceInfo(
- deviceInfoSet,
- className.ToString(),
- ref classGuid,
- null,
- IntPtr.Zero,
- DicdGenerateId,
- ref deviceInfoData)) {
+ var deviceInfoData = new SpDevinfoData { cbSize = (uint) Marshal.SizeOf(typeof(SpDevinfoData)) };
+
+ if (!SetupDiCreateDeviceInfo(deviceInfoSet, rootDeviceName, ref classGuid, null, IntPtr.Zero, DicdGenerateId, ref deviceInfoData)) {
ThrowLastWin32Error("SetupDiCreateDeviceInfo");
}
byte[] hardwareIds = Encoding.Unicode.GetBytes(hardwareId + "\0\0");
- if (!SetupDiSetDeviceRegistryProperty(
- deviceInfoSet,
- ref deviceInfoData,
- SpdrpHardwareId,
- hardwareIds,
- (uint) hardwareIds.Length)) {
+ if (!SetupDiSetDeviceRegistryProperty(deviceInfoSet, ref deviceInfoData, SpdrpHardwareId, hardwareIds, (uint) hardwareIds.Length)) {
ThrowLastWin32Error("SetupDiSetDeviceRegistryProperty");
}
@@ -192,15 +184,22 @@ namespace LibVirtualHid.SetupApi {
} finally {
SetupDiDestroyDeviceInfoList(deviceInfoSet);
}
+ }
- if (!UpdateDriverForPlugAndPlayDevices(
- IntPtr.Zero,
- hardwareId,
- infPath,
- InstallFlagForce | InstallFlagNonInteractive,
- out rebootRequired)) {
- ThrowLastWin32Error("UpdateDriverForPlugAndPlayDevices");
+ private static string GetRootDeviceName(string hardwareId) {
+ const string rootPrefix = "ROOT\\";
+ if (!hardwareId.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) {
+ throw new ArgumentException("Hardware ID must use the ROOT\\ enumerator.", "hardwareId");
}
+
+ string rootDeviceName = hardwareId.Substring(rootPrefix.Length);
+ if (rootDeviceName.Length == 0 || rootDeviceName.Contains("\\")) {
+ throw new ArgumentException(
+ "Hardware ID must be a root-enumerated device ID without an instance suffix.",
+ "hardwareId");
+ }
+
+ return rootDeviceName;
}
private static void ThrowLastWin32Error(string action) {
@@ -213,17 +212,75 @@ namespace LibVirtualHid.SetupApi {
"@
}
-function Get-RootDeviceInstanceId {
- param([string] $TargetHardwareId)
+function Remove-DeviceInstance {
+ [CmdletBinding(SupportsShouldProcess)]
+ param([string] $InstanceId)
- try {
- $prefix = "$TargetHardwareId\"
- @(Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction Stop |
- Where-Object { $_.PNPDeviceID -like "$prefix*" } |
- ForEach-Object { $_.PNPDeviceID })
- } catch {
- Write-Verbose "Unable to enumerate PnP devices: $($_.Exception.Message)"
- @()
+ if ($PSCmdlet.ShouldProcess($InstanceId, "Remove stale libvirtualhid root device")) {
+ Invoke-CheckedCommand -FilePath "pnputil.exe" -Arguments @("/remove-device", $InstanceId)
+ }
+}
+
+function Set-RootDeviceVhfMode {
+ [CmdletBinding(SupportsShouldProcess)]
+ param([string] $InstanceId)
+
+ $deviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Enum\$InstanceId"
+ if (-not (Test-Path -LiteralPath $deviceRegistryPath)) {
+ Write-Verbose "Unable to set VhfMode because $deviceRegistryPath does not exist."
+ return
+ }
+
+ if ($PSCmdlet.ShouldProcess($InstanceId, "Set VhfMode=1 for UMDF VHF source device")) {
+ New-ItemProperty -LiteralPath $deviceRegistryPath -Name "VhfMode" -Value 1 -PropertyType DWord -Force | Out-Null
+ Write-Information "Set VhfMode=1 on $InstanceId." -InformationAction Continue
+ }
+}
+
+function Restart-RootDevice {
+ [CmdletBinding(SupportsShouldProcess)]
+ param([string] $InstanceId)
+
+ if (-not $InstanceId) {
+ return
+ }
+
+ if (-not $PSCmdlet.ShouldProcess($InstanceId, "Restart libvirtualhid development device")) {
+ return
+ }
+
+ $output = @(pnputil.exe /restart-device $InstanceId 2>&1)
+ $exitCode = $LASTEXITCODE
+ foreach ($line in $output) {
+ Write-Information $line -InformationAction Continue
+ }
+
+ if ($exitCode -ne 0) {
+ throw "pnputil.exe /restart-device $InstanceId exited with code $exitCode"
+ }
+
+ if ($output -match "reboot is needed") {
+ Write-Warning "Windows reported that a reboot is required to reload the libvirtualhid UMDF driver."
+ }
+}
+
+function Update-RootDeviceDriverWithSetupApi {
+ [CmdletBinding(SupportsShouldProcess)]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string] $Path,
+
+ [Parameter(Mandatory = $true)]
+ [string] $TargetHardwareId
+ )
+
+ Add-SetupApiRootDeviceInstaller
+ $rebootRequired = $false
+ if ($PSCmdlet.ShouldProcess($TargetHardwareId, "Update libvirtualhid development device driver")) {
+ [LibVirtualHid.SetupApi.RootDeviceInstaller]::Update($Path, $TargetHardwareId, [ref] $rebootRequired)
+ }
+ if ($rebootRequired) {
+ Write-Warning "Windows reported that a reboot is required to finish installing the libvirtualhid driver."
}
}
@@ -244,30 +301,50 @@ function Install-RootDeviceWithSetupApi {
}
}
-$resolvedInf = (Resolve-Path -LiteralPath $InfPath).Path
-Import-DriverCertificate -Path $CertificatePath
+Start-LibVirtualHidTranscript -Path $LogPath
-if ($PSCmdlet.ShouldProcess($resolvedInf, "Stage libvirtualhid driver package")) {
- Invoke-CheckedCommand -FilePath "pnputil.exe" -Arguments @("/add-driver", $resolvedInf, "/install")
-}
+try {
+ $resolvedInf = (Resolve-Path -LiteralPath $InfPath).Path
+ Import-DriverCertificate -Path $CertificatePath
-if ($StageOnly) {
- return
-}
+ if ($PSCmdlet.ShouldProcess($resolvedInf, "Stage libvirtualhid driver package")) {
+ Invoke-CheckedCommand -FilePath "pnputil.exe" -Arguments @("/add-driver", $resolvedInf) -SuccessExitCodes @(0, 5)
+ }
-if ((Get-RootDeviceInstanceId -TargetHardwareId $HardwareId).Count -gt 0) {
- Write-Information "The $HardwareId device already exists." -InformationAction Continue
- return
-}
+ if ($StageOnly) {
+ return
+ }
-$devcon = Find-Devcon
-if ($devcon) {
- if ($PSCmdlet.ShouldProcess($HardwareId, "Create libvirtualhid development device with devcon")) {
- Invoke-CheckedCommand -FilePath $devcon -Arguments @("install", $resolvedInf, $HardwareId)
+ $registryRootDevices = @(Get-LibVirtualHidRegistryRootDevice -TargetHardwareId $HardwareId)
+ foreach ($device in ($registryRootDevices | Where-Object { $_.HasCorruptHardwareId -or $_.HasLegacyHidClass })) {
+ Remove-DeviceInstance -InstanceId $device.InstanceId
}
- return
-}
-if ($PSCmdlet.ShouldProcess($HardwareId, "Create libvirtualhid development device with SetupAPI")) {
- Install-RootDeviceWithSetupApi -Path $resolvedInf -TargetHardwareId $HardwareId
+ $rootDevices = @(Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId $HardwareId)
+ if ($rootDevices.Count -gt 0) {
+ Write-Information "Updating the existing $HardwareId device driver." -InformationAction Continue
+ foreach ($rootDevice in $rootDevices) {
+ Set-RootDeviceVhfMode -InstanceId $rootDevice
+ }
+ Update-RootDeviceDriverWithSetupApi -Path $resolvedInf -TargetHardwareId $HardwareId
+ foreach ($rootDevice in $rootDevices) {
+ Restart-RootDevice -InstanceId $rootDevice
+ }
+ return
+ }
+
+ if ($PSCmdlet.ShouldProcess($HardwareId, "Create libvirtualhid development device with SetupAPI")) {
+ Install-RootDeviceWithSetupApi -Path $resolvedInf -TargetHardwareId $HardwareId
+ }
+
+ $rootDevices = @(Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId $HardwareId)
+ foreach ($rootDevice in $rootDevices) {
+ Set-RootDeviceVhfMode -InstanceId $rootDevice
+ }
+ Update-RootDeviceDriverWithSetupApi -Path $resolvedInf -TargetHardwareId $HardwareId
+ foreach ($rootDevice in $rootDevices) {
+ Restart-RootDevice -InstanceId $rootDevice
+ }
+} finally {
+ Stop-LibVirtualHidTranscript
}
diff --git a/scripts/windows/libvirtualhid-driver-common.ps1 b/scripts/windows/libvirtualhid-driver-common.ps1
new file mode 100644
index 0000000..ad5caa2
--- /dev/null
+++ b/scripts/windows/libvirtualhid-driver-common.ps1
@@ -0,0 +1,70 @@
+function Get-LibVirtualHidRootDeviceInstanceId {
+ param([string] $TargetHardwareId)
+
+ try {
+ $devices = & pnputil.exe /enum-devices /deviceid $TargetHardwareId /deviceids
+ if ($LASTEXITCODE -eq 0) {
+ $instanceIds = @($devices |
+ Where-Object { $_ -match "^\s*Instance ID\s*:\s*(.+)$" } |
+ ForEach-Object { $Matches[1].Trim() })
+ if ($instanceIds.Count -gt 0) {
+ return $instanceIds
+ }
+ }
+ } catch {
+ Write-Verbose "Unable to enumerate PnP devices with pnputil: $($_.Exception.Message)"
+ }
+
+ try {
+ $prefix = "$TargetHardwareId\"
+ @(Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction Stop |
+ Where-Object { $_.PNPDeviceID -like "$prefix*" -or $_.HardwareID -contains $TargetHardwareId } |
+ ForEach-Object { $_.PNPDeviceID })
+ } catch {
+ Write-Verbose "Unable to enumerate PnP devices: $($_.Exception.Message)"
+ @()
+ }
+}
+
+function Get-LibVirtualHidRegistryRootDevice {
+ param([string] $TargetHardwareId)
+
+ $rootKey = "HKLM:\SYSTEM\CurrentControlSet\Enum\ROOT"
+ Get-ChildItem -LiteralPath $rootKey -ErrorAction SilentlyContinue | ForEach-Object {
+ $rootDeviceId = $_.PSChildName
+ Get-ChildItem -LiteralPath $_.PSPath -ErrorAction SilentlyContinue | ForEach-Object {
+ $instanceId = "ROOT\$rootDeviceId\$($_.PSChildName)"
+ $hardwareIds = @()
+ try {
+ $hardwareIds = @((Get-ItemProperty -LiteralPath $_.PSPath -Name HardwareID -ErrorAction Stop).HardwareID)
+ } catch {
+ $hardwareIds = @()
+ }
+
+ $hasExactHardwareId = $hardwareIds -contains $TargetHardwareId
+ $hasCorruptHardwareId = (
+ $hardwareIds.Count -gt 1 -and
+ -not $hasExactHardwareId -and
+ (($hardwareIds -join "") -ieq $TargetHardwareId)
+ )
+ $hasTargetInstanceId = $instanceId -like "$TargetHardwareId\*"
+
+ if ($hasExactHardwareId -or $hasCorruptHardwareId -or $hasTargetInstanceId) {
+ $classGuid = $null
+ try {
+ $deviceProperties = Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction Stop
+ $classGuid = $deviceProperties.ClassGUID
+ } catch {
+ Write-Verbose "Unable to read registry properties for $instanceId`: $($_.Exception.Message)"
+ }
+
+ [pscustomobject]@{
+ InstanceId = $instanceId
+ HasExactHardwareId = $hasExactHardwareId
+ HasCorruptHardwareId = $hasCorruptHardwareId
+ HasLegacyHidClass = $classGuid -ieq "{745a17a0-74d3-11d0-b6fe-00a0c90f57da}"
+ }
+ }
+ }
+ }
+}
diff --git a/scripts/windows/test-browser-gamepad.ps1 b/scripts/windows/test-browser-gamepad.ps1
new file mode 100644
index 0000000..de2c82f
--- /dev/null
+++ b/scripts/windows/test-browser-gamepad.ps1
@@ -0,0 +1,490 @@
+<#
+.SYNOPSIS
+Validates an installed libvirtualhid gamepad through a real browser Gamepad API.
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $GamepadAdapterPath,
+
+ [ValidateSet("generic", "x360", "xone", "xseries", "ds4", "ds5", "switch")]
+ [Alias("Profile")]
+ [string] $GamepadProfile = "xseries",
+
+ [string] $BrowserPath,
+
+ [string] $Url = "https://hardwaretester.com/gamepad",
+
+ [int] $TimeoutSeconds = 20,
+
+ [int] $HoldSeconds = 35,
+
+ [string] $ExpectedIdPattern,
+
+ [switch] $AllowAnyGamepad,
+
+ [switch] $KeepBrowserOpen
+)
+
+$ErrorActionPreference = "Stop"
+$script:DevToolsCommandId = 0
+
+function Get-ExpectedGamepadIdPattern {
+ param([string] $ProfileName)
+
+ switch ($ProfileName) {
+ "generic" { return "(1209.*0001|vid[_ -]?1209.*pid[_ -]?0001|generic)" }
+ "x360" { return "(045e.*028e|vid[_ -]?045e.*pid[_ -]?028e|x-?box.*360)" }
+ "xone" { return "(045e.*02ea|vid[_ -]?045e.*pid[_ -]?02ea|xbox one|x-box one)" }
+ "xseries" { return "(045e.*0b12|045e.*02ff|vid[_ -]?045e.*pid[_ -]?0b12|vid[_ -]?045e.*pid[_ -]?02ff|xbox wireless|xbox series)" }
+ "ds4" { return "(054c.*05c4|vid[_ -]?054c.*pid[_ -]?05c4|dualshock|wireless controller)" }
+ "ds5" { return "(054c.*0ce6|vid[_ -]?054c.*pid[_ -]?0ce6|dualsense|wireless controller)" }
+ "switch" { return "(057e.*2009|vid[_ -]?057e.*pid[_ -]?2009|switch|pro controller)" }
+ }
+
+ throw "Unsupported profile: $ProfileName"
+}
+
+function Resolve-BrowserPath {
+ param([string] $Path)
+
+ if ($Path) {
+ return (Resolve-Path -LiteralPath $Path).Path
+ }
+
+ $candidates = @(
+ (Join-Path ${env:ProgramFiles(x86)} "Microsoft\Edge\Application\msedge.exe"),
+ (Join-Path $env:ProgramFiles "Microsoft\Edge\Application\msedge.exe"),
+ (Join-Path ${env:ProgramFiles(x86)} "Google\Chrome\Application\chrome.exe"),
+ (Join-Path $env:ProgramFiles "Google\Chrome\Application\chrome.exe")
+ )
+
+ foreach ($candidate in $candidates) {
+ if (Test-Path -LiteralPath $candidate) {
+ return $candidate
+ }
+ }
+
+ throw "No supported browser was found. Pass -BrowserPath with msedge.exe or chrome.exe."
+}
+
+function Get-FreeTcpPort {
+ $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
+ try {
+ $listener.Start()
+ return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port
+ } finally {
+ $listener.Stop()
+ }
+}
+
+function Wait-ForDevToolsJson {
+ param(
+ [int] $Port,
+ [string] $Path,
+ [int] $TimeoutSeconds
+ )
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ $uri = "http://127.0.0.1:${Port}${Path}"
+ do {
+ try {
+ return Invoke-RestMethod -Uri $uri -TimeoutSec 2
+ } catch {
+ Start-Sleep -Milliseconds 250
+ }
+ } while ((Get-Date) -lt $deadline)
+
+ throw "Timed out waiting for browser DevTools endpoint $uri."
+}
+
+function Wait-ForDevToolsPageTarget {
+ param(
+ [int] $Port,
+ [string] $ExpectedUrl,
+ [int] $TimeoutSeconds
+ )
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ do {
+ try {
+ $rawTargets = Wait-ForDevToolsJson -Port $Port -Path "/json" -TimeoutSeconds 2
+ $targets = @($rawTargets)
+ if ($targets.Count -eq 1 -and $targets[0] -is [System.Array]) {
+ $targets = @($targets[0])
+ }
+ } catch {
+ $targets = @()
+ }
+ $page = $targets |
+ Where-Object {
+ $_.type -eq "page" -and
+ $_.webSocketDebuggerUrl -and
+ $_.url -and
+ $_.url.StartsWith($ExpectedUrl, [System.StringComparison]::OrdinalIgnoreCase)
+ } |
+ Select-Object -First 1
+ if (-not $page) {
+ $page = $targets |
+ Where-Object {
+ $_.type -eq "page" -and
+ $_.webSocketDebuggerUrl -and
+ $_.url -and
+ -not $_.url.StartsWith("edge://", [System.StringComparison]::OrdinalIgnoreCase) -and
+ -not $_.url.StartsWith("chrome://", [System.StringComparison]::OrdinalIgnoreCase)
+ } |
+ Select-Object -First 1
+ }
+ if ($page) {
+ Write-Verbose "Using browser page target: $($page.url) ($($page.webSocketDebuggerUrl))"
+ return [pscustomobject] @{
+ url = $page.url
+ webSocketDebuggerUrl = $page.webSocketDebuggerUrl
+ }
+ }
+
+ Start-Sleep -Milliseconds 250
+ } while ((Get-Date) -lt $deadline)
+
+ throw "Timed out waiting for a browser page target."
+}
+
+function Invoke-DevToolsCommand {
+ param(
+ [string] $WebSocketDebuggerUrl,
+ [string] $Method,
+ [hashtable] $Params = @{},
+ [int] $TimeoutSeconds = 30
+ )
+
+ $socket = [System.Net.WebSockets.ClientWebSocket]::new()
+ $cancellation = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds($TimeoutSeconds))
+ try {
+ $normalizedWebSocketDebuggerUrl = $WebSocketDebuggerUrl -replace "://localhost(:|/)", '://127.0.0.1$1'
+ $socket.ConnectAsync([Uri] $normalizedWebSocketDebuggerUrl, $cancellation.Token).GetAwaiter().GetResult()
+
+ $id = [System.Threading.Interlocked]::Increment([ref] $script:DevToolsCommandId)
+ $message = @{
+ id = $id
+ method = $Method
+ params = $Params
+ } | ConvertTo-Json -Depth 20 -Compress
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($message)
+ $socket.SendAsync(
+ [ArraySegment[byte]]::new($bytes),
+ [System.Net.WebSockets.WebSocketMessageType]::Text,
+ $true,
+ $cancellation.Token
+ ).GetAwaiter().GetResult()
+
+ do {
+ $buffer = New-Object byte[] 65536
+ $builder = [System.Text.StringBuilder]::new()
+ do {
+ $segment = [ArraySegment[byte]]::new($buffer)
+ $result = $socket.ReceiveAsync($segment, $cancellation.Token).GetAwaiter().GetResult()
+ if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) {
+ throw "Browser DevTools websocket closed before $Method returned."
+ }
+ [void] $builder.Append([System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count))
+ } while (-not $result.EndOfMessage)
+
+ $response = $builder.ToString() | ConvertFrom-Json
+ if ($response.id -eq $id) {
+ if ($response.error) {
+ throw "DevTools $Method failed: $($response.error.message)"
+ }
+ return $response.result
+ }
+ } while ($true)
+ } finally {
+ if ($socket.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
+ $socket.CloseAsync(
+ [System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure,
+ "done",
+ [System.Threading.CancellationToken]::None
+ ).GetAwaiter().GetResult()
+ }
+ $socket.Dispose()
+ $cancellation.Dispose()
+ }
+}
+
+function Get-GamepadApiProbeExpression {
+ param(
+ [string] $ExpectedIdPattern,
+ [bool] $AllowAnyGamepad,
+ [int] $TimeoutSeconds
+ )
+
+ $patternJson = ConvertTo-Json $ExpectedIdPattern -Compress
+ $allowAnyJson = if ($AllowAnyGamepad) { "true" } else { "false" }
+ return @"
+(async () => {
+ const expectedPattern = new RegExp($patternJson, "i");
+ const allowAnyGamepad = $allowAnyJson;
+ const deadline = Date.now() + ($TimeoutSeconds * 1000);
+ const summary = {
+ ok: false,
+ gamepadApi: typeof navigator.getGamepads === "function",
+ secureContext: window.isSecureContext,
+ userAgent: navigator.userAgent,
+ ids: [],
+ samples: [],
+ matched: null
+ };
+
+ if (!summary.gamepadApi) {
+ return summary;
+ }
+
+ function round(value) {
+ return Math.round(value * 1000) / 1000;
+ }
+
+ function snapshot() {
+ return Array.from(navigator.getGamepads())
+ .filter((pad) => pad)
+ .map((pad) => ({
+ id: pad.id,
+ index: pad.index,
+ mapping: pad.mapping,
+ connected: pad.connected,
+ buttonCount: pad.buttons.length,
+ axisCount: pad.axes.length,
+ buttons: pad.buttons.map((button) => ({
+ pressed: button.pressed,
+ value: round(button.value)
+ })),
+ axes: pad.axes.map(round)
+ }));
+ }
+
+ const seenById = new Map();
+ function stateFor(pad) {
+ if (!seenById.has(pad.id)) {
+ seenById.set(pad.id, {
+ id: pad.id,
+ mapping: pad.mapping,
+ buttonCount: pad.buttonCount,
+ axisCount: pad.axisCount,
+ buttonPressed: false,
+ buttonChanged: false,
+ axisMoved: false,
+ buttons: [],
+ axes: []
+ });
+ }
+ return seenById.get(pad.id);
+ }
+
+ function updateExtents(extents, index, value) {
+ if (!extents[index]) {
+ extents[index] = { min: value, max: value };
+ } else {
+ extents[index].min = Math.min(extents[index].min, value);
+ extents[index].max = Math.max(extents[index].max, value);
+ }
+ return extents[index].max - extents[index].min;
+ }
+
+ while (Date.now() < deadline) {
+ const pads = snapshot();
+ if (summary.samples.length < 5 || Date.now() + 1000 >= deadline) {
+ summary.samples.push(pads.map((pad) => ({
+ id: pad.id,
+ mapping: pad.mapping,
+ buttonCount: pad.buttonCount,
+ axisCount: pad.axisCount,
+ pressedButtons: pad.buttons
+ .map((button, index) => button.pressed || button.value > 0.5 ? index : null)
+ .filter((index) => index !== null),
+ axes: pad.axes
+ })));
+ }
+
+ for (const pad of pads) {
+ if (!summary.ids.includes(pad.id)) {
+ summary.ids.push(pad.id);
+ }
+ if (!allowAnyGamepad && !expectedPattern.test(pad.id)) {
+ continue;
+ }
+
+ const seen = stateFor(pad);
+ seen.mapping = pad.mapping;
+ for (let index = 0; index < pad.buttons.length; index += 1) {
+ const button = pad.buttons[index];
+ if (button.pressed || button.value > 0.5) {
+ seen.buttonPressed = true;
+ }
+ if (updateExtents(seen.buttons, index, button.value) > 0.5) {
+ seen.buttonChanged = true;
+ }
+ }
+
+ for (let index = 0; index < pad.axes.length; index += 1) {
+ if (updateExtents(seen.axes, index, pad.axes[index]) > 0.5) {
+ seen.axisMoved = true;
+ }
+ }
+
+ if (seen.buttonPressed && seen.buttonChanged && seen.axisMoved) {
+ summary.ok = true;
+ summary.matched = seen;
+ return summary;
+ }
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+
+ for (const seen of seenById.values()) {
+ summary.matched = seen;
+ break;
+ }
+ return summary;
+})()
+"@
+}
+
+if ($HoldSeconds -le $TimeoutSeconds) {
+ throw "-HoldSeconds must be greater than -TimeoutSeconds so the adapter remains alive for browser polling."
+}
+
+$resolvedGamepadAdapterPath = (Resolve-Path -LiteralPath $GamepadAdapterPath).Path
+$resolvedBrowserPath = Resolve-BrowserPath -Path $BrowserPath
+$expectedPattern = if ($ExpectedIdPattern) {
+ $ExpectedIdPattern
+} else {
+ Get-ExpectedGamepadIdPattern -ProfileName $GamepadProfile
+}
+if ($GamepadProfile -eq "x360") {
+ throw "The Windows UMDF/VHF backend does not expose Xbox 360 XUSB gamepads. Use the consumer's XUSB fallback for x360."
+}
+$remoteDebuggingPort = Get-FreeTcpPort
+$browserUserDataDir = Join-Path ([System.IO.Path]::GetTempPath()) "libvirtualhid-browser-gamepad-$([Guid]::NewGuid())"
+$adapterStdoutPath = Join-Path ([System.IO.Path]::GetTempPath()) "libvirtualhid-browser-gamepad-adapter.out"
+$adapterStderrPath = Join-Path ([System.IO.Path]::GetTempPath()) "libvirtualhid-browser-gamepad-adapter.err"
+Remove-Item -LiteralPath $adapterStdoutPath, $adapterStderrPath -Force -ErrorAction SilentlyContinue
+
+$browserProcess = $null
+$adapterProcess = $null
+try {
+ $browserArguments = @(
+ "--user-data-dir=$browserUserDataDir",
+ "--remote-debugging-port=$remoteDebuggingPort",
+ "--remote-allow-origins=*",
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-background-timer-throttling",
+ "--disable-renderer-backgrounding",
+ "--disable-backgrounding-occluded-windows",
+ "--new-window",
+ $Url
+ )
+
+ $browserProcess = Start-Process `
+ -FilePath $resolvedBrowserPath `
+ -ArgumentList $browserArguments `
+ -PassThru
+
+ [void] (Wait-ForDevToolsJson -Port $remoteDebuggingPort -Path "/json/version" -TimeoutSeconds $TimeoutSeconds)
+ $page = Wait-ForDevToolsPageTarget -Port $remoteDebuggingPort -ExpectedUrl $Url -TimeoutSeconds $TimeoutSeconds
+ [void] (Invoke-DevToolsCommand `
+ -WebSocketDebuggerUrl $page.webSocketDebuggerUrl `
+ -Method "Page.bringToFront" `
+ -TimeoutSeconds 5)
+ $adapterProcess = Start-Process `
+ -FilePath $resolvedGamepadAdapterPath `
+ -WorkingDirectory (Split-Path -Parent $resolvedGamepadAdapterPath) `
+ -ArgumentList @($GamepadProfile, "--hold-seconds", "$HoldSeconds") `
+ -PassThru `
+ -RedirectStandardOutput $adapterStdoutPath `
+ -RedirectStandardError $adapterStderrPath `
+ -WindowStyle Hidden
+
+ Start-Sleep -Seconds 2
+ if ($adapterProcess.HasExited) {
+ $stdout = Get-Content -LiteralPath $adapterStdoutPath -Raw -ErrorAction SilentlyContinue
+ $stderr = Get-Content -LiteralPath $adapterStderrPath -Raw -ErrorAction SilentlyContinue
+ throw "gamepad_adapter exited with code $($adapterProcess.ExitCode).`nstdout:`n$stdout`nstderr:`n$stderr"
+ }
+
+ [void] (Invoke-DevToolsCommand `
+ -WebSocketDebuggerUrl $page.webSocketDebuggerUrl `
+ -Method "Runtime.evaluate" `
+ -Params @{
+ expression = "window.focus(); document.body && document.body.focus && document.body.focus();"
+ } `
+ -TimeoutSeconds 5)
+ [void] (Invoke-DevToolsCommand `
+ -WebSocketDebuggerUrl $page.webSocketDebuggerUrl `
+ -Method "Input.dispatchMouseEvent" `
+ -Params @{
+ type = "mousePressed"
+ x = 32
+ y = 32
+ button = "left"
+ clickCount = 1
+ } `
+ -TimeoutSeconds 5)
+ [void] (Invoke-DevToolsCommand `
+ -WebSocketDebuggerUrl $page.webSocketDebuggerUrl `
+ -Method "Input.dispatchMouseEvent" `
+ -Params @{
+ type = "mouseReleased"
+ x = 32
+ y = 32
+ button = "left"
+ clickCount = 1
+ } `
+ -TimeoutSeconds 5)
+
+ $expression = Get-GamepadApiProbeExpression `
+ -ExpectedIdPattern $expectedPattern `
+ -AllowAnyGamepad:$AllowAnyGamepad `
+ -TimeoutSeconds $TimeoutSeconds
+ $result = Invoke-DevToolsCommand `
+ -WebSocketDebuggerUrl $page.webSocketDebuggerUrl `
+ -Method "Runtime.evaluate" `
+ -Params @{
+ expression = $expression
+ awaitPromise = $true
+ returnByValue = $true
+ } `
+ -TimeoutSeconds ($TimeoutSeconds + 10)
+
+ if ($result.exceptionDetails) {
+ throw "Browser Gamepad API probe threw: $($result.exceptionDetails.text)"
+ }
+
+ $probe = $result.result.value
+ if (-not $probe.ok) {
+ $probeJson = $probe | ConvertTo-Json -Depth 20
+ $expectedMessage = if ($AllowAnyGamepad) {
+ "any gamepad"
+ } else {
+ "a gamepad matching /$expectedPattern/i"
+ }
+ throw "Browser Gamepad API did not observe changing input from $expectedMessage.`n$probeJson"
+ }
+
+ Write-Information `
+ "Browser Gamepad API observed $GamepadProfile as '$($probe.matched.id)' with mapping '$($probe.matched.mapping)'." `
+ -InformationAction Continue
+} finally {
+ if ($adapterProcess -and -not $adapterProcess.HasExited) {
+ Stop-Process -Id $adapterProcess.Id -Force -ErrorAction SilentlyContinue
+ Wait-Process -Id $adapterProcess.Id -Timeout 5 -ErrorAction SilentlyContinue
+ }
+
+ if ($browserProcess -and -not $KeepBrowserOpen -and -not $browserProcess.HasExited) {
+ Stop-Process -Id $browserProcess.Id -Force -ErrorAction SilentlyContinue
+ Wait-Process -Id $browserProcess.Id -Timeout 5 -ErrorAction SilentlyContinue
+ }
+
+ if (-not $KeepBrowserOpen) {
+ Remove-Item -LiteralPath $browserUserDataDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+}
diff --git a/scripts/windows/test-installed-driver.ps1 b/scripts/windows/test-installed-driver.ps1
new file mode 100644
index 0000000..15051b0
--- /dev/null
+++ b/scripts/windows/test-installed-driver.ps1
@@ -0,0 +1,323 @@
+<#
+.SYNOPSIS
+Validates that the installed libvirtualhid Windows driver starts and can expose a gamepad child device.
+#>
+[CmdletBinding()]
+param(
+ [string] $HardwareId = "ROOT\LIBVIRTUALHID",
+
+ [string] $ControlDevicePath = "\\.\LibVirtualHid",
+
+ [string] $GamepadAdapterPath,
+
+ [ValidateSet("generic", "x360", "xone", "xseries", "ds4", "ds5", "switch")]
+ [Alias("Profile")]
+ [string] $GamepadProfile = "xseries",
+
+ [int] $HoldSeconds = 12,
+
+ [int] $DeviceStartTimeoutSeconds = 20
+)
+
+$ErrorActionPreference = "Stop"
+
+function Invoke-PnPUtil {
+ param([string[]] $Arguments)
+
+ $output = @(pnputil.exe @Arguments 2>&1)
+ $exitCode = $LASTEXITCODE
+
+ if ($exitCode -ne 0) {
+ throw "pnputil.exe $($Arguments -join ' ') exited with code $exitCode`n$($output -join "`n")"
+ }
+
+ return $output
+}
+
+function ConvertTo-PnPUtilDeviceRecord {
+ param([string] $InstanceId)
+
+ return @{
+ InstanceId = $InstanceId.Trim()
+ DeviceDescription = $null
+ DriverName = $null
+ HardwareIds = @()
+ Status = $null
+ ProblemCode = $null
+ ProblemStatus = $null
+ }
+}
+
+function ConvertFrom-PnPUtilDeviceLine {
+ param(
+ [hashtable] $Record,
+ [string] $Line,
+ [string] $Section
+ )
+
+ $singleValueLabels = @{
+ "Device Description" = "DeviceDescription"
+ "Driver Name" = "DriverName"
+ "Status" = "Status"
+ "Problem Code" = "ProblemCode"
+ "Problem Status" = "ProblemStatus"
+ }
+
+ if ($Line -match "^\s{0,2}([^:]+):\s*(.*)\s*$") {
+ $label = $Matches[1].Trim()
+ $value = $Matches[2].Trim()
+ if ($label -eq "Hardware IDs") {
+ if ($value) {
+ $Record.HardwareIds += $value
+ }
+ return "HardwareIds"
+ }
+
+ if ($singleValueLabels.ContainsKey($label)) {
+ $Record[$singleValueLabels[$label]] = $value
+ }
+ return $null
+ }
+
+ if ($Section -eq "HardwareIds" -and $Line -match "^\s+(.+)\s*$") {
+ $Record.HardwareIds += $Matches[1].Trim()
+ }
+
+ return $Section
+}
+
+function ConvertFrom-PnPUtilDeviceOutput {
+ param([string[]] $Output)
+
+ $records = @()
+ $current = $null
+ foreach ($line in $Output) {
+ if ($line -match "^\s*Instance ID:\s*(.+)\s*$") {
+ if ($current) {
+ $records += [pscustomobject] $current
+ }
+ $current = ConvertTo-PnPUtilDeviceRecord -InstanceId $Matches[1]
+ $section = $null
+ continue
+ }
+
+ if (-not $current) {
+ continue
+ }
+
+ $section = ConvertFrom-PnPUtilDeviceLine -Record $current -Line $line -Section $section
+ }
+
+ if ($current) {
+ $records += [pscustomobject] $current
+ }
+
+ return $records
+}
+
+function Write-PnPRecordVerbose {
+ param([object] $Record)
+
+ Write-Verbose "Matched device: $($Record.InstanceId)"
+ if ($Record.DeviceDescription) {
+ Write-Verbose " Description: $($Record.DeviceDescription)"
+ }
+ if ($Record.Status) {
+ Write-Verbose " Status: $($Record.Status)"
+ }
+ if ($Record.DriverName) {
+ Write-Verbose " Driver Name: $($Record.DriverName)"
+ }
+ if ($Record.HardwareIds) {
+ Write-Verbose " Hardware IDs: $($Record.HardwareIds -join ', ')"
+ }
+ if ($Record.ProblemCode) {
+ Write-Verbose " Problem Code: $($Record.ProblemCode)"
+ }
+ if ($Record.ProblemStatus) {
+ Write-Verbose " Problem Status: $($Record.ProblemStatus)"
+ }
+}
+
+function Get-PnPUtilDevicesByDeviceId {
+ param([string] $DeviceId)
+
+ $output = Invoke-PnPUtil -Arguments @(
+ "/enum-devices",
+ "/deviceids",
+ "/drivers"
+ )
+ $matchingDevices = @(ConvertFrom-PnPUtilDeviceOutput -Output $output |
+ Where-Object {
+ $_.InstanceId -like "$DeviceId\*" -or
+ $_.HardwareIds -contains $DeviceId
+ }
+ )
+ foreach ($device in $matchingDevices) {
+ Write-PnPRecordVerbose -Record $device
+ }
+
+ return $matchingDevices
+}
+
+function Assert-StartedPnPRecord {
+ param(
+ [object] $Record,
+ [string] $Description
+ )
+
+ if ($Record.Status -ne "Started") {
+ $details = @(
+ "$Description did not report Status: Started.",
+ "Instance ID: $($Record.InstanceId)",
+ "Status: $($Record.Status)"
+ )
+ if ($Record.ProblemCode) {
+ $details += "Problem Code: $($Record.ProblemCode)"
+ }
+ if ($Record.ProblemStatus) {
+ $details += "Problem Status: $($Record.ProblemStatus)"
+ }
+
+ throw ($details -join "`n")
+ }
+}
+
+function Assert-RootDeviceStarted {
+ param([string] $TargetHardwareId)
+
+ $rootDevices = @(Get-PnPUtilDevicesByDeviceId -DeviceId $TargetHardwareId)
+ if (-not $rootDevices) {
+ throw "No installed libvirtualhid root device was found for $TargetHardwareId."
+ }
+
+ foreach ($device in $rootDevices) {
+ Assert-StartedPnPRecord -Record $device -Description "Root device $($device.InstanceId)"
+ }
+}
+
+function Assert-ControlDeviceOpen {
+ param([string] $Path)
+
+ try {
+ $stream = [System.IO.File]::Open(
+ $Path,
+ [System.IO.FileMode]::Open,
+ [System.IO.FileAccess]::ReadWrite,
+ [System.IO.FileShare]::ReadWrite
+ )
+ $stream.Dispose()
+ } catch {
+ throw "Could not open ${Path}: $($_.Exception.Message)"
+ }
+}
+
+function Get-ExpectedGamepadHardwareId {
+ param([string] $ProfileName)
+
+ switch ($ProfileName) {
+ "generic" { return @("HID\VID_1209&PID_0001") }
+ "x360" { return @("HID\VID_045E&PID_028E&IG_00") }
+ "xone" { return @("HID\VID_045E&PID_02EA&IG_00") }
+ "xseries" { return @("HID\VID_045E&PID_0B12&IG_00", "HID\VID_045E&PID_02FF&IG_00") }
+ "ds4" { return @("HID\VID_054C&PID_05C4") }
+ "ds5" { return @("HID\VID_054C&PID_0CE6") }
+ "switch" { return @("HID\VID_057E&PID_2009") }
+ }
+
+ throw "Unsupported profile: $ProfileName"
+}
+
+function Wait-ForStartedGamepadChild {
+ param(
+ [string] $ProfileName,
+ [int] $TimeoutSeconds
+ )
+
+ $deviceIds = @(Get-ExpectedGamepadHardwareId -ProfileName $ProfileName)
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ $latestRecords = @()
+
+ do {
+ $latestRecords = @()
+ foreach ($deviceId in $deviceIds) {
+ $latestRecords += @(Get-PnPUtilDevicesByDeviceId -DeviceId $deviceId)
+ }
+
+ $started = $latestRecords |
+ Where-Object {
+ $_.Status -eq "Started" -and
+ $_.DriverName -ne "hidvhf.inf" -and
+ $_.DeviceDescription -ne "Virtual HID Framework (VHF) HID device"
+ } |
+ Select-Object -First 1
+ if ($started) {
+ Write-Information "Gamepad child device started: $($started.InstanceId) ($($started.DriverName))" -InformationAction Continue
+ return
+ }
+
+ Start-Sleep -Milliseconds 500
+ } while ((Get-Date) -lt $deadline)
+
+ if (-not $latestRecords) {
+ throw "No gamepad child device was found for $($deviceIds -join ', ')."
+ }
+
+ foreach ($record in $latestRecords) {
+ Assert-StartedPnPRecord -Record $record -Description "Gamepad child device $($deviceIds -join ', ')"
+ }
+}
+
+function Invoke-GamepadAdapterSmoke {
+ param(
+ [string] $Path,
+ [string] $ProfileName,
+ [int] $HoldSeconds,
+ [int] $DeviceStartTimeoutSeconds
+ )
+
+ if (-not $Path) {
+ return
+ }
+
+ if ($ProfileName -eq "x360") {
+ throw "The Windows UMDF/VHF backend does not expose Xbox 360 XUSB gamepads. Use the consumer's XUSB fallback for x360."
+ }
+
+ $resolvedGamepadAdapterPath = (Resolve-Path -LiteralPath $Path).Path
+ $stdoutPath = Join-Path ([System.IO.Path]::GetTempPath()) "libvirtualhid-gamepad-adapter.out"
+ $stderrPath = Join-Path ([System.IO.Path]::GetTempPath()) "libvirtualhid-gamepad-adapter.err"
+ Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue
+
+ $process = Start-Process `
+ -FilePath $resolvedGamepadAdapterPath `
+ -ArgumentList @($ProfileName, "--hold-seconds", "$HoldSeconds") `
+ -PassThru `
+ -RedirectStandardOutput $stdoutPath `
+ -RedirectStandardError $stderrPath `
+ -WindowStyle Hidden
+
+ try {
+ Start-Sleep -Seconds 2
+ if ($process.HasExited) {
+ $stdout = Get-Content -LiteralPath $stdoutPath -Raw -ErrorAction SilentlyContinue
+ $stderr = Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue
+ throw "gamepad_adapter exited with code $($process.ExitCode).`nstdout:`n$stdout`nstderr:`n$stderr"
+ }
+
+ Wait-ForStartedGamepadChild -ProfileName $ProfileName -TimeoutSeconds $DeviceStartTimeoutSeconds
+ } finally {
+ if (-not $process.HasExited) {
+ Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
+ Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue
+ }
+ }
+}
+
+Assert-RootDeviceStarted -TargetHardwareId $HardwareId
+Assert-ControlDeviceOpen -Path $ControlDevicePath
+Invoke-GamepadAdapterSmoke `
+ -Path $GamepadAdapterPath `
+ -ProfileName $GamepadProfile `
+ -HoldSeconds $HoldSeconds `
+ -DeviceStartTimeoutSeconds $DeviceStartTimeoutSeconds
diff --git a/scripts/windows/uninstall-driver.ps1 b/scripts/windows/uninstall-driver.ps1
index 3012415..b89cc3b 100644
--- a/scripts/windows/uninstall-driver.ps1
+++ b/scripts/windows/uninstall-driver.ps1
@@ -16,6 +16,7 @@ param(
)
$ErrorActionPreference = "Stop"
+. (Join-Path $PSScriptRoot "libvirtualhid-driver-common.ps1")
function Invoke-CheckedCommand {
param(
@@ -63,6 +64,7 @@ function Find-PublishedName {
$drivers = & pnputil.exe /enum-drivers
$currentPublished = $null
$currentOriginal = $null
+ $publishedNames = @()
foreach ($line in $drivers) {
if ($line -match "^\s*Published Name\s*:\s*(.+)$") {
@@ -74,26 +76,12 @@ function Find-PublishedName {
if ($line -match "^\s*Original Name\s*:\s*(.+)$") {
$currentOriginal = $Matches[1].Trim()
if ($currentPublished -and $currentOriginal -ieq $TargetOriginalName) {
- return $currentPublished
+ $publishedNames += $currentPublished
}
}
}
- return $null
-}
-
-function Get-RootDeviceInstanceId {
- param([string] $TargetHardwareId)
-
- try {
- $prefix = "$TargetHardwareId\"
- @(Get-CimInstance -ClassName Win32_PnPEntity -ErrorAction Stop |
- Where-Object { $_.PNPDeviceID -like "$prefix*" } |
- ForEach-Object { $_.PNPDeviceID })
- } catch {
- Write-Verbose "Unable to enumerate PnP devices: $($_.Exception.Message)"
- @()
- }
+ return $publishedNames
}
function Remove-DriverCertificate {
@@ -121,29 +109,40 @@ if ($devcon -and $PSCmdlet.ShouldProcess($HardwareId, "Remove libvirtualhid deve
Invoke-CheckedCommand -FilePath $devcon -Arguments @("remove", $HardwareId) -IgnoreFailure
}
-foreach ($instanceId in (Get-RootDeviceInstanceId -TargetHardwareId $HardwareId)) {
+foreach ($instanceId in (Get-LibVirtualHidRootDeviceInstanceId -TargetHardwareId $HardwareId)) {
if ($PSCmdlet.ShouldProcess($instanceId, "Remove libvirtualhid development device with pnputil")) {
Invoke-CheckedCommand -FilePath "pnputil.exe" -Arguments @("/remove-device", $instanceId) -IgnoreFailure
}
}
-if (-not $PublishedName) {
- $PublishedName = Find-PublishedName -TargetOriginalName $OriginalName
+foreach ($instanceId in (Get-LibVirtualHidRegistryRootDevice -TargetHardwareId $HardwareId | Select-Object -ExpandProperty InstanceId -Unique)) {
+ if ($PSCmdlet.ShouldProcess($instanceId, "Remove libvirtualhid registry-discovered development device with pnputil")) {
+ Invoke-CheckedCommand -FilePath "pnputil.exe" -Arguments @("/remove-device", $instanceId) -IgnoreFailure
+ }
}
-if (-not $PublishedName) {
+$publishedNames = @()
+if ($PublishedName) {
+ $publishedNames += $PublishedName
+} else {
+ $publishedNames = @(Find-PublishedName -TargetOriginalName $OriginalName)
+}
+
+if ($publishedNames.Count -eq 0) {
Write-Warning "No staged libvirtualhid driver package matching $OriginalName was found."
Remove-DriverCertificate -Subject $RemoveCertificateSubject
return
}
-$deleteArgs = @("/delete-driver", $PublishedName, "/uninstall")
-if ($Force) {
- $deleteArgs += "/force"
-}
+foreach ($driverPackage in $publishedNames) {
+ $deleteArgs = @("/delete-driver", $driverPackage, "/uninstall")
+ if ($Force) {
+ $deleteArgs += "/force"
+ }
-if ($PSCmdlet.ShouldProcess($PublishedName, "Delete libvirtualhid driver package")) {
- Invoke-CheckedCommand -FilePath "pnputil.exe" -Arguments $deleteArgs
+ if ($PSCmdlet.ShouldProcess($driverPackage, "Delete libvirtualhid driver package")) {
+ Invoke-CheckedCommand -FilePath "pnputil.exe" -Arguments $deleteArgs
+ }
}
Remove-DriverCertificate -Subject $RemoveCertificateSubject
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 242541d..acccf7f 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -58,6 +58,9 @@ elseif(WIN32)
NOMINMAX
WIN32_LEAN_AND_MEAN
_WIN32_WINNT=0x0600)
+ target_link_libraries(${PROJECT_NAME}
+ PRIVATE
+ setupapi)
else()
target_sources(${PROJECT_NAME}
PRIVATE
diff --git a/src/core/gamepad_adapter.cpp b/src/core/gamepad_adapter.cpp
index 6e7d7a8..23a9487 100644
--- a/src/core/gamepad_adapter.cpp
+++ b/src/core/gamepad_adapter.cpp
@@ -171,7 +171,13 @@ namespace lvh {
return {std::move(created.status), nullptr};
}
- return {OperationStatus::success(), std::make_unique(std::move(created.gamepad))};
+ auto adapter = std::make_unique(std::move(created.gamepad));
+ if (const auto status = adapter->submit(); !status.ok()) {
+ static_cast(adapter->close());
+ return {status, nullptr};
+ }
+
+ return {OperationStatus::success(), std::move(adapter)};
}
Gamepad *GamepadStateAdapter::gamepad() {
diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp
index 25acd2b..9305536 100644
--- a/src/core/profiles.cpp
+++ b/src/core/profiles.cpp
@@ -7,6 +7,7 @@
#include
#include
#include
+#include
#include
// local includes
@@ -15,10 +16,26 @@
namespace lvh::profiles {
namespace {
- constexpr std::size_t common_report_size = 14;
+ constexpr std::uint8_t common_button_count = 12;
+
+ constexpr std::uint8_t standard_button_count = 16;
+
+ constexpr std::uint8_t common_axis_count = 6;
+
+ constexpr std::size_t common_button_bytes = 2;
+
+ constexpr std::size_t common_report_size = 1 + common_button_bytes + common_axis_count;
constexpr std::size_t common_output_report_size = 5;
+ constexpr std::size_t xbox_gip_input_report_size = 17;
+
+ constexpr std::uint8_t switch_pro_report_id = 0x30;
+
+ constexpr std::size_t switch_pro_input_report_size = 64;
+
+ constexpr std::size_t switch_pro_output_report_size = 64;
+
constexpr std::size_t dualshock4_usb_input_report_size = 64;
constexpr std::size_t dualshock4_usb_output_report_size = 32;
@@ -35,6 +52,70 @@ namespace lvh::profiles {
constexpr std::size_t dualsense_bluetooth_output_report_size = 78;
+ std::byte hex_nibble(char digit) {
+ if (digit >= '0' && digit <= '9') {
+ return static_cast(digit - '0');
+ }
+ if (digit >= 'A' && digit <= 'F') {
+ return static_cast(digit - 'A' + 10);
+ }
+ if (digit >= 'a' && digit <= 'f') {
+ return static_cast(digit - 'a' + 10);
+ }
+ return std::byte {0};
+ }
+
+ std::byte byte_from_hex(char high, char low) {
+ const auto high_nibble = hex_nibble(high);
+ const auto low_nibble = hex_nibble(low);
+ return (high_nibble << 4U) | low_nibble;
+ }
+
+ std::vector bytes_from_hex(std::string_view hex) {
+ std::vector parsed_bytes;
+ parsed_bytes.reserve(hex.size() / 2U);
+ for (std::size_t index = 0; index + 1U < hex.size(); index += 2U) {
+ parsed_bytes.push_back(byte_from_hex(hex[index], hex[index + 1U]));
+ }
+
+ std::vector descriptor;
+ descriptor.reserve(parsed_bytes.size());
+ for (const auto byte : parsed_bytes) {
+ descriptor.push_back(std::to_integer(byte));
+ }
+ return descriptor;
+ }
+
+ std::vector make_xbox_gip_report_descriptor(bool include_share_button) {
+ constexpr std::string_view xbox_one_descriptor =
+ "05010905a101a10009300931150027ffff0000950275108102c0a10009330934150027ffff0000950275108102c0"
+ "05010932150026ff039501750a81021500250075069501810305010935150026ff039501750a8102150025007506"
+ "9501810305091901290a950a750181021500250075069501810305010939150125083500463b0166140075049501"
+ "814275049501150025003500450065008103a102050f099715002501750495019102150025009103097015002564"
+ "7508950491020950660110550e26ff009501910209a7910265005500097c9102c005010980a100098515002501"
+ "95017501810215002500750795018103c005060920150026ff00750895018102c0";
+ constexpr std::string_view xbox_series_descriptor =
+ "05010905a101a10009300931150027ffff0000950275108102c0a10009330934150027ffff0000950275108102c0"
+ "05010932150026ff039501750a81021500250075069501810305010935150026ff039501750a8102150025007506"
+ "9501810305091901290c950c750181021500250075049501810305010939150125083500463b0166140075049501"
+ "814275049501150025003500450065008103a102050f099715002501750495019102150025009103097015002564"
+ "7508950491020950660110550e26ff009501910209a7910265005500097c9102c005010980a100098515002501"
+ "95017501810215002500750795018103c005060920150026ff00750895018102c0";
+
+ return bytes_from_hex(include_share_button ? xbox_series_descriptor : xbox_one_descriptor);
+ }
+
+ std::vector make_switch_pro_report_descriptor() {
+ constexpr std::string_view descriptor =
+ "050115000904a1018530050105091901290a150025017501950a5500650081020509190b290e150025017501"
+ "950481027501950281030b01000100a1000b300001000b310001000b320001000b35000100150027ffff0000"
+ "751095048102c00b39000100150025073500463b0165147504950181020509190f291215002501750195048102"
+ "7508953481030600ff852109017508953f8103858109027508953f8103850109037508953f9183851009047508"
+ "953f9183858009057508953f9183858209067508953f9183c0";
+
+ return bytes_from_hex(descriptor);
+ }
+
std::vector make_gamepad_report_descriptor(std::uint8_t report_id, bool supports_rumble) {
std::vector descriptor {
0x05,
@@ -50,7 +131,7 @@ namespace lvh::profiles {
0x19,
0x01, // Usage Minimum (Button 1)
0x29,
- 0x0C, // Usage Maximum (Button 12)
+ common_button_count, // Usage Maximum
0x15,
0x00, // Logical Minimum (0)
0x25,
@@ -58,15 +139,9 @@ namespace lvh::profiles {
0x75,
0x01, // Report Size (1)
0x95,
- 0x0C, // Report Count (12)
+ common_button_count, // Report Count
0x81,
0x02, // Input (Data,Var,Abs)
- 0x75,
- 0x01, // Report Size (1)
- 0x95,
- 0x04, // Report Count (4)
- 0x81,
- 0x03, // Input (Const,Var,Abs)
0x05,
0x01, // Usage Page (Generic Desktop)
0x09,
@@ -81,39 +156,101 @@ namespace lvh::profiles {
0x3B,
0x01, // Physical Maximum (315)
0x65,
- 0x14, // Unit (Degrees)
+ 0x14, // Unit (Eng Rot:Angular Pos)
0x75,
0x04, // Report Size (4)
0x95,
0x01, // Report Count (1)
0x81,
0x42, // Input (Data,Var,Abs,Null)
- 0x75,
- 0x04, // Report Size (4)
- 0x95,
- 0x01, // Report Count (1)
- 0x81,
- 0x03, // Input (Const,Var,Abs)
- 0x16,
- 0x00,
- 0x80, // Logical Minimum (-32768)
+ 0x65,
+ 0x00, // Unit (None)
+ 0x05,
+ 0x01, // Usage Page (Generic Desktop)
+ 0x15,
+ 0x00, // Logical Minimum (0)
0x26,
0xFF,
- 0x7F, // Logical Maximum (32767)
+ 0x00, // Logical Maximum (255)
0x75,
- 0x10, // Report Size (16)
+ 0x08, // Report Size (8)
0x95,
- 0x04, // Report Count (4)
+ common_axis_count, // Report Count
0x09,
0x30, // Usage (X)
0x09,
0x31, // Usage (Y)
0x09,
+ 0x32, // Usage (Z)
+ 0x09,
0x33, // Usage (Rx)
0x09,
0x34, // Usage (Ry)
+ 0x09,
+ 0x35, // Usage (Rz)
+ 0x81,
+ 0x02, // Input (Data,Var,Abs)
+ };
+
+ if (supports_rumble) {
+ descriptor.insert(
+ descriptor.end(),
+ {
+ 0x06,
+ 0x00,
+ 0xFF, // Usage Page (Vendor Defined)
+ 0x09,
+ 0x01, // Usage (Vendor Usage 1)
+ 0x15,
+ 0x00, // Logical Minimum (0)
+ 0x26,
+ 0xFF,
+ 0x00, // Logical Maximum (255)
+ 0x75,
+ 0x08, // Report Size (8)
+ 0x95,
+ 0x04, // Report Count (4)
+ 0x91,
+ 0x02, // Output (Data,Var,Abs)
+ }
+ );
+ }
+
+ descriptor.push_back(0xC0); // End Collection
+ return descriptor;
+ }
+
+ std::vector make_standard_gamepad_report_descriptor(
+ std::uint8_t report_id,
+ bool supports_rumble
+ ) {
+ std::vector descriptor {
+ 0x05,
+ 0x01, // Usage Page (Generic Desktop)
+ 0x09,
+ 0x05, // Usage (Game Pad)
+ 0xA1,
+ 0x01, // Collection (Application)
+ 0x85,
+ report_id, // Report ID
+ 0x05,
+ 0x09, // Usage Page (Button)
+ 0x19,
+ 0x01, // Usage Minimum (Button 1)
+ 0x29,
+ standard_button_count, // Usage Maximum
+ 0x15,
+ 0x00, // Logical Minimum (0)
+ 0x25,
+ 0x01, // Logical Maximum (1)
+ 0x75,
+ 0x01, // Report Size (1)
+ 0x95,
+ standard_button_count, // Report Count
0x81,
0x02, // Input (Data,Var,Abs)
+ 0x05,
+ 0x01, // Usage Page (Generic Desktop)
0x15,
0x00, // Logical Minimum (0)
0x26,
@@ -122,7 +259,15 @@ namespace lvh::profiles {
0x75,
0x08, // Report Size (8)
0x95,
- 0x02, // Report Count (2)
+ common_axis_count, // Report Count
+ 0x09,
+ 0x30, // Usage (X)
+ 0x09,
+ 0x31, // Usage (Y)
+ 0x09,
+ 0x33, // Usage (Rx)
+ 0x09,
+ 0x34, // Usage (Ry)
0x09,
0x32, // Usage (Z)
0x09,
@@ -1596,9 +1741,10 @@ namespace lvh::profiles {
return descriptor;
}
- DeviceProfile make_gamepad_profile(
+ DeviceProfile make_base_gamepad_profile(
GamepadProfileKind kind,
std::string name,
+ std::string manufacturer,
std::uint16_t vendor_id,
std::uint16_t product_id,
std::uint16_t version,
@@ -1617,12 +1763,80 @@ namespace lvh::profiles {
profile.output_report_size = common_output_report_size;
}
profile.name = std::move(name);
- profile.manufacturer = "LizardByte";
+ profile.manufacturer = std::move(manufacturer);
profile.capabilities = capabilities;
+ return profile;
+ }
+
+ DeviceProfile make_gamepad_profile(
+ GamepadProfileKind kind,
+ std::string name,
+ std::string manufacturer,
+ std::uint16_t vendor_id,
+ std::uint16_t product_id,
+ std::uint16_t version,
+ GamepadProfileCapabilities capabilities
+ ) {
+ auto profile = make_base_gamepad_profile(
+ kind,
+ std::move(name),
+ std::move(manufacturer),
+ vendor_id,
+ product_id,
+ version,
+ capabilities
+ );
profile.report_descriptor = make_gamepad_report_descriptor(profile.report_id, profile.capabilities.supports_rumble);
return profile;
}
+ DeviceProfile make_standard_gamepad_profile(
+ GamepadProfileKind kind,
+ std::string name,
+ std::string manufacturer,
+ std::uint16_t vendor_id,
+ std::uint16_t product_id,
+ std::uint16_t version,
+ GamepadProfileCapabilities capabilities
+ ) {
+ auto profile = make_base_gamepad_profile(
+ kind,
+ std::move(name),
+ std::move(manufacturer),
+ vendor_id,
+ product_id,
+ version,
+ capabilities
+ );
+ profile.report_descriptor =
+ make_standard_gamepad_report_descriptor(profile.report_id, profile.capabilities.supports_rumble);
+ return profile;
+ }
+
+ DeviceProfile make_xbox_gip_profile(
+ GamepadProfileKind kind,
+ std::string name,
+ std::uint16_t product_id,
+ std::uint16_t version,
+ bool include_share_button
+ ) {
+ DeviceProfile profile;
+ profile.device_type = DeviceType::gamepad;
+ profile.gamepad_kind = kind;
+ profile.bus_type = BusType::usb;
+ profile.vendor_id = 0x045E;
+ profile.product_id = product_id;
+ profile.version = version;
+ profile.report_id = 0;
+ profile.input_report_size = xbox_gip_input_report_size;
+ profile.output_report_size = common_output_report_size;
+ profile.name = std::move(name);
+ profile.manufacturer = "Microsoft";
+ profile.capabilities = {.supports_rumble = true, .supports_battery = include_share_button};
+ profile.report_descriptor = make_xbox_gip_report_descriptor(include_share_button);
+ return profile;
+ }
+
DeviceProfile make_dualshock4_profile(BusType bus_type) {
DeviceProfile profile;
profile.device_type = DeviceType::gamepad;
@@ -1630,14 +1844,14 @@ namespace lvh::profiles {
profile.bus_type = bus_type;
profile.vendor_id = 0x054C;
profile.product_id = 0x05C4;
- profile.version = 0x0000;
+ profile.version = 0x0100;
profile.report_id = bus_type == BusType::bluetooth ? 0x11 : 1;
profile.input_report_size =
bus_type == BusType::bluetooth ? dualshock4_bluetooth_input_report_size : dualshock4_usb_input_report_size;
profile.output_report_size =
bus_type == BusType::bluetooth ? dualshock4_bluetooth_output_report_size : dualshock4_usb_output_report_size;
profile.name = "Wireless Controller";
- profile.manufacturer = "Sony Interactive Entertainment";
+ profile.manufacturer = "Sony Computer Entertainment";
profile.capabilities = {
.supports_rumble = true,
.supports_motion = true,
@@ -1663,7 +1877,7 @@ namespace lvh::profiles {
bus_type == BusType::bluetooth ? dualsense_bluetooth_input_report_size : dualsense_usb_input_report_size;
profile.output_report_size =
bus_type == BusType::bluetooth ? dualsense_bluetooth_output_report_size : dualsense_usb_output_report_size;
- profile.name = "DualSense Wireless Controller";
+ profile.name = "Wireless Controller";
profile.manufacturer = "Sony Interactive Entertainment";
profile.capabilities = {
.supports_rumble = true,
@@ -1678,6 +1892,24 @@ namespace lvh::profiles {
return profile;
}
+ DeviceProfile make_switch_pro_profile() {
+ DeviceProfile profile;
+ profile.device_type = DeviceType::gamepad;
+ profile.gamepad_kind = GamepadProfileKind::switch_pro;
+ profile.bus_type = BusType::usb;
+ profile.vendor_id = 0x057E;
+ profile.product_id = 0x2009;
+ profile.version = 0x8111;
+ profile.report_id = switch_pro_report_id;
+ profile.input_report_size = switch_pro_input_report_size;
+ profile.output_report_size = switch_pro_output_report_size;
+ profile.name = "Pro Controller";
+ profile.manufacturer = "Nintendo Co., Ltd.";
+ profile.capabilities = {.supports_motion = true, .supports_battery = true};
+ profile.report_descriptor = make_switch_pro_report_descriptor();
+ return profile;
+ }
+
DeviceProfile make_simple_profile(DeviceType device_type, std::string name, std::uint16_t product_id) {
DeviceProfile profile;
profile.device_type = device_type;
@@ -1693,9 +1925,10 @@ namespace lvh::profiles {
} // namespace
DeviceProfile generic_gamepad() {
- return make_gamepad_profile(
+ return make_standard_gamepad_profile(
GamepadProfileKind::generic,
"libvirtualhid Generic Gamepad",
+ "LizardByte",
0x1209,
0x0001,
0x0001,
@@ -1707,6 +1940,7 @@ namespace lvh::profiles {
return make_gamepad_profile(
GamepadProfileKind::xbox_360,
"Microsoft X-Box 360 pad",
+ "Microsoft",
0x045E,
0x028E,
0x0114,
@@ -1715,24 +1949,22 @@ namespace lvh::profiles {
}
DeviceProfile xbox_one() {
- return make_gamepad_profile(
+ return make_xbox_gip_profile(
GamepadProfileKind::xbox_one,
"Xbox One Controller",
- 0x045E,
0x02EA,
0x0408,
- {.supports_rumble = true}
+ false
);
}
DeviceProfile xbox_series() {
- return make_gamepad_profile(
+ return make_xbox_gip_profile(
GamepadProfileKind::xbox_series,
- "Xbox Wireless Controller",
- 0x045E,
+ "Xbox Controller",
0x0B12,
0x0500,
- {.supports_rumble = true, .supports_battery = true}
+ true
);
}
@@ -1758,19 +1990,12 @@ namespace lvh::profiles {
DeviceProfile dualsense_bluetooth() {
auto profile = make_dualsense_profile(BusType::bluetooth);
- profile.name = "DualSense Wireless Controller";
+ profile.name = "Wireless Controller";
return profile;
}
DeviceProfile switch_pro() {
- return make_gamepad_profile(
- GamepadProfileKind::switch_pro,
- "Nintendo Switch Pro Controller",
- 0x057E,
- 0x2009,
- 0x8111,
- {.supports_rumble = true, .supports_motion = true, .supports_battery = true}
- );
+ return make_switch_pro_profile();
}
DeviceProfile keyboard() {
diff --git a/src/core/report.cpp b/src/core/report.cpp
index d0406f6..b4d546e 100644
--- a/src/core/report.cpp
+++ b/src/core/report.cpp
@@ -99,15 +99,6 @@ namespace lvh::reports {
return bytes;
}
- void append_u16(std::vector &report, std::uint16_t value) {
- report.push_back(static_cast(value & 0xFFU));
- report.push_back(static_cast((value >> 8U) & 0xFFU));
- }
-
- void append_i16(std::vector &report, std::int16_t value) {
- append_u16(report, static_cast(value));
- }
-
void write_u16(ByteReport &report, std::size_t offset, std::uint16_t value) {
report[offset] = to_low_byte(value);
report[offset + 1U] = to_low_byte(value >> 8U);
@@ -125,11 +116,16 @@ namespace lvh::reports {
}
std::uint16_t read_u16(const std::vector &report, std::size_t offset) {
- const auto low = static_cast(report[offset]);
- const auto high = static_cast(report[offset + 1U]);
+ const auto low = std::to_integer(to_byte(report[offset]));
+ const auto high = std::to_integer(to_byte(report[offset + 1U]));
return static_cast(low | static_cast(high << 8U));
}
+ void append_u16(std::vector &report, std::uint16_t value) {
+ report.push_back(static_cast(value & 0xFFU));
+ report.push_back(static_cast((value >> 8U) & 0xFFU));
+ }
+
std::uint32_t read_u32(const ByteReport &report, std::size_t offset) {
return std::to_integer(report[offset]) |
(std::to_integer(report[offset + 1U]) << 8U) |
@@ -168,6 +164,10 @@ namespace lvh::reports {
return static_cast(std::lround(scaled));
}
+ std::uint16_t normalize_unsigned_axis(float value) {
+ return static_cast(std::lround((clamp_axis(value) + 1.0F) * 32767.5F));
+ }
+
std::uint8_t normalize_u8_axis(float value) {
return static_cast(std::lround((clamp_axis(value) + 1.0F) * 127.5F));
}
@@ -176,28 +176,120 @@ namespace lvh::reports {
return static_cast(std::lround((static_cast(value) / 255.0F) * 65535.0F));
}
- std::uint16_t report_button_bits(const ButtonSet &buttons) {
- std::uint16_t bits = 0;
+ struct ButtonBit {
+ std::uint16_t bit;
+ GamepadButton button;
+ };
+
+ constexpr auto face_shoulder_button_map() {
+ using enum GamepadButton;
+
+ return std::array {
+ ButtonBit {0U, a},
+ ButtonBit {1U, b},
+ ButtonBit {2U, x},
+ ButtonBit {3U, y},
+ ButtonBit {4U, left_shoulder},
+ ButtonBit {5U, right_shoulder},
+ };
+ }
+
+ constexpr auto common_menu_button_map() {
+ using enum GamepadButton;
+
+ return std::array {
+ ButtonBit {6U, back},
+ ButtonBit {7U, start},
+ ButtonBit {8U, left_stick},
+ ButtonBit {9U, right_stick},
+ };
+ }
+
+ constexpr auto common_extra_button_map() {
+ using enum GamepadButton;
- const auto add = [&bits, &buttons](GamepadButton button, std::uint16_t bit) {
+ return std::array {
+ ButtonBit {10U, guide},
+ ButtonBit {11U, misc1},
+ };
+ }
+
+ constexpr auto standard_dpad_button_map() {
+ using enum GamepadButton;
+
+ return std::array {
+ ButtonBit {12U, dpad_up},
+ ButtonBit {13U, dpad_down},
+ ButtonBit {14U, dpad_left},
+ ButtonBit {15U, dpad_right},
+ };
+ }
+
+ constexpr auto xbox_extra_button_map() {
+ using enum GamepadButton;
+
+ return std::array {
+ ButtonBit {11U, misc1},
+ };
+ }
+
+ constexpr auto switch_menu_button_map() {
+ using enum GamepadButton;
+
+ return std::array {
+ ButtonBit {8U, back},
+ ButtonBit {9U, start},
+ ButtonBit {10U, left_stick},
+ ButtonBit {11U, right_stick},
+ ButtonBit {12U, guide},
+ ButtonBit {13U, misc1},
+ };
+ }
+
+ std::uint16_t button_bits(std::span button_map, const ButtonSet &buttons) {
+ auto bits = std::uint16_t {};
+ for (const auto [bit, button] : button_map) {
if (buttons.test(button)) {
- bits |= bit;
+ bits |= static_cast(1U << bit);
}
- };
+ }
+ return bits;
+ }
- add(GamepadButton::a, 1U << 0U);
- add(GamepadButton::b, 1U << 1U);
- add(GamepadButton::x, 1U << 2U);
- add(GamepadButton::y, 1U << 3U);
- add(GamepadButton::back, 1U << 4U);
- add(GamepadButton::start, 1U << 5U);
- add(GamepadButton::guide, 1U << 6U);
- add(GamepadButton::left_stick, 1U << 7U);
- add(GamepadButton::right_stick, 1U << 8U);
- add(GamepadButton::left_shoulder, 1U << 9U);
- add(GamepadButton::right_shoulder, 1U << 10U);
- add(GamepadButton::misc1, 1U << 11U);
+ std::uint16_t common_button_bits(const ButtonSet &buttons) {
+ return static_cast(
+ button_bits(face_shoulder_button_map(), buttons) |
+ button_bits(common_menu_button_map(), buttons) |
+ button_bits(common_extra_button_map(), buttons)
+ );
+ }
+
+ std::uint16_t standard_gamepad_button_bits(const ButtonSet &buttons) {
+ return static_cast(
+ common_button_bits(buttons) |
+ button_bits(standard_dpad_button_map(), buttons)
+ );
+ }
+ std::uint16_t xbox_gip_button_bits(const ButtonSet &buttons) {
+ return static_cast(
+ button_bits(face_shoulder_button_map(), buttons) |
+ button_bits(common_menu_button_map(), buttons) |
+ button_bits(xbox_extra_button_map(), buttons)
+ );
+ }
+
+ std::uint16_t switch_pro_button_bits(const GamepadState &state) {
+ auto bits = static_cast(
+ button_bits(face_shoulder_button_map(), state.buttons) |
+ button_bits(switch_menu_button_map(), state.buttons)
+ );
+ if (state.left_trigger > 0.0F) {
+ bits |= static_cast(1U << 6U);
+ }
+ if (state.right_trigger > 0.0F) {
+ bits |= static_cast(1U << 7U);
+ }
return bits;
}
@@ -303,9 +395,9 @@ namespace lvh::reports {
report[0] = is_bluetooth ? dualshock4_bt_input_report_id : to_byte(profile.report_id);
report[payload_offset + 0U] = to_byte(normalize_u8_axis(normalized.left_stick.x));
- report[payload_offset + 1U] = to_byte(normalize_u8_axis(normalized.left_stick.y));
+ report[payload_offset + 1U] = to_byte(normalize_u8_axis(-normalized.left_stick.y));
report[payload_offset + 2U] = to_byte(normalize_u8_axis(normalized.right_stick.x));
- report[payload_offset + 3U] = to_byte(normalize_u8_axis(normalized.right_stick.y));
+ report[payload_offset + 3U] = to_byte(normalize_u8_axis(-normalized.right_stick.y));
report[payload_offset + 4U] = to_byte(hat_from_buttons(normalized.buttons));
if (normalized.buttons.test(GamepadButton::x)) {
@@ -391,9 +483,9 @@ namespace lvh::reports {
}
report[payload_offset + 0U] = to_byte(normalize_u8_axis(normalized.left_stick.x));
- report[payload_offset + 1U] = to_byte(normalize_u8_axis(normalized.left_stick.y));
+ report[payload_offset + 1U] = to_byte(normalize_u8_axis(-normalized.left_stick.y));
report[payload_offset + 2U] = to_byte(normalize_u8_axis(normalized.right_stick.x));
- report[payload_offset + 3U] = to_byte(normalize_u8_axis(normalized.right_stick.y));
+ report[payload_offset + 3U] = to_byte(normalize_u8_axis(-normalized.right_stick.y));
report[payload_offset + 4U] = to_byte(normalize_trigger(normalized.left_trigger));
report[payload_offset + 5U] = to_byte(normalize_trigger(normalized.right_trigger));
report[payload_offset + 7U] = to_byte(hat_from_buttons(normalized.buttons));
@@ -560,8 +652,7 @@ namespace lvh::reports {
const auto right_trigger_effect_type = raw_report[offset + 10U];
const auto left_trigger_effect_type = raw_report[offset + 21U];
- if (const auto valid_flag2 = report[offset + 38U];
- has_flag(valid_flag0, dualsense_flag0_rumble) || has_flag(valid_flag2, dualsense_flag2_compatible_vibration)) {
+ if (const auto valid_flag2 = report[offset + 38U]; has_flag(valid_flag0, dualsense_flag0_rumble) || has_flag(valid_flag2, dualsense_flag2_compatible_vibration)) {
GamepadOutput output;
output.kind = GamepadOutputKind::rumble;
output.low_frequency_rumble = scale_output_byte(motor_left);
@@ -696,17 +787,117 @@ namespace lvh::reports {
return neutral_hat;
}
+ std::uint16_t normalize_u10_trigger(float value) {
+ return static_cast(std::lround(clamp_trigger(value) * 1023.0F));
+ }
+
+ std::uint8_t xbox_gip_hat_from_buttons(const ButtonSet &buttons) {
+ const auto hat = hat_from_buttons(buttons);
+ return hat == neutral_hat ? 0 : static_cast(hat + 1U);
+ }
+
+ std::uint8_t battery_strength(const std::optional &battery) {
+ if (!battery) {
+ return 0xFF;
+ }
+
+ return static_cast(std::lround((static_cast(battery->percentage) / 100.0F) * 255.0F));
+ }
+
+ std::uint16_t dpad_hat_bits(const ButtonSet &buttons) {
+ const auto hat = to_byte(hat_from_buttons(buttons));
+ return static_cast(std::to_integer(hat) << 12U);
+ }
+
+ std::vector pack_xbox_gip_input_report(const DeviceProfile &profile, const GamepadState &state) {
+ if (constexpr std::size_t xbox_gip_input_report_size = 17; profile.input_report_size < xbox_gip_input_report_size) {
+ return {};
+ }
+
+ const auto normalized = normalize_state(state);
+
+ ByteReport report(profile.input_report_size, zero_byte);
+ write_u16(report, 0U, normalize_unsigned_axis(normalized.left_stick.x));
+ write_u16(report, 2U, normalize_unsigned_axis(-normalized.left_stick.y));
+ write_u16(report, 4U, normalize_unsigned_axis(normalized.right_stick.x));
+ write_u16(report, 6U, normalize_unsigned_axis(-normalized.right_stick.y));
+ write_u16(report, 8U, normalize_u10_trigger(normalized.left_trigger));
+ write_u16(report, 10U, normalize_u10_trigger(normalized.right_trigger));
+ write_u16(report, 12U, xbox_gip_button_bits(normalized.buttons));
+ report[14] = to_byte(xbox_gip_hat_from_buttons(normalized.buttons));
+ if (normalized.buttons.test(GamepadButton::guide)) {
+ report[15] = std::byte {0x01};
+ }
+ report[16] = to_byte(battery_strength(normalized.battery));
+ return to_uint8_report(report);
+ }
+
+ std::vector pack_standard_gamepad_input_report(
+ const DeviceProfile &profile,
+ const GamepadState &state
+ ) {
+ constexpr std::size_t standard_report_size = 9;
+ if (profile.input_report_size < standard_report_size) {
+ return {};
+ }
+
+ const auto normalized = normalize_state(state);
+
+ std::vector report;
+ report.reserve(standard_report_size);
+ report.push_back(profile.report_id);
+ append_u16(report, standard_gamepad_button_bits(normalized.buttons));
+ report.push_back(normalize_u8_axis(normalized.left_stick.x));
+ report.push_back(normalize_u8_axis(-normalized.left_stick.y));
+ report.push_back(normalize_u8_axis(normalized.right_stick.x));
+ report.push_back(normalize_u8_axis(-normalized.right_stick.y));
+ report.push_back(normalize_trigger(normalized.left_trigger));
+ report.push_back(normalize_trigger(normalized.right_trigger));
+
+ report.resize(profile.input_report_size, 0);
+ return report;
+ }
+
+ std::vector pack_switch_pro_input_report(const DeviceProfile &profile, const GamepadState &state) {
+ if (constexpr std::size_t switch_pro_input_report_size = 64; profile.input_report_size < switch_pro_input_report_size) {
+ return {};
+ }
+
+ const auto normalized = normalize_state(state);
+
+ ByteReport report(profile.input_report_size, zero_byte);
+ report[0] = to_byte(profile.report_id);
+ write_u16(report, 1U, switch_pro_button_bits(normalized));
+ write_u16(report, 3U, normalize_unsigned_axis(normalized.left_stick.x));
+ write_u16(report, 5U, normalize_unsigned_axis(-normalized.left_stick.y));
+ write_u16(report, 7U, normalize_unsigned_axis(normalized.right_stick.x));
+ write_u16(report, 9U, normalize_unsigned_axis(-normalized.right_stick.y));
+ report[11] = to_byte(hat_from_buttons(normalized.buttons));
+ return to_uint8_report(report);
+ }
+
std::vector pack_input_report(const DeviceProfile &profile, const GamepadState &state) {
if (profile.device_type == DeviceType::gamepad) {
- if (profile.gamepad_kind == GamepadProfileKind::dualshock4) {
- return pack_dualshock4_input_report(profile, state);
- }
- if (profile.gamepad_kind == GamepadProfileKind::dualsense) {
- return pack_dualsense_input_report(profile, state);
+ switch (profile.gamepad_kind) {
+ using enum GamepadProfileKind;
+
+ case xbox_one:
+ case xbox_series:
+ return pack_xbox_gip_input_report(profile, state);
+ case dualshock4:
+ return pack_dualshock4_input_report(profile, state);
+ case dualsense:
+ return pack_dualsense_input_report(profile, state);
+ case switch_pro:
+ return pack_switch_pro_input_report(profile, state);
+ case generic:
+ return pack_standard_gamepad_input_report(profile, state);
+ case xbox_360:
+ break;
}
}
- constexpr std::size_t common_report_size = 14;
+ constexpr std::size_t common_report_size = 9;
if (profile.device_type != DeviceType::gamepad || profile.input_report_size < common_report_size) {
return {};
}
@@ -716,13 +907,12 @@ namespace lvh::reports {
std::vector report;
report.reserve(common_report_size);
report.push_back(profile.report_id);
- append_u16(report, report_button_bits(normalized.buttons));
- report.push_back(hat_from_buttons(normalized.buttons));
- append_i16(report, normalize_axis(normalized.left_stick.x));
- append_i16(report, normalize_axis(normalized.left_stick.y));
- append_i16(report, normalize_axis(normalized.right_stick.x));
- append_i16(report, normalize_axis(normalized.right_stick.y));
+ append_u16(report, static_cast(common_button_bits(normalized.buttons) | dpad_hat_bits(normalized.buttons)));
+ report.push_back(normalize_u8_axis(normalized.left_stick.x));
+ report.push_back(normalize_u8_axis(-normalized.left_stick.y));
report.push_back(normalize_trigger(normalized.left_trigger));
+ report.push_back(normalize_u8_axis(normalized.right_stick.x));
+ report.push_back(normalize_u8_axis(-normalized.right_stick.y));
report.push_back(normalize_trigger(normalized.right_trigger));
report.resize(profile.input_report_size, 0);
diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp
index fce2171..db9b1d5 100644
--- a/src/core/runtime.cpp
+++ b/src/core/runtime.cpp
@@ -173,9 +173,6 @@ namespace lvh {
if (options.profile.report_descriptor.empty()) {
return OperationStatus::failure(invalid_argument, "device profile report descriptor must not be empty");
}
- if (options.profile.report_id == 0) {
- return OperationStatus::failure(invalid_argument, "device profile report id must not be zero");
- }
if (options.profile.input_report_size == 0) {
return OperationStatus::failure(invalid_argument, "device profile input report size must not be zero");
}
diff --git a/src/include/libvirtualhid/profiles.hpp b/src/include/libvirtualhid/profiles.hpp
index f379d9a..0ca6a4e 100644
--- a/src/include/libvirtualhid/profiles.hpp
+++ b/src/include/libvirtualhid/profiles.hpp
@@ -134,9 +134,12 @@ namespace lvh::profiles {
std::optional gamepad_profile(GamepadProfileKind kind);
/**
- * @brief Get every built-in gamepad profile.
+ * @brief Get the default built-in gamepad profile set.
*
- * @return Built-in gamepad profiles.
+ * Transport-specific variants, such as explicit Bluetooth PlayStation profiles,
+ * are available through their named profile constructors.
+ *
+ * @return Default built-in gamepad profiles.
*/
std::vector built_in_gamepad_profiles();
diff --git a/src/include/libvirtualhid/report.hpp b/src/include/libvirtualhid/report.hpp
index f902509..76b103d 100644
--- a/src/include/libvirtualhid/report.hpp
+++ b/src/include/libvirtualhid/report.hpp
@@ -62,7 +62,7 @@ namespace lvh::reports {
std::uint8_t hat_from_buttons(const ButtonSet &buttons);
/**
- * @brief Pack a gamepad state into the profile's common input report format.
+ * @brief Pack a gamepad state into the profile's input report format.
*
* @param profile Device profile used for report identity and size.
* @param state Gamepad state to pack.
diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp
index 36e2c91..a90c89e 100644
--- a/src/include/libvirtualhid/types.hpp
+++ b/src/include/libvirtualhid/types.hpp
@@ -303,7 +303,7 @@ namespace lvh {
std::uint16_t version = 0;
/**
- * @brief Primary input report identifier.
+ * @brief Primary input report identifier, or `0` for unnumbered HID reports.
*/
std::uint8_t report_id = 1;
diff --git a/src/platform/windows/driver/CMakeLists.txt b/src/platform/windows/driver/CMakeLists.txt
index 0e197c6..f1eff88 100644
--- a/src/platform/windows/driver/CMakeLists.txt
+++ b/src/platform/windows/driver/CMakeLists.txt
@@ -6,6 +6,14 @@ if(NOT MSVC)
message(FATAL_ERROR "The libvirtualhid Windows driver package requires the Microsoft WDK/MSVC toolchain.")
endif()
+if(LIBVIRTUALHID_ENABLE_PACKAGING
+ AND NOT CMAKE_CONFIGURATION_TYPES
+ AND CMAKE_BUILD_TYPE STREQUAL "Debug")
+ message(FATAL_ERROR
+ "The libvirtualhid Windows driver package must not be built from a Debug configuration. "
+ "Configure with -DCMAKE_BUILD_TYPE=Release before packaging the UMDF driver.")
+endif()
+
set(LIBVIRTUALHID_WDK_ARCH "${CMAKE_VS_PLATFORM_NAME}")
if(NOT LIBVIRTUALHID_WDK_ARCH)
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
@@ -53,6 +61,9 @@ set(_lvh_wdk_shared_include_candidates)
set(_lvh_wdf_library_candidates)
set(_lvh_wdk_um_library_candidates)
set(_lvh_wdk_tool_candidates)
+set(LIBVIRTUALHID_UMDF_LIBRARY_VERSION "2.15" CACHE STRING
+ "UMDF library version used for the Windows driver package")
+string(REGEX REPLACE "\\." "\\\\." _lvh_umdf_library_version_regex "${LIBVIRTUALHID_UMDF_LIBRARY_VERSION}")
foreach(lvh_wdk_root_cmake IN LISTS _lvh_wdk_roots)
if(EXISTS "${lvh_wdk_root_cmake}")
file(GLOB _lvh_wdf_include_glob
@@ -83,12 +94,14 @@ endforeach()
if(_lvh_wdf_include_candidates)
list(SORT _lvh_wdf_include_candidates COMPARE NATURAL ORDER DESCENDING)
+ list(FILTER _lvh_wdf_include_candidates INCLUDE REGEX "/${_lvh_umdf_library_version_regex}$")
endif()
if(_lvh_wdk_shared_include_candidates)
list(SORT _lvh_wdk_shared_include_candidates COMPARE NATURAL ORDER DESCENDING)
endif()
if(_lvh_wdf_library_candidates)
list(SORT _lvh_wdf_library_candidates COMPARE NATURAL ORDER DESCENDING)
+ list(FILTER _lvh_wdf_library_candidates INCLUDE REGEX "/${_lvh_umdf_library_version_regex}$")
endif()
if(_lvh_wdk_um_library_candidates)
list(SORT _lvh_wdk_um_library_candidates COMPARE NATURAL ORDER DESCENDING)
@@ -141,6 +154,24 @@ message(STATUS "WDF UMDF stub library: ${LIBVIRTUALHID_WDF_DRIVER_STUB_UM_LIBRAR
message(STATUS "VHF UMDF library: ${LIBVIRTUALHID_VHF_UM_LIBRARY}")
message(STATUS "NTDLL import library: ${LIBVIRTUALHID_NTDLL_LIBRARY}")
+get_filename_component(_lvh_wdf_include_version "${LIBVIRTUALHID_WDF_INCLUDE_DIR}" NAME)
+get_filename_component(_lvh_wdf_stub_directory "${LIBVIRTUALHID_WDF_DRIVER_STUB_UM_LIBRARY}" DIRECTORY)
+get_filename_component(_lvh_wdf_stub_version "${_lvh_wdf_stub_directory}" NAME)
+if(NOT _lvh_wdf_include_version MATCHES "^2\\.[0-9]+$")
+ message(FATAL_ERROR
+ "Could not determine the UMDF library version from ${LIBVIRTUALHID_WDF_INCLUDE_DIR}.")
+endif()
+if(NOT _lvh_wdf_include_version STREQUAL LIBVIRTUALHID_UMDF_LIBRARY_VERSION)
+ message(FATAL_ERROR
+ "UMDF include version ${_lvh_wdf_include_version} does not match requested "
+ "version ${LIBVIRTUALHID_UMDF_LIBRARY_VERSION}.")
+endif()
+if(NOT _lvh_wdf_stub_version STREQUAL LIBVIRTUALHID_UMDF_LIBRARY_VERSION)
+ message(FATAL_ERROR
+ "UMDF stub library version ${_lvh_wdf_stub_version} does not match requested "
+ "version ${LIBVIRTUALHID_UMDF_LIBRARY_VERSION}.")
+endif()
+message(STATUS "UMDF library version: ${LIBVIRTUALHID_UMDF_LIBRARY_VERSION}")
find_program(LIBVIRTUALHID_STAMPINF
NAMES stampinf stampinf.exe
PATHS ${_lvh_wdk_tool_candidates})
@@ -186,6 +217,7 @@ target_link_libraries(libvirtualhid_umdf
"${LIBVIRTUALHID_VHF_UM_LIBRARY}"
"${LIBVIRTUALHID_NTDLL_LIBRARY}")
set_target_properties(libvirtualhid_umdf PROPERTIES
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>"
OUTPUT_NAME libvirtualhid_umdf
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/package")
@@ -218,6 +250,24 @@ add_custom_target(libvirtualhid_windows_catalog
install(TARGETS libvirtualhid_umdf
RUNTIME DESTINATION "drivers/windows"
COMPONENT driver)
+install(CODE
+ "
+ set(lvh_driver_dll \"$\")
+ set(lvh_driver_inf \"$/libvirtualhid.inf\")
+ set(lvh_driver_cat \"$/libvirtualhid.cat\")
+ foreach(lvh_driver_file IN ITEMS \"\${lvh_driver_dll}\" \"\${lvh_driver_inf}\" \"\${lvh_driver_cat}\")
+ if(NOT EXISTS \"\${lvh_driver_file}\")
+ message(FATAL_ERROR \"Windows driver package file is missing: \${lvh_driver_file}\")
+ endif()
+ endforeach()
+ if(\"\${lvh_driver_dll}\" IS_NEWER_THAN \"\${lvh_driver_cat}\"
+ OR \"\${lvh_driver_inf}\" IS_NEWER_THAN \"\${lvh_driver_cat}\")
+ message(FATAL_ERROR
+ \"Windows driver catalog is stale; build libvirtualhid_windows_catalog \"
+ \"and sign libvirtualhid.cat before packaging.\")
+ endif()
+ "
+ COMPONENT driver)
install(FILES
"$/libvirtualhid.inf"
"$/libvirtualhid.cat"
diff --git a/src/platform/windows/driver/libvirtualhid.inf.in b/src/platform/windows/driver/libvirtualhid.inf.in
index 11db515..e3bfe93 100644
--- a/src/platform/windows/driver/libvirtualhid.inf.in
+++ b/src/platform/windows/driver/libvirtualhid.inf.in
@@ -1,11 +1,12 @@
;
; libvirtualhid UMDF2 control driver package.
+; This package includes UMDF startup file tracing for local driver bring-up.
;
[Version]
Signature="$WINDOWS NT$"
-Class=HIDClass
-ClassGuid={745A17A0-74D3-11D0-B6FE-00A0C90F57DA}
+Class=System
+ClassGuid={4D36E97D-E325-11CE-BFC1-08002BE10318}
Provider=%ManufacturerName%
DriverVer=*,@LIBVIRTUALHID_DRIVER_VERSION@
CatalogFile=libvirtualhid.cat
@@ -28,36 +29,38 @@ libvirtualhid_umdf.dll=1
[DeviceInstall.NT]
CopyFiles=UMDriverCopy
+Include=wudfrd.inf
+Needs=WUDFRD.NT
[DeviceInstall.NT.HW]
-AddReg=DeviceInstall_AddReg
+AddReg=DeviceInstall_Device_AddReg,DeviceInstall_Vhf_AddReg
+Include=wudfrd.inf
+Needs=WUDFRD.NT.HW
[UMDriverCopy]
libvirtualhid_umdf.dll
-[DeviceInstall_AddReg]
-HKR,,"LowerFilters",0x00010000,"vhf"
+[DeviceInstall_Device_AddReg]
+HKR,,Security,,"D:P(A;;GA;;;SY)(A;;GRGWGX;;;BA)(A;;GRGWGX;;;BU)(A;;GRGWGX;;;AU)(A;;GRGW;;;WD)(A;;GR;;;RC)"
-[DeviceInstall.NT.Services]
-AddService=WUDFRd,0x000001fa,WUDFRD_ServiceInstall
+[DeviceInstall_Vhf_AddReg]
+HKR,,"LowerFilters",0x00010008,"vhf"
+HKR,,VhfMode,0x00010001,0x1
-[WUDFRD_ServiceInstall]
-DisplayName=%WudfRdDisplayName%
-ServiceType=1
-StartType=3
-ErrorControl=1
-ServiceBinary=%12%\WUDFRd.sys
+[DeviceInstall.NT.Services]
+Include=wudfrd.inf
+Needs=WUDFRD.NT.Services
[DeviceInstall.NT.Wdf]
UmdfService=libvirtualhid_umdf,libvirtualhid_umdf_Install
UmdfServiceOrder=libvirtualhid_umdf
[libvirtualhid_umdf_Install]
-UmdfLibraryVersion=2.0
+UmdfLibraryVersion=@LIBVIRTUALHID_UMDF_LIBRARY_VERSION@
+UmdfHostProcessSharing=ProcessSharingDisabled
ServiceBinary=%13%\libvirtualhid_umdf.dll
[Strings]
ManufacturerName="LizardByte"
DiskName="libvirtualhid UMDF Driver Install Disk"
DeviceName="libvirtualhid Virtual HID Control Device"
-WudfRdDisplayName="Windows Driver Foundation - User-mode Driver Framework Reflector"
diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp
index 2381fe7..949e546 100644
--- a/src/platform/windows/driver/libvirtualhid_umdf.cpp
+++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp
@@ -32,10 +32,14 @@
#include
#include
#include
+#include
+#include
#include