diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52f8aa5..701adb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: code: - 'src/**' - 'tests/**' + - 'tools/**' - '.github/workflows/**' - 'global.json' - '*.sln' @@ -89,6 +90,48 @@ jobs: **/*.csproj .config/dotnet-tools.json + - name: 安裝 MSBuild(Native Shim) + uses: microsoft/setup-msbuild@v3 + + - name: 還原主專案 NuGet 套件(Native Shim) + run: dotnet restore ./src/InputBox/InputBox.csproj + + - name: 讀取 Microsoft.GameInput 版本(Native Shim) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + [xml]$project = Get-Content -LiteralPath "./src/InputBox/InputBox.csproj" -Raw + $packageRefs = @($project.Project.ItemGroup.PackageReference | Where-Object { $_.Include -eq 'Microsoft.GameInput' }) + + if ($packageRefs.Count -ne 1) { + Write-Error "InputBox.csproj 必須剛好包含一個 Microsoft.GameInput PackageReference;目前找到 $($packageRefs.Count) 個。" + } + + $version = [string]$packageRefs[0].Version + if ([string]::IsNullOrWhiteSpace($version) -or $version -notmatch '^\d+\.\d+\.\d+(?:\.\d+)?$') { + Write-Error "Microsoft.GameInput Version 必須是明確穩定版本:$version" + } + + "GAMEINPUT_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Host "Microsoft.GameInput version: $version" + + - name: 建置 GameInput Native Shim + shell: pwsh + run: | + msbuild .\src\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj ` + /p:Configuration=Release ` + /p:Platform=x64 ` + /p:PlatformToolset=v143 ` + /p:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` + /m + + - name: 驗證 GameInput Native Shim exports / probe / lifecycle + shell: pwsh + run: | + .\tools\Validate-GameInputNativeShim.ps1 ` + -NativeShimPath ".\src\InputBox.GameInput.Native\bin\x64\Release\InputBox.GameInput.Native.dll" ` + -ManagedSourcePath ".\src\InputBox\Core\Input\GameInputNative.cs" + - name: 建置應用程式與測試專案(Build App + Tests) working-directory: ./tests/InputBox.Tests # 測試專案會連同主專案相依一起建置,避免重複編譯浪費資源。 @@ -220,4 +263,4 @@ jobs: name: ui-smoke-artifacts path: TestResults/UiArtifacts if-no-files-found: ignore - retention-days: 5 \ No newline at end of file + retention-days: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a65f77..b89cfca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ concurrency: jobs: build-and-release: - runs-on: ubuntu-latest + runs-on: windows-latest timeout-minutes: 25 permissions: contents: write @@ -30,7 +30,9 @@ jobs: DOTNET_NOLOGO: '1' PUBLISH_DIR: "./src/InputBox/out" EXE_NAME: 'InputBox.exe' + NATIVE_SHIM_NAME: 'InputBox.GameInput.Native.dll' SHA256_HASH: '' + GAMEINPUT_REDIST_SHA256: '' ZIP_FILENAME: '' steps: @@ -70,12 +72,112 @@ jobs: dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' # Release 觸發頻率遠低於 cache TTL(7 天),不快取以避免無效寫入。 + - name: 安裝 MSBuild(Native Shim) + uses: microsoft/setup-msbuild@v3 + + - name: 還原 NuGet 套件 + run: dotnet restore ./src/InputBox/InputBox.csproj + + - name: 讀取 Microsoft.GameInput 版本 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + [xml]$project = Get-Content -LiteralPath "./src/InputBox/InputBox.csproj" -Raw + $packageRefs = @($project.Project.ItemGroup.PackageReference | Where-Object { $_.Include -eq 'Microsoft.GameInput' }) + + if ($packageRefs.Count -ne 1) { + Write-Error "InputBox.csproj 必須剛好包含一個 Microsoft.GameInput PackageReference;目前找到 $($packageRefs.Count) 個。" + } + + $version = [string]$packageRefs[0].Version + if ([string]::IsNullOrWhiteSpace($version)) { + Write-Error "Microsoft.GameInput PackageReference 缺少明確 Version。" + } + + if ($version -notmatch '^\d+\.\d+\.\d+(?:\.\d+)?$') { + Write-Error "Microsoft.GameInput Version 必須是明確穩定版本,不可使用 floating/range/prerelease:$version" + } + + "GAMEINPUT_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Host "Microsoft.GameInput pinned version: $version" + + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { + "Microsoft.GameInput pinned version: ``$version``" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + + - name: 檢查 Microsoft.GameInput 最新穩定版本(非阻擋) + shell: pwsh + run: | + $ErrorActionPreference = 'Continue' + $indexUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.gameinput/index.json" + + try { + $index = Invoke-RestMethod -Uri $indexUrl -ErrorAction Stop + $stableVersions = @( + $index.versions | + Where-Object { $_ -match '^\d+\.\d+\.\d+(?:\.\d+)?$' } | + Sort-Object { [version]$_ } + ) + + if ($stableVersions.Count -eq 0) { + throw "NuGet index 未回傳可解析的 stable 版本。" + } + + $latest = $stableVersions[-1] + Write-Host "Microsoft.GameInput pinned version: $env:GAMEINPUT_VERSION" + Write-Host "Microsoft.GameInput latest stable: $latest" + + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { + @( + '', + '### Microsoft.GameInput 版本資訊', + '', + "- Pinned: ``$env:GAMEINPUT_VERSION``", + "- Latest stable: ``$latest``" + ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + + if ($env:GAMEINPUT_VERSION -ne $latest) { + Write-Warning "Microsoft.GameInput pinned version ($env:GAMEINPUT_VERSION) 不是 NuGet 最新 stable ($latest);本檢查不阻擋發佈。" + } + } catch { + Write-Warning "無法查詢 Microsoft.GameInput 最新 stable 版本;本檢查不阻擋發佈。原因:$($_.Exception.Message)" + + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { + @( + '', + '### Microsoft.GameInput 版本資訊', + '', + "- Pinned: ``$env:GAMEINPUT_VERSION``", + '- Latest stable: 查詢失敗(非阻擋)' + ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + } + + - name: 建置 GameInput Native Shim + shell: pwsh + run: | + msbuild .\src\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj ` + /p:Configuration=Release ` + /p:Platform=x64 ` + /p:PlatformToolset=v143 ` + /p:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` + /m + + - name: 驗證 GameInput Native Shim exports / probe / lifecycle + shell: pwsh + run: | + .\tools\Validate-GameInputNativeShim.ps1 ` + -NativeShimPath ".\src\InputBox.GameInput.Native\bin\x64\Release\InputBox.GameInput.Native.dll" ` + -ManagedSourcePath ".\src\InputBox\Core\Input\GameInputNative.cs" + - name: 執行專案發佈(Publish) working-directory: ./src/InputBox run: > dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true + -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=true -o out @@ -87,7 +189,7 @@ jobs: # -i:指定專案檔路徑。 # -o:指定輸出格式為 Markdown(讓純文字檔內有整齊的表格排版)。 # -fo:指定輸出的檔案名稱(File Output)。 - dotnet tool run nuget-license -i ./src/InputBox/InputBox.csproj -o Markdown -fo ThirdPartyNotices.txt + dotnet tool run nuget-license -i ./src/InputBox/InputBox.csproj -o Markdown -fo ThirdPartyNotices.txt -ignore Microsoft.GameInput if (Test-Path "ThirdPartyNotices.txt") { Write-Host "✅ 成功產生專案依賴套件的 ThirdPartyNotices.txt!" @@ -117,15 +219,17 @@ jobs: $zipName = "InputBox-$tagName-win-x64.zip" "ZIP_FILENAME=$zipName" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - # 1. 建立外層目錄(dist)、內層目錄(dist/InputBox)與授權子目錄(dist/InputBox/Licenses)。 + # 1. 建立外層目錄(dist)、內層目錄(dist/InputBox)、授權子目錄與 redist 子目錄。 $distDir = "./dist" $packDir = Join-Path $distDir "InputBox" $licensesDir = Join-Path $packDir "Licenses" + $redistDir = Join-Path $packDir "redist" if (Test-Path $distDir) { Remove-Item -Recurse -Force $distDir } New-Item -ItemType Directory -Force -Path $packDir | Out-Null New-Item -ItemType Directory -Force -Path $licensesDir | Out-Null + New-Item -ItemType Directory -Force -Path $redistDir | Out-Null # 2. 複製執行檔與專案文件到內層目錄。 # - 執行檔與 README.md 放根目錄。 @@ -273,82 +377,103 @@ jobs: Write-Warning "❌ 無法解析 .NET Runtime 的版本號,請確認安裝步驟。" } - # 4. 動態解析 csproj,抓取套件授權與產生 usb.ids 授權檔案。 - Write-Host "=== 開始解析 csproj 抓取第三方套件版本並處理授權 ===" - - $csprojPath = "./src/InputBox/InputBox.csproj" - if (Test-Path $csprojPath) { - # 將 csproj 轉為 XML 物件以便解析。 - [xml]$csproj = Get-Content $csprojPath + # 4. 打包 Microsoft GameInput redist 與授權檔案。 + Write-Host "=== 開始處理 Microsoft GameInput redist 與授權 ===" + $gameInputPackageDir = Join-Path $env:USERPROFILE ".nuget\packages\microsoft.gameinput\$env:GAMEINPUT_VERSION" + $gameInputRedistSource = Join-Path $gameInputPackageDir "redist\GameInputRedist.msi" + $gameInputLicenseSource = Join-Path $gameInputPackageDir "LICENSE.txt" + $gameInputNoticeSource = Join-Path $gameInputPackageDir "NOTICE.txt" - # 尋找特定的 PackageReference 節點。 - $gameInputRef = $csproj.Project.ItemGroup.PackageReference | Where-Object { $_.Include -eq 'GameInput.Net' } - $usbVendorsRef = $csproj.Project.ItemGroup.PackageReference | Where-Object { $_.Include -eq 'UsbVendorsLibrary' } + if (-not (Test-Path $gameInputRedistSource)) { + Write-Error "找不到 Microsoft.GameInput redist:$gameInputRedistSource" + } - # 如果有找到版本號,前面補上 v;如果沒找到(理論上不會發生),退回抓 main。 - $gameInputVer = if ($gameInputRef.Version) { "v" + $gameInputRef.Version } else { "main" } - $usbVendorsVer = if ($usbVendorsRef.Version) { "v" + $usbVendorsRef.Version } else { "main" } + if (-not (Test-Path $gameInputLicenseSource)) { + Write-Error "找不到 Microsoft.GameInput 授權檔:$gameInputLicenseSource" + } - Write-Host "解析到 GameInput.Net 版本標籤: $gameInputVer" - Write-Host "解析到 UsbVendorsLibrary 版本標籤: $usbVendorsVer" + if (-not (Test-Path $gameInputNoticeSource)) { + Write-Error "找不到 Microsoft.GameInput notice 檔:$gameInputNoticeSource" + } - $extraFiles = @( - @{ Url = "https://raw.githubusercontent.com/Cephy314/GameInputNet/$gameInputVer/LICENSE"; Out = "GameInputNet_LICENSE.txt" }, - @{ Url = "https://raw.githubusercontent.com/Cephy314/UsbVendorsLibrary/$usbVendorsVer/LICENSE"; Out = "UsbVendorsLibrary_LICENSE.txt" } - ) + Copy-Item -Path $gameInputRedistSource -Destination (Join-Path $redistDir "GameInputRedist.msi") + Copy-Item -Path $gameInputLicenseSource -Destination (Join-Path $licensesDir "Microsoft.GameInput_LICENSE.txt") + Copy-Item -Path $gameInputNoticeSource -Destination (Join-Path $licensesDir "Microsoft.GameInput_NOTICE.txt") - foreach ($item in $extraFiles) { - $outPath = Join-Path $licensesDir $item.Out - Write-Host "嘗試下載: $($item.Url)" - try { - Invoke-WebRequest -Uri $item.Url -OutFile $outPath -ErrorAction Stop - Write-Host "✅ 成功儲存為 $($item.Out)" - } catch { - Write-Warning "⚠️ 無法下載 $($item.Url),請確認該套件的 GitHub 庫是否有此版本標籤!" - } - } - } else { - Write-Warning "找不到 csproj 檔案 ($csprojPath),無法動態抓取套件版本。" - } + $redistHash = (Get-FileHash (Join-Path $redistDir "GameInputRedist.msi") -Algorithm SHA256).Hash + "GAMEINPUT_REDIST_SHA256=$redistHash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Host "產生 usb.ids 的歸因聲明檔……" - # 以事實性歸因聲明(factual attribution notice)替代硬編碼的授權條款全文。 - # 理由:UsbVendorsLibrary NuGet 套件所嵌入的 usb.ids 副本已移除原始授權標頭, - # 無法在發佈時提供從來源可追溯的授權文本。直接產生 BSD 全文會造成文件來源 - # 無法稽核的問題。改以聲明形式標示資料來源、官方雙重授權及本專案所採用的條款。 - $attributionText = @( - 'USB ID Database (usb.ids) Attribution Notice', - '=============================================', - '', - 'This release package includes data from the USB ID Database (usb.ids),', - 'used via the UsbVendorsLibrary NuGet package.', - '', - ' Source: http://www.linux-usb.org/usb-ids.html', - ' Maintained: Stephen J. Gowdy ', - ' License: Dual-licensed — GPL-2.0-or-later OR BSD-3-Clause', - ' (as stated at the official source above)', - '', - 'Note: The usb.ids file itself does not contain an embedded license', - 'header or copyright notice. The dual-license terms above are stated', - 'solely on the official project webpage (the Source URL above).', + $redistReadme = @( + 'Microsoft GameInput Redistributable', + '===================================', '', - 'This project redistributes the embedded usb.ids data under the', - 'BSD 3-Clause License, which is one of the two licenses explicitly', - 'stated at the official source listed above.', + "Version: $env:GAMEINPUT_VERSION", + 'Included file: GameInputRedist.msi', + "SHA256: $redistHash", '', - 'For the full text of the BSD 3-Clause License, see:', - ' https://opensource.org/license/bsd-3-clause', + 'InputBox bundles this redistributable for optional manual installation only.', + 'InputBox does not execute this installer automatically and does not require administrator elevation during normal application startup.', '', - 'For the full text of the GNU General Public License (version 2 or later), see:', - ' https://opensource.org/license/GPL-2.0' + 'License: see ../Licenses/Microsoft.GameInput_LICENSE.txt', + 'Third-party notices: see ../Licenses/Microsoft.GameInput_NOTICE.txt' ) -join "`n" - $attributionText | Out-File -FilePath (Join-Path $licensesDir "usb_ids_LICENSE.txt") -Encoding utf8 - Write-Host "✅ 成功建立 usb_ids_LICENSE.txt" + $redistReadme | Out-File -FilePath (Join-Path $redistDir "README.txt") -Encoding utf8 + + if (Test-Path "ThirdPartyNotices.txt") { + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '## Microsoft GameInput Redistributable' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value "- Package: Microsoft.GameInput $env:GAMEINPUT_VERSION" + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value "- Source: https://www.nuget.org/packages/Microsoft.GameInput/$env:GAMEINPUT_VERSION" + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '- Included file: redist/GameInputRedist.msi' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value "- SHA256: $redistHash" + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '- Install policy: bundled for optional manual installation; InputBox does not install it automatically.' + Add-Content -Path "ThirdPartyNotices.txt" -Encoding utf8 -Value '- License: see Microsoft.GameInput_LICENSE.txt and Microsoft.GameInput_NOTICE.txt in this Licenses directory.' + Copy-Item -Path "ThirdPartyNotices.txt" -Destination $licensesDir -Force + } else { + Write-Error "找不到 ThirdPartyNotices.txt,無法加入 Microsoft GameInput redist 聲明。" + } Write-Host "=======================================" - # 5. 執行壓縮。 + # 5. 執行壓縮並驗證 ZIP 內容。 Compress-Archive -Path $packDir -DestinationPath $zipName -CompressionLevel Optimal + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $zipName)) + try { + $entries = $zip.Entries | ForEach-Object { $_.FullName } + $requiredEntries = @( + 'InputBox/InputBox.exe', + 'InputBox/LICENSE', + 'InputBox/README.md', + 'InputBox/Licenses/ThirdPartyNotices.txt', + 'InputBox/Licenses/Microsoft.GameInput_LICENSE.txt', + 'InputBox/Licenses/Microsoft.GameInput_NOTICE.txt', + 'InputBox/redist/GameInputRedist.msi', + 'InputBox/redist/README.txt' + ) + foreach ($entry in $requiredEntries) { + if ($entries -notcontains $entry) { + Write-Error "ZIP 缺少必要項目:$entry" + } + } + + $forbiddenPatterns = @( + 'GameInputNet_LICENSE.txt', + 'UsbVendorsLibrary_LICENSE.txt', + 'usb_ids_LICENSE.txt', + 'gameinput.dll', + '${{ env.NATIVE_SHIM_NAME }}' + ) + foreach ($pattern in $forbiddenPatterns) { + if ($entries | Where-Object { $_ -like "*$pattern" }) { + Write-Error "ZIP 包含禁止項目:$pattern" + } + } + } finally { + $zip.Dispose() + } + - name: 檢查 VirusTotal API Key 設定 id: vt-config shell: pwsh @@ -376,8 +501,12 @@ jobs: - **目標平台**:Windows x64 - **執行環境**:.NET 10(已隨附 Runtime,無需額外安裝) - **執行檔案的雜湊值(SHA256)**:`${{ env.SHA256_HASH }}` + - **GameInput Redist(選用手動安裝)SHA256**:`${{ env.GAMEINPUT_REDIST_SHA256 }}` --- + > [!NOTE] + > 壓縮檔內含 Microsoft GameInput Redistributable (`redist/GameInputRedist.msi`) 供需要者手動安裝;InputBox 不會自動執行該安裝程式,GameInput 不可用時會退避至 XInput。 + > [!NOTE] > 本版本由 GitHub Actions 自動建置並發佈。詳細變更內容請參考下方的自動產生說明。 env: @@ -394,4 +523,4 @@ jobs: sha256: true update_release: true release_heading: "🛡️ **VirusTotal 掃描結果:**" - summary: true \ No newline at end of file + summary: true diff --git a/InputBox.slnx b/InputBox.slnx index 983251f..fc408e6 100644 --- a/InputBox.slnx +++ b/InputBox.slnx @@ -29,6 +29,7 @@ + - \ No newline at end of file + diff --git a/README.md b/README.md index dafb409..9d62a16 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,7 @@ - **XInput**:系統原生支援,無需額外安裝。 - **GameInput**:系統需具備 **GameInput 執行階段**。 - 多數 Windows 11 系統已內建 GameInput 執行階段;若系統未提供或載入失敗,本應用程式會自動退避至 **XInput** 相容模式。 - - **可轉散發元件(選用)**:可透過微軟 [Microsoft.GameInput](https://www.nuget.org/packages/Microsoft.GameInput) NuGet 套件取得。 - - 安裝方式:執行套件 `redist` 目錄下的 `GameInputRedist.msi`。 + - **可轉散發元件(選用)**:正式發佈 ZIP 會隨附微軟 [Microsoft.GameInput](https://www.nuget.org/packages/Microsoft.GameInput) 套件提供的 `redist/GameInputRedist.msi`,供需要者手動安裝;本應用程式不會自動執行該安裝程式。 ## Steam Deck/SteamOS 3 使用說明 🎮 @@ -590,10 +589,6 @@ Remove-Item Env:INPUTBOX_RUN_UI_TESTS -ErrorAction SilentlyContinue - [.NET Runtime](https://github.com/dotnet/runtime):由 [Microsoft](https://github.com/microsoft) 及其 [貢獻者](https://github.com/dotnet/runtime/graphs/contributors) 開發並採用 [**MIT License**](https://github.com/dotnet/runtime/blob/main/LICENSE.TXT) 授權,作為本應用程式之底層執行環境,相關第三方聲明請參閱 [**THIRD-PARTY-NOTICES**](https://github.com/dotnet/runtime/blob/main/THIRD-PARTY-NOTICES.TXT)。 - [Windows Forms(WinForms)](https://github.com/dotnet/winforms):由 [Microsoft](https://github.com/microsoft) 及其 [貢獻者](https://github.com/dotnet/winforms/graphs/contributors) 開發並採用 [**MIT License**](https://github.com/dotnet/winforms/blob/main/LICENSE.TXT) 授權,提供桌面視窗圖形介面基礎架構,相關第三方聲明請參閱 [**THIRD-PARTY-NOTICES**](https://github.com/dotnet/winforms/blob/main/THIRD-PARTY-NOTICES.TXT)。 -- [GameInput.Net](https://github.com/Cephy314/GameInputNet):由 [Cephy314](https://github.com/Cephy314) 及其[貢獻者](https://github.com/Cephy314/GameInputNet/graphs/contributors)開發並採用 [**MIT License**](https://github.com/Cephy314/GameInputNet/blob/main/LICENSE) 授權,用於存取 GameInput API。 -- [UsbVendorsLibrary](https://github.com/Cephy314/UsbVendorsLibrary):由 [Cephy314](https://github.com/Cephy314) 及其[貢獻者](https://github.com/Cephy314/UsbVendorsLibrary/graphs/contributors)開發並採用 [**MIT License**](https://github.com/Cephy314/UsbVendorsLibrary/blob/main/LICENSE) 授權,用於存取裝置資訊。 -- [USB ID Database(usb.ids)](http://www.linux-usb.org/usb-ids.html):由 Stephen J. Gowdy 等人維護的公開 USB 裝置識別碼資料庫。該資料庫採雙重授權([GNU General Public License(第 2 版或是更新的版本)](https://opensource.org/license/GPL-2.0)/[**3-clause BSD License**](https://opensource.org/license/bsd-3-clause))。 - - 本應用程式透過第三方函式庫 [**UsbVendorsLibrary**](https://github.com/Cephy314/UsbVendorsLibrary) 使用該資料庫,並於 **自包含部署(self‑contained)** 發佈之執行檔中以嵌入資源形式包含其內容。 - - 本專案依照官方雙重授權所明示之選項,以 [**3-clause BSD License**](https://opensource.org/license/bsd-3-clause) 條款隨附發佈;歸因聲明請參閱發佈檔 **Licenses/usb_ids_LICENSE.txt**。 +- [Microsoft GameInput Redistributable](https://www.nuget.org/packages/Microsoft.GameInput):由 Microsoft 提供,作為選用的 GameInput 執行階段可轉散發元件。正式發佈 ZIP 會隨附 `redist/GameInputRedist.msi` 供使用者手動安裝;本應用程式不會自動安裝,且該 redist 依 Microsoft.GameInput 授權條款散布,不屬於本專案 CC0 授權範圍。 -本專案的詳細條款與免責聲明,請參閱隨附之 [**LICENSE**](LICENSE) 文件;發佈檔 `Licenses/` 資料夾內含 `ThirdPartyNotices.txt`(NuGet 套件授權聲明清單)及各元件完整授權文字。 +本專案的詳細條款與免責聲明,請參閱隨附之 [**LICENSE**](LICENSE) 文件;發佈檔 `Licenses/` 資料夾內含 `ThirdPartyNotices.txt`(NuGet 套件授權聲明清單)、Microsoft GameInput 授權/Notice,以及各元件完整授權文字。 diff --git a/docs/engineering/gameinput-hardware-verification.md b/docs/engineering/gameinput-hardware-verification.md new file mode 100644 index 0000000..f616c7e --- /dev/null +++ b/docs/engineering/gameinput-hardware-verification.md @@ -0,0 +1,58 @@ +# GameInput 手動硬體驗證矩陣 + +本文件定義 GameInput 相關變更在正式發佈或高風險修改前的手動硬體檢查表。它補足 CI 無法穩定覆蓋的實體控制器、藍牙、Windows GameInput 執行階段與 redist 情境。 + +這不是日常 PR 必跑項目,也不是 CI 關卡。若只是修改文件、測試或不影響 GameInput 硬體行為的程式碼,不需要執行本矩陣。 + +## 執行時機 + +建議在下列情境執行: + +- 修改 `InputBox.GameInput.Native` shim、GameInput 受控端 interop 或 ABI。 +- 修改 GameInput 連線/斷線偵測、重新列舉、callback、輪詢或退避行為。 +- 修改 GameInput rumble 或緊急停止流程。 +- 升級 `Microsoft.GameInput` NuGet 套件。 +- 正式發佈前,作為發佈冒煙驗證的人工補充。 + +## 非目標 + +- 不取代 `dotnet build`、`dotnet test`、native export 驗證或 probe 冒煙測試。 +- 不要求每個 PR 都跑完整硬體矩陣。 +- 不要求維護者購買或常備所有控制器。 +- 不新增 keyboard、mouse、sensors、raw report、force feedback、aggregate device 或 1:1 GameInput wrapper 的驗證範圍。 + +## 驗證前置條件 + +- 使用 Windows 環境與本機 Debug 或 Release 組建。 +- 在 InputBox 設定中手動選擇 GameInput 提供者;預設提供者仍維持 XInput。 +- 保留輸出視窗、`InputBox.log` 或測試者可取得的診斷輸出,方便記錄 GameInput shim 初始化、裝置狀態與退避訊息。 +- 若某項硬體或環境不可取得,結果標記為 `略過`,並記錄原因。 + +## 驗證矩陣 + +| 項目 | 硬體 / 環境 | 操作步驟 | 預期結果 | 發佈必跑 | 可接受缺測 | +|---|---|---|---|---|---| +| Xbox USB | Xbox One / Xbox Series 控制器,以 USB 連線 | 啟動 InputBox,確認 GameInput 已連線;拔除並重接 3 次 | A11y 連線 / 斷線公告與實際狀態一致;不會卡在 connected;重新連線後仍可輸入 | 是 | 若沒有 Xbox 控制器,需以其他 XInput 相容 USB 控制器替代並記錄 | +| Xbox Bluetooth | Xbox One / Xbox Series 控制器,以藍牙連線 | 配對後啟動 InputBox;關閉控制器;重新喚醒或重新連線 | 裝置清單會重新整理;不殘留舊裝置;重新連線後不需重啟應用程式 | 是 | 若測試機沒有藍牙或控制器不支援藍牙,可標記略過 | +| Sony / DualSense | DualSense 或可回報 Sony VID/PID 的 PlayStation 控制器 | 連線後切換 Face 鍵 Auto 模式,確認 GameInput 診斷資訊中的 VID/PID 與配置 | Auto Face Button 解析為 PlayStation 配置;不依賴 USB database 或顯示名稱關鍵字才成功 | 建議 | 若沒有 Sony 控制器,可略過;正式發佈需記錄缺測 | +| Nintendo / Switch Pro | Switch Pro 或可回報 Nintendo VID/PID 的控制器 | 連線後切換 Face 鍵 Auto 模式,確認 GameInput 診斷資訊中的 VID/PID 與配置 | Auto Face Button 解析為 Nintendo 配置;不依賴 USB database 或顯示名稱關鍵字才成功 | 建議 | 若沒有 Nintendo 控制器,可略過;正式發佈需記錄缺測 | +| Elite / Paddle | Xbox Elite 或其他具 paddle / extra controls 的控制器 | 連線後檢查 GameInput capabilities / diagnostics | extra button / axis metadata 可進入診斷;paddle 或 extra controls 不觸發任何新的 InputBox 指令 | 建議 | 若沒有此類控制器,可略過 | +| 執行階段缺失 / Shim 載入失敗 | 未安裝 GameInput redist 的環境,或暫時遮蔽 native shim 的測試環境 | 啟動 InputBox 並手動選擇 GameInput 提供者 | GameInput 初始化失敗會公告並退避 XInput;沒有 crash;probe / log 能指出載入或初始化失敗原因 | 是 | 若測試機已有系統 GameInput 且無法安全遮蔽,需記錄未測原因 | +| Release ZIP | Release workflow 產物或本機發佈試跑 ZIP | 解壓縮並檢查 ZIP 結構,啟動 `InputBox.exe` 做 GameInput 冒煙驗證 | ZIP 不包含 `gameinput.dll` 或可見 `InputBox.GameInput.Native.dll` sidecar;包含 `redist/GameInputRedist.msi` 與第三方授權聲明 | 是 | 不可缺測 | + +## 結果紀錄格式 + +建議在發佈說明、PR 描述或內部測試紀錄中使用下列格式: + +```text +GameInput hardware verification: +- Xbox USB: 通過 (Xbox Series Controller, USB-C) +- Xbox Bluetooth: 通過 (Xbox Series Controller, Bluetooth) +- Sony / DualSense: 略過 (無可用硬體) +- Nintendo / Switch Pro: 略過 (無可用硬體) +- Elite / Paddle: 略過 (無可用硬體) +- 執行階段缺失 / Shim 載入失敗: 通過 (退避 XInput) +- Release ZIP: 通過 +``` + +若任一發佈必跑項目失敗,應先修正或明確延後發佈;若標記略過,必須說明原因與替代驗證。 diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md index 6721ef8..86e7ced 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -19,6 +19,18 @@ ## 2. API 選擇與退避機制 (Provider & Backoff) - **預設提供者**:應用程式之預設控制器提供者應設定為相容性最高之 **XInput**。 - **自動退避**:當使用者手動設定使用 `GameInput` 但初始化失敗(如系統不支援)時,系統必須**自動退避至 XInput** 並透過 `AnnounceA11y` 告知使用者。 +- **GameInput 實作邊界**:GameInput 必須透過專案自有 `InputBox.GameInput.Native` shim 與專案內部 C# 型別存取 Microsoft GameInput runtime;不得重新引入已封存的第三方 `GameInput.Net`、`GameInputDotNet`、`UsbVendorsLibrary` 或 `usb.ids` 流程。 +- **GameInput shim 同版發布**:Native shim 與 managed GameInput layer 視為同版、同包、一起發布的內部實作;更新 C ABI 時直接同步兩端 struct,不維護外部相容層或 `V2` suffix。若 shim 載入失敗或 ABI 不符,應走既有 GameInput 初始化失敗並退避 XInput 路徑。 +- **GameInput shim ABI 防呆**:shim 必須在 `InputBoxGameInputGetShimInfo` 回報 native ABI number、`GAMEINPUT_API_VERSION`、pointer size 與所有跨邊界 struct size;managed layer 必須以 `Marshal.SizeOf()` 驗證尺寸,不符即視為 shim 載錯並退避 XInput。 +- **GameInput shim 發佈驗證**:CI 與 release 必須在 native shim 建置後比對 `GameInputNativeMethods` 宣告的 `InputBoxGameInput*` EntryPoint 與 DLL exports,並執行 `InputBoxGameInputProbeRuntime` smoke test 與 native lifecycle stress smoke;缺 export、probe crash 或 native/managed struct size 不符時不得產生發佈 ZIP。若 GameInput runtime 不可用,lifecycle stress 可略過,但 export/probe 守門不可略過。CI 的 code filter 必須包含 `tools/**`,避免驗證腳本異動未觸發守門。 +- **GameInput 手動硬體驗證**:自動驗證通過後,若正式發佈或變更內容涉及 GameInput 硬體行為,仍應依 `docs/engineering/gameinput-hardware-verification.md` 抽測實體控制器。此矩陣不是 CI 關卡,也不是每個 PR 的必跑項目。 +- **GameInput runtime probe**:shim 必須提供建立 context 前可呼叫的 runtime probe,回報 LoadLibrary、GetProcAddress、GameInputInitialize 的 HRESULT / Win32 error、嘗試與實際載入的 module kind/path,以及字串截斷旗標;此資訊只供 log 與測試,不可影響按鍵語意。 +- **GameInput DLL 載入安全**:系統 GameInput runtime 只能使用 System32 搜尋範圍;registry redist 絕對路徑必須搭配 `LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32`,並記錄實際載入來源。不得從工作目錄、目前目錄或未限定搜尋路徑載入 `gameinput.dll`。 +- **GameInput native context 同步**:native shim 必須以 SRW lock 保護 `devices`、`callbacks`、診斷 counter,以及 refresh/read/unregister/destroy 等會交錯的路徑;callback 只能複製 POD event 或設定喚醒訊號,不得持有 context lock 呼叫 managed callback。 +- **GameInput 診斷 metadata 邊界**:string truncation flags、timestamp stale counters、missing reading counters、device zombie refresh counters 只能進入 log、測試或未來診斷快照,不可直接改變 edge detection、Pause/Resume neutral gate 或任何 UI 命令。 +- **GameInput subset 範圍**:目前只實作 InputBox 需要的 Gamepad subset:裝置資訊、VID/PID、timestamp、gamepad button state、rumble、device/reading callbacks,以及 gamepad capabilities / extra control metadata。不得在未重新評估安全與產品需求前擴張到 keyboard、mouse、sensors、raw report、force feedback、aggregate device 或 1:1 GameInput wrapper。 +- **Callback 使用邊界**:GameInput 的 `RegisterDeviceCallback` / `RegisterReadingCallback` 僅可用於要求背景 MTA polling thread 重新整理或喚醒診斷路徑;正式輸入命令仍必須由 60 FPS polling 消費,callback 不得直接觸發 UI、剪貼簿、返回前景或任何輸入動作。 +- **GameInput runtime/redist 政策**:`Microsoft.GameInput` NuGet 僅作為官方 SDK、header 與 redist 來源。發佈 ZIP 可隨附 `redist/GameInputRedist.msi` 供使用者手動安裝,但應用程式不得自動執行安裝程式,也不得要求一般啟動流程取得系統管理員權限。 - **Auto 模式解析優先序**:當使用者選擇 Face 鍵 `Auto` 模式且目前提供者為 **GameInput** 時,必須先使用較穩定的硬體識別資訊(例如 VID/PID、產品家族名稱)判斷裝置類型,再退回裝置名稱關鍵字比對;像 `Wireless`、`Bluetooth`、`Adapter`、`Receiver` 等連線詞應視為雜訊而忽略。 - **Auto 模式預設對照**:在上述自動判斷下,Sony / PlayStation 裝置預設應解析為 **PlayStation(× 確認)**,Nintendo 裝置解析為 Nintendo 配置,其餘則回到 Xbox 配置;若使用者手動指定模式,必須優先於自動判斷。 - **緊急停止**:程式結束前必須執行 `EmergencyStopAllActiveControllers()` 強制停止所有控制器馬達。 @@ -57,13 +69,13 @@ XInput 以 `short` 範圍 (±32767) 運作;GameInput 以 float `[-1.0, 1.0]` - 裝置連線後立即以第一幀快照重複執行 **50 次** EMA,使 bias 在第一幀即收斂至約 99%,避免連線初期因 bias ≈ 0 造成方向誤判。 - 暖機時固定傳入 `isDPadActive: false`。 -### 4.5 右向映射保護(各後端可採不同機制,效果須等效) +### 4.5 方向映射統一規範(硬體平等原則) -右向映射較易受到正偏噪聲影響(尤其筆電環境),各後端須各自防止以下場景: -「DPad-Right 方向觸發後,左搖桿殘餘偏移立即重觸發右向」 +兩個後端 (XInput / GameInput) 必須統一採用 `GamepadDeadzoneHysteresis.ResolveDirection`,以對稱 `Enter / Exit` 閾值處理四個方向(左、右、上、下),**不得**為單一方向(例如右向)實作差別補償(如非對稱退出閾值、單向抑制旗標)。 -- **XInput**:以 `_suppressMappedRightFromLeftStick` 旗標實作,重置方向狀態時若上一方向為 Right,則啟動抑制,直到左搖桿回到中立區。另配合非對稱退出閾值 `max(exit, enter × 0.75f)` 提高黏滯防護。 -- **GameInput**:以 `ResetDirectionalRepeatState` 清除 `_previousProcessedButtons` 的 DPad 位元,強制下次觸發須重新穿越 Enter 閾值,配合硬體校準輸出達到等效保護。 +- **與業界黃金標準對齊**:Steam Input、DS4Windows、Unity InputSystem 等主流方案均提供 per-stick 或 per-axis 對稱 deadzone,不採 per-direction 差別補償;本專案遵循相同原則,避免按鍵映射行為因「特定硬體」(例如筆電觸控板控制器的正偏噪聲)而產生不可預測的方向差異。 +- **硬體漂移處方**:若特定硬體出現方向 sticky 或漂移現象,正確做法是請使用者(對稱地)提高 `ThumbDeadzoneEnter / ThumbDeadzoneExit` 設定值,並交由 §4.1–§4.4 的 EMA bias 學習機制與 §4.3 的 D-Pad 機械耦合閘門共同補償;**禁止**在按鍵映射程式碼路徑中為單一方向加入差別補償。 +- **GameInput 架構等效保護**:GameInput 使用 `_previousProcessedButtons` 進行 D-Pad 邊緣偵測,因此 `ResetDirectionalRepeatState` 必須清除 `_previousProcessedButtons` 的 D-Pad 位元,強制下次觸發須重新穿越 `Enter` 閾值;這是 GameInput 邊緣偵測架構的需求,而非右向強化,與 XInput 透過 `previousState.Has(...)` 直接讀取硬體狀態的邊緣偵測方式相對應。 ## 5. Dialog 層級控制器整合 - **ConnectionChanged 強制訂閱**:任何持有或使用 `IGamepadController` 的 Dialog(對話框),在 `BindGamepadEvents` 中**必須**同時訂閱 `ConnectionChanged`,並在 `UnbindGamepadEvents` 中解除。 diff --git a/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj b/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj new file mode 100644 index 0000000..42e8a17 --- /dev/null +++ b/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj @@ -0,0 +1,88 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 17.0 + Win32Proj + {3E85C875-5E9D-4C7D-9B87-0D5B3EB3DA53} + InputBoxGameInputNative + 10.0 + 3.4.218 + $(UserProfile)\.nuget\packages\ + $(NuGetPackageRoot)microsoft.gameinput\$(GameInputPackageVersion)\ + + + + DynamicLibrary + true + v145 + Unicode + + + DynamicLibrary + false + v145 + true + Unicode + + + + $(MSBuildProjectDirectory)\bin\$(Platform)\$(Configuration)\ + $(MSBuildProjectDirectory)\obj\$(Platform)\$(Configuration)\ + InputBox.GameInput.Native + + + + Level4 + true + WIN32;_WINDOWS;_USRDLL;INPUTBOX_GAMEINPUT_NATIVE_EXPORTS;%(PreprocessorDefinitions) + true + stdcpp17 + ProgramDatabase + $(IntDir)$(TargetName).compile.pdb + true + $(GameInputPackageRoot)native\include;%(AdditionalIncludeDirectories) + /utf-8 %(AdditionalOptions) + + + Windows + Advapi32.lib;%(AdditionalDependencies) + + + + + Level4 + true + true + true + WIN32;NDEBUG;_WINDOWS;_USRDLL;INPUTBOX_GAMEINPUT_NATIVE_EXPORTS;%(PreprocessorDefinitions) + true + stdcpp17 + ProgramDatabase + $(IntDir)$(TargetName).compile.pdb + true + $(GameInputPackageRoot)native\include;%(AdditionalIncludeDirectories) + /utf-8 %(AdditionalOptions) + + + Windows + true + true + Advapi32.lib;%(AdditionalDependencies) + + + + + + + + diff --git a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp new file mode 100644 index 0000000..71439d0 --- /dev/null +++ b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp @@ -0,0 +1,1957 @@ +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif + +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; +using namespace GameInput::v3; + +namespace +{ + const HRESULT InputBoxGameInputNoReading = HRESULT_FROM_WIN32(ERROR_NOT_FOUND); + constexpr uint32_t InputBoxGameInputShimAbiVersion = 3; + constexpr uint32_t InputBoxGameInputMaxExtraControlIndexes = 32; + + enum InputBoxGameInputModuleKind : uint32_t + { + InputBoxGameInputModuleUnknown = 0, + InputBoxGameInputModuleSystemGameInput = 1, + InputBoxGameInputModuleSystemGameInputRedist = 2, + InputBoxGameInputModuleRegistryGameInputRedist = 3 + }; + + enum InputBoxGameInputStringTruncationFlags : uint32_t + { + InputBoxGameInputStringTruncatedNone = 0x00000000, + InputBoxGameInputStringTruncatedDeviceId = 0x00000001, + InputBoxGameInputStringTruncatedDeviceRootId = 0x00000002, + InputBoxGameInputStringTruncatedContainerId = 0x00000004, + InputBoxGameInputStringTruncatedDisplayName = 0x00000008, + InputBoxGameInputStringTruncatedPnpPath = 0x00000010, + InputBoxGameInputStringTruncatedAttemptedModulePath = 0x00000020, + InputBoxGameInputStringTruncatedLoadedModulePath = 0x00000040 + }; + + struct InputBoxGameInputVersion + { + uint16_t major; + uint16_t minor; + uint16_t build; + uint16_t revision; + }; + + // 下列 C ABI 結構必須與 GameInputPrimitives.cs 的受控端結構保持版面相容。 + // 欄位異動時,請同步更新 InputBoxGameInputShimAbiVersion 與受控端大小檢查。 + struct InputBoxGameInputShimInfo + { + uint32_t abiVersion; + uint32_t gameInputApiVersion; + uint32_t pointerSize; + uint32_t shimInfoSize; + uint32_t runtimeProbeInfoSize; + uint32_t deviceInfoSize; + uint32_t gamepadStateSize; + uint32_t diagnosticsSnapshotSize; + uint32_t loadedModuleKind; + char loadedModulePath[512]; + }; + + struct InputBoxGameInputRuntimeProbeInfo + { + uint32_t abiVersion; + uint32_t gameInputApiVersion; + uint32_t pointerSize; + uint32_t shimInfoSize; + uint32_t runtimeProbeInfoSize; + uint32_t deviceInfoSize; + uint32_t gamepadStateSize; + uint32_t diagnosticsSnapshotSize; + uint32_t attemptedModuleKind; + uint32_t loadedModuleKind; + int32_t loadLibraryHResult; + int32_t getProcAddressHResult; + int32_t initializeHResult; + int32_t finalHResult; + uint32_t loadLibraryWin32Error; + uint32_t getProcAddressWin32Error; + uint32_t initializeWin32Error; + uint32_t stringTruncationFlags; + char attemptedModulePath[512]; + char loadedModulePath[512]; + }; + + struct InputBoxGameInputDeviceInfo + { + uint16_t vendorId; + uint16_t productId; + uint16_t revisionNumber; + uint16_t usagePage; + uint16_t usageId; + uint16_t reserved; + uint32_t deviceFamily; + uint32_t supportedInput; + uint32_t supportedRumbleMotors; + uint32_t supportedSystemButtons; + uint32_t gamepadSupportedLayout; + uint32_t gamepadExtraButtonCount; + uint32_t gamepadExtraAxisCount; + uint32_t forceFeedbackMotorCount; + uint32_t inputReportCount; + uint32_t outputReportCount; + uint32_t extraButtonCount; + uint32_t extraAxisCount; + uint32_t extraButtonIndexCount; + uint32_t extraAxisIndexCount; + uint32_t hasInputMapper; + uint32_t stringTruncationFlags; + InputBoxGameInputVersion hardwareVersion; + InputBoxGameInputVersion firmwareVersion; + uint8_t extraButtonIndexes[InputBoxGameInputMaxExtraControlIndexes]; + uint8_t extraAxisIndexes[InputBoxGameInputMaxExtraControlIndexes]; + char deviceId[65]; + char deviceRootId[65]; + char containerId[39]; + char displayName[256]; + char pnpPath[512]; + }; + + struct InputBoxGameInputGamepadState + { + uint64_t timestamp; + uint32_t inputKind; + uint32_t buttons; + float leftTrigger; + float rightTrigger; + float leftThumbstickX; + float leftThumbstickY; + float rightThumbstickX; + float rightThumbstickY; + }; + + struct InputBoxGameInputDiagnosticsSnapshot + { + uint64_t missingReadingCount; + uint64_t repeatedTimestampCount; + uint64_t backwardTimestampCount; + uint64_t deviceUnavailableRefreshCount; + uint64_t lastReadingTimestamp; + int32_t lastReadHResult; + uint32_t lastReadDeviceStatus; + uint32_t reserved; + }; + + struct DeviceEntry + { + ComPtr device; + InputBoxGameInputDeviceInfo info{}; + }; + + using InputBoxGameInputReadingCallback = void(__stdcall*)( + void* context, + const InputBoxGameInputGamepadState* state); + + using InputBoxGameInputDeviceCallback = void(__stdcall*)( + void* context, + const char* deviceId, + uint64_t timestamp, + uint32_t currentStatus, + uint32_t previousStatus); + + struct CallbackRegistration + { + GameInputCallbackToken token = 0; + InputBoxGameInputReadingCallback readingCallback = nullptr; + InputBoxGameInputDeviceCallback deviceCallback = nullptr; + void* callbackContext = nullptr; + std::atomic active = true; + }; + + struct InputBoxGameInputContext + { + HMODULE module = nullptr; + uint32_t moduleKind = InputBoxGameInputModuleUnknown; + char modulePath[512]{}; + ComPtr gameInput; + std::vector devices; + std::vector> callbacks; + // 保護 GameInput COM 存取,以及銷毀期間的裝置、回呼與診斷狀態。 + // 持有此鎖時不得呼叫受控端回呼。 + SRWLOCK lock = SRWLOCK_INIT; + uint64_t lastReadingTimestamp = 0; + uint64_t missingReadingCount = 0; + uint64_t repeatedTimestampCount = 0; + uint64_t backwardTimestampCount = 0; + uint64_t deviceUnavailableRefreshCount = 0; + int32_t lastReadHResult = S_OK; + uint32_t lastReadDeviceStatus = 0; + }; + + using GameInputInitializeFn = HRESULT(WINAPI*)( + _In_ REFIID riid, + _COM_Outptr_ LPVOID* ppv); + + class SharedContextLock + { + public: + explicit SharedContextLock(SRWLOCK& lock) noexcept + : _lock(lock) + { + AcquireSRWLockShared(&_lock); + } + + SharedContextLock(const SharedContextLock&) = delete; + SharedContextLock& operator=(const SharedContextLock&) = delete; + + ~SharedContextLock() noexcept + { + ReleaseSRWLockShared(&_lock); + } + + private: + SRWLOCK& _lock; + }; + + class ExclusiveContextLock + { + public: + explicit ExclusiveContextLock(SRWLOCK& lock) noexcept + : _lock(lock) + { + AcquireSRWLockExclusive(&_lock); + } + + ExclusiveContextLock(const ExclusiveContextLock&) = delete; + ExclusiveContextLock& operator=(const ExclusiveContextLock&) = delete; + + ~ExclusiveContextLock() noexcept + { + ReleaseSRWLockExclusive(&_lock); + } + + private: + SRWLOCK& _lock; + }; + + bool CopyUtf8( + char* destination, + size_t destinationLength, + const char* value) noexcept + { + if (destinationLength == 0) + { + return false; + } + + const char* source = value != nullptr ? value : ""; + bool truncated = strlen(source) >= destinationLength; + strncpy_s(destination, destinationLength, source, _TRUNCATE); + + return truncated; + } + + bool CopyWideAsUtf8( + char* destination, + size_t destinationLength, + const wchar_t* value) noexcept + { + if (destinationLength == 0) + { + return false; + } + + destination[0] = '\0'; + + if (value == nullptr || + value[0] == L'\0') + { + return false; + } + + int required = WideCharToMultiByte( + CP_UTF8, + 0, + value, + -1, + nullptr, + 0, + nullptr, + nullptr); + + if (required <= 0) + { + return false; + } + + std::vector converted(static_cast(required)); + int written = WideCharToMultiByte( + CP_UTF8, + 0, + value, + -1, + converted.data(), + required, + nullptr, + nullptr); + + return written > 0 && + CopyUtf8(destination, destinationLength, converted.data()); + } + + bool CopyDeviceId( + char* destination, + size_t destinationLength, + const APP_LOCAL_DEVICE_ID& deviceId) noexcept + { + if (destinationLength < 2) + { + return true; + } + + destination[0] = '\0'; + + const auto* bytes = reinterpret_cast(&deviceId); + const size_t byteCount = std::min(sizeof(APP_LOCAL_DEVICE_ID), (destinationLength - 1) / 2); + + for (size_t i = 0; i < byteCount; i++) + { + sprintf_s(destination + (i * 2), destinationLength - (i * 2), "%02X", bytes[i]); + } + + return byteCount < sizeof(APP_LOCAL_DEVICE_ID); + } + + bool CopyGuid( + char* destination, + size_t destinationLength, + const GUID& value) noexcept + { + wchar_t buffer[39]{}; + int written = StringFromGUID2(value, buffer, static_cast(std::size(buffer))); + + if (written <= 0) + { + return CopyUtf8(destination, destinationLength, ""); + } + + return CopyWideAsUtf8(destination, destinationLength, buffer); + } + + void FillAbiSizes( + uint32_t& pointerSize, + uint32_t& shimInfoSize, + uint32_t& runtimeProbeInfoSize, + uint32_t& deviceInfoSize, + uint32_t& gamepadStateSize, + uint32_t& diagnosticsSnapshotSize) noexcept + { + pointerSize = static_cast(sizeof(void*)); + shimInfoSize = static_cast(sizeof(InputBoxGameInputShimInfo)); + runtimeProbeInfoSize = static_cast(sizeof(InputBoxGameInputRuntimeProbeInfo)); + deviceInfoSize = static_cast(sizeof(InputBoxGameInputDeviceInfo)); + gamepadStateSize = static_cast(sizeof(InputBoxGameInputGamepadState)); + diagnosticsSnapshotSize = static_cast(sizeof(InputBoxGameInputDiagnosticsSnapshot)); + } + + void FillShimInfoCommon(InputBoxGameInputShimInfo* info) noexcept + { + info->abiVersion = InputBoxGameInputShimAbiVersion; + info->gameInputApiVersion = GAMEINPUT_API_VERSION; + FillAbiSizes( + info->pointerSize, + info->shimInfoSize, + info->runtimeProbeInfoSize, + info->deviceInfoSize, + info->gamepadStateSize, + info->diagnosticsSnapshotSize); + } + + void FillRuntimeProbeCommon(InputBoxGameInputRuntimeProbeInfo* info) noexcept + { + info->abiVersion = InputBoxGameInputShimAbiVersion; + info->gameInputApiVersion = GAMEINPUT_API_VERSION; + FillAbiSizes( + info->pointerSize, + info->shimInfoSize, + info->runtimeProbeInfoSize, + info->deviceInfoSize, + info->gamepadStateSize, + info->diagnosticsSnapshotSize); + info->loadLibraryHResult = E_FAIL; + info->getProcAddressHResult = E_FAIL; + info->initializeHResult = E_FAIL; + info->finalHResult = E_FAIL; + } + + InputBoxGameInputVersion ToInputBoxVersion(const GameInputVersion& version) noexcept + { + return InputBoxGameInputVersion + { + version.major, + version.minor, + version.build, + version.revision + }; + } + + void FillGamepadState( + IGameInputReading* reading, + const GameInputGamepadState& source, + InputBoxGameInputGamepadState* destination) noexcept + { + destination->timestamp = reading != nullptr ? reading->GetTimestamp() : 0; + destination->inputKind = reading != nullptr ? static_cast(reading->GetInputKind()) : 0; + destination->buttons = static_cast(source.buttons); + destination->leftTrigger = source.leftTrigger; + destination->rightTrigger = source.rightTrigger; + destination->leftThumbstickX = source.leftThumbstickX; + destination->leftThumbstickY = source.leftThumbstickY; + destination->rightThumbstickX = source.rightThumbstickX; + destination->rightThumbstickY = source.rightThumbstickY; + } + + HRESULT FillDeviceInfo( + IGameInputDevice* device, + InputBoxGameInputDeviceInfo* destination) noexcept + { + if (device == nullptr || + destination == nullptr) + { + return E_INVALIDARG; + } + + const GameInputDeviceInfo* info = nullptr; + HRESULT hr = device->GetDeviceInfo(&info); + + if (FAILED(hr)) + { + return hr; + } + + *destination = {}; + + destination->vendorId = info->vendorId; + destination->productId = info->productId; + destination->revisionNumber = info->revisionNumber; + destination->usagePage = info->usage.page; + destination->usageId = info->usage.id; + destination->deviceFamily = static_cast(info->deviceFamily); + destination->supportedInput = static_cast(info->supportedInput); + destination->supportedRumbleMotors = static_cast(info->supportedRumbleMotors); + destination->supportedSystemButtons = static_cast(info->supportedSystemButtons); + destination->forceFeedbackMotorCount = info->forceFeedbackMotorCount; + destination->inputReportCount = info->inputReportCount; + destination->outputReportCount = info->outputReportCount; + destination->hardwareVersion = ToInputBoxVersion(info->hardwareVersion); + destination->firmwareVersion = ToInputBoxVersion(info->firmwareVersion); + + if (CopyDeviceId(destination->deviceId, sizeof(destination->deviceId), info->deviceId)) + { + destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedDeviceId; + } + + if (CopyDeviceId(destination->deviceRootId, sizeof(destination->deviceRootId), info->deviceRootId)) + { + destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedDeviceRootId; + } + + if (CopyGuid(destination->containerId, sizeof(destination->containerId), info->containerId)) + { + destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedContainerId; + } + + if (CopyUtf8(destination->displayName, sizeof(destination->displayName), info->displayName)) + { + destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedDisplayName; + } + + if (CopyUtf8(destination->pnpPath, sizeof(destination->pnpPath), info->pnpPath)) + { + destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedPnpPath; + } + + if (info->gamepadInfo != nullptr) + { + destination->gamepadSupportedLayout = static_cast(info->gamepadInfo->supportedLayout); + destination->gamepadExtraButtonCount = info->gamepadInfo->extraButtonCount; + destination->gamepadExtraAxisCount = info->gamepadInfo->extraAxisCount; + } + + ComPtr mapper; + if (SUCCEEDED(device->CreateInputMapper(&mapper)) && + mapper != nullptr) + { + destination->hasInputMapper = 1; + } + + uint32_t extraButtonCount = 0; + if (SUCCEEDED(device->GetExtraButtonCount(GameInputKindGamepad, &extraButtonCount))) + { + destination->extraButtonCount = extraButtonCount; + destination->extraButtonIndexCount = std::min(extraButtonCount, InputBoxGameInputMaxExtraControlIndexes); + + if (destination->extraButtonIndexCount > 0) + { + std::vector indexes(extraButtonCount); + + if (SUCCEEDED(device->GetExtraButtonIndexes(GameInputKindGamepad, extraButtonCount, indexes.data()))) + { + std::copy_n(indexes.data(), destination->extraButtonIndexCount, destination->extraButtonIndexes); + } + else + { + destination->extraButtonIndexCount = 0; + } + } + } + + uint32_t extraAxisCount = 0; + if (SUCCEEDED(device->GetExtraAxisCount(GameInputKindGamepad, &extraAxisCount))) + { + destination->extraAxisCount = extraAxisCount; + destination->extraAxisIndexCount = std::min(extraAxisCount, InputBoxGameInputMaxExtraControlIndexes); + + if (destination->extraAxisIndexCount > 0) + { + std::vector indexes(extraAxisCount); + + if (SUCCEEDED(device->GetExtraAxisIndexes(GameInputKindGamepad, extraAxisCount, indexes.data()))) + { + std::copy_n(indexes.data(), destination->extraAxisIndexCount, destination->extraAxisIndexes); + } + else + { + destination->extraAxisIndexCount = 0; + } + } + } + + return S_OK; + } + + HRESULT TryLoadGameInputModule( + const wchar_t* path, + DWORD flags, + HMODULE* module, + GameInputInitializeFn* initialize, + HRESULT* loadLibraryHResult = nullptr, + DWORD* loadLibraryWin32Error = nullptr, + HRESULT* getProcAddressHResult = nullptr, + DWORD* getProcAddressWin32Error = nullptr) noexcept + { + *module = nullptr; + *initialize = nullptr; + if (loadLibraryHResult != nullptr) + { + *loadLibraryHResult = E_FAIL; + } + + if (loadLibraryWin32Error != nullptr) + { + *loadLibraryWin32Error = ERROR_SUCCESS; + } + + if (getProcAddressHResult != nullptr) + { + *getProcAddressHResult = E_FAIL; + } + + if (getProcAddressWin32Error != nullptr) + { + *getProcAddressWin32Error = ERROR_SUCCESS; + } + + HMODULE candidate = LoadLibraryExW(path, nullptr, flags); + + if (candidate == nullptr) + { + DWORD error = GetLastError(); + HRESULT hr = HRESULT_FROM_WIN32(error); + + if (loadLibraryHResult != nullptr) + { + *loadLibraryHResult = hr; + } + + if (loadLibraryWin32Error != nullptr) + { + *loadLibraryWin32Error = error; + } + + return hr; + } + + if (loadLibraryHResult != nullptr) + { + *loadLibraryHResult = S_OK; + } + + FARPROC proc = GetProcAddress(candidate, "GameInputInitialize"); + + if (proc == nullptr) + { + DWORD error = GetLastError(); + HRESULT hr = HRESULT_FROM_WIN32(error); + FreeLibrary(candidate); + + if (getProcAddressHResult != nullptr) + { + *getProcAddressHResult = hr; + } + + if (getProcAddressWin32Error != nullptr) + { + *getProcAddressWin32Error = error; + } + + return hr; + } + + if (getProcAddressHResult != nullptr) + { + *getProcAddressHResult = S_OK; + } + + *module = candidate; + *initialize = reinterpret_cast(proc); + + return S_OK; + } + + HRESULT TryGetRedistPathFromRegistry(std::wstring* path) noexcept + { + if (path == nullptr) + { + return E_INVALIDARG; + } + + path->clear(); + + std::array redistDir{}; + DWORD redistDirSize = static_cast(redistDir.size() * sizeof(wchar_t)); + + LSTATUS status = RegGetValueW( + HKEY_LOCAL_MACHINE, + L"SOFTWARE\\Microsoft\\GameInput", + L"RedistDir", + RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY, + nullptr, + redistDir.data(), + &redistDirSize); + + if (status != ERROR_SUCCESS) + { + return HRESULT_FROM_WIN32(status); + } + + *path = redistDir.data(); + + if (!path->empty() && + path->back() != L'\\') + { + *path += L'\\'; + } + + *path += L"GameInputRedist.dll"; + + return S_OK; + } + + HRESULT TryLoadGameInputCandidate( + const wchar_t* path, + DWORD flags, + uint32_t moduleKind, + HMODULE* module, + GameInputInitializeFn* initialize, + InputBoxGameInputRuntimeProbeInfo* probe) noexcept + { + // 保留每個候選載入嘗試的診斷資訊。GameInput 退避到 XInput 時, + // 這些欄位是判斷 DLL 缺失、export 缺失或初始化失敗的主要線索。 + if (probe != nullptr) + { + probe->attemptedModuleKind = moduleKind; + if (CopyWideAsUtf8(probe->attemptedModulePath, sizeof(probe->attemptedModulePath), path)) + { + probe->stringTruncationFlags |= InputBoxGameInputStringTruncatedAttemptedModulePath; + } + } + + HRESULT loadHr = E_FAIL; + HRESULT procHr = E_FAIL; + DWORD loadError = ERROR_SUCCESS; + DWORD procError = ERROR_SUCCESS; + HRESULT hr = TryLoadGameInputModule( + path, + flags, + module, + initialize, + &loadHr, + &loadError, + &procHr, + &procError); + + if (probe != nullptr) + { + probe->loadLibraryHResult = loadHr; + probe->loadLibraryWin32Error = loadError; + probe->getProcAddressHResult = procHr; + probe->getProcAddressWin32Error = procError; + + if (SUCCEEDED(hr)) + { + probe->loadedModuleKind = moduleKind; + + std::array loadedPath{}; + DWORD pathLength = GetModuleFileNameW(*module, loadedPath.data(), static_cast(loadedPath.size())); + + if (pathLength > 0 && + CopyWideAsUtf8(probe->loadedModulePath, sizeof(probe->loadedModulePath), loadedPath.data())) + { + probe->stringTruncationFlags |= InputBoxGameInputStringTruncatedLoadedModulePath; + } + } + } + + return hr; + } + + HRESULT LoadGameInput( + HMODULE* module, + uint32_t* moduleKind, + char* modulePath, + size_t modulePathLength, + IGameInput** gameInput, + InputBoxGameInputRuntimeProbeInfo* probe = nullptr) noexcept + { + if (probe != nullptr) + { + *probe = {}; + FillRuntimeProbeCommon(probe); + } + + *module = nullptr; + *moduleKind = InputBoxGameInputModuleUnknown; + CopyUtf8(modulePath, modulePathLength, ""); + *gameInput = nullptr; + + GameInputInitializeFn initialize = nullptr; + // 優先使用 System32 內的系統 GameInput。登錄檔 redist 路徑排在最後, + // 並使用 DLL_LOAD_DIR + SYSTEM32,避免相依 DLL 從目前工作目錄解析。 + HRESULT hr = TryLoadGameInputCandidate( + L"GameInput.dll", + LOAD_LIBRARY_SEARCH_SYSTEM32, + InputBoxGameInputModuleSystemGameInput, + module, + &initialize, + probe); + + if (SUCCEEDED(hr)) + { + *moduleKind = InputBoxGameInputModuleSystemGameInput; + } + + if (FAILED(hr)) + { + hr = TryLoadGameInputCandidate( + L"GameInputRedist.dll", + LOAD_LIBRARY_SEARCH_SYSTEM32, + InputBoxGameInputModuleSystemGameInputRedist, + module, + &initialize, + probe); + + if (SUCCEEDED(hr)) + { + *moduleKind = InputBoxGameInputModuleSystemGameInputRedist; + } + } + + if (FAILED(hr)) + { + std::wstring redistPath; + HRESULT pathHr = TryGetRedistPathFromRegistry(&redistPath); + + if (SUCCEEDED(pathHr)) + { + hr = TryLoadGameInputCandidate( + redistPath.c_str(), + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32, + InputBoxGameInputModuleRegistryGameInputRedist, + module, + &initialize, + probe); + + if (SUCCEEDED(hr)) + { + *moduleKind = InputBoxGameInputModuleRegistryGameInputRedist; + } + } + else + { + hr = pathHr; + + if (probe != nullptr) + { + probe->attemptedModuleKind = InputBoxGameInputModuleRegistryGameInputRedist; + probe->loadLibraryHResult = pathHr; + probe->loadLibraryWin32Error = HRESULT_FACILITY(pathHr) == FACILITY_WIN32 + ? HRESULT_CODE(pathHr) + : ERROR_SUCCESS; + } + } + } + + if (FAILED(hr)) + { + if (probe != nullptr) + { + probe->finalHResult = hr; + } + + return hr; + } + + std::array path{}; + DWORD pathLength = GetModuleFileNameW(*module, path.data(), static_cast(path.size())); + if (pathLength > 0) + { + bool truncated = CopyWideAsUtf8(modulePath, modulePathLength, path.data()); + + if (probe != nullptr && + truncated) + { + probe->stringTruncationFlags |= InputBoxGameInputStringTruncatedLoadedModulePath; + } + } + + hr = initialize(IID_IGameInput, reinterpret_cast(gameInput)); + + if (probe != nullptr) + { + probe->initializeHResult = hr; + probe->initializeWin32Error = FAILED(hr) && HRESULT_FACILITY(hr) == FACILITY_WIN32 + ? HRESULT_CODE(hr) + : ERROR_SUCCESS; + probe->finalHResult = hr; + } + + if (FAILED(hr)) + { + FreeLibrary(*module); + *module = nullptr; + *moduleKind = InputBoxGameInputModuleUnknown; + CopyUtf8(modulePath, modulePathLength, ""); + } + else if (probe != nullptr) + { + probe->finalHResult = S_OK; + } + + return hr; + } + + DeviceEntry* FindDevice( + InputBoxGameInputContext* context, + const char* deviceId) noexcept + { + if (context == nullptr || + deviceId == nullptr) + { + return nullptr; + } + + for (DeviceEntry& entry : context->devices) + { + if (strcmp(entry.info.deviceId, deviceId) == 0) + { + return &entry; + } + } + + return nullptr; + } + + HRESULT CopyDeviceLocked( + InputBoxGameInputContext* context, + const char* deviceId, + ComPtr* device, + InputBoxGameInputDeviceInfo* info = nullptr) noexcept + { + // 呼叫端必須先持有 context->lock。回傳的 ComPtr 可讓裝置離開 vector 後仍存活, + // 但 vector 查找本身仍必須同步。 + if (context == nullptr || + deviceId == nullptr || + device == nullptr) + { + return E_INVALIDARG; + } + + device->Reset(); + + DeviceEntry* entry = FindDevice(context, deviceId); + + if (entry == nullptr) + { + return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + } + + *device = entry->device; + + if (info != nullptr) + { + *info = entry->info; + } + + return S_OK; + } + + HRESULT CopyDevice( + InputBoxGameInputContext* context, + const char* deviceId, + ComPtr* device, + InputBoxGameInputDeviceInfo* info = nullptr) noexcept + { + if (context == nullptr) + { + return E_INVALIDARG; + } + + SharedContextLock lock(context->lock); + + return CopyDeviceLocked(context, deviceId, device, info); + } + + void RecordReadResultLocked( + InputBoxGameInputContext* context, + HRESULT hr, + uint32_t deviceStatus, + uint64_t timestamp) noexcept + { + if (context == nullptr) + { + return; + } + + // 這些計數器僅供診斷;受控端邊緣偵測與中立閘門 + // 不應依據它們改變行為。 + context->lastReadHResult = hr; + context->lastReadDeviceStatus = deviceStatus; + + if (FAILED(hr)) + { + if (hr == InputBoxGameInputNoReading) + { + context->missingReadingCount++; + } + + return; + } + + if (timestamp != 0) + { + if (context->lastReadingTimestamp == timestamp) + { + context->repeatedTimestampCount++; + } + else if (context->lastReadingTimestamp > timestamp) + { + context->backwardTimestampCount++; + } + + context->lastReadingTimestamp = timestamp; + } + } + + void RecordReadResult( + InputBoxGameInputContext* context, + HRESULT hr, + uint32_t deviceStatus, + uint64_t timestamp) noexcept + { + if (context == nullptr) + { + return; + } + + ExclusiveContextLock lock(context->lock); + RecordReadResultLocked(context, hr, deviceStatus, timestamp); + } + + void RecordDeviceUnavailableLocked(InputBoxGameInputContext* context, uint32_t status) noexcept + { + if (context == nullptr) + { + return; + } + + context->deviceUnavailableRefreshCount++; + context->lastReadDeviceStatus = status; + context->lastReadHResult = HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + } + + void RecordDeviceUnavailable(InputBoxGameInputContext* context, uint32_t status) noexcept + { + if (context == nullptr) + { + return; + } + + ExclusiveContextLock lock(context->lock); + RecordDeviceUnavailableLocked(context, status); + } + + struct DeviceCollector + { + std::vector> devices; + }; + + void CALLBACK OnDeviceEnumerated( + _In_ GameInputCallbackToken, + _In_ void* context, + _In_ IGameInputDevice* device, + _In_ uint64_t, + _In_ GameInputDeviceStatus currentStatus, + _In_ GameInputDeviceStatus) + { + if (context == nullptr || + device == nullptr || + (currentStatus & GameInputDeviceConnected) == 0) + { + return; + } + + auto* collector = static_cast(context); + collector->devices.emplace_back(device); + } + + void CALLBACK OnReadingCallback( + _In_ GameInputCallbackToken, + _In_ void* context, + _In_ IGameInputReading* reading) + { + // 回呼路徑只作為喚醒與診斷的輔助通道。這裡只把 reading 轉成 POD, + // 是否重新整理則交由受控端輪詢迴圈決定。 + auto* registration = static_cast(context); + + if (registration == nullptr || + !registration->active.load() || + registration->readingCallback == nullptr || + reading == nullptr) + { + return; + } + + GameInputGamepadState gamepadState{}; + if (!reading->GetGamepadState(&gamepadState)) + { + return; + } + + InputBoxGameInputGamepadState state{}; + FillGamepadState(reading, gamepadState, &state); + + registration->readingCallback(registration->callbackContext, &state); + } + + void CALLBACK OnDeviceCallback( + _In_ GameInputCallbackToken, + _In_ void* context, + _In_ IGameInputDevice* device, + _In_ uint64_t timestamp, + _In_ GameInputDeviceStatus currentStatus, + _In_ GameInputDeviceStatus previousStatus) + { + // 不要把 IGameInputDevice 跨過 C ABI 傳給受控端。受控層只接收穩定識別與狀態位元, + // 再透過輪詢執行緒重新整理。 + auto* registration = static_cast(context); + + if (registration == nullptr || + !registration->active.load() || + registration->deviceCallback == nullptr) + { + return; + } + + char deviceId[65]{}; + + if (device != nullptr) + { + const GameInputDeviceInfo* info = nullptr; + if (SUCCEEDED(device->GetDeviceInfo(&info)) && + info != nullptr) + { + CopyDeviceId(deviceId, sizeof(deviceId), info->deviceId); + } + } + + registration->deviceCallback( + registration->callbackContext, + deviceId, + timestamp, + static_cast(currentStatus), + static_cast(previousStatus)); + } + + HRESULT ResolveOptionalDeviceLocked( + InputBoxGameInputContext* context, + const char* deviceId, + ComPtr* device) noexcept + { + if (device == nullptr) + { + return E_INVALIDARG; + } + + device->Reset(); + + if (deviceId == nullptr || + deviceId[0] == '\0') + { + return S_OK; + } + + return CopyDeviceLocked(context, deviceId, device); + } + + HRESULT ResolveOptionalDevice( + InputBoxGameInputContext* context, + const char* deviceId, + ComPtr* device) noexcept + { + if (context == nullptr) + { + return E_INVALIDARG; + } + + SharedContextLock lock(context->lock); + + return ResolveOptionalDeviceLocked(context, deviceId, device); + } +} + +extern "C" +{ + /** + * @brief 探測 GameInput runtime 是否可用,不建立持久 context。 + * + * 啟動期供 managed 層分類 LoadLibrary、GetProcAddress、GameInputInitialize 的 + * 失敗來源;呼叫後立即釋放暫時建立的 IGameInput 與 module 控制代碼,不會留下 + * callback 註冊。 + * + * @param[out] info 回傳的 runtime probe 資訊(ABI 版本、模組種類、嘗試路徑等); + * 不可為 nullptr。 + * @return Native HRESULT;S_OK 表示 runtime 可用,失敗時 info 仍會包含 + * 可供日誌觀察的部分欄位。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputProbeRuntime( + InputBoxGameInputRuntimeProbeInfo* info) noexcept + { + if (info == nullptr) + { + return E_INVALIDARG; + } + + HMODULE module = nullptr; + uint32_t moduleKind = InputBoxGameInputModuleUnknown; + char modulePath[512]{}; + ComPtr gameInput; + + // Probe 只建立短生命週期的 IGameInput,用來分類執行階段載入失敗原因。 + // 不得留下持久 context 或回呼註冊。 + HRESULT hr = LoadGameInput( + &module, + &moduleKind, + modulePath, + sizeof(modulePath), + &gameInput, + info); + + gameInput.Reset(); + + if (module != nullptr) + { + FreeLibrary(module); + } + + return hr; + } + + /** + * @brief 建立 GameInput shim context;成功時透過 out 指標傳回原生 context。 + * + * 內部呼叫 LoadGameInput 取得 IGameInput 與 module handle,並儲存於新配置的 + * InputBoxGameInputContext 中。Managed 端應將回傳的指標包裝為 + * SafeGameInputContextHandle,確保在 GC/Dispose 時呼叫 InputBoxGameInputDestroy。 + * + * @param[out] context 回傳新建立的 context 指標;失敗時設為 nullptr。 + * @return Native HRESULT;失敗時呼叫端應走退避 XInput 路徑。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputCreate( + InputBoxGameInputContext** context) noexcept + { + if (context == nullptr) + { + return E_INVALIDARG; + } + + *context = nullptr; + + auto created = new (std::nothrow) InputBoxGameInputContext(); + + if (created == nullptr) + { + return E_OUTOFMEMORY; + } + + HRESULT hr = LoadGameInput( + &created->module, + &created->moduleKind, + created->modulePath, + sizeof(created->modulePath), + &created->gameInput); + + if (FAILED(hr)) + { + delete created; + + return hr; + } + + *context = created; + + return S_OK; + } + + /** + * @brief 釋放 GameInput shim context 與所有附屬資源。 + * + * 釋放順序:先停止所有 callback(避免回呼觀察到半銷毀的原生狀態)→ 清空裝置 + * 與 callback 清單 → 重置 IGameInput → FreeLibrary 卸載已載入的 module → + * delete context。通常由 SafeGameInputContextHandle::ReleaseHandle 呼叫。 + * + * @param context 由 InputBoxGameInputCreate 建立的 context;nullptr 為合法輸入。 + */ + __declspec(dllexport) void __stdcall InputBoxGameInputDestroy( + InputBoxGameInputContext* context) noexcept + { + if (context == nullptr) + { + return; + } + + { + ExclusiveContextLock lock(context->lock); + + // 先停止回呼,再釋放 vector/module,避免回呼觀察到半銷毀的原生狀態。 + if (context->gameInput != nullptr) + { + for (const std::unique_ptr& registration : context->callbacks) + { + if (registration != nullptr && + registration->token != 0) + { + registration->active.store(false); + context->gameInput->StopCallback(registration->token); + context->gameInput->UnregisterCallback(registration->token); + } + } + } + + context->callbacks.clear(); + context->devices.clear(); + } + + context->gameInput.Reset(); + + if (context->module != nullptr) + { + FreeLibrary(context->module); + context->module = nullptr; + } + + delete context; + } + + /** + * @brief 取得 shim 自身與已載入 GameInput runtime 的版本與 ABI 資訊。 + * + * 回傳的結構包含 shim ABI 版本、GAMEINPUT_API_VERSION、pointer size、所有跨邊界 + * struct 的 size,以及實際載入的 GameInput module kind 與路徑。Managed 端必須以 + * Marshal.SizeOf() 比對 struct size,不符即視為 shim 載錯並退避 XInput。 + * + * @param context 已建立的 context;允許 nullptr,此時僅回傳 shim 本身資訊。 + * @param[out] info 回傳的 shim/runtime 資訊結構;不可為 nullptr。 + * @return Native HRESULT。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetShimInfo( + InputBoxGameInputContext* context, + InputBoxGameInputShimInfo* info) noexcept + { + if (info == nullptr) + { + return E_INVALIDARG; + } + + *info = {}; + FillShimInfoCommon(info); + + if (context != nullptr) + { + SharedContextLock lock(context->lock); + + info->loadedModuleKind = context->moduleKind; + CopyUtf8(info->loadedModulePath, sizeof(info->loadedModulePath), context->modulePath); + } + + return S_OK; + } + + /** + * @brief 取得 shim 累積的診斷快照(missing reading、stale/backward timestamp、 + * device unavailable refresh 等計數)。 + * + * 此快照僅供日誌、測試或未來診斷儀表使用,不可直接影響 edge detection、 + * Pause/Resume neutral gate 或任何 UI 命令。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @param[out] snapshot 回傳的診斷計數結構;不可為 nullptr。 + * @return Native HRESULT。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetDiagnosticsSnapshot( + InputBoxGameInputContext* context, + InputBoxGameInputDiagnosticsSnapshot* snapshot) noexcept + { + if (context == nullptr || + snapshot == nullptr) + { + return E_INVALIDARG; + } + + SharedContextLock lock(context->lock); + + snapshot->missingReadingCount = context->missingReadingCount; + snapshot->repeatedTimestampCount = context->repeatedTimestampCount; + snapshot->backwardTimestampCount = context->backwardTimestampCount; + snapshot->deviceUnavailableRefreshCount = context->deviceUnavailableRefreshCount; + snapshot->lastReadingTimestamp = context->lastReadingTimestamp; + snapshot->lastReadHResult = context->lastReadHResult; + snapshot->lastReadDeviceStatus = context->lastReadDeviceStatus; + snapshot->reserved = 0; + + return S_OK; + } + + /** + * @brief 設定 IGameInput::SetFocusPolicy,控制應用程式失去焦點時是否仍接收輸入。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @param policy GameInputFocusPolicy 位元旗標(以 uint32_t 跨 ABI 傳遞)。 + * @return Native HRESULT。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputSetFocusPolicy( + InputBoxGameInputContext* context, + uint32_t policy) noexcept + { + if (context == nullptr) + { + return E_INVALIDARG; + } + + SharedContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + context->gameInput->SetFocusPolicy(static_cast(policy)); + + return S_OK; + } + + /** + * @brief 強制 shim 對 GameInput runtime 進行裝置重新列舉,並更新內部快取清單。 + * + * 使用 GameInputBlockingEnumeration 取得目前所有已連線 gamepad 的同步快照, + * 暫時註冊一個列舉用的 callback,完成後立即解除。整個流程持有 exclusive lock, + * 避免與 polling 端讀取裝置清單競態。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @return Native HRESULT;發生例外時回傳 E_FAIL。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputRefreshDevices( + InputBoxGameInputContext* context) noexcept + { + if (context == nullptr) + { + return E_INVALIDARG; + } + + try + { + ExclusiveContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + DeviceCollector collector; + GameInputCallbackToken token = 0; + // 阻塞列舉提供 60 FPS 輪詢迴圈需要的完整快照。 + // 這個回呼註冊是暫時的,替換裝置清單前會先解除註冊。 + HRESULT hr = context->gameInput->RegisterDeviceCallback( + nullptr, + GameInputKindGamepad, + GameInputDeviceConnected, + GameInputBlockingEnumeration, + &collector, + OnDeviceEnumerated, + &token); + + if (FAILED(hr)) + { + return hr; + } + + context->gameInput->UnregisterCallback(token); + + std::vector refreshed; + refreshed.reserve(collector.devices.size()); + + for (const ComPtr& device : collector.devices) + { + DeviceEntry entry; + entry.device = device; + + hr = FillDeviceInfo(entry.device.Get(), &entry.info); + + if (SUCCEEDED(hr)) + { + refreshed.push_back(entry); + } + } + + context->devices = std::move(refreshed); + + return S_OK; + } + catch (...) + { + return E_FAIL; + } + } + + /** + * @brief 回傳 shim 目前所知的裝置總數(以 RefreshDevices 結果為準)。 + * + * @param context 已建立的 context;nullptr 時回傳 0。 + * @return 裝置數;以 int32_t 跨 ABI 傳遞。 + */ + __declspec(dllexport) int32_t __stdcall InputBoxGameInputGetDeviceCount( + InputBoxGameInputContext* context) noexcept + { + if (context == nullptr) + { + return 0; + } + + SharedContextLock lock(context->lock); + + return static_cast(context->devices.size()); + } + + /** + * @brief 依索引取得單一裝置的中繼資訊(VID/PID、displayName、capabilities、 + * 支援馬達等)。 + * + * 索引以與 InputBoxGameInputGetDeviceCount 同一回合的計數為準;不要與下一次 + * RefreshDevices 之間共用。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @param index 裝置索引(0-based);超出範圍會回傳 E_INVALIDARG。 + * @param[out] info 回傳的裝置資訊結構;不可為 nullptr。 + * @return Native HRESULT。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetDeviceInfo( + InputBoxGameInputContext* context, + int32_t index, + InputBoxGameInputDeviceInfo* info) noexcept + { + if (context == nullptr || + info == nullptr || + index < 0) + { + return E_INVALIDARG; + } + + SharedContextLock lock(context->lock); + + if (static_cast(index) >= context->devices.size()) + { + return E_INVALIDARG; + } + + *info = context->devices[static_cast(index)].info; + + return S_OK; + } + + /** + * @brief 依穩定 deviceId 查詢目前裝置狀態旗標。 + * + * 若裝置目前未連線會額外更新 deviceUnavailableRefreshCount 診斷計數,但不會 + * 自動觸發重列舉(由 managed 端 polling 邏輯依連續缺幀數決定)。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @param deviceId 穩定裝置識別字串(UTF-8);可為 nullptr,此時依 shim 規則選擇預設裝置。 + * @param[out] status 回傳 GameInputDeviceStatus 旗標;不可為 nullptr。 + * @return Native HRESULT。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetDeviceStatus( + InputBoxGameInputContext* context, + const char* deviceId, + uint32_t* status) noexcept + { + if (status == nullptr) + { + return E_INVALIDARG; + } + + *status = 0; + + if (context == nullptr) + { + return E_INVALIDARG; + } + + ExclusiveContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + ComPtr device; + HRESULT hr = CopyDeviceLocked(context, deviceId, &device); + + if (FAILED(hr)) + { + return hr; + } + + *status = static_cast(device->GetDeviceStatus()); + + if ((*status & GameInputDeviceConnected) == 0) + { + RecordDeviceUnavailableLocked(context, *status); + } + + return S_OK; + } + + /** + * @brief 同步讀取指定裝置目前的 gamepad reading 快照。 + * + * 流程:取得裝置 → 檢查 GameInputDeviceConnected → GetCurrentReading → + * 抽取 GameInputGamepadState 並填入 InputBoxGameInputGamepadState(含 timestamp + * 與診斷 metadata)。途中任何失敗都會更新對應的 read result 診斷計數。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @param deviceId 穩定裝置識別字串(UTF-8);可為 nullptr。 + * @param[out] state 回傳的 gamepad 狀態快照;不可為 nullptr,內容會先被清零。 + * @return Native HRESULT;InputBoxGameInputNoReading 代表暫無可用 reading; + * ERROR_DEVICE_NOT_CONNECTED 代表裝置已斷線。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputReadGamepadState( + InputBoxGameInputContext* context, + const char* deviceId, + InputBoxGameInputGamepadState* state) noexcept + { + if (state == nullptr) + { + return E_INVALIDARG; + } + + *state = {}; + + if (context == nullptr) + { + return E_INVALIDARG; + } + + ExclusiveContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + ComPtr device; + HRESULT hr = CopyDeviceLocked(context, deviceId, &device); + + if (FAILED(hr)) + { + RecordReadResultLocked(context, hr, 0, 0); + + return hr; + } + + uint32_t deviceStatus = static_cast(device->GetDeviceStatus()); + + if ((deviceStatus & GameInputDeviceConnected) == 0) + { + RecordDeviceUnavailableLocked(context, deviceStatus); + + return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + } + + ComPtr reading; + hr = context->gameInput->GetCurrentReading( + GameInputKindGamepad, + device.Get(), + &reading); + + if (FAILED(hr) || + reading == nullptr) + { + HRESULT result = FAILED(hr) ? hr : InputBoxGameInputNoReading; + RecordReadResultLocked(context, result, deviceStatus, 0); + + return result; + } + + GameInputGamepadState gamepadState{}; + + if (!reading->GetGamepadState(&gamepadState)) + { + RecordReadResultLocked(context, InputBoxGameInputNoReading, deviceStatus, 0); + + return InputBoxGameInputNoReading; + } + + FillGamepadState(reading.Get(), gamepadState, state); + RecordReadResultLocked(context, S_OK, deviceStatus, state->timestamp); + + return S_OK; + } + + /** + * @brief 註冊 reading callback;GameInput runtime 在推送新 gamepad reading 時 + * 通知 managed 端喚醒 MTA polling thread。 + * + * 註冊本身不持有 context lock 以避免 GameInput 在註冊期間呼叫回呼時造成死結; + * 但會在 token 發布前重新檢查 context 是否仍存活,避免 destroy 與註冊競態。 + * callback 僅可用於要求重新整理或喚醒診斷路徑,不得直接觸發 UI 或輸入命令。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @param deviceId 穩定裝置識別字串(UTF-8);nullptr 表示訂閱全部裝置。 + * @param kind 必須為 GameInputKindGamepad。 + * @param callback Managed 端建立、需 keep-alive 的回呼函式指標。 + * @param callbackContext 回呼時傳回的 user context(通常為 GCHandle)。 + * @param[out] callbackToken 回傳的回呼識別 token,用於 InputBoxGameInputUnregisterCallback。 + * @return Native HRESULT。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputRegisterReadingCallback( + InputBoxGameInputContext* context, + const char* deviceId, + uint32_t kind, + InputBoxGameInputReadingCallback callback, + void* callbackContext, + uint64_t* callbackToken) noexcept + { + if (context == nullptr || + kind != static_cast(GameInputKindGamepad) || + callback == nullptr || + callbackToken == nullptr) + { + return E_INVALIDARG; + } + + *callbackToken = 0; + + ComPtr gameInput; + ComPtr device; + + { + SharedContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + // 在 shared lock 內複製 COM 參考,接著不持有 context lock 進行註冊。 + // GameInput 可能在註冊期間呼叫回呼。 + gameInput = context->gameInput; + HRESULT hr = ResolveOptionalDeviceLocked(context, deviceId, &device); + + if (FAILED(hr)) + { + return hr; + } + } + + auto registration = std::make_unique(); + registration->readingCallback = callback; + registration->callbackContext = callbackContext; + + GameInputCallbackToken token = 0; + HRESULT hr = gameInput->RegisterReadingCallback( + device.Get(), + static_cast(kind), + registration.get(), + OnReadingCallback, + &token); + + if (FAILED(hr)) + { + return hr; + } + + registration->token = token; + *callbackToken = token; + + { + ExclusiveContextLock lock(context->lock); + if (context->gameInput == nullptr) + { + // 原生註冊成功後 destroy 搶先完成。這裡立即解除註冊並回報失敗, + // 避免受控端保留這個 token。 + registration->active.store(false); + gameInput->StopCallback(token); + gameInput->UnregisterCallback(token); + *callbackToken = 0; + + return E_INVALIDARG; + } + + context->callbacks.push_back(std::move(registration)); + } + + return S_OK; + } + + /** + * @brief 註冊 device callback;裝置連線/斷線/狀態變更時通知 managed 端排程 + * 裝置重列舉。 + * + * 與 RegisterReadingCallback 相同的鎖規則:註冊期間不持有 context lock, + * 並在 token 發布前重新檢查 context 是否仍存活。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @param deviceId 穩定裝置識別字串(UTF-8);nullptr 表示訂閱全部裝置。 + * @param kind 必須為 GameInputKindGamepad。 + * @param statusFilter 關注的 GameInputDeviceStatus 旗標。 + * @param enumerationKind GameInputEnumerationKind(Blocking / Async 等)。 + * @param callback Managed 端建立、需 keep-alive 的回呼函式指標。 + * @param callbackContext 回呼時傳回的 user context(通常為 GCHandle)。 + * @param[out] callbackToken 回傳的回呼識別 token。 + * @return Native HRESULT。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputRegisterDeviceCallback( + InputBoxGameInputContext* context, + const char* deviceId, + uint32_t kind, + uint32_t statusFilter, + uint32_t enumerationKind, + InputBoxGameInputDeviceCallback callback, + void* callbackContext, + uint64_t* callbackToken) noexcept + { + if (context == nullptr || + kind != static_cast(GameInputKindGamepad) || + callback == nullptr || + callbackToken == nullptr) + { + return E_INVALIDARG; + } + + *callbackToken = 0; + + ComPtr gameInput; + ComPtr device; + + { + SharedContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + // 參考 RegisterReadingCallback:回呼註冊不持有 context lock, + // 並且只在 context 仍存活時發布 token。 + gameInput = context->gameInput; + HRESULT hr = ResolveOptionalDeviceLocked(context, deviceId, &device); + + if (FAILED(hr)) + { + return hr; + } + } + + auto registration = std::make_unique(); + registration->deviceCallback = callback; + registration->callbackContext = callbackContext; + + GameInputCallbackToken token = 0; + HRESULT hr = gameInput->RegisterDeviceCallback( + device.Get(), + static_cast(kind), + static_cast(statusFilter), + static_cast(enumerationKind), + registration.get(), + OnDeviceCallback, + &token); + + if (FAILED(hr)) + { + return hr; + } + + registration->token = token; + *callbackToken = token; + + { + ExclusiveContextLock lock(context->lock); + if (context->gameInput == nullptr) + { + // context 銷毀與註冊發生競態;回傳前先停止並解除註冊。 + registration->active.store(false); + gameInput->StopCallback(token); + gameInput->UnregisterCallback(token); + *callbackToken = 0; + + return E_INVALIDARG; + } + + context->callbacks.push_back(std::move(registration)); + } + + return S_OK; + } + + /** + * @brief 註銷先前以 RegisterReadingCallback 或 RegisterDeviceCallback 取得的 + * callback token。 + * + * 流程:標記 registration 為非 active(讓正在執行的回呼盡快返回) → StopCallback + * → UnregisterCallback → 從 context 移除註冊紀錄。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @param callbackToken 先前回傳的 callback token(非 0)。 + * @return Native HRESULT;找不到對應註冊時回傳 HRESULT_FROM_WIN32(ERROR_NOT_FOUND)。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputUnregisterCallback( + InputBoxGameInputContext* context, + uint64_t callbackToken) noexcept + { + if (context == nullptr || + callbackToken == 0) + { + return E_INVALIDARG; + } + + ExclusiveContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + auto found = std::find_if( + context->callbacks.begin(), + context->callbacks.end(), + [callbackToken](const std::unique_ptr& registration) + { + return registration != nullptr && + registration->token == callbackToken; + }); + + if (found == context->callbacks.end()) + { + return HRESULT_FROM_WIN32(ERROR_NOT_FOUND); + } + + (*found)->active.store(false); + context->gameInput->StopCallback(callbackToken); + context->gameInput->UnregisterCallback(callbackToken); + context->callbacks.erase(found); + + return S_OK; + } + + /** + * @brief 套用震動參數到指定裝置;四個馬達強度均為 [0.0, 1.0] 正規化值。 + * + * 不支援的馬達會被 GameInput runtime 忽略(例如 PC 控制器多半無 trigger 馬達); + * 由 managed 端依 GameInputRumbleMotors 旗標決定是否傳遞非零值。 + * + * @param context 已建立的 context;不可為 nullptr。 + * @param deviceId 穩定裝置識別字串(UTF-8);可為 nullptr。 + * @param lowFrequency 低頻主馬達強度。 + * @param highFrequency 高頻主馬達強度。 + * @param leftTrigger 左扳機馬達強度(不支援時忽略)。 + * @param rightTrigger 右扳機馬達強度(不支援時忽略)。 + * @return Native HRESULT。 + */ + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputSetRumbleState( + InputBoxGameInputContext* context, + const char* deviceId, + float lowFrequency, + float highFrequency, + float leftTrigger, + float rightTrigger) noexcept + { + if (context == nullptr) + { + return E_INVALIDARG; + } + + SharedContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + ComPtr device; + HRESULT hr = CopyDeviceLocked(context, deviceId, &device); + + if (FAILED(hr)) + { + return hr; + } + + GameInputRumbleParams rumble + { + lowFrequency, + highFrequency, + leftTrigger, + rightTrigger + }; + + device->SetRumbleState(&rumble); + + return S_OK; + } +} diff --git a/src/InputBox.GameInput.Native/README.md b/src/InputBox.GameInput.Native/README.md new file mode 100644 index 0000000..2312f19 --- /dev/null +++ b/src/InputBox.GameInput.Native/README.md @@ -0,0 +1,91 @@ +# InputBox.GameInput.Native + +`InputBox.GameInput.Native` 是 InputBox 專用的 Windows 原生 shim,用來把 Microsoft GameInput 執行階段轉成專案內部可控的窄版 C ABI。 + +這個專案不是通用 GameInput 包裝層,也不提供對外相容層。原生 shim 與 `src/InputBox/Core/Input/GameInput*.cs` 視為同版、同包、一起發佈。 + +## 範圍 + +目前只支援 InputBox 需要的 Gamepad-only 子集合: + +- GameInput 執行階段載入與診斷 probe。 +- Gamepad 裝置列舉、狀態讀取、VID/PID、顯示名稱與能力中繼資料。 +- Gamepad reading/device 回呼註冊。 +- Gamepad 震動。 +- Shim ABI 與跨邊界結構大小診斷。 + +明確不支援: + +- keyboard +- mouse +- sensors +- raw report +- force feedback +- aggregate device +- 1:1 GameInput API 包裝層 + +若要擴張範圍,必須先更新 `docs/engineering/gamepad-api.md`,重新評估安全邊界、測試覆蓋與發佈授權影響。 + +## ABI 規則 + +- C ABI 結構必須與 `src/InputBox/Core/Input/GameInputPrimitives.cs` 的受控端 P/Invoke 結構保持版面相容。 +- 任何跨邊界結構欄位增刪或順序調整,都必須同步更新 `InputBoxGameInputShimAbiVersion` 與受控端大小檢查。 +- `InputBoxGameInputGetShimInfo` 必須回報 ABI 版本、`GAMEINPUT_API_VERSION`、指標大小與結構大小。 +- 受控端 `GameInput.Create()` 會驗證原生端回報的大小;不符時會視為 shim 載錯並退避 XInput。 + +## 執行階段載入 + +DLL 載入規則必須維持保守: + +- 優先從 System32 載入 `GameInput.dll`。 +- 再嘗試 System32 的 `GameInputRedist.dll`。 +- 最後才使用登錄檔 `RedistDir` 內的 `GameInputRedist.dll`。 +- 登錄檔 redist 絕對路徑必須搭配 `LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32`。 +- 不得從目前工作目錄或未限制搜尋路徑載入 `gameinput.dll`。 + +`InputBoxGameInputProbeRuntime` 是建立正式 context 前的診斷入口。它只能建立短生命週期 `IGameInput` 以回報 LoadLibrary、GetProcAddress、GameInputInitialize 的 HRESULT / Win32 error,不得留下回呼或長生命週期狀態。 + +## 執行緒與回呼 + +- `InputBoxGameInputContext::lock` 使用 SRW lock 保護 `gameInput`、`devices`、`callbacks` 與診斷計數器。 +- refresh/read/unregister/destroy 可能與回呼註冊交錯,必須維持鎖定紀律。 +- 不得在持有 context lock 時呼叫受控端回呼。 +- 回呼只可複製 POD event 或作為喚醒/診斷輔助通道;正式輸入仍由受控端 60 FPS MTA 輪詢迴圈消費。 +- 任何新增回呼路徑都必須避免傳遞 COM pointer 給 C#。 + +## 診斷資料 + +目前診斷資料包含: + +- 執行階段 probe 的模組類型/路徑、HRESULT 與 Win32 錯誤碼。 +- 字串截斷旗標。 +- missing reading 計數器。 +- repeated/backward timestamp 計數器。 +- device unavailable refresh 計數器。 +- 最後讀取的 HRESULT/status/timestamp。 + +這些資料只供 log、測試與未來過期 reading 分析,不得直接改變邊緣偵測、Pause/Resume 中立閘門或任何 UI 命令。 + +## 建置與發佈 + +- 原生 shim 由 `InputBox.GameInput.Native.vcxproj` 建置。 +- `src/InputBox/InputBox.csproj` 會把 Release shim 以原生程式庫形式納入單檔發佈。 +- CI 與 release 在原生 shim 建置後必須執行 `tools/Validate-GameInputNativeShim.ps1`,確認 `GameInputNativeMethods` 使用的 `InputBoxGameInput*` exports 全部存在。 +- 同一支驗證腳本也會呼叫 `InputBoxGameInputProbeRuntime` 做無硬體 smoke test;GameInput runtime 初始化可成功或失敗,但 probe 必須可安全回報 ABI,且 native 回報的指標大小與跨 ABI 結構大小必須與受控端 mirror 相符。 +- 若 GameInput runtime 可用,驗證腳本會再執行 native lifecycle stress smoke,反覆建立 context、註冊/解除回呼、重新整理裝置與銷毀 context。runtime 不可用時此壓力測試可略過,但 export/probe 驗證仍必須通過。 +- Release ZIP 不應包含可見的 `InputBox.GameInput.Native.dll` 側載 DLL,也不應包含 `gameinput.dll`。 +- ZIP 可包含 `redist/GameInputRedist.msi` 供使用者手動安裝;InputBox 不會自動執行安裝程式。 + +## 驗證 + +常用驗證: + +```powershell +dotnet build src/InputBox/InputBox.csproj --configuration Debug +dotnet test --project tests/InputBox.Tests/InputBox.Tests.csproj +.\tools\Validate-GameInputNativeShim.ps1 -NativeShimPath .\src\InputBox.GameInput.Native\bin\x64\Debug\InputBox.GameInput.Native.dll -ManagedSourcePath .\src\InputBox\Core\Input\GameInputNative.cs +``` + +修改 shim ABI、執行階段載入或發佈打包時,還要做 Release 發佈 / ZIP 試跑,確認單檔發佈、redist、授權聲明與禁止項目仍符合工作流程。 + +若變更涉及 GameInput 實體硬體行為,還應依 `docs/engineering/gameinput-hardware-verification.md` 執行手動抽測。建議觸發時機包含修改 shim、斷線偵測、rumble、升級 `Microsoft.GameInput` 或正式發佈前;這不是日常 PR 必跑項目,也不是 CI 關卡。 diff --git a/src/InputBox/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs index a060d92..a8c97ee 100644 --- a/src/InputBox/Core/Input/GameInputGamepadController.cs +++ b/src/InputBox/Core/Input/GameInputGamepadController.cs @@ -1,8 +1,4 @@ -using GameInputDotNet; -using GameInputDotNet.Interop.Enums; -using GameInputDotNet.Interop.Structs; -using GameInputDotNet.States; -using InputBox.Core.Configuration; +using InputBox.Core.Configuration; using InputBox.Core.Extensions; using InputBox.Core.Feedback; using InputBox.Core.Services; @@ -10,7 +6,6 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Numerics; -using UsbVendorsLibrary; namespace InputBox.Core.Input; @@ -64,6 +59,11 @@ internal sealed partial class GameInputGamepadController : IGamepadController /// private long _refreshRequestedTicks; + /// + /// 記錄最後一次收到讀取回呼的時間點;正式輸入仍由 60 FPS polling 消費。 + /// + private long _readingCallbackObservedTicks; + /// /// 儲存 GameInput 讀取回呼註冊憑證 /// @@ -94,6 +94,11 @@ internal sealed partial class GameInputGamepadController : IGamepadController /// private int _reconnectCounter; + /// + /// 目前裝置連續讀不到有效 reading 的幀數。 + /// + private int _missingReadingFrameCounter; + /// /// 輪詢任務的 CancellationTokenSource /// @@ -295,6 +300,11 @@ internal sealed partial class GameInputGamepadController : IGamepadController /// private bool _lastHealthGhostActive; + /// + /// 上一次機制健康度輸出的 mapGuard 活躍狀態 + /// + private bool _lastHealthMapGuardActive; + /// /// 連發風暴診斷輸出間隔(每 N 次輸出一次) /// @@ -750,7 +760,14 @@ private void StartPolling() /// private void StopPolling() { - Interlocked.Exchange(ref _ctsPolling, null)?.CancelAndDispose(); + CancellationTokenSource? cts = Interlocked.Exchange(ref _ctsPolling, null); + if (cts == null) + { + return; + } + + LogGameInputDiagnosticsSnapshot("stop-polling"); + cts.CancelAndDispose(); } /// @@ -761,6 +778,7 @@ private void HandleDisconnect() bool wasConnected = _isConnected || _hasPreviousState; _isConnected = false; + _missingReadingFrameCounter = 0; UpdateCalibrationSnapshot(null, AppSettings.Current.GamepadSettings, false); if (wasConnected) @@ -818,30 +836,11 @@ private void SetupReadingCallback() GameInputKind.Gamepad, (reading) => { - try - { - GamepadStateSnapshot? state = reading.GetGamepadState(); - - if (state != null) - { - if (_isPaused || - _disposed != 0) - { - return; - } - - // 防護機制:避免在背景暫停輪詢時,佇列因玩家在其他遊戲中的操作而無限增長。 - if (_readingQueue.Count > 100) - { - _readingQueue.TryDequeue(out _); - } - - _readingQueue.Enqueue(state); - } - } - catch (Exception cbEx) + if (!_isPaused && + _disposed == 0) { - Debug.WriteLine($"GameInput 讀取回呼內部發生錯誤:{cbEx.Message}"); + _ = reading; + Interlocked.Exchange(ref _readingCallbackObservedTicks, Stopwatch.GetTimestamp()); } }); } @@ -871,6 +870,10 @@ private async Task PollingLoopAsync(CancellationToken cancellationToken, TaskCom _gameInput = GameInput.Create(); _gameInput.SetFocusPolicy(GameInputFocusPolicy.Default); + GameInputShimInfo shimInfo = _gameInput.ShimInfo; + LoggerService.LogInfo($"GameInputShim abi={shimInfo.AbiVersion} api={shimInfo.GameInputApiVersion} moduleKind={shimInfo.LoadedModuleKind} modulePath={shimInfo.LoadedModulePath}"); + LogGameInputDiagnosticsSnapshot("init"); + // 初始化時先直接列舉一次,再註冊非阻塞裝置回呼。 // 這樣可避免為了拿到既有裝置名單而付出兩次同步枚舉成本。 if (_needsRefresh && @@ -904,6 +907,12 @@ private async Task PollingLoopAsync(CancellationToken cancellationToken, TaskCom { LoggerService.LogException(ex, "GameInput 在背景執行緒初始化失敗"); + if (GameInput.TryProbeRuntime(out GameInputRuntimeProbeInfo probeInfo)) + { + LoggerService.LogInfo( + $"GameInputProbe finalHr=0x{probeInfo.FinalHResult:X8} loadHr=0x{probeInfo.LoadLibraryHResult:X8} procHr=0x{probeInfo.GetProcAddressHResult:X8} initHr=0x{probeInfo.InitializeHResult:X8} loadWin32={probeInfo.LoadLibraryWin32Error} procWin32={probeInfo.GetProcAddressWin32Error} initWin32={probeInfo.InitializeWin32Error} attemptedKind={probeInfo.AttemptedModuleKind} loadedKind={probeInfo.LoadedModuleKind} attemptedPath={probeInfo.AttemptedModulePath} loadedPath={probeInfo.LoadedModulePath}"); + } + Debug.WriteLine($"GameInput 在背景執行緒初始化失敗:{ex.Message}"); // 若系統不支援,通知 CreateAsync 丟出例外以觸發退避邏輯 @@ -986,6 +995,37 @@ private async Task PollingLoopAsync(CancellationToken cancellationToken, TaskCom } } + /// + /// 以低噪音方式記錄 GameInput shim 診斷快照。 + /// + /// 診斷觸發原因。 + private void LogGameInputDiagnosticsSnapshot(string reason) + => LogGameInputDiagnosticsSnapshot(_gameInput, reason); + + /// + /// 以低噪音方式記錄指定 GameInput context 的 native 診斷快照。 + /// + /// GameInput context。 + /// 診斷觸發原因。 + private static void LogGameInputDiagnosticsSnapshot(GameInput? gameInput, string reason) + { + if (gameInput == null) + { + return; + } + + try + { + GameInputDiagnosticsSnapshot snapshot = gameInput.GetDiagnosticsSnapshot(); + LoggerService.LogInfo( + $"GameInputDiag reason={reason} missingReadings={snapshot.MissingReadingCount} repeatedTimestamps={snapshot.RepeatedTimestampCount} backwardTimestamps={snapshot.BackwardTimestampCount} deviceUnavailableRefreshes={snapshot.DeviceUnavailableRefreshCount} lastTimestamp={snapshot.LastReadingTimestamp} lastReadHr=0x{unchecked((uint)snapshot.LastReadHResult):X8} lastDeviceStatus=0x{snapshot.LastReadDeviceStatus:X8}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[GameInput] 讀取診斷快照失敗({reason},已忽略):{ex.Message}"); + } + } + /// /// 輪詢 /// @@ -1001,6 +1041,13 @@ private void Poll() // 取得目前的設定快照,確保本幀處理邏輯的原子性。 AppSettings.GamepadConfigSnapshot config = AppSettings.Current.GamepadSettings; + if (Interlocked.Exchange(ref _readingCallbackObservedTicks, 0) != 0 && + _device == null) + { + _needsRefresh = true; + Interlocked.Exchange(ref _refreshRequestedTicks, Stopwatch.GetTimestamp()); + } + // 裝置清單防抖與掃描邏輯。 if (_needsRefresh) { @@ -1050,7 +1097,52 @@ private void Poll() return; } - // 從佇列中取出所有事件(Event-Driven)。 + try + { + GameInputDeviceStatus status = dev.GetDeviceStatus(); + + if ((status & GameInputDeviceStatus.Connected) == 0) + { + Debug.WriteLine("[GameInput] 目前裝置狀態已非 Connected,立即重新整理裝置清單。"); + LogGameInputDiagnosticsSnapshot("device-status-non-connected"); + RefreshAfterCurrentDeviceBecameUnavailable(); + + return; + } + + using GameInputReading? reading = gameInput.GetCurrentReading(GameInputKind.Gamepad, dev); + GamepadStateSnapshot? currentSnapshot = reading?.GetGamepadState(); + + if (currentSnapshot == null) + { + if (HandleMissingCurrentReading()) + { + return; + } + } + else + { + _missingReadingFrameCounter = 0; + + if (!_hasPreviousState || + _previousState == null || + !HasSameInputValues(currentSnapshot, _previousState)) + { + _readingQueue.Enqueue(currentSnapshot); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[GameInput] 讀取目前狀態失敗,稍後重新整理裝置:{ex.Message}"); + + if (HandleMissingCurrentReading()) + { + return; + } + } + + // 從佇列中取出所有快照。 // 分離「邊緣偵測」與「狀態維持」。 bool hasNewState = false; @@ -1162,6 +1254,56 @@ private void Poll() } } + /// + /// 目前裝置已不可用時立即重新整理裝置清單。 + /// + private void RefreshAfterCurrentDeviceBecameUnavailable() + { + _missingReadingFrameCounter = 0; + _needsRefresh = true; + Interlocked.Exchange(ref _refreshRequestedTicks, Stopwatch.GetTimestamp()); + + TryFindDevice(); + } + + /// + /// 處理目前裝置連續讀不到 reading 的情境;超過閾值時立即重列舉以偵測斷線。 + /// + /// 若已重列舉且目前幀應停止後續狀態處理則回傳 true。 + private bool HandleMissingCurrentReading() + { + if (!ShouldRefreshAfterMissingCurrentReading()) + { + return false; + } + + _needsRefresh = true; + Interlocked.Exchange(ref _refreshRequestedTicks, Stopwatch.GetTimestamp()); + + LogGameInputDiagnosticsSnapshot("missing-reading-threshold"); + TryFindDevice(); + + return _device == null; + } + + /// + /// 計算連續 missing reading 是否已達重列舉門檻。 + /// + /// 若應重列舉裝置則回傳 true。 + private bool ShouldRefreshAfterMissingCurrentReading() + { + _missingReadingFrameCounter++; + + if (_missingReadingFrameCounter < AppSettings.GamepadReconnectThresholdFrames) + { + return false; + } + + _missingReadingFrameCounter = 0; + + return true; + } + /// /// 評估方向幽靈重入狀態:處理「狀態有變化但方向持續誤判」情境。 /// @@ -1321,6 +1463,18 @@ private bool IsStateIdle(GamepadStateSnapshot state) threshold: AppSettings.GameInputIdleThreshold); } + /// + /// 比較兩個快照的可操作輸入值;GameInput timestamp 僅供診斷,不應讓 edge detection 誤判為新輸入。 + /// + private static bool HasSameInputValues(GamepadStateSnapshot current, GamepadStateSnapshot previous) + => current.Buttons == previous.Buttons && + current.LeftTrigger.Equals(previous.LeftTrigger) && + current.RightTrigger.Equals(previous.RightTrigger) && + current.LeftThumbstickX.Equals(previous.LeftThumbstickX) && + current.LeftThumbstickY.Equals(previous.LeftThumbstickY) && + current.RightThumbstickX.Equals(previous.RightThumbstickX) && + current.RightThumbstickY.Equals(previous.RightThumbstickY); + /// /// 是否存在方向連發狀態(D-Pad 或 RS) /// @@ -1435,6 +1589,7 @@ private void ResetTransientInputState(bool requireNeutralBeforeInput = true) _rtRepeatCounter = 0; _currentRTRepeatInterval = 0; _reconnectCounter = 0; + _missingReadingFrameCounter = 0; _previousState = null; _previousProcessedButtons = 0; _hasPreviousState = false; @@ -1464,7 +1619,7 @@ private void TryFindDevice() // 執行一次完整的列舉。 IReadOnlyList devices = gameInput.EnumerateDevices(GameInputKind.Gamepad); - // 1. 釋放清單中已不再存在的舊裝置代理(Zero-allocation 替換 LINQ Any)。 + // 1. 釋放清單中已不再存在或已非 Connected 的舊裝置代理(Zero-allocation 替換 LINQ Any)。 for (int i = _allDevices.Count - 1; i >= 0; i--) { GameInputDevice oldDev = _allDevices[i]; @@ -1473,15 +1628,27 @@ private void TryFindDevice() { GameInputDeviceInfo oldInfo = oldDev.GetDeviceInfo(); - bool stillExists = false; + bool stillExists = IsDeviceConnected(oldDev); - for (int j = 0; j < devices.Count; j++) + if (stillExists) { - if (devices[j].GetDeviceInfo().DeviceId.Equals(oldInfo.DeviceId)) + stillExists = false; + + for (int j = 0; j < devices.Count; j++) { - stillExists = true; + GameInputDevice candidate = devices[j]; - break; + if (!IsDeviceConnected(candidate)) + { + continue; + } + + if (candidate.GetDeviceInfo().DeviceId.Equals(oldInfo.DeviceId)) + { + stillExists = true; + + break; + } } } @@ -1530,6 +1697,13 @@ private void TryFindDevice() try { + if (!IsDeviceConnected(newDev)) + { + newDev.Dispose(); + + continue; + } + GameInputDeviceInfo newInfo = newDev.GetDeviceInfo(); bool alreadyTracked = false; @@ -1571,6 +1745,7 @@ private void TryFindDevice() _device = _allDevices[0]; _hasPreviousState = false; + _missingReadingFrameCounter = 0; UpdateDeviceInfo(); InitializeDeviceState(); @@ -1602,6 +1777,33 @@ private void TryFindDevice() } } + /// + /// 判斷 GameInput 裝置目前是否仍可用於輸入。 + /// + /// 要檢查的 GameInput 裝置。 + /// 裝置狀態包含 Connected 時回傳 true。 + private static bool IsDeviceConnected(GameInputDevice device) + { + try + { + return IsConnectedStatus(device.GetDeviceStatus()); + } + catch (Exception ex) + { + Debug.WriteLine($"[GameInput] 檢查裝置連線狀態失敗(視為不可用):{ex.Message}"); + + return false; + } + } + + /// + /// 判斷 GameInput 裝置狀態是否包含 Connected 旗標。 + /// + /// GameInput 裝置狀態旗標。 + /// 若包含 Connected 則回傳 true。 + private static bool IsConnectedStatus(GameInputDeviceStatus status) + => (status & GameInputDeviceStatus.Connected) != 0; + /// /// 掃描目前是否有其他正在活動的裝置,若有則切換 /// @@ -1752,32 +1954,17 @@ private void UpdateDeviceInfo() int supportedMotorCount = BitOperations.PopCount(supportedMotorBits); _rumbleThermalWeight = Math.Clamp(supportedMotorCount, 1, 4); - // 更新裝置名稱與穩定識別資訊。業界實務上會優先使用 VID/PID 或產品家族資訊, - // 再把易變動的藍牙/接收器顯示名稱當成備援。 - string displayName = string.Empty; - - // 保留 GameInput 原始顯示名稱,供 USB 名稱查詢失敗或比對時作為補充線索。 - string fallbackDisplayName = info.GetDisplayName() ?? "Unknown Gamepad"; - - if (UsbIds.TryGetVendorName(info.VendorId, out string? vendorName) && - !string.IsNullOrWhiteSpace(vendorName)) - { - displayName = $"{vendorName} "; - } + // 更新裝置名稱與穩定識別資訊。Auto 判斷改以 VID/PID 與 GameInput + // 原始顯示名稱作為穩定線索。 + string displayName = info.GetDisplayName(); - if (UsbIds.TryGetProductName(info.VendorId, info.ProductId, out string? productName) && - !string.IsNullOrWhiteSpace(productName)) - { - displayName += productName; - } - else + if (string.IsNullOrWhiteSpace(displayName)) { - displayName += fallbackDisplayName; + displayName = "Unknown Gamepad"; } - displayName = string.IsNullOrWhiteSpace(displayName) ? fallbackDisplayName : displayName.Trim(); _cachedDeviceName = displayName; - _cachedDeviceIdentity = $"VID_{info.VendorId:X4} PID_{info.ProductId:X4} {displayName} {fallbackDisplayName}".Trim(); + _cachedDeviceIdentity = $"VID_{info.VendorId:X4} PID_{info.ProductId:X4} {displayName}".Trim(); } catch (Exception ex) { @@ -1958,10 +2145,12 @@ private void EmitMechanismHealthLog( bool staleActive = _directionalStaleFrameCounter > 0, ghostActive = _directionalGhostFrameCounter > 0, + mapGuardActive = _suppressedMappedDirections != MappedGamepadDirection.None, stateChanged = _repeatDirection != _lastHealthDpadDirection || _rsRepeatDirection != _lastHealthRsDirection || staleActive != _lastHealthStaleActive || - ghostActive != _lastHealthGhostActive; + ghostActive != _lastHealthGhostActive || + mapGuardActive != _lastHealthMapGuardActive; _mechanismHealthLogCounter++; @@ -1970,13 +2159,14 @@ private void EmitMechanismHealthLog( _mechanismHealthLogCounter >= MechanismHealthLogIntervalFrames) { LoggerService.LogInfo( - $"Gamepad.MechanismHealth source=GameInput stage=bias_deadzone_hysteresis_repeat dpadDir={_repeatDirection?.ToString() ?? "None"} rsDir={_rsRepeatDirection} lx={correctedLeftX:F4} ly={correctedLeftY:F4} rx={correctedRightX:F4} ry={correctedRightY:F4} biasLx={_leftStickBiasX:F4} biasLy={_leftStickBiasY:F4} biasRx={_rightStickBiasX:F4} biasRy={_rightStickBiasY:F4} enter={config.ThumbDeadzoneEnter} exit={config.ThumbDeadzoneExit} stale={_directionalStaleFrameCounter} ghost={_directionalGhostFrameCounter}"); + $"Gamepad.MechanismHealth source=GameInput stage=bias_deadzone_hysteresis_repeat dpadDir={_repeatDirection?.ToString() ?? "None"} rsDir={_rsRepeatDirection} lx={correctedLeftX:F4} ly={correctedLeftY:F4} rx={correctedRightX:F4} ry={correctedRightY:F4} biasLx={_leftStickBiasX:F4} biasLy={_leftStickBiasY:F4} biasRx={_rightStickBiasX:F4} biasRy={_rightStickBiasY:F4} enter={config.ThumbDeadzoneEnter} exit={config.ThumbDeadzoneExit} stale={_directionalStaleFrameCounter} ghost={_directionalGhostFrameCounter} mapGuard={_suppressedMappedDirections}"); _mechanismHealthLogCounter = 0; _lastHealthDpadDirection = _repeatDirection; _lastHealthRsDirection = _rsRepeatDirection; _lastHealthStaleActive = staleActive; _lastHealthGhostActive = ghostActive; + _lastHealthMapGuardActive = mapGuardActive; } _wasMechanismEngaged = true; @@ -1996,6 +2186,7 @@ private void ResetMechanismHealthLogState() _lastHealthRsDirection = 0; _lastHealthStaleActive = false; _lastHealthGhostActive = false; + _lastHealthMapGuardActive = false; #endif } @@ -2854,6 +3045,7 @@ private Task DisposeResourcesAsync() d.Dispose(); } + LogGameInputDiagnosticsSnapshot(gameInput, "dispose"); gameInput?.Dispose(); } catch (Exception ex) @@ -2904,8 +3096,10 @@ private void ClearAllEvents() LSClickPressed = null; RSClickPressed = null; LeftShoulderPressed = null; + LeftShoulderReleased = null; LeftShoulderRepeat = null; RightShoulderPressed = null; + RightShoulderReleased = null; RightShoulderRepeat = null; LeftTriggerPressed = null; RightTriggerPressed = null; @@ -2913,4 +3107,4 @@ private void ClearAllEvents() RightTriggerRepeat = null; ConnectionChanged = null; } -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Input/GameInputNative.cs b/src/InputBox/Core/Input/GameInputNative.cs new file mode 100644 index 0000000..cf5eb4e --- /dev/null +++ b/src/InputBox/Core/Input/GameInputNative.cs @@ -0,0 +1,763 @@ +using System.Runtime.InteropServices; +using System.Diagnostics; + +namespace InputBox.Core.Input; + +/// +/// GameInput native shim 的受控進入點,負責把窄版 C ABI 轉成專案內部型別。 +/// +internal sealed class GameInput : IDisposable +{ + private static readonly GameInputNativeReadingCallback ReadingCallback = OnNativeReadingCallback; + private static readonly GameInputNativeDeviceCallback DeviceCallback = OnNativeDeviceCallback; + private readonly SafeGameInputContextHandle _handle; + private GameInputDevice[] _devices = []; + + private GameInput(SafeGameInputContextHandle handle, GameInputShimInfo shimInfo) + { + _handle = handle; + ShimInfo = shimInfo; + } + + /// + /// 取得 native shim 與 GameInput runtime 載入診斷資訊。 + /// + public GameInputShimInfo ShimInfo { get; } + + /// + /// 建立 GameInput 內容;若 shim 或 runtime 不可用會丟出例外,交由 XInput 退避流程處理。 + /// + /// GameInput 受控包裝。 + public static GameInput Create() + { + int hr = GameInputNativeMethods.Create(out SafeGameInputContextHandle handle); + + if (hr < 0 || + handle.IsInvalid) + { + handle.Dispose(); + Marshal.ThrowExceptionForHR(hr < 0 ? hr : unchecked((int)0x80004005)); + } + + try + { + hr = GameInputNativeMethods.GetShimInfo(handle, out GameInputNativeShimInfo nativeShimInfo); + + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + GameInputShimInfo shimInfo = nativeShimInfo.ToShimInfo(); + shimInfo.AbiInfo.ThrowIfMismatch(); + + return new GameInput(handle, shimInfo); + } + catch + { + handle.Dispose(); + + throw; + } + } + + /// + /// 嘗試取得 GameInput runtime 載入診斷資訊;此方法不會建立長生命週期 context。 + /// + /// runtime probe 結果。 + /// 若 probe export 可呼叫則回傳 true。 + public static bool TryProbeRuntime(out GameInputRuntimeProbeInfo probeInfo) + { + try + { + _ = GameInputNativeMethods.ProbeRuntime(out GameInputNativeRuntimeProbeInfo nativeProbeInfo); + probeInfo = nativeProbeInfo.ToProbeInfo(); + + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"GameInput runtime probe failed: {ex.Message}"); + probeInfo = default; + + return false; + } + } + + /// + /// 設定 GameInput focus policy。 + /// + /// Focus policy。 + public void SetFocusPolicy(GameInputFocusPolicy policy) + { + _ = GameInputNativeMethods.SetFocusPolicy(_handle, (uint)policy); + } + + /// + /// 重新列舉目前可用的 GameInput gamepad 裝置。 + /// + /// 輸入類型;目前只支援 Gamepad。 + /// 目前裝置清單。 + public IReadOnlyList EnumerateDevices(GameInputKind kind) + { + if (kind != GameInputKind.Gamepad) + { + return []; + } + + int hr = GameInputNativeMethods.RefreshDevices(_handle); + + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + int count = GameInputNativeMethods.GetDeviceCount(_handle); + GameInputDevice[] devices = new GameInputDevice[count]; + + for (int i = 0; i < count; i++) + { + hr = GameInputNativeMethods.GetDeviceInfo(_handle, i, out GameInputNativeDeviceInfo nativeInfo); + + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + devices[i] = new GameInputDevice(this, nativeInfo.ToDeviceInfo()); + } + + _devices = devices; + + return devices; + } + + /// + /// 取得指定裝置目前最新的 gamepad 狀態。 + /// + /// 輸入類型;目前只支援 Gamepad。 + /// 目標裝置。 + /// 成功讀取時回傳 reading,否則回傳 null。 + public GameInputReading? GetCurrentReading(GameInputKind kind, GameInputDevice device) + { + if (kind != GameInputKind.Gamepad || + device.Owner != this) + { + return null; + } + + int hr = GameInputNativeMethods.ReadGamepadState( + _handle, + device.DeviceInfo.DeviceId, + out GameInputGamepadState state); + + return hr >= 0 ? new GameInputReading(new GamepadStateSnapshot(state)) : null; + } + + /// + /// 註冊讀取回呼。控制器仍以 60 FPS 輪詢為主,此回呼只作為輔助訊號來源。 + /// + public GameInputCallbackRegistration RegisterReadingCallback( + GameInputDevice device, + GameInputKind kind, + Action callback) + { + if (device.Owner != this || + kind != GameInputKind.Gamepad) + { + return new GameInputCallbackRegistration(); + } + + var callbackState = new ReadingCallbackState(callback); + GCHandle callbackStateHandle = GCHandle.Alloc(callbackState); + + int hr = GameInputNativeMethods.RegisterReadingCallback( + _handle, + device.DeviceInfo.DeviceId, + (uint)kind, + ReadingCallback, + GCHandle.ToIntPtr(callbackStateHandle), + out ulong callbackToken); + + if (hr < 0) + { + callbackStateHandle.Free(); + Marshal.ThrowExceptionForHR(hr); + } + + return new GameInputCallbackRegistration(_handle, callbackToken, callbackStateHandle); + } + + /// + /// 註冊裝置回呼,用於要求輪詢執行緒重新整理裝置清單。 + /// + public GameInputCallbackRegistration RegisterDeviceCallback( + GameInputDevice? device, + GameInputKind kind, + GameInputDeviceStatus statusFilter, + GameInputEnumerationKind enumerationKind, + Action callback) + { + if (kind != GameInputKind.Gamepad) + { + return new GameInputCallbackRegistration(); + } + + var callbackState = new DeviceCallbackState(this, callback); + GCHandle callbackStateHandle = GCHandle.Alloc(callbackState); + + int hr = GameInputNativeMethods.RegisterDeviceCallback( + _handle, + device?.DeviceInfo.DeviceId, + (uint)kind, + (uint)statusFilter, + (uint)enumerationKind, + DeviceCallback, + GCHandle.ToIntPtr(callbackStateHandle), + out ulong callbackToken); + + if (hr < 0) + { + callbackStateHandle.Free(); + Marshal.ThrowExceptionForHR(hr); + } + + return new GameInputCallbackRegistration(_handle, callbackToken, callbackStateHandle); + } + + internal void SetRumbleState(GameInputDevice device, GameInputRumbleParams rumble) + { + if (device.Owner != this) + { + return; + } + + _ = GameInputNativeMethods.SetRumbleState( + _handle, + device.DeviceInfo.DeviceId, + rumble.LowFrequency, + rumble.HighFrequency, + rumble.LeftTrigger, + rumble.RightTrigger); + } + + internal GameInputDeviceStatus GetDeviceStatus(GameInputDevice device) + { + if (device.Owner != this) + { + return 0; + } + + int hr = GameInputNativeMethods.GetDeviceStatus( + _handle, + device.DeviceInfo.DeviceId, + out uint status); + + return hr >= 0 ? (GameInputDeviceStatus)status : 0; + } + + internal GameInputDiagnosticsSnapshot GetDiagnosticsSnapshot() + { + int hr = GameInputNativeMethods.GetDiagnosticsSnapshot( + _handle, + out GameInputNativeDiagnosticsSnapshot snapshot); + + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + return snapshot.ToDiagnosticsSnapshot(); + } + + /// + /// 釋放 GameInput 內容。 + /// + public void Dispose() + { + _devices = []; + _handle.Dispose(); + } + + private GameInputDevice? FindDeviceById(string deviceId) + { + if (string.IsNullOrWhiteSpace(deviceId)) + { + return null; + } + + GameInputDevice[] devices = _devices; + + for (int i = 0; i < devices.Length; i++) + { + if (devices[i].DeviceInfo.DeviceId.Equals(deviceId, StringComparison.Ordinal)) + { + return devices[i]; + } + } + + return null; + } + + private static void OnNativeReadingCallback(nint callbackContext, ref GameInputGamepadState state) + { + try + { + if (GCHandle.FromIntPtr(callbackContext).Target is ReadingCallbackState callbackState) + { + callbackState.Callback(new GameInputReading(new GamepadStateSnapshot(state))); + } + } + catch (Exception ex) + { + Debug.WriteLine($"GameInput native reading callback failed: {ex.Message}"); + } + } + + private static void OnNativeDeviceCallback( + nint callbackContext, + nint deviceId, + ulong timestamp, + uint currentStatus, + uint previousStatus) + { + try + { + if (GCHandle.FromIntPtr(callbackContext).Target is not DeviceCallbackState callbackState) + { + return; + } + + string parsedDeviceId = Marshal.PtrToStringUTF8(deviceId) ?? string.Empty; + GameInputDevice? device = callbackState.Owner.FindDeviceById(parsedDeviceId); + + callbackState.Callback( + device, + timestamp, + (GameInputDeviceStatus)currentStatus, + (GameInputDeviceStatus)previousStatus); + } + catch (Exception ex) + { + Debug.WriteLine($"GameInput native device callback failed: {ex.Message}"); + } + } + + private sealed class ReadingCallbackState(Action callback) + { + public Action Callback { get; } = callback; + } + + private sealed class DeviceCallbackState( + GameInput owner, + Action callback) + { + public GameInput Owner { get; } = owner; + + public Action Callback { get; } = callback; + } + + /// + /// GameInput 回呼註冊憑證。 + /// + internal sealed class GameInputCallbackRegistration : IDisposable + { + private readonly SafeGameInputContextHandle? _handle; + private readonly ulong _callbackToken; + private GCHandle _callbackStateHandle; + private int _disposed; + + public GameInputCallbackRegistration() + { + + } + + internal GameInputCallbackRegistration( + SafeGameInputContextHandle handle, + ulong callbackToken, + GCHandle callbackStateHandle) + { + _handle = handle; + _callbackToken = callbackToken; + _callbackStateHandle = callbackStateHandle; + } + + /// + /// 釋放回呼註冊。 + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try + { + if (_handle != null && + !_handle.IsInvalid && + _callbackToken != 0) + { + _ = GameInputNativeMethods.UnregisterCallback(_handle, _callbackToken); + } + } + catch (Exception ex) + { + Debug.WriteLine($"GameInput unregister callback failed: {ex.Message}"); + } + finally + { + if (_callbackStateHandle.IsAllocated) + { + _callbackStateHandle.Free(); + } + } + + } + } +} + +/// +/// GameInput 裝置包裝。 +/// +internal sealed class GameInputDevice : IDisposable +{ + internal GameInputDevice(GameInput owner, GameInputDeviceInfo deviceInfo) + { + Owner = owner; + DeviceInfo = deviceInfo; + } + + internal GameInput Owner { get; } + + internal GameInputDeviceInfo DeviceInfo { get; } + + /// + /// 取得裝置資訊。 + /// + /// 裝置資訊快照。 + public GameInputDeviceInfo GetDeviceInfo() => DeviceInfo; + + /// + /// 取得目前裝置狀態。 + /// + /// 目前裝置狀態。 + public GameInputDeviceStatus GetDeviceStatus() + => Owner.GetDeviceStatus(this); + + /// + /// 設定震動狀態。 + /// + /// 震動參數。 + public void SetRumbleState(GameInputRumbleParams rumble) + => Owner.SetRumbleState(this, rumble); + + /// + /// 釋放裝置包裝。裝置生命週期由 native context 管理,因此這裡不釋放底層實體。 + /// + public void Dispose() + { + + } +} + +/// +/// GameInput reading 包裝。 +/// +internal sealed class GameInputReading : IDisposable +{ + private readonly GamepadStateSnapshot _state; + + internal GameInputReading(GamepadStateSnapshot state) + { + _state = state; + } + + /// + /// 取得 gamepad 狀態快照。 + /// + /// gamepad 狀態快照。 + public GamepadStateSnapshot GetGamepadState() => _state; + + /// + /// 釋放 reading 包裝。 + /// + public void Dispose() + { + + } +} + +/// +/// 安全釋放 native GameInput context。 +/// +internal sealed class SafeGameInputContextHandle : SafeHandle +{ + private SafeGameInputContextHandle() + : base(IntPtr.Zero, ownsHandle: true) + { + + } + + /// + /// 判斷 handle 是否無效。 + /// + public override bool IsInvalid => handle == IntPtr.Zero; + + /// + /// 釋放 native context。 + /// + /// 若釋放成功則回傳 true。 + protected override bool ReleaseHandle() + { + GameInputNativeMethods.Destroy(handle); + + return true; + } +} + +/// +/// GameInput native shim P/Invoke 宣告。集中對應 InputBox.GameInput.Native +/// shim 匯出的 C ABI;所有方法均在 MTA 背景輪詢執行緒呼叫,回傳值為 native HRESULT。 +/// +internal static partial class GameInputNativeMethods +{ + /// + /// Native shim DLL 名稱;由 .NET DllImport 依 + /// 規則於應用程式目錄與使用者目錄中搜尋。 + /// + private const string NativeLibraryName = "InputBox.GameInput.Native"; + + /// + /// 探測 GameInput runtime 載入狀態,不建立持久 context。供啟動期分類 + /// LoadLibrary、GetProcAddress、GameInputInitialize 的失敗來源使用。 + /// + /// 回傳的探測資訊(ABI 版本、模組種類、嘗試/實際載入路徑等)。 + /// Native HRESULT;S_OK 表示 runtime 可用。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputProbeRuntime")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int ProbeRuntime(out GameInputNativeRuntimeProbeInfo info); + + /// + /// 建立 GameInput shim context;成功時將原生指標包裝為 , + /// 由 SafeHandle 在 finalize/dispose 時呼叫對應的 。 + /// + /// 回傳的 context SafeHandle;失敗時為 invalid handle。 + /// Native HRESULT;失敗時呼叫端應走退避 XInput 路徑。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputCreate")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int Create(out SafeGameInputContextHandle context); + + /// + /// 釋放 native context;通常由 + /// 自動呼叫,不應由業務程式碼直接呼叫。 + /// + /// 原生 context 指標。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputDestroy")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern void Destroy(nint context); + + /// + /// 取得 shim 自身與已載入 GameInput runtime 的版本資訊,包含 ABI 版本、跨邊界 struct 大小、 + /// 已載入的模組種類與路徑,供 managed layer 用 驗證 ABI。 + /// + /// 已建立的 GameInput context。 + /// 回傳的 shim/runtime 版本資訊結構。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetShimInfo")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetShimInfo( + SafeGameInputContextHandle context, + out GameInputNativeShimInfo info); + + /// + /// 取得 shim 內部累積的診斷快照(字串截斷旗標、timestamp stale、missing reading、 + /// device zombie refresh 等計數),僅供日誌與測試使用,不可改變按鍵語意。 + /// + /// 已建立的 GameInput context。 + /// 回傳的診斷計數快照。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDiagnosticsSnapshot")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetDiagnosticsSnapshot( + SafeGameInputContextHandle context, + out GameInputNativeDiagnosticsSnapshot snapshot); + + /// + /// 設定 IGameInput::SetFocusPolicy,控制應用程式失去焦點時是否仍接收輸入。 + /// + /// 已建立的 GameInput context。 + /// GameInputFocusPolicy 位元旗標。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputSetFocusPolicy")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int SetFocusPolicy(SafeGameInputContextHandle context, uint policy); + + /// + /// 強制 shim 對 GameInput runtime 進行裝置重新列舉;用於連線/斷線回呼觀察到變更後的補抓。 + /// + /// 已建立的 GameInput context。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputRefreshDevices")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int RefreshDevices(SafeGameInputContextHandle context); + + /// + /// 回傳 shim 目前所知的裝置總數(含已連線與暫存中尚未確認斷線的裝置)。 + /// + /// 已建立的 GameInput context。 + /// 裝置數;負值代表錯誤。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDeviceCount")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetDeviceCount(SafeGameInputContextHandle context); + + /// + /// 依索引取得單一裝置的中繼資訊(VID/PID、displayName、capabilities、支援馬達等), + /// 索引以 同一回合的計數為準。 + /// + /// 已建立的 GameInput context。 + /// 裝置索引(0-based)。 + /// 回傳的裝置資訊結構。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDeviceInfo")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetDeviceInfo( + SafeGameInputContextHandle context, + int index, + out GameInputNativeDeviceInfo info); + + /// + /// 依穩定 deviceId 查詢目前裝置狀態旗標(Connected / Synced / Wireless 等)。 + /// + /// 已建立的 GameInput context。 + /// 穩定裝置識別字串(UTF-8)。 + /// 回傳的 GameInputDeviceStatus 旗標。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDeviceStatus")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetDeviceStatus( + SafeGameInputContextHandle context, + [MarshalAs(UnmanagedType.LPUTF8Str)] string deviceId, + out uint status); + + /// + /// 同步讀取指定裝置目前 gamepad reading 快照;用於 polling 與 Resume 預同步。 + /// + /// 已建立的 GameInput context。 + /// 穩定裝置識別字串(UTF-8)。 + /// 回傳的 gamepad 狀態快照。 + /// Native HRESULT;InputBoxGameInputNoReading 代表暫無 reading。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputReadGamepadState")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int ReadGamepadState( + SafeGameInputContextHandle context, + [MarshalAs(UnmanagedType.LPUTF8Str)] string deviceId, + out GameInputGamepadState state); + + /// + /// 註冊 reading callback;shim 會在 GameInput runtime 推送新 reading 時喚醒輪詢執行緒。 + /// callback 僅供 wake-up 與診斷快照,不得直接觸發 UI 或輸入命令。 + /// + /// 已建立的 GameInput context。 + /// 穩定裝置識別字串;傳 null 表示訂閱全部裝置。 + /// 輸入種類過濾(GameInputKind 位元旗標)。 + /// 由 managed 端建立、需 keep-alive 的 delegate。 + /// 回呼時傳回的 user context(通常為 GCHandle)。 + /// 回傳的回呼識別 token,用於 。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputRegisterReadingCallback")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int RegisterReadingCallback( + SafeGameInputContextHandle context, + [MarshalAs(UnmanagedType.LPUTF8Str)] string? deviceId, + uint kind, + GameInputNativeReadingCallback callback, + nint callbackContext, + out ulong callbackToken); + + /// + /// 註冊 device callback;shim 會在裝置連線/斷線/狀態變更時通知 managed 端進行重列舉。 + /// + /// 已建立的 GameInput context。 + /// 穩定裝置識別字串;傳 null 表示訂閱全部裝置。 + /// 輸入種類過濾(GameInputKind 位元旗標)。 + /// 關注的狀態變更旗標(GameInputDeviceStatus)。 + /// 列舉模式(GameInputEnumerationKind)。 + /// 由 managed 端建立、需 keep-alive 的 delegate。 + /// 回呼時傳回的 user context(通常為 GCHandle)。 + /// 回傳的回呼識別 token,用於 。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputRegisterDeviceCallback")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int RegisterDeviceCallback( + SafeGameInputContextHandle context, + [MarshalAs(UnmanagedType.LPUTF8Str)] string? deviceId, + uint kind, + uint statusFilter, + uint enumerationKind, + GameInputNativeDeviceCallback callback, + nint callbackContext, + out ulong callbackToken); + + /// + /// 註銷先前由 + /// 取得的 callback token;shim 會停止對應的 callback 並讓 managed delegate 可被 GC。 + /// + /// 已建立的 GameInput context。 + /// 先前回傳的 callback token。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputUnregisterCallback")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int UnregisterCallback( + SafeGameInputContextHandle context, + ulong callbackToken); + + /// + /// 套用震動參數到指定裝置;四個馬達強度均為 [0.0, 1.0] 正規化值, + /// 不支援的馬達會被 GameInput runtime 忽略。 + /// + /// 已建立的 GameInput context。 + /// 穩定裝置識別字串(UTF-8)。 + /// 低頻主馬達強度。 + /// 高頻主馬達強度。 + /// 左扳機馬達強度(不支援時忽略)。 + /// 右扳機馬達強度(不支援時忽略)。 + /// Native HRESULT。 + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputSetRumbleState")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int SetRumbleState( + SafeGameInputContextHandle context, + [MarshalAs(UnmanagedType.LPUTF8Str)] string deviceId, + float lowFrequency, + float highFrequency, + float leftTrigger, + float rightTrigger); +} + +/// +/// Reading callback delegate:shim 在 GameInput runtime 推送新 gamepad reading 時呼叫, +/// 通常僅用於喚醒 MTA 背景輪詢執行緒;正式輸入命令仍由 60 FPS polling 消費。 +/// +/// 註冊時傳入的 user context(通常為 GCHandle)。 +/// 推送的 gamepad 狀態(POD)。 +[UnmanagedFunctionPointer(CallingConvention.StdCall)] +internal delegate void GameInputNativeReadingCallback( + nint callbackContext, + ref GameInputGamepadState state); + +/// +/// Device callback delegate:shim 在裝置連線/斷線/狀態變更時呼叫,通知 managed 端 +/// 安排裝置重列舉;callback 內僅可記錄旗標或排入工作佇列,不得直接觸發 UI 或輸入。 +/// +/// 註冊時傳入的 user context(通常為 GCHandle)。 +/// 穩定裝置識別字串的 UTF-8 指標(由 shim 持有)。 +/// 事件時戳(QueryPerformanceCounter 等價單位)。 +/// 目前的 GameInputDeviceStatus 旗標。 +/// 事件前的 GameInputDeviceStatus 旗標。 +[UnmanagedFunctionPointer(CallingConvention.StdCall)] +internal delegate void GameInputNativeDeviceCallback( + nint callbackContext, + nint deviceId, + ulong timestamp, + uint currentStatus, + uint previousStatus); diff --git a/src/InputBox/Core/Input/GameInputPrimitives.cs b/src/InputBox/Core/Input/GameInputPrimitives.cs new file mode 100644 index 0000000..76c3bca --- /dev/null +++ b/src/InputBox/Core/Input/GameInputPrimitives.cs @@ -0,0 +1,725 @@ +using System.Runtime.InteropServices; + +namespace InputBox.Core.Input; + +/// +/// GameInput 輸入類型。 +/// +[Flags] +internal enum GameInputKind +{ + /// + /// 未知或不支援的輸入類型。 + /// + Unknown = 0x00000000, + + /// + /// Gamepad。 + /// + Gamepad = 0x00040000 +} + +/// +/// GameInput focus policy。 +/// +[Flags] +internal enum GameInputFocusPolicy : uint +{ + /// + /// 預設 focus policy。 + /// + Default = 0 +} + +/// +/// GameInput 裝置狀態。 +/// +[Flags] +internal enum GameInputDeviceStatus : uint +{ + /// + /// 無狀態。 + /// + None = 0x00000000, + + /// + /// 已連線。 + /// + Connected = 0x00000001, + + /// + /// Haptic 資訊已可用。 + /// + HapticInfoReady = 0x00200000, + + /// + /// 任一狀態變更。 + /// + Any = 0xFFFFFFFF +} + +/// +/// GameInput 裝置列舉模式。 +/// +internal enum GameInputEnumerationKind +{ + /// + /// 不執行初始列舉。 + /// + None = 0, + + /// + /// 非同步列舉。 + /// + Async = 1, + + /// + /// 阻塞列舉。 + /// + Blocking = 2 +} + +/// +/// GameInput gamepad 按鍵旗標。 +/// +[Flags] +internal enum GameInputGamepadButtons : uint +{ + None = 0x00000000, + Menu = 0x00000001, + View = 0x00000002, + A = 0x00000004, + B = 0x00000008, + C = 0x00004000, + X = 0x00000010, + Y = 0x00000020, + Z = 0x00008000, + DPadUp = 0x00000040, + DPadDown = 0x00000080, + DPadLeft = 0x00000100, + DPadRight = 0x00000200, + LeftShoulder = 0x00000400, + RightShoulder = 0x00000800, + LeftThumbstick = 0x00001000, + RightThumbstick = 0x00002000, + LeftTriggerButton = 0x00010000, + RightTriggerButton = 0x00020000, + LeftThumbstickUp = 0x00040000, + LeftThumbstickDown = 0x00080000, + LeftThumbstickLeft = 0x00100000, + LeftThumbstickRight = 0x00200000, + RightThumbstickUp = 0x00400000, + RightThumbstickDown = 0x00800000, + RightThumbstickLeft = 0x01000000, + RightThumbstickRight = 0x02000000, + PaddleLeft1 = 0x04000000, + PaddleLeft2 = 0x08000000, + PaddleRight1 = 0x10000000, + PaddleRight2 = 0x20000000 +} + +/// +/// GameInput 支援的震動馬達旗標。 +/// +[Flags] +internal enum GameInputRumbleMotors : uint +{ + None = 0x00000000, + LowFrequency = 0x00000001, + HighFrequency = 0x00000002, + LeftTrigger = 0x00000004, + RightTrigger = 0x00000008 +} + +/// +/// GameInput gamepad 狀態。 +/// +[StructLayout(LayoutKind.Sequential)] +internal struct GameInputGamepadState +{ + public ulong Timestamp; + public GameInputKind InputKind; + public GameInputGamepadButtons Buttons; + public float LeftTrigger; + public float RightTrigger; + public float LeftThumbstickX; + public float LeftThumbstickY; + public float RightThumbstickX; + public float RightThumbstickY; +} + +/// +/// GameInput gamepad 狀態快照。 +/// +internal sealed record class GamepadStateSnapshot +{ + internal GamepadStateSnapshot(GameInputGamepadState state) + : this( + state.Timestamp, + state.InputKind, + state.Buttons, + state.LeftTrigger, + state.RightTrigger, + state.LeftThumbstickX, + state.LeftThumbstickY, + state.RightThumbstickX, + state.RightThumbstickY) + { + + } + + internal GamepadStateSnapshot( + ulong timestamp, + GameInputKind inputKind, + GameInputGamepadButtons buttons, + float leftTrigger, + float rightTrigger, + float leftThumbstickX, + float leftThumbstickY, + float rightThumbstickX, + float rightThumbstickY) + { + Timestamp = timestamp; + InputKind = inputKind; + Buttons = buttons; + LeftTrigger = leftTrigger; + RightTrigger = rightTrigger; + LeftThumbstickX = leftThumbstickX; + LeftThumbstickY = leftThumbstickY; + RightThumbstickX = rightThumbstickX; + RightThumbstickY = rightThumbstickY; + } + + internal GamepadStateSnapshot( + GameInputGamepadButtons buttons, + float leftTrigger, + float rightTrigger, + float leftThumbstickX, + float leftThumbstickY, + float rightThumbstickX, + float rightThumbstickY) + : this( + 0, + GameInputKind.Gamepad, + buttons, + leftTrigger, + rightTrigger, + leftThumbstickX, + leftThumbstickY, + rightThumbstickX, + rightThumbstickY) + { + + } + + public ulong Timestamp { get; init; } + + public GameInputKind InputKind { get; init; } + + public GameInputGamepadButtons Buttons { get; init; } + + public float LeftTrigger { get; init; } + + public float RightTrigger { get; init; } + + public float LeftThumbstickX { get; init; } + + public float LeftThumbstickY { get; init; } + + public float RightThumbstickX { get; init; } + + public float RightThumbstickY { get; init; } +} + +/// +/// GameInput 震動參數。 +/// +internal readonly record struct GameInputRumbleParams +{ + public float LowFrequency { get; init; } + + public float HighFrequency { get; init; } + + public float LeftTrigger { get; init; } + + public float RightTrigger { get; init; } +} + +/// +/// GameInput 版本資訊。 +/// +internal readonly record struct GameInputVersionInfo( + ushort Major, + ushort Minor, + ushort Build, + ushort Revision); + +/// +/// GameInput gamepad 能力資訊。 +/// +internal readonly record struct GameInputGamepadCapabilities( + GameInputGamepadButtons SupportedLayout, + uint GamepadExtraButtonCount, + uint GamepadExtraAxisCount, + uint ExtraButtonCount, + uint ExtraAxisCount, + byte[] ExtraButtonIndexes, + byte[] ExtraAxisIndexes, + bool HasInputMapper) +{ + public static GameInputGamepadCapabilities Empty { get; } = new( + GameInputGamepadButtons.None, + 0, + 0, + 0, + 0, + [], + [], + false); +} + +/// +/// GameInput shim 字串欄位截斷旗標。 +/// +[Flags] +internal enum GameInputStringTruncationFlags : uint +{ + None = 0x00000000, + DeviceId = 0x00000001, + DeviceRootId = 0x00000002, + ContainerId = 0x00000004, + DisplayName = 0x00000008, + PnpPath = 0x00000010, + AttemptedModulePath = 0x00000020, + LoadedModulePath = 0x00000040 +} + +/// +/// GameInput shim 與 managed layer 的 ABI 尺寸資訊。 +/// +internal readonly record struct GameInputAbiInfo( + uint PointerSize, + uint ShimInfoSize, + uint RuntimeProbeInfoSize, + uint DeviceInfoSize, + uint GamepadStateSize, + uint DiagnosticsSnapshotSize) +{ + public static GameInputAbiInfo Managed { get; } = new( + (uint)IntPtr.Size, + (uint)Marshal.SizeOf(), + (uint)Marshal.SizeOf(), + (uint)Marshal.SizeOf(), + (uint)Marshal.SizeOf(), + (uint)Marshal.SizeOf()); + + public bool MatchesManagedLayout => this == Managed; + + public void ThrowIfMismatch() + { + if (!MatchesManagedLayout) + { + throw new BadImageFormatException( + $"GameInput native shim ABI mismatch. Native={this}; Managed={Managed}"); + } + } +} + +/// +/// GameInput shim 載入診斷資訊。 +/// +internal readonly record struct GameInputShimInfo( + uint AbiVersion, + uint GameInputApiVersion, + GameInputAbiInfo AbiInfo, + GameInputShimModuleKind LoadedModuleKind, + string LoadedModulePath); + +/// +/// GameInput runtime probe 結果。 +/// +internal readonly record struct GameInputRuntimeProbeInfo( + uint AbiVersion, + uint GameInputApiVersion, + GameInputAbiInfo AbiInfo, + GameInputShimModuleKind AttemptedModuleKind, + GameInputShimModuleKind LoadedModuleKind, + int LoadLibraryHResult, + int GetProcAddressHResult, + int InitializeHResult, + int FinalHResult, + uint LoadLibraryWin32Error, + uint GetProcAddressWin32Error, + uint InitializeWin32Error, + GameInputStringTruncationFlags StringTruncationFlags, + string AttemptedModulePath, + string LoadedModulePath); + +/// +/// GameInput shim 診斷計數器快照。 +/// +internal readonly record struct GameInputDiagnosticsSnapshot( + ulong MissingReadingCount, + ulong RepeatedTimestampCount, + ulong BackwardTimestampCount, + ulong DeviceUnavailableRefreshCount, + ulong LastReadingTimestamp, + int LastReadHResult, + uint LastReadDeviceStatus); + +/// +/// GameInput runtime 載入來源。 +/// +internal enum GameInputShimModuleKind : uint +{ + Unknown = 0, + SystemGameInput = 1, + SystemGameInputRedist = 2, + RegistryGameInputRedist = 3 +} + +/// +/// GameInput 裝置資訊快照。 +/// +internal readonly record struct GameInputDeviceInfo +{ + internal GameInputDeviceInfo( + string deviceId, + ushort vendorId, + ushort productId, + ushort revisionNumber, + ushort usagePage, + ushort usageId, + uint deviceFamily, + GameInputKind supportedInput, + GameInputRumbleMotors supportedRumbleMotors, + uint supportedSystemButtons, + GameInputVersionInfo hardwareVersion, + GameInputVersionInfo firmwareVersion, + string deviceRootId, + string containerId, + string pnpPath, + GameInputStringTruncationFlags stringTruncationFlags, + GameInputGamepadCapabilities gamepadCapabilities, + string displayName) + { + DeviceId = deviceId; + VendorId = vendorId; + ProductId = productId; + RevisionNumber = revisionNumber; + UsagePage = usagePage; + UsageId = usageId; + DeviceFamily = deviceFamily; + SupportedInput = supportedInput; + SupportedRumbleMotors = supportedRumbleMotors; + SupportedSystemButtons = supportedSystemButtons; + HardwareVersion = hardwareVersion; + FirmwareVersion = firmwareVersion; + DeviceRootId = deviceRootId; + ContainerId = containerId; + PnpPath = pnpPath; + StringTruncationFlags = stringTruncationFlags; + GamepadCapabilities = gamepadCapabilities; + DisplayName = displayName; + } + + public string DeviceId { get; } + + public ushort VendorId { get; } + + public ushort ProductId { get; } + + public ushort RevisionNumber { get; } + + public ushort UsagePage { get; } + + public ushort UsageId { get; } + + public uint DeviceFamily { get; } + + public GameInputKind SupportedInput { get; } + + public GameInputRumbleMotors SupportedRumbleMotors { get; } + + public uint SupportedSystemButtons { get; } + + public GameInputVersionInfo HardwareVersion { get; } + + public GameInputVersionInfo FirmwareVersion { get; } + + public string DeviceRootId { get; } + + public string ContainerId { get; } + + public string PnpPath { get; } + + public GameInputStringTruncationFlags StringTruncationFlags { get; } + + public GameInputGamepadCapabilities GamepadCapabilities { get; } + + private string DisplayName { get; } + + public string GetDisplayName() => DisplayName; +} + +/// +/// Native shim 回傳的版本資訊。 +/// +[StructLayout(LayoutKind.Sequential)] +internal struct GameInputNativeVersionInfo +{ + public ushort Major; + public ushort Minor; + public ushort Build; + public ushort Revision; + + public readonly GameInputVersionInfo ToVersionInfo() + => new(Major, Minor, Build, Revision); +} + +/// +/// Native shim 回傳的載入診斷資訊。 +/// +[StructLayout(LayoutKind.Sequential)] +internal unsafe struct GameInputNativeShimInfo +{ + public uint AbiVersion; + public uint GameInputApiVersion; + public uint PointerSize; + public uint ShimInfoSize; + public uint RuntimeProbeInfoSize; + public uint DeviceInfoSize; + public uint GamepadStateSize; + public uint DiagnosticsSnapshotSize; + public uint LoadedModuleKind; + public fixed byte LoadedModulePath[512]; + + public readonly GameInputShimInfo ToShimInfo() + { + string loadedModulePath; + + fixed (byte* loadedModulePathPtr = LoadedModulePath) + { + loadedModulePath = Marshal.PtrToStringUTF8((nint)loadedModulePathPtr) ?? string.Empty; + } + + return new GameInputShimInfo( + AbiVersion, + GameInputApiVersion, + new GameInputAbiInfo( + PointerSize, + ShimInfoSize, + RuntimeProbeInfoSize, + DeviceInfoSize, + GamepadStateSize, + DiagnosticsSnapshotSize), + (GameInputShimModuleKind)LoadedModuleKind, + loadedModulePath); + } +} + +/// +/// Native shim 回傳的 runtime probe 診斷資訊。 +/// +[StructLayout(LayoutKind.Sequential)] +internal unsafe struct GameInputNativeRuntimeProbeInfo +{ + public uint AbiVersion; + public uint GameInputApiVersion; + public uint PointerSize; + public uint ShimInfoSize; + public uint RuntimeProbeInfoSize; + public uint DeviceInfoSize; + public uint GamepadStateSize; + public uint DiagnosticsSnapshotSize; + public uint AttemptedModuleKind; + public uint LoadedModuleKind; + public int LoadLibraryHResult; + public int GetProcAddressHResult; + public int InitializeHResult; + public int FinalHResult; + public uint LoadLibraryWin32Error; + public uint GetProcAddressWin32Error; + public uint InitializeWin32Error; + public uint StringTruncationFlags; + public fixed byte AttemptedModulePath[512]; + public fixed byte LoadedModulePath[512]; + + public readonly GameInputRuntimeProbeInfo ToProbeInfo() + { + string attemptedModulePath; + string loadedModulePath; + + fixed (byte* attemptedModulePathPtr = AttemptedModulePath) + fixed (byte* loadedModulePathPtr = LoadedModulePath) + { + attemptedModulePath = Marshal.PtrToStringUTF8((nint)attemptedModulePathPtr) ?? string.Empty; + loadedModulePath = Marshal.PtrToStringUTF8((nint)loadedModulePathPtr) ?? string.Empty; + } + + return new GameInputRuntimeProbeInfo( + AbiVersion, + GameInputApiVersion, + new GameInputAbiInfo( + PointerSize, + ShimInfoSize, + RuntimeProbeInfoSize, + DeviceInfoSize, + GamepadStateSize, + DiagnosticsSnapshotSize), + (GameInputShimModuleKind)AttemptedModuleKind, + (GameInputShimModuleKind)LoadedModuleKind, + LoadLibraryHResult, + GetProcAddressHResult, + InitializeHResult, + FinalHResult, + LoadLibraryWin32Error, + GetProcAddressWin32Error, + InitializeWin32Error, + (GameInputStringTruncationFlags)StringTruncationFlags, + attemptedModulePath, + loadedModulePath); + } +} + +/// +/// Native shim 回傳的裝置資訊。 +/// +[StructLayout(LayoutKind.Sequential)] +internal unsafe struct GameInputNativeDeviceInfo +{ + public ushort VendorId; + public ushort ProductId; + public ushort RevisionNumber; + public ushort UsagePage; + public ushort UsageId; + public ushort Reserved; + public uint DeviceFamily; + public uint SupportedInput; + public uint SupportedRumbleMotors; + public uint SupportedSystemButtons; + public uint GamepadSupportedLayout; + public uint GamepadExtraButtonCount; + public uint GamepadExtraAxisCount; + public uint ForceFeedbackMotorCount; + public uint InputReportCount; + public uint OutputReportCount; + public uint ExtraButtonCount; + public uint ExtraAxisCount; + public uint ExtraButtonIndexCount; + public uint ExtraAxisIndexCount; + public uint HasInputMapper; + public uint StringTruncationFlags; + public GameInputNativeVersionInfo HardwareVersion; + public GameInputNativeVersionInfo FirmwareVersion; + public fixed byte ExtraButtonIndexes[32]; + public fixed byte ExtraAxisIndexes[32]; + public fixed byte DeviceId[65]; + public fixed byte DeviceRootId[65]; + public fixed byte ContainerId[39]; + public fixed byte DisplayName[256]; + public fixed byte PnpPath[512]; + + public GameInputDeviceInfo ToDeviceInfo() + { + string parsedDeviceId; + string parsedDeviceRootId; + string parsedContainerId; + string parsedDisplayName; + string parsedPnpPath; + byte[] extraButtonIndexes; + byte[] extraAxisIndexes; + + fixed (byte* extraButtonIndexesPtr = ExtraButtonIndexes) + fixed (byte* extraAxisIndexesPtr = ExtraAxisIndexes) + fixed (byte* deviceIdPtr = DeviceId) + fixed (byte* deviceRootIdPtr = DeviceRootId) + fixed (byte* containerIdPtr = ContainerId) + fixed (byte* displayNamePtr = DisplayName) + fixed (byte* pnpPathPtr = PnpPath) + { + extraButtonIndexes = ReadIndexes(extraButtonIndexesPtr, ExtraButtonIndexCount); + extraAxisIndexes = ReadIndexes(extraAxisIndexesPtr, ExtraAxisIndexCount); + parsedDeviceId = Marshal.PtrToStringUTF8((nint)deviceIdPtr) ?? string.Empty; + parsedDeviceRootId = Marshal.PtrToStringUTF8((nint)deviceRootIdPtr) ?? string.Empty; + parsedContainerId = Marshal.PtrToStringUTF8((nint)containerIdPtr) ?? string.Empty; + parsedDisplayName = Marshal.PtrToStringUTF8((nint)displayNamePtr) ?? string.Empty; + parsedPnpPath = Marshal.PtrToStringUTF8((nint)pnpPathPtr) ?? string.Empty; + } + + GameInputGamepadCapabilities capabilities = new( + (GameInputGamepadButtons)GamepadSupportedLayout, + GamepadExtraButtonCount, + GamepadExtraAxisCount, + ExtraButtonCount, + ExtraAxisCount, + extraButtonIndexes, + extraAxisIndexes, + HasInputMapper != 0); + + return new GameInputDeviceInfo( + parsedDeviceId, + VendorId, + ProductId, + RevisionNumber, + UsagePage, + UsageId, + DeviceFamily, + (GameInputKind)SupportedInput, + (GameInputRumbleMotors)SupportedRumbleMotors, + SupportedSystemButtons, + HardwareVersion.ToVersionInfo(), + FirmwareVersion.ToVersionInfo(), + parsedDeviceRootId, + parsedContainerId, + parsedPnpPath, + (GameInputStringTruncationFlags)StringTruncationFlags, + capabilities, + parsedDisplayName); + } + + private static byte[] ReadIndexes(byte* source, uint count) + { + int length = (int)Math.Min(count, 32); + + if (length == 0) + { + return []; + } + + byte[] indexes = new byte[length]; + + for (int i = 0; i < length; i++) + { + indexes[i] = source[i]; + } + + return indexes; + } +} + +/// +/// Native shim 回傳的診斷計數器快照。 +/// +[StructLayout(LayoutKind.Sequential)] +internal struct GameInputNativeDiagnosticsSnapshot +{ + public ulong MissingReadingCount; + public ulong RepeatedTimestampCount; + public ulong BackwardTimestampCount; + public ulong DeviceUnavailableRefreshCount; + public ulong LastReadingTimestamp; + public int LastReadHResult; + public uint LastReadDeviceStatus; + public uint Reserved; + + public readonly GameInputDiagnosticsSnapshot ToDiagnosticsSnapshot() + => new( + MissingReadingCount, + RepeatedTimestampCount, + BackwardTimestampCount, + DeviceUnavailableRefreshCount, + LastReadingTimestamp, + LastReadHResult, + LastReadDeviceStatus); +} diff --git a/src/InputBox/Core/Input/GamepadControllerFactory.cs b/src/InputBox/Core/Input/GamepadControllerFactory.cs new file mode 100644 index 0000000..33e58b2 --- /dev/null +++ b/src/InputBox/Core/Input/GamepadControllerFactory.cs @@ -0,0 +1,92 @@ +using InputBox.Core.Configuration; + +namespace InputBox.Core.Input; + +/// +/// 建立遊戲控制器後端的結果。 +/// +/// 已建立的控制器。 +/// 是否因 GameInput 初始化失敗而退避至 XInput。 +/// GameInput 初始化失敗例外;未退避時為 null。 +internal readonly record struct GamepadControllerCreationResult( + IGamepadController Controller, + bool FellBackToXInput, + Exception? GameInputFailure); + +/// +/// 根據設定建立遊戲控制器後端,並集中處理 GameInput 退避至 XInput 的策略。 +/// +internal static class GamepadControllerFactory +{ + /// + /// 建立目前設定指定的控制器後端。 + /// + /// 使用者設定的控制器後端。 + /// 輸入狀態內容。 + /// 連發設定。 + /// 控制器建立結果。 + public static Task CreateAsync( + AppSettings.GamepadProvider provider, + IInputContext context, + GamepadRepeatSettings repeatSettings) + => CreateAsync( + provider, + context, + repeatSettings, + CreateGameInputControllerAsync, + CreateXInputController); + + /// + /// 建立目前設定指定的控制器後端,供測試注入後端建立函式。 + /// + /// 使用者設定的控制器後端。 + /// 輸入狀態內容。 + /// 連發設定。 + /// GameInput 建立函式。 + /// XInput 建立函式。 + /// 控制器建立結果。 + internal static async Task CreateAsync( + AppSettings.GamepadProvider provider, + IInputContext context, + GamepadRepeatSettings repeatSettings, + Func> gameInputFactory, + Func xInputFactory) + { + if (provider != AppSettings.GamepadProvider.GameInput) + { + return new GamepadControllerCreationResult( + xInputFactory(context, repeatSettings), + FellBackToXInput: false, + GameInputFailure: null); + } + + try + { + return new GamepadControllerCreationResult( + await gameInputFactory(context, repeatSettings).ConfigureAwait(false), + FellBackToXInput: false, + GameInputFailure: null); + } + catch (Exception ex) + { + return new GamepadControllerCreationResult( + xInputFactory(context, repeatSettings), + FellBackToXInput: true, + GameInputFailure: ex); + } + } + + private static async Task CreateGameInputControllerAsync( + IInputContext context, + GamepadRepeatSettings repeatSettings) + => await GameInputGamepadController.CreateAsync(context, repeatSettings).ConfigureAwait(false); + + private static IGamepadController CreateXInputController( + IInputContext context, + GamepadRepeatSettings repeatSettings) + { + uint activeUserIndex = XInputGamepadController.GetFirstConnectedUserIndex(); + + return new XInputGamepadController(context, activeUserIndex, repeatSettings); + } +} \ No newline at end of file diff --git a/src/InputBox/Core/Input/XInputGamepadController.cs b/src/InputBox/Core/Input/XInputGamepadController.cs index b9a12ec..5d7d2d1 100644 --- a/src/InputBox/Core/Input/XInputGamepadController.cs +++ b/src/InputBox/Core/Input/XInputGamepadController.cs @@ -1771,22 +1771,12 @@ private static void ApplyStickToButtons( wasUp = previousState.Has(XInput.GamepadButton.DpadUp), wasDown = previousState.Has(XInput.GamepadButton.DpadDown); - // 右向映射在筆電環境較容易受正偏噪聲影響, - // 這裡對「已在右向」時使用更高的退出門檻,避免進入後黏滯在 DPadRight。 - int rightExitThreshold = Math.Max( - config.ThumbDeadzoneExit, - (int)(config.ThumbDeadzoneEnter * 0.75f)), - thresholdNegative = wasLeft ? - config.ThumbDeadzoneExit : - config.ThumbDeadzoneEnter, - thresholdPositive = wasRight ? - rightExitThreshold : - config.ThumbDeadzoneEnter, - horizontalDirection = correctedLeftThumbX < -thresholdNegative ? - -1 : - correctedLeftThumbX > thresholdPositive ? - 1 : - 0; + int horizontalDirection = GamepadDeadzoneHysteresis.ResolveDirection( + correctedLeftThumbX, + wasLeft, + wasRight, + config.ThumbDeadzoneEnter, + config.ThumbDeadzoneExit); if (horizontalDirection < 0 && !GamepadMappedDirectionGuard.IsSuppressed(suppressedDirections, MappedGamepadDirection.Left)) @@ -2291,8 +2281,10 @@ private void ClearAllEvents() LSClickPressed = null; RSClickPressed = null; LeftShoulderPressed = null; + LeftShoulderReleased = null; LeftShoulderRepeat = null; RightShoulderPressed = null; + RightShoulderReleased = null; RightShoulderRepeat = null; LeftTriggerPressed = null; RightTriggerPressed = null; diff --git a/src/InputBox/Core/Interop/DllResolver.cs b/src/InputBox/Core/Interop/DllResolver.cs index 5593a3b..9332b8b 100644 --- a/src/InputBox/Core/Interop/DllResolver.cs +++ b/src/InputBox/Core/Interop/DllResolver.cs @@ -19,17 +19,29 @@ internal class DllResolver private static volatile nint _cachedHandle = IntPtr.Zero; /// - /// 自訂的 XInput 載入解析器,用於覆寫 DllImport 的載入邏輯,僅在要求載入 "xinput1_4.dll" 時介入,否則直接返回 IntPtr.Zero + /// 快取的自有 GameInput native shim handle,用於避免重複載入。 + /// 目前設計假設同一個行程只會解析並載入單一版本/位置的 InputBox.GameInput.Native。 + /// + private static volatile nint _cachedGameInputShimHandle = IntPtr.Zero; + + /// + /// 自訂的 native 載入解析器,用於覆寫 XInput 與自有 GameInput shim 的載入邏輯。 /// /// 開啟端要求載入的 DLL 名稱 - /// 觸發載入的組件(未使用) - /// DllImportSearchPath 設定(未使用) + /// 觸發載入的組件。 + /// DllImportSearchPath 設定。 /// 成功載入時回傳 DLL handle;若無法載入或名稱不符則回傳 IntPtr.Zero。 - public static nint ResolveXInput( + public static nint ResolveNativeLibrary( string libraryName, - Assembly _, - DllImportSearchPath? __) + Assembly assembly, + DllImportSearchPath? searchPath) { + if (libraryName.Equals("InputBox.GameInput.Native", StringComparison.OrdinalIgnoreCase) || + libraryName.Equals("InputBox.GameInput.Native.dll", StringComparison.OrdinalIgnoreCase)) + { + return ResolveGameInputShim(libraryName, assembly, searchPath); + } + // 僅攔截 xinput1_4.dll,其餘 DLL 交由系統處理。 if (!libraryName.Equals("xinput1_4.dll", StringComparison.OrdinalIgnoreCase)) { @@ -76,4 +88,37 @@ public static nint ResolveXInput( return IntPtr.Zero; } } + + /// + /// 解析自有 GameInput native shim。 + /// 首次成功載入後,後續不同 assembly/searchPath 的解析都會重用同一個 handle。 + /// + /// 要求載入的 library 名稱。 + /// 觸發載入的組件。 + /// DllImportSearchPath 設定。 + /// 成功載入時回傳 DLL handle;否則回傳 IntPtr.Zero。 + private static nint ResolveGameInputShim( + string libraryName, + Assembly assembly, + DllImportSearchPath? searchPath) + { + lock (ResolverLock) + { + if (_cachedGameInputShimHandle != IntPtr.Zero) + { + // Native shim 視為 process-wide singleton,避免重複載入造成匯出狀態分裂。 + return _cachedGameInputShimHandle; + } + + if (NativeLibrary.TryLoad(libraryName, assembly, searchPath, out nint handle) || + NativeLibrary.TryLoad($"{libraryName}.dll", assembly, searchPath, out handle)) + { + _cachedGameInputShimHandle = handle; + + return handle; + } + + return IntPtr.Zero; + } + } } \ No newline at end of file diff --git a/src/InputBox/Core/Services/PhraseService.cs b/src/InputBox/Core/Services/PhraseService.cs index da79ce9..5c191c3 100644 --- a/src/InputBox/Core/Services/PhraseService.cs +++ b/src/InputBox/Core/Services/PhraseService.cs @@ -671,18 +671,21 @@ private static bool TryNormalizeEntry(PhraseEntry entry, out PhraseEntry normali /// 包含成功狀態、錯誤類型與匯出筆數的匯出結果。 public ExportOutcome ExportToFile(string filePath) { - string json; - int count; - - lock (PhraseLock) + lock (PhrasePersistenceLock) { - count = _phrases.Count; - json = JsonSerializer.Serialize(_phrases, Options); - } + string json; + int count; - return TryWriteJsonToPath(filePath, json, "片語匯出失敗") - ? new ExportOutcome(true, ExportError.None, count) - : new ExportOutcome(false, ExportError.Unknown); + lock (PhraseLock) + { + count = _phrases.Count; + json = JsonSerializer.Serialize(_phrases, Options); + } + + return TryWriteJsonToPath(filePath, json, "片語匯出失敗") + ? new ExportOutcome(true, ExportError.None, count) + : new ExportOutcome(false, ExportError.Unknown); + } } /// diff --git a/src/InputBox/InputBox.csproj b/src/InputBox/InputBox.csproj index 5a3da47..40a8d38 100644 --- a/src/InputBox/InputBox.csproj +++ b/src/InputBox/InputBox.csproj @@ -1,4 +1,4 @@ - + WinExe @@ -15,6 +15,8 @@ 輸入框 InputBox 垃圾桶 rubujo 在法律允許的最大範圍內,作者已放棄所有著作權及相關權利(CC0 1.0) + ..\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj + ..\InputBox.GameInput.Native\bin\x64\$(Configuration)\InputBox.GameInput.Native.dll @@ -52,10 +54,36 @@ - - + + + + + + + + + + + + + + + + <_Parameter1>InputBox.Tests @@ -77,4 +105,4 @@ - \ No newline at end of file + diff --git a/src/InputBox/MainForm.Gamepad.cs b/src/InputBox/MainForm.Gamepad.cs index 800422f..8a03f16 100644 --- a/src/InputBox/MainForm.Gamepad.cs +++ b/src/InputBox/MainForm.Gamepad.cs @@ -491,61 +491,36 @@ private GamepadEventBinder.BindingMap CreateGamepadBindingMap(IGamepadController /// 非同步作業。 private async Task InitializeConfiguredControllerAsync(AppSettings config, GamepadRepeatSettings gamepadRepeatSettings) { - // 根據設定嘗試建立遊戲控制器實作。 - if (config.GamepadProviderType == AppSettings.GamepadProvider.GameInput) - { - await TryInitializeGameInputControllerAsync(gamepadRepeatSettings); + IInputContext? inputContext = _inputContext; + if (inputContext == null) + { return; } - // 預設使用 XInput 實作。 - CreateXInputController(gamepadRepeatSettings); - } + GamepadControllerCreationResult result = await GamepadControllerFactory.CreateAsync( + config.GamepadProviderType, + inputContext, + gamepadRepeatSettings); - /// - /// 嘗試初始化 GameInput;失敗時退避至 XInput - /// - /// 控制器連發設定。 - /// 非同步作業。 - private async Task TryInitializeGameInputControllerAsync(GamepadRepeatSettings gamepadRepeatSettings) - { - try + if (IsDisposed) { - IInputContext? inputContext = _inputContext; - - if (inputContext == null) - { - CreateXInputController(gamepadRepeatSettings); - - return; - } - - // 嘗試初始化 GameInput。 - GameInputGamepadController controller = await GameInputGamepadController.CreateAsync( - inputContext, - gamepadRepeatSettings); + result.Controller.Dispose(); - if (IsDisposed) - { - controller.Dispose(); + return; + } - return; - } + _gamepadController = result.Controller; - _gamepadController = controller; - } - catch (Exception ex) + if (result.FellBackToXInput && + result.GameInputFailure != null) { - LoggerService.LogException(ex, "GameInput 初始化失敗,嘗試退避至 XInput"); + LoggerService.LogException(result.GameInputFailure, "GameInput 初始化失敗,嘗試退避至 XInput"); - Debug.WriteLine($"[控制器] GameInput 初始化失敗,嘗試退避至 XInput:{ex.Message}"); + Debug.WriteLine($"[控制器] GameInput 初始化失敗,嘗試退避至 XInput:{result.GameInputFailure.Message}"); // 告知使用者已切換至相容模式。 AnnounceA11y(Strings.A11y_Gamepad_Fallback); - - // 退避至 XInput。 - CreateXInputController(gamepadRepeatSettings); } } @@ -836,17 +811,6 @@ private void NavigateToolStrip(ToolStrip ts, bool forward) } } - /// - /// 建立 XInput 控制器實例 - /// - /// 控制器連發設定。 - private void CreateXInputController(GamepadRepeatSettings settings) - { - uint activeUserIndex = XInputGamepadController.GetFirstConnectedUserIndex(); - - _gamepadController = new XInputGamepadController(_inputContext!, activeUserIndex, settings); - } - /// /// 判斷目前是否應抑制控制器輸入(非作用中或擷取模式)。 /// @@ -2636,4 +2600,4 @@ private void ExpandSelection(int direction) PlaySelectionFeedback(safeDirection, wordGranularity); } -} +} \ No newline at end of file diff --git a/src/InputBox/Program.cs b/src/InputBox/Program.cs index a6fc203..475bf86 100644 --- a/src/InputBox/Program.cs +++ b/src/InputBox/Program.cs @@ -158,8 +158,8 @@ static void Main() try { - // 載入 XInput DLL。 - NativeLibrary.SetDllImportResolver(typeof(XInput).Assembly, DllResolver.ResolveXInput); + // 載入控制器相關 native DLL。 + NativeLibrary.SetDllImportResolver(typeof(XInput).Assembly, DllResolver.ResolveNativeLibrary); // To customize application configuration such as set high DPI settings or default font, // see https://aka.ms/applicationconfiguration. @@ -591,4 +591,4 @@ static void HandleException(Exception? ex) // 發生嚴重錯誤後強制結束進程。 Environment.Exit(1); } -} +} \ No newline at end of file diff --git a/tests/InputBox.Tests/GameInputPrimitivesTests.cs b/tests/InputBox.Tests/GameInputPrimitivesTests.cs new file mode 100644 index 0000000..95a04e8 --- /dev/null +++ b/tests/InputBox.Tests/GameInputPrimitivesTests.cs @@ -0,0 +1,363 @@ +using InputBox.Core.Input; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using Xunit; + +namespace InputBox.Tests; + +/// +/// 驗證自有 GameInput shim 的受控資料模型,避免 native ABI 擴充後破壞既有 Gamepad 語意。 +/// +public sealed class GameInputPrimitivesTests +{ + /// + /// Managed P/Invoke 宣告的 EntryPoint 清單應維持固定,讓 CI/release export 驗證能防止執行期才發現符號缺失。 + /// + [Fact] + public void GameInputNativeMethods_DeclaredEntryPoints_MatchExpectedExportSet() + { + string sourcePath = Path.Combine( + FindRepositoryRoot(), + "src", + "InputBox", + "Core", + "Input", + "GameInputNative.cs"); + string source = File.ReadAllText(sourcePath, Encoding.UTF8); + + string[] actual = Regex + .Matches(source, @"EntryPoint\s*=\s*""(?InputBoxGameInput[^""]+)""") + .Select(match => match.Groups["name"].Value) + .Distinct(StringComparer.Ordinal) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + + string[] expected = + [ + "InputBoxGameInputCreate", + "InputBoxGameInputDestroy", + "InputBoxGameInputGetDeviceCount", + "InputBoxGameInputGetDeviceInfo", + "InputBoxGameInputGetDeviceStatus", + "InputBoxGameInputGetDiagnosticsSnapshot", + "InputBoxGameInputGetShimInfo", + "InputBoxGameInputProbeRuntime", + "InputBoxGameInputReadGamepadState", + "InputBoxGameInputRefreshDevices", + "InputBoxGameInputRegisterDeviceCallback", + "InputBoxGameInputRegisterReadingCallback", + "InputBoxGameInputSetFocusPolicy", + "InputBoxGameInputSetRumbleState", + "InputBoxGameInputUnregisterCallback" + ]; + + Assert.Equal(expected.OrderBy(name => name, StringComparer.Ordinal), actual); + } + + /// + /// GameInput v3 官方新增的 C/Z、搖桿方向與背鍵位元,應與 Microsoft header 常數一致且不覆蓋既有按鍵。 + /// + [Fact] + public void GameInputGamepadButtons_OfficialV3Values_MatchMicrosoftConstants() + { + Assert.Equal(0x00004000u, (uint)GameInputGamepadButtons.C); + Assert.Equal(0x00008000u, (uint)GameInputGamepadButtons.Z); + Assert.Equal(0x00040000u, (uint)GameInputGamepadButtons.LeftThumbstickUp); + Assert.Equal(0x00080000u, (uint)GameInputGamepadButtons.LeftThumbstickDown); + Assert.Equal(0x00100000u, (uint)GameInputGamepadButtons.LeftThumbstickLeft); + Assert.Equal(0x00200000u, (uint)GameInputGamepadButtons.LeftThumbstickRight); + Assert.Equal(0x00400000u, (uint)GameInputGamepadButtons.RightThumbstickUp); + Assert.Equal(0x00800000u, (uint)GameInputGamepadButtons.RightThumbstickDown); + Assert.Equal(0x01000000u, (uint)GameInputGamepadButtons.RightThumbstickLeft); + Assert.Equal(0x02000000u, (uint)GameInputGamepadButtons.RightThumbstickRight); + Assert.Equal(0x04000000u, (uint)GameInputGamepadButtons.PaddleLeft1); + Assert.Equal(0x08000000u, (uint)GameInputGamepadButtons.PaddleLeft2); + Assert.Equal(0x10000000u, (uint)GameInputGamepadButtons.PaddleRight1); + Assert.Equal(0x20000000u, (uint)GameInputGamepadButtons.PaddleRight2); + } + + /// + /// Native gamepad state 的 timestamp 與 input kind 應被帶入快照,供診斷使用但不改變按鍵資料。 + /// + [Fact] + public void GamepadStateSnapshot_NativeState_CarriesTimestampAndInputKind() + { + GameInputGamepadState nativeState = new() + { + Timestamp = 123456789, + InputKind = GameInputKind.Gamepad, + Buttons = GameInputGamepadButtons.A | GameInputGamepadButtons.PaddleLeft1, + LeftTrigger = 0.25f, + RightTrigger = 0.75f, + LeftThumbstickX = -0.5f, + LeftThumbstickY = 0.5f, + RightThumbstickX = -1.0f, + RightThumbstickY = 1.0f + }; + + GamepadStateSnapshot snapshot = new(nativeState); + + Assert.Equal(123456789ul, snapshot.Timestamp); + Assert.Equal(GameInputKind.Gamepad, snapshot.InputKind); + Assert.Equal(nativeState.Buttons, snapshot.Buttons); + Assert.Equal(nativeState.LeftTrigger, snapshot.LeftTrigger); + Assert.Equal(nativeState.RightTrigger, snapshot.RightTrigger); + } + + /// + /// GameInput edge detection 應忽略 timestamp 差異,避免硬體持續回報相同狀態時被誤判為新按鍵。 + /// + [Fact] + public void HasSameInputValues_TimestampOnlyChanged_ReturnsTrue() + { + MethodInfo method = typeof(GameInputGamepadController).GetMethod( + "HasSameInputValues", + BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("找不到 GameInputGamepadController.HasSameInputValues。"); + + GamepadStateSnapshot first = new( + 1, + GameInputKind.Gamepad, + GameInputGamepadButtons.A, + 0.1f, + 0.2f, + -0.3f, + 0.4f, + -0.5f, + 0.6f); + GamepadStateSnapshot second = first with { Timestamp = 2 }; + + Assert.True((bool)method.Invoke(null, [second, first])!); + } + + /// + /// Shim info 應保留 native struct size metadata,供 managed create 階段拒絕載錯或 ABI 不符的 DLL。 + /// + [Fact] + public unsafe void ToShimInfo_NativeAbiSizes_PreservesManagedLayoutMetadata() + { + GameInputNativeShimInfo nativeInfo = CreateNativeShimInfo(); + byte* loadedModulePath = nativeInfo.LoadedModulePath; + WriteUtf8(loadedModulePath, 512, @"C:\Windows\System32\GameInput.dll"); + + GameInputShimInfo shimInfo = nativeInfo.ToShimInfo(); + + Assert.Equal(GameInputAbiInfo.Managed, shimInfo.AbiInfo); + Assert.True(shimInfo.AbiInfo.MatchesManagedLayout); + Assert.Equal(GameInputShimModuleKind.SystemGameInput, shimInfo.LoadedModuleKind); + Assert.Equal(@"C:\Windows\System32\GameInput.dll", shimInfo.LoadedModulePath); + } + + /// + /// ABI size mismatch 應被視為 shim 載錯,避免 managed layer 用錯 struct layout 讀取 native 資料。 + /// + [Fact] + public void ThrowIfMismatch_DifferentNativeDeviceInfoSize_ThrowsBadImageFormatException() + { + GameInputAbiInfo mismatched = GameInputAbiInfo.Managed with + { + DeviceInfoSize = GameInputAbiInfo.Managed.DeviceInfoSize + 4 + }; + + Assert.Throws(mismatched.ThrowIfMismatch); + } + + /// + /// Runtime probe 應完整保留載入階段錯誤資訊,讓 fallback XInput 時能定位 DLL、export 或 initialize 失敗。 + /// + [Fact] + public unsafe void ToProbeInfo_NativeRuntimeProbe_PreservesLoadDiagnostics() + { + GameInputNativeRuntimeProbeInfo nativeInfo = new() + { + AbiVersion = 3, + GameInputApiVersion = 0x12345678, + PointerSize = GameInputAbiInfo.Managed.PointerSize, + ShimInfoSize = GameInputAbiInfo.Managed.ShimInfoSize, + RuntimeProbeInfoSize = GameInputAbiInfo.Managed.RuntimeProbeInfoSize, + DeviceInfoSize = GameInputAbiInfo.Managed.DeviceInfoSize, + GamepadStateSize = GameInputAbiInfo.Managed.GamepadStateSize, + DiagnosticsSnapshotSize = GameInputAbiInfo.Managed.DiagnosticsSnapshotSize, + AttemptedModuleKind = (uint)GameInputShimModuleKind.RegistryGameInputRedist, + LoadedModuleKind = (uint)GameInputShimModuleKind.RegistryGameInputRedist, + LoadLibraryHResult = unchecked((int)0x8007007E), + GetProcAddressHResult = 0, + InitializeHResult = unchecked((int)0x80004005), + FinalHResult = unchecked((int)0x80004005), + LoadLibraryWin32Error = 126, + GetProcAddressWin32Error = 0, + InitializeWin32Error = 0, + StringTruncationFlags = (uint)GameInputStringTruncationFlags.AttemptedModulePath + }; + + byte* attemptedModulePath = nativeInfo.AttemptedModulePath; + byte* loadedModulePath = nativeInfo.LoadedModulePath; + WriteUtf8(attemptedModulePath, 512, @"C:\Program Files\GameInput\GameInputRedist.dll"); + WriteUtf8(loadedModulePath, 512, @"C:\Program Files\GameInput\GameInputRedist.dll"); + + GameInputRuntimeProbeInfo probeInfo = nativeInfo.ToProbeInfo(); + + Assert.Equal(GameInputAbiInfo.Managed, probeInfo.AbiInfo); + Assert.Equal(GameInputShimModuleKind.RegistryGameInputRedist, probeInfo.AttemptedModuleKind); + Assert.Equal(GameInputShimModuleKind.RegistryGameInputRedist, probeInfo.LoadedModuleKind); + Assert.Equal(unchecked((int)0x8007007E), probeInfo.LoadLibraryHResult); + Assert.Equal(unchecked((int)0x80004005), probeInfo.InitializeHResult); + Assert.Equal(126u, probeInfo.LoadLibraryWin32Error); + Assert.True(probeInfo.StringTruncationFlags.HasFlag(GameInputStringTruncationFlags.AttemptedModulePath)); + Assert.Equal(@"C:\Program Files\GameInput\GameInputRedist.dll", probeInfo.AttemptedModulePath); + } + + /// + /// Shim diagnostics counter 應只作為診斷快照保留,不與 gamepad state edge detection 混在一起。 + /// + [Fact] + public void ToDiagnosticsSnapshot_NativeCounters_PreservesStaleReadingMetadata() + { + GameInputNativeDiagnosticsSnapshot nativeSnapshot = new() + { + MissingReadingCount = 3, + RepeatedTimestampCount = 4, + BackwardTimestampCount = 5, + DeviceUnavailableRefreshCount = 6, + LastReadingTimestamp = 7, + LastReadHResult = unchecked((int)0x8007048F), + LastReadDeviceStatus = 0x20 + }; + + GameInputDiagnosticsSnapshot snapshot = nativeSnapshot.ToDiagnosticsSnapshot(); + + Assert.Equal(3ul, snapshot.MissingReadingCount); + Assert.Equal(4ul, snapshot.RepeatedTimestampCount); + Assert.Equal(5ul, snapshot.BackwardTimestampCount); + Assert.Equal(6ul, snapshot.DeviceUnavailableRefreshCount); + Assert.Equal(7ul, snapshot.LastReadingTimestamp); + Assert.Equal(unchecked((int)0x8007048F), snapshot.LastReadHResult); + Assert.Equal(0x20u, snapshot.LastReadDeviceStatus); + } + + /// + /// Managed GameInput kind 應維持 gamepad-only subset,不暴露 keyboard、mouse、sensor 或 raw report 路徑。 + /// + [Fact] + public void GameInputKind_ManagedSubset_OnlyExposesUnknownAndGamepad() + { + GameInputKind[] values = Enum.GetValues(); + + Assert.Equal([GameInputKind.Unknown, GameInputKind.Gamepad], values); + } + + /// + /// Native device info 擴充欄位應完整轉成 managed model,保留 VID/PID、版本、PnP path 與 extra control indexes。 + /// + [Fact] + public unsafe void ToDeviceInfo_ExtendedNativeFields_PreservesGamepadCapabilities() + { + GameInputNativeDeviceInfo nativeInfo = new() + { + VendorId = 0x054C, + ProductId = 0x0CE6, + RevisionNumber = 7, + UsagePage = 1, + UsageId = 5, + DeviceFamily = 3, + SupportedInput = (uint)GameInputKind.Gamepad, + SupportedRumbleMotors = (uint)(GameInputRumbleMotors.LowFrequency | GameInputRumbleMotors.HighFrequency), + SupportedSystemButtons = 0x12, + GamepadSupportedLayout = (uint)(GameInputGamepadButtons.A | GameInputGamepadButtons.B | GameInputGamepadButtons.PaddleLeft1), + GamepadExtraButtonCount = 2, + GamepadExtraAxisCount = 1, + ForceFeedbackMotorCount = 4, + InputReportCount = 2, + OutputReportCount = 1, + ExtraButtonCount = 2, + ExtraAxisCount = 1, + ExtraButtonIndexCount = 2, + ExtraAxisIndexCount = 1, + HasInputMapper = 1, + StringTruncationFlags = (uint)GameInputStringTruncationFlags.PnpPath, + HardwareVersion = new GameInputNativeVersionInfo { Major = 1, Minor = 2, Build = 3, Revision = 4 }, + FirmwareVersion = new GameInputNativeVersionInfo { Major = 5, Minor = 6, Build = 7, Revision = 8 } + }; + + byte* deviceId = nativeInfo.DeviceId; + byte* deviceRootId = nativeInfo.DeviceRootId; + byte* containerId = nativeInfo.ContainerId; + byte* displayName = nativeInfo.DisplayName; + byte* pnpPath = nativeInfo.PnpPath; + byte* extraButtonIndexes = nativeInfo.ExtraButtonIndexes; + byte* extraAxisIndexes = nativeInfo.ExtraAxisIndexes; + + WriteUtf8(deviceId, 65, "00112233445566778899AABBCCDDEEFF"); + WriteUtf8(deviceRootId, 65, "FFEEDDCCBBAA99887766554433221100"); + WriteUtf8(containerId, 39, "{00112233-4455-6677-8899-AABBCCDDEEFF}"); + WriteUtf8(displayName, 256, "DualSense Wireless Controller"); + WriteUtf8(pnpPath, 512, @"HID\VID_054C&PID_0CE6"); + extraButtonIndexes[0] = 4; + extraButtonIndexes[1] = 5; + extraAxisIndexes[0] = 6; + + GameInputDeviceInfo info = nativeInfo.ToDeviceInfo(); + + Assert.Equal("00112233445566778899AABBCCDDEEFF", info.DeviceId); + Assert.Equal("FFEEDDCCBBAA99887766554433221100", info.DeviceRootId); + Assert.Equal("{00112233-4455-6677-8899-AABBCCDDEEFF}", info.ContainerId); + Assert.Equal("DualSense Wireless Controller", info.GetDisplayName()); + Assert.Equal(@"HID\VID_054C&PID_0CE6", info.PnpPath); + Assert.Equal((ushort)0x054C, info.VendorId); + Assert.Equal((ushort)0x0CE6, info.ProductId); + Assert.Equal((ushort)7, info.RevisionNumber); + Assert.Equal(GameInputStringTruncationFlags.PnpPath, info.StringTruncationFlags); + Assert.Equal(new GameInputVersionInfo(1, 2, 3, 4), info.HardwareVersion); + Assert.Equal(new GameInputVersionInfo(5, 6, 7, 8), info.FirmwareVersion); + Assert.True(info.GamepadCapabilities.HasInputMapper); + Assert.Equal(GameInputGamepadButtons.A | GameInputGamepadButtons.B | GameInputGamepadButtons.PaddleLeft1, info.GamepadCapabilities.SupportedLayout); + Assert.Equal(new byte[] { 4, 5 }, info.GamepadCapabilities.ExtraButtonIndexes); + Assert.Equal(new byte[] { 6 }, info.GamepadCapabilities.ExtraAxisIndexes); + } + + private static GameInputNativeShimInfo CreateNativeShimInfo() + => new() + { + AbiVersion = 3, + GameInputApiVersion = 0x12345678, + PointerSize = GameInputAbiInfo.Managed.PointerSize, + ShimInfoSize = GameInputAbiInfo.Managed.ShimInfoSize, + RuntimeProbeInfoSize = GameInputAbiInfo.Managed.RuntimeProbeInfoSize, + DeviceInfoSize = GameInputAbiInfo.Managed.DeviceInfoSize, + GamepadStateSize = GameInputAbiInfo.Managed.GamepadStateSize, + DiagnosticsSnapshotSize = GameInputAbiInfo.Managed.DiagnosticsSnapshotSize, + LoadedModuleKind = (uint)GameInputShimModuleKind.SystemGameInput + }; + + private static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, "src", "InputBox", "Core", "Input", "GameInputNative.cs"); + if (File.Exists(candidate)) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("找不到 InputBox repository root。"); + } + + private static unsafe void WriteUtf8(byte* destination, int destinationLength, string value) + { + byte[] bytes = Encoding.UTF8.GetBytes(value); + int count = Math.Min(bytes.Length, destinationLength - 1); + + for (int i = 0; i < count; i++) + { + destination[i] = bytes[i]; + } + + destination[count] = 0; + } +} diff --git a/tests/InputBox.Tests/GamepadControllerFactoryTests.cs b/tests/InputBox.Tests/GamepadControllerFactoryTests.cs new file mode 100644 index 0000000..d2ccda6 --- /dev/null +++ b/tests/InputBox.Tests/GamepadControllerFactoryTests.cs @@ -0,0 +1,251 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Feedback; +using InputBox.Core.Input; +using Xunit; + +namespace InputBox.Tests; + +/// +/// 驗證控制器後端建立策略,確保 GameInput 是選用後端且失敗時會退避至 XInput。 +/// +public sealed class GamepadControllerFactoryTests +{ + /// + /// 使用者設定為 XInput 時,應直接建立 XInput 控制器,不觸發 GameInput 初始化。 + /// + [Fact] + public async Task CreateAsync_XInputProvider_UsesXInputFactory() + { + using var context = new StubInputContext(); + using var xInputController = new StubGamepadController("XInput"); + + GamepadControllerCreationResult result = await GamepadControllerFactory.CreateAsync( + AppSettings.GamepadProvider.XInput, + context, + new GamepadRepeatSettings(), + static (_, _) => throw new InvalidOperationException("GameInput factory should not be called."), + (_, _) => xInputController); + + Assert.Same(xInputController, result.Controller); + Assert.False(result.FellBackToXInput); + Assert.Null(result.GameInputFailure); + } + + /// + /// 使用者設定為 GameInput 且初始化成功時,應保留 GameInput 控制器,不退避。 + /// + [Fact] + public async Task CreateAsync_GameInputProviderAndFactorySucceeds_UsesGameInputController() + { + using var context = new StubInputContext(); + using var gameInputController = new StubGamepadController("GameInput"); + using var xInputController = new StubGamepadController("XInput"); + + GamepadControllerCreationResult result = await GamepadControllerFactory.CreateAsync( + AppSettings.GamepadProvider.GameInput, + context, + new GamepadRepeatSettings(), + (_, _) => Task.FromResult(gameInputController), + (_, _) => xInputController); + + Assert.Same(gameInputController, result.Controller); + Assert.False(result.FellBackToXInput); + Assert.Null(result.GameInputFailure); + } + + /// + /// 使用者設定為 GameInput 但 shim 或 runtime 初始化失敗時,應退避為 XInput 並保留原始例外。 + /// + [Fact] + public async Task CreateAsync_GameInputFactoryThrows_ReturnsXInputFallback() + { + using var context = new StubInputContext(); + using var xInputController = new StubGamepadController("XInput"); + InvalidOperationException failure = new("Native shim unavailable."); + + GamepadControllerCreationResult result = await GamepadControllerFactory.CreateAsync( + AppSettings.GamepadProvider.GameInput, + context, + new GamepadRepeatSettings(), + (_, _) => Task.FromException(failure), + (_, _) => xInputController); + + Assert.Same(xInputController, result.Controller); + Assert.True(result.FellBackToXInput); + Assert.Same(failure, result.GameInputFailure); + } + + /// + /// 測試用輸入狀態內容。 + /// + private sealed class StubInputContext : IInputContext + { + /// + /// 測試中固定允許輸入。 + /// + public bool IsInputActive => true; + + /// + /// 測試替身不持有外部資源。 + /// + public void Dispose() + { + + } + } + + /// + /// 測試用控制器替身。 + /// + private sealed class StubGamepadController : IGamepadController + { + public StubGamepadController(string deviceName) + { + DeviceName = deviceName; + } + + public string DeviceName { get; } + + public string DeviceIdentity => DeviceName; + + public bool IsConnected => true; + + public GamepadCalibrationSnapshot CurrentCalibrationSnapshot => GamepadCalibrationSnapshot.Empty; + + public bool IsLeftShoulderHeld => false; + + public bool IsRightShoulderHeld => false; + + public bool IsLeftTriggerHeld => false; + + public bool IsRightTriggerHeld => false; + + public bool IsBackHeld => false; + + public bool IsBHeld => false; + + public bool IsXHeld => false; + + public VibrationMotorSupport VibrationMotorSupport => VibrationMotorSupport.DualMain; + + public GamepadRepeatSettings RepeatSettings { get; set; } = new(); + + public int ThumbDeadzoneEnter { get; set; } + + public int ThumbDeadzoneExit { get; set; } + + public event Action? ConnectionChanged; + public event Action? UpPressed; + public event Action? DownPressed; + public event Action? LeftPressed; + public event Action? RightPressed; + public event Action? LeftShoulderPressed; + public event Action? LeftShoulderReleased; + public event Action? LeftShoulderRepeat; + public event Action? RightShoulderPressed; + public event Action? RightShoulderReleased; + public event Action? RightShoulderRepeat; + public event Action? StartPressed; + public event Action? BackPressed; + public event Action? BackReleased; + public event Action? APressed; + public event Action? BPressed; + public event Action? XPressed; + public event Action? YPressed; + public event Action? UpRepeat; + public event Action? DownRepeat; + public event Action? LeftRepeat; + public event Action? RightRepeat; + public event Action? RSLeftPressed; + public event Action? RSRightPressed; + public event Action? RSLeftRepeat; + public event Action? RSRightRepeat; + public event Action? LSClickPressed; + public event Action? RSClickPressed; + public event Action? LeftTriggerPressed; + public event Action? RightTriggerPressed; + public event Action? LeftTriggerRepeat; + public event Action? RightTriggerRepeat; + + public Task VibrateAsync( + ushort strength, + int milliseconds = 60, + VibrationPriority priority = VibrationPriority.Normal, + CancellationToken ct = default) + => Task.CompletedTask; + + public Task VibrateAsync( + VibrationProfile profile, + VibrationPriority priority = VibrationPriority.Normal, + CancellationToken ct = default) + => Task.CompletedTask; + + public void StopVibration() + { + + } + + public void Pause() + { + + } + + public void Resume() + { + + } + + public void ResetCalibration() + { + + } + + public void Dispose() + { + TouchRegisteredEvents(); + } + + public ValueTask DisposeAsync() + { + TouchRegisteredEvents(); + + return ValueTask.CompletedTask; + } + + private void TouchRegisteredEvents() + { + _ = ConnectionChanged; + _ = UpPressed; + _ = DownPressed; + _ = LeftPressed; + _ = RightPressed; + _ = LeftShoulderPressed; + _ = LeftShoulderReleased; + _ = LeftShoulderRepeat; + _ = RightShoulderPressed; + _ = RightShoulderReleased; + _ = RightShoulderRepeat; + _ = StartPressed; + _ = BackPressed; + _ = BackReleased; + _ = APressed; + _ = BPressed; + _ = XPressed; + _ = YPressed; + _ = UpRepeat; + _ = DownRepeat; + _ = LeftRepeat; + _ = RightRepeat; + _ = RSLeftPressed; + _ = RSRightPressed; + _ = RSLeftRepeat; + _ = RSRightRepeat; + _ = LSClickPressed; + _ = RSClickPressed; + _ = LeftTriggerPressed; + _ = RightTriggerPressed; + _ = LeftTriggerRepeat; + _ = RightTriggerRepeat; + } + } +} \ No newline at end of file diff --git a/tests/InputBox.Tests/GamepadControllerPauseTests.cs b/tests/InputBox.Tests/GamepadControllerPauseTests.cs index c81d45b..d1d3ba7 100644 --- a/tests/InputBox.Tests/GamepadControllerPauseTests.cs +++ b/tests/InputBox.Tests/GamepadControllerPauseTests.cs @@ -1,7 +1,4 @@ -using GameInputDotNet.Interop.Enums; -using GameInputDotNet.Interop.Structs; -using GameInputDotNet.States; -using InputBox.Core.Configuration; +using InputBox.Core.Configuration; using InputBox.Core.Input; using InputBox.Core.Interop; using System.Reflection; @@ -175,6 +172,152 @@ public void IsConnected_GameInputController_UsesConnectionAvailabilityFlag() Assert.True(controller.IsConnected); } + /// + /// GameInput 即使目前沒有可用裝置,StopVibration 也應同步取消待執行震動並讓令牌失效。 + /// + [Fact] + public void StopVibration_GameInputControllerWithoutDevice_CancelsPendingRumbleState() + { + using var controller = (GameInputGamepadController)Activator.CreateInstance( + typeof(GameInputGamepadController), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + args: [new StubInputContext(), null], + culture: null)!; + + CancellationTokenSource vibrationCts = new(); + CancellationToken token = vibrationCts.Token; + + SetPrivateField(controller, "_vibrationCts", vibrationCts); + SetPrivateField(controller, "_vibrationToken", 10L); + + controller.StopVibration(); + + Assert.Null(GetPrivateField(controller, "_vibrationCts")); + Assert.True(token.IsCancellationRequested); + Assert.Equal(11L, GetPrivateField(controller, "_vibrationToken")); + } + + /// + /// GameInput 目前裝置連續讀不到 reading 時,應在重連閾值後觸發裝置重列舉,避免斷線後仍沿用上一幀狀態。 + /// + [Fact] + public void ShouldRefreshAfterMissingCurrentReading_WhenThresholdReached_ReturnsTrueAndResetsCounter() + { + using var controller = (GameInputGamepadController)Activator.CreateInstance( + typeof(GameInputGamepadController), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + args: [new StubInputContext(), null], + culture: null)!; + + MethodInfo method = typeof(GameInputGamepadController).GetMethod( + "ShouldRefreshAfterMissingCurrentReading", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("找不到 GameInputGamepadController.ShouldRefreshAfterMissingCurrentReading。"); + + for (int i = 1; i < AppSettings.GamepadReconnectThresholdFrames; i++) + { + Assert.False((bool)method.Invoke(controller, [])!); + Assert.Equal(i, GetPrivateField(controller, "_missingReadingFrameCounter")); + } + + Assert.True((bool)method.Invoke(controller, [])!); + Assert.Equal(0, GetPrivateField(controller, "_missingReadingFrameCounter")); + } + + /// + /// GameInput 裝置列舉後,只有狀態仍包含 Connected 的裝置才應被視為可用, + /// 避免拔除瞬間仍在列舉清單中的裝置造成假重連公告。 + /// + [Fact] + public void IsConnectedStatus_GameInputDeviceStatus_FiltersUnavailableDevices() + { + MethodInfo method = typeof(GameInputGamepadController).GetMethod( + "IsConnectedStatus", + BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("找不到 GameInputGamepadController.IsConnectedStatus。"); + + Assert.True((bool)method.Invoke(null, [GameInputDeviceStatus.Connected])!); + Assert.False((bool)method.Invoke(null, [(GameInputDeviceStatus)0])!); + } + + /// + /// XInput 控制器在 ClearAllEvents 後,必須一併清除 LeftShoulderReleased 與 + /// RightShoulderReleased 兩個事件訂閱,避免處置後仍殘留呼叫鏈造成事件洩漏。 + /// + [Fact] + public void ClearAllEvents_XInputController_ClearsShoulderReleasedSubscriptions() + { + using var controller = new XInputGamepadController(new StubInputContext()); + + controller.LeftShoulderReleased += static () => { }; + controller.RightShoulderReleased += static () => { }; + + Assert.NotNull(GetEventDelegate(controller, "LeftShoulderReleased")); + Assert.NotNull(GetEventDelegate(controller, "RightShoulderReleased")); + + InvokeClearAllEvents(controller); + + Assert.Null(GetEventDelegate(controller, "LeftShoulderReleased")); + Assert.Null(GetEventDelegate(controller, "RightShoulderReleased")); + } + + /// + /// GameInput 控制器在 ClearAllEvents 後,必須一併清除 LeftShoulderReleased 與 + /// RightShoulderReleased 兩個事件訂閱,與 XInput 行為對齊,避免不同後端產生 + /// 事件殘留差異。 + /// + [Fact] + public void ClearAllEvents_GameInputController_ClearsShoulderReleasedSubscriptions() + { + using var controller = (GameInputGamepadController)Activator.CreateInstance( + typeof(GameInputGamepadController), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + args: [new StubInputContext(), null], + culture: null)!; + + controller.LeftShoulderReleased += static () => { }; + controller.RightShoulderReleased += static () => { }; + + Assert.NotNull(GetEventDelegate(controller, "LeftShoulderReleased")); + Assert.NotNull(GetEventDelegate(controller, "RightShoulderReleased")); + + InvokeClearAllEvents(controller); + + Assert.Null(GetEventDelegate(controller, "LeftShoulderReleased")); + Assert.Null(GetEventDelegate(controller, "RightShoulderReleased")); + } + + /// + /// 透過反射呼叫控制器的 private ClearAllEvents 方法。 + /// + /// 目標控制器實例。 + private static void InvokeClearAllEvents(object controller) + { + MethodInfo method = controller.GetType().GetMethod( + "ClearAllEvents", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("找不到 ClearAllEvents 方法。"); + + method.Invoke(controller, []); + } + + /// + /// 透過反射讀取 field-like event 的 backing delegate,用於驗證事件訂閱是否已清除。 + /// + /// 事件所屬物件。 + /// 事件名稱(與 backing field 同名)。 + /// backing delegate;若為 null 表示無訂閱。 + private static Delegate? GetEventDelegate(object target, string eventName) + { + FieldInfo field = target.GetType().GetField(eventName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"找不到事件 backing field:{eventName}"); + + return field.GetValue(target) as Delegate; + } + /// /// 建立 GameInput 狀態快照,供反射測試用。 /// diff --git a/tests/InputBox.Tests/GamepadDeadzoneHysteresisTests.cs b/tests/InputBox.Tests/GamepadDeadzoneHysteresisTests.cs index 7c055fc..e15eefb 100644 --- a/tests/InputBox.Tests/GamepadDeadzoneHysteresisTests.cs +++ b/tests/InputBox.Tests/GamepadDeadzoneHysteresisTests.cs @@ -190,4 +190,48 @@ public void ResolveDirection_Float_NegativeAxis_ReturnsNegative() Assert.Equal(-1, result); } + + // ── 對稱性(硬體平等原則)守門 ─────────────────────────────────────────── + + /// + /// 對稱性守門(int):在同樣的 |axisValue| 下,正向進入與負向進入必須使用同一組 + /// enterThreshold;正向退出與負向退出必須使用同一組 exitThreshold。確保不會出現 + /// 為單一方向(如右向)做差別補償的回歸。 + /// + [Fact] + public void ResolveDirection_Int_SymmetricThresholds_AcrossPositiveAndNegative() + { + const int enter = 8000, exit = 6000; + + // 進入:±enter 邊界一致 + Assert.Equal(1, GamepadDeadzoneHysteresis.ResolveDirection(enter + 1, false, false, enter, exit)); + Assert.Equal(-1, GamepadDeadzoneHysteresis.ResolveDirection(-(enter + 1), false, false, enter, exit)); + Assert.Equal(0, GamepadDeadzoneHysteresis.ResolveDirection(enter, false, false, enter, exit)); + Assert.Equal(0, GamepadDeadzoneHysteresis.ResolveDirection(-enter, false, false, enter, exit)); + + // 退出(已持有方向):±exit 邊界一致 + Assert.Equal(1, GamepadDeadzoneHysteresis.ResolveDirection(exit + 1, false, true, enter, exit)); + Assert.Equal(-1, GamepadDeadzoneHysteresis.ResolveDirection(-(exit + 1), true, false, enter, exit)); + Assert.Equal(0, GamepadDeadzoneHysteresis.ResolveDirection(exit, false, true, enter, exit)); + Assert.Equal(0, GamepadDeadzoneHysteresis.ResolveDirection(-exit, true, false, enter, exit)); + } + + /// + /// 對稱性守門(float):浮點數多載亦須維持 ±方向對稱;確保 GameInput 與 XInput + /// 兩個後端在 deadzone 行為上完全等效,不為單一方向加入差別補償。 + /// + [Fact] + public void ResolveDirection_Float_SymmetricThresholds_AcrossPositiveAndNegative() + { + const float enter = 0.8f, exit = 0.6f; + + Assert.Equal(1, GamepadDeadzoneHysteresis.ResolveDirection(0.85f, false, false, enter, exit)); + Assert.Equal(-1, GamepadDeadzoneHysteresis.ResolveDirection(-0.85f, false, false, enter, exit)); + + Assert.Equal(1, GamepadDeadzoneHysteresis.ResolveDirection(0.65f, false, true, enter, exit)); + Assert.Equal(-1, GamepadDeadzoneHysteresis.ResolveDirection(-0.65f, true, false, enter, exit)); + + Assert.Equal(0, GamepadDeadzoneHysteresis.ResolveDirection(0.55f, false, true, enter, exit)); + Assert.Equal(0, GamepadDeadzoneHysteresis.ResolveDirection(-0.55f, true, false, enter, exit)); + } } \ No newline at end of file diff --git a/tests/InputBox.Tests/GamepadFaceButtonProfileTests.cs b/tests/InputBox.Tests/GamepadFaceButtonProfileTests.cs index 0268220..ceca928 100644 --- a/tests/InputBox.Tests/GamepadFaceButtonProfileTests.cs +++ b/tests/InputBox.Tests/GamepadFaceButtonProfileTests.cs @@ -54,6 +54,36 @@ public void ResolveEffectiveLayout_AutoWithNintendoGameInput_ReturnsNintendo() Assert.Equal(AppSettings.GamepadFaceButtonMode.Nintendo, resolved); } + /// + /// Auto 模式即使只取得自有 shim 保留的 Sony VID/PID,也應解析為 PlayStation 國際配置。 + /// + [Fact] + public void ResolveEffectiveLayout_AutoWithSonyVidIdentity_ReturnsPlayStationCrossConfirm() + { + AppSettings.GamepadFaceButtonMode resolved = GamepadFaceButtonProfile.ResolveEffectiveLayout( + AppSettings.GamepadFaceButtonMode.Auto, + AppSettings.GamepadProvider.GameInput, + "Wireless Controller", + "VID_054C PID_0CE6 Wireless Controller"); + + Assert.Equal(AppSettings.GamepadFaceButtonMode.PlayStationCrossConfirm, resolved); + } + + /// + /// Auto 模式即使只取得自有 shim 保留的 Nintendo VID/PID,也應解析為 Nintendo 配置。 + /// + [Fact] + public void ResolveEffectiveLayout_AutoWithNintendoVidIdentity_ReturnsNintendo() + { + AppSettings.GamepadFaceButtonMode resolved = GamepadFaceButtonProfile.ResolveEffectiveLayout( + AppSettings.GamepadFaceButtonMode.Auto, + AppSettings.GamepadProvider.GameInput, + "Pro Controller", + "VID_057E PID_2009 Pro Controller"); + + Assert.Equal(AppSettings.GamepadFaceButtonMode.Nintendo, resolved); + } + /// /// 業界常見的藍牙名稱有時只會顯示 PS4 / PS5 或 Wireless Controller,仍應辨識為 PlayStation,而不是落回 Xbox。 /// diff --git a/tests/InputBox.Tests/InputBox.Tests.csproj b/tests/InputBox.Tests/InputBox.Tests.csproj index 0b6f003..3e2e3cc 100644 --- a/tests/InputBox.Tests/InputBox.Tests.csproj +++ b/tests/InputBox.Tests/InputBox.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0-windows @@ -8,6 +8,7 @@ true false true + true true @@ -17,7 +18,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md index 6bc00db..d0fe79c 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -19,11 +19,13 @@ | `PhraseManagerDialogGamepadTests` | `PhraseManagerDialog` 左側片語清單的 LB/RB/LT/RT 快速切換、邊界跳轉、焦點接手與非必要連發抑制回歸保護 | 3 | | `FloatingPointFormatConverterTests` | `FloatingPointFormatConverter` 字串轉換 | 16 | | `FormInputStateManagerTests` | `FormInputStateManager` 輸入狀態切換 | 15 | -| `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載) | 12 | -| `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意與原生對話框切換時的殘留輸入回歸保護 | 5 | +| `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載),含正負方向對稱性守門(硬體平等原則) | 14 | +| `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 shim/runtime 不可用時退避至 XInput | 3 | +| `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput missing reading 斷線重列舉、裝置狀態過濾、震動停止安全性、`ClearAllEvents` 肩鍵釋放訂閱清除與原生對話框切換時的殘留輸入回歸保護 | 10 | +| `GameInputPrimitivesTests` | 自有 GameInput shim 受控資料模型,涵蓋官方 v3 gamepad button 位元、timestamp 診斷欄位、edge detection timestamp 忽略、runtime probe/ABI size 診斷、Gamepad-only 邊界、P/Invoke export 宣告清單與擴充 device info / capabilities 轉換 | 10 | | `GamepadCalibrationVisualizerMapperTests` | `GamepadCalibrationVisualizerMapper` 對校準視覺化座標限制、死區半徑換算、D-Pad 導覽防誤觸,以及雙搖桿狀態/控制器連線文案格式化的回歸保護 | 14 | | `GamepadEventBinderTests` | `GamepadEventBinder` 的 LB / RB / LT / RT 與肩鍵放開事件綁定回歸保護 | 1 | -| `GamepadFaceButtonProfileTests` | `GamepadFaceButtonProfile` 的 Auto 解析、手動覆寫優先權,以及 Xbox / PlayStation / Nintendo 模式的按鍵標示、助記詞同步、資源化字串、主畫面說明文字、目前生效配置顯示、標題列提示、選單勾選邏輯與 PlayStation ○/× 確認模式回歸保護 | 23 | +| `GamepadFaceButtonProfileTests` | `GamepadFaceButtonProfile` 的 Auto 解析、手動覆寫優先權、shim 保留 VID/PID 時的 Sony/Nintendo 判斷,以及 Xbox / PlayStation / Nintendo 模式的按鍵標示、助記詞同步、資源化字串、主畫面說明文字、目前生效配置顯示、標題列提示、選單勾選邏輯與 PlayStation ○/× 確認模式回歸保護 | 25 | | `GamepadShoulderShortcutArbiterTests` | `GamepadShoulderShortcutArbiter` 的肩鍵單按、連發、修飾鍵與雙肩鍵組合仲裁回歸保護 | 4 | | `GamepadMappedDirectionGuardTests` | `GamepadMappedDirectionGuard` 全方向幽靈保護的封鎖/解除節奏 | 2 | | `GamepadRepeatSettingsTests` | `GamepadRepeatSettings` 預設值與 `Validate()` | 7 | @@ -43,7 +45,7 @@ | `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 | | `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 | | `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 | -| **合計** | | **364** | +| **合計** | | **386** | ## 二、執行方式 🚀 diff --git a/tools/Validate-GameInputNativeShim.ps1 b/tools/Validate-GameInputNativeShim.ps1 new file mode 100644 index 0000000..60c49bd --- /dev/null +++ b/tools/Validate-GameInputNativeShim.ps1 @@ -0,0 +1,650 @@ +param( + [Parameter(Mandatory = $true)] + [string]$NativeShimPath, + + [Parameter(Mandatory = $true)] + [string]$ManagedSourcePath, + + [string]$DumpBinPath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Resolve-RequiredPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Description + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "找不到 $Description:$Path" + } + + return (Resolve-Path -LiteralPath $Path).Path +} + +function Get-DumpBin { + if (-not [string]::IsNullOrWhiteSpace($DumpBinPath)) { + return Resolve-RequiredPath -Path $DumpBinPath -Description 'dumpbin.exe' + } + + $command = Get-Command dumpbin.exe -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + $programFilesX86 = ${env:ProgramFiles(x86)} + if (-not [string]::IsNullOrWhiteSpace($programFilesX86)) { + $vswhere = Join-Path $programFilesX86 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path -LiteralPath $vswhere) { + $installPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not [string]::IsNullOrWhiteSpace($installPath)) { + $toolsRoot = Join-Path $installPath 'VC\Tools\MSVC' + if (Test-Path -LiteralPath $toolsRoot) { + $candidates = Get-ChildItem -LiteralPath $toolsRoot -Directory | + Sort-Object -Property Name -Descending | + ForEach-Object { + Join-Path $_.FullName 'bin\Hostx64\x64\dumpbin.exe' + Join-Path $_.FullName 'bin\Hostx86\x64\dumpbin.exe' + } | + Where-Object { Test-Path -LiteralPath $_ } + + $dumpbin = $candidates | Select-Object -First 1 + if ($dumpbin) { + return $dumpbin + } + } + } + } + } + + throw '找不到 dumpbin.exe,請確認 Visual Studio C++ 工具鏈已安裝。' +} + +function Get-ExpectedExports { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $source = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 + $matches = [regex]::Matches($source, 'EntryPoint\s*=\s*"(?InputBoxGameInput[^"]+)"') + $exports = @($matches | + ForEach-Object { $_.Groups['name'].Value } | + Sort-Object -Unique) + + if ($exports.Count -eq 0) { + throw "未在 $Path 找到任何 InputBoxGameInput* P/Invoke EntryPoint。" + } + + return @($exports) +} + +function Test-NativeExports { + param( + [Parameter(Mandatory = $true)] + [string]$NativeShim, + + [Parameter(Mandatory = $true)] + [string]$ManagedSource + ) + + $dumpbin = Get-DumpBin + $expectedExports = Get-ExpectedExports -Path $ManagedSource + $dumpbinOutput = & $dumpbin /exports $NativeShim + + if ($LASTEXITCODE -ne 0) { + throw "dumpbin /exports 執行失敗,ExitCode=$LASTEXITCODE。" + } + + $missingExports = @(foreach ($export in $expectedExports) { + if (-not ($dumpbinOutput -match "\b$([regex]::Escape($export))\b")) { + $export + } + }) + + if ($missingExports.Count -gt 0) { + throw "GameInput native shim 缺少必要 export:$($missingExports -join ', ')" + } + + Write-Host "GameInput native exports 驗證通過:$($expectedExports.Count) 個 managed P/Invoke EntryPoint 皆存在。" +} + +function ConvertTo-UInt32HexValue { + param( + [Parameter(Mandatory = $true)] + [int]$Value + ) + + return [BitConverter]::ToUInt32([BitConverter]::GetBytes($Value), 0) +} + +function Invoke-ProbeSmoke { + param( + [Parameter(Mandatory = $true)] + [string]$NativeShim + ) + + $nativeShimLiteral = $NativeShim.Replace('\', '\\').Replace('"', '\"') + $typeName = 'InputBoxGameInputNativeSmoke' + [Guid]::NewGuid().ToString('N') + $source = @" +using System; +using System.Runtime.InteropServices; + +public static unsafe class $typeName +{ + [StructLayout(LayoutKind.Sequential)] + public struct VersionInfo + { + public ushort Major; + public ushort Minor; + public ushort Build; + public ushort Revision; + } + + [StructLayout(LayoutKind.Sequential)] + public unsafe struct ShimInfo + { + public uint AbiVersion; + public uint GameInputApiVersion; + public uint PointerSize; + public uint ShimInfoSize; + public uint RuntimeProbeInfoSize; + public uint DeviceInfoSize; + public uint GamepadStateSize; + public uint DiagnosticsSnapshotSize; + public uint LoadedModuleKind; + public fixed byte LoadedModulePath[512]; + } + + [StructLayout(LayoutKind.Sequential)] + public unsafe struct RuntimeProbeInfo + { + public uint AbiVersion; + public uint GameInputApiVersion; + public uint PointerSize; + public uint ShimInfoSize; + public uint RuntimeProbeInfoSize; + public uint DeviceInfoSize; + public uint GamepadStateSize; + public uint DiagnosticsSnapshotSize; + public uint AttemptedModuleKind; + public uint LoadedModuleKind; + public int LoadLibraryHResult; + public int GetProcAddressHResult; + public int InitializeHResult; + public int FinalHResult; + public uint LoadLibraryWin32Error; + public uint GetProcAddressWin32Error; + public uint InitializeWin32Error; + public uint StringTruncationFlags; + public fixed byte AttemptedModulePath[512]; + public fixed byte LoadedModulePath[512]; + } + + [StructLayout(LayoutKind.Sequential)] + public unsafe struct DeviceInfo + { + public ushort VendorId; + public ushort ProductId; + public ushort RevisionNumber; + public ushort UsagePage; + public ushort UsageId; + public ushort Reserved; + public uint DeviceFamily; + public uint SupportedInput; + public uint SupportedRumbleMotors; + public uint SupportedSystemButtons; + public uint GamepadSupportedLayout; + public uint GamepadExtraButtonCount; + public uint GamepadExtraAxisCount; + public uint ForceFeedbackMotorCount; + public uint InputReportCount; + public uint OutputReportCount; + public uint ExtraButtonCount; + public uint ExtraAxisCount; + public uint ExtraButtonIndexCount; + public uint ExtraAxisIndexCount; + public uint HasInputMapper; + public uint StringTruncationFlags; + public VersionInfo HardwareVersion; + public VersionInfo FirmwareVersion; + public fixed byte ExtraButtonIndexes[32]; + public fixed byte ExtraAxisIndexes[32]; + public fixed byte DeviceId[65]; + public fixed byte DeviceRootId[65]; + public fixed byte ContainerId[39]; + public fixed byte DisplayName[256]; + public fixed byte PnpPath[512]; + } + + [StructLayout(LayoutKind.Sequential)] + public struct GamepadState + { + public ulong Timestamp; + public uint InputKind; + public uint Buttons; + public float LeftTrigger; + public float RightTrigger; + public float LeftThumbstickX; + public float LeftThumbstickY; + public float RightThumbstickX; + public float RightThumbstickY; + } + + [StructLayout(LayoutKind.Sequential)] + public struct DiagnosticsSnapshot + { + public ulong MissingReadingCount; + public ulong RepeatedTimestampCount; + public ulong BackwardTimestampCount; + public ulong DeviceUnavailableRefreshCount; + public ulong LastReadingTimestamp; + public int LastReadHResult; + public uint LastReadDeviceStatus; + public uint Reserved; + } + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputProbeRuntime", CallingConvention = CallingConvention.StdCall)] + public static extern int ProbeRuntime(out RuntimeProbeInfo info); + + public static uint GetPointerSize() => (uint)IntPtr.Size; + + public static uint GetShimInfoSize() => (uint)Marshal.SizeOf(); + + public static uint GetRuntimeProbeInfoSize() => (uint)Marshal.SizeOf(); + + public static uint GetDeviceInfoSize() => (uint)Marshal.SizeOf(); + + public static uint GetGamepadStateSize() => (uint)Marshal.SizeOf(); + + public static uint GetDiagnosticsSnapshotSize() => (uint)Marshal.SizeOf(); +} +"@ + + $smokeType = Add-Type -TypeDefinition $source -Language CSharp -CompilerOptions '/unsafe' -PassThru | + Where-Object { $_.Name -eq $typeName } | + Select-Object -First 1 + if ($smokeType -eq $null) { + throw '無法建立 GameInput native probe smoke 型別。' + } + + $probeType = $smokeType.GetNestedType('RuntimeProbeInfo') + $probe = [Activator]::CreateInstance($probeType) + $arguments = @($probe) + $hr = [int]$smokeType.GetMethod('ProbeRuntime').Invoke($null, $arguments) + $probe = $arguments[0] + + if ($probe.AbiVersion -eq 0) { + throw 'GameInput native probe 未回報 ABI version。' + } + + if ($probe.PointerSize -ne [uint32][IntPtr]::Size) { + throw "GameInput native probe 指標大小不符:native=$($probe.PointerSize), process=$([IntPtr]::Size)。" + } + + $expectedSizes = @{ + PointerSize = [uint32]$smokeType.GetMethod('GetPointerSize').Invoke($null, @()) + ShimInfoSize = [uint32]$smokeType.GetMethod('GetShimInfoSize').Invoke($null, @()) + RuntimeProbeInfoSize = [uint32]$smokeType.GetMethod('GetRuntimeProbeInfoSize').Invoke($null, @()) + DeviceInfoSize = [uint32]$smokeType.GetMethod('GetDeviceInfoSize').Invoke($null, @()) + GamepadStateSize = [uint32]$smokeType.GetMethod('GetGamepadStateSize').Invoke($null, @()) + DiagnosticsSnapshotSize = [uint32]$smokeType.GetMethod('GetDiagnosticsSnapshotSize').Invoke($null, @()) + } + + foreach ($name in $expectedSizes.Keys) { + $actual = [uint32]$probe.$name + $expected = $expectedSizes[$name] + if ($actual -ne $expected) { + throw "GameInput native probe ABI size mismatch: $name native=$actual, managed=$expected。" + } + } + + Write-Host ("GameInput native probe smoke 通過:hr=0x{0:X8}, abi={1}, api=0x{2:X8}, pointer={3}, shimInfoSize={4}, runtimeProbeInfoSize={5}, deviceInfoSize={6}, gamepadStateSize={7}, diagnosticsSnapshotSize={8}, finalHr=0x{9:X8}, initHr=0x{10:X8}" -f ` + (ConvertTo-UInt32HexValue -Value $hr), + $probe.AbiVersion, + $probe.GameInputApiVersion, + $probe.PointerSize, + $probe.ShimInfoSize, + $probe.RuntimeProbeInfoSize, + $probe.DeviceInfoSize, + $probe.GamepadStateSize, + $probe.DiagnosticsSnapshotSize, + (ConvertTo-UInt32HexValue -Value $probe.FinalHResult), + (ConvertTo-UInt32HexValue -Value $probe.InitializeHResult)) + + return [pscustomobject]@{ + HResult = $hr + FinalHResult = [int]$probe.FinalHResult + InitializeHResult = [int]$probe.InitializeHResult + } +} + +function Invoke-LifecycleStressSmoke { + param( + [Parameter(Mandatory = $true)] + [string]$NativeShim, + + [Parameter(Mandatory = $true)] + [pscustomobject]$ProbeInfo + ) + + $nativeShimLiteral = $NativeShim.Replace('\', '\\').Replace('"', '\"') + $typeName = 'InputBoxGameInputNativeLifecycleSmoke' + [Guid]::NewGuid().ToString('N') + $source = @" +using System; +using System.Runtime.InteropServices; +using System.Threading; + +public static unsafe class $typeName +{ + private const uint GameInputKindGamepad = 0x00040000; + private const uint GameInputDeviceStatusAny = 0xFFFFFFFF; + private const uint GameInputEnumerationNone = 0; + private const int HResultNotFound = unchecked((int)0x80070490); + + private static int s_readingCallbackCount; + private static int s_deviceCallbackCount; + + [StructLayout(LayoutKind.Sequential)] + public unsafe struct ShimInfo + { + public uint AbiVersion; + public uint GameInputApiVersion; + public uint PointerSize; + public uint ShimInfoSize; + public uint RuntimeProbeInfoSize; + public uint DeviceInfoSize; + public uint GamepadStateSize; + public uint DiagnosticsSnapshotSize; + public uint LoadedModuleKind; + public fixed byte LoadedModulePath[512]; + } + + [StructLayout(LayoutKind.Sequential)] + public struct GamepadState + { + public ulong Timestamp; + public uint InputKind; + public uint Buttons; + public float LeftTrigger; + public float RightTrigger; + public float LeftThumbstickX; + public float LeftThumbstickY; + public float RightThumbstickX; + public float RightThumbstickY; + } + + [StructLayout(LayoutKind.Sequential)] + public struct DiagnosticsSnapshot + { + public ulong MissingReadingCount; + public ulong RepeatedTimestampCount; + public ulong BackwardTimestampCount; + public ulong DeviceUnavailableRefreshCount; + public ulong LastReadingTimestamp; + public int LastReadHResult; + public uint LastReadDeviceStatus; + public uint Reserved; + } + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + public delegate void ReadingCallback(IntPtr context, ref GamepadState state); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + public delegate void DeviceCallback( + IntPtr context, + IntPtr deviceId, + ulong timestamp, + uint currentStatus, + uint previousStatus); + + private static readonly ReadingCallback ReadingCallbackRoot = OnReadingCallback; + private static readonly DeviceCallback DeviceCallbackRoot = OnDeviceCallback; + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputCreate", CallingConvention = CallingConvention.StdCall)] + public static extern int Create(out IntPtr context); + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputDestroy", CallingConvention = CallingConvention.StdCall)] + public static extern void Destroy(IntPtr context); + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputGetShimInfo", CallingConvention = CallingConvention.StdCall)] + public static extern int GetShimInfo(IntPtr context, out ShimInfo info); + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputRefreshDevices", CallingConvention = CallingConvention.StdCall)] + public static extern int RefreshDevices(IntPtr context); + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputGetDeviceCount", CallingConvention = CallingConvention.StdCall)] + public static extern int GetDeviceCount(IntPtr context); + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputRegisterReadingCallback", CallingConvention = CallingConvention.StdCall)] + public static extern int RegisterReadingCallback( + IntPtr context, + IntPtr deviceId, + uint kind, + ReadingCallback callback, + IntPtr callbackContext, + out ulong callbackToken); + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputRegisterDeviceCallback", CallingConvention = CallingConvention.StdCall)] + public static extern int RegisterDeviceCallback( + IntPtr context, + IntPtr deviceId, + uint kind, + uint statusFilter, + uint enumerationKind, + DeviceCallback callback, + IntPtr callbackContext, + out ulong callbackToken); + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputGetDiagnosticsSnapshot", CallingConvention = CallingConvention.StdCall)] + public static extern int GetDiagnosticsSnapshot(IntPtr context, out DiagnosticsSnapshot snapshot); + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputUnregisterCallback", CallingConvention = CallingConvention.StdCall)] + public static extern int UnregisterCallback(IntPtr context, ulong callbackToken); + + public static int TryCreateAndDestroy() + { + IntPtr context = IntPtr.Zero; + int hr = Create(out context); + + if (context != IntPtr.Zero) + { + Destroy(context); + } + + return hr; + } + + public static string RunStress(int iterations) + { + int explicitUnregisterCount = 0; + int destroyCleanupCount = 0; + int doubleUnregisterNotFoundCount = 0; + int maxDeviceCount = 0; + + for (int i = 0; i < iterations; i++) + { + IntPtr context = IntPtr.Zero; + ulong readingToken = 0; + ulong deviceToken = 0; + bool destroyOwnsCallbacks = (i % 2) == 1; + + try + { + int hr = Create(out context); + ThrowIfFailed(hr, "Create"); + + if (context == IntPtr.Zero) + { + throw new InvalidOperationException("Create 回傳成功但 context 為空。"); + } + + ShimInfo shimInfo; + ThrowIfFailed(GetShimInfo(context, out shimInfo), "GetShimInfo"); + ThrowIfFailed(RefreshDevices(context), "RefreshDevices"); + + int deviceCount = GetDeviceCount(context); + if (deviceCount < 0) + { + throw new InvalidOperationException("GetDeviceCount 回傳負值。"); + } + + maxDeviceCount = Math.Max(maxDeviceCount, deviceCount); + + ThrowIfFailed( + RegisterDeviceCallback( + context, + IntPtr.Zero, + GameInputKindGamepad, + GameInputDeviceStatusAny, + GameInputEnumerationNone, + DeviceCallbackRoot, + IntPtr.Zero, + out deviceToken), + "RegisterDeviceCallback"); + EnsureToken(deviceToken, "RegisterDeviceCallback"); + + ThrowIfFailed( + RegisterReadingCallback( + context, + IntPtr.Zero, + GameInputKindGamepad, + ReadingCallbackRoot, + IntPtr.Zero, + out readingToken), + "RegisterReadingCallback"); + EnsureToken(readingToken, "RegisterReadingCallback"); + + DiagnosticsSnapshot diagnostics; + ThrowIfFailed(GetDiagnosticsSnapshot(context, out diagnostics), "GetDiagnosticsSnapshot"); + + if (!destroyOwnsCallbacks) + { + ThrowIfFailed(UnregisterCallback(context, readingToken), "Unregister reading callback"); + int secondReadingUnregister = UnregisterCallback(context, readingToken); + if (secondReadingUnregister != HResultNotFound) + { + throw new InvalidOperationException( + string.Format( + "第二次解除 reading callback 應回傳 not found,但得到 0x{0:X8}。", + unchecked((uint)secondReadingUnregister))); + } + + doubleUnregisterNotFoundCount++; + ThrowIfFailed(UnregisterCallback(context, deviceToken), "Unregister device callback"); + explicitUnregisterCount++; + readingToken = 0; + deviceToken = 0; + } + else + { + destroyCleanupCount++; + } + } + finally + { + if (context != IntPtr.Zero) + { + Destroy(context); + } + + GC.KeepAlive(ReadingCallbackRoot); + GC.KeepAlive(DeviceCallbackRoot); + } + } + + return string.Format( + "iterations={0}, explicitUnregister={1}, destroyCleanup={2}, doubleUnregisterNotFound={3}, maxDeviceCount={4}, readingCallbacks={5}, deviceCallbacks={6}", + iterations, + explicitUnregisterCount, + destroyCleanupCount, + doubleUnregisterNotFoundCount, + maxDeviceCount, + Volatile.Read(ref s_readingCallbackCount), + Volatile.Read(ref s_deviceCallbackCount)); + } + + private static void OnReadingCallback(IntPtr context, ref GamepadState state) + { + Interlocked.Increment(ref s_readingCallbackCount); + } + + private static void OnDeviceCallback( + IntPtr context, + IntPtr deviceId, + ulong timestamp, + uint currentStatus, + uint previousStatus) + { + Interlocked.Increment(ref s_deviceCallbackCount); + } + + private static void ThrowIfFailed(int hr, string operation) + { + if (hr < 0) + { + throw new InvalidOperationException( + string.Format( + "{0} failed: 0x{1:X8}", + operation, + unchecked((uint)hr))); + } + } + + private static void EnsureToken(ulong callbackToken, string operation) + { + if (callbackToken == 0) + { + throw new InvalidOperationException(operation + " 回傳成功但 callback token 為 0。"); + } + } +} +"@ + + $stressType = Add-Type -TypeDefinition $source -Language CSharp -CompilerOptions '/unsafe' -PassThru | + Where-Object { $_.Name -eq $typeName } | + Select-Object -First 1 + if ($stressType -eq $null) { + throw '無法建立 GameInput native lifecycle smoke 型別。' + } + + $createHr = [int]$stressType.GetMethod('TryCreateAndDestroy').Invoke($null, @()) + if ($createHr -lt 0) { + if ($ProbeInfo.FinalHResult -eq 0 -or $ProbeInfo.InitializeHResult -eq 0) { + throw ("GameInput native lifecycle smoke 建立 context 失敗,但 probe 顯示 runtime 初始化成功:createHr=0x{0:X8}, finalHr=0x{1:X8}, initHr=0x{2:X8}" -f ` + (ConvertTo-UInt32HexValue -Value $createHr), + (ConvertTo-UInt32HexValue -Value $ProbeInfo.FinalHResult), + (ConvertTo-UInt32HexValue -Value $ProbeInfo.InitializeHResult)) + } + + Write-Warning ("GameInput native lifecycle smoke 已略過:runtime/context 不可用,createHr=0x{0:X8}, finalHr=0x{1:X8}, initHr=0x{2:X8}。" -f ` + (ConvertTo-UInt32HexValue -Value $createHr), + (ConvertTo-UInt32HexValue -Value $ProbeInfo.FinalHResult), + (ConvertTo-UInt32HexValue -Value $ProbeInfo.InitializeHResult)) + return + } + + try { + $summary = [string]$stressType.GetMethod('RunStress').Invoke($null, @(16)) + Write-Host "GameInput native lifecycle stress smoke 通過:$summary" + } + catch [System.Reflection.TargetInvocationException] { + if ($_.Exception.InnerException -ne $null) { + throw $_.Exception.InnerException + } + + throw + } +} + +$resolvedNativeShim = Resolve-RequiredPath -Path $NativeShimPath -Description 'GameInput native shim' +$resolvedManagedSource = Resolve-RequiredPath -Path $ManagedSourcePath -Description 'managed GameInputNative.cs' + +Test-NativeExports -NativeShim $resolvedNativeShim -ManagedSource $resolvedManagedSource +$probeInfo = Invoke-ProbeSmoke -NativeShim $resolvedNativeShim +Invoke-LifecycleStressSmoke -NativeShim $resolvedNativeShim -ProbeInfo $probeInfo