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 #include #include #include +#include +#include #include // local includes @@ -47,6 +51,7 @@ extern "C" DRIVER_INITIALIZE DriverEntry; EVT_WDF_DRIVER_DEVICE_ADD LvhEvtDeviceAdd; EVT_WDF_DEVICE_PREPARE_HARDWARE LvhEvtDevicePrepareHardware; EVT_WDF_DEVICE_RELEASE_HARDWARE LvhEvtDeviceReleaseHardware; +EVT_WDF_FILE_CLEANUP LvhEvtFileCleanup; EVT_WDF_IO_QUEUE_IO_DEVICE_CONTROL LvhEvtIoDeviceControl; EVT_WDF_OBJECT_CONTEXT_CLEANUP LvhEvtDeviceCleanup; EVT_WDF_REQUEST_CANCEL LvhEvtOutputReadCanceled; @@ -56,18 +61,22 @@ namespace { constexpr auto symbolic_link_name = L"\\DosDevices\\LibVirtualHid"; constexpr auto global_symbolic_link_name = L"\\DosDevices\\Global\\LibVirtualHid"; + constexpr auto trace_file_name = std::wstring_view {L"libvirtualhid-umdf-driver.log"}; struct DeviceRecord { std::mutex mutex; std::uint64_t driver_device_id {}; + WDFDEVICE owner_device {}; + WDFFILEOBJECT owner_file {}; + WDFIOTARGET vhf_io_target {}; LvhWindowsCreateGamepadRequest request {}; VHFHANDLE vhf_handle {}; std::vector report_descriptor; + std::wstring hardware_ids; }; struct DriverState { std::atomic next_driver_device_id {1}; - WDFIOTARGET vhf_io_target {}; std::mutex devices_mutex; std::map> devices; std::mutex output_requests_mutex; @@ -80,6 +89,63 @@ namespace { return state; } + void trace_status(const char *step, NTSTATUS status = STATUS_SUCCESS) { + static std::atomic sequence {0}; + + constexpr auto trace_file_path_length = static_cast(MAX_PATH); + std::wstring trace_file_path(trace_file_path_length, L'\0'); + auto trace_path_size = GetWindowsDirectoryW(trace_file_path.data(), trace_file_path_length); + if (trace_path_size == 0U || trace_path_size >= trace_file_path_length) { + return; + } + + constexpr auto trace_directory = std::wstring_view {L"\\Temp\\"}; + const auto required_path_size = + static_cast(trace_path_size) + trace_directory.size() + trace_file_name.size(); + if (required_path_size >= trace_file_path_length) { + return; + } + trace_file_path.resize(trace_path_size); + trace_file_path.append(trace_directory); + trace_file_path.append(trace_file_name); + + const auto file = CreateFileW( + trace_file_path.c_str(), + FILE_APPEND_DATA, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr + ); + if (file == INVALID_HANDLE_VALUE) { + return; + } + + SYSTEMTIME time {}; + GetSystemTime(&time); + + const auto line = std::format( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z [{}] {} status=0x{:08X}\r\n", + time.wYear, + time.wMonth, + time.wDay, + time.wHour, + time.wMinute, + time.wSecond, + time.wMilliseconds, + sequence.fetch_add(1U) + 1U, + step, + static_cast(status) + ); + DWORD bytes_written {}; + const auto bytes_to_write = + static_cast(std::min(line.size(), static_cast(std::numeric_limits::max()))); + static_cast(WriteFile(file, line.data(), bytes_to_write, &bytes_written, nullptr)); + + static_cast(CloseHandle(file)); + } + bool valid_header(std::uint32_t version, std::uint32_t size, std::uint32_t expected_size) { return version == LVH_WINDOWS_CONTROL_PROTOCOL_VERSION && size == expected_size; } @@ -190,28 +256,52 @@ namespace { return; } + trace_status("delete_vhf_device begin"); + VHFHANDLE vhf_handle = nullptr; + WDFIOTARGET vhf_io_target = nullptr; { std::lock_guard lock {record->mutex}; vhf_handle = record->vhf_handle; record->vhf_handle = nullptr; + vhf_io_target = record->vhf_io_target; + record->vhf_io_target = nullptr; } if (vhf_handle != nullptr) { + trace_status("delete_vhf_device VhfDelete"); VhfDelete(vhf_handle, TRUE); } + + if (vhf_io_target != nullptr) { + trace_status("delete_vhf_device WdfObjectDelete target"); + WdfObjectDelete(vhf_io_target); + } } - void delete_all_vhf_devices() { + void delete_vhf_devices_for_device(WDFDEVICE device) { + if (device == nullptr) { + return; + } + + trace_status("delete_vhf_devices_for_device begin"); + std::vector> devices; { auto &state = driver_state(); std::lock_guard lock {state.devices_mutex}; - for (const auto &[driver_device_id, record] : state.devices) { - static_cast(driver_device_id); - devices.push_back(record); - } - state.devices.clear(); + static_cast(std::erase_if( + state.devices, + [&](const auto &entry) { + if (entry.second->owner_device != device) { + return false; + } + + trace_status("delete_vhf_devices_for_device matched"); + devices.push_back(entry.second); + return true; + } + )); } for (const auto &record : devices) { @@ -219,18 +309,44 @@ namespace { } } - NTSTATUS initialize_vhf_target(WDFDEVICE device) { - auto &state = driver_state(); - if (state.vhf_io_target != nullptr) { - return STATUS_SUCCESS; + void delete_vhf_devices_for_file(WDFFILEOBJECT file_object) { + if (file_object == nullptr) { + return; } - WDFIOTARGET vhf_io_target = nullptr; + trace_status("delete_vhf_devices_for_file begin"); + + std::vector> devices; + { + auto &state = driver_state(); + std::lock_guard lock {state.devices_mutex}; + static_cast(std::erase_if( + state.devices, + [&](const auto &entry) { + if (entry.second->owner_file != file_object) { + return false; + } + + trace_status("delete_vhf_devices_for_file matched"); + devices.push_back(entry.second); + return true; + } + )); + } + + for (const auto &record : devices) { + delete_vhf_device(record); + } + } + + NTSTATUS initialize_vhf_target(WDFDEVICE device, const std::shared_ptr &record) { WDF_OBJECT_ATTRIBUTES target_attributes; WDF_OBJECT_ATTRIBUTES_INIT(&target_attributes); - target_attributes.ParentObject = device; + target_attributes.ParentObject = record->owner_file != nullptr ? WDFOBJECT(record->owner_file) : WDFOBJECT(device); + WDFIOTARGET vhf_io_target = nullptr; auto status = WdfIoTargetCreate(device, &target_attributes, &vhf_io_target); + trace_status("initialize_vhf_target WdfIoTargetCreate", status); if (!NT_SUCCESS(status)) { return status; } @@ -238,31 +354,80 @@ namespace { WDF_IO_TARGET_OPEN_PARAMS open_params; WDF_IO_TARGET_OPEN_PARAMS_INIT_OPEN_BY_FILE(&open_params, nullptr); status = WdfIoTargetOpen(vhf_io_target, &open_params); + trace_status("initialize_vhf_target WdfIoTargetOpen", status); if (!NT_SUCCESS(status)) { WdfObjectDelete(vhf_io_target); return status; } - state.vhf_io_target = vhf_io_target; + record->vhf_io_target = vhf_io_target; return STATUS_SUCCESS; } - void reset_vhf_target(bool delete_target) { - auto &state = driver_state(); - const auto vhf_io_target = state.vhf_io_target; - state.vhf_io_target = nullptr; + void reset_vhf_devices(WDFDEVICE device) { + delete_vhf_devices_for_device(device); + } - delete_all_vhf_devices(); + wchar_t hex_digit(unsigned value) { + constexpr wchar_t digits[] = L"0123456789ABCDEF"; + return digits[value & 0x0FU]; + } - if (delete_target && vhf_io_target != nullptr) { - WdfObjectDelete(vhf_io_target); + void append_hex4(std::wstring &text, std::uint16_t value) { + text.push_back(hex_digit(value >> 12U)); + text.push_back(hex_digit(value >> 8U)); + text.push_back(hex_digit(value >> 4U)); + text.push_back(hex_digit(value)); + } + + void append_hid_vid_pid( + std::wstring &hardware_ids, + std::uint16_t vendor_id, + std::uint16_t product_id + ) { + hardware_ids.append(L"HID\\VID_"); + append_hex4(hardware_ids, vendor_id); + hardware_ids.append(L"&PID_"); + append_hex4(hardware_ids, product_id); + } + + std::uint16_t xinputhid_match_product_id(const LvhWindowsCreateGamepadRequest &request) { + if (request.gamepad_kind == LVH_WINDOWS_GAMEPAD_XBOX_SERIES) { + return 0x02FF; } + + return request.hardware_ids.product_id; } - NTSTATUS create_vhf_device(const std::shared_ptr &record) { - auto &state = driver_state(); - if (state.vhf_io_target == nullptr) { - return STATUS_DEVICE_NOT_READY; + bool is_xbox_gamepad(std::uint32_t gamepad_kind) { + return gamepad_kind == LVH_WINDOWS_GAMEPAD_XBOX_360 || gamepad_kind == LVH_WINDOWS_GAMEPAD_XBOX_ONE || + gamepad_kind == LVH_WINDOWS_GAMEPAD_XBOX_SERIES; + } + + std::wstring make_hardware_ids(const LvhWindowsCreateGamepadRequest &request) { + const auto &ids = request.hardware_ids; + std::wstring hardware_ids; + if (is_xbox_gamepad(request.gamepad_kind)) { + append_hid_vid_pid(hardware_ids, ids.vendor_id, xinputhid_match_product_id(request)); + hardware_ids.append(L"&IG_00"); + hardware_ids.push_back(L'\0'); + } + + append_hid_vid_pid(hardware_ids, ids.vendor_id, ids.product_id); + hardware_ids.append(L"&REV_"); + append_hex4(hardware_ids, ids.device_version); + hardware_ids.push_back(L'\0'); + + append_hid_vid_pid(hardware_ids, ids.vendor_id, ids.product_id); + hardware_ids.push_back(L'\0'); + hardware_ids.push_back(L'\0'); + return hardware_ids; + } + + NTSTATUS create_vhf_device(WDFDEVICE device, const std::shared_ptr &record) { + auto status = initialize_vhf_target(device, record); + if (!NT_SUCCESS(status)) { + return status; } const auto descriptor_size = record->request.report_sizes.report_descriptor_size; @@ -270,11 +435,12 @@ namespace { record->request.report_descriptor, record->request.report_descriptor + descriptor_size ); + record->hardware_ids = make_hardware_ids(record->request); VHF_CONFIG vhf_config; VHF_CONFIG_INIT( &vhf_config, - WdfIoTargetWdmGetTargetFileHandle(state.vhf_io_target), + WdfIoTargetWdmGetTargetFileHandle(record->vhf_io_target), static_cast(record->report_descriptor.size()), record->report_descriptor.data() ); @@ -282,15 +448,20 @@ namespace { vhf_config.VendorID = record->request.hardware_ids.vendor_id; vhf_config.ProductID = record->request.hardware_ids.product_id; vhf_config.VersionNumber = record->request.hardware_ids.device_version; + vhf_config.HardwareIDsLength = static_cast(record->hardware_ids.size() * sizeof(wchar_t)); + vhf_config.HardwareIDs = record->hardware_ids.data(); vhf_config.EvtVhfAsyncOperationWriteReport = LvhEvtVhfWriteReport; - auto status = VhfCreate(&vhf_config, &record->vhf_handle); + status = VhfCreate(&vhf_config, &record->vhf_handle); + trace_status("create_vhf_device VhfCreate", status); if (!NT_SUCCESS(status)) { record->vhf_handle = nullptr; + delete_vhf_device(record); return status; } status = VhfStart(record->vhf_handle); + trace_status("create_vhf_device VhfStart", status); if (!NT_SUCCESS(status)) { delete_vhf_device(record); } @@ -314,6 +485,71 @@ namespace { request.report_size <= LVH_WINDOWS_MAX_INPUT_REPORT_SIZE; } + bool symbolic_link_already_exists(NTSTATUS status) { + const auto value = static_cast(status); + return value == 0xC0000035U || value == 0x800700B7U || value == 0x900700B7U; + } + + constexpr GUID control_device_interface_guid { + 0x3890af65, + 0x2da0, + 0x443c, + {0x84, 0xff, 0x6e, 0x70, 0xe8, 0x41, 0xba, 0x1e} + }; + + NTSTATUS create_control_symbolic_link(WDFDEVICE device, const wchar_t *link_name, const char *trace_step) { + UNICODE_STRING symbolic_link; + RtlInitUnicodeString(&symbolic_link, link_name); + + const auto status = WdfDeviceCreateSymbolicLink(device, &symbolic_link); + trace_status(trace_step, status); + if (NT_SUCCESS(status) || symbolic_link_already_exists(status)) { + return STATUS_SUCCESS; + } + + return status; + } + + std::vector make_vhf_input_payload( + const DeviceRecord &record, + const LvhWindowsSubmitInputReportRequest &request + ) { + const auto report_id = record.request.hardware_ids.report_id; + const auto report_begin = request.report; + const auto report_end = request.report + request.report_size; + if (report_id == 0U) { + return {report_begin, report_end}; + } + + if (request.report[0] != report_id) { + return {}; + } + + return {report_begin, report_end}; + } + + void copy_vhf_output_payload( + LvhWindowsOutputReportEvent &event, + const HID_XFER_PACKET &packet + ) { + const auto report_id = packet.reportId; + const auto packet_includes_report_id = + report_id != 0U && packet.reportBufferLen > 0U && packet.reportBuffer[0] == report_id; + const auto report_id_size = report_id == 0U || packet_includes_report_id ? 0U : 1U; + const auto payload_capacity = LVH_WINDOWS_MAX_OUTPUT_REPORT_SIZE - report_id_size; + const auto payload_size = std::min(packet.reportBufferLen, static_cast(payload_capacity)); + + if (report_id_size != 0U) { + event.report[0] = report_id; + } + + if (payload_size > 0U) { + std::memcpy(event.report + report_id_size, packet.reportBuffer, payload_size); + } + + event.report_size = static_cast(report_id_size + payload_size); + } + void set_device_path(std::uint64_t driver_device_id, char (&device_path)[LVH_WINDOWS_MAX_DEVICE_PATH_SIZE]) { constexpr auto path_prefix_size = sizeof(LVH_WINDOWS_CONTROL_DEVICE_PATH) - 1U; constexpr auto separator_size = 1U; @@ -334,7 +570,7 @@ namespace { } } - void handle_create_gamepad_request(WDFREQUEST request) { + void handle_create_gamepad_request(WDFDEVICE device, WDFREQUEST request) { auto *create_request = static_cast(nullptr); auto status = retrieve_input_buffer(request, create_request); if (!NT_SUCCESS(status)) { @@ -363,10 +599,14 @@ namespace { const auto driver_device_id = state.next_driver_device_id.fetch_add(1); auto record = std::make_shared(); record->driver_device_id = driver_device_id; + record->owner_device = device; + record->owner_file = WdfRequestGetFileObject(request); record->request = *create_request; + trace_status("create_gamepad begin"); - status = create_vhf_device(record); + status = create_vhf_device(device, record); if (!NT_SUCCESS(status)) { + trace_status("create_gamepad failed", status); create_response->status = LVH_WINDOWS_STATUS_BACKEND_FAILURE; complete_request(request, STATUS_SUCCESS, sizeof(*create_response)); return; @@ -379,6 +619,7 @@ namespace { create_response->status = LVH_WINDOWS_STATUS_SUCCESS; create_response->driver_device_id = driver_device_id; set_device_path(driver_device_id, create_response->device_path); + trace_status("create_gamepad success"); complete_request(request, STATUS_SUCCESS, sizeof(*create_response)); } @@ -403,6 +644,9 @@ namespace { if (iter != state.devices.end()) { record = iter->second; state.devices.erase(iter); + trace_status("destroy_device found"); + } else { + trace_status("destroy_device missing"); } } delete_vhf_device(record); @@ -422,28 +666,37 @@ namespace { return; } + trace_status("submit_input_report begin"); + auto record = find_device(submit_request->driver_device_id); if (!record) { + trace_status("submit_input_report missing device"); complete_request(request, STATUS_OBJECT_NAME_NOT_FOUND); return; } - std::vector report( - submit_request->report, - submit_request->report + submit_request->report_size - ); - HID_XFER_PACKET packet {}; - packet.reportBuffer = report.data(); - packet.reportBufferLen = static_cast(report.size()); - packet.reportId = report.empty() ? 0 : report.front(); - std::lock_guard lock {record->mutex}; if (record->vhf_handle == nullptr) { + trace_status("submit_input_report missing vhf"); complete_request(request, STATUS_OBJECT_NAME_NOT_FOUND); return; } - complete_request(request, VhfReadReportSubmit(record->vhf_handle, &packet)); + auto report = make_vhf_input_payload(*record, *submit_request); + if (report.empty()) { + trace_status("submit_input_report invalid payload"); + complete_request(request, STATUS_INVALID_PARAMETER); + return; + } + + HID_XFER_PACKET packet {}; + packet.reportBuffer = report.data(); + packet.reportBufferLen = static_cast(report.size()); + packet.reportId = record->request.hardware_ids.report_id; + + const auto submit_status = VhfReadReportSubmit(record->vhf_handle, &packet); + trace_status("submit_input_report VhfReadReportSubmit", submit_status); + complete_request(request, submit_status); } void handle_read_output_report_request(WDFREQUEST request) { @@ -473,45 +726,64 @@ namespace { } // namespace extern "C" NTSTATUS DriverEntry(PDRIVER_OBJECT driver_object, PUNICODE_STRING registry_path) { + trace_status("DriverEntry begin"); + WDF_DRIVER_CONFIG config; WDF_DRIVER_CONFIG_INIT(&config, LvhEvtDeviceAdd); - return WdfDriverCreate(driver_object, registry_path, WDF_NO_OBJECT_ATTRIBUTES, &config, WDF_NO_HANDLE); + const auto status = WdfDriverCreate(driver_object, registry_path, WDF_NO_OBJECT_ATTRIBUTES, &config, WDF_NO_HANDLE); + trace_status("DriverEntry WdfDriverCreate", status); + return status; } NTSTATUS LvhEvtDeviceAdd(WDFDRIVER driver, PWDFDEVICE_INIT device_init) { UNREFERENCED_PARAMETER(driver); + trace_status("EvtDeviceAdd begin"); + WDF_PNPPOWER_EVENT_CALLBACKS pnp_callbacks; WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnp_callbacks); pnp_callbacks.EvtDevicePrepareHardware = LvhEvtDevicePrepareHardware; pnp_callbacks.EvtDeviceReleaseHardware = LvhEvtDeviceReleaseHardware; WdfDeviceInitSetPnpPowerEventCallbacks(device_init, &pnp_callbacks); + WDF_FILEOBJECT_CONFIG file_config; + WDF_FILEOBJECT_CONFIG_INIT(&file_config, WDF_NO_EVENT_CALLBACK, WDF_NO_EVENT_CALLBACK, LvhEvtFileCleanup); + WdfDeviceInitSetFileObjectConfig(device_init, &file_config, WDF_NO_OBJECT_ATTRIBUTES); + WDFDEVICE device = nullptr; WDF_OBJECT_ATTRIBUTES device_attributes; WDF_OBJECT_ATTRIBUTES_INIT(&device_attributes); device_attributes.EvtCleanupCallback = LvhEvtDeviceCleanup; auto status = WdfDeviceCreate(&device_init, &device_attributes, &device); + trace_status("EvtDeviceAdd WdfDeviceCreate", status); + if (!NT_SUCCESS(status)) { + return status; + } + + status = WdfDeviceCreateDeviceInterface(device, &control_device_interface_guid, nullptr); + trace_status("EvtDeviceAdd WdfDeviceCreateDeviceInterface", status); if (!NT_SUCCESS(status)) { return status; } - UNICODE_STRING symbolic_link; - RtlInitUnicodeString(&symbolic_link, global_symbolic_link_name); - status = WdfDeviceCreateSymbolicLink(device, &symbolic_link); + status = + create_control_symbolic_link(device, global_symbolic_link_name, "EvtDeviceAdd WdfDeviceCreateSymbolicLink global"); if (!NT_SUCCESS(status)) { return status; } - RtlInitUnicodeString(&symbolic_link, symbolic_link_name); - static_cast(WdfDeviceCreateSymbolicLink(device, &symbolic_link)); + static_cast( + create_control_symbolic_link(device, symbolic_link_name, "EvtDeviceAdd WdfDeviceCreateSymbolicLink local") + ); WDF_IO_QUEUE_CONFIG queue_config; WDF_IO_QUEUE_CONFIG_INIT_DEFAULT_QUEUE(&queue_config, WdfIoQueueDispatchParallel); queue_config.EvtIoDeviceControl = LvhEvtIoDeviceControl; - return WdfIoQueueCreate(device, &queue_config, WDF_NO_OBJECT_ATTRIBUTES, WDF_NO_HANDLE); + status = WdfIoQueueCreate(device, &queue_config, WDF_NO_OBJECT_ATTRIBUTES, WDF_NO_HANDLE); + trace_status("EvtDeviceAdd WdfIoQueueCreate", status); + return status; } NTSTATUS LvhEvtDevicePrepareHardware( @@ -519,26 +791,37 @@ NTSTATUS LvhEvtDevicePrepareHardware( WDFCMRESLIST resources_raw, WDFCMRESLIST resources_translated ) { + UNREFERENCED_PARAMETER(device); UNREFERENCED_PARAMETER(resources_raw); UNREFERENCED_PARAMETER(resources_translated); - return initialize_vhf_target(device); + trace_status("EvtDevicePrepareHardware begin"); + + // The control device should still start if the local VHF target cannot be + // opened yet. Gamepad creation will initialize VHF lazily and report the + // backend failure through the IOCTL response if the target is unavailable. + return STATUS_SUCCESS; } NTSTATUS LvhEvtDeviceReleaseHardware(WDFDEVICE device, WDFCMRESLIST resources_translated) { UNREFERENCED_PARAMETER(device); UNREFERENCED_PARAMETER(resources_translated); + trace_status("EvtDeviceReleaseHardware begin"); complete_pending_output_requests(STATUS_CANCELLED); - reset_vhf_target(true); + reset_vhf_devices(device); return STATUS_SUCCESS; } void LvhEvtDeviceCleanup(WDFOBJECT device_object) { - UNREFERENCED_PARAMETER(device_object); - + trace_status("EvtDeviceCleanup begin"); complete_pending_output_requests(STATUS_CANCELLED); - reset_vhf_target(false); + reset_vhf_devices(WDFDEVICE(device_object)); +} + +void LvhEvtFileCleanup(WDFFILEOBJECT file_object) { + trace_status("EvtFileCleanup begin"); + delete_vhf_devices_for_file(file_object); } void LvhEvtOutputReadCanceled(WDFREQUEST request) { @@ -565,15 +848,7 @@ void LvhEvtVhfWriteReport( event.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; event.size = sizeof(event); event.driver_device_id = record->driver_device_id; - event.report_size = - std::min(hid_transfer_packet->reportBufferLen, static_cast(LVH_WINDOWS_MAX_OUTPUT_REPORT_SIZE)); - - if (event.report_size > 0U) { - std::memcpy(event.report, hid_transfer_packet->reportBuffer, event.report_size); - } else if (hid_transfer_packet->reportId != 0U) { - event.report_size = 1U; - event.report[0] = hid_transfer_packet->reportId; - } + copy_vhf_output_payload(event, *hid_transfer_packet); queue_output_event(event); static_cast(VhfAsyncOperationComplete(vhf_operation_handle, STATUS_SUCCESS)); @@ -586,13 +861,12 @@ void LvhEvtIoDeviceControl( size_t input_buffer_length, ULONG io_control_code ) { - UNREFERENCED_PARAMETER(queue); UNREFERENCED_PARAMETER(output_buffer_length); UNREFERENCED_PARAMETER(input_buffer_length); switch (io_control_code) { case LVH_WINDOWS_IOCTL_CREATE_GAMEPAD: - handle_create_gamepad_request(request); + handle_create_gamepad_request(WdfIoQueueGetDevice(queue), request); return; case LVH_WINDOWS_IOCTL_DESTROY_DEVICE: diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index e4766e4..132e48d 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -39,7 +39,10 @@ #endif // platform includes +// clang-format off #include +#include +// clang-format on namespace lvh::detail { namespace { // NOSONAR(cpp:S1000): Windows backend internals need internal linkage; tests include this file with the platform factory renamed. @@ -62,6 +65,13 @@ namespace lvh::detail { return function; } + constexpr GUID control_device_interface_guid { + 0x3890af65, + 0x2da0, + 0x443c, + {0x84, 0xff, 0x6e, 0x70, 0xe8, 0x41, 0xba, 0x1e} + }; + UniqueHandle make_unique_handle(HANDLE handle) { return {handle, &::CloseHandle}; } @@ -114,6 +124,55 @@ namespace lvh::detail { return send_input(std::span {inputs}, operation); } + std::vector enumerate_control_device_interface_paths() { + std::vector paths; + + const auto device_info_set = ::SetupDiGetClassDevsA( + &control_device_interface_guid, + nullptr, + nullptr, + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE + ); + if (device_info_set == INVALID_HANDLE_VALUE) { + return paths; + } + + const auto cleanup = std::unique_ptr { + device_info_set, + &::SetupDiDestroyDeviceInfoList + }; + + for (DWORD index = 0;; ++index) { + SP_DEVICE_INTERFACE_DATA interface_data {}; + interface_data.cbSize = sizeof(interface_data); + if (::SetupDiEnumDeviceInterfaces(device_info_set, nullptr, &control_device_interface_guid, index, &interface_data) == FALSE) { + break; + } + + DWORD required_size = 0; + static_cast(::SetupDiGetDeviceInterfaceDetailA( + device_info_set, + &interface_data, + nullptr, + 0, + &required_size, + nullptr + )); + if (required_size == 0U || ::GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + continue; + } + + auto buffer = std::make_unique_for_overwrite(required_size); + auto *detail_data = static_cast(static_cast(buffer.get())); + detail_data->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_A); + if (::SetupDiGetDeviceInterfaceDetailA(device_info_set, &interface_data, detail_data, required_size, nullptr, nullptr) != FALSE) { + paths.emplace_back(detail_data->DevicePath); + } + } + + return paths; + } + DWORD mouse_button_flags(MouseButton button, bool pressed) { switch (button) { using enum MouseButton; @@ -169,10 +228,10 @@ namespace lvh::detail { } } - return { - std::string {windows::default_control_device_path}, - std::string {windows::global_control_device_path}, - }; + auto paths = enumerate_control_device_interface_paths(); + paths.emplace_back(windows::default_control_device_path); + paths.emplace_back(windows::global_control_device_path); + return paths; } OperationStatus protocol_status(std::uint32_t status, std::string_view operation) { @@ -279,14 +338,7 @@ namespace lvh::detail { auto request_copy = request; DWORD bytes_returned = 0; - if (const auto status = device_io_control( - LVH_WINDOWS_IOCTL_CREATE_GAMEPAD, - request_copy, - response, - &bytes_returned, - "create Windows gamepad" - ); - !status.ok()) { + if (const auto status = device_io_control(LVH_WINDOWS_IOCTL_CREATE_GAMEPAD, request_copy, response, &bytes_returned, "create Windows gamepad"); !status.ok()) { return status; } @@ -342,17 +394,7 @@ namespace lvh::detail { overlapped.hEvent = operation_event.get(); DWORD bytes_returned = 0; - if (const auto started = ::DeviceIoControl( - handle_.get(), - LVH_WINDOWS_IOCTL_READ_OUTPUT_REPORT, - nullptr, - 0, - &event, - sizeof(event), - &bytes_returned, - &overlapped - ); - started == FALSE) { + if (const auto started = ::DeviceIoControl(handle_.get(), LVH_WINDOWS_IOCTL_READ_OUTPUT_REPORT, nullptr, 0, &event, sizeof(event), &bytes_returned, &overlapped); started == FALSE) { if (const auto error_code = ::GetLastError(); error_code != ERROR_IO_PENDING) { return std::nullopt; } @@ -381,9 +423,7 @@ namespace lvh::detail { return std::nullopt; } - if (constexpr auto event_header_size = - sizeof(event.version) + sizeof(event.size) + sizeof(event.driver_device_id) + sizeof(event.report_size); - bytes_returned < event_header_size) { + if (constexpr auto event_header_size = sizeof(event.version) + sizeof(event.size) + sizeof(event.driver_device_id) + sizeof(event.report_size); bytes_returned < event_header_size) { return std::nullopt; } @@ -406,16 +446,7 @@ namespace lvh::detail { ) const { using enum ErrorCode; - if (::DeviceIoControl( - handle_.get(), - control_code, - &input, - sizeof(input), - &output, - sizeof(output), - bytes_returned, - nullptr - ) == FALSE) { + if (::DeviceIoControl(handle_.get(), control_code, &input, sizeof(input), &output, sizeof(output), bytes_returned, nullptr) == FALSE) { return windows_failure(backend_failure, operation, ::GetLastError()); } @@ -431,16 +462,7 @@ namespace lvh::detail { ) const { using enum ErrorCode; - if (::DeviceIoControl( - handle_.get(), - control_code, - &input, - sizeof(input), - nullptr, - 0, - bytes_returned, - nullptr - ) == FALSE) { + if (::DeviceIoControl(handle_.get(), control_code, &input, sizeof(input), nullptr, 0, bytes_returned, nullptr) == FALSE) { return windows_failure(backend_failure, operation, ::GetLastError()); } @@ -878,6 +900,15 @@ namespace lvh::detail { }; } + if (options.profile.gamepad_kind == GamepadProfileKind::xbox_360) { + return { + unsupported_device_status( + "Windows UMDF/VHF backend cannot expose Xbox 360 XUSB gamepads; use an XUSB fallback for this profile" + ), + nullptr, + }; + } + return context_->create_gamepad(id, options); } diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp index 9ba6746..fe75b62 100644 --- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp @@ -37,6 +37,7 @@ namespace lvh::detail::test { OperationStatus backend_failure_status; OperationStatus transport_failure_status; OperationStatus unavailable_status; + OperationStatus xbox_360_unsupported_status; OperationStatus oversized_descriptor_status; OperationStatus oversized_input_report_status; OperationStatus oversized_output_report_status; diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index 34a925f..7311d94 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -226,7 +226,7 @@ namespace lvh::detail { auto backend = make_fake_windows_backend(command_state, std::make_shared()); CreateGamepadOptions options; - options.profile = profiles::xbox_360(); + options.profile = profiles::xbox_series(); return backend->create_gamepad(1, options).status; } @@ -299,7 +299,7 @@ namespace lvh::detail { result.capabilities = backend->capabilities(); CreateGamepadOptions options; - options.profile = profiles::xbox_360(); + options.profile = profiles::xbox_series(); auto created = backend->create_gamepad(7, options); result.create_status = created.status; if (created) { @@ -351,7 +351,7 @@ namespace lvh::detail { auto backend = make_fake_windows_backend(command_state, std::make_shared()); CreateGamepadOptions options; - options.profile = profiles::xbox_360(); + options.profile = profiles::xbox_series(); result.transport_failure_status = backend->create_gamepad(2, options).status; } @@ -359,7 +359,7 @@ namespace lvh::detail { WindowsBackend backend {nullptr, nullptr}; CreateGamepadOptions options; - options.profile = profiles::xbox_360(); + options.profile = profiles::xbox_series(); result.unavailable_status = backend.create_gamepad(3, options).status; auto oversized_descriptor_options = options; @@ -375,12 +375,23 @@ namespace lvh::detail { result.oversized_output_report_status = backend.create_gamepad(19, oversized_output_options).status; } + { + auto backend = make_fake_windows_backend( + std::make_shared(), + std::make_shared() + ); + + CreateGamepadOptions options; + options.profile = profiles::xbox_360(); + result.xbox_360_unsupported_status = backend->create_gamepad(20, options).status; + } + { auto command_state = std::make_shared("", ""); auto backend = make_fake_windows_backend(command_state, std::make_shared()); CreateGamepadOptions options; - options.profile = profiles::xbox_360(); + options.profile = profiles::xbox_series(); auto created = backend->create_gamepad(4, options); result.empty_nodes_create_status = created.status; if (created) { diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp index c42d4d7..9f207ab 100644 --- a/tests/unit/test_gamepad_adapter.cpp +++ b/tests/unit/test_gamepad_adapter.cpp @@ -16,6 +16,7 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { const auto generic = lvh::profiles::generic_gamepad(); const auto dualshock4 = lvh::profiles::dualshock4(); const auto dualsense = lvh::profiles::dualsense(); + const auto switch_pro = lvh::profiles::switch_pro(); const auto keyboard = lvh::profiles::keyboard(); const auto generic_support = lvh::gamepad_profile_support(generic); @@ -44,6 +45,12 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) { EXPECT_TRUE(dualsense_support.supports_misc1_button); EXPECT_EQ(dualsense_support.supported_rear_paddle_count, 0U); + const auto switch_pro_support = lvh::gamepad_profile_support(switch_pro); + EXPECT_FALSE(switch_pro_support.supports_rumble); + EXPECT_TRUE(switch_pro_support.supports_motion); + EXPECT_TRUE(switch_pro_support.supports_battery); + EXPECT_TRUE(switch_pro_support.supports_misc1_button); + const auto keyboard_support = lvh::gamepad_profile_support(keyboard); EXPECT_FALSE(keyboard_support.supports_rumble); EXPECT_FALSE(keyboard_support.supports_motion); @@ -61,6 +68,7 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) { const auto generic = lvh::profiles::generic_gamepad(); const auto dualshock4 = lvh::profiles::dualshock4(); const auto dualsense = lvh::profiles::dualsense(); + const auto switch_pro = lvh::profiles::switch_pro(); const auto keyboard = lvh::profiles::keyboard(); EXPECT_TRUE(lvh::supports_gamepad_button(xbox, lvh::GamepadButton::guide)); @@ -83,6 +91,8 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) { EXPECT_FALSE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::trigger_rumble)); EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::raw_report)); EXPECT_TRUE(lvh::supports_gamepad_output(dualsense, lvh::GamepadOutputKind::adaptive_triggers)); + EXPECT_FALSE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::rumble)); + EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::raw_report)); EXPECT_FALSE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::raw_report)); EXPECT_FALSE(lvh::supports_gamepad_output(keyboard, lvh::GamepadOutputKind::rumble)); EXPECT_FALSE(lvh::supports_gamepad_output(generic, static_cast(255))); @@ -130,7 +140,7 @@ TEST(GamepadAdapterTest, CachesAndSubmitsPartialUpdates) { const auto *gamepad = adapter.gamepad(); ASSERT_NE(gamepad, nullptr); - EXPECT_EQ(gamepad->submit_count(), 9U); + EXPECT_EQ(gamepad->submit_count(), 10U); const auto submitted = gamepad->last_submitted_state(); EXPECT_TRUE(submitted.buttons.test(lvh::GamepadButton::a)); @@ -190,7 +200,7 @@ TEST(GamepadAdapterTest, RejectsUnsupportedPartialUpdates) { EXPECT_EQ(adapter.clear_touchpad_contact(0).code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(adapter.set_button(lvh::GamepadButton::touchpad, true).code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(adapter.set_button(lvh::GamepadButton::paddle1, true).code(), lvh::ErrorCode::unsupported_profile); - EXPECT_EQ(adapter.gamepad()->submit_count(), 0U); + EXPECT_EQ(adapter.gamepad()->submit_count(), 1U); } TEST(GamepadAdapterTest, RejectsInvalidCreationAndClosedAdapterUpdates) { diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 8a35d52..8baa564 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -3,6 +3,10 @@ * @brief Unit tests for built-in gamepad profiles. */ +// standard includes +#include +#include + // local includes #include "fixtures/fixtures.hpp" @@ -17,8 +21,7 @@ TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { EXPECT_FALSE(profile.name.empty()); EXPECT_NE(profile.vendor_id, 0); EXPECT_NE(profile.product_id, 0); - EXPECT_NE(profile.report_id, 0); - EXPECT_GE(profile.input_report_size, 14U); + EXPECT_GE(profile.input_report_size, 9U); EXPECT_FALSE(profile.report_descriptor.empty()); } } @@ -31,10 +34,105 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(xbox_one.vendor_id, 0x045E); EXPECT_EQ(xbox_one.product_id, 0x02EA); + EXPECT_EQ(xbox_one.bus_type, lvh::BusType::usb); + EXPECT_EQ(xbox_one.manufacturer, "Microsoft"); EXPECT_TRUE(xbox_one.capabilities.supports_rumble); + EXPECT_EQ(xbox_one.report_id, 0); + EXPECT_EQ(xbox_one.input_report_size, 17U); + + const auto xbox_series = lvh::profiles::xbox_series(); + EXPECT_EQ(xbox_series.vendor_id, 0x045E); + EXPECT_EQ(xbox_series.product_id, 0x0B12); + EXPECT_EQ(xbox_series.bus_type, lvh::BusType::usb); + EXPECT_EQ(xbox_series.name, "Xbox Controller"); + EXPECT_EQ(xbox_series.manufacturer, "Microsoft"); + EXPECT_EQ(xbox_series.report_id, 0); + EXPECT_EQ(xbox_series.input_report_size, 17U); + + const std::array xbox_gip_button_descriptor { + 0x05, + 0x09, + 0x19, + 0x01, + 0x29, + 0x0A, + 0x95, + 0x0A, + 0x75, + 0x01, + 0x81, + 0x02, + }; + EXPECT_TRUE( + std::ranges::search(xbox_one.report_descriptor, xbox_gip_button_descriptor).begin() != xbox_one.report_descriptor.end() + ); + + const std::array xbox_gip_stick_axis_descriptor { + 0x15, + 0x00, + 0x27, + 0xFF, + 0xFF, + 0x00, + 0x00, + 0x95, + 0x02, + 0x75, + 0x10, + }; + EXPECT_TRUE( + std::ranges::search(xbox_one.report_descriptor, xbox_gip_stick_axis_descriptor).begin() != xbox_one.report_descriptor.end() + ); + + const std::array right_stick_usage_descriptor { + 0x09, + 0x33, + 0x09, + 0x34, + 0x15, + 0x00, + 0x27, + 0xFF, + 0xFF, + }; + EXPECT_TRUE( + std::ranges::search(xbox_one.report_descriptor, right_stick_usage_descriptor).begin() != xbox_one.report_descriptor.end() + ); + + const std::array trigger_usage_descriptor { + 0x09, + 0x32, + 0x15, + 0x00, + 0x26, + 0xFF, + 0x03, + 0x95, + 0x01, + }; + EXPECT_TRUE( + std::ranges::search(xbox_one.report_descriptor, trigger_usage_descriptor).begin() != xbox_one.report_descriptor.end() + ); + + const std::array right_trigger_usage_descriptor { + 0x09, + 0x35, + 0x15, + 0x00, + 0x26, + 0xFF, + 0x03, + 0x95, + 0x01, + }; + EXPECT_TRUE( + std::ranges::search(xbox_one.report_descriptor, right_trigger_usage_descriptor).begin() != xbox_one.report_descriptor.end() + ); EXPECT_EQ(dualshock4.vendor_id, 0x054C); EXPECT_EQ(dualshock4.product_id, 0x05C4); + EXPECT_EQ(dualshock4.version, 0x0100); + EXPECT_EQ(dualshock4.name, "Wireless Controller"); EXPECT_EQ(dualshock4.input_report_size, 64U); EXPECT_EQ(dualshock4.output_report_size, 32U); EXPECT_TRUE(dualshock4.capabilities.supports_motion); @@ -42,16 +140,20 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_TRUE(dualshock4.capabilities.supports_rgb_led); EXPECT_TRUE(dualshock4.capabilities.supports_battery); EXPECT_FALSE(dualshock4.capabilities.supports_adaptive_triggers); - EXPECT_EQ(dualshock4.manufacturer, "Sony Interactive Entertainment"); + EXPECT_EQ(dualshock4.manufacturer, "Sony Computer Entertainment"); const auto dualshock4_bluetooth = lvh::profiles::dualshock4_bluetooth(); EXPECT_EQ(dualshock4_bluetooth.bus_type, lvh::BusType::bluetooth); + EXPECT_EQ(dualshock4_bluetooth.version, 0x0100); + EXPECT_EQ(dualshock4_bluetooth.name, "Wireless Controller"); + EXPECT_EQ(dualshock4_bluetooth.manufacturer, "Sony Computer Entertainment"); EXPECT_EQ(dualshock4_bluetooth.report_id, 0x11); EXPECT_EQ(dualshock4_bluetooth.input_report_size, 78U); EXPECT_EQ(dualshock4_bluetooth.output_report_size, 78U); EXPECT_NE(dualshock4_bluetooth.report_descriptor, dualshock4.report_descriptor); EXPECT_EQ(dualsense.vendor_id, 0x054C); + EXPECT_EQ(dualsense.name, "Wireless Controller"); EXPECT_TRUE(dualsense.capabilities.supports_motion); EXPECT_TRUE(dualsense.capabilities.supports_touchpad); EXPECT_TRUE(dualsense.capabilities.supports_rgb_led); @@ -62,6 +164,7 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { const auto dualsense_bluetooth = lvh::profiles::dualsense_bluetooth(); EXPECT_EQ(dualsense_bluetooth.bus_type, lvh::BusType::bluetooth); + EXPECT_EQ(dualsense_bluetooth.name, "Wireless Controller"); EXPECT_EQ(dualsense_bluetooth.report_id, 0x31); EXPECT_EQ(dualsense_bluetooth.input_report_size, 78U); EXPECT_EQ(dualsense_bluetooth.output_report_size, 78U); @@ -69,6 +172,77 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_EQ(switch_pro.vendor_id, 0x057E); EXPECT_EQ(switch_pro.product_id, 0x2009); + EXPECT_EQ(switch_pro.name, "Pro Controller"); + EXPECT_EQ(switch_pro.manufacturer, "Nintendo Co., Ltd."); + EXPECT_EQ(switch_pro.report_id, 0x30); + EXPECT_EQ(switch_pro.input_report_size, 64U); + EXPECT_EQ(switch_pro.output_report_size, 64U); + EXPECT_FALSE(switch_pro.capabilities.supports_rumble); + EXPECT_TRUE(switch_pro.capabilities.supports_motion); + EXPECT_TRUE(switch_pro.capabilities.supports_battery); + + const auto generic = lvh::profiles::generic_gamepad(); + const std::array standard_button_descriptor { + 0x05, + 0x09, + 0x19, + 0x01, + 0x29, + 0x10, + 0x15, + 0x00, + 0x25, + 0x01, + 0x75, + 0x01, + }; + EXPECT_TRUE( + std::ranges::search(generic.report_descriptor, standard_button_descriptor).begin() != generic.report_descriptor.end() + ); + + const std::array standard_axis_order { + 0x09, + 0x30, + 0x09, + 0x31, + 0x09, + 0x33, + 0x09, + 0x34, + 0x09, + 0x32, + 0x09, + 0x35, + }; + EXPECT_TRUE( + std::ranges::search(generic.report_descriptor, standard_axis_order).begin() != generic.report_descriptor.end() + ); + EXPECT_NE(switch_pro.report_descriptor, generic.report_descriptor); + + const std::array switch_pro_report_id_descriptor {0x85, 0x30}; + EXPECT_TRUE( + std::ranges::search(switch_pro.report_descriptor, switch_pro_report_id_descriptor).begin() != + switch_pro.report_descriptor.end() + ); + + const std::array switch_pro_button_descriptor { + 0x19, + 0x01, + 0x29, + 0x0A, + 0x15, + 0x00, + 0x25, + 0x01, + 0x75, + 0x01, + 0x95, + 0x0A, + }; + EXPECT_TRUE( + std::ranges::search(switch_pro.report_descriptor, switch_pro_button_descriptor).begin() != + switch_pro.report_descriptor.end() + ); } TEST(ProfileTest, RumbleProfilesExposeOutputReports) { diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index 671126d..f4f7a06 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -4,6 +4,7 @@ */ // standard includes +#include #include #include #include @@ -15,6 +16,10 @@ #include namespace { + std::byte to_byte(std::uint8_t value) { + return static_cast(value); + } + std::uint32_t test_crc32(std::span buffer, std::uint32_t seed = 0) { auto crc = seed ^ 0xFFFFFFFFU; for (const auto byte : buffer) { @@ -37,6 +42,28 @@ namespace { (static_cast(bytes[offset + 2U]) << 16U) | (static_cast(bytes[offset + 3U]) << 24U); } + + std::uint16_t read_u16_le(std::span bytes, std::size_t offset) { + const auto low = std::to_integer(to_byte(bytes[offset])); + const auto high = std::to_integer(to_byte(bytes[offset + 1U])); + return static_cast(low | static_cast(high << 8U)); + } + + lvh::GamepadState make_active_gamepad_state() { + using enum lvh::GamepadButton; + + lvh::GamepadState state; + state.buttons.set(a); + state.buttons.set(start); + state.buttons.set(dpad_left); + state.buttons.set(guide); + state.buttons.set(misc1); + state.left_stick = {1.0F, -1.0F}; + state.right_stick = {0.5F, -0.5F}; + state.left_trigger = 0.25F; + state.right_trigger = 1.0F; + return state; + } } // namespace TEST(ReportTest, NormalizesAxesAndTriggers) { @@ -71,28 +98,107 @@ TEST(ReportTest, EncodesHatSwitch) { TEST(ReportTest, PacksCommonGamepadReport) { auto profile = lvh::profiles::xbox_360(); + auto state = make_active_gamepad_state(); + + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_EQ(report.size(), profile.input_report_size); + EXPECT_EQ(report[0], profile.report_id); + EXPECT_EQ(report[1], 0x81); // A and Start. + EXPECT_EQ(report[2], 0x6C); // Guide, Misc/share, and D-pad-left hat value. + EXPECT_EQ(report[3], 255); // Left stick X. + EXPECT_EQ(report[4], 255); // Left stick Y. + EXPECT_EQ(report[5], 64); // Left trigger. + EXPECT_EQ(report[6], 191); // Right stick X. + EXPECT_EQ(report[7], 191); // Right stick Y. + EXPECT_EQ(report[8], 255); // Right trigger. +} + +TEST(ReportTest, PacksStandardGamepadReport) { + auto profile = lvh::profiles::generic_gamepad(); + + auto state = make_active_gamepad_state(); + + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_EQ(report.size(), profile.input_report_size); + EXPECT_EQ(report[0], profile.report_id); + EXPECT_EQ(report[1], 0x81); // A and Start. + EXPECT_EQ(report[2], 0x4C); // Guide, Misc/share, and D-pad-left button. + EXPECT_EQ(report[3], 255); // Left stick X. + EXPECT_EQ(report[4], 255); // Left stick Y. + EXPECT_EQ(report[5], 191); // Right stick X. + EXPECT_EQ(report[6], 191); // Right stick Y. + EXPECT_EQ(report[7], 64); // Left trigger. + EXPECT_EQ(report[8], 255); // Right trigger. +} + +TEST(ReportTest, PacksXboxGipReport) { + auto profile = lvh::profiles::xbox_series(); + + auto state = make_active_gamepad_state(); + state.battery = lvh::GamepadBattery {.state = lvh::GamepadBatteryState::discharging, .percentage = 80}; + + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_EQ(report.size(), profile.input_report_size); + EXPECT_EQ(profile.report_id, 0); + EXPECT_EQ(read_u16_le(report, 0U), 0xFFFF); // Left stick X. + EXPECT_EQ(read_u16_le(report, 2U), 0xFFFF); // Left stick Y. + EXPECT_EQ(read_u16_le(report, 4U), 0xBFFF); // Right stick X. + EXPECT_EQ(read_u16_le(report, 6U), 0xBFFF); // Right stick Y. + EXPECT_EQ(read_u16_le(report, 8U), 256); // Left trigger. + EXPECT_EQ(read_u16_le(report, 10U), 1023); // Right trigger. + EXPECT_EQ(read_u16_le(report, 12U), 0x0881); // A, Start, and Share. + EXPECT_EQ(report[14], 7); // D-pad left. + EXPECT_EQ(report[15], 1); // Guide/System Main Menu. + EXPECT_EQ(report[16], 204); // Battery strength. +} + +TEST(ReportTest, PacksSwitchProReport) { + using enum lvh::GamepadButton; + + auto profile = lvh::profiles::switch_pro(); + lvh::GamepadState state; - state.buttons.set(lvh::GamepadButton::a); - state.buttons.set(lvh::GamepadButton::start); - state.buttons.set(lvh::GamepadButton::dpad_left); + state.buttons.set(a); + state.buttons.set(b); + state.buttons.set(start); + state.buttons.set(guide); + state.buttons.set(misc1); + state.buttons.set(dpad_left); state.left_stick = {1.0F, -1.0F}; state.right_stick = {0.5F, -0.5F}; state.left_trigger = 0.25F; - state.right_trigger = 1.0F; const auto report = lvh::reports::pack_input_report(profile, state); ASSERT_EQ(report.size(), profile.input_report_size); - EXPECT_EQ(report[0], profile.report_id); - EXPECT_EQ(report[1], 0x21); // A + Start - EXPECT_EQ(report[2], 0x00); - EXPECT_EQ(report[3], 6); // D-pad left - EXPECT_EQ(report[4], 0xFF); - EXPECT_EQ(report[5], 0x7F); - EXPECT_EQ(report[6], 0x00); - EXPECT_EQ(report[7], 0x80); - EXPECT_EQ(report[12], 64); - EXPECT_EQ(report[13], 255); + EXPECT_EQ(report[0], 0x30); + EXPECT_EQ(read_u16_le(report, 1U), 0x3243); // B, A, ZL, Plus, Home, and Capture. + EXPECT_EQ(read_u16_le(report, 3U), 0xFFFF); // Left stick X. + EXPECT_EQ(read_u16_le(report, 5U), 0xFFFF); // Left stick Y. + EXPECT_EQ(read_u16_le(report, 7U), 0xBFFF); // Right stick X. + EXPECT_EQ(read_u16_le(report, 9U), 0xBFFF); // Right stick Y. + EXPECT_EQ(report[11] & 0x0F, 6); // D-pad left. +} + +TEST(ReportTest, PacksXboxGipNeutralReport) { + const auto profile = lvh::profiles::xbox_series(); + + const auto report = lvh::reports::pack_input_report(profile, {}); + + ASSERT_EQ(report.size(), profile.input_report_size); + EXPECT_EQ(read_u16_le(report, 0U), 0x8000); // Left stick X. + EXPECT_EQ(read_u16_le(report, 2U), 0x8000); // Left stick Y. + EXPECT_EQ(read_u16_le(report, 4U), 0x8000); // Right stick X. + EXPECT_EQ(read_u16_le(report, 6U), 0x8000); // Right stick Y. + EXPECT_EQ(read_u16_le(report, 8U), 0x0000); // Left trigger. + EXPECT_EQ(read_u16_le(report, 10U), 0x0000); // Right trigger. + EXPECT_EQ(read_u16_le(report, 12U), 0x0000); // Buttons. + EXPECT_EQ(report[14], 0); // Neutral D-pad. + EXPECT_EQ(report[15], 0); // Guide/System Main Menu. + EXPECT_EQ(report[16], 0xFF); // Unknown battery defaults to full. } TEST(ReportTest, PacksDualSenseUsbReport) { @@ -114,7 +220,7 @@ TEST(ReportTest, PacksDualSenseUsbReport) { ASSERT_EQ(report.size(), profile.input_report_size); EXPECT_EQ(report[0], 1); EXPECT_EQ(report[1], 255); - EXPECT_EQ(report[2], 0); + EXPECT_EQ(report[2], 255); EXPECT_EQ(report[5], 255); EXPECT_EQ(report[8] & 0x20, 0x20); EXPECT_EQ(report[9] & 0x05, 0x05); @@ -139,7 +245,7 @@ TEST(ReportTest, PacksDualSenseBluetoothReportWithCrc) { EXPECT_EQ(report[0], 0x31); EXPECT_EQ(report[1], 0x00); EXPECT_EQ(report[2], 255); - EXPECT_EQ(report[3], 0); + EXPECT_EQ(report[3], 255); EXPECT_EQ(report[7], 255); EXPECT_EQ(report[9] & 0x20, 0x20); EXPECT_EQ(report[10] & 0x08, 0x08); @@ -183,7 +289,7 @@ TEST(ReportTest, PacksDualShock4UsbReport) { ASSERT_EQ(report.size(), profile.input_report_size); EXPECT_EQ(report[0], 0x01); EXPECT_EQ(report[1], 255); - EXPECT_EQ(report[2], 0); + EXPECT_EQ(report[2], 255); EXPECT_EQ(report[3], 128); EXPECT_EQ(report[4], 128); EXPECT_EQ(report[5], 0xF8); diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 8c85e77..7e19466 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -66,7 +66,9 @@ TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) { EXPECT_FALSE(created); EXPECT_EQ(created.status.code(), lvh::ErrorCode::backend_unavailable); } else { - auto created = runtime->create_gamepad(lvh::profiles::xbox_360()); + EXPECT_EQ(runtime->create_gamepad(lvh::profiles::xbox_360()).status.code(), lvh::ErrorCode::unsupported_profile); + + auto created = runtime->create_gamepad(lvh::profiles::xbox_series()); ASSERT_TRUE(created) << created.status.message(); ASSERT_NE(created.gamepad, nullptr); EXPECT_FALSE(created.gamepad->device_nodes().empty()); @@ -82,15 +84,15 @@ TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) { EXPECT_EQ(created.gamepad->submit(state).code(), lvh::ErrorCode::device_closed); } - auto invalid_profile = lvh::profiles::xbox_360(); + auto invalid_profile = lvh::profiles::xbox_series(); invalid_profile.report_descriptor.resize(LVH_WINDOWS_MAX_REPORT_DESCRIPTOR_SIZE + 1U); EXPECT_EQ(runtime->create_gamepad(invalid_profile).status.code(), lvh::ErrorCode::invalid_argument); - invalid_profile = lvh::profiles::xbox_360(); + invalid_profile = lvh::profiles::xbox_series(); invalid_profile.input_report_size = LVH_WINDOWS_MAX_INPUT_REPORT_SIZE + 1U; EXPECT_EQ(runtime->create_gamepad(invalid_profile).status.code(), lvh::ErrorCode::invalid_argument); - invalid_profile = lvh::profiles::xbox_360(); + invalid_profile = lvh::profiles::xbox_series(); invalid_profile.output_report_size = LVH_WINDOWS_MAX_OUTPUT_REPORT_SIZE + 1U; EXPECT_EQ(runtime->create_gamepad(invalid_profile).status.code(), lvh::ErrorCode::invalid_argument); #else diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp index 8eb8a50..e52462d 100644 --- a/tests/unit/test_windows_backend.cpp +++ b/tests/unit/test_windows_backend.cpp @@ -57,7 +57,7 @@ TEST_F(WindowsBackendTest, FakeChannelExercisesLifecycleSubmitCloseAndOutput) { EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678U); EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234U); ASSERT_GE(result.last_output.raw_report.size(), 5U); - EXPECT_EQ(result.last_output.raw_report[0], 1U); + EXPECT_EQ(result.last_output.raw_report[0], 0U); } TEST_F(WindowsBackendTest, FakeChannelCoversCreateFailureBranches) { @@ -69,6 +69,7 @@ TEST_F(WindowsBackendTest, FakeChannelCoversCreateFailureBranches) { EXPECT_EQ(result.backend_failure_status.code(), lvh::ErrorCode::backend_failure); EXPECT_EQ(result.transport_failure_status.code(), lvh::ErrorCode::backend_failure); EXPECT_EQ(result.unavailable_status.code(), lvh::ErrorCode::backend_unavailable); + EXPECT_EQ(result.xbox_360_unsupported_status.code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(result.oversized_descriptor_status.code(), lvh::ErrorCode::invalid_argument); EXPECT_EQ(result.oversized_input_report_status.code(), lvh::ErrorCode::invalid_argument); EXPECT_EQ(result.oversized_output_report_status.code(), lvh::ErrorCode::invalid_argument); @@ -81,9 +82,9 @@ TEST_F(WindowsBackendTest, FakeChannelCoversCreateFailureBranches) { TEST_F(WindowsBackendTest, UtilityHookCoversEnvironmentErrorAndThreadBranches) { const auto result = lvh::detail::test::windows_backend_fake_channel_utilities(); - ASSERT_EQ(result.default_device_paths.size(), 2U); - EXPECT_EQ(result.default_device_paths[0], R"(\\.\LibVirtualHid)"); - EXPECT_EQ(result.default_device_paths[1], R"(\\.\Global\LibVirtualHid)"); + ASSERT_GE(result.default_device_paths.size(), 2U); + EXPECT_EQ(result.default_device_paths[result.default_device_paths.size() - 2U], R"(\\.\LibVirtualHid)"); + EXPECT_EQ(result.default_device_paths[result.default_device_paths.size() - 1U], R"(\\.\Global\LibVirtualHid)"); ASSERT_EQ(result.custom_device_paths.size(), 1U); EXPECT_EQ(result.custom_device_paths[0], R"(\\.\LibVirtualHid-Test)"); EXPECT_EQ(result.formatted_error_status.code(), lvh::ErrorCode::backend_failure);