From 33abb521c95bb362640406f10aa311d9ca85261e Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Fri, 22 May 2026 00:40:03 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(gamepad):=20=E9=87=8D=E5=BB=BA=20Gam?= =?UTF-8?q?eInput=20=E5=BE=8C=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除已封存的 GameInput.Net、UsbVendorsLibrary 與 usb.ids 授權流程,改由專案自有 native shim 連接 Microsoft GameInput runtime,並維持 XInput 作為預設控制器提供者。 更新 release workflow 以在 Windows runner 編譯 native shim、發佈 single-file、隨附 GameInputRedist.msi 與 Microsoft 授權聲明,並驗證 ZIP 不含舊授權檔、gameinput.dll 或可見 shim sidecar。 補上 GameInput fallback、VID 判斷、Pause/Resume、斷線重列舉與 rumble safety 測試,同時修正 PhraseService 併發匯出在 CI 下的檔案替換競態。 --- .github/workflows/release.yml | 175 +++--- InputBox.slnx | 3 +- README.md | 11 +- docs/engineering/gamepad-api.md | 2 + .../InputBox.GameInput.Native.vcxproj | 85 +++ .../InputBoxGameInputNative.cpp | 541 ++++++++++++++++++ .../Core/Input/GameInputGamepadController.cs | 195 +++++-- src/InputBox/Core/Input/GameInputNative.cs | 337 +++++++++++ .../Core/Input/GameInputPrimitives.cs | 232 ++++++++ .../Core/Input/GamepadControllerFactory.cs | 92 +++ src/InputBox/Core/Interop/DllResolver.cs | 54 +- src/InputBox/Core/Services/PhraseService.cs | 23 +- src/InputBox/InputBox.csproj | 31 +- src/InputBox/MainForm.Gamepad.cs | 70 +-- src/InputBox/Program.cs | 6 +- .../GamepadControllerFactoryTests.cs | 251 ++++++++ .../GamepadControllerPauseTests.cs | 75 ++- .../GamepadFaceButtonProfileTests.cs | 30 + tests/InputBox.Tests/README.md | 7 +- 19 files changed, 2029 insertions(+), 191 deletions(-) create mode 100644 src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj create mode 100644 src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp create mode 100644 src/InputBox/Core/Input/GameInputNative.cs create mode 100644 src/InputBox/Core/Input/GameInputPrimitives.cs create mode 100644 src/InputBox/Core/Input/GamepadControllerFactory.cs create mode 100644 tests/InputBox.Tests/GamepadControllerFactoryTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a65f77..c30f73d 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,10 @@ jobs: DOTNET_NOLOGO: '1' PUBLISH_DIR: "./src/InputBox/out" EXE_NAME: 'InputBox.exe' + NATIVE_SHIM_NAME: 'InputBox.GameInput.Native.dll' + GAMEINPUT_VERSION: '3.4.218' SHA256_HASH: '' + GAMEINPUT_REDIST_SHA256: '' ZIP_FILENAME: '' steps: @@ -70,12 +73,27 @@ 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: 建置 GameInput Native Shim + run: > + msbuild .\src\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj + /p:Configuration=Release + /p:Platform=x64 + /p:GameInputPackageVersion=${{ env.GAMEINPUT_VERSION }} + /m + - 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 +105,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 +135,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 +293,103 @@ jobs: Write-Warning "❌ 無法解析 .NET Runtime 的版本號,請確認安裝步驟。" } - # 4. 動態解析 csproj,抓取套件授權與產生 usb.ids 授權檔案。 - Write-Host "=== 開始解析 csproj 抓取第三方套件版本並處理授權 ===" + # 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" - $csprojPath = "./src/InputBox/InputBox.csproj" - if (Test-Path $csprojPath) { - # 將 csproj 轉為 XML 物件以便解析。 - [xml]$csproj = Get-Content $csprojPath + if (-not (Test-Path $gameInputRedistSource)) { + Write-Error "找不到 Microsoft.GameInput redist:$gameInputRedistSource" + } - # 尋找特定的 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 $gameInputLicenseSource)) { + Write-Error "找不到 Microsoft.GameInput 授權檔:$gameInputLicenseSource" + } - # 如果有找到版本號,前面補上 v;如果沒找到(理論上不會發生),退回抓 main。 - $gameInputVer = if ($gameInputRef.Version) { "v" + $gameInputRef.Version } else { "main" } - $usbVendorsVer = if ($usbVendorsRef.Version) { "v" + $usbVendorsRef.Version } else { "main" } + if (-not (Test-Path $gameInputNoticeSource)) { + Write-Error "找不到 Microsoft.GameInput notice 檔:$gameInputNoticeSource" + } - Write-Host "解析到 GameInput.Net 版本標籤: $gameInputVer" - Write-Host "解析到 UsbVendorsLibrary 版本標籤: $usbVendorsVer" + 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") - $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" } - ) + $redistHash = (Get-FileHash (Join-Path $redistDir "GameInputRedist.msi") -Algorithm SHA256).Hash + "GAMEINPUT_REDIST_SHA256=$redistHash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - 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),無法動態抓取套件版本。" - } - - 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.', + $redistReadme = @( + 'Microsoft GameInput Redistributable', + '===================================', '', - ' 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)', + "Version: ${{ env.GAMEINPUT_VERSION }}", + 'Included file: GameInputRedist.msi', + "SHA256: $redistHash", '', - '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).', + '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.', '', - '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.', - '', - 'For the full text of the BSD 3-Clause License, see:', - ' https://opensource.org/license/bsd-3-clause', - '', - '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 +417,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: 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/gamepad-api.md b/docs/engineering/gamepad-api.md index 6721ef8..715e6e6 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -19,6 +19,8 @@ ## 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 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()` 強制停止所有控制器馬達。 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..0939fb5 --- /dev/null +++ b/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj @@ -0,0 +1,85 @@ + + + + + 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) + + + 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) + + + 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..d8b5123 --- /dev/null +++ b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp @@ -0,0 +1,541 @@ +#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 + +using Microsoft::WRL::ComPtr; +using namespace GameInput::v3; + +namespace +{ + const HRESULT InputBoxGameInputNoReading = HRESULT_FROM_WIN32(ERROR_NOT_FOUND); + + struct InputBoxGameInputDeviceInfo + { + uint16_t vendorId; + uint16_t productId; + uint32_t supportedRumbleMotors; + char deviceId[65]; + char displayName[256]; + }; + + struct InputBoxGameInputGamepadState + { + uint32_t buttons; + float leftTrigger; + float rightTrigger; + float leftThumbstickX; + float leftThumbstickY; + float rightThumbstickX; + float rightThumbstickY; + }; + + struct DeviceEntry + { + ComPtr device; + InputBoxGameInputDeviceInfo info{}; + }; + + struct InputBoxGameInputContext + { + HMODULE module = nullptr; + ComPtr gameInput; + std::vector devices; + }; + + using GameInputInitializeFn = HRESULT(WINAPI*)( + _In_ REFIID riid, + _COM_Outptr_ LPVOID* ppv); + + void CopyUtf8( + char* destination, + size_t destinationLength, + const char* value) noexcept + { + if (destinationLength == 0) + { + return; + } + + const char* source = value != nullptr ? value : ""; + strncpy_s(destination, destinationLength, source, _TRUNCATE); + } + + void CopyDeviceId( + char* destination, + size_t destinationLength, + const APP_LOCAL_DEVICE_ID& deviceId) noexcept + { + if (destinationLength < 2) + { + return; + } + + 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]); + } + } + + 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->vendorId = info->vendorId; + destination->productId = info->productId; + destination->supportedRumbleMotors = static_cast(info->supportedRumbleMotors); + CopyDeviceId(destination->deviceId, sizeof(destination->deviceId), info->deviceId); + CopyUtf8(destination->displayName, sizeof(destination->displayName), info->displayName); + + return S_OK; + } + + HRESULT TryLoadGameInputModule( + const wchar_t* path, + DWORD flags, + HMODULE* module, + GameInputInitializeFn* initialize) noexcept + { + *module = nullptr; + *initialize = nullptr; + + HMODULE candidate = LoadLibraryExW(path, nullptr, flags); + + if (candidate == nullptr) + { + return HRESULT_FROM_WIN32(GetLastError()); + } + + FARPROC proc = GetProcAddress(candidate, "GameInputInitialize"); + + if (proc == nullptr) + { + FreeLibrary(candidate); + + return HRESULT_FROM_WIN32(GetLastError()); + } + + *module = candidate; + *initialize = reinterpret_cast(proc); + + return S_OK; + } + + HRESULT TryLoadRedistFromRegistry( + HMODULE* module, + GameInputInitializeFn* initialize) noexcept + { + 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); + } + + std::wstring path(redistDir.data()); + + if (!path.empty() && + path.back() != L'\\') + { + path += L'\\'; + } + + path += L"GameInputRedist.dll"; + + return TryLoadGameInputModule(path.c_str(), 0, module, initialize); + } + + HRESULT LoadGameInput( + HMODULE* module, + IGameInput** gameInput) noexcept + { + *module = nullptr; + *gameInput = nullptr; + + GameInputInitializeFn initialize = nullptr; + HRESULT hr = TryLoadGameInputModule( + L"GameInput.dll", + LOAD_LIBRARY_SEARCH_SYSTEM32, + module, + &initialize); + + if (FAILED(hr)) + { + hr = TryLoadGameInputModule( + L"GameInputRedist.dll", + LOAD_LIBRARY_SEARCH_SYSTEM32, + module, + &initialize); + } + + if (FAILED(hr)) + { + hr = TryLoadRedistFromRegistry(module, &initialize); + } + + if (FAILED(hr)) + { + return hr; + } + + hr = initialize(IID_IGameInput, reinterpret_cast(gameInput)); + + if (FAILED(hr)) + { + FreeLibrary(*module); + *module = nullptr; + } + + 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; + } + + 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); + } +} + +extern "C" +{ + __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->gameInput); + + if (FAILED(hr)) + { + delete created; + + return hr; + } + + *context = created; + + return S_OK; + } + + __declspec(dllexport) void __stdcall InputBoxGameInputDestroy( + InputBoxGameInputContext* context) noexcept + { + if (context == nullptr) + { + return; + } + + context->devices.clear(); + context->gameInput.Reset(); + + if (context->module != nullptr) + { + FreeLibrary(context->module); + context->module = nullptr; + } + + delete context; + } + + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputSetFocusPolicy( + InputBoxGameInputContext* context, + uint32_t policy) noexcept + { + if (context == nullptr || + context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + context->gameInput->SetFocusPolicy(static_cast(policy)); + + return S_OK; + } + + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputRefreshDevices( + InputBoxGameInputContext* context) noexcept + { + if (context == nullptr || + context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + try + { + DeviceCollector collector; + GameInputCallbackToken token = 0; + 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; + } + } + + __declspec(dllexport) int32_t __stdcall InputBoxGameInputGetDeviceCount( + InputBoxGameInputContext* context) noexcept + { + if (context == nullptr) + { + return 0; + } + + return static_cast(context->devices.size()); + } + + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetDeviceInfo( + InputBoxGameInputContext* context, + int32_t index, + InputBoxGameInputDeviceInfo* info) noexcept + { + if (context == nullptr || + info == nullptr || + index < 0 || + static_cast(index) >= context->devices.size()) + { + return E_INVALIDARG; + } + + *info = context->devices[static_cast(index)].info; + + return S_OK; + } + + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetDeviceStatus( + InputBoxGameInputContext* context, + const char* deviceId, + uint32_t* status) noexcept + { + if (status == nullptr) + { + return E_INVALIDARG; + } + + *status = 0; + + DeviceEntry* entry = FindDevice(context, deviceId); + + if (entry == nullptr) + { + return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + } + + *status = static_cast(entry->device->GetDeviceStatus()); + + return S_OK; + } + + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputReadGamepadState( + InputBoxGameInputContext* context, + const char* deviceId, + InputBoxGameInputGamepadState* state) noexcept + { + if (state == nullptr) + { + return E_INVALIDARG; + } + + *state = {}; + + DeviceEntry* entry = FindDevice(context, deviceId); + + if (entry == nullptr) + { + return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + } + + if ((entry->device->GetDeviceStatus() & GameInputDeviceConnected) == 0) + { + return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + } + + ComPtr reading; + HRESULT hr = context->gameInput->GetCurrentReading( + GameInputKindGamepad, + entry->device.Get(), + &reading); + + if (FAILED(hr) || + reading == nullptr) + { + return FAILED(hr) ? hr : InputBoxGameInputNoReading; + } + + GameInputGamepadState gamepadState{}; + + if (!reading->GetGamepadState(&gamepadState)) + { + return InputBoxGameInputNoReading; + } + + state->buttons = static_cast(gamepadState.buttons); + state->leftTrigger = gamepadState.leftTrigger; + state->rightTrigger = gamepadState.rightTrigger; + state->leftThumbstickX = gamepadState.leftThumbstickX; + state->leftThumbstickY = gamepadState.leftThumbstickY; + state->rightThumbstickX = gamepadState.rightThumbstickX; + state->rightThumbstickY = gamepadState.rightThumbstickY; + + return S_OK; + } + + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputSetRumbleState( + InputBoxGameInputContext* context, + const char* deviceId, + float lowFrequency, + float highFrequency, + float leftTrigger, + float rightTrigger) noexcept + { + DeviceEntry* entry = FindDevice(context, deviceId); + + if (entry == nullptr) + { + return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + } + + GameInputRumbleParams rumble + { + lowFrequency, + highFrequency, + leftTrigger, + rightTrigger + }; + + entry->device->SetRumbleState(&rumble); + + return S_OK; + } +} diff --git a/src/InputBox/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs index a060d92..8e76a7e 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; @@ -94,6 +89,11 @@ internal sealed partial class GameInputGamepadController : IGamepadController /// private int _reconnectCounter; + /// + /// 目前裝置連續讀不到有效 reading 的幀數。 + /// + private int _missingReadingFrameCounter; + /// /// 輪詢任務的 CancellationTokenSource /// @@ -761,6 +761,7 @@ private void HandleDisconnect() bool wasConnected = _isConnected || _hasPreviousState; _isConnected = false; + _missingReadingFrameCounter = 0; UpdateCalibrationSnapshot(null, AppSettings.Current.GamepadSettings, false); if (wasConnected) @@ -1050,7 +1051,51 @@ private void Poll() return; } - // 從佇列中取出所有事件(Event-Driven)。 + try + { + GameInputDeviceStatus status = dev.GetDeviceStatus(); + + if ((status & GameInputDeviceStatus.Connected) == 0) + { + Debug.WriteLine("[GameInput] 目前裝置狀態已非 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 || + currentSnapshot != _previousState) + { + _readingQueue.Enqueue(currentSnapshot); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[GameInput] 讀取目前狀態失敗,稍後重新整理裝置:{ex.Message}"); + + if (HandleMissingCurrentReading()) + { + return; + } + } + + // 從佇列中取出所有快照。 // 分離「邊緣偵測」與「狀態維持」。 bool hasNewState = false; @@ -1162,6 +1207,55 @@ 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()); + + TryFindDevice(); + + return _device == null; + } + + /// + /// 計算連續 missing reading 是否已達重列舉門檻。 + /// + /// 若應重列舉裝置則回傳 true。 + private bool ShouldRefreshAfterMissingCurrentReading() + { + _missingReadingFrameCounter++; + + if (_missingReadingFrameCounter < AppSettings.GamepadReconnectThresholdFrames) + { + return false; + } + + _missingReadingFrameCounter = 0; + + return true; + } + /// /// 評估方向幽靈重入狀態:處理「狀態有變化但方向持續誤判」情境。 /// @@ -1435,6 +1529,7 @@ private void ResetTransientInputState(bool requireNeutralBeforeInput = true) _rtRepeatCounter = 0; _currentRTRepeatInterval = 0; _reconnectCounter = 0; + _missingReadingFrameCounter = 0; _previousState = null; _previousProcessedButtons = 0; _hasPreviousState = false; @@ -1464,7 +1559,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 +1568,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 +1637,13 @@ private void TryFindDevice() try { + if (!IsDeviceConnected(newDev)) + { + newDev.Dispose(); + + continue; + } + GameInputDeviceInfo newInfo = newDev.GetDeviceInfo(); bool alreadyTracked = false; @@ -1571,6 +1685,7 @@ private void TryFindDevice() _device = _allDevices[0]; _hasPreviousState = false; + _missingReadingFrameCounter = 0; UpdateDeviceInfo(); InitializeDeviceState(); @@ -1602,6 +1717,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 +1894,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"; + // 更新裝置名稱與穩定識別資訊。Auto 判斷改以 VID/PID 與 GameInput + // 原始顯示名稱作為穩定線索。 + string displayName = info.GetDisplayName(); - if (UsbIds.TryGetVendorName(info.VendorId, out string? vendorName) && - !string.IsNullOrWhiteSpace(vendorName)) - { - displayName = $"{vendorName} "; - } - - 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) { diff --git a/src/InputBox/Core/Input/GameInputNative.cs b/src/InputBox/Core/Input/GameInputNative.cs new file mode 100644 index 0000000..dc6fbb0 --- /dev/null +++ b/src/InputBox/Core/Input/GameInputNative.cs @@ -0,0 +1,337 @@ +using System.Runtime.InteropServices; + +namespace InputBox.Core.Input; + +/// +/// GameInput native shim 的受控進入點,負責把窄版 C ABI 轉成專案內部型別。 +/// +internal sealed class GameInput : IDisposable +{ + private readonly SafeGameInputContextHandle _handle; + private GameInputDevice[] _devices = []; + + private GameInput(SafeGameInputContextHandle handle) + { + _handle = handle; + } + + /// + /// 建立 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); + } + + return new GameInput(handle); + } + + /// + /// 設定 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; + } + + /// + /// 註冊讀取回呼。自有 shim 目前以 60 FPS 輪詢為主,此方法保留為 no-op 以維持 controller 結構。 + /// + public GameInputCallbackRegistration RegisterReadingCallback( + GameInputDevice _, + GameInputKind __, + Action ___) + => new(); + + /// + /// 註冊裝置回呼。自有 shim 以定期重新列舉偵測變化,此方法保留為 no-op。 + /// + public GameInputCallbackRegistration RegisterDeviceCallback( + GameInputDevice? _, + GameInputKind __, + GameInputDeviceStatus ___, + GameInputEnumerationKind ____, + Action _____) + => new(); + + 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; + } + + /// + /// 釋放 GameInput 內容。 + /// + public void Dispose() + { + _devices = []; + _handle.Dispose(); + } + + /// + /// GameInput 回呼註冊憑證。 + /// + internal sealed class GameInputCallbackRegistration : IDisposable + { + /// + /// 釋放回呼註冊。 + /// + public void Dispose() + { + + } + } +} + +/// +/// 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 宣告。 +/// +internal static partial class GameInputNativeMethods +{ + private const string NativeLibraryName = "InputBox.GameInput.Native"; + + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputCreate")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int Create(out SafeGameInputContextHandle context); + + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputDestroy")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern void Destroy(nint context); + + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputSetFocusPolicy")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int SetFocusPolicy(SafeGameInputContextHandle context, uint policy); + + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputRefreshDevices")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int RefreshDevices(SafeGameInputContextHandle context); + + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDeviceCount")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetDeviceCount(SafeGameInputContextHandle context); + + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDeviceInfo")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetDeviceInfo( + SafeGameInputContextHandle context, + int index, + out GameInputNativeDeviceInfo info); + + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDeviceStatus")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetDeviceStatus( + SafeGameInputContextHandle context, + [MarshalAs(UnmanagedType.LPUTF8Str)] string deviceId, + out uint status); + + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputReadGamepadState")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int ReadGamepadState( + SafeGameInputContextHandle context, + [MarshalAs(UnmanagedType.LPUTF8Str)] string deviceId, + out GameInputGamepadState state); + + [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); +} \ No newline at end of file diff --git a/src/InputBox/Core/Input/GameInputPrimitives.cs b/src/InputBox/Core/Input/GameInputPrimitives.cs new file mode 100644 index 0000000..6176078 --- /dev/null +++ b/src/InputBox/Core/Input/GameInputPrimitives.cs @@ -0,0 +1,232 @@ +using System.Runtime.InteropServices; + +namespace InputBox.Core.Input; + +/// +/// GameInput 輸入類型。 +/// +internal enum GameInputKind +{ + /// + /// Gamepad。 + /// + Gamepad = 0x00040000 +} + +/// +/// GameInput focus policy。 +/// +[Flags] +internal enum GameInputFocusPolicy : uint +{ + /// + /// 預設 focus policy。 + /// + Default = 0 +} + +/// +/// GameInput 裝置狀態。 +/// +[Flags] +internal enum GameInputDeviceStatus : uint +{ + /// + /// 已連線。 + /// + Connected = 0x00000001 +} + +/// +/// GameInput 裝置列舉模式。 +/// +internal enum GameInputEnumerationKind +{ + /// + /// 不執行初始列舉。 + /// + None = 0 +} + +/// +/// GameInput gamepad 按鍵旗標。 +/// +[Flags] +internal enum GameInputGamepadButtons : uint +{ + None = 0x00000000, + Menu = 0x00000001, + View = 0x00000002, + A = 0x00000004, + B = 0x00000008, + X = 0x00000010, + Y = 0x00000020, + DPadUp = 0x00000040, + DPadDown = 0x00000080, + DPadLeft = 0x00000100, + DPadRight = 0x00000200, + LeftShoulder = 0x00000400, + RightShoulder = 0x00000800, + LeftThumbstick = 0x00001000, + RightThumbstick = 0x00002000, + LeftTriggerButton = 0x00010000, + RightTriggerButton = 0x00020000 +} + +/// +/// 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 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.Buttons, + state.LeftTrigger, + state.RightTrigger, + state.LeftThumbstickX, + state.LeftThumbstickY, + state.RightThumbstickX, + state.RightThumbstickY) + { + + } + + internal GamepadStateSnapshot( + GameInputGamepadButtons buttons, + float leftTrigger, + float rightTrigger, + float leftThumbstickX, + float leftThumbstickY, + float rightThumbstickX, + float rightThumbstickY) + { + Buttons = buttons; + LeftTrigger = leftTrigger; + RightTrigger = rightTrigger; + LeftThumbstickX = leftThumbstickX; + LeftThumbstickY = leftThumbstickY; + RightThumbstickX = rightThumbstickX; + RightThumbstickY = rightThumbstickY; + } + + 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 GameInputDeviceInfo +{ + internal GameInputDeviceInfo( + string deviceId, + ushort vendorId, + ushort productId, + GameInputRumbleMotors supportedRumbleMotors, + string displayName) + { + DeviceId = deviceId; + VendorId = vendorId; + ProductId = productId; + SupportedRumbleMotors = supportedRumbleMotors; + DisplayName = displayName; + } + + public string DeviceId { get; } + + public ushort VendorId { get; } + + public ushort ProductId { get; } + + public GameInputRumbleMotors SupportedRumbleMotors { get; } + + private string DisplayName { get; } + + public string GetDisplayName() => DisplayName; +} + +/// +/// Native shim 回傳的裝置資訊。 +/// +[StructLayout(LayoutKind.Sequential)] +internal unsafe struct GameInputNativeDeviceInfo +{ + public ushort VendorId; + public ushort ProductId; + public uint SupportedRumbleMotors; + public fixed byte DeviceId[65]; + public fixed byte DisplayName[256]; + + public GameInputDeviceInfo ToDeviceInfo() + { + string parsedDeviceId; + string parsedDisplayName; + + fixed (byte* deviceIdPtr = DeviceId) + fixed (byte* displayNamePtr = DisplayName) + { + parsedDeviceId = Marshal.PtrToStringUTF8((nint)deviceIdPtr) ?? string.Empty; + parsedDisplayName = Marshal.PtrToStringUTF8((nint)displayNamePtr) ?? string.Empty; + } + + return new GameInputDeviceInfo( + parsedDeviceId, + VendorId, + ProductId, + (GameInputRumbleMotors)SupportedRumbleMotors, + parsedDisplayName); + } +} \ No newline at end of file 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/Interop/DllResolver.cs b/src/InputBox/Core/Interop/DllResolver.cs index 5593a3b..331f57a 100644 --- a/src/InputBox/Core/Interop/DllResolver.cs +++ b/src/InputBox/Core/Interop/DllResolver.cs @@ -19,17 +19,28 @@ internal class DllResolver private static volatile nint _cachedHandle = IntPtr.Zero; /// - /// 自訂的 XInput 載入解析器,用於覆寫 DllImport 的載入邏輯,僅在要求載入 "xinput1_4.dll" 時介入,否則直接返回 IntPtr.Zero + /// 快取的自有 GameInput native shim handle,用於避免重複載入。 + /// + 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 +87,35 @@ public static nint ResolveXInput( return IntPtr.Zero; } } + + /// + /// 解析自有 GameInput native shim。 + /// + /// 要求載入的 library 名稱。 + /// 觸發載入的組件。 + /// DllImportSearchPath 設定。 + /// 成功載入時回傳 DLL handle;否則回傳 IntPtr.Zero。 + private static nint ResolveGameInputShim( + string libraryName, + Assembly assembly, + DllImportSearchPath? searchPath) + { + lock (ResolverLock) + { + if (_cachedGameInputShimHandle != IntPtr.Zero) + { + 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..49cb82b 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,31 @@ - - + + + + + + + + + + + + + <_Parameter1>InputBox.Tests @@ -77,4 +100,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/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..1f2bc01 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,76 @@ 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])!); + } + /// /// 建立 GameInput 狀態快照,供反射測試用。 /// 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/README.md b/tests/InputBox.Tests/README.md index 6bc00db..339ea63 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -20,10 +20,11 @@ | `FloatingPointFormatConverterTests` | `FloatingPointFormatConverter` 字串轉換 | 16 | | `FormInputStateManagerTests` | `FormInputStateManager` 輸入狀態切換 | 15 | | `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載) | 12 | -| `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意與原生對話框切換時的殘留輸入回歸保護 | 5 | +| `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 shim/runtime 不可用時退避至 XInput | 3 | +| `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput missing reading 斷線重列舉、裝置狀態過濾、震動停止安全性與原生對話框切換時的殘留輸入回歸保護 | 8 | | `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 +44,7 @@ | `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 | | `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 | | `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 | -| **合計** | | **364** | +| **合計** | | **372** | ## 二、執行方式 🚀 From b0e635c1d3d5e698430435900f6b6b3f3a557ef6 Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Fri, 22 May 2026 01:43:02 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(gamepad):=20=E8=A3=9C=E5=BC=B7=20Gam?= =?UTF-8?q?eInput=20shim=20gamepad=20subset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直接更新 embedded native shim 與 managed GameInput layer 的同版 ABI。 補齊 Gamepad device info、timestamp、v3 buttons、callbacks 與 capabilities metadata,並維持 60 FPS polling 作為唯一正式輸入流程。 新增 GameInput primitives 測試與工程規範,確認 release single-file publish 不暴露 shim sidecar。 --- docs/engineering/gamepad-api.md | 3 + .../InputBoxGameInputNative.cpp | 541 +++++++++++++++++- .../Core/Input/GameInputGamepadController.cs | 58 +- src/InputBox/Core/Input/GameInputNative.cs | 268 ++++++++- .../Core/Input/GameInputPrimitives.cs | 312 +++++++++- .../GameInputPrimitivesTests.cs | 168 ++++++ tests/InputBox.Tests/InputBox.Tests.csproj | 3 +- tests/InputBox.Tests/README.md | 3 +- 8 files changed, 1302 insertions(+), 54 deletions(-) create mode 100644 tests/InputBox.Tests/GameInputPrimitivesTests.cs diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md index 715e6e6..2b29551 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -20,6 +20,9 @@ - **預設提供者**:應用程式之預設控制器提供者應設定為相容性最高之 **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 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 配置;若使用者手動指定模式,必須優先於自動判斷。 diff --git a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp index d8b5123..cf12c9a 100644 --- a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp +++ b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp @@ -12,9 +12,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -26,18 +28,71 @@ using namespace GameInput::v3; namespace { const HRESULT InputBoxGameInputNoReading = HRESULT_FROM_WIN32(ERROR_NOT_FOUND); + constexpr uint32_t InputBoxGameInputShimAbiVersion = 2; + constexpr uint32_t InputBoxGameInputMaxExtraControlIndexes = 32; + + enum InputBoxGameInputModuleKind : uint32_t + { + InputBoxGameInputModuleUnknown = 0, + InputBoxGameInputModuleSystemGameInput = 1, + InputBoxGameInputModuleSystemGameInputRedist = 2, + InputBoxGameInputModuleRegistryGameInputRedist = 3 + }; + + struct InputBoxGameInputVersion + { + uint16_t major; + uint16_t minor; + uint16_t build; + uint16_t revision; + }; + + struct InputBoxGameInputShimInfo + { + uint32_t abiVersion; + uint32_t gameInputApiVersion; + uint32_t loadedModuleKind; + 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; + 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; @@ -53,11 +108,34 @@ namespace 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; }; using GameInputInitializeFn = HRESULT(WINAPI*)( @@ -78,6 +156,40 @@ namespace strncpy_s(destination, destinationLength, source, _TRUNCATE); } + void CopyWideAsUtf8( + char* destination, + size_t destinationLength, + const wchar_t* value) noexcept + { + if (destinationLength == 0) + { + return; + } + + destination[0] = '\0'; + + if (value == nullptr || + value[0] == L'\0') + { + return; + } + + int written = WideCharToMultiByte( + CP_UTF8, + 0, + value, + -1, + destination, + static_cast(destinationLength), + nullptr, + nullptr); + + if (written == 0) + { + destination[0] = '\0'; + } + } + void CopyDeviceId( char* destination, size_t destinationLength, @@ -99,6 +211,51 @@ namespace } } + void 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) + { + CopyUtf8(destination, destinationLength, ""); + + return; + } + + CopyWideAsUtf8(destination, destinationLength, buffer); + } + + 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 @@ -117,11 +274,83 @@ namespace 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); CopyDeviceId(destination->deviceId, sizeof(destination->deviceId), info->deviceId); + CopyDeviceId(destination->deviceRootId, sizeof(destination->deviceRootId), info->deviceRootId); + CopyGuid(destination->containerId, sizeof(destination->containerId), info->containerId); CopyUtf8(destination->displayName, sizeof(destination->displayName), info->displayName); + CopyUtf8(destination->pnpPath, sizeof(destination->pnpPath), info->pnpPath); + + 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; } @@ -193,9 +422,14 @@ namespace HRESULT LoadGameInput( HMODULE* module, + uint32_t* moduleKind, + char* modulePath, + size_t modulePathLength, IGameInput** gameInput) noexcept { *module = nullptr; + *moduleKind = InputBoxGameInputModuleUnknown; + CopyUtf8(modulePath, modulePathLength, ""); *gameInput = nullptr; GameInputInitializeFn initialize = nullptr; @@ -205,6 +439,11 @@ namespace module, &initialize); + if (SUCCEEDED(hr)) + { + *moduleKind = InputBoxGameInputModuleSystemGameInput; + } + if (FAILED(hr)) { hr = TryLoadGameInputModule( @@ -212,11 +451,21 @@ namespace LOAD_LIBRARY_SEARCH_SYSTEM32, module, &initialize); + + if (SUCCEEDED(hr)) + { + *moduleKind = InputBoxGameInputModuleSystemGameInputRedist; + } } if (FAILED(hr)) { hr = TryLoadRedistFromRegistry(module, &initialize); + + if (SUCCEEDED(hr)) + { + *moduleKind = InputBoxGameInputModuleRegistryGameInputRedist; + } } if (FAILED(hr)) @@ -224,12 +473,21 @@ namespace return hr; } + std::array path{}; + DWORD pathLength = GetModuleFileNameW(*module, path.data(), static_cast(path.size())); + if (pathLength > 0) + { + CopyWideAsUtf8(modulePath, modulePathLength, path.data()); + } + hr = initialize(IID_IGameInput, reinterpret_cast(gameInput)); if (FAILED(hr)) { FreeLibrary(*module); *module = nullptr; + *moduleKind = InputBoxGameInputModuleUnknown; + CopyUtf8(modulePath, modulePathLength, ""); } return hr; @@ -279,6 +537,100 @@ namespace auto* collector = static_cast(context); collector->devices.emplace_back(device); } + + void CALLBACK OnReadingCallback( + _In_ GameInputCallbackToken, + _In_ void* context, + _In_ IGameInputReading* reading) + { + 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) + { + 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 ResolveOptionalDevice( + InputBoxGameInputContext* context, + const char* deviceId, + IGameInputDevice** device) noexcept + { + if (device == nullptr) + { + return E_INVALIDARG; + } + + *device = nullptr; + + if (deviceId == nullptr || + deviceId[0] == '\0') + { + return S_OK; + } + + DeviceEntry* entry = FindDevice(context, deviceId); + + if (entry == nullptr) + { + return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + } + + *device = entry->device.Get(); + + return S_OK; + } } extern "C" @@ -300,7 +652,12 @@ extern "C" return E_OUTOFMEMORY; } - HRESULT hr = LoadGameInput(&created->module, &created->gameInput); + HRESULT hr = LoadGameInput( + &created->module, + &created->moduleKind, + created->modulePath, + sizeof(created->modulePath), + &created->gameInput); if (FAILED(hr)) { @@ -322,6 +679,21 @@ extern "C" return; } + 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(); @@ -334,6 +706,28 @@ extern "C" delete context; } + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetShimInfo( + InputBoxGameInputContext* context, + InputBoxGameInputShimInfo* info) noexcept + { + if (info == nullptr) + { + return E_INVALIDARG; + } + + *info = {}; + info->abiVersion = InputBoxGameInputShimAbiVersion; + info->gameInputApiVersion = GAMEINPUT_API_VERSION; + + if (context != nullptr) + { + info->loadedModuleKind = context->moduleKind; + CopyUtf8(info->loadedModulePath, sizeof(info->loadedModulePath), context->modulePath); + } + + return S_OK; + } + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputSetFocusPolicy( InputBoxGameInputContext* context, uint32_t policy) noexcept @@ -500,13 +894,144 @@ extern "C" return InputBoxGameInputNoReading; } - state->buttons = static_cast(gamepadState.buttons); - state->leftTrigger = gamepadState.leftTrigger; - state->rightTrigger = gamepadState.rightTrigger; - state->leftThumbstickX = gamepadState.leftThumbstickX; - state->leftThumbstickY = gamepadState.leftThumbstickY; - state->rightThumbstickX = gamepadState.rightThumbstickX; - state->rightThumbstickY = gamepadState.rightThumbstickY; + FillGamepadState(reading.Get(), gamepadState, state); + + return S_OK; + } + + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputRegisterReadingCallback( + InputBoxGameInputContext* context, + const char* deviceId, + uint32_t kind, + InputBoxGameInputReadingCallback callback, + void* callbackContext, + uint64_t* callbackToken) noexcept + { + if (context == nullptr || + context->gameInput == nullptr || + callback == nullptr || + callbackToken == nullptr) + { + return E_INVALIDARG; + } + + *callbackToken = 0; + + IGameInputDevice* device = nullptr; + HRESULT hr = ResolveOptionalDevice(context, deviceId, &device); + + if (FAILED(hr)) + { + return hr; + } + + auto registration = std::make_unique(); + registration->readingCallback = callback; + registration->callbackContext = callbackContext; + + GameInputCallbackToken token = 0; + hr = context->gameInput->RegisterReadingCallback( + device, + static_cast(kind), + registration.get(), + OnReadingCallback, + &token); + + if (FAILED(hr)) + { + return hr; + } + + registration->token = token; + *callbackToken = token; + context->callbacks.push_back(std::move(registration)); + + return S_OK; + } + + __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 || + context->gameInput == nullptr || + callback == nullptr || + callbackToken == nullptr) + { + return E_INVALIDARG; + } + + *callbackToken = 0; + + IGameInputDevice* device = nullptr; + HRESULT hr = ResolveOptionalDevice(context, deviceId, &device); + + if (FAILED(hr)) + { + return hr; + } + + auto registration = std::make_unique(); + registration->deviceCallback = callback; + registration->callbackContext = callbackContext; + + GameInputCallbackToken token = 0; + hr = context->gameInput->RegisterDeviceCallback( + device, + static_cast(kind), + static_cast(statusFilter), + static_cast(enumerationKind), + registration.get(), + OnDeviceCallback, + &token); + + if (FAILED(hr)) + { + return hr; + } + + registration->token = token; + *callbackToken = token; + context->callbacks.push_back(std::move(registration)); + + return S_OK; + } + + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputUnregisterCallback( + InputBoxGameInputContext* context, + uint64_t callbackToken) noexcept + { + if (context == nullptr || + context->gameInput == nullptr || + callbackToken == 0) + { + 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; } diff --git a/src/InputBox/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs index 8e76a7e..9bd8764 100644 --- a/src/InputBox/Core/Input/GameInputGamepadController.cs +++ b/src/InputBox/Core/Input/GameInputGamepadController.cs @@ -59,6 +59,11 @@ internal sealed partial class GameInputGamepadController : IGamepadController /// private long _refreshRequestedTicks; + /// + /// 記錄最後一次收到讀取回呼的時間點;正式輸入仍由 60 FPS polling 消費。 + /// + private long _readingCallbackObservedTicks; + /// /// 儲存 GameInput 讀取回呼註冊憑證 /// @@ -819,30 +824,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()); } }); } @@ -872,6 +858,9 @@ 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}"); + // 初始化時先直接列舉一次,再註冊非阻塞裝置回呼。 // 這樣可避免為了拿到既有裝置名單而付出兩次同步枚舉成本。 if (_needsRefresh && @@ -1002,6 +991,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) { @@ -1079,7 +1075,7 @@ private void Poll() if (!_hasPreviousState || _previousState == null || - currentSnapshot != _previousState) + !HasSameInputValues(currentSnapshot, _previousState)) { _readingQueue.Enqueue(currentSnapshot); } @@ -1415,6 +1411,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) /// @@ -3040,4 +3048,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 index dc6fbb0..cab9c21 100644 --- a/src/InputBox/Core/Input/GameInputNative.cs +++ b/src/InputBox/Core/Input/GameInputNative.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using System.Diagnostics; namespace InputBox.Core.Input; @@ -7,14 +8,22 @@ namespace InputBox.Core.Input; /// 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) + private GameInput(SafeGameInputContextHandle handle, GameInputShimInfo shimInfo) { _handle = handle; + ShimInfo = shimInfo; } + /// + /// 取得 native shim 與 GameInput runtime 載入診斷資訊。 + /// + public GameInputShimInfo ShimInfo { get; } + /// /// 建立 GameInput 內容;若 shim 或 runtime 不可用會丟出例外,交由 XInput 退避流程處理。 /// @@ -30,7 +39,15 @@ public static GameInput Create() Marshal.ThrowExceptionForHR(hr); } - return new GameInput(handle); + hr = GameInputNativeMethods.GetShimInfo(handle, out GameInputNativeShimInfo nativeShimInfo); + + if (hr < 0) + { + handle.Dispose(); + Marshal.ThrowExceptionForHR(hr); + } + + return new GameInput(handle, nativeShimInfo.ToShimInfo()); } /// @@ -104,24 +121,75 @@ public IReadOnlyList EnumerateDevices(GameInputKind kind) } /// - /// 註冊讀取回呼。自有 shim 目前以 60 FPS 輪詢為主,此方法保留為 no-op 以維持 controller 結構。 + /// 註冊讀取回呼。控制器仍以 60 FPS 輪詢為主,此回呼只作為輔助訊號來源。 /// public GameInputCallbackRegistration RegisterReadingCallback( - GameInputDevice _, - GameInputKind __, - Action ___) - => new(); + 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); + } /// - /// 註冊裝置回呼。自有 shim 以定期重新列舉偵測變化,此方法保留為 no-op。 + /// 註冊裝置回呼,用於要求輪詢執行緒重新整理裝置清單。 /// public GameInputCallbackRegistration RegisterDeviceCallback( - GameInputDevice? _, - GameInputKind __, - GameInputDeviceStatus ___, - GameInputEnumerationKind ____, - Action _____) - => new(); + 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) { @@ -163,16 +231,139 @@ public void Dispose() _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(); + } + } } } @@ -292,6 +483,12 @@ internal static partial class GameInputNativeMethods [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] internal static extern void Destroy(nint context); + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetShimInfo")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetShimInfo( + SafeGameInputContextHandle context, + out GameInputNativeShimInfo info); + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputSetFocusPolicy")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] internal static extern int SetFocusPolicy(SafeGameInputContextHandle context, uint policy); @@ -325,6 +522,34 @@ internal static extern int ReadGamepadState( [MarshalAs(UnmanagedType.LPUTF8Str)] string deviceId, out GameInputGamepadState state); + [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); + + [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); + + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputUnregisterCallback")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int UnregisterCallback( + SafeGameInputContextHandle context, + ulong callbackToken); + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputSetRumbleState")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] internal static extern int SetRumbleState( @@ -334,4 +559,17 @@ internal static extern int SetRumbleState( float highFrequency, float leftTrigger, float rightTrigger); -} \ No newline at end of file +} + +[UnmanagedFunctionPointer(CallingConvention.StdCall)] +internal delegate void GameInputNativeReadingCallback( + nint callbackContext, + ref GameInputGamepadState state); + +[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 index 6176078..2589f99 100644 --- a/src/InputBox/Core/Input/GameInputPrimitives.cs +++ b/src/InputBox/Core/Input/GameInputPrimitives.cs @@ -5,8 +5,14 @@ namespace InputBox.Core.Input; /// /// GameInput 輸入類型。 /// +[Flags] internal enum GameInputKind { + /// + /// 未知或不支援的輸入類型。 + /// + Unknown = 0x00000000, + /// /// Gamepad。 /// @@ -31,10 +37,25 @@ internal enum GameInputFocusPolicy : uint [Flags] internal enum GameInputDeviceStatus : uint { + /// + /// 無狀態。 + /// + None = 0x00000000, + /// /// 已連線。 /// - Connected = 0x00000001 + Connected = 0x00000001, + + /// + /// Haptic 資訊已可用。 + /// + HapticInfoReady = 0x00200000, + + /// + /// 任一狀態變更。 + /// + Any = 0xFFFFFFFF } /// @@ -45,7 +66,17 @@ internal enum GameInputEnumerationKind /// /// 不執行初始列舉。 /// - None = 0 + None = 0, + + /// + /// 非同步列舉。 + /// + Async = 1, + + /// + /// 阻塞列舉。 + /// + Blocking = 2 } /// @@ -59,8 +90,10 @@ internal enum GameInputGamepadButtons : uint View = 0x00000002, A = 0x00000004, B = 0x00000008, + C = 0x00004000, X = 0x00000010, Y = 0x00000020, + Z = 0x00008000, DPadUp = 0x00000040, DPadDown = 0x00000080, DPadLeft = 0x00000100, @@ -70,7 +103,19 @@ internal enum GameInputGamepadButtons : uint LeftThumbstick = 0x00001000, RightThumbstick = 0x00002000, LeftTriggerButton = 0x00010000, - RightTriggerButton = 0x00020000 + 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 } /// @@ -92,6 +137,8 @@ internal enum GameInputRumbleMotors : uint [StructLayout(LayoutKind.Sequential)] internal struct GameInputGamepadState { + public ulong Timestamp; + public GameInputKind InputKind; public GameInputGamepadButtons Buttons; public float LeftTrigger; public float RightTrigger; @@ -108,6 +155,8 @@ internal sealed record class GamepadStateSnapshot { internal GamepadStateSnapshot(GameInputGamepadState state) : this( + state.Timestamp, + state.InputKind, state.Buttons, state.LeftTrigger, state.RightTrigger, @@ -120,6 +169,8 @@ internal GamepadStateSnapshot(GameInputGamepadState state) } internal GamepadStateSnapshot( + ulong timestamp, + GameInputKind inputKind, GameInputGamepadButtons buttons, float leftTrigger, float rightTrigger, @@ -128,6 +179,8 @@ internal GamepadStateSnapshot( float rightThumbstickX, float rightThumbstickY) { + Timestamp = timestamp; + InputKind = inputKind; Buttons = buttons; LeftTrigger = leftTrigger; RightTrigger = rightTrigger; @@ -137,6 +190,32 @@ internal GamepadStateSnapshot( 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; } @@ -166,6 +245,59 @@ internal readonly record struct GameInputRumbleParams 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 載入診斷資訊。 +/// +internal readonly record struct GameInputShimInfo( + uint AbiVersion, + uint GameInputApiVersion, + GameInputShimModuleKind LoadedModuleKind, + string LoadedModulePath); + +/// +/// GameInput runtime 載入來源。 +/// +internal enum GameInputShimModuleKind : uint +{ + Unknown = 0, + SystemGameInput = 1, + SystemGameInputRedist = 2, + RegistryGameInputRedist = 3 +} + /// /// GameInput 裝置資訊快照。 /// @@ -175,13 +307,37 @@ 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, + 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; + GamepadCapabilities = gamepadCapabilities; DisplayName = displayName; } @@ -191,13 +347,80 @@ internal GameInputDeviceInfo( 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 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 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, + (GameInputShimModuleKind)LoadedModuleKind, + loadedModulePath); + } +} + /// /// Native shim 回傳的裝置資訊。 /// @@ -206,27 +429,108 @@ 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 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, + capabilities, parsedDisplayName); } -} \ No newline at end of file + + 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; + } +} diff --git a/tests/InputBox.Tests/GameInputPrimitivesTests.cs b/tests/InputBox.Tests/GameInputPrimitivesTests.cs new file mode 100644 index 0000000..cc86436 --- /dev/null +++ b/tests/InputBox.Tests/GameInputPrimitivesTests.cs @@ -0,0 +1,168 @@ +using InputBox.Core.Input; +using System.Reflection; +using System.Text; +using Xunit; + +namespace InputBox.Tests; + +/// +/// 驗證自有 GameInput shim 的受控資料模型,避免 native ABI 擴充後破壞既有 Gamepad 語意。 +/// +public sealed class GameInputPrimitivesTests +{ + /// + /// 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])!); + } + + /// + /// 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, + 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(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 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/InputBox.Tests.csproj b/tests/InputBox.Tests/InputBox.Tests.csproj index 0b6f003..bfe0ab7 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 diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md index 339ea63..df4b343 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -22,6 +22,7 @@ | `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載) | 12 | | `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 shim/runtime 不可用時退避至 XInput | 3 | | `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput missing reading 斷線重列舉、裝置狀態過濾、震動停止安全性與原生對話框切換時的殘留輸入回歸保護 | 8 | +| `GameInputPrimitivesTests` | 自有 GameInput shim 受控資料模型,涵蓋官方 v3 gamepad button 位元、timestamp 診斷欄位、edge detection timestamp 忽略與擴充 device info / capabilities 轉換 | 4 | | `GamepadCalibrationVisualizerMapperTests` | `GamepadCalibrationVisualizerMapper` 對校準視覺化座標限制、死區半徑換算、D-Pad 導覽防誤觸,以及雙搖桿狀態/控制器連線文案格式化的回歸保護 | 14 | | `GamepadEventBinderTests` | `GamepadEventBinder` 的 LB / RB / LT / RT 與肩鍵放開事件綁定回歸保護 | 1 | | `GamepadFaceButtonProfileTests` | `GamepadFaceButtonProfile` 的 Auto 解析、手動覆寫優先權、shim 保留 VID/PID 時的 Sony/Nintendo 判斷,以及 Xbox / PlayStation / Nintendo 模式的按鍵標示、助記詞同步、資源化字串、主畫面說明文字、目前生效配置顯示、標題列提示、選單勾選邏輯與 PlayStation ○/× 確認模式回歸保護 | 25 | @@ -44,7 +45,7 @@ | `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 | | `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 | | `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 | -| **合計** | | **372** | +| **合計** | | **376** | ## 二、執行方式 🚀 From 751dfdd1ba1e92c83b4007c63484334a12a7aa95 Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Fri, 22 May 2026 07:07:11 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat(gamepad):=20=E5=BC=B7=E5=8C=96=20Gam?= =?UTF-8?q?eInput=20shim=20=E8=BC=89=E5=85=A5=E8=A8=BA=E6=96=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 補強 GameInput native shim 的 runtime probe、ABI size 防呆、安全 DLL 載入來源記錄與 SRW lock 保護。 Managed layer 同步驗證 shim struct size,新增診斷模型與 Gamepad-only 測試,並更新工程文件與測試清單。 --- docs/engineering/gamepad-api.md | 5 + .../InputBoxGameInputNative.cpp | 895 ++++++++++++++++-- .../Core/Input/GameInputGamepadController.cs | 6 + src/InputBox/Core/Input/GameInputNative.cs | 66 +- .../Core/Input/GameInputPrimitives.cs | 189 ++++ .../GameInputPrimitivesTests.cs | 132 +++ tests/InputBox.Tests/README.md | 4 +- 7 files changed, 1190 insertions(+), 107 deletions(-) diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md index 2b29551..ee3bf83 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -21,6 +21,11 @@ - **自動退避**:當使用者手動設定使用 `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 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` 供使用者手動安裝,但應用程式不得自動執行安裝程式,也不得要求一般啟動流程取得系統管理員權限。 diff --git a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp index cf12c9a..a264058 100644 --- a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp +++ b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp @@ -28,7 +28,7 @@ using namespace GameInput::v3; namespace { const HRESULT InputBoxGameInputNoReading = HRESULT_FROM_WIN32(ERROR_NOT_FOUND); - constexpr uint32_t InputBoxGameInputShimAbiVersion = 2; + constexpr uint32_t InputBoxGameInputShimAbiVersion = 3; constexpr uint32_t InputBoxGameInputMaxExtraControlIndexes = 32; enum InputBoxGameInputModuleKind : uint32_t @@ -39,6 +39,18 @@ namespace 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; @@ -51,7 +63,37 @@ namespace { 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]; }; @@ -78,6 +120,7 @@ namespace uint32_t extraButtonIndexCount; uint32_t extraAxisIndexCount; uint32_t hasInputMapper; + uint32_t stringTruncationFlags; InputBoxGameInputVersion hardwareVersion; InputBoxGameInputVersion firmwareVersion; uint8_t extraButtonIndexes[InputBoxGameInputMaxExtraControlIndexes]; @@ -102,6 +145,18 @@ namespace 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; @@ -136,34 +191,87 @@ namespace ComPtr gameInput; std::vector devices; std::vector> callbacks; + 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); - void CopyUtf8( + 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; + return false; } const char* source = value != nullptr ? value : ""; + bool truncated = strlen(source) >= destinationLength; strncpy_s(destination, destinationLength, source, _TRUNCATE); + + return truncated; } - void CopyWideAsUtf8( + bool CopyWideAsUtf8( char* destination, size_t destinationLength, const wchar_t* value) noexcept { if (destinationLength == 0) { - return; + return false; } destination[0] = '\0'; @@ -171,33 +279,47 @@ namespace if (value == nullptr || value[0] == L'\0') { - return; + return false; } - int written = WideCharToMultiByte( + int required = WideCharToMultiByte( CP_UTF8, 0, value, -1, - destination, - static_cast(destinationLength), + nullptr, + 0, nullptr, nullptr); - if (written == 0) + if (required <= 0) { - destination[0] = '\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()); } - void CopyDeviceId( + bool CopyDeviceId( char* destination, size_t destinationLength, const APP_LOCAL_DEVICE_ID& deviceId) noexcept { if (destinationLength < 2) { - return; + return true; } destination[0] = '\0'; @@ -209,9 +331,11 @@ namespace { sprintf_s(destination + (i * 2), destinationLength - (i * 2), "%02X", bytes[i]); } + + return byteCount < sizeof(APP_LOCAL_DEVICE_ID); } - void CopyGuid( + bool CopyGuid( char* destination, size_t destinationLength, const GUID& value) noexcept @@ -221,12 +345,56 @@ namespace if (written <= 0) { - CopyUtf8(destination, destinationLength, ""); - - return; + return CopyUtf8(destination, destinationLength, ""); } - CopyWideAsUtf8(destination, destinationLength, buffer); + 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 @@ -290,11 +458,31 @@ namespace destination->outputReportCount = info->outputReportCount; destination->hardwareVersion = ToInputBoxVersion(info->hardwareVersion); destination->firmwareVersion = ToInputBoxVersion(info->firmwareVersion); - CopyDeviceId(destination->deviceId, sizeof(destination->deviceId), info->deviceId); - CopyDeviceId(destination->deviceRootId, sizeof(destination->deviceRootId), info->deviceRootId); - CopyGuid(destination->containerId, sizeof(destination->containerId), info->containerId); - CopyUtf8(destination->displayName, sizeof(destination->displayName), info->displayName); - CopyUtf8(destination->pnpPath, sizeof(destination->pnpPath), info->pnpPath); + + 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) { @@ -359,25 +547,83 @@ namespace const wchar_t* path, DWORD flags, HMODULE* module, - GameInputInitializeFn* initialize) noexcept + 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) { - return HRESULT_FROM_WIN32(GetLastError()); + 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); - return HRESULT_FROM_WIN32(GetLastError()); + if (getProcAddressHResult != nullptr) + { + *getProcAddressHResult = hr; + } + + if (getProcAddressWin32Error != nullptr) + { + *getProcAddressWin32Error = error; + } + + return hr; + } + + if (getProcAddressHResult != nullptr) + { + *getProcAddressHResult = S_OK; } *module = candidate; @@ -386,10 +632,15 @@ namespace return S_OK; } - HRESULT TryLoadRedistFromRegistry( - HMODULE* module, - GameInputInitializeFn* initialize) noexcept + 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)); @@ -407,17 +658,73 @@ namespace return HRESULT_FROM_WIN32(status); } - std::wstring path(redistDir.data()); + *path = redistDir.data(); - if (!path.empty() && - path.back() != L'\\') + if (!path->empty() && + path->back() != L'\\') { - path += L'\\'; + *path += L'\\'; } - path += L"GameInputRedist.dll"; + *path += L"GameInputRedist.dll"; - return TryLoadGameInputModule(path.c_str(), 0, module, initialize); + return S_OK; + } + + HRESULT TryLoadGameInputCandidate( + const wchar_t* path, + DWORD flags, + uint32_t moduleKind, + HMODULE* module, + GameInputInitializeFn* initialize, + InputBoxGameInputRuntimeProbeInfo* probe) noexcept + { + 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( @@ -425,19 +732,28 @@ namespace uint32_t* moduleKind, char* modulePath, size_t modulePathLength, - IGameInput** gameInput) noexcept + IGameInput** gameInput, + InputBoxGameInputRuntimeProbeInfo* probe = nullptr) noexcept { + if (probe != nullptr) + { + *probe = {}; + FillRuntimeProbeCommon(probe); + } + *module = nullptr; *moduleKind = InputBoxGameInputModuleUnknown; CopyUtf8(modulePath, modulePathLength, ""); *gameInput = nullptr; GameInputInitializeFn initialize = nullptr; - HRESULT hr = TryLoadGameInputModule( + HRESULT hr = TryLoadGameInputCandidate( L"GameInput.dll", LOAD_LIBRARY_SEARCH_SYSTEM32, + InputBoxGameInputModuleSystemGameInput, module, - &initialize); + &initialize, + probe); if (SUCCEEDED(hr)) { @@ -446,11 +762,13 @@ namespace if (FAILED(hr)) { - hr = TryLoadGameInputModule( + hr = TryLoadGameInputCandidate( L"GameInputRedist.dll", LOAD_LIBRARY_SEARCH_SYSTEM32, + InputBoxGameInputModuleSystemGameInputRedist, module, - &initialize); + &initialize, + probe); if (SUCCEEDED(hr)) { @@ -460,16 +778,46 @@ namespace if (FAILED(hr)) { - hr = TryLoadRedistFromRegistry(module, &initialize); + std::wstring redistPath; + HRESULT pathHr = TryGetRedistPathFromRegistry(&redistPath); - if (SUCCEEDED(hr)) + if (SUCCEEDED(pathHr)) { - *moduleKind = InputBoxGameInputModuleRegistryGameInputRedist; + 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; } @@ -477,11 +825,26 @@ namespace DWORD pathLength = GetModuleFileNameW(*module, path.data(), static_cast(path.size())); if (pathLength > 0) { - CopyWideAsUtf8(modulePath, modulePathLength, path.data()); + 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); @@ -489,6 +852,10 @@ namespace *moduleKind = InputBoxGameInputModuleUnknown; CopyUtf8(modulePath, modulePathLength, ""); } + else if (probe != nullptr) + { + probe->finalHResult = S_OK; + } return hr; } @@ -514,6 +881,131 @@ namespace return nullptr; } + HRESULT CopyDeviceLocked( + InputBoxGameInputContext* context, + const char* deviceId, + ComPtr* device, + InputBoxGameInputDeviceInfo* info = nullptr) noexcept + { + 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; @@ -602,17 +1094,17 @@ namespace static_cast(previousStatus)); } - HRESULT ResolveOptionalDevice( + HRESULT ResolveOptionalDeviceLocked( InputBoxGameInputContext* context, const char* deviceId, - IGameInputDevice** device) noexcept + ComPtr* device) noexcept { if (device == nullptr) { return E_INVALIDARG; } - *device = nullptr; + device->Reset(); if (deviceId == nullptr || deviceId[0] == '\0') @@ -620,21 +1112,58 @@ namespace return S_OK; } - DeviceEntry* entry = FindDevice(context, deviceId); + return CopyDeviceLocked(context, deviceId, device); + } - if (entry == nullptr) + HRESULT ResolveOptionalDevice( + InputBoxGameInputContext* context, + const char* deviceId, + ComPtr* device) noexcept + { + if (context == nullptr) { - return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + return E_INVALIDARG; } - *device = entry->device.Get(); + SharedContextLock lock(context->lock); - return S_OK; + return ResolveOptionalDeviceLocked(context, deviceId, device); } } extern "C" { + __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; + + HRESULT hr = LoadGameInput( + &module, + &moduleKind, + modulePath, + sizeof(modulePath), + &gameInput, + info); + + gameInput.Reset(); + + if (module != nullptr) + { + FreeLibrary(module); + } + + return hr; + } + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputCreate( InputBoxGameInputContext** context) noexcept { @@ -679,22 +1208,27 @@ extern "C" return; } - if (context->gameInput != nullptr) { - for (const std::unique_ptr& registration : context->callbacks) + ExclusiveContextLock lock(context->lock); + + if (context->gameInput != nullptr) { - if (registration != nullptr && - registration->token != 0) + for (const std::unique_ptr& registration : context->callbacks) { - registration->active.store(false); - context->gameInput->StopCallback(registration->token); - context->gameInput->UnregisterCallback(registration->token); + 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->callbacks.clear(); - context->devices.clear(); context->gameInput.Reset(); if (context->module != nullptr) @@ -716,11 +1250,12 @@ extern "C" } *info = {}; - info->abiVersion = InputBoxGameInputShimAbiVersion; - info->gameInputApiVersion = GAMEINPUT_API_VERSION; + FillShimInfoCommon(info); if (context != nullptr) { + SharedContextLock lock(context->lock); + info->loadedModuleKind = context->moduleKind; CopyUtf8(info->loadedModulePath, sizeof(info->loadedModulePath), context->modulePath); } @@ -728,12 +1263,42 @@ extern "C" return S_OK; } + __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; + } + __declspec(dllexport) HRESULT __stdcall InputBoxGameInputSetFocusPolicy( InputBoxGameInputContext* context, uint32_t policy) noexcept { - if (context == nullptr || - context->gameInput == nullptr) + if (context == nullptr) + { + return E_INVALIDARG; + } + + SharedContextLock lock(context->lock); + + if (context->gameInput == nullptr) { return E_INVALIDARG; } @@ -746,14 +1311,20 @@ extern "C" __declspec(dllexport) HRESULT __stdcall InputBoxGameInputRefreshDevices( InputBoxGameInputContext* context) noexcept { - if (context == nullptr || - context->gameInput == nullptr) + if (context == nullptr) { return E_INVALIDARG; } try { + ExclusiveContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + DeviceCollector collector; GameInputCallbackToken token = 0; HRESULT hr = context->gameInput->RegisterDeviceCallback( @@ -806,6 +1377,8 @@ extern "C" return 0; } + SharedContextLock lock(context->lock); + return static_cast(context->devices.size()); } @@ -816,8 +1389,14 @@ extern "C" { if (context == nullptr || info == nullptr || - index < 0 || - static_cast(index) >= context->devices.size()) + index < 0) + { + return E_INVALIDARG; + } + + SharedContextLock lock(context->lock); + + if (static_cast(index) >= context->devices.size()) { return E_INVALIDARG; } @@ -839,14 +1418,32 @@ extern "C" *status = 0; - DeviceEntry* entry = FindDevice(context, deviceId); + if (context == nullptr) + { + return E_INVALIDARG; + } - if (entry == nullptr) + ExclusiveContextLock lock(context->lock); + + if (context->gameInput == nullptr) { - return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + return E_INVALIDARG; } - *status = static_cast(entry->device->GetDeviceStatus()); + 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; } @@ -863,38 +1460,63 @@ extern "C" *state = {}; - DeviceEntry* entry = FindDevice(context, deviceId); + if (context == nullptr) + { + return E_INVALIDARG; + } - if (entry == nullptr) + ExclusiveContextLock lock(context->lock); + + if (context->gameInput == nullptr) { - return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + return E_INVALIDARG; + } + + ComPtr device; + HRESULT hr = CopyDeviceLocked(context, deviceId, &device); + + if (FAILED(hr)) + { + RecordReadResultLocked(context, hr, 0, 0); + + return hr; } - if ((entry->device->GetDeviceStatus() & GameInputDeviceConnected) == 0) + uint32_t deviceStatus = static_cast(device->GetDeviceStatus()); + + if ((deviceStatus & GameInputDeviceConnected) == 0) { + RecordDeviceUnavailableLocked(context, deviceStatus); + return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); } ComPtr reading; - HRESULT hr = context->gameInput->GetCurrentReading( + hr = context->gameInput->GetCurrentReading( GameInputKindGamepad, - entry->device.Get(), + device.Get(), &reading); if (FAILED(hr) || reading == nullptr) { - return FAILED(hr) ? hr : InputBoxGameInputNoReading; + 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; } @@ -908,7 +1530,7 @@ extern "C" uint64_t* callbackToken) noexcept { if (context == nullptr || - context->gameInput == nullptr || + kind != static_cast(GameInputKindGamepad) || callback == nullptr || callbackToken == nullptr) { @@ -917,12 +1539,24 @@ extern "C" *callbackToken = 0; - IGameInputDevice* device = nullptr; - HRESULT hr = ResolveOptionalDevice(context, deviceId, &device); + ComPtr gameInput; + ComPtr device; - if (FAILED(hr)) { - return hr; + SharedContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + gameInput = context->gameInput; + HRESULT hr = ResolveOptionalDeviceLocked(context, deviceId, &device); + + if (FAILED(hr)) + { + return hr; + } } auto registration = std::make_unique(); @@ -930,8 +1564,8 @@ extern "C" registration->callbackContext = callbackContext; GameInputCallbackToken token = 0; - hr = context->gameInput->RegisterReadingCallback( - device, + HRESULT hr = gameInput->RegisterReadingCallback( + device.Get(), static_cast(kind), registration.get(), OnReadingCallback, @@ -944,7 +1578,21 @@ extern "C" registration->token = token; *callbackToken = token; - context->callbacks.push_back(std::move(registration)); + + { + ExclusiveContextLock lock(context->lock); + if (context->gameInput == nullptr) + { + 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; } @@ -960,7 +1608,7 @@ extern "C" uint64_t* callbackToken) noexcept { if (context == nullptr || - context->gameInput == nullptr || + kind != static_cast(GameInputKindGamepad) || callback == nullptr || callbackToken == nullptr) { @@ -969,12 +1617,24 @@ extern "C" *callbackToken = 0; - IGameInputDevice* device = nullptr; - HRESULT hr = ResolveOptionalDevice(context, deviceId, &device); + ComPtr gameInput; + ComPtr device; - if (FAILED(hr)) { - return hr; + SharedContextLock lock(context->lock); + + if (context->gameInput == nullptr) + { + return E_INVALIDARG; + } + + gameInput = context->gameInput; + HRESULT hr = ResolveOptionalDeviceLocked(context, deviceId, &device); + + if (FAILED(hr)) + { + return hr; + } } auto registration = std::make_unique(); @@ -982,8 +1642,8 @@ extern "C" registration->callbackContext = callbackContext; GameInputCallbackToken token = 0; - hr = context->gameInput->RegisterDeviceCallback( - device, + HRESULT hr = gameInput->RegisterDeviceCallback( + device.Get(), static_cast(kind), static_cast(statusFilter), static_cast(enumerationKind), @@ -998,7 +1658,21 @@ extern "C" registration->token = token; *callbackToken = token; - context->callbacks.push_back(std::move(registration)); + + { + ExclusiveContextLock lock(context->lock); + if (context->gameInput == nullptr) + { + 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; } @@ -1008,12 +1682,18 @@ extern "C" uint64_t callbackToken) noexcept { if (context == nullptr || - context->gameInput == 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(), @@ -1044,11 +1724,24 @@ extern "C" float leftTrigger, float rightTrigger) noexcept { - DeviceEntry* entry = FindDevice(context, deviceId); + if (context == nullptr) + { + return E_INVALIDARG; + } - if (entry == nullptr) + SharedContextLock lock(context->lock); + + if (context->gameInput == nullptr) { - return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); + return E_INVALIDARG; + } + + ComPtr device; + HRESULT hr = CopyDeviceLocked(context, deviceId, &device); + + if (FAILED(hr)) + { + return hr; } GameInputRumbleParams rumble @@ -1059,7 +1752,7 @@ extern "C" rightTrigger }; - entry->device->SetRumbleState(&rumble); + device->SetRumbleState(&rumble); return S_OK; } diff --git a/src/InputBox/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs index 9bd8764..d93b8f1 100644 --- a/src/InputBox/Core/Input/GameInputGamepadController.cs +++ b/src/InputBox/Core/Input/GameInputGamepadController.cs @@ -894,6 +894,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 丟出例外以觸發退避邏輯 diff --git a/src/InputBox/Core/Input/GameInputNative.cs b/src/InputBox/Core/Input/GameInputNative.cs index cab9c21..c04f441 100644 --- a/src/InputBox/Core/Input/GameInputNative.cs +++ b/src/InputBox/Core/Input/GameInputNative.cs @@ -39,15 +39,49 @@ public static GameInput Create() Marshal.ThrowExceptionForHR(hr); } - hr = GameInputNativeMethods.GetShimInfo(handle, out GameInputNativeShimInfo nativeShimInfo); + try + { + hr = GameInputNativeMethods.GetShimInfo(handle, out GameInputNativeShimInfo nativeShimInfo); - if (hr < 0) + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + GameInputShimInfo shimInfo = nativeShimInfo.ToShimInfo(); + shimInfo.AbiInfo.ThrowIfMismatch(); + + return new GameInput(handle, shimInfo); + } + catch { handle.Dispose(); - Marshal.ThrowExceptionForHR(hr); + + 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 new GameInput(handle, nativeShimInfo.ToShimInfo()); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"GameInput runtime probe failed: {ex.Message}"); + probeInfo = default; + + return false; + } } /// @@ -222,6 +256,20 @@ internal GameInputDeviceStatus GetDeviceStatus(GameInputDevice device) 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 內容。 /// @@ -475,6 +523,10 @@ internal static partial class GameInputNativeMethods { private const string NativeLibraryName = "InputBox.GameInput.Native"; + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputProbeRuntime")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int ProbeRuntime(out GameInputNativeRuntimeProbeInfo info); + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputCreate")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] internal static extern int Create(out SafeGameInputContextHandle context); @@ -489,6 +541,12 @@ internal static extern int GetShimInfo( SafeGameInputContextHandle context, out GameInputNativeShimInfo info); + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDiagnosticsSnapshot")] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] + internal static extern int GetDiagnosticsSnapshot( + SafeGameInputContextHandle context, + out GameInputNativeDiagnosticsSnapshot snapshot); + [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputSetFocusPolicy")] [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] internal static extern int SetFocusPolicy(SafeGameInputContextHandle context, uint policy); diff --git a/src/InputBox/Core/Input/GameInputPrimitives.cs b/src/InputBox/Core/Input/GameInputPrimitives.cs index 2589f99..76c3bca 100644 --- a/src/InputBox/Core/Input/GameInputPrimitives.cs +++ b/src/InputBox/Core/Input/GameInputPrimitives.cs @@ -278,15 +278,95 @@ internal readonly record struct GameInputGamepadCapabilities( 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 載入來源。 /// @@ -319,6 +399,7 @@ internal GameInputDeviceInfo( string deviceRootId, string containerId, string pnpPath, + GameInputStringTruncationFlags stringTruncationFlags, GameInputGamepadCapabilities gamepadCapabilities, string displayName) { @@ -337,6 +418,7 @@ internal GameInputDeviceInfo( DeviceRootId = deviceRootId; ContainerId = containerId; PnpPath = pnpPath; + StringTruncationFlags = stringTruncationFlags; GamepadCapabilities = gamepadCapabilities; DisplayName = displayName; } @@ -371,6 +453,8 @@ internal GameInputDeviceInfo( public string PnpPath { get; } + public GameInputStringTruncationFlags StringTruncationFlags { get; } + public GameInputGamepadCapabilities GamepadCapabilities { get; } private string DisplayName { get; } @@ -401,6 +485,12 @@ 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]; @@ -416,11 +506,82 @@ public readonly GameInputShimInfo ToShimInfo() 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 回傳的裝置資訊。 /// @@ -448,6 +609,7 @@ internal unsafe struct GameInputNativeDeviceInfo public uint ExtraButtonIndexCount; public uint ExtraAxisIndexCount; public uint HasInputMapper; + public uint StringTruncationFlags; public GameInputNativeVersionInfo HardwareVersion; public GameInputNativeVersionInfo FirmwareVersion; public fixed byte ExtraButtonIndexes[32]; @@ -511,6 +673,7 @@ public GameInputDeviceInfo ToDeviceInfo() parsedDeviceRootId, parsedContainerId, parsedPnpPath, + (GameInputStringTruncationFlags)StringTruncationFlags, capabilities, parsedDisplayName); } @@ -534,3 +697,29 @@ private static byte[] ReadIndexes(byte* source, uint count) 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/tests/InputBox.Tests/GameInputPrimitivesTests.cs b/tests/InputBox.Tests/GameInputPrimitivesTests.cs index cc86436..036f8d3 100644 --- a/tests/InputBox.Tests/GameInputPrimitivesTests.cs +++ b/tests/InputBox.Tests/GameInputPrimitivesTests.cs @@ -86,6 +86,122 @@ public void HasSameInputValues_TimestampOnlyChanged_ReturnsTrue() 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。 /// @@ -114,6 +230,7 @@ public unsafe void ToDeviceInfo_ExtendedNativeFields_PreservesGamepadCapabilitie 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 } }; @@ -145,6 +262,7 @@ public unsafe void ToDeviceInfo_ExtendedNativeFields_PreservesGamepadCapabilitie 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); @@ -153,6 +271,20 @@ public unsafe void ToDeviceInfo_ExtendedNativeFields_PreservesGamepadCapabilitie 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 unsafe void WriteUtf8(byte* destination, int destinationLength, string value) { byte[] bytes = Encoding.UTF8.GetBytes(value); diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md index df4b343..cd66b22 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -22,7 +22,7 @@ | `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載) | 12 | | `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 shim/runtime 不可用時退避至 XInput | 3 | | `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput missing reading 斷線重列舉、裝置狀態過濾、震動停止安全性與原生對話框切換時的殘留輸入回歸保護 | 8 | -| `GameInputPrimitivesTests` | 自有 GameInput shim 受控資料模型,涵蓋官方 v3 gamepad button 位元、timestamp 診斷欄位、edge detection timestamp 忽略與擴充 device info / capabilities 轉換 | 4 | +| `GameInputPrimitivesTests` | 自有 GameInput shim 受控資料模型,涵蓋官方 v3 gamepad button 位元、timestamp 診斷欄位、edge detection timestamp 忽略、runtime probe/ABI size 診斷、Gamepad-only 邊界與擴充 device info / capabilities 轉換 | 9 | | `GamepadCalibrationVisualizerMapperTests` | `GamepadCalibrationVisualizerMapper` 對校準視覺化座標限制、死區半徑換算、D-Pad 導覽防誤觸,以及雙搖桿狀態/控制器連線文案格式化的回歸保護 | 14 | | `GamepadEventBinderTests` | `GamepadEventBinder` 的 LB / RB / LT / RT 與肩鍵放開事件綁定回歸保護 | 1 | | `GamepadFaceButtonProfileTests` | `GamepadFaceButtonProfile` 的 Auto 解析、手動覆寫優先權、shim 保留 VID/PID 時的 Sony/Nintendo 判斷,以及 Xbox / PlayStation / Nintendo 模式的按鍵標示、助記詞同步、資源化字串、主畫面說明文字、目前生效配置顯示、標題列提示、選單勾選邏輯與 PlayStation ○/× 確認模式回歸保護 | 25 | @@ -45,7 +45,7 @@ | `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 | | `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 | | `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 | -| **合計** | | **376** | +| **合計** | | **381** | ## 二、執行方式 🚀 From ce0328fa593d4fdf2958ff935cf83bc2395efc9f Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Fri, 22 May 2026 07:29:46 +0800 Subject: [PATCH 04/12] =?UTF-8?q?chore(gamepad):=20=E8=A3=9C=E5=85=85=20Ga?= =?UTF-8?q?meInput=20shim=20=E8=AA=AA=E6=98=8E=E8=88=87=E6=B8=AC=E8=A9=A6?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 InputBox.GameInput.Native README,說明 gamepad-only 範圍、ABI 規則、runtime 載入、回呼與發佈驗證邊界。 補上 C++ shim 維護註解,並讓 native 專案以 /utf-8 編譯,避免正體中文註解在 MSVC CP950 環境產生 C4819。 同步將 Microsoft.Testing.Extensions.CodeCoverage 更新至 18.7.0,保持 CI coverage 工具鏈使用新版套件。 --- .../InputBox.GameInput.Native.vcxproj | 3 + .../InputBoxGameInputNative.cpp | 28 ++++++ src/InputBox.GameInput.Native/README.md | 85 +++++++++++++++++++ tests/InputBox.Tests/InputBox.Tests.csproj | 2 +- 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 src/InputBox.GameInput.Native/README.md diff --git a/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj b/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj index 0939fb5..230b927 100644 --- a/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj +++ b/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj @@ -51,6 +51,7 @@ $(IntDir)$(TargetName).compile.pdb true $(GameInputPackageRoot)native\include;%(AdditionalIncludeDirectories) + /utf-8 %(AdditionalOptions) Windows @@ -70,6 +71,7 @@ $(IntDir)$(TargetName).compile.pdb true $(GameInputPackageRoot)native\include;%(AdditionalIncludeDirectories) + /utf-8 %(AdditionalOptions) Windows @@ -80,6 +82,7 @@ + diff --git a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp index a264058..36b43b6 100644 --- a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp +++ b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp @@ -59,6 +59,8 @@ namespace uint16_t revision; }; + // 下列 C ABI 結構必須與 GameInputPrimitives.cs 的受控端結構保持版面相容。 + // 欄位異動時,請同步更新 InputBoxGameInputShimAbiVersion 與受控端大小檢查。 struct InputBoxGameInputShimInfo { uint32_t abiVersion; @@ -191,6 +193,8 @@ namespace ComPtr gameInput; std::vector devices; std::vector> callbacks; + // 保護 GameInput COM 存取,以及銷毀期間的裝置、回呼與診斷狀態。 + // 持有此鎖時不得呼叫受控端回呼。 SRWLOCK lock = SRWLOCK_INIT; uint64_t lastReadingTimestamp = 0; uint64_t missingReadingCount = 0; @@ -679,6 +683,8 @@ namespace GameInputInitializeFn* initialize, InputBoxGameInputRuntimeProbeInfo* probe) noexcept { + // 保留每個候選載入嘗試的診斷資訊。GameInput 退避到 XInput 時, + // 這些欄位是判斷 DLL 缺失、export 缺失或初始化失敗的主要線索。 if (probe != nullptr) { probe->attemptedModuleKind = moduleKind; @@ -747,6 +753,8 @@ namespace *gameInput = nullptr; GameInputInitializeFn initialize = nullptr; + // 優先使用 System32 內的系統 GameInput。登錄檔 redist 路徑排在最後, + // 並使用 DLL_LOAD_DIR + SYSTEM32,避免相依 DLL 從目前工作目錄解析。 HRESULT hr = TryLoadGameInputCandidate( L"GameInput.dll", LOAD_LIBRARY_SEARCH_SYSTEM32, @@ -887,6 +895,8 @@ namespace ComPtr* device, InputBoxGameInputDeviceInfo* info = nullptr) noexcept { + // 呼叫端必須先持有 context->lock。回傳的 ComPtr 可讓裝置離開 vector 後仍存活, + // 但 vector 查找本身仍必須同步。 if (context == nullptr || deviceId == nullptr || device == nullptr) @@ -940,6 +950,8 @@ namespace return; } + // 這些計數器僅供診斷;受控端邊緣偵測與中立閘門 + // 不應依據它們改變行為。 context->lastReadHResult = hr; context->lastReadDeviceStatus = deviceStatus; @@ -1035,6 +1047,8 @@ namespace _In_ void* context, _In_ IGameInputReading* reading) { + // 回呼路徑只作為喚醒與診斷的輔助通道。這裡只把 reading 轉成 POD, + // 是否重新整理則交由受控端輪詢迴圈決定。 auto* registration = static_cast(context); if (registration == nullptr || @@ -1065,6 +1079,8 @@ namespace _In_ GameInputDeviceStatus currentStatus, _In_ GameInputDeviceStatus previousStatus) { + // 不要把 IGameInputDevice 跨過 C ABI 傳給受控端。受控層只接收穩定識別與狀態位元, + // 再透過輪詢執行緒重新整理。 auto* registration = static_cast(context); if (registration == nullptr || @@ -1146,6 +1162,8 @@ extern "C" char modulePath[512]{}; ComPtr gameInput; + // Probe 只建立短生命週期的 IGameInput,用來分類執行階段載入失敗原因。 + // 不得留下持久 context 或回呼註冊。 HRESULT hr = LoadGameInput( &module, &moduleKind, @@ -1211,6 +1229,7 @@ extern "C" { ExclusiveContextLock lock(context->lock); + // 先停止回呼,再釋放 vector/module,避免回呼觀察到半銷毀的原生狀態。 if (context->gameInput != nullptr) { for (const std::unique_ptr& registration : context->callbacks) @@ -1327,6 +1346,8 @@ extern "C" DeviceCollector collector; GameInputCallbackToken token = 0; + // 阻塞列舉提供 60 FPS 輪詢迴圈需要的完整快照。 + // 這個回呼註冊是暫時的,替換裝置清單前會先解除註冊。 HRESULT hr = context->gameInput->RegisterDeviceCallback( nullptr, GameInputKindGamepad, @@ -1550,6 +1571,8 @@ extern "C" return E_INVALIDARG; } + // 在 shared lock 內複製 COM 參考,接著不持有 context lock 進行註冊。 + // GameInput 可能在註冊期間呼叫回呼。 gameInput = context->gameInput; HRESULT hr = ResolveOptionalDeviceLocked(context, deviceId, &device); @@ -1583,6 +1606,8 @@ extern "C" ExclusiveContextLock lock(context->lock); if (context->gameInput == nullptr) { + // 原生註冊成功後 destroy 搶先完成。這裡立即解除註冊並回報失敗, + // 避免受控端保留這個 token。 registration->active.store(false); gameInput->StopCallback(token); gameInput->UnregisterCallback(token); @@ -1628,6 +1653,8 @@ extern "C" return E_INVALIDARG; } + // 參考 RegisterReadingCallback:回呼註冊不持有 context lock, + // 並且只在 context 仍存活時發布 token。 gameInput = context->gameInput; HRESULT hr = ResolveOptionalDeviceLocked(context, deviceId, &device); @@ -1663,6 +1690,7 @@ extern "C" ExclusiveContextLock lock(context->lock); if (context->gameInput == nullptr) { + // context 銷毀與註冊發生競態;回傳前先停止並解除註冊。 registration->active.store(false); gameInput->StopCallback(token); gameInput->UnregisterCallback(token); diff --git a/src/InputBox.GameInput.Native/README.md b/src/InputBox.GameInput.Native/README.md new file mode 100644 index 0000000..0fb2cb3 --- /dev/null +++ b/src/InputBox.GameInput.Native/README.md @@ -0,0 +1,85 @@ +# 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 以原生程式庫形式納入單檔發佈。 +- 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 +``` + +修改 shim ABI、執行階段載入或發佈打包時,還要做 Release 發佈 / ZIP 試跑,確認單檔發佈、redist、授權聲明與禁止項目仍符合工作流程。 diff --git a/tests/InputBox.Tests/InputBox.Tests.csproj b/tests/InputBox.Tests/InputBox.Tests.csproj index bfe0ab7..3e2e3cc 100644 --- a/tests/InputBox.Tests/InputBox.Tests.csproj +++ b/tests/InputBox.Tests/InputBox.Tests.csproj @@ -18,7 +18,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + From 804212a8a8331686551e4b249be388ea8e2b7e4a Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Fri, 22 May 2026 09:38:40 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat(gameinput):=20=E8=A3=9C=E5=BC=B7=20s?= =?UTF-8?q?him=20=E5=8F=AF=E9=9D=A0=E6=80=A7=E9=A9=97=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 native shim export/probe 驗證腳本,並接入 CI 與 release workflow。 補強 GameInput 診斷快照 log,維持 Gamepad-only 行為不變。 同步更新 GameInput shim 文件、工程規範與 P/Invoke export 清單測試。 --- .github/workflows/ci.yml | 43 +++- .github/workflows/release.yml | 107 ++++++++- docs/engineering/gamepad-api.md | 1 + .../InputBox.GameInput.Native.vcxproj | 2 +- src/InputBox.GameInput.Native/README.md | 3 + .../Core/Input/GameInputGamepadController.cs | 44 +++- src/InputBox/InputBox.csproj | 4 +- .../GameInputPrimitivesTests.cs | 63 ++++++ tests/InputBox.Tests/README.md | 4 +- tools/Validate-GameInputNativeShim.ps1 | 212 ++++++++++++++++++ 10 files changed, 464 insertions(+), 19 deletions(-) create mode 100644 tools/Validate-GameInputNativeShim.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52f8aa5..fd60fca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,47 @@ 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:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` + /m + + - name: 驗證 GameInput Native Shim exports / probe + 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 +261,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 c30f73d..2914dda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,6 @@ jobs: PUBLISH_DIR: "./src/InputBox/out" EXE_NAME: 'InputBox.exe' NATIVE_SHIM_NAME: 'InputBox.GameInput.Native.dll' - GAMEINPUT_VERSION: '3.4.218' SHA256_HASH: '' GAMEINPUT_REDIST_SHA256: '' ZIP_FILENAME: '' @@ -79,13 +78,97 @@ jobs: - 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 - run: > - msbuild .\src\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj - /p:Configuration=Release - /p:Platform=x64 - /p:GameInputPackageVersion=${{ env.GAMEINPUT_VERSION }} - /m + shell: pwsh + run: | + msbuild .\src\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj ` + /p:Configuration=Release ` + /p:Platform=x64 ` + /p:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` + /m + + - name: 驗證 GameInput Native Shim exports / probe + 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 @@ -295,7 +378,7 @@ jobs: # 4. 打包 Microsoft GameInput redist 與授權檔案。 Write-Host "=== 開始處理 Microsoft GameInput redist 與授權 ===" - $gameInputPackageDir = Join-Path $env:USERPROFILE ".nuget\packages\microsoft.gameinput\${{ env.GAMEINPUT_VERSION }}" + $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" @@ -323,7 +406,7 @@ jobs: 'Microsoft GameInput Redistributable', '===================================', '', - "Version: ${{ env.GAMEINPUT_VERSION }}", + "Version: $env:GAMEINPUT_VERSION", 'Included file: GameInputRedist.msi', "SHA256: $redistHash", '', @@ -339,8 +422,8 @@ jobs: 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 "- 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.' @@ -439,4 +522,4 @@ jobs: sha256: true update_release: true release_heading: "🛡️ **VirusTotal 掃描結果:**" - summary: true \ No newline at end of file + summary: true diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md index ee3bf83..ed0e8d4 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -22,6 +22,7 @@ - **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;缺 export 或 probe crash 時不得產生發佈 ZIP。 - **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。 diff --git a/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj b/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj index 230b927..42e8a17 100644 --- a/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj +++ b/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj @@ -18,7 +18,7 @@ 10.0 3.4.218 $(UserProfile)\.nuget\packages\ - $(NuGetPackageRoot)microsoft.gameinput\$(GameInputPackageVersion)\ + $(NuGetPackageRoot)microsoft.gameinput\$(GameInputPackageVersion)\ diff --git a/src/InputBox.GameInput.Native/README.md b/src/InputBox.GameInput.Native/README.md index 0fb2cb3..02ec92e 100644 --- a/src/InputBox.GameInput.Native/README.md +++ b/src/InputBox.GameInput.Native/README.md @@ -70,6 +70,8 @@ DLL 載入規則必須維持保守: - 原生 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、指標大小與結構大小。 - Release ZIP 不應包含可見的 `InputBox.GameInput.Native.dll` 側載 DLL,也不應包含 `gameinput.dll`。 - ZIP 可包含 `redist/GameInputRedist.msi` 供使用者手動安裝;InputBox 不會自動執行安裝程式。 @@ -80,6 +82,7 @@ DLL 載入規則必須維持保守: ```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、授權聲明與禁止項目仍符合工作流程。 diff --git a/src/InputBox/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs index d93b8f1..a1851e6 100644 --- a/src/InputBox/Core/Input/GameInputGamepadController.cs +++ b/src/InputBox/Core/Input/GameInputGamepadController.cs @@ -755,7 +755,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(); } /// @@ -860,6 +867,7 @@ private async Task PollingLoopAsync(CancellationToken cancellationToken, TaskCom GameInputShimInfo shimInfo = _gameInput.ShimInfo; LoggerService.LogInfo($"GameInputShim abi={shimInfo.AbiVersion} api={shimInfo.GameInputApiVersion} moduleKind={shimInfo.LoadedModuleKind} modulePath={shimInfo.LoadedModulePath}"); + LogGameInputDiagnosticsSnapshot("init"); // 初始化時先直接列舉一次,再註冊非阻塞裝置回呼。 // 這樣可避免為了拿到既有裝置名單而付出兩次同步枚舉成本。 @@ -982,6 +990,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}"); + } + } + /// /// 輪詢 /// @@ -1060,6 +1099,7 @@ private void Poll() if ((status & GameInputDeviceStatus.Connected) == 0) { Debug.WriteLine("[GameInput] 目前裝置狀態已非 Connected,立即重新整理裝置清單。"); + LogGameInputDiagnosticsSnapshot("device-status-non-connected"); RefreshAfterCurrentDeviceBecameUnavailable(); return; @@ -1235,6 +1275,7 @@ private bool HandleMissingCurrentReading() _needsRefresh = true; Interlocked.Exchange(ref _refreshRequestedTicks, Stopwatch.GetTimestamp()); + LogGameInputDiagnosticsSnapshot("missing-reading-threshold"); TryFindDevice(); return _device == null; @@ -2995,6 +3036,7 @@ private Task DisposeResourcesAsync() d.Dispose(); } + LogGameInputDiagnosticsSnapshot(gameInput, "dispose"); gameInput?.Dispose(); } catch (Exception ex) diff --git a/src/InputBox/InputBox.csproj b/src/InputBox/InputBox.csproj index 49cb82b..0786d29 100644 --- a/src/InputBox/InputBox.csproj +++ b/src/InputBox/InputBox.csproj @@ -54,7 +54,7 @@ - + @@ -62,7 +62,7 @@ Condition="'$(OS)' == 'Windows_NT' and Exists('$(VCTargetsPath)\Microsoft.Cpp.Default.props')" ReferenceOutputAssembly="false" SkipGetTargetFrameworkProperties="true" - Properties="Configuration=$(Configuration);Platform=x64;GameInputPackageVersion=3.4.218" /> + Properties="Configuration=$(Configuration);Platform=x64;GameInputPackageRoot=$(PkgMicrosoft_GameInput)\" /> diff --git a/tests/InputBox.Tests/GameInputPrimitivesTests.cs b/tests/InputBox.Tests/GameInputPrimitivesTests.cs index 036f8d3..95a04e8 100644 --- a/tests/InputBox.Tests/GameInputPrimitivesTests.cs +++ b/tests/InputBox.Tests/GameInputPrimitivesTests.cs @@ -1,6 +1,7 @@ using InputBox.Core.Input; using System.Reflection; using System.Text; +using System.Text.RegularExpressions; using Xunit; namespace InputBox.Tests; @@ -10,6 +11,50 @@ namespace InputBox.Tests; /// 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 常數一致且不覆蓋既有按鍵。 /// @@ -285,6 +330,24 @@ private static GameInputNativeShimInfo CreateNativeShimInfo() 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); diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md index cd66b22..3a2fefa 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -22,7 +22,7 @@ | `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載) | 12 | | `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 shim/runtime 不可用時退避至 XInput | 3 | | `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput missing reading 斷線重列舉、裝置狀態過濾、震動停止安全性與原生對話框切換時的殘留輸入回歸保護 | 8 | -| `GameInputPrimitivesTests` | 自有 GameInput shim 受控資料模型,涵蓋官方 v3 gamepad button 位元、timestamp 診斷欄位、edge detection timestamp 忽略、runtime probe/ABI size 診斷、Gamepad-only 邊界與擴充 device info / capabilities 轉換 | 9 | +| `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 解析、手動覆寫優先權、shim 保留 VID/PID 時的 Sony/Nintendo 判斷,以及 Xbox / PlayStation / Nintendo 模式的按鍵標示、助記詞同步、資源化字串、主畫面說明文字、目前生效配置顯示、標題列提示、選單勾選邏輯與 PlayStation ○/× 確認模式回歸保護 | 25 | @@ -45,7 +45,7 @@ | `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 | | `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 | | `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 | -| **合計** | | **381** | +| **合計** | | **382** | ## 二、執行方式 🚀 diff --git a/tools/Validate-GameInputNativeShim.ps1 b/tools/Validate-GameInputNativeShim.ps1 new file mode 100644 index 0000000..3b31653 --- /dev/null +++ b/tools/Validate-GameInputNativeShim.ps1 @@ -0,0 +1,212 @@ +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 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]; + } + + [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputProbeRuntime", CallingConvention = CallingConvention.StdCall)] + public static extern int ProbeRuntime(out RuntimeProbeInfo info); +} +"@ + + $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)。" + } + + if ($probe.ShimInfoSize -eq 0 -or + $probe.RuntimeProbeInfoSize -eq 0 -or + $probe.DeviceInfoSize -eq 0 -or + $probe.GamepadStateSize -eq 0 -or + $probe.DiagnosticsSnapshotSize -eq 0) { + throw 'GameInput native probe 回報的 struct size 不完整。' + } + + Write-Host ("GameInput native probe smoke 通過:hr=0x{0:X8}, abi={1}, api=0x{2:X8}, pointer={3}, finalHr=0x{4:X8}, initHr=0x{5:X8}" -f ` + (ConvertTo-UInt32HexValue -Value $hr), + $probe.AbiVersion, + $probe.GameInputApiVersion, + $probe.PointerSize, + (ConvertTo-UInt32HexValue -Value $probe.FinalHResult), + (ConvertTo-UInt32HexValue -Value $probe.InitializeHResult)) +} + +$resolvedNativeShim = Resolve-RequiredPath -Path $NativeShimPath -Description 'GameInput native shim' +$resolvedManagedSource = Resolve-RequiredPath -Path $ManagedSourcePath -Description 'managed GameInputNative.cs' + +Test-NativeExports -NativeShim $resolvedNativeShim -ManagedSource $resolvedManagedSource +Invoke-ProbeSmoke -NativeShim $resolvedNativeShim From df67a048960582956817731917c0343e8a907abb Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Fri, 22 May 2026 09:58:11 +0800 Subject: [PATCH 06/12] =?UTF-8?q?test(gameinput):=20=E5=BC=B7=E5=8C=96=20s?= =?UTF-8?q?him=20ABI=20=E5=AE=88=E9=96=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 讓 tools 變更觸發 CI,避免驗證腳本修改未進入 native shim 守門流程。 補強 GameInput native probe smoke,直接比對 native 回報與 managed mirror 的 struct size。 同步更新 GameInput shim 文件與工程規範。 --- .github/workflows/ci.yml | 1 + docs/engineering/gamepad-api.md | 2 +- src/InputBox.GameInput.Native/README.md | 2 +- tools/Validate-GameInputNativeShim.ps1 | 127 ++++++++++++++++++++++-- 4 files changed, 123 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd60fca..621f2a3 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' diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md index ed0e8d4..d54fd77 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -22,7 +22,7 @@ - **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;缺 export 或 probe crash 時不得產生發佈 ZIP。 +- **GameInput shim 發佈驗證**:CI 與 release 必須在 native shim 建置後比對 `GameInputNativeMethods` 宣告的 `InputBoxGameInput*` EntryPoint 與 DLL exports,並執行 `InputBoxGameInputProbeRuntime` smoke test;缺 export、probe crash 或 native/managed struct size 不符時不得產生發佈 ZIP。CI 的 code filter 必須包含 `tools/**`,避免驗證腳本異動未觸發守門。 - **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。 diff --git a/src/InputBox.GameInput.Native/README.md b/src/InputBox.GameInput.Native/README.md index 02ec92e..cae5026 100644 --- a/src/InputBox.GameInput.Native/README.md +++ b/src/InputBox.GameInput.Native/README.md @@ -71,7 +71,7 @@ DLL 載入規則必須維持保守: - 原生 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、指標大小與結構大小。 +- 同一支驗證腳本也會呼叫 `InputBoxGameInputProbeRuntime` 做無硬體 smoke test;GameInput runtime 初始化可成功或失敗,但 probe 必須可安全回報 ABI,且 native 回報的指標大小與跨 ABI 結構大小必須與受控端 mirror 相符。 - Release ZIP 不應包含可見的 `InputBox.GameInput.Native.dll` 側載 DLL,也不應包含 `gameinput.dll`。 - ZIP 可包含 `redist/GameInputRedist.msi` 供使用者手動安裝;InputBox 不會自動執行安裝程式。 diff --git a/tools/Validate-GameInputNativeShim.ps1 b/tools/Validate-GameInputNativeShim.ps1 index 3b31653..6123dbc 100644 --- a/tools/Validate-GameInputNativeShim.ps1 +++ b/tools/Validate-GameInputNativeShim.ps1 @@ -137,6 +137,30 @@ 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 { @@ -162,8 +186,83 @@ public static unsafe class $typeName 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(); } "@ @@ -188,19 +287,33 @@ public static unsafe class $typeName throw "GameInput native probe 指標大小不符:native=$($probe.PointerSize), process=$([IntPtr]::Size)。" } - if ($probe.ShimInfoSize -eq 0 -or - $probe.RuntimeProbeInfoSize -eq 0 -or - $probe.DeviceInfoSize -eq 0 -or - $probe.GamepadStateSize -eq 0 -or - $probe.DiagnosticsSnapshotSize -eq 0) { - throw 'GameInput native probe 回報的 struct 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}, finalHr=0x{4:X8}, initHr=0x{5:X8}" -f ` + 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)) } From 24a12db27ab4a7d84bdbea1263f8ca4d9a074ff8 Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Fri, 22 May 2026 10:14:15 +0800 Subject: [PATCH 07/12] =?UTF-8?q?docs(gameinput):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=A1=AC=E9=AB=94=E9=A9=97=E8=AD=89=E7=9F=A9=E9=99=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 GameInput 手動硬體驗證文件,整理實體控制器與發佈 ZIP 抽測情境。 同步更新 GameInput API 規範與 native shim README,說明此矩陣不是日常 PR 或 CI 必跑關卡。 --- .../gameinput-hardware-verification.md | 58 +++++++++++++++++++ docs/engineering/gamepad-api.md | 1 + src/InputBox.GameInput.Native/README.md | 2 + 3 files changed, 61 insertions(+) create mode 100644 docs/engineering/gameinput-hardware-verification.md 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 d54fd77..30a4bf3 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -23,6 +23,7 @@ - **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;缺 export、probe crash 或 native/managed struct size 不符時不得產生發佈 ZIP。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。 diff --git a/src/InputBox.GameInput.Native/README.md b/src/InputBox.GameInput.Native/README.md index cae5026..3b973c2 100644 --- a/src/InputBox.GameInput.Native/README.md +++ b/src/InputBox.GameInput.Native/README.md @@ -86,3 +86,5 @@ dotnet test --project tests/InputBox.Tests/InputBox.Tests.csproj ``` 修改 shim ABI、執行階段載入或發佈打包時,還要做 Release 發佈 / ZIP 試跑,確認單檔發佈、redist、授權聲明與禁止項目仍符合工作流程。 + +若變更涉及 GameInput 實體硬體行為,還應依 `docs/engineering/gameinput-hardware-verification.md` 執行手動抽測。建議觸發時機包含修改 shim、斷線偵測、rumble、升級 `Microsoft.GameInput` 或正式發佈前;這不是日常 PR 必跑項目,也不是 CI 關卡。 From 0efa6bc907e9d4ced3500ac038d44f69f51728b0 Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Fri, 22 May 2026 11:22:19 +0800 Subject: [PATCH 08/12] =?UTF-8?q?test(gameinput):=20=E5=A2=9E=E5=8A=A0=20n?= =?UTF-8?q?ative=20lifecycle=20=E5=A3=93=E5=8A=9B=E9=A9=97=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 GameInput native shim 驗證腳本加入 lifecycle stress smoke,覆蓋 context 建立、callback 註冊解除與銷毀清理。 同步更新 CI/release 步驟名稱與 GameInput shim 文件,說明 runtime 不可用時只略過 lifecycle 守門。 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- docs/engineering/gamepad-api.md | 2 +- src/InputBox.GameInput.Native/README.md | 1 + tools/Validate-GameInputNativeShim.ps1 | 327 +++++++++++++++++++++++- 5 files changed, 330 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 621f2a3..0eae4a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,7 @@ jobs: /p:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` /m - - name: 驗證 GameInput Native Shim exports / probe + - name: 驗證 GameInput Native Shim exports / probe / lifecycle shell: pwsh run: | .\tools\Validate-GameInputNativeShim.ps1 ` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2914dda..2978d90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -163,7 +163,7 @@ jobs: /p:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` /m - - name: 驗證 GameInput Native Shim exports / probe + - name: 驗證 GameInput Native Shim exports / probe / lifecycle shell: pwsh run: | .\tools\Validate-GameInputNativeShim.ps1 ` diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md index 30a4bf3..ca926b8 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -22,7 +22,7 @@ - **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;缺 export、probe crash 或 native/managed struct size 不符時不得產生發佈 ZIP。CI 的 code filter 必須包含 `tools/**`,避免驗證腳本異動未觸發守門。 +- **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`。 diff --git a/src/InputBox.GameInput.Native/README.md b/src/InputBox.GameInput.Native/README.md index 3b973c2..2312f19 100644 --- a/src/InputBox.GameInput.Native/README.md +++ b/src/InputBox.GameInput.Native/README.md @@ -72,6 +72,7 @@ DLL 載入規則必須維持保守: - `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 不會自動執行安裝程式。 diff --git a/tools/Validate-GameInputNativeShim.ps1 b/tools/Validate-GameInputNativeShim.ps1 index 6123dbc..60c49bd 100644 --- a/tools/Validate-GameInputNativeShim.ps1 +++ b/tools/Validate-GameInputNativeShim.ps1 @@ -316,10 +316,335 @@ public static unsafe class $typeName $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 -Invoke-ProbeSmoke -NativeShim $resolvedNativeShim +$probeInfo = Invoke-ProbeSmoke -NativeShim $resolvedNativeShim +Invoke-LifecycleStressSmoke -NativeShim $resolvedNativeShim -ProbeInfo $probeInfo From a1b6b0e4cfb078ca267429a5ce000566f4d3f129 Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Sat, 23 May 2026 08:42:18 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(gamepad):=20=E7=B5=B1=E4=B8=80?= =?UTF-8?q?=E5=85=A9=E5=BE=8C=E7=AB=AF=E6=96=B9=E5=90=91=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E8=88=87=E8=B3=87=E6=BA=90=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 取消 XInput 的非對稱右向退出閾值(max(exit, enter × 0.75f)), 改與 GameInput 統一使用 GamepadDeadzoneHysteresis.ResolveDirection 對稱 Enter/Exit 閾值,落實「硬體平等」原則並對齊業界黃金標準 (Steam Input / DS4Windows / Unity InputSystem 均不做 per-direction 差別補償)。若特定硬體出現右向 sticky 現象,正確處方是請使用者 (對稱地)提高 ThumbDeadzoneEnter / Exit 設定值,而非在程式碼路徑 做差別補償。 - 修補兩個控制器 ClearAllEvents 漏清的 LeftShoulderReleased 與 RightShoulderReleased 事件訂閱,避免處置後仍殘留呼叫鏈造成事件洩漏。 - GameInput EmitMechanismHealthLog 補上 mapGuard={...} 欄位與 _lastHealthMapGuardActive 比對欄位,與 XInput 端日誌格式對齊, 便於跨後端排查 anti-stuck 啟動狀態(DEBUG 模式)。 - 改寫 docs/engineering/gamepad-api.md §4.5:移除文件債(既有程式碼 中不存在的 _suppressMappedRightFromLeftStick 旗標)與「非對稱 退出閾值」描述,改為「方向映射統一規範(硬體平等原則)」並補上 業界標準佐證與硬體漂移處方。 - 新增單元測試守門:GamepadDeadzoneHysteresis 正負方向對稱性 (int / float 各一)與兩個控制器 ClearAllEvents 肩鍵釋放清除。 - 同步更新 tests/InputBox.Tests/README.md 測試數(382 → 386)。 ToS 合規分析(git-commit-safety.md §2 / §5): - FFXIV 使用者合約已成功擷取並評估:本次變更為內部 deadzone 閾值 對稱化、DEBUG-only 診斷日誌補強、事件訂閱清除,未改變遊戲平衡、 未提供不正當優勢、未規避防護機制、未做自動化或輸入模擬,符合條款。 - UserJoy EULA / 隱私權政策頁面僅回傳客服界面(疑為 JS 渲染), 依規範明確記錄「未能擷取完整本文」;本次變更不屬於「按鍵處理 / 剪貼簿自動化 / 控制器映射」的高風險核心輸入/輸出異動,仍符合 AGENTS.md §2 安全紅線。 Co-Authored-By: Claude Opus 4.7 --- docs/engineering/gamepad-api.md | 10 +-- .../Core/Input/GameInputGamepadController.cs | 15 +++- .../Core/Input/XInputGamepadController.cs | 24 ++---- .../GamepadControllerPauseTests.cs | 76 +++++++++++++++++++ .../GamepadDeadzoneHysteresisTests.cs | 44 +++++++++++ tests/InputBox.Tests/README.md | 6 +- 6 files changed, 149 insertions(+), 26 deletions(-) diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md index ca926b8..86e7ced 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -69,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/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs index a1851e6..a8c97ee 100644 --- a/src/InputBox/Core/Input/GameInputGamepadController.cs +++ b/src/InputBox/Core/Input/GameInputGamepadController.cs @@ -300,6 +300,11 @@ internal sealed partial class GameInputGamepadController : IGamepadController /// private bool _lastHealthGhostActive; + /// + /// 上一次機制健康度輸出的 mapGuard 活躍狀態 + /// + private bool _lastHealthMapGuardActive; + /// /// 連發風暴診斷輸出間隔(每 N 次輸出一次) /// @@ -2140,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++; @@ -2152,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; @@ -2178,6 +2186,7 @@ private void ResetMechanismHealthLogState() _lastHealthRsDirection = 0; _lastHealthStaleActive = false; _lastHealthGhostActive = false; + _lastHealthMapGuardActive = false; #endif } @@ -3087,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; 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/tests/InputBox.Tests/GamepadControllerPauseTests.cs b/tests/InputBox.Tests/GamepadControllerPauseTests.cs index 1f2bc01..d1d3ba7 100644 --- a/tests/InputBox.Tests/GamepadControllerPauseTests.cs +++ b/tests/InputBox.Tests/GamepadControllerPauseTests.cs @@ -242,6 +242,82 @@ public void IsConnectedStatus_GameInputDeviceStatus_FiltersUnavailableDevices() 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/README.md b/tests/InputBox.Tests/README.md index 3a2fefa..d0fe79c 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -19,9 +19,9 @@ | `PhraseManagerDialogGamepadTests` | `PhraseManagerDialog` 左側片語清單的 LB/RB/LT/RT 快速切換、邊界跳轉、焦點接手與非必要連發抑制回歸保護 | 3 | | `FloatingPointFormatConverterTests` | `FloatingPointFormatConverter` 字串轉換 | 16 | | `FormInputStateManagerTests` | `FormInputStateManager` 輸入狀態切換 | 15 | -| `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載) | 12 | +| `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載),含正負方向對稱性守門(硬體平等原則) | 14 | | `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 shim/runtime 不可用時退避至 XInput | 3 | -| `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput missing reading 斷線重列舉、裝置狀態過濾、震動停止安全性與原生對話框切換時的殘留輸入回歸保護 | 8 | +| `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 | @@ -45,7 +45,7 @@ | `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 | | `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 | | `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 | -| **合計** | | **382** | +| **合計** | | **386** | ## 二、執行方式 🚀 From 47b8d8aadc720d9adf16b3da186c46fb46ed7b9c Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Sat, 23 May 2026 08:42:52 +0800 Subject: [PATCH 10/12] =?UTF-8?q?docs(gameinput):=20=E8=A3=9C=E9=BD=8A=20n?= =?UTF-8?q?ative=20shim=20=E5=85=A8=E9=83=A8=20P/Invoke=20=E8=88=87?= =?UTF-8?q?=E5=B0=8E=E5=87=BA=E5=87=BD=E5=BC=8F=E8=A8=BB=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/InputBox/Core/Input/GameInputNative.cs:為 GameInputNativeMethods 內 15 個 P/Invoke 方法、2 個 callback delegate 與 NativeLibraryName 常數補上完整 XML doc(/// summary + param + returns),涵蓋 ProbeRuntime、Create、Destroy、GetShimInfo、GetDiagnosticsSnapshot、 SetFocusPolicy、RefreshDevices、GetDeviceCount、GetDeviceInfo、 GetDeviceStatus、ReadGamepadState、RegisterReadingCallback、 RegisterDeviceCallback、UnregisterCallback、SetRumbleState,以及 GameInputNativeReadingCallback / GameInputNativeDeviceCallback。 - src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp:為 15 個 __declspec(dllexport) 導出函式補上 Doxygen 風格頂端註解 (@brief + @param + @return),標註每個參數的 nullptr 合法性、 UTF-8 編碼語意、out 參數行為,以及特殊 HRESULT 回傳值 (InputBoxGameInputNoReading、ERROR_DEVICE_NOT_CONNECTED、 ERROR_NOT_FOUND)。 純文件補強,不改變任何執行階段行為;Debug/Release 全量重建(含 native shim vcxproj)與 375 個非 UI 單元測試皆通過,0 警告 0 錯誤。 Co-Authored-By: Claude Opus 4.7 --- .../InputBoxGameInputNative.cpp | 170 ++++++++++++++++++ src/InputBox/Core/Input/GameInputNative.cs | 132 +++++++++++++- 2 files changed, 301 insertions(+), 1 deletion(-) diff --git a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp index 36b43b6..71439d0 100644 --- a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp +++ b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp @@ -1149,6 +1149,18 @@ namespace 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 { @@ -1182,6 +1194,16 @@ extern "C" 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 { @@ -1218,6 +1240,15 @@ extern "C" 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 { @@ -1259,6 +1290,17 @@ extern "C" 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 @@ -1282,6 +1324,17 @@ extern "C" 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 @@ -1306,6 +1359,13 @@ extern "C" 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 @@ -1327,6 +1387,16 @@ extern "C" 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 { @@ -1390,6 +1460,12 @@ extern "C" } } + /** + * @brief 回傳 shim 目前所知的裝置總數(以 RefreshDevices 結果為準)。 + * + * @param context 已建立的 context;nullptr 時回傳 0。 + * @return 裝置數;以 int32_t 跨 ABI 傳遞。 + */ __declspec(dllexport) int32_t __stdcall InputBoxGameInputGetDeviceCount( InputBoxGameInputContext* context) noexcept { @@ -1403,6 +1479,18 @@ extern "C" 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, @@ -1427,6 +1515,17 @@ extern "C" 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, @@ -1469,6 +1568,19 @@ extern "C" 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, @@ -1542,6 +1654,22 @@ extern "C" 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, @@ -1622,6 +1750,23 @@ extern "C" 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, @@ -1705,6 +1850,17 @@ extern "C" 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 @@ -1744,6 +1900,20 @@ extern "C" 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, diff --git a/src/InputBox/Core/Input/GameInputNative.cs b/src/InputBox/Core/Input/GameInputNative.cs index c04f441..cf43caf 100644 --- a/src/InputBox/Core/Input/GameInputNative.cs +++ b/src/InputBox/Core/Input/GameInputNative.cs @@ -517,48 +517,108 @@ protected override bool ReleaseHandle() } /// -/// GameInput native shim P/Invoke 宣告。 +/// 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( @@ -566,6 +626,13 @@ internal static extern int GetDeviceInfo( 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( @@ -573,6 +640,13 @@ internal static extern int GetDeviceStatus( [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( @@ -580,6 +654,17 @@ internal static extern int ReadGamepadState( [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( @@ -590,6 +675,18 @@ internal static extern int RegisterReadingCallback( 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( @@ -602,12 +699,30 @@ internal static extern int RegisterDeviceCallback( 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( @@ -619,11 +734,26 @@ internal static extern int SetRumbleState( 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, From 1f1e97e51fb8d28f996142fd04e1a81915adbae3 Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Sat, 23 May 2026 09:52:28 +0800 Subject: [PATCH 11/12] =?UTF-8?q?ci(gameinput):=20=E4=BD=BF=E7=94=A8=20VS2?= =?UTF-8?q?022=20toolset=20=E5=BB=BA=E7=BD=AE=20native=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 CI 與 release 的 GameInput native shim build 明確覆寫 PlatformToolset=v143,符合 GitHub windows-latest 的 Visual Studio 2022 工具鏈。 保留本機 vcxproj 預設 v145,避免影響 VS2026 開發環境。 --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0eae4a3..701adb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,7 @@ jobs: msbuild .\src\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj ` /p:Configuration=Release ` /p:Platform=x64 ` + /p:PlatformToolset=v143 ` /p:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` /m diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2978d90..b89cfca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,6 +160,7 @@ jobs: msbuild .\src\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj ` /p:Configuration=Release ` /p:Platform=x64 ` + /p:PlatformToolset=v143 ` /p:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` /m From 440782e4dd740096cbea826234530db3bf4b909d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 01:57:07 +0000 Subject: [PATCH 12/12] =?UTF-8?q?fix(gameinput):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=AF=A9=E6=9F=A5=E5=BB=BA=E8=AD=B0=E4=B8=AD=E7=9A=84=20shim?= =?UTF-8?q?=20=E8=BC=89=E5=85=A5=E9=82=8F=E8=BC=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/rubujo/InputBox/sessions/817496c4-aaa2-40a9-8c11-8a704d99237a Co-authored-by: perditavojo <117562794+perditavojo@users.noreply.github.com> --- src/InputBox/Core/Input/GameInputNative.cs | 2 +- src/InputBox/Core/Interop/DllResolver.cs | 3 +++ src/InputBox/InputBox.csproj | 17 +++++++++++------ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/InputBox/Core/Input/GameInputNative.cs b/src/InputBox/Core/Input/GameInputNative.cs index cf43caf..cf5eb4e 100644 --- a/src/InputBox/Core/Input/GameInputNative.cs +++ b/src/InputBox/Core/Input/GameInputNative.cs @@ -36,7 +36,7 @@ public static GameInput Create() handle.IsInvalid) { handle.Dispose(); - Marshal.ThrowExceptionForHR(hr); + Marshal.ThrowExceptionForHR(hr < 0 ? hr : unchecked((int)0x80004005)); } try diff --git a/src/InputBox/Core/Interop/DllResolver.cs b/src/InputBox/Core/Interop/DllResolver.cs index 331f57a..9332b8b 100644 --- a/src/InputBox/Core/Interop/DllResolver.cs +++ b/src/InputBox/Core/Interop/DllResolver.cs @@ -20,6 +20,7 @@ internal class DllResolver /// /// 快取的自有 GameInput native shim handle,用於避免重複載入。 + /// 目前設計假設同一個行程只會解析並載入單一版本/位置的 InputBox.GameInput.Native。 /// private static volatile nint _cachedGameInputShimHandle = IntPtr.Zero; @@ -90,6 +91,7 @@ public static nint ResolveNativeLibrary( /// /// 解析自有 GameInput native shim。 + /// 首次成功載入後,後續不同 assembly/searchPath 的解析都會重用同一個 handle。 /// /// 要求載入的 library 名稱。 /// 觸發載入的組件。 @@ -104,6 +106,7 @@ private static nint ResolveGameInputShim( { if (_cachedGameInputShimHandle != IntPtr.Zero) { + // Native shim 視為 process-wide singleton,避免重複載入造成匯出狀態分裂。 return _cachedGameInputShimHandle; } diff --git a/src/InputBox/InputBox.csproj b/src/InputBox/InputBox.csproj index 0786d29..40a8d38 100644 --- a/src/InputBox/InputBox.csproj +++ b/src/InputBox/InputBox.csproj @@ -65,12 +65,6 @@ Properties="Configuration=$(Configuration);Platform=x64;GameInputPackageRoot=$(PkgMicrosoft_GameInput)\" /> - - - - @@ -79,6 +73,17 @@ SkipUnchangedFiles="true" /> + + + + + + + <_Parameter1>InputBox.Tests