diff --git a/.agents/skills/inputbox-dev/SKILL.md b/.agents/skills/inputbox-dev/SKILL.md index a35e0f6..090e194 100644 --- a/.agents/skills/inputbox-dev/SKILL.md +++ b/.agents/skills/inputbox-dev/SKILL.md @@ -1,28 +1,28 @@ --- name: inputbox-dev -description: InputBox 專案的工程規範與安全標準。當修改程式碼、設計 UI、實作控制器邏輯、處理在地化或準備 Git 提交時,請使用此技能以確保符合專案專屬規則。 +description: InputBox 專案的權威工程技能。當修改程式碼、設計 UI、實作控制器邏輯、處理在地化、調整測試或準備 Git 提交時,請使用此技能。 --- -# InputBox 工程規範指引 (Engineering Guidelines) +# InputBox 工程規範指引 -本技能提供 InputBox 專案的權威工程標準。為了確保系統安全性、無障礙 (A11y) 與技術完整性,你**必須參考**位於 `docs/engineering/` 目錄下的原子化規範檔案。 +本技能是 InputBox 的權威 project skill。`AGENTS.md` 只負責載入順序與索引;詳細工程規範以本技能和 `docs/engineering/` 為準。Claude Code 若透過 `.claude/skills/inputbox-dev/SKILL.md` 進入,也必須回到本技能與對應工程文件。 -## 核心規範索引 (Core Reference Index) +## 核心規範索引 請根據目前任務載入相關檔案: -1. **環境與編碼**:`docs/engineering/environment.md` -2. **核心工程 (.NET/非同步/鎖/資源)**:`docs/engineering/core-engineering.md` -3. **A11y 與視覺安全 (關鍵)**:`docs/engineering/a11y-safety.md` -4. **遊戲控制器 API (XInput/GameInput)**:`docs/engineering/gamepad-api.md` -5. **在地化與術語規範**:`docs/engineering/localization.md` -6. **Git 提交與安全性紅線**:`docs/engineering/git-commit-safety.md` -7. **測試規範 (xUnit / 隔離模式 / CI)**:`docs/engineering/testing.md` +1. **環境與編碼**:`docs/engineering/environment.md` +2. **核心工程(.NET/非同步/鎖/資源)**:`docs/engineering/core-engineering.md` +3. **A11y 與視覺安全**:`docs/engineering/a11y-safety.md` +4. **遊戲控制器 API(XInput/GameInput)**:`docs/engineering/gamepad-api.md` +5. **在地化與術語規範**:`docs/engineering/localization.md` +6. **Git 提交與安全性紅線**:`docs/engineering/git-commit-safety.md` +7. **測試規範(xUnit/隔離模式/CI)**:`docs/engineering/testing.md` -## 工作流程指令 (Workflow Mandates) +## 工作流程指令 - **UI/並行處理**:在實作前,務必先閱讀 `a11y-safety.md` 與 `core-engineering.md`。 -- **ToS 驗證 (核心要求)**:涉及輸入、輸出或控制器邏輯變更時,**必須**使用網頁抓取工具(Copilot:`fetch_webpage`;Gemini:`web_fetch`)擷取 `git-commit-safety.md` 中列出的第三方服務條款進行即時合規分析。 +- **ToS 驗證**:涉及輸入、輸出或控制器邏輯變更時,必須使用可用的官方網頁工具擷取 `git-commit-safety.md` 中列出的第三方服務條款,進行即時合規分析。 - **資源管理**:所有 IDisposable 資源必須遵循「原子化處置模式」。 - **合規性**:提交前須對照 `git-commit-safety.md` 檢查異動,避免觸發防弊系統。 - **GPG 簽章提交**:凡執行 Git 提交,預設必須使用使用者既有且有效的 GPG 簽章設定;Agent 嚴禁自行修改 `gpg.conf`、`gpg-agent.conf` 或其他相關設定檔。若簽章環境異常,僅可提醒使用者自行修復,不得以停用簽章或自動改寫設定方式繞過。 diff --git a/.claude/skills/inputbox-dev/SKILL.md b/.claude/skills/inputbox-dev/SKILL.md new file mode 100644 index 0000000..f85a5c8 --- /dev/null +++ b/.claude/skills/inputbox-dev/SKILL.md @@ -0,0 +1,16 @@ +--- +name: inputbox-dev +description: Claude Code 對 InputBox 工程技能的橋接。當修改 InputBox 程式碼、UI、控制器邏輯、在地化、測試或 Git 工作流時使用。 +--- + +# InputBox Claude Code Skill Bridge + +本檔只負責讓 Claude Code discovery 找到 InputBox project skill。權威 skill 位於 `.agents/skills/inputbox-dev/SKILL.md`。 + +開始工作前: + +1. 讀取 `.agents/skills/inputbox-dev/SKILL.md`。 +2. 依 `AGENTS.md` 確認載入順序與安全紅線。 +3. 讀取 `docs/engineering/` 下的任務相關規範。 + +不要在本檔維護第二份 InputBox 工程規則。 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 05c09e8..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,15 +0,0 @@ -# InputBox - GitHub Copilot CLI 指引 - -本檔是 GitHub Copilot CLI 與 Copilot repository-wide custom instructions 的入口。GitHub 官方文件指出 Copilot CLI 會使用 `.github/copilot-instructions.md`,也會讀取 root `AGENTS.md` 並將其視為 primary instructions;因此本檔只做導向,不維護第二份完整規範。 - -## 讀取順序 - -1. 讀取根目錄 `AGENTS.md`。 -2. 載入 `.agents/skills/inputbox-dev/SKILL.md`。 -3. 依任務性質讀取 `docs/engineering/` 下的對應規範。 - -## Copilot 相容要求 - -- 若本檔與 `AGENTS.md`、`inputbox-dev` 或 `docs/engineering/` 有衝突,以共同規範與更細部工程規範為準。 -- 不要在 `.github/copilot-instructions.md` 複製完整工程規則,避免和 `AGENTS.md` 發散。 -- 若未來新增 `.github/instructions/**/*.instructions.md`,只放 path-specific 補充,且不得與 root `AGENTS.md` 衝突。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 701adb1..7812be5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,266 +1,254 @@ -name: 程式碼建置檢查 - -on: - pull_request: - branches: - # 應用程式採用 main 保持可發布、dev 每輪先對齊 main 再提交 PR 的守門流程。 - - main - -concurrency: - # 同一分支或 PR 只保留最新一次執行,降低重複建置浪費。 - group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - changes: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - outputs: - code: ${{ steps.filter.outputs.code }} - ui: ${{ steps.filter.outputs.ui }} - - steps: - - name: 簽出原始碼(Checkout) - uses: actions/checkout@v6 - with: - fetch-depth: 2 - - - name: 判斷是否涉及 UI 冒煙測試範圍 - id: filter - uses: dorny/paths-filter@v4 - with: - filters: | - code: - - 'src/**' - - 'tests/**' - - 'tools/**' - - '.github/workflows/**' - - 'global.json' - - '*.sln' - - '*.slnx' - - '**/*.csproj' - - '**/*.props' - - '**/*.targets' - ui: - - 'src/InputBox/MainForm*.cs' - - 'src/InputBox/MainForm.resx' - - 'src/InputBox/Core/Controls/**' - - 'src/InputBox/Resources/**' - - 'tests/InputBox.Tests/MainFormUiSmokeTests.cs' - - 'tests/InputBox.Tests/UiSmokeTestRequirements.cs' - - build-check: - if: needs.changes.outputs.code == 'true' - needs: changes - runs-on: windows-latest - timeout-minutes: 15 - permissions: - # 只需要讀取原始碼進行建置即可。 - contents: read - - # 定義環境變數,方便統一管理。 - env: - DOTNET_MAJOR_VERSION: '10' - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' - # xUnit v3 MTP 的 coverage 輸出在 bin 輸出目錄的 TestResults 子目錄下。 - COVERAGE_GLOB: tests/**/coverage.cobertura.xml - REPORT_DIR: CoverageReport - # 行覆蓋率最低門檻(%)。低於此值時 CI 強制失敗。 - # WinForms UI 層雖已加入少量 FlaUI 冒煙測試,但大部分互動與硬體流程仍不納入 coverage 基線, - # 因此整體行覆蓋率仍維持在低檔,這裡僅作為基本迴歸守衛。 - # 若移除已測邏輯或測試檔,CI 應能即時失敗提醒。 - COVERAGE_THRESHOLD: '2' - - steps: - - name: 簽出原始碼(Checkout) - uses: actions/checkout@v6 - - - name: 安裝 .NET 環境 - uses: actions/setup-dotnet@v5 - with: - # 使用 GitHub Actions 語法來讀取 env 變數。 - dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' - # 啟用快取功能,加速 NuGet 套件還原。 - cache: true - # 同時涵蓋主專案、測試專案與工具清單,確保 NuGet 套件快取完整。 - cache-dependency-path: | - **/*.csproj - .config/dotnet-tools.json - - - name: 安裝 MSBuild(Native Shim) - uses: microsoft/setup-msbuild@v3 - - - name: 還原主專案 NuGet 套件(Native Shim) - run: dotnet restore ./src/InputBox/InputBox.csproj - - - name: 讀取 Microsoft.GameInput 版本(Native Shim) - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - [xml]$project = Get-Content -LiteralPath "./src/InputBox/InputBox.csproj" -Raw - $packageRefs = @($project.Project.ItemGroup.PackageReference | Where-Object { $_.Include -eq 'Microsoft.GameInput' }) - - if ($packageRefs.Count -ne 1) { - Write-Error "InputBox.csproj 必須剛好包含一個 Microsoft.GameInput PackageReference;目前找到 $($packageRefs.Count) 個。" - } - - $version = [string]$packageRefs[0].Version - if ([string]::IsNullOrWhiteSpace($version) -or $version -notmatch '^\d+\.\d+\.\d+(?:\.\d+)?$') { - Write-Error "Microsoft.GameInput Version 必須是明確穩定版本:$version" - } - - "GAMEINPUT_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Host "Microsoft.GameInput version: $version" - - - name: 建置 GameInput Native Shim - shell: pwsh - run: | - msbuild .\src\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj ` - /p:Configuration=Release ` - /p:Platform=x64 ` - /p:PlatformToolset=v143 ` - /p:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` - /m - - - name: 驗證 GameInput Native Shim exports / probe / lifecycle - shell: pwsh - run: | - .\tools\Validate-GameInputNativeShim.ps1 ` - -NativeShimPath ".\src\InputBox.GameInput.Native\bin\x64\Release\InputBox.GameInput.Native.dll" ` - -ManagedSourcePath ".\src\InputBox\Core\Input\GameInputNative.cs" - - - name: 建置應用程式與測試專案(Build App + Tests) - working-directory: ./tests/InputBox.Tests - # 測試專案會連同主專案相依一起建置,避免重複編譯浪費資源。 - run: dotnet build InputBox.Tests.csproj -c Release --no-incremental - - - name: 上傳建置產出(供 ui-smoke 重用) - if: needs.changes.outputs.ui == 'true' - uses: actions/upload-artifact@v7 - with: - name: build-output - path: | - tests/InputBox.Tests/bin/Release/ - src/InputBox/bin/Release/ - retention-days: 1 - if-no-files-found: error - - - name: 執行非 UI 單元測試並收集覆蓋率(Test + Coverage) - working-directory: ./tests/InputBox.Tests - # xUnit v3 + MTP 原生模式(global.json test.runner=Microsoft.Testing.Platform)。 - # UI 冒煙測試使用 FlaUI,與一般邏輯測試分流,避免桌面自動化影響 coverage 基線穩定度。 - # --no-build 重用上一步的建置產出;coverage 輸出至 bin/Release 目錄下的 TestResults 子目錄。 - run: | - dotnet test --project InputBox.Tests.csproj -c Release --no-build ` - --filter-not-trait "Category=UI" ` - --coverage ` - --coverage-output-format cobertura ` - --coverage-output coverage.cobertura.xml - - - name: 產生覆蓋率報告(ReportGenerator) - run: | - dotnet tool restore - dotnet tool run reportgenerator ` - "-reports:${{ env.COVERAGE_GLOB }}" ` - "-targetdir:${{ env.REPORT_DIR }}" ` - "-reporttypes:Html;Cobertura;MarkdownSummaryGithub" ` - "-filefilters:-*\obj\*" - - - name: 寫入覆蓋率摘要至 GitHub Step Summary - run: | - Get-Content -Path "${{ env.REPORT_DIR }}/SummaryGithub.md" | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - - - name: 上傳覆蓋率報告(Artifact) - if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: ${{ env.REPORT_DIR }} - if-no-files-found: error - retention-days: 7 - - - name: 覆蓋率門檻檢查(Coverage Threshold Gate) - run: | - $xml = [xml](Get-Content -Path "${{ env.REPORT_DIR }}/Cobertura.xml" -Encoding UTF8) - $lineRate = [double]$xml.coverage.'line-rate' - $pct = [Math]::Round($lineRate * 100, 1) - Write-Host "目前行覆蓋率:$pct%(最低門檻:${{ env.COVERAGE_THRESHOLD }}%)" - if ($pct -lt [double]"${{ env.COVERAGE_THRESHOLD }}") { - Write-Host "##[error]覆蓋率 $pct% 低於最低門檻 ${{ env.COVERAGE_THRESHOLD }}%,請補充測試後再提交。" - exit 1 - } - Write-Host "覆蓋率檢查通過。" - - - name: 建置與測試成功通知 - if: success() - run: Write-Host "建置與測試全部通過!程式碼狀態良好。" - - ui-smoke: - if: needs.changes.outputs.ui == 'true' - runs-on: windows-latest - timeout-minutes: 8 - needs: - - changes - - build-check - permissions: - contents: read - - env: - DOTNET_MAJOR_VERSION: '10' - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' - INPUTBOX_RUN_UI_TESTS: '1' - INPUTBOX_UI_ARTIFACT_DIR: '${{ github.workspace }}/TestResults/UiArtifacts' - - steps: - - name: 簽出原始碼(Checkout) - uses: actions/checkout@v6 - - - name: 安裝 .NET 環境 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' - cache: true - cache-dependency-path: | - **/*.csproj - .config/dotnet-tools.json - - - name: 下載建置產出(來自 build-check) - uses: actions/download-artifact@v8 - with: - name: build-output - path: . - - - name: 還原 NuGet 套件(供 MTP runner 偵測) - working-directory: ./tests/InputBox.Tests - # dotnet test --no-build 在 global.json MTP 模式下仍需 MSBuild 評估專案屬性, - # 必須先還原 NuGet 套件,否則 MTP props 無法匯入,導致誤判為 VSTest runner。 - run: dotnet restore InputBox.Tests.csproj - - - name: 執行 UI 冒煙測試(FlaUI Smoke) - id: ui_smoke - continue-on-error: true - working-directory: ./tests/InputBox.Tests - run: dotnet test --project InputBox.Tests.csproj -c Release --no-build --filter-trait "Category=UI" - - - name: 寫入 UI 冒煙測試警告摘要 - if: always() && steps.ui_smoke.outcome == 'failure' - run: | - "## ⚠️ UI 冒煙測試未通過" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "此工作為報告用途,不阻擋主建置與一般單元測試;請下載 Artifact 進行排查。" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "- Artifact 名稱:ui-smoke-artifacts" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "- 優先檢查:桌面擷圖 PNG 與診斷 TXT" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - "- 診斷 TXT 會包含 lastStep、applicationWindows 與 openMenus 摘要,可直接定位卡住步驟。" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append - - - name: 上傳 UI 冒煙測試失敗產物(Artifacts) - if: always() && steps.ui_smoke.outcome == 'failure' - uses: actions/upload-artifact@v7 - with: - name: ui-smoke-artifacts - path: TestResults/UiArtifacts - if-no-files-found: ignore - retention-days: 5 +name: 程式碼建置檢查 + +on: + pull_request: + branches: + # 應用程式採用 main 保持可發布、dev 每輪先對齊 main 再提交 PR 的守門流程。 + - main + +concurrency: + # 同一分支或 PR 只保留最新一次執行,降低重複建置浪費。 + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.filter.outputs.code }} + ui: ${{ steps.filter.outputs.ui }} + + steps: + - name: 簽出原始碼(Checkout) + uses: actions/checkout@v6 + with: + fetch-depth: 2 + + - name: 判斷是否涉及 UI 冒煙測試範圍 + id: filter + uses: dorny/paths-filter@v4 + with: + filters: | + code: + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'eng/nuget/**' + - '.github/workflows/**' + - 'NuGet.config' + - 'global.json' + - '*.sln' + - '*.slnx' + - '**/*.csproj' + - '**/*.props' + - '**/*.targets' + ui: + - 'src/InputBox/MainForm*.cs' + - 'src/InputBox/MainForm.resx' + - 'src/InputBox/Core/Controls/**' + - 'src/InputBox/Resources/**' + - 'tests/InputBox.Tests/MainFormUiSmokeTests.cs' + - 'tests/InputBox.Tests/UiSmokeTestRequirements.cs' + + build-check: + if: needs.changes.outputs.code == 'true' + needs: changes + runs-on: windows-latest + timeout-minutes: 15 + permissions: + # 只需要讀取原始碼進行建置即可。 + contents: read + + # 定義環境變數,方便統一管理。 + env: + DOTNET_MAJOR_VERSION: '10' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + # xUnit v3 MTP 的 coverage 輸出在 bin 輸出目錄的 TestResults 子目錄下。 + COVERAGE_GLOB: tests/**/coverage.cobertura.xml + REPORT_DIR: CoverageReport + # 行覆蓋率最低門檻(%)。低於此值時 CI 強制失敗。 + # WinForms UI 層雖已加入少量 FlaUI 冒煙測試,但大部分互動與硬體流程仍不納入 coverage 基線, + # 因此整體行覆蓋率仍維持在低檔,這裡僅作為基本迴歸守衛。 + # 若移除已測邏輯或測試檔,CI 應能即時失敗提醒。 + COVERAGE_THRESHOLD: '2' + + steps: + - name: 簽出原始碼(Checkout) + uses: actions/checkout@v6 + + - name: 安裝 .NET 環境 + uses: actions/setup-dotnet@v5 + with: + # 使用 GitHub Actions 語法來讀取 env 變數。 + dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' + # 啟用快取功能,加速 NuGet 套件還原。 + cache: true + # 同時涵蓋主專案、測試專案與工具清單,確保 NuGet 套件快取完整。 + cache-dependency-path: | + **/*.csproj + NuGet.config + eng/nuget/*.sha256 + .config/dotnet-tools.json + + - name: 驗證本儲存庫 InputWeave.GameInput NuGet 套件 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" + $checksumPath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg.sha256" + + if (-not (Test-Path $packagePath)) { + Write-Error "找不到本儲存庫 InputWeave.GameInput NuGet 套件:$packagePath" + } + + if (-not (Test-Path $checksumPath)) { + Write-Error "找不到本儲存庫 InputWeave.GameInput 套件 SHA256 檔案:$checksumPath" + } + + $expected = ((Get-Content -LiteralPath $checksumPath -Raw).Trim() -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" + } + + Write-Host "InputWeave.GameInput 套件 SHA256 驗證通過:$actual" + + - name: 還原主專案 NuGet 套件 + run: dotnet restore ./src/InputBox/InputBox.csproj --force-evaluate + + - name: 建置應用程式與測試專案(Build App + Tests) + working-directory: ./tests/InputBox.Tests + # 測試專案會連同主專案相依一起建置,避免重複編譯浪費資源。 + run: dotnet build InputBox.Tests.csproj -c Release --no-incremental + + - name: 上傳建置產出(供 ui-smoke 重用) + if: needs.changes.outputs.ui == 'true' + uses: actions/upload-artifact@v7 + with: + name: build-output + path: | + tests/InputBox.Tests/bin/Release/ + src/InputBox/bin/Release/ + retention-days: 1 + if-no-files-found: error + + - name: 執行非 UI 單元測試並收集覆蓋率(Test + Coverage) + working-directory: ./tests/InputBox.Tests + # xUnit v3 + MTP 原生模式(global.json test.runner=Microsoft.Testing.Platform)。 + # UI 冒煙測試使用 FlaUI,與一般邏輯測試分流,避免桌面自動化影響 coverage 基線穩定度。 + # --no-build 重用上一步的建置產出;coverage 輸出至 bin/Release 目錄下的 TestResults 子目錄。 + run: | + dotnet test --project InputBox.Tests.csproj -c Release --no-build ` + --filter-not-trait "Category=UI" ` + --coverage ` + --coverage-output-format cobertura ` + --coverage-output coverage.cobertura.xml + + - name: 產生覆蓋率報告(ReportGenerator) + run: | + dotnet tool restore + dotnet tool run reportgenerator ` + "-reports:${{ env.COVERAGE_GLOB }}" ` + "-targetdir:${{ env.REPORT_DIR }}" ` + "-reporttypes:Html;Cobertura;MarkdownSummaryGithub" ` + "-filefilters:-*\obj\*" + + - name: 寫入覆蓋率摘要至 GitHub Step Summary + run: | + Get-Content -Path "${{ env.REPORT_DIR }}/SummaryGithub.md" | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: 上傳覆蓋率報告(Artifact) + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: ${{ env.REPORT_DIR }} + if-no-files-found: error + retention-days: 7 + + - name: 覆蓋率門檻檢查(Coverage Threshold Gate) + run: | + $xml = [xml](Get-Content -Path "${{ env.REPORT_DIR }}/Cobertura.xml" -Encoding UTF8) + $lineRate = [double]$xml.coverage.'line-rate' + $pct = [Math]::Round($lineRate * 100, 1) + Write-Host "目前行覆蓋率:$pct%(最低門檻:${{ env.COVERAGE_THRESHOLD }}%)" + if ($pct -lt [double]"${{ env.COVERAGE_THRESHOLD }}") { + Write-Host "##[error]覆蓋率 $pct% 低於最低門檻 ${{ env.COVERAGE_THRESHOLD }}%,請補充測試後再提交。" + exit 1 + } + Write-Host "覆蓋率檢查通過。" + + - name: 建置與測試成功通知 + if: success() + run: Write-Host "建置與測試全部通過!程式碼狀態良好。" + + ui-smoke: + if: needs.changes.outputs.ui == 'true' + runs-on: windows-latest + timeout-minutes: 8 + needs: + - changes + - build-check + permissions: + contents: read + + env: + DOTNET_MAJOR_VERSION: '10' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + INPUTBOX_RUN_UI_TESTS: '1' + INPUTBOX_UI_ARTIFACT_DIR: '${{ github.workspace }}/TestResults/UiArtifacts' + + steps: + - name: 簽出原始碼(Checkout) + uses: actions/checkout@v6 + + - name: 安裝 .NET 環境 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' + cache: true + cache-dependency-path: | + **/*.csproj + .config/dotnet-tools.json + + - name: 下載建置產出(來自 build-check) + uses: actions/download-artifact@v8 + with: + name: build-output + path: . + + - name: 還原 NuGet 套件(供 MTP runner 偵測) + working-directory: ./tests/InputBox.Tests + # dotnet test --no-build 在 global.json MTP 模式下仍需 MSBuild 評估專案屬性, + # 必須先還原 NuGet 套件,否則 MTP props 無法匯入,導致誤判為 VSTest runner。 + run: dotnet restore InputBox.Tests.csproj + + - name: 執行 UI 冒煙測試(FlaUI Smoke) + id: ui_smoke + continue-on-error: true + working-directory: ./tests/InputBox.Tests + run: dotnet test --project InputBox.Tests.csproj -c Release --no-build --filter-trait "Category=UI" + + - name: 寫入 UI 冒煙測試警告摘要 + if: always() && steps.ui_smoke.outcome == 'failure' + run: | + "## ⚠️ UI 冒煙測試未通過" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "此工作為報告用途,不阻擋主建置與一般單元測試;請下載 Artifact 進行排查。" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "- Artifact 名稱:ui-smoke-artifacts" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "- 優先檢查:桌面擷圖 PNG 與診斷 TXT" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + "- 診斷 TXT 會包含 lastStep、applicationWindows 與 openMenus 摘要,可直接定位卡住步驟。" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: 上傳 UI 冒煙測試失敗產物(Artifacts) + if: always() && steps.ui_smoke.outcome == 'failure' + uses: actions/upload-artifact@v7 + with: + name: ui-smoke-artifacts + path: TestResults/UiArtifacts + if-no-files-found: ignore + retention-days: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce047a2..ad6318b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,543 +1,591 @@ -name: 建置與發佈 - -on: - push: - tags: - # 當推送符合語意版本的標籤(如 v1.0.0)時觸發,排除非版本標籤(如 vtest)。 - - 'v[0-9]*' - workflow_call: - secrets: - VT_API_KEY: - required: false - -concurrency: - # 相同 tag 的重複發版只保留最新一次,避免重覆占用 runner 與 API 配額。 - group: release-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-and-release: - runs-on: windows-latest - timeout-minutes: 25 - permissions: - contents: write - - # 定義環境變數,方便統一管理。 - env: - DOTNET_MAJOR_VERSION: '10' - DOTNET_CLI_TELEMETRY_OPTOUT: '1' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' - DOTNET_NOLOGO: '1' - PUBLISH_DIR: "./src/InputBox/out" - EXE_NAME: 'InputBox.exe' - NATIVE_SHIM_NAME: 'InputBox.GameInput.Native.dll' - SHA256_HASH: '' - GAMEINPUT_REDIST_SHA256: '' - ZIP_FILENAME: '' - - steps: - - name: 簽出原始碼(Checkout) - uses: actions/checkout@v6 - with: - # 發版僅需目前 tag 對應提交,避免抓取完整歷史紀錄。 - fetch-depth: 1 - - - name: 驗證版本標籤來源為 main 最新提交 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - # 僅抓取 main 的最新遠端頭,避免不必要的歷史下載。 - git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main - - $tagCommit = (git rev-list -n 1 "${{ github.ref_name }}").Trim() - $mainHead = (git rev-parse origin/main).Trim() - - Write-Host "Tag commit: $tagCommit" - Write-Host "main HEAD: $mainHead" - - if ([string]::IsNullOrWhiteSpace($tagCommit) -or [string]::IsNullOrWhiteSpace($mainHead)) { - Write-Error "無法解析版本標籤或 main 分支的提交資訊。" - } - - if ($tagCommit -ne $mainHead) { - Write-Error "正式版本標籤只能從 main 分支最新提交建立;目前 tag 未指向 origin/main 的最新 commit。" - } - - Write-Host "版本標籤來源驗證通過。" - - - name: 安裝 .NET 環境 - uses: actions/setup-dotnet@v5 - with: - # 使用 GitHub Actions 語法來讀取 env 變數。 - dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' - # Release 觸發頻率遠低於 cache TTL(7 天),不快取以避免無效寫入。 - - - name: 安裝 MSBuild(Native Shim) - uses: microsoft/setup-msbuild@v3 - - - name: 還原 NuGet 套件 - run: dotnet restore ./src/InputBox/InputBox.csproj - - - name: 讀取 Microsoft.GameInput 版本 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - [xml]$project = Get-Content -LiteralPath "./src/InputBox/InputBox.csproj" -Raw - $packageRefs = @($project.Project.ItemGroup.PackageReference | Where-Object { $_.Include -eq 'Microsoft.GameInput' }) - - if ($packageRefs.Count -ne 1) { - Write-Error "InputBox.csproj 必須剛好包含一個 Microsoft.GameInput PackageReference;目前找到 $($packageRefs.Count) 個。" - } - - $version = [string]$packageRefs[0].Version - if ([string]::IsNullOrWhiteSpace($version)) { - Write-Error "Microsoft.GameInput PackageReference 缺少明確 Version。" - } - - if ($version -notmatch '^\d+\.\d+\.\d+(?:\.\d+)?$') { - Write-Error "Microsoft.GameInput Version 必須是明確穩定版本,不可使用 floating/range/prerelease:$version" - } - - "GAMEINPUT_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Host "Microsoft.GameInput pinned version: $version" - - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { - "Microsoft.GameInput pinned version: ``$version``" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - - - name: 檢查 Microsoft.GameInput 最新穩定版本(非阻擋) - shell: pwsh - run: | - $ErrorActionPreference = 'Continue' - $indexUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.gameinput/index.json" - - try { - $index = Invoke-RestMethod -Uri $indexUrl -ErrorAction Stop - $stableVersions = @( - $index.versions | - Where-Object { $_ -match '^\d+\.\d+\.\d+(?:\.\d+)?$' } | - Sort-Object { [version]$_ } - ) - - if ($stableVersions.Count -eq 0) { - throw "NuGet index 未回傳可解析的 stable 版本。" - } - - $latest = $stableVersions[-1] - Write-Host "Microsoft.GameInput pinned version: $env:GAMEINPUT_VERSION" - Write-Host "Microsoft.GameInput latest stable: $latest" - - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { - @( - '', - '### Microsoft.GameInput 版本資訊', - '', - "- Pinned: ``$env:GAMEINPUT_VERSION``", - "- Latest stable: ``$latest``" - ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - - if ($env:GAMEINPUT_VERSION -ne $latest) { - Write-Warning "Microsoft.GameInput pinned version ($env:GAMEINPUT_VERSION) 不是 NuGet 最新 stable ($latest);本檢查不阻擋發佈。" - } - } catch { - Write-Warning "無法查詢 Microsoft.GameInput 最新 stable 版本;本檢查不阻擋發佈。原因:$($_.Exception.Message)" - - if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) { - @( - '', - '### Microsoft.GameInput 版本資訊', - '', - "- Pinned: ``$env:GAMEINPUT_VERSION``", - '- Latest stable: 查詢失敗(非阻擋)' - ) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - } - - - name: 建置 GameInput Native Shim - shell: pwsh - run: | - msbuild .\src\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj ` - /p:Configuration=Release ` - /p:Platform=x64 ` - /p:PlatformToolset=v143 ` - /p:GameInputPackageVersion=$env:GAMEINPUT_VERSION ` - /m - - - name: 驗證 GameInput Native Shim exports / probe / lifecycle - shell: pwsh - run: | - .\tools\Validate-GameInputNativeShim.ps1 ` - -NativeShimPath ".\src\InputBox.GameInput.Native\bin\x64\Release\InputBox.GameInput.Native.dll" ` - -ManagedSourcePath ".\src\InputBox\Core\Input\GameInputNative.cs" - - - name: 執行專案發佈(Publish) - working-directory: ./src/InputBox - run: > - dotnet publish -c Release -r win-x64 - --self-contained true - -p:PublishSingleFile=true - -p:IncludeNativeLibrariesForSelfExtract=true - -p:PublishReadyToRun=true - -o out - - - name: 驗證單檔發佈輸出 - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $exePath = Join-Path "${{ env.PUBLISH_DIR }}" "${{ env.EXE_NAME }}" - $nativeShimPath = Join-Path "${{ env.PUBLISH_DIR }}" "${{ env.NATIVE_SHIM_NAME }}" - - if (-not (Test-Path $exePath)) { - Write-Error "找不到單檔發佈執行檔:$exePath" - } - - if (Test-Path $nativeShimPath) { - Write-Error "單檔發佈輸出不應包含可見的 GameInput native shim sidecar:$nativeShimPath" - } - - Write-Host "單檔發佈輸出驗證通過:GameInput native shim 已由 MSBuild bundle guard 驗證,且未留下 sidecar DLL。" - - - name: 自動產生第三方套件授權聲明(nuget-license) - shell: pwsh - run: | - dotnet tool restore - Write-Host "掃描專案 NuGet 套件並匯出 ThirdPartyNotices.txt……" - # -i:指定專案檔路徑。 - # -o:指定輸出格式為 Markdown(讓純文字檔內有整齊的表格排版)。 - # -fo:指定輸出的檔案名稱(File Output)。 - 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!" - } else { - Write-Warning "⚠️ 未產生 ThirdPartyNotices.txt,可能是因為專案目前沒有依賴任何第三方套件。" - } - - - name: 產生執行檔的雜湊值(SHA256) - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $exePath = Join-Path -Path "${{ env.PUBLISH_DIR }}" -ChildPath "${{ env.EXE_NAME }}" - - if (Test-Path $exePath) { - $hash = (Get-FileHash $exePath -Algorithm SHA256).Hash - "SHA256_HASH=$hash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Host "成功產生雜湊值:$hash" - } else { - Write-Error "找不到執行檔($exePath),請檢查發佈步驟!" - } - - - name: 打包並壓縮發佈檔案(ZIP Archive) - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $tagName = "${{ github.ref_name }}" - $zipName = "InputBox-$tagName-win-x64.zip" - "ZIP_FILENAME=$zipName" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - # 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 放根目錄。 - # - 專案授權(LICENSE)保留根目錄(業界慣例:使用者預期在根層找到它)。 - # - ThirdPartyNotices.txt 放入 Licenses/ 子目錄。 - Copy-Item -Path (Join-Path ${{ env.PUBLISH_DIR }} ${{ env.EXE_NAME }}) -Destination $packDir - $rootDocs = @("LICENSE", "README.md") - foreach ($doc in $rootDocs) { - if (Test-Path $doc) { - Copy-Item -Path $doc -Destination $packDir - } - } - if (Test-Path "ThirdPartyNotices.txt") { - Copy-Item -Path "ThirdPartyNotices.txt" -Destination $licensesDir - } - - # 3. 動態抓取 .NET 官方授權檔案(含最相近 Tag/分支 Fallback)。 - Write-Host "=== 開始動態下載 .NET 官方授權檔案 ===" - $runtimeInfo = dotnet --list-runtimes - - $majorVersion = "${{ env.DOTNET_MAJOR_VERSION }}" - $runtimeLine = $runtimeInfo | Where-Object { $_ -match "^Microsoft\.NETCore\.App ($majorVersion\.\d+\.\d+)" } | Select-Object -Last 1 - $githubHeaders = @{ - 'User-Agent' = 'InputBox-Release-Workflow' - 'X-GitHub-Api-Version' = '2022-11-28' - } - - function Get-TagStageRank { - param([string]$TagName) - - if ($TagName -match '(?i)preview') { return 3 } - if ($TagName -match '(?i)rc') { return 2 } - if ($TagName -match '(?i)rtm|servicing') { return 1 } - return 0 - } - - function Get-ClosestDotnetTag { - param( - [string]$Repo, - [version]$TargetVersion - ) - - $tagsApiUrl = "https://api.github.com/repos/dotnet/$Repo/tags?per_page=100" - - try { - $tagResponse = Invoke-RestMethod -Uri $tagsApiUrl -Headers $githubHeaders -ErrorAction Stop - } catch { - Write-Warning "⚠️ 無法查詢 dotnet/$Repo 的 Tag 清單:$($_.Exception.Message)" - return $null - } - - $candidates = foreach ($tagItem in $tagResponse) { - if ($tagItem.name -match '^v(?\d+)\.(?\d+)\.(?\d+)') { - [pscustomobject]@{ - Name = $tagItem.name - Major = [int]$Matches['major'] - Minor = [int]$Matches['minor'] - Patch = [int]$Matches['patch'] - StageRank = Get-TagStageRank -TagName $tagItem.name - } - } - } - - $nearest = $candidates | - Where-Object { $_.Major -eq $TargetVersion.Major -and $_.Minor -eq $TargetVersion.Minor } | - Sort-Object ` - @{ Expression = { [Math]::Abs($_.Patch - $TargetVersion.Build) } }, ` - @{ Expression = { $_.StageRank } }, ` - @{ Expression = { $_.Patch }; Descending = $true } | - Select-Object -First 1 - - return $nearest.Name - } - - function Save-GitHubFileWithFallback { - param( - [string]$Repo, - [string[]]$Refs, - [string]$File, - [string]$OutPath - ) - - foreach ($ref in ($Refs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)) { - $rawUrl = "https://raw.githubusercontent.com/dotnet/$Repo/$ref/$File" - $metadataUrl = "https://api.github.com/repos/dotnet/$Repo/contents/$File?ref=$([uri]::EscapeDataString($ref))" - Write-Host "嘗試下載:dotnet/$Repo@$ref -> $File" - - try { - Invoke-WebRequest -Uri $rawUrl -OutFile $OutPath -Headers $githubHeaders -ErrorAction Stop - Write-Host "✅ 成功儲存為 $(Split-Path $OutPath -Leaf)(來源:$ref / raw)" - return $true - } catch { - try { - $metadata = Invoke-RestMethod -Uri $metadataUrl -Headers $githubHeaders -ErrorAction Stop - if (-not [string]::IsNullOrWhiteSpace($metadata.download_url)) { - Invoke-WebRequest -Uri $metadata.download_url -OutFile $OutPath -Headers $githubHeaders -ErrorAction Stop - Write-Host "✅ 成功儲存為 $(Split-Path $OutPath -Leaf)(來源:$ref / api)" - return $true - } - } catch { - # Ignore and continue to the next candidate below. - } - - Write-Warning "⚠️ 無法從 $ref 下載 $File,繼續嘗試下一個候選來源。" - } - } - - return $false - } - - if ($runtimeLine -match "^Microsoft\.NETCore\.App (\d+\.\d+\.\d+)") { - $version = $Matches[1] - $tag = "v$version" - $targetVersion = [version]$version - $releaseBranch = "release/$($targetVersion.Major).$($targetVersion.Minor)" - Write-Host "偵測到目前使用的 .NET Runtime 版本為:$version,優先使用 GitHub 標籤:$tag" - - $filesToDownload = @( - @{ Repo = "runtime"; File = "LICENSE.TXT"; Out = "DOTNET_RUNTIME_LICENSE.txt" }, - @{ Repo = "runtime"; File = "THIRD-PARTY-NOTICES.TXT"; Out = "DOTNET_RUNTIME_THIRD_PARTY_NOTICES.txt" }, - @{ Repo = "winforms"; File = "LICENSE.TXT"; Out = "DOTNET_WINFORMS_LICENSE.txt" }, - @{ Repo = "winforms"; File = "THIRD-PARTY-NOTICES.TXT"; Out = "DOTNET_WINFORMS_THIRD_PARTY_NOTICES.txt" } - ) - - $repoRefCandidates = @{} - foreach ($repoName in ($filesToDownload.Repo | Select-Object -Unique)) { - $closestTag = Get-ClosestDotnetTag -Repo $repoName -TargetVersion $targetVersion - if ($closestTag) { - Write-Host "dotnet/$repoName 最相近的可用 Tag 候選:$closestTag" - } else { - Write-Warning "⚠️ dotnet/$repoName 找不到符合 $($targetVersion.Major).$($targetVersion.Minor).x 的 Tag,將直接退回分支。" - } - - $repoRefCandidates[$repoName] = @($tag, $closestTag, $releaseBranch, 'main') - } - - foreach ($item in $filesToDownload) { - $outPath = Join-Path $licensesDir $item.Out - $downloaded = Save-GitHubFileWithFallback -Repo $item.Repo -Refs $repoRefCandidates[$item.Repo] -File $item.File -OutPath $outPath - if (-not $downloaded) { - Write-Warning "⚠️ 最終仍無法取得 $($item.Out)。" - } - } - } else { - Write-Warning "❌ 無法解析 .NET Runtime 的版本號,請確認安裝步驟。" - } - - # 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" - - if (-not (Test-Path $gameInputRedistSource)) { - Write-Error "找不到 Microsoft.GameInput redist:$gameInputRedistSource" - } - - if (-not (Test-Path $gameInputLicenseSource)) { - Write-Error "找不到 Microsoft.GameInput 授權檔:$gameInputLicenseSource" - } - - if (-not (Test-Path $gameInputNoticeSource)) { - Write-Error "找不到 Microsoft.GameInput notice 檔:$gameInputNoticeSource" - } - - 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") - - $redistHash = (Get-FileHash (Join-Path $redistDir "GameInputRedist.msi") -Algorithm SHA256).Hash - "GAMEINPUT_REDIST_SHA256=$redistHash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - $redistReadme = @( - 'Microsoft GameInput Redistributable', - '===================================', - '', - "Version: $env:GAMEINPUT_VERSION", - 'Included file: GameInputRedist.msi', - "SHA256: $redistHash", - '', - '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.', - '', - 'License: see ../Licenses/Microsoft.GameInput_LICENSE.txt', - 'Third-party notices: see ../Licenses/Microsoft.GameInput_NOTICE.txt' - ) -join "`n" - $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. 執行壓縮並驗證 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 - env: - VT_API_KEY: ${{ secrets.VT_API_KEY }} - run: | - if ([string]::IsNullOrWhiteSpace($env:VT_API_KEY)) { - "enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - Write-Warning "未設定 VT_API_KEY,略過 VirusTotal 掃描。" - } else { - "enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - Write-Host "已偵測到 VT_API_KEY,將進行 VirusTotal 掃描。" - } - - - name: 建立 GitHub Release - id: release - uses: softprops/action-gh-release@v3 - with: - files: ${{ env.ZIP_FILENAME }} - # 根據 Commit 與 PR 自動生成版本說明(Changelog)。 - generate_release_notes: true - body: | - ## 📦 輸入框發佈摘要 - - **版本號碼**:`${{ github.ref_name }}` - - **目標平台**: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: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: 使用 VirusTotal 掃描並更新 Release - if: ${{ steps.vt-config.outputs.enabled == 'true' }} - continue-on-error: true - uses: cssnr/virustotal-action@v2 - with: - vt_api_key: ${{ secrets.VT_API_KEY }} - release_id: ${{ steps.release.outputs.id }} - rate_limit: 4 - sha256: true - update_release: true - release_heading: "🛡️ **VirusTotal 掃描結果:**" - summary: true +name: 建置與發佈 + +on: + push: + tags: + # 當推送符合語意版本的標籤(如 v1.0.0)時觸發,排除非版本標籤(如 vtest)。 + - 'v[0-9]*' + workflow_call: + secrets: + VT_API_KEY: + required: false + +concurrency: + # 相同 tag 的重複發版只保留最新一次,避免重覆占用 runner 與 API 配額。 + group: release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-release: + runs-on: windows-latest + timeout-minutes: 25 + permissions: + contents: write + + # 定義環境變數,方便統一管理。 + env: + DOTNET_MAJOR_VERSION: '10' + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' + DOTNET_NOLOGO: '1' + PUBLISH_DIR: "./src/InputBox/out" + EXE_NAME: 'InputBox.exe' + SHA256_HASH: '' + GAMEINPUT_REDIST_SHA256: '' + ZIP_FILENAME: '' + + steps: + - name: 簽出原始碼(Checkout) + uses: actions/checkout@v6 + with: + # 發版僅需目前 tag 對應提交,避免抓取完整歷史紀錄。 + fetch-depth: 1 + + - name: 驗證版本標籤來源為 main 最新提交 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # 僅抓取 main 的最新遠端頭,避免不必要的歷史下載。 + git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main + + $tagCommit = (git rev-list -n 1 "${{ github.ref_name }}").Trim() + $mainHead = (git rev-parse origin/main).Trim() + + Write-Host "Tag commit: $tagCommit" + Write-Host "main HEAD: $mainHead" + + if ([string]::IsNullOrWhiteSpace($tagCommit) -or [string]::IsNullOrWhiteSpace($mainHead)) { + Write-Error "無法解析版本標籤或 main 分支的提交資訊。" + } + + if ($tagCommit -ne $mainHead) { + Write-Error "正式版本標籤只能從 main 分支最新提交建立;目前 tag 未指向 origin/main 的最新 commit。" + } + + Write-Host "版本標籤來源驗證通過。" + + - name: 安裝 .NET 環境 + uses: actions/setup-dotnet@v5 + with: + # 使用 GitHub Actions 語法來讀取 env 變數。 + dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' + # Release 觸發頻率遠低於 cache TTL(7 天),不快取以避免無效寫入。 + + - name: 驗證本儲存庫 InputWeave.GameInput NuGet 套件 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" + $checksumPath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg.sha256" + + if (-not (Test-Path $packagePath)) { + Write-Error "找不到本儲存庫 InputWeave.GameInput NuGet 套件:$packagePath" + } + + if (-not (Test-Path $checksumPath)) { + Write-Error "找不到本儲存庫 InputWeave.GameInput 套件 SHA256 檔案:$checksumPath" + } + + $expected = ((Get-Content -LiteralPath $checksumPath -Raw).Trim() -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" + } + + Write-Host "InputWeave.GameInput 套件 SHA256 驗證通過:$actual" + + - name: 還原 NuGet 套件 + run: dotnet restore ./src/InputBox/InputBox.csproj --force-evaluate + + - 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: 執行專案發佈(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 + + - name: 驗證單檔發佈輸出 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $exePath = Join-Path "${{ env.PUBLISH_DIR }}" "${{ env.EXE_NAME }}" + + if (-not (Test-Path $exePath)) { + Write-Error "找不到單檔發佈執行檔:$exePath" + } + + $forbiddenNames = @( + "InputBox.GameInput.Native.dll", + "gameinput.dll" + ) + $publishFiles = Get-ChildItem -LiteralPath "${{ env.PUBLISH_DIR }}" -Recurse -File + foreach ($name in $forbiddenNames) { + $match = $publishFiles | Where-Object { $_.Name.Equals($name, [StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1 + if ($match) { + Write-Error "單檔發佈輸出不應包含可見的 GameInput sidecar:$($match.FullName)" + } + } + + Write-Host "單檔發佈輸出驗證通過:未留下 InputBox.GameInput.Native.dll 或 gameinput.dll sidecar。" + + - name: 自動產生第三方套件授權聲明(nuget-license) + shell: pwsh + run: | + dotnet tool restore + Write-Host "掃描專案 NuGet 套件並匯出 ThirdPartyNotices.txt……" + # -i:指定專案檔路徑。 + # -o:指定輸出格式為 Markdown(讓純文字檔內有整齊的表格排版)。 + # -fo:指定輸出的檔案名稱(File Output)。 + 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!" + } else { + Write-Warning "⚠️ 未產生 ThirdPartyNotices.txt,可能是因為專案目前沒有依賴任何第三方套件。" + } + + - name: 補寫 InputWeave.GameInput 授權聲明 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $noticePath = "ThirdPartyNotices.txt" + + if (-not (Test-Path $noticePath)) { + New-Item -ItemType File -Path $noticePath -Force | Out-Null + } + + Add-Content -Path $noticePath -Encoding utf8 -Value '' + Add-Content -Path $noticePath -Encoding utf8 -Value '## InputWeave.GameInput' + Add-Content -Path $noticePath -Encoding utf8 -Value '' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 套件:InputWeave.GameInput 0.0.1' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 來源:https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 作者:https://github.com/rubujo' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 貢獻者:https://github.com/rubujo/InputWeave.GameInput/graphs/contributors' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 封裝來源:https://github.com/rubujo/InputWeave.GameInput/commit/664a9d3e96458a49688ff2255a36f6e073977065' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 套件檔案:eng/nuget/InputWeave.GameInput.0.0.1.nupkg' + Add-Content -Path $noticePath -Encoding utf8 -Value '- SHA256: 64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 授權:Creative Commons CC0 1.0 Universal(SPDX:CC0-1.0);詳見本 Licenses 目錄中的 InputWeave.GameInput_LICENSE.txt 與 https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE。' + + - name: 產生執行檔的雜湊值(SHA256) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $exePath = Join-Path -Path "${{ env.PUBLISH_DIR }}" -ChildPath "${{ env.EXE_NAME }}" + + if (Test-Path $exePath) { + $hash = (Get-FileHash $exePath -Algorithm SHA256).Hash + "SHA256_HASH=$hash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Host "成功產生雜湊值:$hash" + } else { + Write-Error "找不到執行檔($exePath),請檢查發佈步驟!" + } + + - name: 打包並壓縮發佈檔案(ZIP Archive) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $tagName = "${{ github.ref_name }}" + $zipName = "InputBox-$tagName-win-x64.zip" + "ZIP_FILENAME=$zipName" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + # 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 放根目錄。 + # - 專案授權(LICENSE)保留根目錄(業界慣例:使用者預期在根層找到它)。 + # - ThirdPartyNotices.txt 放入 Licenses/ 子目錄。 + Copy-Item -Path (Join-Path ${{ env.PUBLISH_DIR }} ${{ env.EXE_NAME }}) -Destination $packDir + $rootDocs = @("LICENSE", "README.md") + foreach ($doc in $rootDocs) { + if (Test-Path $doc) { + Copy-Item -Path $doc -Destination $packDir + } + } + if (Test-Path "ThirdPartyNotices.txt") { + Copy-Item -Path "ThirdPartyNotices.txt" -Destination $licensesDir + } + Copy-Item -Path ".\eng\nuget\InputWeave.GameInput_LICENSE.txt" -Destination (Join-Path $licensesDir "InputWeave.GameInput_LICENSE.txt") + + # 3. 動態抓取 .NET 官方授權檔案(含最相近 Tag/分支 Fallback)。 + Write-Host "=== 開始動態下載 .NET 官方授權檔案 ===" + $runtimeInfo = dotnet --list-runtimes + + $majorVersion = "${{ env.DOTNET_MAJOR_VERSION }}" + $runtimeLine = $runtimeInfo | Where-Object { $_ -match "^Microsoft\.NETCore\.App ($majorVersion\.\d+\.\d+)" } | Select-Object -Last 1 + $githubHeaders = @{ + 'User-Agent' = 'InputBox-Release-Workflow' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + function Get-TagStageRank { + param([string]$TagName) + + if ($TagName -match '(?i)preview') { return 3 } + if ($TagName -match '(?i)rc') { return 2 } + if ($TagName -match '(?i)rtm|servicing') { return 1 } + return 0 + } + + function Get-ClosestDotnetTag { + param( + [string]$Repo, + [version]$TargetVersion + ) + + $tagsApiUrl = "https://api.github.com/repos/dotnet/$Repo/tags?per_page=100" + + try { + $tagResponse = Invoke-RestMethod -Uri $tagsApiUrl -Headers $githubHeaders -ErrorAction Stop + } catch { + Write-Warning "⚠️ 無法查詢 dotnet/$Repo 的 Tag 清單:$($_.Exception.Message)" + return $null + } + + $candidates = foreach ($tagItem in $tagResponse) { + if ($tagItem.name -match '^v(?\d+)\.(?\d+)\.(?\d+)') { + [pscustomobject]@{ + Name = $tagItem.name + Major = [int]$Matches['major'] + Minor = [int]$Matches['minor'] + Patch = [int]$Matches['patch'] + StageRank = Get-TagStageRank -TagName $tagItem.name + } + } + } + + $nearest = $candidates | + Where-Object { $_.Major -eq $TargetVersion.Major -and $_.Minor -eq $TargetVersion.Minor } | + Sort-Object ` + @{ Expression = { [Math]::Abs($_.Patch - $TargetVersion.Build) } }, ` + @{ Expression = { $_.StageRank } }, ` + @{ Expression = { $_.Patch }; Descending = $true } | + Select-Object -First 1 + + return $nearest.Name + } + + function Save-GitHubFileWithFallback { + param( + [string]$Repo, + [string[]]$Refs, + [string]$File, + [string]$OutPath + ) + + foreach ($ref in ($Refs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)) { + $rawUrl = "https://raw.githubusercontent.com/dotnet/$Repo/$ref/$File" + $metadataUrl = "https://api.github.com/repos/dotnet/$Repo/contents/$File?ref=$([uri]::EscapeDataString($ref))" + Write-Host "嘗試下載:dotnet/$Repo@$ref -> $File" + + try { + Invoke-WebRequest -Uri $rawUrl -OutFile $OutPath -Headers $githubHeaders -ErrorAction Stop + Write-Host "✅ 成功儲存為 $(Split-Path $OutPath -Leaf)(來源:$ref / raw)" + return $true + } catch { + try { + $metadata = Invoke-RestMethod -Uri $metadataUrl -Headers $githubHeaders -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($metadata.download_url)) { + Invoke-WebRequest -Uri $metadata.download_url -OutFile $OutPath -Headers $githubHeaders -ErrorAction Stop + Write-Host "✅ 成功儲存為 $(Split-Path $OutPath -Leaf)(來源:$ref / api)" + return $true + } + } catch { + # Ignore and continue to the next candidate below. + } + + Write-Warning "⚠️ 無法從 $ref 下載 $File,繼續嘗試下一個候選來源。" + } + } + + return $false + } + + if ($runtimeLine -match "^Microsoft\.NETCore\.App (\d+\.\d+\.\d+)") { + $version = $Matches[1] + $tag = "v$version" + $targetVersion = [version]$version + $releaseBranch = "release/$($targetVersion.Major).$($targetVersion.Minor)" + Write-Host "偵測到目前使用的 .NET Runtime 版本為:$version,優先使用 GitHub 標籤:$tag" + + $filesToDownload = @( + @{ Repo = "runtime"; File = "LICENSE.TXT"; Out = "DOTNET_RUNTIME_LICENSE.txt" }, + @{ Repo = "runtime"; File = "THIRD-PARTY-NOTICES.TXT"; Out = "DOTNET_RUNTIME_THIRD_PARTY_NOTICES.txt" }, + @{ Repo = "winforms"; File = "LICENSE.TXT"; Out = "DOTNET_WINFORMS_LICENSE.txt" }, + @{ Repo = "winforms"; File = "THIRD-PARTY-NOTICES.TXT"; Out = "DOTNET_WINFORMS_THIRD_PARTY_NOTICES.txt" } + ) + + $repoRefCandidates = @{} + foreach ($repoName in ($filesToDownload.Repo | Select-Object -Unique)) { + $closestTag = Get-ClosestDotnetTag -Repo $repoName -TargetVersion $targetVersion + if ($closestTag) { + Write-Host "dotnet/$repoName 最相近的可用 Tag 候選:$closestTag" + } else { + Write-Warning "⚠️ dotnet/$repoName 找不到符合 $($targetVersion.Major).$($targetVersion.Minor).x 的 Tag,將直接退回分支。" + } + + $repoRefCandidates[$repoName] = @($tag, $closestTag, $releaseBranch, 'main') + } + + foreach ($item in $filesToDownload) { + $outPath = Join-Path $licensesDir $item.Out + $downloaded = Save-GitHubFileWithFallback -Repo $item.Repo -Refs $repoRefCandidates[$item.Repo] -File $item.File -OutPath $outPath + if (-not $downloaded) { + Write-Warning "⚠️ 最終仍無法取得 $($item.Out)。" + } + } + } else { + Write-Warning "❌ 無法解析 .NET Runtime 的版本號,請確認安裝步驟。" + } + + # 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" + + if (-not (Test-Path $gameInputRedistSource)) { + Write-Error "找不到 Microsoft.GameInput redist:$gameInputRedistSource" + } + + if (-not (Test-Path $gameInputLicenseSource)) { + Write-Error "找不到 Microsoft.GameInput 授權檔:$gameInputLicenseSource" + } + + if (-not (Test-Path $gameInputNoticeSource)) { + Write-Error "找不到 Microsoft.GameInput notice 檔:$gameInputNoticeSource" + } + + 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") + + $redistHash = (Get-FileHash (Join-Path $redistDir "GameInputRedist.msi") -Algorithm SHA256).Hash + "GAMEINPUT_REDIST_SHA256=$redistHash" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + $redistReadme = @( + 'Microsoft GameInput Redistributable', + '===================================', + '', + "Version: $env:GAMEINPUT_VERSION", + 'Included file: GameInputRedist.msi', + "SHA256: $redistHash", + '', + '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.', + '', + 'License: see ../Licenses/Microsoft.GameInput_LICENSE.txt', + 'Third-party notices: see ../Licenses/Microsoft.GameInput_NOTICE.txt' + ) -join "`n" + $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. 執行壓縮並驗證 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/InputWeave.GameInput_LICENSE.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', + 'InputBox.GameInput.Native.dll' + ) + foreach ($pattern in $forbiddenPatterns) { + if ($entries | Where-Object { $_ -like "*$pattern" }) { + Write-Error "ZIP 包含禁止項目:$pattern" + } + } + + $thirdParty = $zip.GetEntry('InputBox/Licenses/ThirdPartyNotices.txt') + if ($null -eq $thirdParty) { + Write-Error "ZIP 缺少 ThirdPartyNotices.txt,無法驗證 InputWeave.GameInput 聲明。" + } + + $reader = [System.IO.StreamReader]::new($thirdParty.Open()) + try { + $thirdPartyText = $reader.ReadToEnd() + if ($thirdPartyText -notmatch 'InputWeave\.GameInput') { + Write-Error "ThirdPartyNotices.txt 缺少 InputWeave.GameInput 聲明。" + } + } finally { + $reader.Dispose() + } + } finally { + $zip.Dispose() + } + + - name: 檢查 VirusTotal API Key 設定 + id: vt-config + shell: pwsh + env: + VT_API_KEY: ${{ secrets.VT_API_KEY }} + run: | + if ([string]::IsNullOrWhiteSpace($env:VT_API_KEY)) { + "enabled=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Warning "未設定 VT_API_KEY,略過 VirusTotal 掃描。" + } else { + "enabled=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "已偵測到 VT_API_KEY,將進行 VirusTotal 掃描。" + } + + - name: 建立 GitHub Release + id: release + uses: softprops/action-gh-release@v3 + with: + files: ${{ env.ZIP_FILENAME }} + # 根據 Commit 與 PR 自動生成版本說明(Changelog)。 + generate_release_notes: true + body: | + ## 📦 輸入框發佈摘要 + - **版本號碼**:`${{ github.ref_name }}` + - **目標平台**: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: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 使用 VirusTotal 掃描並更新 Release + if: ${{ steps.vt-config.outputs.enabled == 'true' }} + continue-on-error: true + uses: cssnr/virustotal-action@v2 + with: + vt_api_key: ${{ secrets.VT_API_KEY }} + release_id: ${{ steps.release.outputs.id }} + rate_limit: 4 + sha256: true + update_release: true + release_heading: "🛡️ **VirusTotal 掃描結果:**" + summary: true diff --git a/.gitignore b/.gitignore index 9491a2f..669da9e 100644 --- a/.gitignore +++ b/.gitignore @@ -194,6 +194,7 @@ PublishScripts/ # NuGet Packages *.nupkg +!eng/nuget/*.nupkg # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore @@ -360,4 +361,4 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd diff --git a/AGENTS.md b/AGENTS.md index 5da9ee2..34d654f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,54 +1,35 @@ -# InputBox - Agent 工作區總入口 +# InputBox - Agent 工作區入口 -本檔案是 InputBox 的 repo root agent 指引入口,供 Codex CLI、GitHub Copilot CLI、Antigravity CLI 與其他支援 `AGENTS.md` 的工具使用。Claude Code 與其他工具專屬入口檔只保留薄相容層,不維護第二份完整規範。開始任何修改前,應先讀取 `.agents/skills/inputbox-dev/SKILL.md`,再依任務需要載入 `docs/engineering/` 下的對應規範。 +本檔是 InputBox repo root 的唯一跨工具 agent 指引入口,供支援 `AGENTS.md` 的 Codex CLI、GitHub Copilot CLI 與 Antigravity CLI 使用。Claude Code 只透過最小 `CLAUDE.md` 與 Claude project skill 橋接進入同一條權威鏈,不維護第二份完整規範。 -## 0. Agent 支援結構 - -### 0.1 單一權威鏈 - -本專案採用「共用入口 + 專案技能 + 原子化工程規範」三層結構: +開始任何修改前,先讀取 `.agents/skills/inputbox-dev/SKILL.md`,再依任務類型讀取 `docs/engineering/` 下的對應規範。 -1. `AGENTS.md`:跨工具的共同入口,描述載入順序、安全紅線與任務索引。 -2. `.agents/skills/inputbox-dev/SKILL.md`:InputBox 專案唯一權威技能,封裝工程規範、A11y、在地化、測試與 Git 提交要求。 -3. `docs/engineering/`:原子化細節規範;任務涉及哪個領域,就讀取對應文件。 +## 0. Agent 支援結構 -工具專屬入口檔只能導向這條權威鏈,不得複製完整規範: +### 0.1 官方依據查核日期:2026-05-25 -- `CLAUDE.md`:Claude Code 專案記憶入口,使用 `@AGENTS.md` 匯入本檔。 -- `GEMINI.md`:Antigravity CLI / Gemini 相容 context 入口,導向本檔與 `inputbox-dev`。 -- `.github/copilot-instructions.md`:GitHub Copilot CLI 與 Copilot repository-wide instructions 入口,導向本檔。 +- Codex CLI:OpenAI 文件定義 `AGENTS.md` 為 custom instructions 檔,repo skills 位於 `.agents/skills`。 +- Claude Code:Anthropic 文件定義 project memory 位於 `CLAUDE.md` 或 `.claude/CLAUDE.md`,`CLAUDE.md` 可匯入 `AGENTS.md`,project skills 位於 `.claude/skills`。 +- GitHub Copilot CLI:GitHub 文件定義 root `AGENTS.md` 為 primary instructions,project skills 可位於 `.agents/skills`。 +- Antigravity CLI:Google 文件定義 workspace skills 位於 `.agents/skills`;若未來需要 workspace rules,使用 `.agents/rules`。 -### 0.2 官方文件查核(2026-05-24) +### 0.2 權威鏈 -本結構依下列官方資料調整: +1. `AGENTS.md`:跨工具共同入口,只放載入順序、安全紅線與工程文件索引。 +2. `.agents/skills/inputbox-dev/SKILL.md`:InputBox 權威工程 skill。 +3. `docs/engineering/`:任務領域的原子化工程規範。 +4. `CLAUDE.md` 與 `.claude/skills/inputbox-dev/SKILL.md`:Claude Code 必要橋接。 -- Codex CLI:OpenAI Codex 文件指出 Codex 會在工作前讀取 `AGENTS.md`,並由全域到 repo root 再到目前目錄建立 instruction chain。來源:[Custom instructions with AGENTS.md](https://developers.openai.com/codex/guides/agents-md)。 -- Claude Code:Claude Code 文件指出 project instructions 可放在 `./CLAUDE.md` 或 `./.claude/CLAUDE.md`,且 `CLAUDE.md` 可用 `@path/to/import` 匯入其他檔案。來源:[How Claude remembers your project](https://code.claude.com/docs/en/memory)。 -- GitHub Copilot CLI:GitHub 文件指出 Copilot CLI 支援 `.github/copilot-instructions.md`、`.github/instructions/**/*.instructions.md` 與 `AGENTS.md`;root `AGENTS.md` 會被視為 primary instructions。來源:[Adding custom instructions for GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions)。 -- Antigravity CLI:Google Antigravity 遷移文件指出 Antigravity CLI 讀取與 Gemini CLI 相同的 context files,workspace 會讀 `GEMINI.md` 與 `AGENTS.md`,workspace skills 使用 `.agents/skills`;Antigravity Rules 目前預設放在 `.agents/rules`。來源:[Migrating from Gemini CLI](https://antigravity.google/docs/gcli-migration)、[Rules and Workflows](https://antigravity.google/docs/rules-workflows)。 +不要新增重複的 root instructions、舊版相容入口,或任何工具專屬的完整規範副本。細節規範只維護在 project skill 與 `docs/engineering/`。 ### 0.3 支援矩陣 -| 工具 | 主要入口 | 本 repo 策略 | -|---|---|---| -| Codex CLI | `AGENTS.md` | 直接使用本檔,不建立 `CODEX.md` 或另一份 Codex 專屬規範。 | -| Claude Code | `CLAUDE.md` | `CLAUDE.md` 僅用 `@AGENTS.md` 匯入共同規範,並保留少量 Claude 專屬說明。 | -| GitHub Copilot CLI | `.github/copilot-instructions.md` + `AGENTS.md` | 兩者都存在,但 `.github/copilot-instructions.md` 只做導向,避免和本檔衝突。 | -| Antigravity CLI | `AGENTS.md` + `GEMINI.md` + `.agents/skills` | `AGENTS.md` 是共同規範,`GEMINI.md` 是薄相容層,`inputbox-dev` 留在 `.agents/skills`。若日後需要 Antigravity workspace rules,新增於 `.agents/rules`,不要使用舊的 `.agent/rules`。 | - -### 0.4 是否需要建立工具專屬 Skill - -目前不建議為 Codex CLI、Claude Code、Copilot CLI 或 Antigravity CLI 各自建立重複技能,原因如下: - -- 專案已存在可重用的 `inputbox-dev` 技能,內容已涵蓋安全紅線、工程規範、A11y、在地化、測試與 Git 提交要求。 -- 多份工具專屬技能會與 `.agents/skills/inputbox-dev/SKILL.md` 形成雙份維護,長期更容易漂移。 -- 若未來需要工具專屬能力,應只新增薄包裝或工具設定,實際工程規範仍回到 `inputbox-dev` 與 `docs/engineering/`。 - -只有在下列情況,才建議新增獨立 skill 或 rules: - -- 需要封裝可被多工具重用的固定工作流程,例如審查指令碼、專用驗證命令或跨專案模板。 -- 需要將 `inputbox-dev` 拆分為可獨立安裝、可跨 repo 復用的技能模組。 -- Antigravity 需要 workspace rule 的啟用模式、glob 或 model-decision 設定;此時應放在 `.agents/rules`,並保持只引用共同規範。 +| 工具 | 入口 | Skill 路徑 | Repo 策略 | +|---|---|---|---| +| Codex CLI | `AGENTS.md` | `.agents/skills/inputbox-dev/SKILL.md` | 使用共同入口與權威 project skill。 | +| Claude Code | `CLAUDE.md` 匯入 `AGENTS.md` | `.claude/skills/inputbox-dev/SKILL.md` 橋接 | 僅作橋接;權威規範仍在 `.agents/skills` 與 `docs/engineering/`。 | +| GitHub Copilot CLI | `AGENTS.md` | `.agents/skills/inputbox-dev/SKILL.md` | 使用 root primary instructions 與權威 project skill。 | +| Antigravity CLI | `AGENTS.md` | `.agents/skills/inputbox-dev/SKILL.md` | 使用共同入口與權威 project skill;目前不新增 workspace rules。 | ## 1. 必讀規範索引 @@ -68,137 +49,34 @@ 以下限制為硬性要求: -- 禁止記憶體注入、封包攔截、電磁紀錄、或修改第三方程式行為。 +- 禁止記憶體注入、封包攔截、電磁紀錄,或修改第三方程式行為。 - 禁止模擬輸入到其他視窗;輸出邊界僅限於「複製到剪貼簿」。 - 禁止實作任何自動化遊戲行為,例如自動連點、自動施法、輪播或掛機控制。 - 禁止主動偵測特定第三方應用程式。 -- 所有 Git 提交預設必須使用使用者既有的 GPG 簽章設定;不得修改 `gpg.conf`、`gpg-agent.conf`、`common.conf` 或其他相關設定。若簽章失敗,應停止並回報使用者。 +- Git 提交必須使用使用者既有的 GPG 簽章設定;不得修改 `gpg.conf`、`gpg-agent.conf`、`common.conf` 或相關簽章設定。若簽章失敗,停止並回報。 -詳細內容以 `docs/engineering/git-commit-safety.md` 為準。 +若任務涉及輸入流程、剪貼簿流程、快速鍵、控制器映射、輸出行為或返回前景視窗邏輯,交付前必須依 `docs/engineering/git-commit-safety.md` 完成即時合規檢查。 -## 3. ToS 與合規檢查 +## 3. 環境與編碼 -若任務涉及下列核心行為: - -- 輸入流程 -- 剪貼簿流程 -- 快速鍵 -- 控制器映射 -- 輸出或返回前景視窗邏輯 - -則 Codex 在實作前或最遲於交付前,必須依 `docs/engineering/git-commit-safety.md` 的要求,抓取並檢視列出的第三方服務條款,確認異動仍符合「非自動化、非模擬、非注入」原則,並在交付摘要中簡述合規結論。 - -## 4. 環境與編碼 - -- 預設作業系統:Windows -- 預設 Shell:PowerShell -- 執行 PowerShell 指令前先設定 UTF-8: +- 預設作業系統:Windows。 +- 預設 shell:PowerShell。 +- 執行可能輸出非 ASCII 文字的 PowerShell 指令前,先設定 UTF-8: ```powershell [Console]::OutputEncoding = [Console]::InputEncoding = [System.Text.Encoding]::UTF8 ``` -- 所有異動必須遵循 repo 根目錄 `.editorconfig`。 +- 遵循 `.editorconfig`。 - 新增或修改 `*.cs`、`*.resx` 時,必須使用 UTF-8 with BOM 與 CRLF。 - 其他文字檔使用 UTF-8 與 CRLF。 -## 5. 核心工程規則 - -- 目標框架:`.NET 10 (net10.0-windows)` -- 命名空間使用 file-scoped namespace。 -- 私有欄位採 `_camelCase`。 -- 若 `CancellationTokenSource` 欄位需要原子替換或釋放,禁止宣告為 `readonly`。 -- 釋放資源時必須遵循原子化處置模式: - -```csharp -Interlocked.Exchange(ref _cts, null)?.CancelAndDispose(); -Interlocked.Exchange(ref _resource, null)?.Dispose(); -``` - -- 讀取 `CancellationToken` 時使用安全模式: - -```csharp -_cts?.Token ?? CancellationToken.None -``` - -- 涉及 UI 執行緒調度時,優先沿用既有 `SafeInvoke`、`SafeInvokeAsync` 或 `InvokeAsync` 模式。 -- 涉及 DPI、版面與最小尺寸時,必須遵循 `docs/engineering/core-engineering.md` 的 `UpdateLayoutConstraints`、`UpdateMinimumSize` 與智慧定位規範。 -- 修改 `*.cs` 後,完成前必須清除新增的 IDE 與 CS 診斷。 - -## 6. A11y 與視覺安全 - -只要變更 UI、公告廣播、焦點回饋、驗證提示或動畫效果,必須先閱讀 `docs/engineering/a11y-safety.md`。 - -關鍵要求: - -- 不得引入超出規範的閃爍或高刺激視覺效果。 -- 不得破壞 live region 與螢幕閱讀器行為。 -- `AnnouncerLabel.Clear()` 必須使用 ZWSP,不可改成 NBSP。 -- 焦點、按壓、懸停、驗證狀態不得造成版面抖動。 -- 必須維持深色、淺色、高對比與色覺友善條件下的對比與色彩規則。 - -## 7. 在地化規則 - -- 所有執行階段使用者可見文字,都必須定義在 `src/InputBox/Resources/Strings.resx` 與對應語系 `.resx`。 -- 禁止在執行階段程式碼中硬式編碼顯示文字。 -- `{0}` 等預留位置不可翻譯或改序,除非語序調整本身有明確需求。 -- 助記鍵需維持跨語系一致性,並避免重複後綴。 -- 禁止手動修改 `MainForm.Designer.cs` 的自動生成版面配置結構。 - -## 8. 測試要求 - -- 測試框架:xUnit v3 -- 測試專案:`tests/InputBox.Tests/InputBox.Tests.csproj` -- 常用驗證指令: - -```powershell -dotnet build src/InputBox/InputBox.csproj --configuration Debug -dotnet test --project tests/InputBox.Tests/InputBox.Tests.csproj -``` - -- 若測試會讀寫 `%AppData%` 相關資料,必須依 `docs/engineering/testing.md` 採用檔案系統隔離模式。 -- 新增測試時,方法命名必須遵循 `Method_Condition_ExpectedResult`。 -- 每個 `[Fact]` 方法都應有繁體中文 XML `summary`。 -- 若新增、刪除或明顯調整測試,必須同步更新 `tests/InputBox.Tests/README.md`。 - -## 9. 專案結構速覽 - -```text -src/InputBox/ - Core/ - Configuration/ - Controls/ - Extensions/ - Feedback/ - Input/ - Interop/ - Services/ - Utilities/ - Resources/ - MainForm.cs - MainForm.Events.cs - MainForm.A11y.cs - MainForm.ContextMenu.cs - MainForm.Gamepad.cs - Program.cs -tests/InputBox.Tests/ -docs/engineering/ -``` - -## 10. Git 工作流 - -- 提交格式遵循 Conventional Commits:`(): ` -- 提交訊息預設使用正體中文。 -- 應以 `dev` 為工作分支,透過 PR 合併回 `main`;不得直接推送應用程式變更到 `main`。 -- 新一輪開發前,`dev` 應先對齊最新 `main`。 -- 提交後應以 `git log --show-signature -1` 或 `git verify-commit HEAD` 驗證簽章。 - -## 11. 完成前檢查清單 +## 4. 完成前檢查 -交付前至少確認: +依異動類型確認下列項目: 1. `dotnet build src/InputBox/InputBox.csproj --configuration Debug` -2. 若異動可測,執行 `dotnet test --project tests/InputBox.Tests/InputBox.Tests.csproj` +2. 異動行為或測試時,執行 `dotnet test --project tests/InputBox.Tests/InputBox.Tests.csproj` 3. 修改過的 `*.cs` 沒有新增 IDE 或 CS 診斷 -4. 若有使用者可見文字異動,已同步更新對應 `.resx` -5. 若有輸入、輸出、剪貼簿、快速鍵或控制器邏輯異動,已重新檢查 `docs/engineering/git-commit-safety.md` +4. 使用者可見文字異動已同步更新所有必要 `.resx` +5. 輸入、輸出、剪貼簿、快速鍵或控制器異動已依 `docs/engineering/git-commit-safety.md` 完成合規檢查 diff --git a/CLAUDE.md b/CLAUDE.md index 708be0c..3b98a8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,11 @@ # InputBox - Claude Code 入口 -本檔是 Claude Code 的專案記憶入口。Claude Code 官方文件支援在 `CLAUDE.md` 內使用 `@path/to/import` 匯入其他檔案;因此本檔只匯入共同規範,不複製第二份工程規則。 +Claude Code 會從 `CLAUDE.md` 讀取 project memory,因此本檔只作為共同 repo 規範的薄橋接。 @AGENTS.md -## Claude Code 相容要求 +## Claude Code 橋接 -- 若本檔與 `AGENTS.md`、`.agents/skills/inputbox-dev/SKILL.md` 或 `docs/engineering/` 有衝突,以後三者為準。 -- 開始任何修改前,先依 `AGENTS.md` 載入 `inputbox-dev` 與任務相關工程規範。 -- 不要把完整規範貼回本檔;需要 Claude 專屬補充時,只加入最小相容說明。 +- 遵循 `AGENTS.md`、`.agents/skills/inputbox-dev/SKILL.md` 與任務相關的 `docs/engineering/` 文件。 +- `.claude/skills/inputbox-dev/SKILL.md` 只作為 Claude Code discovery 橋接。 +- 不要把完整工程規範複製到本檔。 diff --git a/ENGINEERING_GUIDELINES.md b/ENGINEERING_GUIDELINES.md index 1aad464..05e3884 100644 --- a/ENGINEERING_GUIDELINES.md +++ b/ENGINEERING_GUIDELINES.md @@ -1,6 +1,6 @@ # InputBox 工程規範目錄 (Engineering Standards) -為了提升 AI Agent (Gemini & Copilot) 的檢索效率與 Context 管理,本專案已將工程規範拆解為原子化的技能模組。 +為了提升 Codex CLI、Claude Code、GitHub Copilot CLI 與 Antigravity CLI 的檢索效率與 context 管理,本專案已將工程規範拆解為共同入口、project skill 與原子化工程文件。 ## 核心規範檔案 - [環境與編碼 (Environment)](docs/engineering/environment.md) @@ -10,9 +10,11 @@ - [在地化與術語表 (Localization)](docs/engineering/localization.md) - [Git 提交規範與安全性紅線 (Git/Safety)](docs/engineering/git-commit-safety.md)(包含 Conventional Commits、GPG 簽章提交、main-first 分支守門與合規紅線) -## AI 技能位置 -- **Gemini CLI**: `.agents/skills/inputbox-dev/`(目前共用) -- **GitHub Copilot / Others**: `.agents/skills/inputbox-dev/` +## Agent 規範位置 + +- **共同入口**:`AGENTS.md` +- **權威技能**:`.agents/skills/inputbox-dev/SKILL.md` +- **Claude Code 橋接**:`CLAUDE.md` 與 `.claude/skills/inputbox-dev/SKILL.md` --- *註:若需人工閱讀,建議從上方清單選取對應主題。* diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 8ca793a..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,16 +0,0 @@ -# InputBox - Antigravity / Gemini 相容入口 - -本檔是 Antigravity CLI 與 Gemini 相容工具的 context 入口。Antigravity CLI 官方遷移文件指出 workspace context 會讀取 `GEMINI.md` 與 `AGENTS.md`,因此本檔只做薄相容層,避免和共同規範重複。 - -## 載入順序 - -1. 讀取根目錄 `AGENTS.md`。 -2. 載入 `.agents/skills/inputbox-dev/SKILL.md`。 -3. 依任務性質讀取 `docs/engineering/` 下的對應規範。 - -## 相容要求 - -- `AGENTS.md` 是跨工具主要入口;本檔不得複製完整規範。 -- Workspace skills 保持在 `.agents/skills`。 -- 若日後需要 Antigravity workspace rules,放在 `.agents/rules`,並只引用共同規範。 -- 若本檔與 `AGENTS.md`、`inputbox-dev` 或 `docs/engineering/` 有衝突,以共同規範與更細部工程規範為準。 diff --git a/InputBox.slnx b/InputBox.slnx index fc408e6..afdcf9e 100644 --- a/InputBox.slnx +++ b/InputBox.slnx @@ -4,9 +4,18 @@ - - + + + + + + + + + + + @@ -16,20 +25,23 @@ + + + + - + - diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..b1aeb40 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 7537236..55cdf3d 100644 --- a/README.md +++ b/README.md @@ -516,7 +516,7 @@ dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile= - `-r win-x64`:指定目標執行階段為 Windows 64 位元作業系統。 - `--self-contained true`:啟用獨立式部署。這會將底層的 .NET 執行階段與應用程式一併打包,使用者**無需在電腦上預先安裝任何 .NET 環境**即可直接執行。 - `-p:PublishSingleFile=true`:啟用單一檔案發佈。會將應用程式本身及其所有依賴的函式庫全部打包進單一個 `.exe` 執行檔中,讓資料夾保持乾淨,方便使用者複製或分享。 -- `-p:IncludeNativeLibrariesForSelfExtract=true`:讓 .NET host 在執行階段自動將 bundle 中的原生程式庫(例如 `InputBox.GameInput.Native.dll`)自解壓後載入。**win-x64 單一檔案發佈時此參數為必要項**,省略將導致 MSBuild 建置錯誤。 +- `-p:IncludeNativeLibrariesForSelfExtract=true`:讓 .NET host 在執行階段自動將 bundle 中需要自解壓的原生程式庫載入。GameInput 後端直接使用 `InputWeave.GameInput` gamepad API 載入系統或 redist runtime;發佈檔不包含可見的 `InputBox.GameInput.Native.dll` sidecar。 - `-p:PublishReadyToRun=true`:啟用 ReadyToRun 預先編譯。這會在建置時提前將部分程式碼編譯為原生機器碼,減少執行時即時編譯的負擔,能**顯著縮短應用程式的冷啟動時間**,讓喚出輸入框的反應更迅速。 發佈完成後,編譯好的單一執行檔會生成在以下路徑: @@ -592,6 +592,7 @@ 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)。 +- [InputWeave.GameInput 0.0.1](https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065):由 [rubujo](https://github.com/rubujo) 及其 [貢獻者](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [**CC0 1.0 Universal**](https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE) 授權,提供 GameInput 執行階段、用戶端、裝置、讀取狀態與震動控制介面。套件固定於本儲存庫的 NuGet 來源 `eng/nuget/InputWeave.GameInput.0.0.1.nupkg`,封裝來源為 [`664a9d3e96458a49688ff2255a36f6e073977065`](https://github.com/rubujo/InputWeave.GameInput/commit/664a9d3e96458a49688ff2255a36f6e073977065),套件 SHA256 為 `64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805`。 - [Microsoft GameInput Redistributable](https://www.nuget.org/packages/Microsoft.GameInput):由 Microsoft 提供,作為選用的 GameInput 執行階段可轉散發套件。正式發佈 ZIP 檔會隨附 `redist/GameInputRedist.msi` 供使用者手動安裝;本應用程式不會自動安裝。手動安裝 `redist/GameInputRedist.msi` 即表示使用者需遵守 [Microsoft.GameInput 授權條款](https://www.nuget.org/packages/Microsoft.GameInput/3.4.218/License);相關授權與 NOTICE 檔案位於 `Licenses/Microsoft.GameInput_LICENSE.txt` 與 `Licenses/Microsoft.GameInput_NOTICE.txt`,且該可轉散發安裝程式不屬於本專案 CC0 授權範圍。 -本專案的詳細條款與免責聲明,請參閱隨附之 [**LICENSE**](LICENSE) 文件;發佈檔 `Licenses/` 資料夾內含 `ThirdPartyNotices.txt`(NuGet 套件授權聲明清單)、`Microsoft.GameInput_LICENSE.txt`、`Microsoft.GameInput_NOTICE.txt`,以及各元件完整授權文字;[Microsoft.GameInput 3.4.218 線上授權頁](https://www.nuget.org/packages/Microsoft.GameInput/3.4.218/License) 另可於 NuGet 查閱。 +本專案的詳細條款與免責聲明,請參閱隨附之 [**LICENSE**](LICENSE) 文件;發佈檔 `Licenses/` 資料夾內含 `ThirdPartyNotices.txt`(NuGet 套件授權聲明清單)、`InputWeave.GameInput_LICENSE.txt`、`Microsoft.GameInput_LICENSE.txt`、`Microsoft.GameInput_NOTICE.txt`,以及各元件完整授權文字;[Microsoft.GameInput 3.4.218 線上授權頁](https://www.nuget.org/packages/Microsoft.GameInput/3.4.218/License) 另可於 NuGet 查閱。 diff --git a/docs/engineering/gameinput-hardware-verification.md b/docs/engineering/gameinput-hardware-verification.md index f616c7e..92432d0 100644 --- a/docs/engineering/gameinput-hardware-verification.md +++ b/docs/engineering/gameinput-hardware-verification.md @@ -8,15 +8,15 @@ 建議在下列情境執行: -- 修改 `InputBox.GameInput.Native` shim、GameInput 受控端 interop 或 ABI。 +- 修改 `InputWeave.GameInput` 版本、repo-local nupkg,或 InputBox 直接使用的 GameInput client/device/reading/callback/rumble 路徑。 - 修改 GameInput 連線/斷線偵測、重新列舉、callback、輪詢或退避行為。 - 修改 GameInput rumble 或緊急停止流程。 -- 升級 `Microsoft.GameInput` NuGet 套件。 +- 升級 `InputWeave.GameInput` 或 `Microsoft.GameInput` NuGet 套件。 - 正式發佈前,作為發佈冒煙驗證的人工補充。 ## 非目標 -- 不取代 `dotnet build`、`dotnet test`、native export 驗證或 probe 冒煙測試。 +- 不取代 `dotnet build`、`dotnet test`、runtime probe 或發佈輸出驗證。 - 不要求每個 PR 都跑完整硬體矩陣。 - 不要求維護者購買或常備所有控制器。 - 不新增 keyboard、mouse、sensors、raw report、force feedback、aggregate device 或 1:1 GameInput wrapper 的驗證範圍。 @@ -25,7 +25,7 @@ - 使用 Windows 環境與本機 Debug 或 Release 組建。 - 在 InputBox 設定中手動選擇 GameInput 提供者;預設提供者仍維持 XInput。 -- 保留輸出視窗、`InputBox.log` 或測試者可取得的診斷輸出,方便記錄 GameInput shim 初始化、裝置狀態與退避訊息。 +- 保留輸出視窗、`InputBox.log` 或測試者可取得的診斷輸出,方便記錄 InputWeave runtime 初始化、裝置狀態與退避訊息。 - 若某項硬體或環境不可取得,結果標記為 `略過`,並記錄原因。 ## 驗證矩陣 @@ -37,7 +37,7 @@ | 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 且無法安全遮蔽,需記錄未測原因 | +| 執行階段缺失 / runtime 載入失敗 | 未安裝 GameInput redist,且系統沒有可用 GameInput runtime 的環境 | 啟動 InputBox 並手動選擇 GameInput 提供者 | GameInput 初始化失敗會公告並退避 XInput;沒有 crash;InputWeave probe / log 能指出載入或初始化失敗原因 | 是 | 若測試機已有系統 GameInput 且無法安全遮蔽,需記錄未測原因 | | Release ZIP | Release workflow 產物或本機發佈試跑 ZIP | 解壓縮並檢查 ZIP 結構,啟動 `InputBox.exe` 做 GameInput 冒煙驗證 | ZIP 不包含 `gameinput.dll` 或可見 `InputBox.GameInput.Native.dll` sidecar;包含 `redist/GameInputRedist.msi` 與第三方授權聲明 | 是 | 不可缺測 | ## 結果紀錄格式 @@ -51,7 +51,7 @@ GameInput hardware verification: - Sony / DualSense: 略過 (無可用硬體) - Nintendo / Switch Pro: 略過 (無可用硬體) - Elite / Paddle: 略過 (無可用硬體) -- 執行階段缺失 / Shim 載入失敗: 通過 (退避 XInput) +- 執行階段缺失 / runtime 載入失敗: 通過 (退避 XInput) - Release ZIP: 通過 ``` diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md index 86e7ced..15932eb 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -19,15 +19,14 @@ ## 2. API 選擇與退避機制 (Provider & Backoff) - **預設提供者**:應用程式之預設控制器提供者應設定為相容性最高之 **XInput**。 - **自動退避**:當使用者手動設定使用 `GameInput` 但初始化失敗(如系統不支援)時,系統必須**自動退避至 XInput** 並透過 `AnnounceA11y` 告知使用者。 -- **GameInput 實作邊界**:GameInput 必須透過專案自有 `InputBox.GameInput.Native` shim 與專案內部 C# 型別存取 Microsoft GameInput runtime;不得重新引入已封存的第三方 `GameInput.Net`、`GameInputDotNet`、`UsbVendorsLibrary` 或 `usb.ids` 流程。 -- **GameInput shim 同版發布**:Native shim 與 managed GameInput layer 視為同版、同包、一起發布的內部實作;更新 C ABI 時直接同步兩端 struct,不維護外部相容層或 `V2` suffix。若 shim 載入失敗或 ABI 不符,應走既有 GameInput 初始化失敗並退避 XInput 路徑。 -- **GameInput shim ABI 防呆**:shim 必須在 `InputBoxGameInputGetShimInfo` 回報 native ABI number、`GAMEINPUT_API_VERSION`、pointer size 與所有跨邊界 struct size;managed layer 必須以 `Marshal.SizeOf()` 驗證尺寸,不符即視為 shim 載錯並退避 XInput。 -- **GameInput shim 發佈驗證**:CI 與 release 必須在 native shim 建置後比對 `GameInputNativeMethods` 宣告的 `InputBoxGameInput*` EntryPoint 與 DLL exports,並執行 `InputBoxGameInputProbeRuntime` smoke test 與 native lifecycle stress smoke;缺 export、probe crash 或 native/managed struct size 不符時不得產生發佈 ZIP。若 GameInput runtime 不可用,lifecycle stress 可略過,但 export/probe 守門不可略過。CI 的 code filter 必須包含 `tools/**`,避免驗證腳本異動未觸發守門。 +- **GameInput 實作邊界**:GameInput 由 repo-local `InputWeave.GameInput` nupkg 提供 runtime 載入與 GameInput COM 包裝;InputBox 直接使用 InputWeave 的 gamepad runtime/client/device/reading/rumble/callback 路徑,不得重新引入已封存的 `GameInput.Net`、`GameInputDotNet`、`UsbVendorsLibrary` 或 `usb.ids` 流程。 +- **InputWeave 套件來源**:`InputWeave.GameInput` 必須以 `NuGet.config` 的 repo-local source 與 package source mapping 還原,正式提交固定版本 nupkg 與 SHA256;不得在未更新來源、授權與驗證文件前改為 floating version 或外部私有 feed。 +- **GameInput 發佈驗證**:CI 與 release 不再建置舊版 `InputBox.GameInput.Native` 原生專案;發佈輸出與 ZIP 必須確認不包含 `InputBox.GameInput.Native.dll` 或 `gameinput.dll` sidecar。GameInput runtime 不可用時,仍必須走既有初始化失敗並退避 XInput 路徑。 - **GameInput 手動硬體驗證**:自動驗證通過後,若正式發佈或變更內容涉及 GameInput 硬體行為,仍應依 `docs/engineering/gameinput-hardware-verification.md` 抽測實體控制器。此矩陣不是 CI 關卡,也不是每個 PR 的必跑項目。 -- **GameInput runtime probe**:shim 必須提供建立 context 前可呼叫的 runtime probe,回報 LoadLibrary、GetProcAddress、GameInputInitialize 的 HRESULT / Win32 error、嘗試與實際載入的 module kind/path,以及字串截斷旗標;此資訊只供 log 與測試,不可影響按鍵語意。 -- **GameInput DLL 載入安全**:系統 GameInput runtime 只能使用 System32 搜尋範圍;registry redist 絕對路徑必須搭配 `LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32`,並記錄實際載入來源。不得從工作目錄、目前目錄或未限定搜尋路徑載入 `gameinput.dll`。 -- **GameInput native context 同步**:native shim 必須以 SRW lock 保護 `devices`、`callbacks`、診斷 counter,以及 refresh/read/unregister/destroy 等會交錯的路徑;callback 只能複製 POD event 或設定喚醒訊號,不得持有 context lock 呼叫 managed callback。 -- **GameInput 診斷 metadata 邊界**:string truncation flags、timestamp stale counters、missing reading counters、device zombie refresh counters 只能進入 log、測試或未來診斷快照,不可直接改變 edge detection、Pause/Resume neutral gate 或任何 UI 命令。 +- **GameInput runtime probe**:必須保留 InputWeave 的 runtime probe 診斷,回報 loader policy、HRESULT / Win32 error、候選與實際選取的 module kind/path/version;此資訊只供 log 與測試,不可影響按鍵語意。 +- **GameInput DLL 載入安全**:GameInput runtime 載入策略由 InputWeave 負責,InputBox 不得自行從工作目錄、目前目錄或未限定搜尋路徑載入 `gameinput.dll`。 +- **GameInput 同步**:InputBox 仍必須在背景 MTA polling thread 建立、存取與釋放 InputWeave `GameInputClient` / `GameInputDevice` / callback registration;callback 只能設定喚醒或重新整理訊號,不得直接觸發 managed UI 命令。 +- **GameInput 診斷 metadata 邊界**:runtime probe、timestamp stale counters、missing reading counters、device unavailable 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/eng/nuget/InputWeave.GameInput.0.0.1.nupkg b/eng/nuget/InputWeave.GameInput.0.0.1.nupkg new file mode 100644 index 0000000..da3d81e Binary files /dev/null and b/eng/nuget/InputWeave.GameInput.0.0.1.nupkg differ diff --git a/eng/nuget/InputWeave.GameInput.0.0.1.nupkg.sha256 b/eng/nuget/InputWeave.GameInput.0.0.1.nupkg.sha256 new file mode 100644 index 0000000..ee5c8d7 --- /dev/null +++ b/eng/nuget/InputWeave.GameInput.0.0.1.nupkg.sha256 @@ -0,0 +1 @@ +64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805 InputWeave.GameInput.0.0.1.nupkg diff --git a/eng/nuget/InputWeave.GameInput_LICENSE.txt b/eng/nuget/InputWeave.GameInput_LICENSE.txt new file mode 100644 index 0000000..66fddfe --- /dev/null +++ b/eng/nuget/InputWeave.GameInput_LICENSE.txt @@ -0,0 +1,18 @@ +InputWeave.GameInput 0.0.1 +=========================== + +來源: https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065 +作者: https://github.com/rubujo +貢獻者: https://github.com/rubujo/InputWeave.GameInput/graphs/contributors +封裝來源: https://github.com/rubujo/InputWeave.GameInput/commit/664a9d3e96458a49688ff2255a36f6e073977065 +套件檔案: eng/nuget/InputWeave.GameInput.0.0.1.nupkg +套件 SHA256: 64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805 +授權名稱: Creative Commons CC0 1.0 Universal +授權識別碼 (SPDX): CC0-1.0 +授權檔案: https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE + +InputWeave.GameInput 採用 Creative Commons CC0 1.0 Universal 公眾領域貢獻宣告。 +在法律允許的最大範圍內,作者已將此套件的著作權與相關權利貢獻至全球公眾領域。 + +SPDX-License-Identifier: CC0-1.0 +授權全文: https://creativecommons.org/publicdomain/zero/1.0/legalcode diff --git a/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj b/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj deleted file mode 100644 index 42e8a17..0000000 --- a/src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj +++ /dev/null @@ -1,88 +0,0 @@ - - - - - Debug - x64 - - - Release - x64 - - - - 17.0 - Win32Proj - {3E85C875-5E9D-4C7D-9B87-0D5B3EB3DA53} - InputBoxGameInputNative - 10.0 - 3.4.218 - $(UserProfile)\.nuget\packages\ - $(NuGetPackageRoot)microsoft.gameinput\$(GameInputPackageVersion)\ - - - - DynamicLibrary - true - v145 - Unicode - - - DynamicLibrary - false - v145 - true - Unicode - - - - $(MSBuildProjectDirectory)\bin\$(Platform)\$(Configuration)\ - $(MSBuildProjectDirectory)\obj\$(Platform)\$(Configuration)\ - InputBox.GameInput.Native - - - - Level4 - true - WIN32;_WINDOWS;_USRDLL;INPUTBOX_GAMEINPUT_NATIVE_EXPORTS;%(PreprocessorDefinitions) - true - stdcpp17 - ProgramDatabase - $(IntDir)$(TargetName).compile.pdb - true - $(GameInputPackageRoot)native\include;%(AdditionalIncludeDirectories) - /utf-8 %(AdditionalOptions) - - - Windows - Advapi32.lib;%(AdditionalDependencies) - - - - - Level4 - true - true - true - WIN32;NDEBUG;_WINDOWS;_USRDLL;INPUTBOX_GAMEINPUT_NATIVE_EXPORTS;%(PreprocessorDefinitions) - true - stdcpp17 - ProgramDatabase - $(IntDir)$(TargetName).compile.pdb - true - $(GameInputPackageRoot)native\include;%(AdditionalIncludeDirectories) - /utf-8 %(AdditionalOptions) - - - Windows - true - true - Advapi32.lib;%(AdditionalDependencies) - - - - - - - - diff --git a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp b/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp deleted file mode 100644 index 71439d0..0000000 --- a/src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp +++ /dev/null @@ -1,1957 +0,0 @@ -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif - -#ifndef NOMINMAX -#define NOMINMAX -#endif - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using Microsoft::WRL::ComPtr; -using namespace GameInput::v3; - -namespace -{ - const HRESULT InputBoxGameInputNoReading = HRESULT_FROM_WIN32(ERROR_NOT_FOUND); - constexpr uint32_t InputBoxGameInputShimAbiVersion = 3; - constexpr uint32_t InputBoxGameInputMaxExtraControlIndexes = 32; - - enum InputBoxGameInputModuleKind : uint32_t - { - InputBoxGameInputModuleUnknown = 0, - InputBoxGameInputModuleSystemGameInput = 1, - InputBoxGameInputModuleSystemGameInputRedist = 2, - InputBoxGameInputModuleRegistryGameInputRedist = 3 - }; - - enum InputBoxGameInputStringTruncationFlags : uint32_t - { - InputBoxGameInputStringTruncatedNone = 0x00000000, - InputBoxGameInputStringTruncatedDeviceId = 0x00000001, - InputBoxGameInputStringTruncatedDeviceRootId = 0x00000002, - InputBoxGameInputStringTruncatedContainerId = 0x00000004, - InputBoxGameInputStringTruncatedDisplayName = 0x00000008, - InputBoxGameInputStringTruncatedPnpPath = 0x00000010, - InputBoxGameInputStringTruncatedAttemptedModulePath = 0x00000020, - InputBoxGameInputStringTruncatedLoadedModulePath = 0x00000040 - }; - - struct InputBoxGameInputVersion - { - uint16_t major; - uint16_t minor; - uint16_t build; - uint16_t revision; - }; - - // 下列 C ABI 結構必須與 GameInputPrimitives.cs 的受控端結構保持版面相容。 - // 欄位異動時,請同步更新 InputBoxGameInputShimAbiVersion 與受控端大小檢查。 - struct InputBoxGameInputShimInfo - { - uint32_t abiVersion; - uint32_t gameInputApiVersion; - uint32_t pointerSize; - uint32_t shimInfoSize; - uint32_t runtimeProbeInfoSize; - uint32_t deviceInfoSize; - uint32_t gamepadStateSize; - uint32_t diagnosticsSnapshotSize; - uint32_t loadedModuleKind; - char loadedModulePath[512]; - }; - - struct InputBoxGameInputRuntimeProbeInfo - { - uint32_t abiVersion; - uint32_t gameInputApiVersion; - uint32_t pointerSize; - uint32_t shimInfoSize; - uint32_t runtimeProbeInfoSize; - uint32_t deviceInfoSize; - uint32_t gamepadStateSize; - uint32_t diagnosticsSnapshotSize; - uint32_t attemptedModuleKind; - uint32_t loadedModuleKind; - int32_t loadLibraryHResult; - int32_t getProcAddressHResult; - int32_t initializeHResult; - int32_t finalHResult; - uint32_t loadLibraryWin32Error; - uint32_t getProcAddressWin32Error; - uint32_t initializeWin32Error; - uint32_t stringTruncationFlags; - char attemptedModulePath[512]; - char loadedModulePath[512]; - }; - - struct InputBoxGameInputDeviceInfo - { - uint16_t vendorId; - uint16_t productId; - uint16_t revisionNumber; - uint16_t usagePage; - uint16_t usageId; - uint16_t reserved; - uint32_t deviceFamily; - uint32_t supportedInput; - uint32_t supportedRumbleMotors; - uint32_t supportedSystemButtons; - uint32_t gamepadSupportedLayout; - uint32_t gamepadExtraButtonCount; - uint32_t gamepadExtraAxisCount; - uint32_t forceFeedbackMotorCount; - uint32_t inputReportCount; - uint32_t outputReportCount; - uint32_t extraButtonCount; - uint32_t extraAxisCount; - uint32_t extraButtonIndexCount; - uint32_t extraAxisIndexCount; - uint32_t hasInputMapper; - uint32_t stringTruncationFlags; - InputBoxGameInputVersion hardwareVersion; - InputBoxGameInputVersion firmwareVersion; - uint8_t extraButtonIndexes[InputBoxGameInputMaxExtraControlIndexes]; - uint8_t extraAxisIndexes[InputBoxGameInputMaxExtraControlIndexes]; - char deviceId[65]; - char deviceRootId[65]; - char containerId[39]; - char displayName[256]; - char pnpPath[512]; - }; - - struct InputBoxGameInputGamepadState - { - uint64_t timestamp; - uint32_t inputKind; - uint32_t buttons; - float leftTrigger; - float rightTrigger; - float leftThumbstickX; - float leftThumbstickY; - float rightThumbstickX; - float rightThumbstickY; - }; - - struct InputBoxGameInputDiagnosticsSnapshot - { - uint64_t missingReadingCount; - uint64_t repeatedTimestampCount; - uint64_t backwardTimestampCount; - uint64_t deviceUnavailableRefreshCount; - uint64_t lastReadingTimestamp; - int32_t lastReadHResult; - uint32_t lastReadDeviceStatus; - uint32_t reserved; - }; - - struct DeviceEntry - { - ComPtr device; - InputBoxGameInputDeviceInfo info{}; - }; - - using InputBoxGameInputReadingCallback = void(__stdcall*)( - void* context, - const InputBoxGameInputGamepadState* state); - - using InputBoxGameInputDeviceCallback = void(__stdcall*)( - void* context, - const char* deviceId, - uint64_t timestamp, - uint32_t currentStatus, - uint32_t previousStatus); - - struct CallbackRegistration - { - GameInputCallbackToken token = 0; - InputBoxGameInputReadingCallback readingCallback = nullptr; - InputBoxGameInputDeviceCallback deviceCallback = nullptr; - void* callbackContext = nullptr; - std::atomic active = true; - }; - - struct InputBoxGameInputContext - { - HMODULE module = nullptr; - uint32_t moduleKind = InputBoxGameInputModuleUnknown; - char modulePath[512]{}; - ComPtr gameInput; - std::vector devices; - std::vector> callbacks; - // 保護 GameInput COM 存取,以及銷毀期間的裝置、回呼與診斷狀態。 - // 持有此鎖時不得呼叫受控端回呼。 - SRWLOCK lock = SRWLOCK_INIT; - uint64_t lastReadingTimestamp = 0; - uint64_t missingReadingCount = 0; - uint64_t repeatedTimestampCount = 0; - uint64_t backwardTimestampCount = 0; - uint64_t deviceUnavailableRefreshCount = 0; - int32_t lastReadHResult = S_OK; - uint32_t lastReadDeviceStatus = 0; - }; - - using GameInputInitializeFn = HRESULT(WINAPI*)( - _In_ REFIID riid, - _COM_Outptr_ LPVOID* ppv); - - class SharedContextLock - { - public: - explicit SharedContextLock(SRWLOCK& lock) noexcept - : _lock(lock) - { - AcquireSRWLockShared(&_lock); - } - - SharedContextLock(const SharedContextLock&) = delete; - SharedContextLock& operator=(const SharedContextLock&) = delete; - - ~SharedContextLock() noexcept - { - ReleaseSRWLockShared(&_lock); - } - - private: - SRWLOCK& _lock; - }; - - class ExclusiveContextLock - { - public: - explicit ExclusiveContextLock(SRWLOCK& lock) noexcept - : _lock(lock) - { - AcquireSRWLockExclusive(&_lock); - } - - ExclusiveContextLock(const ExclusiveContextLock&) = delete; - ExclusiveContextLock& operator=(const ExclusiveContextLock&) = delete; - - ~ExclusiveContextLock() noexcept - { - ReleaseSRWLockExclusive(&_lock); - } - - private: - SRWLOCK& _lock; - }; - - bool CopyUtf8( - char* destination, - size_t destinationLength, - const char* value) noexcept - { - if (destinationLength == 0) - { - return false; - } - - const char* source = value != nullptr ? value : ""; - bool truncated = strlen(source) >= destinationLength; - strncpy_s(destination, destinationLength, source, _TRUNCATE); - - return truncated; - } - - bool CopyWideAsUtf8( - char* destination, - size_t destinationLength, - const wchar_t* value) noexcept - { - if (destinationLength == 0) - { - return false; - } - - destination[0] = '\0'; - - if (value == nullptr || - value[0] == L'\0') - { - return false; - } - - int required = WideCharToMultiByte( - CP_UTF8, - 0, - value, - -1, - nullptr, - 0, - nullptr, - nullptr); - - if (required <= 0) - { - return false; - } - - std::vector converted(static_cast(required)); - int written = WideCharToMultiByte( - CP_UTF8, - 0, - value, - -1, - converted.data(), - required, - nullptr, - nullptr); - - return written > 0 && - CopyUtf8(destination, destinationLength, converted.data()); - } - - bool CopyDeviceId( - char* destination, - size_t destinationLength, - const APP_LOCAL_DEVICE_ID& deviceId) noexcept - { - if (destinationLength < 2) - { - return true; - } - - destination[0] = '\0'; - - const auto* bytes = reinterpret_cast(&deviceId); - const size_t byteCount = std::min(sizeof(APP_LOCAL_DEVICE_ID), (destinationLength - 1) / 2); - - for (size_t i = 0; i < byteCount; i++) - { - sprintf_s(destination + (i * 2), destinationLength - (i * 2), "%02X", bytes[i]); - } - - return byteCount < sizeof(APP_LOCAL_DEVICE_ID); - } - - bool CopyGuid( - char* destination, - size_t destinationLength, - const GUID& value) noexcept - { - wchar_t buffer[39]{}; - int written = StringFromGUID2(value, buffer, static_cast(std::size(buffer))); - - if (written <= 0) - { - return CopyUtf8(destination, destinationLength, ""); - } - - return CopyWideAsUtf8(destination, destinationLength, buffer); - } - - void FillAbiSizes( - uint32_t& pointerSize, - uint32_t& shimInfoSize, - uint32_t& runtimeProbeInfoSize, - uint32_t& deviceInfoSize, - uint32_t& gamepadStateSize, - uint32_t& diagnosticsSnapshotSize) noexcept - { - pointerSize = static_cast(sizeof(void*)); - shimInfoSize = static_cast(sizeof(InputBoxGameInputShimInfo)); - runtimeProbeInfoSize = static_cast(sizeof(InputBoxGameInputRuntimeProbeInfo)); - deviceInfoSize = static_cast(sizeof(InputBoxGameInputDeviceInfo)); - gamepadStateSize = static_cast(sizeof(InputBoxGameInputGamepadState)); - diagnosticsSnapshotSize = static_cast(sizeof(InputBoxGameInputDiagnosticsSnapshot)); - } - - void FillShimInfoCommon(InputBoxGameInputShimInfo* info) noexcept - { - info->abiVersion = InputBoxGameInputShimAbiVersion; - info->gameInputApiVersion = GAMEINPUT_API_VERSION; - FillAbiSizes( - info->pointerSize, - info->shimInfoSize, - info->runtimeProbeInfoSize, - info->deviceInfoSize, - info->gamepadStateSize, - info->diagnosticsSnapshotSize); - } - - void FillRuntimeProbeCommon(InputBoxGameInputRuntimeProbeInfo* info) noexcept - { - info->abiVersion = InputBoxGameInputShimAbiVersion; - info->gameInputApiVersion = GAMEINPUT_API_VERSION; - FillAbiSizes( - info->pointerSize, - info->shimInfoSize, - info->runtimeProbeInfoSize, - info->deviceInfoSize, - info->gamepadStateSize, - info->diagnosticsSnapshotSize); - info->loadLibraryHResult = E_FAIL; - info->getProcAddressHResult = E_FAIL; - info->initializeHResult = E_FAIL; - info->finalHResult = E_FAIL; - } - - InputBoxGameInputVersion ToInputBoxVersion(const GameInputVersion& version) noexcept - { - return InputBoxGameInputVersion - { - version.major, - version.minor, - version.build, - version.revision - }; - } - - void FillGamepadState( - IGameInputReading* reading, - const GameInputGamepadState& source, - InputBoxGameInputGamepadState* destination) noexcept - { - destination->timestamp = reading != nullptr ? reading->GetTimestamp() : 0; - destination->inputKind = reading != nullptr ? static_cast(reading->GetInputKind()) : 0; - destination->buttons = static_cast(source.buttons); - destination->leftTrigger = source.leftTrigger; - destination->rightTrigger = source.rightTrigger; - destination->leftThumbstickX = source.leftThumbstickX; - destination->leftThumbstickY = source.leftThumbstickY; - destination->rightThumbstickX = source.rightThumbstickX; - destination->rightThumbstickY = source.rightThumbstickY; - } - - HRESULT FillDeviceInfo( - IGameInputDevice* device, - InputBoxGameInputDeviceInfo* destination) noexcept - { - if (device == nullptr || - destination == nullptr) - { - return E_INVALIDARG; - } - - const GameInputDeviceInfo* info = nullptr; - HRESULT hr = device->GetDeviceInfo(&info); - - if (FAILED(hr)) - { - return hr; - } - - *destination = {}; - - destination->vendorId = info->vendorId; - destination->productId = info->productId; - destination->revisionNumber = info->revisionNumber; - destination->usagePage = info->usage.page; - destination->usageId = info->usage.id; - destination->deviceFamily = static_cast(info->deviceFamily); - destination->supportedInput = static_cast(info->supportedInput); - destination->supportedRumbleMotors = static_cast(info->supportedRumbleMotors); - destination->supportedSystemButtons = static_cast(info->supportedSystemButtons); - destination->forceFeedbackMotorCount = info->forceFeedbackMotorCount; - destination->inputReportCount = info->inputReportCount; - destination->outputReportCount = info->outputReportCount; - destination->hardwareVersion = ToInputBoxVersion(info->hardwareVersion); - destination->firmwareVersion = ToInputBoxVersion(info->firmwareVersion); - - if (CopyDeviceId(destination->deviceId, sizeof(destination->deviceId), info->deviceId)) - { - destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedDeviceId; - } - - if (CopyDeviceId(destination->deviceRootId, sizeof(destination->deviceRootId), info->deviceRootId)) - { - destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedDeviceRootId; - } - - if (CopyGuid(destination->containerId, sizeof(destination->containerId), info->containerId)) - { - destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedContainerId; - } - - if (CopyUtf8(destination->displayName, sizeof(destination->displayName), info->displayName)) - { - destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedDisplayName; - } - - if (CopyUtf8(destination->pnpPath, sizeof(destination->pnpPath), info->pnpPath)) - { - destination->stringTruncationFlags |= InputBoxGameInputStringTruncatedPnpPath; - } - - if (info->gamepadInfo != nullptr) - { - destination->gamepadSupportedLayout = static_cast(info->gamepadInfo->supportedLayout); - destination->gamepadExtraButtonCount = info->gamepadInfo->extraButtonCount; - destination->gamepadExtraAxisCount = info->gamepadInfo->extraAxisCount; - } - - ComPtr mapper; - if (SUCCEEDED(device->CreateInputMapper(&mapper)) && - mapper != nullptr) - { - destination->hasInputMapper = 1; - } - - uint32_t extraButtonCount = 0; - if (SUCCEEDED(device->GetExtraButtonCount(GameInputKindGamepad, &extraButtonCount))) - { - destination->extraButtonCount = extraButtonCount; - destination->extraButtonIndexCount = std::min(extraButtonCount, InputBoxGameInputMaxExtraControlIndexes); - - if (destination->extraButtonIndexCount > 0) - { - std::vector indexes(extraButtonCount); - - if (SUCCEEDED(device->GetExtraButtonIndexes(GameInputKindGamepad, extraButtonCount, indexes.data()))) - { - std::copy_n(indexes.data(), destination->extraButtonIndexCount, destination->extraButtonIndexes); - } - else - { - destination->extraButtonIndexCount = 0; - } - } - } - - uint32_t extraAxisCount = 0; - if (SUCCEEDED(device->GetExtraAxisCount(GameInputKindGamepad, &extraAxisCount))) - { - destination->extraAxisCount = extraAxisCount; - destination->extraAxisIndexCount = std::min(extraAxisCount, InputBoxGameInputMaxExtraControlIndexes); - - if (destination->extraAxisIndexCount > 0) - { - std::vector indexes(extraAxisCount); - - if (SUCCEEDED(device->GetExtraAxisIndexes(GameInputKindGamepad, extraAxisCount, indexes.data()))) - { - std::copy_n(indexes.data(), destination->extraAxisIndexCount, destination->extraAxisIndexes); - } - else - { - destination->extraAxisIndexCount = 0; - } - } - } - - return S_OK; - } - - HRESULT TryLoadGameInputModule( - const wchar_t* path, - DWORD flags, - HMODULE* module, - GameInputInitializeFn* initialize, - HRESULT* loadLibraryHResult = nullptr, - DWORD* loadLibraryWin32Error = nullptr, - HRESULT* getProcAddressHResult = nullptr, - DWORD* getProcAddressWin32Error = nullptr) noexcept - { - *module = nullptr; - *initialize = nullptr; - if (loadLibraryHResult != nullptr) - { - *loadLibraryHResult = E_FAIL; - } - - if (loadLibraryWin32Error != nullptr) - { - *loadLibraryWin32Error = ERROR_SUCCESS; - } - - if (getProcAddressHResult != nullptr) - { - *getProcAddressHResult = E_FAIL; - } - - if (getProcAddressWin32Error != nullptr) - { - *getProcAddressWin32Error = ERROR_SUCCESS; - } - - HMODULE candidate = LoadLibraryExW(path, nullptr, flags); - - if (candidate == nullptr) - { - DWORD error = GetLastError(); - HRESULT hr = HRESULT_FROM_WIN32(error); - - if (loadLibraryHResult != nullptr) - { - *loadLibraryHResult = hr; - } - - if (loadLibraryWin32Error != nullptr) - { - *loadLibraryWin32Error = error; - } - - return hr; - } - - if (loadLibraryHResult != nullptr) - { - *loadLibraryHResult = S_OK; - } - - FARPROC proc = GetProcAddress(candidate, "GameInputInitialize"); - - if (proc == nullptr) - { - DWORD error = GetLastError(); - HRESULT hr = HRESULT_FROM_WIN32(error); - FreeLibrary(candidate); - - if (getProcAddressHResult != nullptr) - { - *getProcAddressHResult = hr; - } - - if (getProcAddressWin32Error != nullptr) - { - *getProcAddressWin32Error = error; - } - - return hr; - } - - if (getProcAddressHResult != nullptr) - { - *getProcAddressHResult = S_OK; - } - - *module = candidate; - *initialize = reinterpret_cast(proc); - - return S_OK; - } - - HRESULT TryGetRedistPathFromRegistry(std::wstring* path) noexcept - { - if (path == nullptr) - { - return E_INVALIDARG; - } - - path->clear(); - - std::array redistDir{}; - DWORD redistDirSize = static_cast(redistDir.size() * sizeof(wchar_t)); - - LSTATUS status = RegGetValueW( - HKEY_LOCAL_MACHINE, - L"SOFTWARE\\Microsoft\\GameInput", - L"RedistDir", - RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY, - nullptr, - redistDir.data(), - &redistDirSize); - - if (status != ERROR_SUCCESS) - { - return HRESULT_FROM_WIN32(status); - } - - *path = redistDir.data(); - - if (!path->empty() && - path->back() != L'\\') - { - *path += L'\\'; - } - - *path += L"GameInputRedist.dll"; - - return S_OK; - } - - HRESULT TryLoadGameInputCandidate( - const wchar_t* path, - DWORD flags, - uint32_t moduleKind, - HMODULE* module, - GameInputInitializeFn* initialize, - InputBoxGameInputRuntimeProbeInfo* probe) noexcept - { - // 保留每個候選載入嘗試的診斷資訊。GameInput 退避到 XInput 時, - // 這些欄位是判斷 DLL 缺失、export 缺失或初始化失敗的主要線索。 - if (probe != nullptr) - { - probe->attemptedModuleKind = moduleKind; - if (CopyWideAsUtf8(probe->attemptedModulePath, sizeof(probe->attemptedModulePath), path)) - { - probe->stringTruncationFlags |= InputBoxGameInputStringTruncatedAttemptedModulePath; - } - } - - HRESULT loadHr = E_FAIL; - HRESULT procHr = E_FAIL; - DWORD loadError = ERROR_SUCCESS; - DWORD procError = ERROR_SUCCESS; - HRESULT hr = TryLoadGameInputModule( - path, - flags, - module, - initialize, - &loadHr, - &loadError, - &procHr, - &procError); - - if (probe != nullptr) - { - probe->loadLibraryHResult = loadHr; - probe->loadLibraryWin32Error = loadError; - probe->getProcAddressHResult = procHr; - probe->getProcAddressWin32Error = procError; - - if (SUCCEEDED(hr)) - { - probe->loadedModuleKind = moduleKind; - - std::array loadedPath{}; - DWORD pathLength = GetModuleFileNameW(*module, loadedPath.data(), static_cast(loadedPath.size())); - - if (pathLength > 0 && - CopyWideAsUtf8(probe->loadedModulePath, sizeof(probe->loadedModulePath), loadedPath.data())) - { - probe->stringTruncationFlags |= InputBoxGameInputStringTruncatedLoadedModulePath; - } - } - } - - return hr; - } - - HRESULT LoadGameInput( - HMODULE* module, - uint32_t* moduleKind, - char* modulePath, - size_t modulePathLength, - IGameInput** gameInput, - InputBoxGameInputRuntimeProbeInfo* probe = nullptr) noexcept - { - if (probe != nullptr) - { - *probe = {}; - FillRuntimeProbeCommon(probe); - } - - *module = nullptr; - *moduleKind = InputBoxGameInputModuleUnknown; - CopyUtf8(modulePath, modulePathLength, ""); - *gameInput = nullptr; - - GameInputInitializeFn initialize = nullptr; - // 優先使用 System32 內的系統 GameInput。登錄檔 redist 路徑排在最後, - // 並使用 DLL_LOAD_DIR + SYSTEM32,避免相依 DLL 從目前工作目錄解析。 - HRESULT hr = TryLoadGameInputCandidate( - L"GameInput.dll", - LOAD_LIBRARY_SEARCH_SYSTEM32, - InputBoxGameInputModuleSystemGameInput, - module, - &initialize, - probe); - - if (SUCCEEDED(hr)) - { - *moduleKind = InputBoxGameInputModuleSystemGameInput; - } - - if (FAILED(hr)) - { - hr = TryLoadGameInputCandidate( - L"GameInputRedist.dll", - LOAD_LIBRARY_SEARCH_SYSTEM32, - InputBoxGameInputModuleSystemGameInputRedist, - module, - &initialize, - probe); - - if (SUCCEEDED(hr)) - { - *moduleKind = InputBoxGameInputModuleSystemGameInputRedist; - } - } - - if (FAILED(hr)) - { - std::wstring redistPath; - HRESULT pathHr = TryGetRedistPathFromRegistry(&redistPath); - - if (SUCCEEDED(pathHr)) - { - hr = TryLoadGameInputCandidate( - redistPath.c_str(), - LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32, - InputBoxGameInputModuleRegistryGameInputRedist, - module, - &initialize, - probe); - - if (SUCCEEDED(hr)) - { - *moduleKind = InputBoxGameInputModuleRegistryGameInputRedist; - } - } - else - { - hr = pathHr; - - if (probe != nullptr) - { - probe->attemptedModuleKind = InputBoxGameInputModuleRegistryGameInputRedist; - probe->loadLibraryHResult = pathHr; - probe->loadLibraryWin32Error = HRESULT_FACILITY(pathHr) == FACILITY_WIN32 - ? HRESULT_CODE(pathHr) - : ERROR_SUCCESS; - } - } - } - - if (FAILED(hr)) - { - if (probe != nullptr) - { - probe->finalHResult = hr; - } - - return hr; - } - - std::array path{}; - DWORD pathLength = GetModuleFileNameW(*module, path.data(), static_cast(path.size())); - if (pathLength > 0) - { - bool truncated = CopyWideAsUtf8(modulePath, modulePathLength, path.data()); - - if (probe != nullptr && - truncated) - { - probe->stringTruncationFlags |= InputBoxGameInputStringTruncatedLoadedModulePath; - } - } - - hr = initialize(IID_IGameInput, reinterpret_cast(gameInput)); - - if (probe != nullptr) - { - probe->initializeHResult = hr; - probe->initializeWin32Error = FAILED(hr) && HRESULT_FACILITY(hr) == FACILITY_WIN32 - ? HRESULT_CODE(hr) - : ERROR_SUCCESS; - probe->finalHResult = hr; - } - - if (FAILED(hr)) - { - FreeLibrary(*module); - *module = nullptr; - *moduleKind = InputBoxGameInputModuleUnknown; - CopyUtf8(modulePath, modulePathLength, ""); - } - else if (probe != nullptr) - { - probe->finalHResult = S_OK; - } - - return hr; - } - - DeviceEntry* FindDevice( - InputBoxGameInputContext* context, - const char* deviceId) noexcept - { - if (context == nullptr || - deviceId == nullptr) - { - return nullptr; - } - - for (DeviceEntry& entry : context->devices) - { - if (strcmp(entry.info.deviceId, deviceId) == 0) - { - return &entry; - } - } - - return nullptr; - } - - HRESULT CopyDeviceLocked( - InputBoxGameInputContext* context, - const char* deviceId, - ComPtr* device, - InputBoxGameInputDeviceInfo* info = nullptr) noexcept - { - // 呼叫端必須先持有 context->lock。回傳的 ComPtr 可讓裝置離開 vector 後仍存活, - // 但 vector 查找本身仍必須同步。 - if (context == nullptr || - deviceId == nullptr || - device == nullptr) - { - return E_INVALIDARG; - } - - device->Reset(); - - DeviceEntry* entry = FindDevice(context, deviceId); - - if (entry == nullptr) - { - return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); - } - - *device = entry->device; - - if (info != nullptr) - { - *info = entry->info; - } - - return S_OK; - } - - HRESULT CopyDevice( - InputBoxGameInputContext* context, - const char* deviceId, - ComPtr* device, - InputBoxGameInputDeviceInfo* info = nullptr) noexcept - { - if (context == nullptr) - { - return E_INVALIDARG; - } - - SharedContextLock lock(context->lock); - - return CopyDeviceLocked(context, deviceId, device, info); - } - - void RecordReadResultLocked( - InputBoxGameInputContext* context, - HRESULT hr, - uint32_t deviceStatus, - uint64_t timestamp) noexcept - { - if (context == nullptr) - { - return; - } - - // 這些計數器僅供診斷;受控端邊緣偵測與中立閘門 - // 不應依據它們改變行為。 - context->lastReadHResult = hr; - context->lastReadDeviceStatus = deviceStatus; - - if (FAILED(hr)) - { - if (hr == InputBoxGameInputNoReading) - { - context->missingReadingCount++; - } - - return; - } - - if (timestamp != 0) - { - if (context->lastReadingTimestamp == timestamp) - { - context->repeatedTimestampCount++; - } - else if (context->lastReadingTimestamp > timestamp) - { - context->backwardTimestampCount++; - } - - context->lastReadingTimestamp = timestamp; - } - } - - void RecordReadResult( - InputBoxGameInputContext* context, - HRESULT hr, - uint32_t deviceStatus, - uint64_t timestamp) noexcept - { - if (context == nullptr) - { - return; - } - - ExclusiveContextLock lock(context->lock); - RecordReadResultLocked(context, hr, deviceStatus, timestamp); - } - - void RecordDeviceUnavailableLocked(InputBoxGameInputContext* context, uint32_t status) noexcept - { - if (context == nullptr) - { - return; - } - - context->deviceUnavailableRefreshCount++; - context->lastReadDeviceStatus = status; - context->lastReadHResult = HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); - } - - void RecordDeviceUnavailable(InputBoxGameInputContext* context, uint32_t status) noexcept - { - if (context == nullptr) - { - return; - } - - ExclusiveContextLock lock(context->lock); - RecordDeviceUnavailableLocked(context, status); - } - - struct DeviceCollector - { - std::vector> devices; - }; - - void CALLBACK OnDeviceEnumerated( - _In_ GameInputCallbackToken, - _In_ void* context, - _In_ IGameInputDevice* device, - _In_ uint64_t, - _In_ GameInputDeviceStatus currentStatus, - _In_ GameInputDeviceStatus) - { - if (context == nullptr || - device == nullptr || - (currentStatus & GameInputDeviceConnected) == 0) - { - return; - } - - auto* collector = static_cast(context); - collector->devices.emplace_back(device); - } - - void CALLBACK OnReadingCallback( - _In_ GameInputCallbackToken, - _In_ void* context, - _In_ IGameInputReading* reading) - { - // 回呼路徑只作為喚醒與診斷的輔助通道。這裡只把 reading 轉成 POD, - // 是否重新整理則交由受控端輪詢迴圈決定。 - auto* registration = static_cast(context); - - if (registration == nullptr || - !registration->active.load() || - registration->readingCallback == nullptr || - reading == nullptr) - { - return; - } - - GameInputGamepadState gamepadState{}; - if (!reading->GetGamepadState(&gamepadState)) - { - return; - } - - InputBoxGameInputGamepadState state{}; - FillGamepadState(reading, gamepadState, &state); - - registration->readingCallback(registration->callbackContext, &state); - } - - void CALLBACK OnDeviceCallback( - _In_ GameInputCallbackToken, - _In_ void* context, - _In_ IGameInputDevice* device, - _In_ uint64_t timestamp, - _In_ GameInputDeviceStatus currentStatus, - _In_ GameInputDeviceStatus previousStatus) - { - // 不要把 IGameInputDevice 跨過 C ABI 傳給受控端。受控層只接收穩定識別與狀態位元, - // 再透過輪詢執行緒重新整理。 - auto* registration = static_cast(context); - - if (registration == nullptr || - !registration->active.load() || - registration->deviceCallback == nullptr) - { - return; - } - - char deviceId[65]{}; - - if (device != nullptr) - { - const GameInputDeviceInfo* info = nullptr; - if (SUCCEEDED(device->GetDeviceInfo(&info)) && - info != nullptr) - { - CopyDeviceId(deviceId, sizeof(deviceId), info->deviceId); - } - } - - registration->deviceCallback( - registration->callbackContext, - deviceId, - timestamp, - static_cast(currentStatus), - static_cast(previousStatus)); - } - - HRESULT ResolveOptionalDeviceLocked( - InputBoxGameInputContext* context, - const char* deviceId, - ComPtr* device) noexcept - { - if (device == nullptr) - { - return E_INVALIDARG; - } - - device->Reset(); - - if (deviceId == nullptr || - deviceId[0] == '\0') - { - return S_OK; - } - - return CopyDeviceLocked(context, deviceId, device); - } - - HRESULT ResolveOptionalDevice( - InputBoxGameInputContext* context, - const char* deviceId, - ComPtr* device) noexcept - { - if (context == nullptr) - { - return E_INVALIDARG; - } - - SharedContextLock lock(context->lock); - - return ResolveOptionalDeviceLocked(context, deviceId, device); - } -} - -extern "C" -{ - /** - * @brief 探測 GameInput runtime 是否可用,不建立持久 context。 - * - * 啟動期供 managed 層分類 LoadLibrary、GetProcAddress、GameInputInitialize 的 - * 失敗來源;呼叫後立即釋放暫時建立的 IGameInput 與 module 控制代碼,不會留下 - * callback 註冊。 - * - * @param[out] info 回傳的 runtime probe 資訊(ABI 版本、模組種類、嘗試路徑等); - * 不可為 nullptr。 - * @return Native HRESULT;S_OK 表示 runtime 可用,失敗時 info 仍會包含 - * 可供日誌觀察的部分欄位。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputProbeRuntime( - InputBoxGameInputRuntimeProbeInfo* info) noexcept - { - if (info == nullptr) - { - return E_INVALIDARG; - } - - HMODULE module = nullptr; - uint32_t moduleKind = InputBoxGameInputModuleUnknown; - char modulePath[512]{}; - ComPtr gameInput; - - // Probe 只建立短生命週期的 IGameInput,用來分類執行階段載入失敗原因。 - // 不得留下持久 context 或回呼註冊。 - HRESULT hr = LoadGameInput( - &module, - &moduleKind, - modulePath, - sizeof(modulePath), - &gameInput, - info); - - gameInput.Reset(); - - if (module != nullptr) - { - FreeLibrary(module); - } - - return hr; - } - - /** - * @brief 建立 GameInput shim context;成功時透過 out 指標傳回原生 context。 - * - * 內部呼叫 LoadGameInput 取得 IGameInput 與 module handle,並儲存於新配置的 - * InputBoxGameInputContext 中。Managed 端應將回傳的指標包裝為 - * SafeGameInputContextHandle,確保在 GC/Dispose 時呼叫 InputBoxGameInputDestroy。 - * - * @param[out] context 回傳新建立的 context 指標;失敗時設為 nullptr。 - * @return Native HRESULT;失敗時呼叫端應走退避 XInput 路徑。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputCreate( - InputBoxGameInputContext** context) noexcept - { - if (context == nullptr) - { - return E_INVALIDARG; - } - - *context = nullptr; - - auto created = new (std::nothrow) InputBoxGameInputContext(); - - if (created == nullptr) - { - return E_OUTOFMEMORY; - } - - HRESULT hr = LoadGameInput( - &created->module, - &created->moduleKind, - created->modulePath, - sizeof(created->modulePath), - &created->gameInput); - - if (FAILED(hr)) - { - delete created; - - return hr; - } - - *context = created; - - return S_OK; - } - - /** - * @brief 釋放 GameInput shim context 與所有附屬資源。 - * - * 釋放順序:先停止所有 callback(避免回呼觀察到半銷毀的原生狀態)→ 清空裝置 - * 與 callback 清單 → 重置 IGameInput → FreeLibrary 卸載已載入的 module → - * delete context。通常由 SafeGameInputContextHandle::ReleaseHandle 呼叫。 - * - * @param context 由 InputBoxGameInputCreate 建立的 context;nullptr 為合法輸入。 - */ - __declspec(dllexport) void __stdcall InputBoxGameInputDestroy( - InputBoxGameInputContext* context) noexcept - { - if (context == nullptr) - { - return; - } - - { - ExclusiveContextLock lock(context->lock); - - // 先停止回呼,再釋放 vector/module,避免回呼觀察到半銷毀的原生狀態。 - if (context->gameInput != nullptr) - { - for (const std::unique_ptr& registration : context->callbacks) - { - if (registration != nullptr && - registration->token != 0) - { - registration->active.store(false); - context->gameInput->StopCallback(registration->token); - context->gameInput->UnregisterCallback(registration->token); - } - } - } - - context->callbacks.clear(); - context->devices.clear(); - } - - context->gameInput.Reset(); - - if (context->module != nullptr) - { - FreeLibrary(context->module); - context->module = nullptr; - } - - delete context; - } - - /** - * @brief 取得 shim 自身與已載入 GameInput runtime 的版本與 ABI 資訊。 - * - * 回傳的結構包含 shim ABI 版本、GAMEINPUT_API_VERSION、pointer size、所有跨邊界 - * struct 的 size,以及實際載入的 GameInput module kind 與路徑。Managed 端必須以 - * Marshal.SizeOf() 比對 struct size,不符即視為 shim 載錯並退避 XInput。 - * - * @param context 已建立的 context;允許 nullptr,此時僅回傳 shim 本身資訊。 - * @param[out] info 回傳的 shim/runtime 資訊結構;不可為 nullptr。 - * @return Native HRESULT。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetShimInfo( - InputBoxGameInputContext* context, - InputBoxGameInputShimInfo* info) noexcept - { - if (info == nullptr) - { - return E_INVALIDARG; - } - - *info = {}; - FillShimInfoCommon(info); - - if (context != nullptr) - { - SharedContextLock lock(context->lock); - - info->loadedModuleKind = context->moduleKind; - CopyUtf8(info->loadedModulePath, sizeof(info->loadedModulePath), context->modulePath); - } - - return S_OK; - } - - /** - * @brief 取得 shim 累積的診斷快照(missing reading、stale/backward timestamp、 - * device unavailable refresh 等計數)。 - * - * 此快照僅供日誌、測試或未來診斷儀表使用,不可直接影響 edge detection、 - * Pause/Resume neutral gate 或任何 UI 命令。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @param[out] snapshot 回傳的診斷計數結構;不可為 nullptr。 - * @return Native HRESULT。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetDiagnosticsSnapshot( - InputBoxGameInputContext* context, - InputBoxGameInputDiagnosticsSnapshot* snapshot) noexcept - { - if (context == nullptr || - snapshot == nullptr) - { - return E_INVALIDARG; - } - - SharedContextLock lock(context->lock); - - snapshot->missingReadingCount = context->missingReadingCount; - snapshot->repeatedTimestampCount = context->repeatedTimestampCount; - snapshot->backwardTimestampCount = context->backwardTimestampCount; - snapshot->deviceUnavailableRefreshCount = context->deviceUnavailableRefreshCount; - snapshot->lastReadingTimestamp = context->lastReadingTimestamp; - snapshot->lastReadHResult = context->lastReadHResult; - snapshot->lastReadDeviceStatus = context->lastReadDeviceStatus; - snapshot->reserved = 0; - - return S_OK; - } - - /** - * @brief 設定 IGameInput::SetFocusPolicy,控制應用程式失去焦點時是否仍接收輸入。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @param policy GameInputFocusPolicy 位元旗標(以 uint32_t 跨 ABI 傳遞)。 - * @return Native HRESULT。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputSetFocusPolicy( - InputBoxGameInputContext* context, - uint32_t policy) noexcept - { - if (context == nullptr) - { - return E_INVALIDARG; - } - - SharedContextLock lock(context->lock); - - if (context->gameInput == nullptr) - { - return E_INVALIDARG; - } - - context->gameInput->SetFocusPolicy(static_cast(policy)); - - return S_OK; - } - - /** - * @brief 強制 shim 對 GameInput runtime 進行裝置重新列舉,並更新內部快取清單。 - * - * 使用 GameInputBlockingEnumeration 取得目前所有已連線 gamepad 的同步快照, - * 暫時註冊一個列舉用的 callback,完成後立即解除。整個流程持有 exclusive lock, - * 避免與 polling 端讀取裝置清單競態。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @return Native HRESULT;發生例外時回傳 E_FAIL。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputRefreshDevices( - InputBoxGameInputContext* context) noexcept - { - if (context == nullptr) - { - return E_INVALIDARG; - } - - try - { - ExclusiveContextLock lock(context->lock); - - if (context->gameInput == nullptr) - { - return E_INVALIDARG; - } - - DeviceCollector collector; - GameInputCallbackToken token = 0; - // 阻塞列舉提供 60 FPS 輪詢迴圈需要的完整快照。 - // 這個回呼註冊是暫時的,替換裝置清單前會先解除註冊。 - HRESULT hr = context->gameInput->RegisterDeviceCallback( - nullptr, - GameInputKindGamepad, - GameInputDeviceConnected, - GameInputBlockingEnumeration, - &collector, - OnDeviceEnumerated, - &token); - - if (FAILED(hr)) - { - return hr; - } - - context->gameInput->UnregisterCallback(token); - - std::vector refreshed; - refreshed.reserve(collector.devices.size()); - - for (const ComPtr& device : collector.devices) - { - DeviceEntry entry; - entry.device = device; - - hr = FillDeviceInfo(entry.device.Get(), &entry.info); - - if (SUCCEEDED(hr)) - { - refreshed.push_back(entry); - } - } - - context->devices = std::move(refreshed); - - return S_OK; - } - catch (...) - { - return E_FAIL; - } - } - - /** - * @brief 回傳 shim 目前所知的裝置總數(以 RefreshDevices 結果為準)。 - * - * @param context 已建立的 context;nullptr 時回傳 0。 - * @return 裝置數;以 int32_t 跨 ABI 傳遞。 - */ - __declspec(dllexport) int32_t __stdcall InputBoxGameInputGetDeviceCount( - InputBoxGameInputContext* context) noexcept - { - if (context == nullptr) - { - return 0; - } - - SharedContextLock lock(context->lock); - - return static_cast(context->devices.size()); - } - - /** - * @brief 依索引取得單一裝置的中繼資訊(VID/PID、displayName、capabilities、 - * 支援馬達等)。 - * - * 索引以與 InputBoxGameInputGetDeviceCount 同一回合的計數為準;不要與下一次 - * RefreshDevices 之間共用。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @param index 裝置索引(0-based);超出範圍會回傳 E_INVALIDARG。 - * @param[out] info 回傳的裝置資訊結構;不可為 nullptr。 - * @return Native HRESULT。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetDeviceInfo( - InputBoxGameInputContext* context, - int32_t index, - InputBoxGameInputDeviceInfo* info) noexcept - { - if (context == nullptr || - info == nullptr || - index < 0) - { - return E_INVALIDARG; - } - - SharedContextLock lock(context->lock); - - if (static_cast(index) >= context->devices.size()) - { - return E_INVALIDARG; - } - - *info = context->devices[static_cast(index)].info; - - return S_OK; - } - - /** - * @brief 依穩定 deviceId 查詢目前裝置狀態旗標。 - * - * 若裝置目前未連線會額外更新 deviceUnavailableRefreshCount 診斷計數,但不會 - * 自動觸發重列舉(由 managed 端 polling 邏輯依連續缺幀數決定)。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @param deviceId 穩定裝置識別字串(UTF-8);可為 nullptr,此時依 shim 規則選擇預設裝置。 - * @param[out] status 回傳 GameInputDeviceStatus 旗標;不可為 nullptr。 - * @return Native HRESULT。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputGetDeviceStatus( - InputBoxGameInputContext* context, - const char* deviceId, - uint32_t* status) noexcept - { - if (status == nullptr) - { - return E_INVALIDARG; - } - - *status = 0; - - if (context == nullptr) - { - return E_INVALIDARG; - } - - ExclusiveContextLock lock(context->lock); - - if (context->gameInput == nullptr) - { - return E_INVALIDARG; - } - - ComPtr device; - HRESULT hr = CopyDeviceLocked(context, deviceId, &device); - - if (FAILED(hr)) - { - return hr; - } - - *status = static_cast(device->GetDeviceStatus()); - - if ((*status & GameInputDeviceConnected) == 0) - { - RecordDeviceUnavailableLocked(context, *status); - } - - return S_OK; - } - - /** - * @brief 同步讀取指定裝置目前的 gamepad reading 快照。 - * - * 流程:取得裝置 → 檢查 GameInputDeviceConnected → GetCurrentReading → - * 抽取 GameInputGamepadState 並填入 InputBoxGameInputGamepadState(含 timestamp - * 與診斷 metadata)。途中任何失敗都會更新對應的 read result 診斷計數。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @param deviceId 穩定裝置識別字串(UTF-8);可為 nullptr。 - * @param[out] state 回傳的 gamepad 狀態快照;不可為 nullptr,內容會先被清零。 - * @return Native HRESULT;InputBoxGameInputNoReading 代表暫無可用 reading; - * ERROR_DEVICE_NOT_CONNECTED 代表裝置已斷線。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputReadGamepadState( - InputBoxGameInputContext* context, - const char* deviceId, - InputBoxGameInputGamepadState* state) noexcept - { - if (state == nullptr) - { - return E_INVALIDARG; - } - - *state = {}; - - if (context == nullptr) - { - return E_INVALIDARG; - } - - ExclusiveContextLock lock(context->lock); - - if (context->gameInput == nullptr) - { - return E_INVALIDARG; - } - - ComPtr device; - HRESULT hr = CopyDeviceLocked(context, deviceId, &device); - - if (FAILED(hr)) - { - RecordReadResultLocked(context, hr, 0, 0); - - return hr; - } - - uint32_t deviceStatus = static_cast(device->GetDeviceStatus()); - - if ((deviceStatus & GameInputDeviceConnected) == 0) - { - RecordDeviceUnavailableLocked(context, deviceStatus); - - return HRESULT_FROM_WIN32(ERROR_DEVICE_NOT_CONNECTED); - } - - ComPtr reading; - hr = context->gameInput->GetCurrentReading( - GameInputKindGamepad, - device.Get(), - &reading); - - if (FAILED(hr) || - reading == nullptr) - { - HRESULT result = FAILED(hr) ? hr : InputBoxGameInputNoReading; - RecordReadResultLocked(context, result, deviceStatus, 0); - - return result; - } - - GameInputGamepadState gamepadState{}; - - if (!reading->GetGamepadState(&gamepadState)) - { - RecordReadResultLocked(context, InputBoxGameInputNoReading, deviceStatus, 0); - - return InputBoxGameInputNoReading; - } - - FillGamepadState(reading.Get(), gamepadState, state); - RecordReadResultLocked(context, S_OK, deviceStatus, state->timestamp); - - return S_OK; - } - - /** - * @brief 註冊 reading callback;GameInput runtime 在推送新 gamepad reading 時 - * 通知 managed 端喚醒 MTA polling thread。 - * - * 註冊本身不持有 context lock 以避免 GameInput 在註冊期間呼叫回呼時造成死結; - * 但會在 token 發布前重新檢查 context 是否仍存活,避免 destroy 與註冊競態。 - * callback 僅可用於要求重新整理或喚醒診斷路徑,不得直接觸發 UI 或輸入命令。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @param deviceId 穩定裝置識別字串(UTF-8);nullptr 表示訂閱全部裝置。 - * @param kind 必須為 GameInputKindGamepad。 - * @param callback Managed 端建立、需 keep-alive 的回呼函式指標。 - * @param callbackContext 回呼時傳回的 user context(通常為 GCHandle)。 - * @param[out] callbackToken 回傳的回呼識別 token,用於 InputBoxGameInputUnregisterCallback。 - * @return Native HRESULT。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputRegisterReadingCallback( - InputBoxGameInputContext* context, - const char* deviceId, - uint32_t kind, - InputBoxGameInputReadingCallback callback, - void* callbackContext, - uint64_t* callbackToken) noexcept - { - if (context == nullptr || - kind != static_cast(GameInputKindGamepad) || - callback == nullptr || - callbackToken == nullptr) - { - return E_INVALIDARG; - } - - *callbackToken = 0; - - ComPtr gameInput; - ComPtr device; - - { - SharedContextLock lock(context->lock); - - if (context->gameInput == nullptr) - { - return E_INVALIDARG; - } - - // 在 shared lock 內複製 COM 參考,接著不持有 context lock 進行註冊。 - // GameInput 可能在註冊期間呼叫回呼。 - gameInput = context->gameInput; - HRESULT hr = ResolveOptionalDeviceLocked(context, deviceId, &device); - - if (FAILED(hr)) - { - return hr; - } - } - - auto registration = std::make_unique(); - registration->readingCallback = callback; - registration->callbackContext = callbackContext; - - GameInputCallbackToken token = 0; - HRESULT hr = gameInput->RegisterReadingCallback( - device.Get(), - static_cast(kind), - registration.get(), - OnReadingCallback, - &token); - - if (FAILED(hr)) - { - return hr; - } - - registration->token = token; - *callbackToken = token; - - { - ExclusiveContextLock lock(context->lock); - if (context->gameInput == nullptr) - { - // 原生註冊成功後 destroy 搶先完成。這裡立即解除註冊並回報失敗, - // 避免受控端保留這個 token。 - registration->active.store(false); - gameInput->StopCallback(token); - gameInput->UnregisterCallback(token); - *callbackToken = 0; - - return E_INVALIDARG; - } - - context->callbacks.push_back(std::move(registration)); - } - - return S_OK; - } - - /** - * @brief 註冊 device callback;裝置連線/斷線/狀態變更時通知 managed 端排程 - * 裝置重列舉。 - * - * 與 RegisterReadingCallback 相同的鎖規則:註冊期間不持有 context lock, - * 並在 token 發布前重新檢查 context 是否仍存活。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @param deviceId 穩定裝置識別字串(UTF-8);nullptr 表示訂閱全部裝置。 - * @param kind 必須為 GameInputKindGamepad。 - * @param statusFilter 關注的 GameInputDeviceStatus 旗標。 - * @param enumerationKind GameInputEnumerationKind(Blocking / Async 等)。 - * @param callback Managed 端建立、需 keep-alive 的回呼函式指標。 - * @param callbackContext 回呼時傳回的 user context(通常為 GCHandle)。 - * @param[out] callbackToken 回傳的回呼識別 token。 - * @return Native HRESULT。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputRegisterDeviceCallback( - InputBoxGameInputContext* context, - const char* deviceId, - uint32_t kind, - uint32_t statusFilter, - uint32_t enumerationKind, - InputBoxGameInputDeviceCallback callback, - void* callbackContext, - uint64_t* callbackToken) noexcept - { - if (context == nullptr || - kind != static_cast(GameInputKindGamepad) || - callback == nullptr || - callbackToken == nullptr) - { - return E_INVALIDARG; - } - - *callbackToken = 0; - - ComPtr gameInput; - ComPtr device; - - { - SharedContextLock lock(context->lock); - - if (context->gameInput == nullptr) - { - return E_INVALIDARG; - } - - // 參考 RegisterReadingCallback:回呼註冊不持有 context lock, - // 並且只在 context 仍存活時發布 token。 - gameInput = context->gameInput; - HRESULT hr = ResolveOptionalDeviceLocked(context, deviceId, &device); - - if (FAILED(hr)) - { - return hr; - } - } - - auto registration = std::make_unique(); - registration->deviceCallback = callback; - registration->callbackContext = callbackContext; - - GameInputCallbackToken token = 0; - HRESULT hr = gameInput->RegisterDeviceCallback( - device.Get(), - static_cast(kind), - static_cast(statusFilter), - static_cast(enumerationKind), - registration.get(), - OnDeviceCallback, - &token); - - if (FAILED(hr)) - { - return hr; - } - - registration->token = token; - *callbackToken = token; - - { - ExclusiveContextLock lock(context->lock); - if (context->gameInput == nullptr) - { - // context 銷毀與註冊發生競態;回傳前先停止並解除註冊。 - registration->active.store(false); - gameInput->StopCallback(token); - gameInput->UnregisterCallback(token); - *callbackToken = 0; - - return E_INVALIDARG; - } - - context->callbacks.push_back(std::move(registration)); - } - - return S_OK; - } - - /** - * @brief 註銷先前以 RegisterReadingCallback 或 RegisterDeviceCallback 取得的 - * callback token。 - * - * 流程:標記 registration 為非 active(讓正在執行的回呼盡快返回) → StopCallback - * → UnregisterCallback → 從 context 移除註冊紀錄。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @param callbackToken 先前回傳的 callback token(非 0)。 - * @return Native HRESULT;找不到對應註冊時回傳 HRESULT_FROM_WIN32(ERROR_NOT_FOUND)。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputUnregisterCallback( - InputBoxGameInputContext* context, - uint64_t callbackToken) noexcept - { - if (context == nullptr || - callbackToken == 0) - { - return E_INVALIDARG; - } - - ExclusiveContextLock lock(context->lock); - - if (context->gameInput == nullptr) - { - return E_INVALIDARG; - } - - auto found = std::find_if( - context->callbacks.begin(), - context->callbacks.end(), - [callbackToken](const std::unique_ptr& registration) - { - return registration != nullptr && - registration->token == callbackToken; - }); - - if (found == context->callbacks.end()) - { - return HRESULT_FROM_WIN32(ERROR_NOT_FOUND); - } - - (*found)->active.store(false); - context->gameInput->StopCallback(callbackToken); - context->gameInput->UnregisterCallback(callbackToken); - context->callbacks.erase(found); - - return S_OK; - } - - /** - * @brief 套用震動參數到指定裝置;四個馬達強度均為 [0.0, 1.0] 正規化值。 - * - * 不支援的馬達會被 GameInput runtime 忽略(例如 PC 控制器多半無 trigger 馬達); - * 由 managed 端依 GameInputRumbleMotors 旗標決定是否傳遞非零值。 - * - * @param context 已建立的 context;不可為 nullptr。 - * @param deviceId 穩定裝置識別字串(UTF-8);可為 nullptr。 - * @param lowFrequency 低頻主馬達強度。 - * @param highFrequency 高頻主馬達強度。 - * @param leftTrigger 左扳機馬達強度(不支援時忽略)。 - * @param rightTrigger 右扳機馬達強度(不支援時忽略)。 - * @return Native HRESULT。 - */ - __declspec(dllexport) HRESULT __stdcall InputBoxGameInputSetRumbleState( - InputBoxGameInputContext* context, - const char* deviceId, - float lowFrequency, - float highFrequency, - float leftTrigger, - float rightTrigger) noexcept - { - if (context == nullptr) - { - return E_INVALIDARG; - } - - SharedContextLock lock(context->lock); - - if (context->gameInput == nullptr) - { - return E_INVALIDARG; - } - - ComPtr device; - HRESULT hr = CopyDeviceLocked(context, deviceId, &device); - - if (FAILED(hr)) - { - return hr; - } - - GameInputRumbleParams rumble - { - lowFrequency, - highFrequency, - leftTrigger, - rightTrigger - }; - - device->SetRumbleState(&rumble); - - return S_OK; - } -} diff --git a/src/InputBox.GameInput.Native/README.md b/src/InputBox.GameInput.Native/README.md deleted file mode 100644 index ac3a83f..0000000 --- a/src/InputBox.GameInput.Native/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# 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` 會在 `win-x64` 發佈時把對應組態(`$(Configuration)`)的 shim 加入 `ResolvedFileToPublish`,並以 `AssetType=native` 納入 self-contained single-file bundle;建議使用 Release 組態發佈。 -- `InputBox.GameInput.Native.dll` 不應以 `NativeLibrary` item 發佈;該 item 不會保證進入 .NET single-file 的 `FilesToBundle` 清單。 -- `PublishSingleFile=true` 時必須同時設定 `IncludeNativeLibrariesForSelfExtract=true`,讓 bundled native shim 在執行階段由 .NET host 自解壓後載入。 -- CI 與 release 在原生 shim 建置後必須執行 `tools/Validate-GameInputNativeShim.ps1`,確認 `GameInputNativeMethods` 使用的 `InputBoxGameInput*` exports 全部存在。 -- 同一支驗證腳本也會呼叫 `InputBoxGameInputProbeRuntime` 做無硬體 smoke test;GameInput runtime 初始化可成功或失敗,但 probe 必須可安全回報 ABI,且 native 回報的指標大小與跨 ABI 結構大小必須與受控端 mirror 相符。 -- 若 GameInput runtime 可用,驗證腳本會再執行 native lifecycle stress smoke,反覆建立 context、註冊/解除回呼、重新整理裝置與銷毀 context。runtime 不可用時此壓力測試可略過,但 export/probe 驗證仍必須通過。 -- Release ZIP 不應包含可見的 `InputBox.GameInput.Native.dll` 側載 DLL,也不應包含 `gameinput.dll`。 -- ZIP 可包含 `redist/GameInputRedist.msi` 供使用者手動安裝;InputBox 不會自動執行安裝程式。 - -## 驗證 - -常用驗證: - -```powershell -dotnet build src/InputBox/InputBox.csproj --configuration Debug -dotnet test --project tests/InputBox.Tests/InputBox.Tests.csproj -.\tools\Validate-GameInputNativeShim.ps1 -NativeShimPath .\src\InputBox.GameInput.Native\bin\x64\Debug\InputBox.GameInput.Native.dll -ManagedSourcePath .\src\InputBox\Core\Input\GameInputNative.cs -``` - -修改 shim ABI、執行階段載入或發佈打包時,還要做 Release 發佈 / ZIP 試跑,確認單檔發佈、redist、授權聲明與禁止項目仍符合工作流程。 - -若變更涉及 GameInput 實體硬體行為,還應依 `docs/engineering/gameinput-hardware-verification.md` 執行手動抽測。建議觸發時機包含修改 shim、斷線偵測、rumble、升級 `Microsoft.GameInput` 或正式發佈前;這不是日常 PR 必跑項目,也不是 CI 關卡。 diff --git a/src/InputBox/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs index a8c97ee..3701043 100644 --- a/src/InputBox/Core/Input/GameInputGamepadController.cs +++ b/src/InputBox/Core/Input/GameInputGamepadController.cs @@ -3,6 +3,8 @@ using InputBox.Core.Feedback; using InputBox.Core.Services; using InputBox.Resources; +using InputWeave.GameInput; +using InputWeave.GameInput.Interop; using System.Collections.Concurrent; using System.Diagnostics; using System.Numerics; @@ -42,7 +44,7 @@ internal sealed partial class GameInputGamepadController : IGamepadController /// /// GameInput 實體 /// - private GameInput? _gameInput; + private GameInputClient? _gameInput; /// /// 目前使用的 GameInput 設備 @@ -64,15 +66,28 @@ internal sealed partial class GameInputGamepadController : IGamepadController /// private long _readingCallbackObservedTicks; + /// + /// 保護 GameInput runtime / reading 診斷計數器。 + /// + private readonly Lock _gameInputDiagnosticsLock = new(); + + private ulong _missingReadingCount; + private ulong _repeatedTimestampCount; + private ulong _backwardTimestampCount; + private ulong _deviceUnavailableRefreshCount; + private ulong _lastReadingTimestamp; + private int _lastReadHResult; + private uint _lastReadDeviceStatus; + /// /// 儲存 GameInput 讀取回呼註冊憑證 /// - private GameInput.GameInputCallbackRegistration? _readingCallbackReg; + private GameInputCallbackRegistration? _readingCallbackReg; /// /// 安全的事件佇列,用於暫存兩次 Timer Tick 之間的所有硬體輸入 /// - private readonly ConcurrentQueue _readingQueue = new(); + private readonly ConcurrentQueue _readingQueue = new(); /// /// 保護設備存取的鎖 @@ -82,7 +97,7 @@ internal sealed partial class GameInputGamepadController : IGamepadController /// /// GameInput 設備回呼註冊憑證 /// - private GameInput.GameInputCallbackRegistration? _deviceCallbackReg; + private GameInputCallbackRegistration? _deviceCallbackReg; /// /// 所有目前連接的設備清單(用於多控制器自動切換) @@ -110,9 +125,9 @@ internal sealed partial class GameInputGamepadController : IGamepadController private Task? _taskPolling; /// - /// 前一次的 GamepadStateSnapshot + /// 前一次的 GamepadReadingSnapshot /// - private GamepadStateSnapshot? _previousState; + private GamepadReadingSnapshot? _previousState; /// /// 前一次「包含搖桿模擬方向鍵」的最終按鍵狀態 @@ -833,7 +848,7 @@ private void SetupReadingCallback() { _readingCallbackReg = _gameInput.RegisterReadingCallback( _device, - GameInputKind.Gamepad, + GameInputKind.GameInputKindGamepad, (reading) => { if (!_isPaused && @@ -867,11 +882,22 @@ private async Task PollingLoopAsync(CancellationToken cancellationToken, TaskCom { if (_gameInput == null) { - _gameInput = GameInput.Create(); - _gameInput.SetFocusPolicy(GameInputFocusPolicy.Default); + if (!GameInputRuntime.TryProbe(out GameInputRuntimeProbeInfo initialProbeInfo) || + !initialProbeInfo.IsAvailable) + { + int hResult = initialProbeInfo.HResult < 0 ? + initialProbeInfo.HResult : + unchecked((int)0x80004005); + + throw new DllNotFoundException( + $"GameInput runtime is unavailable. hresult=0x{hResult:X8} win32={initialProbeInfo.Win32Error}"); + } + + _gameInput = GameInputClient.Create(); + _gameInput.SetFocusPolicy(GameInputFocusPolicy.GameInputDefaultFocusPolicy); - GameInputShimInfo shimInfo = _gameInput.ShimInfo; - LoggerService.LogInfo($"GameInputShim abi={shimInfo.AbiVersion} api={shimInfo.GameInputApiVersion} moduleKind={shimInfo.LoadedModuleKind} modulePath={shimInfo.LoadedModulePath}"); + GameInputRuntimeInfo runtimeInfo = GameInputRuntime.GetInfo(); + LoggerService.LogInfo($"GameInputRuntime/InputWeave loader={runtimeInfo.LoaderPolicy} available={runtimeInfo.IsAvailable} moduleKind={runtimeInfo.LoadedModuleKind} modulePath={runtimeInfo.LoadedModulePath} version={runtimeInfo.FileVersion}"); LogGameInputDiagnosticsSnapshot("init"); // 初始化時先直接列舉一次,再註冊非阻塞裝置回呼。 @@ -887,10 +913,10 @@ private async Task PollingLoopAsync(CancellationToken cancellationToken, TaskCom // 使用 None 避免在註冊當下再次為現有裝置執行同步枚舉, // 讓啟動成本只落在一次直接列舉上。 _deviceCallbackReg = _gameInput.RegisterDeviceCallback( - null, - GameInputKind.Gamepad, - GameInputDeviceStatus.Connected, - GameInputEnumerationKind.None, + null!, + GameInputKind.GameInputKindGamepad, + GameInputDeviceStatus.GameInputDeviceConnected, + GameInputEnumerationKind.GameInputNoEnumeration, (_, _, _, _) => { _needsRefresh = true; @@ -907,10 +933,15 @@ private async Task PollingLoopAsync(CancellationToken cancellationToken, TaskCom { LoggerService.LogException(ex, "GameInput 在背景執行緒初始化失敗"); - if (GameInput.TryProbeRuntime(out GameInputRuntimeProbeInfo probeInfo)) + if (TryProbeGameInputRuntime(out GameInputRuntimeProbeInfo probeInfo)) { + string candidateSummary = string.Join( + ";", + probeInfo.Candidates.Select(candidate => + $"{candidate.ModuleKind}:exists={candidate.Exists}:loadHr=0x{unchecked((uint)candidate.LoadHResult):X8}:procHr=0x{unchecked((uint)candidate.GetProcAddressHResult):X8}:win32={candidate.Win32Error}:path={candidate.ModulePath}")); + 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}"); + $"GameInputProbe/InputWeave available={probeInfo.IsAvailable} hr=0x{unchecked((uint)probeInfo.HResult):X8} win32={probeInfo.Win32Error} selectedKind={probeInfo.SelectedModuleKind} selectedPath={probeInfo.SelectedModulePath} selectedVersion={probeInfo.SelectedFileVersion} candidates={candidateSummary}"); } Debug.WriteLine($"GameInput 在背景執行緒初始化失敗:{ex.Message}"); @@ -996,42 +1027,171 @@ private async Task PollingLoopAsync(CancellationToken cancellationToken, TaskCom } /// - /// 以低噪音方式記錄 GameInput shim 診斷快照。 + /// 以低噪音方式記錄 GameInput 診斷快照。 /// /// 診斷觸發原因。 private void LogGameInputDiagnosticsSnapshot(string reason) - => LogGameInputDiagnosticsSnapshot(_gameInput, reason); + { + try + { + ulong missingReadingCount; + ulong repeatedTimestampCount; + ulong backwardTimestampCount; + ulong deviceUnavailableRefreshCount; + ulong lastReadingTimestamp; + int lastReadHResult; + uint lastReadDeviceStatus; + + lock (_gameInputDiagnosticsLock) + { + missingReadingCount = _missingReadingCount; + repeatedTimestampCount = _repeatedTimestampCount; + backwardTimestampCount = _backwardTimestampCount; + deviceUnavailableRefreshCount = _deviceUnavailableRefreshCount; + lastReadingTimestamp = _lastReadingTimestamp; + lastReadHResult = _lastReadHResult; + lastReadDeviceStatus = _lastReadDeviceStatus; + } + + LoggerService.LogInfo( + $"GameInputDiag reason={reason} missingReadings={missingReadingCount} repeatedTimestamps={repeatedTimestampCount} backwardTimestamps={backwardTimestampCount} deviceUnavailableRefreshes={deviceUnavailableRefreshCount} lastTimestamp={lastReadingTimestamp} lastReadHr=0x{unchecked((uint)lastReadHResult):X8} lastDeviceStatus=0x{lastReadDeviceStatus:X8}"); + } + catch (Exception ex) + { + Debug.WriteLine($"[GameInput] 讀取診斷快照失敗({reason},已忽略):{ex.Message}"); + } + } /// - /// 以低噪音方式記錄指定 GameInput context 的 native 診斷快照。 + /// 安全呼叫 InputWeave runtime probe。 /// - /// GameInput context。 - /// 診斷觸發原因。 - private static void LogGameInputDiagnosticsSnapshot(GameInput? gameInput, string reason) + /// GameInput runtime probe 結果。 + /// 若 probe 呼叫成功則回傳 true。 + private static bool TryProbeGameInputRuntime(out GameInputRuntimeProbeInfo probeInfo) { - if (gameInput == null) + try { - return; + return GameInputRuntime.TryProbe(out probeInfo); + } + catch (Exception ex) + { + Debug.WriteLine($"[GameInput] Runtime probe 失敗(已忽略):{ex.Message}"); + probeInfo = default; + + return false; + } + } + + /// + /// 記錄成功讀取的 GameInput snapshot 診斷資料。 + /// + private void RecordSuccessfulReading(GamepadReadingSnapshot snapshot, GameInputDevice device) + { + lock (_gameInputDiagnosticsLock) + { + if (_lastReadingTimestamp != 0) + { + if (snapshot.Timestamp == _lastReadingTimestamp) + { + _repeatedTimestampCount++; + } + else if (snapshot.Timestamp < _lastReadingTimestamp) + { + _backwardTimestampCount++; + } + } + + _lastReadingTimestamp = snapshot.Timestamp; + _lastReadHResult = 0; + _lastReadDeviceStatus = TryGetDeviceStatusValue(device); } + } + + /// + /// 記錄讀不到目前 GameInput reading 的診斷資料。 + /// + private void RecordMissingReading(Exception? ex, GameInputDevice device) + { + lock (_gameInputDiagnosticsLock) + { + _missingReadingCount++; + _lastReadHResult = ex == null ? unchecked((int)0x80070490) : GetErrorCode(ex); + _lastReadDeviceStatus = TryGetDeviceStatusValue(device); + } + } + + /// + /// 記錄 GameInput 讀取例外診斷資料。 + /// + private void RecordReadFailure(Exception ex) + { + lock (_gameInputDiagnosticsLock) + { + _lastReadHResult = GetErrorCode(ex); + } + } + + /// + /// 記錄 GameInput 裝置狀態診斷資料。 + /// + private void RecordDeviceStatus(GameInputDeviceStatus status) + { + lock (_gameInputDiagnosticsLock) + { + _lastReadDeviceStatus = Convert.ToUInt32(status); + + if ((status & GameInputDeviceStatus.GameInputDeviceConnected) == 0) + { + _deviceUnavailableRefreshCount++; + } + } + } + + private static int GetErrorCode(Exception ex) + => ex is GameInputException gameInputEx && gameInputEx.NativeErrorCode != 0 ? + gameInputEx.NativeErrorCode : + ex.HResult; + private static uint TryGetDeviceStatusValue(GameInputDevice device) + { 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}"); + return Convert.ToUInt32(device.Status); } catch (Exception ex) { - Debug.WriteLine($"[GameInput] 讀取診斷快照失敗({reason},已忽略):{ex.Message}"); + Debug.WriteLine($"[GameInput] 讀取裝置狀態診斷失敗(已忽略):{ex.Message}"); + + return 0; } } + /// + /// 取得 InputBox 用於跨列舉追蹤的穩定裝置識別。 + /// + private static string GetStableDeviceId(GameInputDeviceInfoSnapshot snapshot) + { + if (!string.IsNullOrWhiteSpace(snapshot.PnpPath)) + { + return snapshot.PnpPath; + } + + string displayName = GetDisplayName(snapshot); + + return $"VID_{snapshot.VendorId:X4} PID_{snapshot.ProductId:X4} {displayName}".Trim(); + } + + private static string GetDisplayName(GameInputDeviceInfoSnapshot snapshot) + => string.IsNullOrWhiteSpace(snapshot.DisplayName) ? + "Unknown Gamepad" : + snapshot.DisplayName; + /// /// 輪詢 /// private void Poll() { - GameInput? gameInput = _gameInput; + GameInputClient? gameInput = _gameInput; if (gameInput == null) { @@ -1099,9 +1259,10 @@ private void Poll() try { - GameInputDeviceStatus status = dev.GetDeviceStatus(); + GameInputDeviceStatus status = dev.Status; + RecordDeviceStatus(status); - if ((status & GameInputDeviceStatus.Connected) == 0) + if ((status & GameInputDeviceStatus.GameInputDeviceConnected) == 0) { Debug.WriteLine("[GameInput] 目前裝置狀態已非 Connected,立即重新整理裝置清單。"); LogGameInputDiagnosticsSnapshot("device-status-non-connected"); @@ -1110,11 +1271,12 @@ private void Poll() return; } - using GameInputReading? reading = gameInput.GetCurrentReading(GameInputKind.Gamepad, dev); - GamepadStateSnapshot? currentSnapshot = reading?.GetGamepadState(); + GamepadReadingSnapshot? currentSnapshot = gameInput.GetCurrentGamepad(dev); if (currentSnapshot == null) { + RecordMissingReading(null, dev); + if (HandleMissingCurrentReading()) { return; @@ -1123,6 +1285,7 @@ private void Poll() else { _missingReadingFrameCounter = 0; + RecordSuccessfulReading(currentSnapshot, dev); if (!_hasPreviousState || _previousState == null || @@ -1134,6 +1297,7 @@ private void Poll() } catch (Exception ex) { + RecordReadFailure(ex); Debug.WriteLine($"[GameInput] 讀取目前狀態失敗,稍後重新整理裝置:{ex.Message}"); if (HandleMissingCurrentReading()) @@ -1146,9 +1310,9 @@ private void Poll() // 分離「邊緣偵測」與「狀態維持」。 bool hasNewState = false; - GamepadStateSnapshot? latestSnapshot = null; + GamepadReadingSnapshot? latestSnapshot = null; - while (_readingQueue.TryDequeue(out GamepadStateSnapshot? state)) + while (_readingQueue.TryDequeue(out GamepadReadingSnapshot? state)) { if (state == null) { @@ -1191,8 +1355,8 @@ private void Poll() { UpdateCalibrationSnapshot(_previousState, config, true); - short correctedThumbLX = GetCorrectedLeftThumbShort(_previousState.LeftThumbstickX, _leftStickBiasX), - correctedThumbLY = GetCorrectedLeftThumbShort(_previousState.LeftThumbstickY, _leftStickBiasY); + short correctedThumbLX = GetCorrectedLeftThumbShort(_previousState.State.LeftThumbstickX, _leftStickBiasX), + correctedThumbLY = GetCorrectedLeftThumbShort(_previousState.State.LeftThumbstickY, _leftStickBiasY); UpdateMappedDirectionSuppression(correctedThumbLX, correctedThumbLY, config); @@ -1308,7 +1472,7 @@ private bool ShouldRefreshAfterMissingCurrentReading() /// 評估方向幽靈重入狀態:處理「狀態有變化但方向持續誤判」情境。 /// private void EvaluateDirectionalGhostState( - GamepadStateSnapshot state, + GamepadReadingSnapshot state, AppSettings.GamepadConfigSnapshot config) { if (!HasActiveDirectionalRepeat()) @@ -1332,7 +1496,7 @@ private void EvaluateDirectionalGhostState( return; } - LoggerService.LogInfo($"Gamepad.AntiStuckTriggered source=GameInput reason=ghost_reentry ghostFrames={_directionalGhostFrameCounter} dpadDir={_repeatDirection?.ToString() ?? "None"} rsDir={_rsRepeatDirection} lx={state.LeftThumbstickX:F4} ly={state.LeftThumbstickY:F4} rx={state.RightThumbstickX:F4} ry={state.RightThumbstickY:F4} biasLx={_leftStickBiasX:F4} biasLy={_leftStickBiasY:F4} biasRx={_rightStickBiasX:F4} biasRy={_rightStickBiasY:F4}"); + LoggerService.LogInfo($"Gamepad.AntiStuckTriggered source=GameInput reason=ghost_reentry ghostFrames={_directionalGhostFrameCounter} dpadDir={_repeatDirection?.ToString() ?? "None"} rsDir={_rsRepeatDirection} lx={state.State.LeftThumbstickX:F4} ly={state.State.LeftThumbstickY:F4} rx={state.State.RightThumbstickX:F4} ry={state.State.RightThumbstickY:F4} biasLx={_leftStickBiasX:F4} biasLy={_leftStickBiasY:F4} biasRx={_rightStickBiasX:F4} biasRy={_rightStickBiasY:F4}"); ResetDirectionalRepeatState(); } @@ -1343,28 +1507,28 @@ private void EvaluateDirectionalGhostState( /// 目前的遊戲控制器狀態快照 /// 遊戲控制器設定快照 private void ProcessEdgeTransitions( - GamepadStateSnapshot currentState, + GamepadReadingSnapshot currentState, AppSettings.GamepadConfigSnapshot config) { - GameInputGamepadButtons currentButtons = currentState.Buttons; + GameInputGamepadButtons currentButtons = currentState.State.Buttons; // 將搖桿原始值 clamp 至物理合法範圍 [-1.0, 1.0]。 // Log 顯示,在裝置受到物理撞擊時,GameInput 可能回報超出正常範圍的值 // (如 ly = -1.0514, -1.0630),這些值會干擾 bias 學習並造成假方向觸發。 // clamp 不影響正常操作(GameInput 已校正值接近但不超過 ±1.0)。 - float clampedLX = Math.Clamp(currentState.LeftThumbstickX, -1.0f, 1.0f), - clampedLY = Math.Clamp(currentState.LeftThumbstickY, -1.0f, 1.0f), - clampedRX = Math.Clamp(currentState.RightThumbstickX, -1.0f, 1.0f), - clampedRY = Math.Clamp(currentState.RightThumbstickY, -1.0f, 1.0f); + float clampedLX = Math.Clamp(currentState.State.LeftThumbstickX, -1.0f, 1.0f), + clampedLY = Math.Clamp(currentState.State.LeftThumbstickY, -1.0f, 1.0f), + clampedRX = Math.Clamp(currentState.State.RightThumbstickX, -1.0f, 1.0f), + clampedRY = Math.Clamp(currentState.State.RightThumbstickY, -1.0f, 1.0f); // D-Pad 按下時,左搖桿因機械耦合會產生 ±0.15~0.25 的偏移, // 低於 LeftStickBiasLearningThreshold(0.28),會被 EMA 誤學成中立位置。 // 在 D-Pad 作動期間暫停左搖桿 bias 學習以防止污染;右搖桿不受影響。 bool isDPadActive = (currentButtons & ( - GameInputGamepadButtons.DPadLeft | - GameInputGamepadButtons.DPadRight | - GameInputGamepadButtons.DPadUp | - GameInputGamepadButtons.DPadDown)) != 0; + GameInputGamepadButtons.GameInputGamepadDPadLeft | + GameInputGamepadButtons.GameInputGamepadDPadRight | + GameInputGamepadButtons.GameInputGamepadDPadUp | + GameInputGamepadButtons.GameInputGamepadDPadDown)) != 0; UpdateStickBias( clampedLX, @@ -1416,13 +1580,13 @@ private void ProcessEdgeTransitions( _hasPreviousState = true; bool hasActiveSignal = GamepadSignalEvaluator.IsActive( - hasButtons: currentState.Buttons != 0, - leftTrigger: currentState.LeftTrigger, - rightTrigger: currentState.RightTrigger, - leftThumbX: currentState.LeftThumbstickX - _leftStickBiasX, - leftThumbY: currentState.LeftThumbstickY - _leftStickBiasY, + hasButtons: currentState.State.Buttons != 0, + leftTrigger: currentState.State.LeftTrigger, + rightTrigger: currentState.State.RightTrigger, + leftThumbX: currentState.State.LeftThumbstickX - _leftStickBiasX, + leftThumbY: currentState.State.LeftThumbstickY - _leftStickBiasY, rightThumbX: correctedRightThumbX, - rightThumbY: Math.Clamp(currentState.RightThumbstickY, -1.0f, 1.0f) - _rightStickBiasY, + rightThumbY: Math.Clamp(currentState.State.RightThumbstickY, -1.0f, 1.0f) - _rightStickBiasY, threshold: config.ThumbDeadzoneExit / 32768f); if (hasActiveSignal) @@ -1447,33 +1611,33 @@ private void ProcessEdgeTransitions( /// /// 目前的遊戲控制器狀態快照 /// 若狀態為閒置,則回傳 true;否則回傳 false。 - private bool IsStateIdle(GamepadStateSnapshot state) + private bool IsStateIdle(GamepadReadingSnapshot state) { return _previousState != null && - state.Buttons == 0 && - _previousState.Buttons == 0 && + state.State.Buttons == 0 && + _previousState.State.Buttons == 0 && GamepadSignalEvaluator.IsIdle( hasButtons: false, - leftTrigger: state.LeftTrigger, - rightTrigger: state.RightTrigger, - leftThumbX: state.LeftThumbstickX, - leftThumbY: state.LeftThumbstickY, - rightThumbX: state.RightThumbstickX, - rightThumbY: state.RightThumbstickY, + leftTrigger: state.State.LeftTrigger, + rightTrigger: state.State.RightTrigger, + leftThumbX: state.State.LeftThumbstickX, + leftThumbY: state.State.LeftThumbstickY, + rightThumbX: state.State.RightThumbstickX, + rightThumbY: state.State.RightThumbstickY, 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); + private static bool HasSameInputValues(GamepadReadingSnapshot current, GamepadReadingSnapshot previous) + => current.State.Buttons == previous.State.Buttons && + current.State.LeftTrigger.Equals(previous.State.LeftTrigger) && + current.State.RightTrigger.Equals(previous.State.RightTrigger) && + current.State.LeftThumbstickX.Equals(previous.State.LeftThumbstickX) && + current.State.LeftThumbstickY.Equals(previous.State.LeftThumbstickY) && + current.State.RightThumbstickX.Equals(previous.State.RightThumbstickX) && + current.State.RightThumbstickY.Equals(previous.State.RightThumbstickY); /// /// 是否存在方向連發狀態(D-Pad 或 RS) @@ -1491,16 +1655,16 @@ private bool HasActiveDirectionalRepeat() /// 遊戲控制器設定快照 /// 若符合條件,則回傳 true;否則回傳 false。 private bool ShouldForceReleaseDirectionalRepeat( - GamepadStateSnapshot state, + GamepadReadingSnapshot state, AppSettings.GamepadConfigSnapshot config) { const GameInputGamepadButtons directionalFlags = - GameInputGamepadButtons.DPadLeft | - GameInputGamepadButtons.DPadRight | - GameInputGamepadButtons.DPadUp | - GameInputGamepadButtons.DPadDown; + GameInputGamepadButtons.GameInputGamepadDPadLeft | + GameInputGamepadButtons.GameInputGamepadDPadRight | + GameInputGamepadButtons.GameInputGamepadDPadUp | + GameInputGamepadButtons.GameInputGamepadDPadDown; - if ((state.Buttons & directionalFlags) != 0) + if ((state.State.Buttons & directionalFlags) != 0) { return false; } @@ -1512,9 +1676,9 @@ private bool ShouldForceReleaseDirectionalRepeat( softCenterThreshold = MathF.Min( enterThreshold, exitThreshold + MathF.Max(0.02f, (enterThreshold - exitThreshold) * 0.5f)), - correctedLeftX = state.LeftThumbstickX - _leftStickBiasX, - correctedLeftY = state.LeftThumbstickY - _leftStickBiasY, - correctedRightX = state.RightThumbstickX - _rightStickBiasX; + correctedLeftX = state.State.LeftThumbstickX - _leftStickBiasX, + correctedLeftY = state.State.LeftThumbstickY - _leftStickBiasY, + correctedRightX = state.State.RightThumbstickX - _rightStickBiasX; bool leftStickNearCenter = MathF.Abs(correctedLeftX) <= softCenterThreshold && @@ -1533,10 +1697,10 @@ private void ResetDirectionalRepeatState() { MappedGamepadDirection suppressedDirection = _repeatDirection switch { - GameInputGamepadButtons.DPadLeft => MappedGamepadDirection.Left, - GameInputGamepadButtons.DPadRight => MappedGamepadDirection.Right, - GameInputGamepadButtons.DPadUp => MappedGamepadDirection.Up, - GameInputGamepadButtons.DPadDown => MappedGamepadDirection.Down, + GameInputGamepadButtons.GameInputGamepadDPadLeft => MappedGamepadDirection.Left, + GameInputGamepadButtons.GameInputGamepadDPadRight => MappedGamepadDirection.Right, + GameInputGamepadButtons.GameInputGamepadDPadUp => MappedGamepadDirection.Up, + GameInputGamepadButtons.GameInputGamepadDPadDown => MappedGamepadDirection.Down, _ => MappedGamepadDirection.None, }; @@ -1552,10 +1716,10 @@ private void ResetDirectionalRepeatState() _directionalGhostFrameCounter = 0; _previousProcessedButtons &= ~( - GameInputGamepadButtons.DPadLeft | - GameInputGamepadButtons.DPadRight | - GameInputGamepadButtons.DPadUp | - GameInputGamepadButtons.DPadDown); + GameInputGamepadButtons.GameInputGamepadDPadLeft | + GameInputGamepadButtons.GameInputGamepadDPadRight | + GameInputGamepadButtons.GameInputGamepadDPadUp | + GameInputGamepadButtons.GameInputGamepadDPadDown); if (suppressedDirection != MappedGamepadDirection.None) { @@ -1602,7 +1766,7 @@ private void ResetTransientInputState(bool requireNeutralBeforeInput = true) /// private void TryFindDevice() { - GameInput? gameInput = _gameInput; + GameInputClient? gameInput = _gameInput; if (gameInput == null) { @@ -1617,7 +1781,9 @@ private void TryFindDevice() _needsRefresh = false; // 執行一次完整的列舉。 - IReadOnlyList devices = gameInput.EnumerateDevices(GameInputKind.Gamepad); + IReadOnlyList devices = gameInput.EnumerateDevices( + GameInputKind.GameInputKindGamepad, + GameInputDeviceStatus.GameInputDeviceConnected); // 1. 釋放清單中已不再存在或已非 Connected 的舊裝置代理(Zero-allocation 替換 LINQ Any)。 for (int i = _allDevices.Count - 1; i >= 0; i--) @@ -1626,7 +1792,8 @@ private void TryFindDevice() try { - GameInputDeviceInfo oldInfo = oldDev.GetDeviceInfo(); + GameInputDeviceInfoSnapshot oldInfo = oldDev.GetDeviceInfoSnapshot(); + string oldDeviceId = GetStableDeviceId(oldInfo); bool stillExists = IsDeviceConnected(oldDev); @@ -1643,7 +1810,7 @@ private void TryFindDevice() continue; } - if (candidate.GetDeviceInfo().DeviceId.Equals(oldInfo.DeviceId)) + if (GetStableDeviceId(candidate.GetDeviceInfoSnapshot()).Equals(oldDeviceId, StringComparison.Ordinal)) { stillExists = true; @@ -1704,13 +1871,14 @@ private void TryFindDevice() continue; } - GameInputDeviceInfo newInfo = newDev.GetDeviceInfo(); + GameInputDeviceInfoSnapshot newInfo = newDev.GetDeviceInfoSnapshot(); + string newDeviceId = GetStableDeviceId(newInfo); bool alreadyTracked = false; for (int j = 0; j < _allDevices.Count; j++) { - if (_allDevices[j].GetDeviceInfo().DeviceId.Equals(newInfo.DeviceId)) + if (GetStableDeviceId(_allDevices[j].GetDeviceInfoSnapshot()).Equals(newDeviceId, StringComparison.Ordinal)) { alreadyTracked = true; @@ -1786,7 +1954,7 @@ private static bool IsDeviceConnected(GameInputDevice device) { try { - return IsConnectedStatus(device.GetDeviceStatus()); + return IsConnectedStatus(device.Status); } catch (Exception ex) { @@ -1802,7 +1970,7 @@ private static bool IsDeviceConnected(GameInputDevice device) /// GameInput 裝置狀態旗標。 /// 若包含 Connected 則回傳 true。 private static bool IsConnectedStatus(GameInputDeviceStatus status) - => (status & GameInputDeviceStatus.Connected) != 0; + => (status & GameInputDeviceStatus.GameInputDeviceConnected) != 0; /// /// 掃描目前是否有其他正在活動的裝置,若有則切換 @@ -1810,7 +1978,7 @@ private static bool IsConnectedStatus(GameInputDeviceStatus status) /// 若有切換至其他裝置,則回傳 true;否則回傳 false。 private bool ScanForActiveDevice() { - GameInput? gameInput = _gameInput; + GameInputClient? gameInput = _gameInput; if (gameInput == null) { @@ -1833,21 +2001,19 @@ private bool ScanForActiveDevice() try { // 檢查其他控制器的活動狀態。 - using GameInputReading? reading = gameInput.GetCurrentReading(GameInputKind.Gamepad, otherDev); - - GamepadStateSnapshot? state = reading?.GetGamepadState(); + GamepadReadingSnapshot? state = gameInput.GetCurrentGamepad(otherDev); if (state != null) { // 若其他控制器有明顯的動作(按下任何按鈕、推動搖桿或按板機),則切換過去。 if (GamepadSignalEvaluator.IsActive( - hasButtons: state.Buttons != 0, - leftTrigger: state.LeftTrigger, - rightTrigger: state.RightTrigger, - leftThumbX: state.LeftThumbstickX, - leftThumbY: state.LeftThumbstickY, - rightThumbX: state.RightThumbstickX, - rightThumbY: state.RightThumbstickY, + hasButtons: state.State.Buttons != 0, + leftTrigger: state.State.LeftTrigger, + rightTrigger: state.State.RightTrigger, + leftThumbX: state.State.LeftThumbstickX, + leftThumbY: state.State.LeftThumbstickY, + rightThumbX: state.State.RightThumbstickX, + rightThumbY: state.State.RightThumbstickY, threshold: AppSettings.GameInputActiveThreshold)) { // 重置原本的按鍵狀態,避免按住的按鍵殘留給新控制器。 @@ -1890,16 +2056,16 @@ private bool ScanForActiveDevice() /// 目前的控制器狀態快照。 /// 目前的遊戲控制器設定快照。 private void PrimeResumeStateFromSnapshot( - GamepadStateSnapshot state, + GamepadReadingSnapshot state, AppSettings.GamepadConfigSnapshot config) { _previousState = state; - GameInputGamepadButtons currentButtons = state.Buttons; - short correctedThumbLX = GetCorrectedLeftThumbShort(state.LeftThumbstickX, _leftStickBiasX), - correctedThumbLY = GetCorrectedLeftThumbShort(state.LeftThumbstickY, _leftStickBiasY); - float correctedRightThumbX = Math.Clamp(state.RightThumbstickX, -1.0f, 1.0f) - _rightStickBiasX, - correctedRightThumbY = Math.Clamp(state.RightThumbstickY, -1.0f, 1.0f) - _rightStickBiasY; + GameInputGamepadButtons currentButtons = state.State.Buttons; + short correctedThumbLX = GetCorrectedLeftThumbShort(state.State.LeftThumbstickX, _leftStickBiasX), + correctedThumbLY = GetCorrectedLeftThumbShort(state.State.LeftThumbstickY, _leftStickBiasY); + float correctedRightThumbX = Math.Clamp(state.State.RightThumbstickX, -1.0f, 1.0f) - _rightStickBiasX, + correctedRightThumbY = Math.Clamp(state.State.RightThumbstickY, -1.0f, 1.0f) - _rightStickBiasY; UpdateMappedDirectionSuppression(correctedThumbLX, correctedThumbLY, config); ApplyStickToButtons(ref currentButtons, correctedThumbLX, correctedThumbLY, config); @@ -1907,20 +2073,20 @@ private void PrimeResumeStateFromSnapshot( _previousProcessedButtons = currentButtons; _hasPreviousState = true; - IsLeftShoulderHeld = currentButtons.HasFlag(GameInputGamepadButtons.LeftShoulder); - IsRightShoulderHeld = currentButtons.HasFlag(GameInputGamepadButtons.RightShoulder); - IsBackHeld = currentButtons.HasFlag(GameInputGamepadButtons.View); - IsBHeld = currentButtons.HasFlag(GameInputGamepadButtons.B); - IsXHeld = currentButtons.HasFlag(GameInputGamepadButtons.X); - IsLeftTriggerHeld = state.LeftTrigger > AppSettings.GameInputTriggerThreshold; - IsRightTriggerHeld = state.RightTrigger > AppSettings.GameInputTriggerThreshold; + IsLeftShoulderHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadLeftShoulder); + IsRightShoulderHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadRightShoulder); + IsBackHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadView); + IsBHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadB); + IsXHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadX); + IsLeftTriggerHeld = state.State.LeftTrigger > AppSettings.GameInputTriggerThreshold; + IsRightTriggerHeld = state.State.RightTrigger > AppSettings.GameInputTriggerThreshold; _requireNeutralBeforeInput = GamepadSignalEvaluator.IsActive( - hasButtons: state.Buttons != 0, - leftTrigger: state.LeftTrigger, - rightTrigger: state.RightTrigger, - leftThumbX: state.LeftThumbstickX - _leftStickBiasX, - leftThumbY: state.LeftThumbstickY - _leftStickBiasY, + hasButtons: state.State.Buttons != 0, + leftTrigger: state.State.LeftTrigger, + rightTrigger: state.State.RightTrigger, + leftThumbX: state.State.LeftThumbstickX - _leftStickBiasX, + leftThumbY: state.State.LeftThumbstickY - _leftStickBiasY, rightThumbX: correctedRightThumbX, rightThumbY: correctedRightThumbY, threshold: config.ThumbDeadzoneExit / 32768f); @@ -1946,22 +2112,17 @@ private void UpdateDeviceInfo() try { - GameInputDeviceInfo info = dev.GetDeviceInfo(); + GameInputDeviceInfoSnapshot info = dev.GetDeviceInfoSnapshot(); // 更新震動支援狀態。 - _supportsRumble = info.SupportedRumbleMotors != GameInputRumbleMotors.None; + _supportsRumble = info.SupportedRumbleMotors != 0; uint supportedMotorBits = Convert.ToUInt32(info.SupportedRumbleMotors); int supportedMotorCount = BitOperations.PopCount(supportedMotorBits); _rumbleThermalWeight = Math.Clamp(supportedMotorCount, 1, 4); // 更新裝置名稱與穩定識別資訊。Auto 判斷改以 VID/PID 與 GameInput // 原始顯示名稱作為穩定線索。 - string displayName = info.GetDisplayName(); - - if (string.IsNullOrWhiteSpace(displayName)) - { - displayName = "Unknown Gamepad"; - } + string displayName = GetDisplayName(info); _cachedDeviceName = displayName; _cachedDeviceIdentity = $"VID_{info.VendorId:X4} PID_{info.ProductId:X4} {displayName}".Trim(); @@ -1989,15 +2150,13 @@ private void InitializeDeviceState() try { - using GameInputReading? reading = _gameInput.GetCurrentReading(GameInputKind.Gamepad, _device); - - GamepadStateSnapshot? state = reading?.GetGamepadState(); + GamepadReadingSnapshot? state = _gameInput.GetCurrentGamepad(_device); if (state != null) { _previousState = state; - GameInputGamepadButtons currentButtons = state.Buttons; + GameInputGamepadButtons currentButtons = state.State.Buttons; // Bias warm-up:以初始讀值連續執行 50 次 EMA, // 使偏移估計在第一幀即接近真實值(收斂率 ≈ 99%), @@ -2007,7 +2166,7 @@ private void InitializeDeviceState() for (int i = 0; i < 50; i++) { // 暖機時不存在 D-Pad 操作情境,閘門固定傳 false。 - UpdateStickBias(state.LeftThumbstickX, state.LeftThumbstickY, state.RightThumbstickX, state.RightThumbstickY, isDPadActive: false); + UpdateStickBias(state.State.LeftThumbstickX, state.State.LeftThumbstickY, state.State.RightThumbstickX, state.State.RightThumbstickY, isDPadActive: false); } // 取得快照以確保初始化時死區校驗一致。 @@ -2111,14 +2270,14 @@ private static short GetCorrectedLeftThumbShort(float rawValue, float bias) /// 遊戲控制器狀態快照 /// 遊戲控制器設定快照 private void EmitMechanismHealthLog( - GamepadStateSnapshot state, + GamepadReadingSnapshot state, AppSettings.GamepadConfigSnapshot config) { #if DEBUG - float correctedLeftX = state.LeftThumbstickX - _leftStickBiasX, - correctedLeftY = state.LeftThumbstickY - _leftStickBiasY, - correctedRightX = state.RightThumbstickX - _rightStickBiasX, - correctedRightY = Math.Clamp(state.RightThumbstickY, -1.0f, 1.0f) - _rightStickBiasY, + float correctedLeftX = state.State.LeftThumbstickX - _leftStickBiasX, + correctedLeftY = state.State.LeftThumbstickY - _leftStickBiasY, + correctedRightX = state.State.RightThumbstickX - _rightStickBiasX, + correctedRightY = Math.Clamp(state.State.RightThumbstickY, -1.0f, 1.0f) - _rightStickBiasY, exitThreshold = config.ThumbDeadzoneExit / 32768f; bool hasSignificantInput = @@ -2234,13 +2393,13 @@ private void ApplyStickToButtons( AppSettings.GamepadConfigSnapshot config) { bool wasLeft = _hasPreviousState && - _previousProcessedButtons.HasFlag(GameInputGamepadButtons.DPadLeft), + _previousProcessedButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadDPadLeft), wasRight = _hasPreviousState && - _previousProcessedButtons.HasFlag(GameInputGamepadButtons.DPadRight), + _previousProcessedButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadDPadRight), wasUp = _hasPreviousState && - _previousProcessedButtons.HasFlag(GameInputGamepadButtons.DPadUp), + _previousProcessedButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadDPadUp), wasDown = _hasPreviousState && - _previousProcessedButtons.HasFlag(GameInputGamepadButtons.DPadDown); + _previousProcessedButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadDPadDown); int horizontalDirection = GamepadDeadzoneHysteresis.ResolveDirection( thumbLX, @@ -2252,12 +2411,12 @@ private void ApplyStickToButtons( if (horizontalDirection < 0 && !GamepadMappedDirectionGuard.IsSuppressed(_suppressedMappedDirections, MappedGamepadDirection.Left)) { - currentButtons |= GameInputGamepadButtons.DPadLeft; + currentButtons |= GameInputGamepadButtons.GameInputGamepadDPadLeft; } else if (horizontalDirection > 0 && !GamepadMappedDirectionGuard.IsSuppressed(_suppressedMappedDirections, MappedGamepadDirection.Right)) { - currentButtons |= GameInputGamepadButtons.DPadRight; + currentButtons |= GameInputGamepadButtons.GameInputGamepadDPadRight; } int verticalDirection = GamepadDeadzoneHysteresis.ResolveDirection( @@ -2270,12 +2429,12 @@ private void ApplyStickToButtons( if (verticalDirection < 0 && !GamepadMappedDirectionGuard.IsSuppressed(_suppressedMappedDirections, MappedGamepadDirection.Down)) { - currentButtons |= GameInputGamepadButtons.DPadDown; + currentButtons |= GameInputGamepadButtons.GameInputGamepadDPadDown; } else if (verticalDirection > 0 && !GamepadMappedDirectionGuard.IsSuppressed(_suppressedMappedDirections, MappedGamepadDirection.Up)) { - currentButtons |= GameInputGamepadButtons.DPadUp; + currentButtons |= GameInputGamepadButtons.GameInputGamepadDPadUp; } } @@ -2288,7 +2447,7 @@ private void ApplyStickToButtons( /// 遊戲控制器設定快照 private void DetectRisingEdge( GameInputGamepadButtons currentButtons, - GamepadStateSnapshot currentState, + GamepadReadingSnapshot currentState, float correctedRightThumbX, AppSettings.GamepadConfigSnapshot config) { @@ -2328,32 +2487,32 @@ static void DetectReleased( } // 更新按住狀態(使用 GameInput 原生旗標)。 - IsLeftShoulderHeld = currentButtons.HasFlag(GameInputGamepadButtons.LeftShoulder); - IsRightShoulderHeld = currentButtons.HasFlag(GameInputGamepadButtons.RightShoulder); - IsBackHeld = currentButtons.HasFlag(GameInputGamepadButtons.View); - IsBHeld = currentButtons.HasFlag(GameInputGamepadButtons.B); - IsXHeld = currentButtons.HasFlag(GameInputGamepadButtons.X); - IsLeftTriggerHeld = currentState.LeftTrigger > AppSettings.GameInputTriggerThreshold; - IsRightTriggerHeld = currentState.RightTrigger > AppSettings.GameInputTriggerThreshold; + IsLeftShoulderHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadLeftShoulder); + IsRightShoulderHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadRightShoulder); + IsBackHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadView); + IsBHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadB); + IsXHeld = currentButtons.HasFlag(GameInputGamepadButtons.GameInputGamepadX); + IsLeftTriggerHeld = currentState.State.LeftTrigger > AppSettings.GameInputTriggerThreshold; + IsRightTriggerHeld = currentState.State.RightTrigger > AppSettings.GameInputTriggerThreshold; // 偵測按下事件。 - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.DPadUp, UpPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.DPadDown, DownPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.DPadLeft, LeftPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.DPadRight, RightPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.LeftShoulder, LeftShoulderPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.RightShoulder, RightShoulderPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.Menu, StartPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.View, BackPressed); - DetectReleased(currentButtons, prevButtons, GameInputGamepadButtons.View, BackReleased); - DetectReleased(currentButtons, prevButtons, GameInputGamepadButtons.LeftShoulder, LeftShoulderReleased); - DetectReleased(currentButtons, prevButtons, GameInputGamepadButtons.RightShoulder, RightShoulderReleased); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.A, APressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.B, BPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.X, XPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.Y, YPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.LeftThumbstick, LSClickPressed); - DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.RightThumbstick, RSClickPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadDPadUp, UpPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadDPadDown, DownPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadDPadLeft, LeftPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadDPadRight, RightPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadLeftShoulder, LeftShoulderPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadRightShoulder, RightShoulderPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadMenu, StartPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadView, BackPressed); + DetectReleased(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadView, BackReleased); + DetectReleased(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadLeftShoulder, LeftShoulderReleased); + DetectReleased(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadRightShoulder, RightShoulderReleased); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadA, APressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadB, BPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadX, XPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadY, YPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadLeftThumbstick, LSClickPressed); + DetectRising(currentButtons, prevButtons, GameInputGamepadButtons.GameInputGamepadRightThumbstick, RSClickPressed); // 處理右搖桿(RS)虛擬按鍵偵測(使用 Hysteresis 邏輯以對抗漂移)。 float enterThreshold = config.ThumbDeadzoneEnter / 32768f, @@ -2386,7 +2545,7 @@ static void DetectReleased( } bool prevLtDown = _hasPreviousState && - _previousState!.LeftTrigger > AppSettings.GameInputTriggerThreshold; + _previousState!.State.LeftTrigger > AppSettings.GameInputTriggerThreshold; if (IsLeftTriggerHeld && !prevLtDown) @@ -2395,7 +2554,7 @@ static void DetectReleased( } bool prevRtDown = _hasPreviousState && - _previousState!.RightTrigger > AppSettings.GameInputTriggerThreshold; + _previousState!.State.RightTrigger > AppSettings.GameInputTriggerThreshold; if (IsRightTriggerHeld && !prevRtDown) @@ -2419,10 +2578,10 @@ private void HandleRepeat( #endif GameInputGamepadButtons? currentDir = - buttons.HasFlag(GameInputGamepadButtons.DPadLeft) ? GameInputGamepadButtons.DPadLeft : - buttons.HasFlag(GameInputGamepadButtons.DPadRight) ? GameInputGamepadButtons.DPadRight : - buttons.HasFlag(GameInputGamepadButtons.DPadUp) ? GameInputGamepadButtons.DPadUp : - buttons.HasFlag(GameInputGamepadButtons.DPadDown) ? GameInputGamepadButtons.DPadDown : + buttons.HasFlag(GameInputGamepadButtons.GameInputGamepadDPadLeft) ? GameInputGamepadButtons.GameInputGamepadDPadLeft : + buttons.HasFlag(GameInputGamepadButtons.GameInputGamepadDPadRight) ? GameInputGamepadButtons.GameInputGamepadDPadRight : + buttons.HasFlag(GameInputGamepadButtons.GameInputGamepadDPadUp) ? GameInputGamepadButtons.GameInputGamepadDPadUp : + buttons.HasFlag(GameInputGamepadButtons.GameInputGamepadDPadDown) ? GameInputGamepadButtons.GameInputGamepadDPadDown : null; if (GamepadRepeatStateMachine.AdvanceDirectionRepeat( @@ -2433,11 +2592,11 @@ private void HandleRepeat( config.RepeatInitialDelayFrames, config.RepeatIntervalFrames)) { - if (currentDir == GameInputGamepadButtons.DPadLeft) + if (currentDir == GameInputGamepadButtons.GameInputGamepadDPadLeft) { LeftRepeat?.Invoke(); } - else if (currentDir == GameInputGamepadButtons.DPadRight) + else if (currentDir == GameInputGamepadButtons.GameInputGamepadDPadRight) { RightRepeat?.Invoke(); @@ -2445,11 +2604,11 @@ private void HandleRepeat( emittedDpadRightRepeat = true; #endif } - else if (currentDir == GameInputGamepadButtons.DPadUp) + else if (currentDir == GameInputGamepadButtons.GameInputGamepadDPadUp) { UpRepeat?.Invoke(); } - else if (currentDir == GameInputGamepadButtons.DPadDown) + else if (currentDir == GameInputGamepadButtons.GameInputGamepadDPadDown) { DownRepeat?.Invoke(); } @@ -2534,7 +2693,7 @@ private void HandleRepeat( LoggerService.LogInfo($"Gamepad.RightRepeatStorm source=GameInput kind=DPad count={_dpadRightRepeatDiagnosticCounter} dpadDir={_repeatDirection?.ToString() ?? "None"}"); } } - else if (currentDir != GameInputGamepadButtons.DPadRight) + else if (currentDir != GameInputGamepadButtons.GameInputGamepadDPadRight) { _dpadRightRepeatDiagnosticCounter = 0; } @@ -2785,14 +2944,14 @@ public void Pause() /// 更新供校準視覺化使用的診斷快照。 /// private void UpdateCalibrationSnapshot( - GamepadStateSnapshot? state, + GamepadReadingSnapshot? state, AppSettings.GamepadConfigSnapshot config, bool isConnected) { - float rawLeftX = state?.LeftThumbstickX ?? 0f, - rawLeftY = state?.LeftThumbstickY ?? 0f, - rawRightX = state?.RightThumbstickX ?? 0f, - rawRightY = state?.RightThumbstickY ?? 0f; + float rawLeftX = state?.State.LeftThumbstickX ?? 0f, + rawLeftY = state?.State.LeftThumbstickY ?? 0f, + rawRightX = state?.State.RightThumbstickX ?? 0f, + rawRightY = state?.State.RightThumbstickY ?? 0f; _currentCalibrationSnapshot = new GamepadCalibrationSnapshot { @@ -2993,10 +3152,10 @@ public void StopVibration() private Task DisposeResourcesAsync() { // 捕獲目前實例以供背景執行緒釋放。 - GameInput? gameInput = _gameInput; + GameInputClient? gameInput = _gameInput; GameInputDevice? dev = _device; - GameInput.GameInputCallbackRegistration? deviceCallbackReg = _deviceCallbackReg; - GameInput.GameInputCallbackRegistration? readingCallbackReg = _readingCallbackReg; + GameInputCallbackRegistration? deviceCallbackReg = _deviceCallbackReg; + GameInputCallbackRegistration? readingCallbackReg = _readingCallbackReg; List devicesToDispose; lock (_deviceLock) @@ -3045,7 +3204,7 @@ private Task DisposeResourcesAsync() d.Dispose(); } - LogGameInputDiagnosticsSnapshot(gameInput, "dispose"); + LogGameInputDiagnosticsSnapshot("dispose"); gameInput?.Dispose(); } catch (Exception ex) diff --git a/src/InputBox/Core/Input/GameInputNative.cs b/src/InputBox/Core/Input/GameInputNative.cs deleted file mode 100644 index cf5eb4e..0000000 --- a/src/InputBox/Core/Input/GameInputNative.cs +++ /dev/null @@ -1,763 +0,0 @@ -using System.Runtime.InteropServices; -using System.Diagnostics; - -namespace InputBox.Core.Input; - -/// -/// GameInput native shim 的受控進入點,負責把窄版 C ABI 轉成專案內部型別。 -/// -internal sealed class GameInput : IDisposable -{ - private static readonly GameInputNativeReadingCallback ReadingCallback = OnNativeReadingCallback; - private static readonly GameInputNativeDeviceCallback DeviceCallback = OnNativeDeviceCallback; - private readonly SafeGameInputContextHandle _handle; - private GameInputDevice[] _devices = []; - - private GameInput(SafeGameInputContextHandle handle, GameInputShimInfo shimInfo) - { - _handle = handle; - ShimInfo = shimInfo; - } - - /// - /// 取得 native shim 與 GameInput runtime 載入診斷資訊。 - /// - public GameInputShimInfo ShimInfo { get; } - - /// - /// 建立 GameInput 內容;若 shim 或 runtime 不可用會丟出例外,交由 XInput 退避流程處理。 - /// - /// GameInput 受控包裝。 - public static GameInput Create() - { - int hr = GameInputNativeMethods.Create(out SafeGameInputContextHandle handle); - - if (hr < 0 || - handle.IsInvalid) - { - handle.Dispose(); - Marshal.ThrowExceptionForHR(hr < 0 ? hr : unchecked((int)0x80004005)); - } - - try - { - hr = GameInputNativeMethods.GetShimInfo(handle, out GameInputNativeShimInfo nativeShimInfo); - - if (hr < 0) - { - Marshal.ThrowExceptionForHR(hr); - } - - GameInputShimInfo shimInfo = nativeShimInfo.ToShimInfo(); - shimInfo.AbiInfo.ThrowIfMismatch(); - - return new GameInput(handle, shimInfo); - } - catch - { - handle.Dispose(); - - throw; - } - } - - /// - /// 嘗試取得 GameInput runtime 載入診斷資訊;此方法不會建立長生命週期 context。 - /// - /// runtime probe 結果。 - /// 若 probe export 可呼叫則回傳 true。 - public static bool TryProbeRuntime(out GameInputRuntimeProbeInfo probeInfo) - { - try - { - _ = GameInputNativeMethods.ProbeRuntime(out GameInputNativeRuntimeProbeInfo nativeProbeInfo); - probeInfo = nativeProbeInfo.ToProbeInfo(); - - return true; - } - catch (Exception ex) - { - Debug.WriteLine($"GameInput runtime probe failed: {ex.Message}"); - probeInfo = default; - - return false; - } - } - - /// - /// 設定 GameInput focus policy。 - /// - /// Focus policy。 - public void SetFocusPolicy(GameInputFocusPolicy policy) - { - _ = GameInputNativeMethods.SetFocusPolicy(_handle, (uint)policy); - } - - /// - /// 重新列舉目前可用的 GameInput gamepad 裝置。 - /// - /// 輸入類型;目前只支援 Gamepad。 - /// 目前裝置清單。 - public IReadOnlyList EnumerateDevices(GameInputKind kind) - { - if (kind != GameInputKind.Gamepad) - { - return []; - } - - int hr = GameInputNativeMethods.RefreshDevices(_handle); - - if (hr < 0) - { - Marshal.ThrowExceptionForHR(hr); - } - - int count = GameInputNativeMethods.GetDeviceCount(_handle); - GameInputDevice[] devices = new GameInputDevice[count]; - - for (int i = 0; i < count; i++) - { - hr = GameInputNativeMethods.GetDeviceInfo(_handle, i, out GameInputNativeDeviceInfo nativeInfo); - - if (hr < 0) - { - Marshal.ThrowExceptionForHR(hr); - } - - devices[i] = new GameInputDevice(this, nativeInfo.ToDeviceInfo()); - } - - _devices = devices; - - return devices; - } - - /// - /// 取得指定裝置目前最新的 gamepad 狀態。 - /// - /// 輸入類型;目前只支援 Gamepad。 - /// 目標裝置。 - /// 成功讀取時回傳 reading,否則回傳 null。 - public GameInputReading? GetCurrentReading(GameInputKind kind, GameInputDevice device) - { - if (kind != GameInputKind.Gamepad || - device.Owner != this) - { - return null; - } - - int hr = GameInputNativeMethods.ReadGamepadState( - _handle, - device.DeviceInfo.DeviceId, - out GameInputGamepadState state); - - return hr >= 0 ? new GameInputReading(new GamepadStateSnapshot(state)) : null; - } - - /// - /// 註冊讀取回呼。控制器仍以 60 FPS 輪詢為主,此回呼只作為輔助訊號來源。 - /// - public GameInputCallbackRegistration RegisterReadingCallback( - GameInputDevice device, - GameInputKind kind, - Action callback) - { - if (device.Owner != this || - kind != GameInputKind.Gamepad) - { - return new GameInputCallbackRegistration(); - } - - var callbackState = new ReadingCallbackState(callback); - GCHandle callbackStateHandle = GCHandle.Alloc(callbackState); - - int hr = GameInputNativeMethods.RegisterReadingCallback( - _handle, - device.DeviceInfo.DeviceId, - (uint)kind, - ReadingCallback, - GCHandle.ToIntPtr(callbackStateHandle), - out ulong callbackToken); - - if (hr < 0) - { - callbackStateHandle.Free(); - Marshal.ThrowExceptionForHR(hr); - } - - return new GameInputCallbackRegistration(_handle, callbackToken, callbackStateHandle); - } - - /// - /// 註冊裝置回呼,用於要求輪詢執行緒重新整理裝置清單。 - /// - public GameInputCallbackRegistration RegisterDeviceCallback( - GameInputDevice? device, - GameInputKind kind, - GameInputDeviceStatus statusFilter, - GameInputEnumerationKind enumerationKind, - Action callback) - { - if (kind != GameInputKind.Gamepad) - { - return new GameInputCallbackRegistration(); - } - - var callbackState = new DeviceCallbackState(this, callback); - GCHandle callbackStateHandle = GCHandle.Alloc(callbackState); - - int hr = GameInputNativeMethods.RegisterDeviceCallback( - _handle, - device?.DeviceInfo.DeviceId, - (uint)kind, - (uint)statusFilter, - (uint)enumerationKind, - DeviceCallback, - GCHandle.ToIntPtr(callbackStateHandle), - out ulong callbackToken); - - if (hr < 0) - { - callbackStateHandle.Free(); - Marshal.ThrowExceptionForHR(hr); - } - - return new GameInputCallbackRegistration(_handle, callbackToken, callbackStateHandle); - } - - internal void SetRumbleState(GameInputDevice device, GameInputRumbleParams rumble) - { - if (device.Owner != this) - { - return; - } - - _ = GameInputNativeMethods.SetRumbleState( - _handle, - device.DeviceInfo.DeviceId, - rumble.LowFrequency, - rumble.HighFrequency, - rumble.LeftTrigger, - rumble.RightTrigger); - } - - internal GameInputDeviceStatus GetDeviceStatus(GameInputDevice device) - { - if (device.Owner != this) - { - return 0; - } - - int hr = GameInputNativeMethods.GetDeviceStatus( - _handle, - device.DeviceInfo.DeviceId, - out uint status); - - return hr >= 0 ? (GameInputDeviceStatus)status : 0; - } - - internal GameInputDiagnosticsSnapshot GetDiagnosticsSnapshot() - { - int hr = GameInputNativeMethods.GetDiagnosticsSnapshot( - _handle, - out GameInputNativeDiagnosticsSnapshot snapshot); - - if (hr < 0) - { - Marshal.ThrowExceptionForHR(hr); - } - - return snapshot.ToDiagnosticsSnapshot(); - } - - /// - /// 釋放 GameInput 內容。 - /// - public void Dispose() - { - _devices = []; - _handle.Dispose(); - } - - private GameInputDevice? FindDeviceById(string deviceId) - { - if (string.IsNullOrWhiteSpace(deviceId)) - { - return null; - } - - GameInputDevice[] devices = _devices; - - for (int i = 0; i < devices.Length; i++) - { - if (devices[i].DeviceInfo.DeviceId.Equals(deviceId, StringComparison.Ordinal)) - { - return devices[i]; - } - } - - return null; - } - - private static void OnNativeReadingCallback(nint callbackContext, ref GameInputGamepadState state) - { - try - { - if (GCHandle.FromIntPtr(callbackContext).Target is ReadingCallbackState callbackState) - { - callbackState.Callback(new GameInputReading(new GamepadStateSnapshot(state))); - } - } - catch (Exception ex) - { - Debug.WriteLine($"GameInput native reading callback failed: {ex.Message}"); - } - } - - private static void OnNativeDeviceCallback( - nint callbackContext, - nint deviceId, - ulong timestamp, - uint currentStatus, - uint previousStatus) - { - try - { - if (GCHandle.FromIntPtr(callbackContext).Target is not DeviceCallbackState callbackState) - { - return; - } - - string parsedDeviceId = Marshal.PtrToStringUTF8(deviceId) ?? string.Empty; - GameInputDevice? device = callbackState.Owner.FindDeviceById(parsedDeviceId); - - callbackState.Callback( - device, - timestamp, - (GameInputDeviceStatus)currentStatus, - (GameInputDeviceStatus)previousStatus); - } - catch (Exception ex) - { - Debug.WriteLine($"GameInput native device callback failed: {ex.Message}"); - } - } - - private sealed class ReadingCallbackState(Action callback) - { - public Action Callback { get; } = callback; - } - - private sealed class DeviceCallbackState( - GameInput owner, - Action callback) - { - public GameInput Owner { get; } = owner; - - public Action Callback { get; } = callback; - } - - /// - /// GameInput 回呼註冊憑證。 - /// - internal sealed class GameInputCallbackRegistration : IDisposable - { - private readonly SafeGameInputContextHandle? _handle; - private readonly ulong _callbackToken; - private GCHandle _callbackStateHandle; - private int _disposed; - - public GameInputCallbackRegistration() - { - - } - - internal GameInputCallbackRegistration( - SafeGameInputContextHandle handle, - ulong callbackToken, - GCHandle callbackStateHandle) - { - _handle = handle; - _callbackToken = callbackToken; - _callbackStateHandle = callbackStateHandle; - } - - /// - /// 釋放回呼註冊。 - /// - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - try - { - if (_handle != null && - !_handle.IsInvalid && - _callbackToken != 0) - { - _ = GameInputNativeMethods.UnregisterCallback(_handle, _callbackToken); - } - } - catch (Exception ex) - { - Debug.WriteLine($"GameInput unregister callback failed: {ex.Message}"); - } - finally - { - if (_callbackStateHandle.IsAllocated) - { - _callbackStateHandle.Free(); - } - } - - } - } -} - -/// -/// GameInput 裝置包裝。 -/// -internal sealed class GameInputDevice : IDisposable -{ - internal GameInputDevice(GameInput owner, GameInputDeviceInfo deviceInfo) - { - Owner = owner; - DeviceInfo = deviceInfo; - } - - internal GameInput Owner { get; } - - internal GameInputDeviceInfo DeviceInfo { get; } - - /// - /// 取得裝置資訊。 - /// - /// 裝置資訊快照。 - public GameInputDeviceInfo GetDeviceInfo() => DeviceInfo; - - /// - /// 取得目前裝置狀態。 - /// - /// 目前裝置狀態。 - public GameInputDeviceStatus GetDeviceStatus() - => Owner.GetDeviceStatus(this); - - /// - /// 設定震動狀態。 - /// - /// 震動參數。 - public void SetRumbleState(GameInputRumbleParams rumble) - => Owner.SetRumbleState(this, rumble); - - /// - /// 釋放裝置包裝。裝置生命週期由 native context 管理,因此這裡不釋放底層實體。 - /// - public void Dispose() - { - - } -} - -/// -/// GameInput reading 包裝。 -/// -internal sealed class GameInputReading : IDisposable -{ - private readonly GamepadStateSnapshot _state; - - internal GameInputReading(GamepadStateSnapshot state) - { - _state = state; - } - - /// - /// 取得 gamepad 狀態快照。 - /// - /// gamepad 狀態快照。 - public GamepadStateSnapshot GetGamepadState() => _state; - - /// - /// 釋放 reading 包裝。 - /// - public void Dispose() - { - - } -} - -/// -/// 安全釋放 native GameInput context。 -/// -internal sealed class SafeGameInputContextHandle : SafeHandle -{ - private SafeGameInputContextHandle() - : base(IntPtr.Zero, ownsHandle: true) - { - - } - - /// - /// 判斷 handle 是否無效。 - /// - public override bool IsInvalid => handle == IntPtr.Zero; - - /// - /// 釋放 native context。 - /// - /// 若釋放成功則回傳 true。 - protected override bool ReleaseHandle() - { - GameInputNativeMethods.Destroy(handle); - - return true; - } -} - -/// -/// GameInput native shim P/Invoke 宣告。集中對應 InputBox.GameInput.Native -/// shim 匯出的 C ABI;所有方法均在 MTA 背景輪詢執行緒呼叫,回傳值為 native HRESULT。 -/// -internal static partial class GameInputNativeMethods -{ - /// - /// Native shim DLL 名稱;由 .NET DllImport 依 - /// 規則於應用程式目錄與使用者目錄中搜尋。 - /// - private const string NativeLibraryName = "InputBox.GameInput.Native"; - - /// - /// 探測 GameInput runtime 載入狀態,不建立持久 context。供啟動期分類 - /// LoadLibrary、GetProcAddress、GameInputInitialize 的失敗來源使用。 - /// - /// 回傳的探測資訊(ABI 版本、模組種類、嘗試/實際載入路徑等)。 - /// Native HRESULT;S_OK 表示 runtime 可用。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputProbeRuntime")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int ProbeRuntime(out GameInputNativeRuntimeProbeInfo info); - - /// - /// 建立 GameInput shim context;成功時將原生指標包裝為 , - /// 由 SafeHandle 在 finalize/dispose 時呼叫對應的 。 - /// - /// 回傳的 context SafeHandle;失敗時為 invalid handle。 - /// Native HRESULT;失敗時呼叫端應走退避 XInput 路徑。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputCreate")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int Create(out SafeGameInputContextHandle context); - - /// - /// 釋放 native context;通常由 - /// 自動呼叫,不應由業務程式碼直接呼叫。 - /// - /// 原生 context 指標。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputDestroy")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern void Destroy(nint context); - - /// - /// 取得 shim 自身與已載入 GameInput runtime 的版本資訊,包含 ABI 版本、跨邊界 struct 大小、 - /// 已載入的模組種類與路徑,供 managed layer 用 驗證 ABI。 - /// - /// 已建立的 GameInput context。 - /// 回傳的 shim/runtime 版本資訊結構。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetShimInfo")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int GetShimInfo( - SafeGameInputContextHandle context, - out GameInputNativeShimInfo info); - - /// - /// 取得 shim 內部累積的診斷快照(字串截斷旗標、timestamp stale、missing reading、 - /// device zombie refresh 等計數),僅供日誌與測試使用,不可改變按鍵語意。 - /// - /// 已建立的 GameInput context。 - /// 回傳的診斷計數快照。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDiagnosticsSnapshot")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int GetDiagnosticsSnapshot( - SafeGameInputContextHandle context, - out GameInputNativeDiagnosticsSnapshot snapshot); - - /// - /// 設定 IGameInput::SetFocusPolicy,控制應用程式失去焦點時是否仍接收輸入。 - /// - /// 已建立的 GameInput context。 - /// GameInputFocusPolicy 位元旗標。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputSetFocusPolicy")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int SetFocusPolicy(SafeGameInputContextHandle context, uint policy); - - /// - /// 強制 shim 對 GameInput runtime 進行裝置重新列舉;用於連線/斷線回呼觀察到變更後的補抓。 - /// - /// 已建立的 GameInput context。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputRefreshDevices")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int RefreshDevices(SafeGameInputContextHandle context); - - /// - /// 回傳 shim 目前所知的裝置總數(含已連線與暫存中尚未確認斷線的裝置)。 - /// - /// 已建立的 GameInput context。 - /// 裝置數;負值代表錯誤。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDeviceCount")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int GetDeviceCount(SafeGameInputContextHandle context); - - /// - /// 依索引取得單一裝置的中繼資訊(VID/PID、displayName、capabilities、支援馬達等), - /// 索引以 同一回合的計數為準。 - /// - /// 已建立的 GameInput context。 - /// 裝置索引(0-based)。 - /// 回傳的裝置資訊結構。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDeviceInfo")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int GetDeviceInfo( - SafeGameInputContextHandle context, - int index, - out GameInputNativeDeviceInfo info); - - /// - /// 依穩定 deviceId 查詢目前裝置狀態旗標(Connected / Synced / Wireless 等)。 - /// - /// 已建立的 GameInput context。 - /// 穩定裝置識別字串(UTF-8)。 - /// 回傳的 GameInputDeviceStatus 旗標。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputGetDeviceStatus")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int GetDeviceStatus( - SafeGameInputContextHandle context, - [MarshalAs(UnmanagedType.LPUTF8Str)] string deviceId, - out uint status); - - /// - /// 同步讀取指定裝置目前 gamepad reading 快照;用於 polling 與 Resume 預同步。 - /// - /// 已建立的 GameInput context。 - /// 穩定裝置識別字串(UTF-8)。 - /// 回傳的 gamepad 狀態快照。 - /// Native HRESULT;InputBoxGameInputNoReading 代表暫無 reading。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputReadGamepadState")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int ReadGamepadState( - SafeGameInputContextHandle context, - [MarshalAs(UnmanagedType.LPUTF8Str)] string deviceId, - out GameInputGamepadState state); - - /// - /// 註冊 reading callback;shim 會在 GameInput runtime 推送新 reading 時喚醒輪詢執行緒。 - /// callback 僅供 wake-up 與診斷快照,不得直接觸發 UI 或輸入命令。 - /// - /// 已建立的 GameInput context。 - /// 穩定裝置識別字串;傳 null 表示訂閱全部裝置。 - /// 輸入種類過濾(GameInputKind 位元旗標)。 - /// 由 managed 端建立、需 keep-alive 的 delegate。 - /// 回呼時傳回的 user context(通常為 GCHandle)。 - /// 回傳的回呼識別 token,用於 。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputRegisterReadingCallback")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int RegisterReadingCallback( - SafeGameInputContextHandle context, - [MarshalAs(UnmanagedType.LPUTF8Str)] string? deviceId, - uint kind, - GameInputNativeReadingCallback callback, - nint callbackContext, - out ulong callbackToken); - - /// - /// 註冊 device callback;shim 會在裝置連線/斷線/狀態變更時通知 managed 端進行重列舉。 - /// - /// 已建立的 GameInput context。 - /// 穩定裝置識別字串;傳 null 表示訂閱全部裝置。 - /// 輸入種類過濾(GameInputKind 位元旗標)。 - /// 關注的狀態變更旗標(GameInputDeviceStatus)。 - /// 列舉模式(GameInputEnumerationKind)。 - /// 由 managed 端建立、需 keep-alive 的 delegate。 - /// 回呼時傳回的 user context(通常為 GCHandle)。 - /// 回傳的回呼識別 token,用於 。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputRegisterDeviceCallback")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int RegisterDeviceCallback( - SafeGameInputContextHandle context, - [MarshalAs(UnmanagedType.LPUTF8Str)] string? deviceId, - uint kind, - uint statusFilter, - uint enumerationKind, - GameInputNativeDeviceCallback callback, - nint callbackContext, - out ulong callbackToken); - - /// - /// 註銷先前由 - /// 取得的 callback token;shim 會停止對應的 callback 並讓 managed delegate 可被 GC。 - /// - /// 已建立的 GameInput context。 - /// 先前回傳的 callback token。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputUnregisterCallback")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int UnregisterCallback( - SafeGameInputContextHandle context, - ulong callbackToken); - - /// - /// 套用震動參數到指定裝置;四個馬達強度均為 [0.0, 1.0] 正規化值, - /// 不支援的馬達會被 GameInput runtime 忽略。 - /// - /// 已建立的 GameInput context。 - /// 穩定裝置識別字串(UTF-8)。 - /// 低頻主馬達強度。 - /// 高頻主馬達強度。 - /// 左扳機馬達強度(不支援時忽略)。 - /// 右扳機馬達強度(不支援時忽略)。 - /// Native HRESULT。 - [DllImport(NativeLibraryName, EntryPoint = "InputBoxGameInputSetRumbleState")] - [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory | DllImportSearchPath.UserDirectories)] - internal static extern int SetRumbleState( - SafeGameInputContextHandle context, - [MarshalAs(UnmanagedType.LPUTF8Str)] string deviceId, - float lowFrequency, - float highFrequency, - float leftTrigger, - float rightTrigger); -} - -/// -/// Reading callback delegate:shim 在 GameInput runtime 推送新 gamepad reading 時呼叫, -/// 通常僅用於喚醒 MTA 背景輪詢執行緒;正式輸入命令仍由 60 FPS polling 消費。 -/// -/// 註冊時傳入的 user context(通常為 GCHandle)。 -/// 推送的 gamepad 狀態(POD)。 -[UnmanagedFunctionPointer(CallingConvention.StdCall)] -internal delegate void GameInputNativeReadingCallback( - nint callbackContext, - ref GameInputGamepadState state); - -/// -/// Device callback delegate:shim 在裝置連線/斷線/狀態變更時呼叫,通知 managed 端 -/// 安排裝置重列舉;callback 內僅可記錄旗標或排入工作佇列,不得直接觸發 UI 或輸入。 -/// -/// 註冊時傳入的 user context(通常為 GCHandle)。 -/// 穩定裝置識別字串的 UTF-8 指標(由 shim 持有)。 -/// 事件時戳(QueryPerformanceCounter 等價單位)。 -/// 目前的 GameInputDeviceStatus 旗標。 -/// 事件前的 GameInputDeviceStatus 旗標。 -[UnmanagedFunctionPointer(CallingConvention.StdCall)] -internal delegate void GameInputNativeDeviceCallback( - nint callbackContext, - nint deviceId, - ulong timestamp, - uint currentStatus, - uint previousStatus); diff --git a/src/InputBox/Core/Input/GameInputPrimitives.cs b/src/InputBox/Core/Input/GameInputPrimitives.cs deleted file mode 100644 index 76c3bca..0000000 --- a/src/InputBox/Core/Input/GameInputPrimitives.cs +++ /dev/null @@ -1,725 +0,0 @@ -using System.Runtime.InteropServices; - -namespace InputBox.Core.Input; - -/// -/// GameInput 輸入類型。 -/// -[Flags] -internal enum GameInputKind -{ - /// - /// 未知或不支援的輸入類型。 - /// - Unknown = 0x00000000, - - /// - /// Gamepad。 - /// - Gamepad = 0x00040000 -} - -/// -/// GameInput focus policy。 -/// -[Flags] -internal enum GameInputFocusPolicy : uint -{ - /// - /// 預設 focus policy。 - /// - Default = 0 -} - -/// -/// GameInput 裝置狀態。 -/// -[Flags] -internal enum GameInputDeviceStatus : uint -{ - /// - /// 無狀態。 - /// - None = 0x00000000, - - /// - /// 已連線。 - /// - Connected = 0x00000001, - - /// - /// Haptic 資訊已可用。 - /// - HapticInfoReady = 0x00200000, - - /// - /// 任一狀態變更。 - /// - Any = 0xFFFFFFFF -} - -/// -/// GameInput 裝置列舉模式。 -/// -internal enum GameInputEnumerationKind -{ - /// - /// 不執行初始列舉。 - /// - None = 0, - - /// - /// 非同步列舉。 - /// - Async = 1, - - /// - /// 阻塞列舉。 - /// - Blocking = 2 -} - -/// -/// GameInput gamepad 按鍵旗標。 -/// -[Flags] -internal enum GameInputGamepadButtons : uint -{ - None = 0x00000000, - Menu = 0x00000001, - View = 0x00000002, - A = 0x00000004, - B = 0x00000008, - C = 0x00004000, - X = 0x00000010, - Y = 0x00000020, - Z = 0x00008000, - DPadUp = 0x00000040, - DPadDown = 0x00000080, - DPadLeft = 0x00000100, - DPadRight = 0x00000200, - LeftShoulder = 0x00000400, - RightShoulder = 0x00000800, - LeftThumbstick = 0x00001000, - RightThumbstick = 0x00002000, - LeftTriggerButton = 0x00010000, - RightTriggerButton = 0x00020000, - LeftThumbstickUp = 0x00040000, - LeftThumbstickDown = 0x00080000, - LeftThumbstickLeft = 0x00100000, - LeftThumbstickRight = 0x00200000, - RightThumbstickUp = 0x00400000, - RightThumbstickDown = 0x00800000, - RightThumbstickLeft = 0x01000000, - RightThumbstickRight = 0x02000000, - PaddleLeft1 = 0x04000000, - PaddleLeft2 = 0x08000000, - PaddleRight1 = 0x10000000, - PaddleRight2 = 0x20000000 -} - -/// -/// GameInput 支援的震動馬達旗標。 -/// -[Flags] -internal enum GameInputRumbleMotors : uint -{ - None = 0x00000000, - LowFrequency = 0x00000001, - HighFrequency = 0x00000002, - LeftTrigger = 0x00000004, - RightTrigger = 0x00000008 -} - -/// -/// GameInput gamepad 狀態。 -/// -[StructLayout(LayoutKind.Sequential)] -internal struct GameInputGamepadState -{ - public ulong Timestamp; - public GameInputKind InputKind; - public GameInputGamepadButtons Buttons; - public float LeftTrigger; - public float RightTrigger; - public float LeftThumbstickX; - public float LeftThumbstickY; - public float RightThumbstickX; - public float RightThumbstickY; -} - -/// -/// GameInput gamepad 狀態快照。 -/// -internal sealed record class GamepadStateSnapshot -{ - internal GamepadStateSnapshot(GameInputGamepadState state) - : this( - state.Timestamp, - state.InputKind, - state.Buttons, - state.LeftTrigger, - state.RightTrigger, - state.LeftThumbstickX, - state.LeftThumbstickY, - state.RightThumbstickX, - state.RightThumbstickY) - { - - } - - internal GamepadStateSnapshot( - ulong timestamp, - GameInputKind inputKind, - GameInputGamepadButtons buttons, - float leftTrigger, - float rightTrigger, - float leftThumbstickX, - float leftThumbstickY, - float rightThumbstickX, - float rightThumbstickY) - { - Timestamp = timestamp; - InputKind = inputKind; - Buttons = buttons; - LeftTrigger = leftTrigger; - RightTrigger = rightTrigger; - LeftThumbstickX = leftThumbstickX; - LeftThumbstickY = leftThumbstickY; - RightThumbstickX = rightThumbstickX; - RightThumbstickY = rightThumbstickY; - } - - internal GamepadStateSnapshot( - GameInputGamepadButtons buttons, - float leftTrigger, - float rightTrigger, - float leftThumbstickX, - float leftThumbstickY, - float rightThumbstickX, - float rightThumbstickY) - : this( - 0, - GameInputKind.Gamepad, - buttons, - leftTrigger, - rightTrigger, - leftThumbstickX, - leftThumbstickY, - rightThumbstickX, - rightThumbstickY) - { - - } - - public ulong Timestamp { get; init; } - - public GameInputKind InputKind { get; init; } - - public GameInputGamepadButtons Buttons { get; init; } - - public float LeftTrigger { get; init; } - - public float RightTrigger { get; init; } - - public float LeftThumbstickX { get; init; } - - public float LeftThumbstickY { get; init; } - - public float RightThumbstickX { get; init; } - - public float RightThumbstickY { get; init; } -} - -/// -/// GameInput 震動參數。 -/// -internal readonly record struct GameInputRumbleParams -{ - public float LowFrequency { get; init; } - - public float HighFrequency { get; init; } - - public float LeftTrigger { get; init; } - - public float RightTrigger { get; init; } -} - -/// -/// GameInput 版本資訊。 -/// -internal readonly record struct GameInputVersionInfo( - ushort Major, - ushort Minor, - ushort Build, - ushort Revision); - -/// -/// GameInput gamepad 能力資訊。 -/// -internal readonly record struct GameInputGamepadCapabilities( - GameInputGamepadButtons SupportedLayout, - uint GamepadExtraButtonCount, - uint GamepadExtraAxisCount, - uint ExtraButtonCount, - uint ExtraAxisCount, - byte[] ExtraButtonIndexes, - byte[] ExtraAxisIndexes, - bool HasInputMapper) -{ - public static GameInputGamepadCapabilities Empty { get; } = new( - GameInputGamepadButtons.None, - 0, - 0, - 0, - 0, - [], - [], - false); -} - -/// -/// GameInput shim 字串欄位截斷旗標。 -/// -[Flags] -internal enum GameInputStringTruncationFlags : uint -{ - None = 0x00000000, - DeviceId = 0x00000001, - DeviceRootId = 0x00000002, - ContainerId = 0x00000004, - DisplayName = 0x00000008, - PnpPath = 0x00000010, - AttemptedModulePath = 0x00000020, - LoadedModulePath = 0x00000040 -} - -/// -/// GameInput shim 與 managed layer 的 ABI 尺寸資訊。 -/// -internal readonly record struct GameInputAbiInfo( - uint PointerSize, - uint ShimInfoSize, - uint RuntimeProbeInfoSize, - uint DeviceInfoSize, - uint GamepadStateSize, - uint DiagnosticsSnapshotSize) -{ - public static GameInputAbiInfo Managed { get; } = new( - (uint)IntPtr.Size, - (uint)Marshal.SizeOf(), - (uint)Marshal.SizeOf(), - (uint)Marshal.SizeOf(), - (uint)Marshal.SizeOf(), - (uint)Marshal.SizeOf()); - - public bool MatchesManagedLayout => this == Managed; - - public void ThrowIfMismatch() - { - if (!MatchesManagedLayout) - { - throw new BadImageFormatException( - $"GameInput native shim ABI mismatch. Native={this}; Managed={Managed}"); - } - } -} - -/// -/// GameInput shim 載入診斷資訊。 -/// -internal readonly record struct GameInputShimInfo( - uint AbiVersion, - uint GameInputApiVersion, - GameInputAbiInfo AbiInfo, - GameInputShimModuleKind LoadedModuleKind, - string LoadedModulePath); - -/// -/// GameInput runtime probe 結果。 -/// -internal readonly record struct GameInputRuntimeProbeInfo( - uint AbiVersion, - uint GameInputApiVersion, - GameInputAbiInfo AbiInfo, - GameInputShimModuleKind AttemptedModuleKind, - GameInputShimModuleKind LoadedModuleKind, - int LoadLibraryHResult, - int GetProcAddressHResult, - int InitializeHResult, - int FinalHResult, - uint LoadLibraryWin32Error, - uint GetProcAddressWin32Error, - uint InitializeWin32Error, - GameInputStringTruncationFlags StringTruncationFlags, - string AttemptedModulePath, - string LoadedModulePath); - -/// -/// GameInput shim 診斷計數器快照。 -/// -internal readonly record struct GameInputDiagnosticsSnapshot( - ulong MissingReadingCount, - ulong RepeatedTimestampCount, - ulong BackwardTimestampCount, - ulong DeviceUnavailableRefreshCount, - ulong LastReadingTimestamp, - int LastReadHResult, - uint LastReadDeviceStatus); - -/// -/// GameInput runtime 載入來源。 -/// -internal enum GameInputShimModuleKind : uint -{ - Unknown = 0, - SystemGameInput = 1, - SystemGameInputRedist = 2, - RegistryGameInputRedist = 3 -} - -/// -/// GameInput 裝置資訊快照。 -/// -internal readonly record struct GameInputDeviceInfo -{ - internal GameInputDeviceInfo( - string deviceId, - ushort vendorId, - ushort productId, - ushort revisionNumber, - ushort usagePage, - ushort usageId, - uint deviceFamily, - GameInputKind supportedInput, - GameInputRumbleMotors supportedRumbleMotors, - uint supportedSystemButtons, - GameInputVersionInfo hardwareVersion, - GameInputVersionInfo firmwareVersion, - string deviceRootId, - string containerId, - string pnpPath, - GameInputStringTruncationFlags stringTruncationFlags, - GameInputGamepadCapabilities gamepadCapabilities, - string displayName) - { - DeviceId = deviceId; - VendorId = vendorId; - ProductId = productId; - RevisionNumber = revisionNumber; - UsagePage = usagePage; - UsageId = usageId; - DeviceFamily = deviceFamily; - SupportedInput = supportedInput; - SupportedRumbleMotors = supportedRumbleMotors; - SupportedSystemButtons = supportedSystemButtons; - HardwareVersion = hardwareVersion; - FirmwareVersion = firmwareVersion; - DeviceRootId = deviceRootId; - ContainerId = containerId; - PnpPath = pnpPath; - StringTruncationFlags = stringTruncationFlags; - GamepadCapabilities = gamepadCapabilities; - DisplayName = displayName; - } - - public string DeviceId { get; } - - public ushort VendorId { get; } - - public ushort ProductId { get; } - - public ushort RevisionNumber { get; } - - public ushort UsagePage { get; } - - public ushort UsageId { get; } - - public uint DeviceFamily { get; } - - public GameInputKind SupportedInput { get; } - - public GameInputRumbleMotors SupportedRumbleMotors { get; } - - public uint SupportedSystemButtons { get; } - - public GameInputVersionInfo HardwareVersion { get; } - - public GameInputVersionInfo FirmwareVersion { get; } - - public string DeviceRootId { get; } - - public string ContainerId { get; } - - public string PnpPath { get; } - - public GameInputStringTruncationFlags StringTruncationFlags { get; } - - public GameInputGamepadCapabilities GamepadCapabilities { get; } - - private string DisplayName { get; } - - public string GetDisplayName() => DisplayName; -} - -/// -/// Native shim 回傳的版本資訊。 -/// -[StructLayout(LayoutKind.Sequential)] -internal struct GameInputNativeVersionInfo -{ - public ushort Major; - public ushort Minor; - public ushort Build; - public ushort Revision; - - public readonly GameInputVersionInfo ToVersionInfo() - => new(Major, Minor, Build, Revision); -} - -/// -/// Native shim 回傳的載入診斷資訊。 -/// -[StructLayout(LayoutKind.Sequential)] -internal unsafe struct GameInputNativeShimInfo -{ - public uint AbiVersion; - public uint GameInputApiVersion; - public uint PointerSize; - public uint ShimInfoSize; - public uint RuntimeProbeInfoSize; - public uint DeviceInfoSize; - public uint GamepadStateSize; - public uint DiagnosticsSnapshotSize; - public uint LoadedModuleKind; - public fixed byte LoadedModulePath[512]; - - public readonly GameInputShimInfo ToShimInfo() - { - string loadedModulePath; - - fixed (byte* loadedModulePathPtr = LoadedModulePath) - { - loadedModulePath = Marshal.PtrToStringUTF8((nint)loadedModulePathPtr) ?? string.Empty; - } - - return new GameInputShimInfo( - AbiVersion, - GameInputApiVersion, - new GameInputAbiInfo( - PointerSize, - ShimInfoSize, - RuntimeProbeInfoSize, - DeviceInfoSize, - GamepadStateSize, - DiagnosticsSnapshotSize), - (GameInputShimModuleKind)LoadedModuleKind, - loadedModulePath); - } -} - -/// -/// Native shim 回傳的 runtime probe 診斷資訊。 -/// -[StructLayout(LayoutKind.Sequential)] -internal unsafe struct GameInputNativeRuntimeProbeInfo -{ - public uint AbiVersion; - public uint GameInputApiVersion; - public uint PointerSize; - public uint ShimInfoSize; - public uint RuntimeProbeInfoSize; - public uint DeviceInfoSize; - public uint GamepadStateSize; - public uint DiagnosticsSnapshotSize; - public uint AttemptedModuleKind; - public uint LoadedModuleKind; - public int LoadLibraryHResult; - public int GetProcAddressHResult; - public int InitializeHResult; - public int FinalHResult; - public uint LoadLibraryWin32Error; - public uint GetProcAddressWin32Error; - public uint InitializeWin32Error; - public uint StringTruncationFlags; - public fixed byte AttemptedModulePath[512]; - public fixed byte LoadedModulePath[512]; - - public readonly GameInputRuntimeProbeInfo ToProbeInfo() - { - string attemptedModulePath; - string loadedModulePath; - - fixed (byte* attemptedModulePathPtr = AttemptedModulePath) - fixed (byte* loadedModulePathPtr = LoadedModulePath) - { - attemptedModulePath = Marshal.PtrToStringUTF8((nint)attemptedModulePathPtr) ?? string.Empty; - loadedModulePath = Marshal.PtrToStringUTF8((nint)loadedModulePathPtr) ?? string.Empty; - } - - return new GameInputRuntimeProbeInfo( - AbiVersion, - GameInputApiVersion, - new GameInputAbiInfo( - PointerSize, - ShimInfoSize, - RuntimeProbeInfoSize, - DeviceInfoSize, - GamepadStateSize, - DiagnosticsSnapshotSize), - (GameInputShimModuleKind)AttemptedModuleKind, - (GameInputShimModuleKind)LoadedModuleKind, - LoadLibraryHResult, - GetProcAddressHResult, - InitializeHResult, - FinalHResult, - LoadLibraryWin32Error, - GetProcAddressWin32Error, - InitializeWin32Error, - (GameInputStringTruncationFlags)StringTruncationFlags, - attemptedModulePath, - loadedModulePath); - } -} - -/// -/// Native shim 回傳的裝置資訊。 -/// -[StructLayout(LayoutKind.Sequential)] -internal unsafe struct GameInputNativeDeviceInfo -{ - public ushort VendorId; - public ushort ProductId; - public ushort RevisionNumber; - public ushort UsagePage; - public ushort UsageId; - public ushort Reserved; - public uint DeviceFamily; - public uint SupportedInput; - public uint SupportedRumbleMotors; - public uint SupportedSystemButtons; - public uint GamepadSupportedLayout; - public uint GamepadExtraButtonCount; - public uint GamepadExtraAxisCount; - public uint ForceFeedbackMotorCount; - public uint InputReportCount; - public uint OutputReportCount; - public uint ExtraButtonCount; - public uint ExtraAxisCount; - public uint ExtraButtonIndexCount; - public uint ExtraAxisIndexCount; - public uint HasInputMapper; - public uint StringTruncationFlags; - public GameInputNativeVersionInfo HardwareVersion; - public GameInputNativeVersionInfo FirmwareVersion; - public fixed byte ExtraButtonIndexes[32]; - public fixed byte ExtraAxisIndexes[32]; - public fixed byte DeviceId[65]; - public fixed byte DeviceRootId[65]; - public fixed byte ContainerId[39]; - public fixed byte DisplayName[256]; - public fixed byte PnpPath[512]; - - public GameInputDeviceInfo ToDeviceInfo() - { - string parsedDeviceId; - string parsedDeviceRootId; - string parsedContainerId; - string parsedDisplayName; - string parsedPnpPath; - byte[] extraButtonIndexes; - byte[] extraAxisIndexes; - - fixed (byte* extraButtonIndexesPtr = ExtraButtonIndexes) - fixed (byte* extraAxisIndexesPtr = ExtraAxisIndexes) - fixed (byte* deviceIdPtr = DeviceId) - fixed (byte* deviceRootIdPtr = DeviceRootId) - fixed (byte* containerIdPtr = ContainerId) - fixed (byte* displayNamePtr = DisplayName) - fixed (byte* pnpPathPtr = PnpPath) - { - extraButtonIndexes = ReadIndexes(extraButtonIndexesPtr, ExtraButtonIndexCount); - extraAxisIndexes = ReadIndexes(extraAxisIndexesPtr, ExtraAxisIndexCount); - parsedDeviceId = Marshal.PtrToStringUTF8((nint)deviceIdPtr) ?? string.Empty; - parsedDeviceRootId = Marshal.PtrToStringUTF8((nint)deviceRootIdPtr) ?? string.Empty; - parsedContainerId = Marshal.PtrToStringUTF8((nint)containerIdPtr) ?? string.Empty; - parsedDisplayName = Marshal.PtrToStringUTF8((nint)displayNamePtr) ?? string.Empty; - parsedPnpPath = Marshal.PtrToStringUTF8((nint)pnpPathPtr) ?? string.Empty; - } - - GameInputGamepadCapabilities capabilities = new( - (GameInputGamepadButtons)GamepadSupportedLayout, - GamepadExtraButtonCount, - GamepadExtraAxisCount, - ExtraButtonCount, - ExtraAxisCount, - extraButtonIndexes, - extraAxisIndexes, - HasInputMapper != 0); - - return new GameInputDeviceInfo( - parsedDeviceId, - VendorId, - ProductId, - RevisionNumber, - UsagePage, - UsageId, - DeviceFamily, - (GameInputKind)SupportedInput, - (GameInputRumbleMotors)SupportedRumbleMotors, - SupportedSystemButtons, - HardwareVersion.ToVersionInfo(), - FirmwareVersion.ToVersionInfo(), - parsedDeviceRootId, - parsedContainerId, - parsedPnpPath, - (GameInputStringTruncationFlags)StringTruncationFlags, - capabilities, - parsedDisplayName); - } - - private static byte[] ReadIndexes(byte* source, uint count) - { - int length = (int)Math.Min(count, 32); - - if (length == 0) - { - return []; - } - - byte[] indexes = new byte[length]; - - for (int i = 0; i < length; i++) - { - indexes[i] = source[i]; - } - - return indexes; - } -} - -/// -/// Native shim 回傳的診斷計數器快照。 -/// -[StructLayout(LayoutKind.Sequential)] -internal struct GameInputNativeDiagnosticsSnapshot -{ - public ulong MissingReadingCount; - public ulong RepeatedTimestampCount; - public ulong BackwardTimestampCount; - public ulong DeviceUnavailableRefreshCount; - public ulong LastReadingTimestamp; - public int LastReadHResult; - public uint LastReadDeviceStatus; - public uint Reserved; - - public readonly GameInputDiagnosticsSnapshot ToDiagnosticsSnapshot() - => new( - MissingReadingCount, - RepeatedTimestampCount, - BackwardTimestampCount, - DeviceUnavailableRefreshCount, - LastReadingTimestamp, - LastReadHResult, - LastReadDeviceStatus); -} diff --git a/src/InputBox/Core/Interop/DllResolver.cs b/src/InputBox/Core/Interop/DllResolver.cs index 9332b8b..8a79dcb 100644 --- a/src/InputBox/Core/Interop/DllResolver.cs +++ b/src/InputBox/Core/Interop/DllResolver.cs @@ -19,13 +19,7 @@ internal class DllResolver private static volatile nint _cachedHandle = IntPtr.Zero; /// - /// 快取的自有 GameInput native shim handle,用於避免重複載入。 - /// 目前設計假設同一個行程只會解析並載入單一版本/位置的 InputBox.GameInput.Native。 - /// - private static volatile nint _cachedGameInputShimHandle = IntPtr.Zero; - - /// - /// 自訂的 native 載入解析器,用於覆寫 XInput 與自有 GameInput shim 的載入邏輯。 + /// 自訂的 native 載入解析器,用於覆寫 XInput 的載入邏輯。 /// /// 開啟端要求載入的 DLL 名稱 /// 觸發載入的組件。 @@ -36,12 +30,6 @@ public static nint ResolveNativeLibrary( 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)) { @@ -88,37 +76,4 @@ public static nint ResolveNativeLibrary( return IntPtr.Zero; } } - - /// - /// 解析自有 GameInput native shim。 - /// 首次成功載入後,後續不同 assembly/searchPath 的解析都會重用同一個 handle。 - /// - /// 要求載入的 library 名稱。 - /// 觸發載入的組件。 - /// DllImportSearchPath 設定。 - /// 成功載入時回傳 DLL handle;否則回傳 IntPtr.Zero。 - private static nint ResolveGameInputShim( - string libraryName, - Assembly assembly, - DllImportSearchPath? searchPath) - { - lock (ResolverLock) - { - if (_cachedGameInputShimHandle != IntPtr.Zero) - { - // Native shim 視為 process-wide singleton,避免重複載入造成匯出狀態分裂。 - return _cachedGameInputShimHandle; - } - - if (NativeLibrary.TryLoad(libraryName, assembly, searchPath, out nint handle) || - NativeLibrary.TryLoad($"{libraryName}.dll", assembly, searchPath, out handle)) - { - _cachedGameInputShimHandle = handle; - - return handle; - } - - return IntPtr.Zero; - } - } -} \ No newline at end of file +} diff --git a/src/InputBox/InputBox.csproj b/src/InputBox/InputBox.csproj index 5d0a0c7..909f441 100644 --- a/src/InputBox/InputBox.csproj +++ b/src/InputBox/InputBox.csproj @@ -15,9 +15,6 @@ 輸入框 InputBox 垃圾桶 rubujo 在法律允許的最大範圍內,作者已放棄所有著作權及相關權利(CC0 1.0) - ..\InputBox.GameInput.Native\InputBox.GameInput.Native.vcxproj - ..\InputBox.GameInput.Native\bin\x64\$(Configuration)\InputBox.GameInput.Native.dll - InputBox.GameInput.Native.dll @@ -55,58 +52,10 @@ + - - - - - - - - - - - - - - - - - $(GameInputNativeShimFileName) - native - PreserveNewest - - - - - - - <_GameInputNativeShimBundleItem Include="@(FilesToBundle)" - Condition="'%(FilesToBundle.RelativePath)' == '$(GameInputNativeShimFileName)' and '%(FilesToBundle.AssetType)' == 'native'" /> - - - - <_Parameter1>InputBox.Tests diff --git a/tests/InputBox.Tests/GameInputDirectUsageTests.cs b/tests/InputBox.Tests/GameInputDirectUsageTests.cs new file mode 100644 index 0000000..68f6c49 --- /dev/null +++ b/tests/InputBox.Tests/GameInputDirectUsageTests.cs @@ -0,0 +1,206 @@ +using InputBox.Core.Input; +using InputWeave.GameInput; +using InputWeave.GameInput.Interop; +using System.Reflection; +using Xunit; + +namespace InputBox.Tests; + +/// +/// 驗證 InputBox 直接使用 InputWeave.GameInput gamepad API 時,仍保留既有 GameInput 行為守門。 +/// +public sealed class GameInputDirectUsageTests +{ + /// + /// InputBox 依賴的 InputWeave gamepad client / device / callback / runtime API surface 必須存在。 + /// + [Fact] + public void RequiredInputWeaveGamepadApi_Surface_IsAvailable() + { + Assert.NotNull(typeof(GameInputRuntime).GetMethod(nameof(GameInputRuntime.TryProbe), BindingFlags.Public | BindingFlags.Static)); + Assert.NotNull(typeof(GameInputRuntime).GetMethod(nameof(GameInputRuntime.GetInfo), BindingFlags.Public | BindingFlags.Static)); + Assert.NotNull(typeof(GameInputClient).GetMethod(nameof(GameInputClient.Create), BindingFlags.Public | BindingFlags.Static)); + Assert.NotNull(typeof(GameInputClient).GetMethod(nameof(GameInputClient.SetFocusPolicy), [typeof(GameInputFocusPolicy)])); + Assert.NotNull(typeof(GameInputClient).GetMethod(nameof(GameInputClient.EnumerateDevices), [typeof(GameInputKind), typeof(GameInputDeviceStatus)])); + Assert.NotNull(typeof(GameInputClient).GetMethod(nameof(GameInputClient.GetCurrentGamepad), [typeof(GameInputDevice)])); + Assert.NotNull(typeof(GameInputClient).GetMethod(nameof(GameInputClient.RegisterReadingCallback), [typeof(GameInputDevice), typeof(GameInputKind), typeof(GameInputReadingHandler)])); + Assert.NotNull(typeof(GameInputClient).GetMethod(nameof(GameInputClient.RegisterDeviceCallback), [typeof(GameInputDevice), typeof(GameInputKind), typeof(GameInputDeviceStatus), typeof(GameInputEnumerationKind), typeof(GameInputDeviceHandler)])); + Assert.NotNull(typeof(GameInputDevice).GetProperty(nameof(GameInputDevice.Status))); + Assert.NotNull(typeof(GameInputDevice).GetMethod(nameof(GameInputDevice.GetDeviceInfoSnapshot), Type.EmptyTypes)); + Assert.NotNull(typeof(GameInputDevice).GetMethod(nameof(GameInputDevice.SetRumbleState), [typeof(GameInputRumbleParams).MakeByRefType()])); + } + + /// + /// GameInput v3 的 gamepad button 位元必須維持 Microsoft 定義值,避免 face key、D-Pad、背鍵與搖桿方向判斷錯位。 + /// + [Fact] + public void GameInputGamepadButtons_OfficialV3Values_AreStable() + { + Assert.Equal(0x00000004u, Convert.ToUInt32(GameInputGamepadButtons.GameInputGamepadA)); + Assert.Equal(0x00000008u, Convert.ToUInt32(GameInputGamepadButtons.GameInputGamepadB)); + Assert.Equal(0x00004000u, Convert.ToUInt32(GameInputGamepadButtons.GameInputGamepadC)); + Assert.Equal(0x00008000u, Convert.ToUInt32(GameInputGamepadButtons.GameInputGamepadZ)); + Assert.Equal(0x00000200u, Convert.ToUInt32(GameInputGamepadButtons.GameInputGamepadDPadRight)); + Assert.Equal(0x00040000u, Convert.ToUInt32(GameInputGamepadButtons.GameInputGamepadLeftThumbstickUp)); + Assert.Equal(0x02000000u, Convert.ToUInt32(GameInputGamepadButtons.GameInputGamepadRightThumbstickRight)); + Assert.Equal(0x04000000u, Convert.ToUInt32(GameInputGamepadButtons.GameInputGamepadPaddleLeft1)); + Assert.Equal(0x20000000u, Convert.ToUInt32(GameInputGamepadButtons.GameInputGamepadPaddleRight2)); + } + + /// + /// InputWeave 的 GamepadReadingSnapshot 應直接保存 timestamp 與 gamepad state,供 InputBox polling 與診斷使用。 + /// + [Fact] + public void GamepadReadingSnapshot_InputWeaveState_PreservesTimestampAndValues() + { + GameInputGamepadState state = new() + { + Buttons = GameInputGamepadButtons.GameInputGamepadA | GameInputGamepadButtons.GameInputGamepadPaddleLeft1, + LeftTrigger = 0.1f, + RightTrigger = 0.2f, + LeftThumbstickX = -0.3f, + LeftThumbstickY = 0.4f, + RightThumbstickX = -0.5f, + RightThumbstickY = 0.6f + }; + + GamepadReadingSnapshot snapshot = new(987654321, state); + + Assert.Equal(987654321ul, snapshot.Timestamp); + Assert.Equal(state.Buttons, snapshot.State.Buttons); + Assert.Equal(state.LeftTrigger, snapshot.State.LeftTrigger); + Assert.Equal(state.RightTrigger, snapshot.State.RightTrigger); + Assert.Equal(state.RightThumbstickY, snapshot.State.RightThumbstickY); + } + + /// + /// 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。"); + GameInputGamepadState state = new() + { + Buttons = GameInputGamepadButtons.GameInputGamepadA, + LeftTrigger = 0.1f, + RightTrigger = 0.2f, + LeftThumbstickX = -0.3f, + LeftThumbstickY = 0.4f, + RightThumbstickX = -0.5f, + RightThumbstickY = 0.6f + }; + GamepadReadingSnapshot first = new(1, state); + GamepadReadingSnapshot second = new(2, state); + + Assert.True((bool)method.Invoke(null, [second, first])!); + } + + /// + /// GameInput edge detection 仍必須比較實際 gamepad 值,避免按鍵或軸變化被 timestamp 邏輯吞掉。 + /// + [Fact] + public void HasSameInputValues_ButtonChanged_ReturnsFalse() + { + MethodInfo method = typeof(GameInputGamepadController).GetMethod( + "HasSameInputValues", + BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("找不到 GameInputGamepadController.HasSameInputValues。"); + GamepadReadingSnapshot previous = new(1, new GameInputGamepadState + { + Buttons = GameInputGamepadButtons.GameInputGamepadA + }); + GamepadReadingSnapshot current = new(2, new GameInputGamepadState + { + Buttons = GameInputGamepadButtons.GameInputGamepadB + }); + + Assert.False((bool)method.Invoke(null, [current, previous])!); + } + + /// + /// InputBox 的穩定裝置識別 helper 應優先使用 PnP path,缺失時退回 VID/PID 與顯示名稱。 + /// + [Fact] + public void GetStableDeviceId_InputWeaveSnapshot_UsesPnpPathOrVidPidFallback() + { + MethodInfo method = typeof(GameInputGamepadController).GetMethod( + "GetStableDeviceId", + BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("找不到 GameInputGamepadController.GetStableDeviceId。"); + GameInputDeviceInfoSnapshot withPnpPath = CreateDeviceInfoSnapshot( + 0x054C, + 0x0CE6, + "DualSense Wireless Controller", + @"HID\VID_054C&PID_0CE6"); + GameInputDeviceInfoSnapshot withoutPnpPath = CreateDeviceInfoSnapshot( + 0x057E, + 0x2009, + "Pro Controller", + string.Empty); + + Assert.Equal(@"HID\VID_054C&PID_0CE6", method.Invoke(null, [withPnpPath])); + Assert.Equal("VID_057E PID_2009 Pro Controller", method.Invoke(null, [withoutPnpPath])); + } + + /// + /// InputWeave rumble 參數應保留四個馬達欄位,供 InputBox 的震動安全限制器輸出到左右主馬達與雙扳機馬達。 + /// + [Fact] + public void GameInputRumbleParams_MotorFields_PreserveAssignedValues() + { + GameInputRumbleParams rumble = new() + { + LowFrequency = 0.1f, + HighFrequency = 0.2f, + LeftTrigger = 0.3f, + RightTrigger = 0.4f + }; + + Assert.Equal(0.1f, rumble.LowFrequency); + Assert.Equal(0.2f, rumble.HighFrequency); + Assert.Equal(0.3f, rumble.LeftTrigger); + Assert.Equal(0.4f, rumble.RightTrigger); + } + + private static GameInputDeviceInfoSnapshot CreateDeviceInfoSnapshot( + ushort vendorId, + ushort productId, + string displayName, + string pnpPath) + { + GameInputDeviceInfo nativeInfo = new() + { + VendorId = vendorId, + ProductId = productId, + SupportedInput = GameInputKind.GameInputKindGamepad + }; + + return CreateInstance( + nativeInfo, + displayName, + pnpPath, + null, + null, + null, + null, + null, + null, + new GameInputGamepadInfo(), + null, + Array.Empty(), + Array.Empty(), + Array.Empty()); + } + + private static T CreateInstance(params object?[] args) + => (T)(Activator.CreateInstance( + typeof(T), + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: args, + culture: null) ?? throw new InvalidOperationException($"無法建立 {typeof(T).FullName}。")); +} diff --git a/tests/InputBox.Tests/GameInputPrimitivesTests.cs b/tests/InputBox.Tests/GameInputPrimitivesTests.cs deleted file mode 100644 index 95a04e8..0000000 --- a/tests/InputBox.Tests/GameInputPrimitivesTests.cs +++ /dev/null @@ -1,363 +0,0 @@ -using InputBox.Core.Input; -using System.Reflection; -using System.Text; -using System.Text.RegularExpressions; -using Xunit; - -namespace InputBox.Tests; - -/// -/// 驗證自有 GameInput shim 的受控資料模型,避免 native ABI 擴充後破壞既有 Gamepad 語意。 -/// -public sealed class GameInputPrimitivesTests -{ - /// - /// Managed P/Invoke 宣告的 EntryPoint 清單應維持固定,讓 CI/release export 驗證能防止執行期才發現符號缺失。 - /// - [Fact] - public void GameInputNativeMethods_DeclaredEntryPoints_MatchExpectedExportSet() - { - string sourcePath = Path.Combine( - FindRepositoryRoot(), - "src", - "InputBox", - "Core", - "Input", - "GameInputNative.cs"); - string source = File.ReadAllText(sourcePath, Encoding.UTF8); - - string[] actual = Regex - .Matches(source, @"EntryPoint\s*=\s*""(?InputBoxGameInput[^""]+)""") - .Select(match => match.Groups["name"].Value) - .Distinct(StringComparer.Ordinal) - .OrderBy(name => name, StringComparer.Ordinal) - .ToArray(); - - string[] expected = - [ - "InputBoxGameInputCreate", - "InputBoxGameInputDestroy", - "InputBoxGameInputGetDeviceCount", - "InputBoxGameInputGetDeviceInfo", - "InputBoxGameInputGetDeviceStatus", - "InputBoxGameInputGetDiagnosticsSnapshot", - "InputBoxGameInputGetShimInfo", - "InputBoxGameInputProbeRuntime", - "InputBoxGameInputReadGamepadState", - "InputBoxGameInputRefreshDevices", - "InputBoxGameInputRegisterDeviceCallback", - "InputBoxGameInputRegisterReadingCallback", - "InputBoxGameInputSetFocusPolicy", - "InputBoxGameInputSetRumbleState", - "InputBoxGameInputUnregisterCallback" - ]; - - Assert.Equal(expected.OrderBy(name => name, StringComparer.Ordinal), actual); - } - - /// - /// GameInput v3 官方新增的 C/Z、搖桿方向與背鍵位元,應與 Microsoft header 常數一致且不覆蓋既有按鍵。 - /// - [Fact] - public void GameInputGamepadButtons_OfficialV3Values_MatchMicrosoftConstants() - { - Assert.Equal(0x00004000u, (uint)GameInputGamepadButtons.C); - Assert.Equal(0x00008000u, (uint)GameInputGamepadButtons.Z); - Assert.Equal(0x00040000u, (uint)GameInputGamepadButtons.LeftThumbstickUp); - Assert.Equal(0x00080000u, (uint)GameInputGamepadButtons.LeftThumbstickDown); - Assert.Equal(0x00100000u, (uint)GameInputGamepadButtons.LeftThumbstickLeft); - Assert.Equal(0x00200000u, (uint)GameInputGamepadButtons.LeftThumbstickRight); - Assert.Equal(0x00400000u, (uint)GameInputGamepadButtons.RightThumbstickUp); - Assert.Equal(0x00800000u, (uint)GameInputGamepadButtons.RightThumbstickDown); - Assert.Equal(0x01000000u, (uint)GameInputGamepadButtons.RightThumbstickLeft); - Assert.Equal(0x02000000u, (uint)GameInputGamepadButtons.RightThumbstickRight); - Assert.Equal(0x04000000u, (uint)GameInputGamepadButtons.PaddleLeft1); - Assert.Equal(0x08000000u, (uint)GameInputGamepadButtons.PaddleLeft2); - Assert.Equal(0x10000000u, (uint)GameInputGamepadButtons.PaddleRight1); - Assert.Equal(0x20000000u, (uint)GameInputGamepadButtons.PaddleRight2); - } - - /// - /// Native gamepad state 的 timestamp 與 input kind 應被帶入快照,供診斷使用但不改變按鍵資料。 - /// - [Fact] - public void GamepadStateSnapshot_NativeState_CarriesTimestampAndInputKind() - { - GameInputGamepadState nativeState = new() - { - Timestamp = 123456789, - InputKind = GameInputKind.Gamepad, - Buttons = GameInputGamepadButtons.A | GameInputGamepadButtons.PaddleLeft1, - LeftTrigger = 0.25f, - RightTrigger = 0.75f, - LeftThumbstickX = -0.5f, - LeftThumbstickY = 0.5f, - RightThumbstickX = -1.0f, - RightThumbstickY = 1.0f - }; - - GamepadStateSnapshot snapshot = new(nativeState); - - Assert.Equal(123456789ul, snapshot.Timestamp); - Assert.Equal(GameInputKind.Gamepad, snapshot.InputKind); - Assert.Equal(nativeState.Buttons, snapshot.Buttons); - Assert.Equal(nativeState.LeftTrigger, snapshot.LeftTrigger); - Assert.Equal(nativeState.RightTrigger, snapshot.RightTrigger); - } - - /// - /// GameInput edge detection 應忽略 timestamp 差異,避免硬體持續回報相同狀態時被誤判為新按鍵。 - /// - [Fact] - public void HasSameInputValues_TimestampOnlyChanged_ReturnsTrue() - { - MethodInfo method = typeof(GameInputGamepadController).GetMethod( - "HasSameInputValues", - BindingFlags.Static | BindingFlags.NonPublic) - ?? throw new InvalidOperationException("找不到 GameInputGamepadController.HasSameInputValues。"); - - GamepadStateSnapshot first = new( - 1, - GameInputKind.Gamepad, - GameInputGamepadButtons.A, - 0.1f, - 0.2f, - -0.3f, - 0.4f, - -0.5f, - 0.6f); - GamepadStateSnapshot second = first with { Timestamp = 2 }; - - Assert.True((bool)method.Invoke(null, [second, first])!); - } - - /// - /// Shim info 應保留 native struct size metadata,供 managed create 階段拒絕載錯或 ABI 不符的 DLL。 - /// - [Fact] - public unsafe void ToShimInfo_NativeAbiSizes_PreservesManagedLayoutMetadata() - { - GameInputNativeShimInfo nativeInfo = CreateNativeShimInfo(); - byte* loadedModulePath = nativeInfo.LoadedModulePath; - WriteUtf8(loadedModulePath, 512, @"C:\Windows\System32\GameInput.dll"); - - GameInputShimInfo shimInfo = nativeInfo.ToShimInfo(); - - Assert.Equal(GameInputAbiInfo.Managed, shimInfo.AbiInfo); - Assert.True(shimInfo.AbiInfo.MatchesManagedLayout); - Assert.Equal(GameInputShimModuleKind.SystemGameInput, shimInfo.LoadedModuleKind); - Assert.Equal(@"C:\Windows\System32\GameInput.dll", shimInfo.LoadedModulePath); - } - - /// - /// ABI size mismatch 應被視為 shim 載錯,避免 managed layer 用錯 struct layout 讀取 native 資料。 - /// - [Fact] - public void ThrowIfMismatch_DifferentNativeDeviceInfoSize_ThrowsBadImageFormatException() - { - GameInputAbiInfo mismatched = GameInputAbiInfo.Managed with - { - DeviceInfoSize = GameInputAbiInfo.Managed.DeviceInfoSize + 4 - }; - - Assert.Throws(mismatched.ThrowIfMismatch); - } - - /// - /// Runtime probe 應完整保留載入階段錯誤資訊,讓 fallback XInput 時能定位 DLL、export 或 initialize 失敗。 - /// - [Fact] - public unsafe void ToProbeInfo_NativeRuntimeProbe_PreservesLoadDiagnostics() - { - GameInputNativeRuntimeProbeInfo nativeInfo = new() - { - AbiVersion = 3, - GameInputApiVersion = 0x12345678, - PointerSize = GameInputAbiInfo.Managed.PointerSize, - ShimInfoSize = GameInputAbiInfo.Managed.ShimInfoSize, - RuntimeProbeInfoSize = GameInputAbiInfo.Managed.RuntimeProbeInfoSize, - DeviceInfoSize = GameInputAbiInfo.Managed.DeviceInfoSize, - GamepadStateSize = GameInputAbiInfo.Managed.GamepadStateSize, - DiagnosticsSnapshotSize = GameInputAbiInfo.Managed.DiagnosticsSnapshotSize, - AttemptedModuleKind = (uint)GameInputShimModuleKind.RegistryGameInputRedist, - LoadedModuleKind = (uint)GameInputShimModuleKind.RegistryGameInputRedist, - LoadLibraryHResult = unchecked((int)0x8007007E), - GetProcAddressHResult = 0, - InitializeHResult = unchecked((int)0x80004005), - FinalHResult = unchecked((int)0x80004005), - LoadLibraryWin32Error = 126, - GetProcAddressWin32Error = 0, - InitializeWin32Error = 0, - StringTruncationFlags = (uint)GameInputStringTruncationFlags.AttemptedModulePath - }; - - byte* attemptedModulePath = nativeInfo.AttemptedModulePath; - byte* loadedModulePath = nativeInfo.LoadedModulePath; - WriteUtf8(attemptedModulePath, 512, @"C:\Program Files\GameInput\GameInputRedist.dll"); - WriteUtf8(loadedModulePath, 512, @"C:\Program Files\GameInput\GameInputRedist.dll"); - - GameInputRuntimeProbeInfo probeInfo = nativeInfo.ToProbeInfo(); - - Assert.Equal(GameInputAbiInfo.Managed, probeInfo.AbiInfo); - Assert.Equal(GameInputShimModuleKind.RegistryGameInputRedist, probeInfo.AttemptedModuleKind); - Assert.Equal(GameInputShimModuleKind.RegistryGameInputRedist, probeInfo.LoadedModuleKind); - Assert.Equal(unchecked((int)0x8007007E), probeInfo.LoadLibraryHResult); - Assert.Equal(unchecked((int)0x80004005), probeInfo.InitializeHResult); - Assert.Equal(126u, probeInfo.LoadLibraryWin32Error); - Assert.True(probeInfo.StringTruncationFlags.HasFlag(GameInputStringTruncationFlags.AttemptedModulePath)); - Assert.Equal(@"C:\Program Files\GameInput\GameInputRedist.dll", probeInfo.AttemptedModulePath); - } - - /// - /// Shim diagnostics counter 應只作為診斷快照保留,不與 gamepad state edge detection 混在一起。 - /// - [Fact] - public void ToDiagnosticsSnapshot_NativeCounters_PreservesStaleReadingMetadata() - { - GameInputNativeDiagnosticsSnapshot nativeSnapshot = new() - { - MissingReadingCount = 3, - RepeatedTimestampCount = 4, - BackwardTimestampCount = 5, - DeviceUnavailableRefreshCount = 6, - LastReadingTimestamp = 7, - LastReadHResult = unchecked((int)0x8007048F), - LastReadDeviceStatus = 0x20 - }; - - GameInputDiagnosticsSnapshot snapshot = nativeSnapshot.ToDiagnosticsSnapshot(); - - Assert.Equal(3ul, snapshot.MissingReadingCount); - Assert.Equal(4ul, snapshot.RepeatedTimestampCount); - Assert.Equal(5ul, snapshot.BackwardTimestampCount); - Assert.Equal(6ul, snapshot.DeviceUnavailableRefreshCount); - Assert.Equal(7ul, snapshot.LastReadingTimestamp); - Assert.Equal(unchecked((int)0x8007048F), snapshot.LastReadHResult); - Assert.Equal(0x20u, snapshot.LastReadDeviceStatus); - } - - /// - /// Managed GameInput kind 應維持 gamepad-only subset,不暴露 keyboard、mouse、sensor 或 raw report 路徑。 - /// - [Fact] - public void GameInputKind_ManagedSubset_OnlyExposesUnknownAndGamepad() - { - GameInputKind[] values = Enum.GetValues(); - - Assert.Equal([GameInputKind.Unknown, GameInputKind.Gamepad], values); - } - - /// - /// Native device info 擴充欄位應完整轉成 managed model,保留 VID/PID、版本、PnP path 與 extra control indexes。 - /// - [Fact] - public unsafe void ToDeviceInfo_ExtendedNativeFields_PreservesGamepadCapabilities() - { - GameInputNativeDeviceInfo nativeInfo = new() - { - VendorId = 0x054C, - ProductId = 0x0CE6, - RevisionNumber = 7, - UsagePage = 1, - UsageId = 5, - DeviceFamily = 3, - SupportedInput = (uint)GameInputKind.Gamepad, - SupportedRumbleMotors = (uint)(GameInputRumbleMotors.LowFrequency | GameInputRumbleMotors.HighFrequency), - SupportedSystemButtons = 0x12, - GamepadSupportedLayout = (uint)(GameInputGamepadButtons.A | GameInputGamepadButtons.B | GameInputGamepadButtons.PaddleLeft1), - GamepadExtraButtonCount = 2, - GamepadExtraAxisCount = 1, - ForceFeedbackMotorCount = 4, - InputReportCount = 2, - OutputReportCount = 1, - ExtraButtonCount = 2, - ExtraAxisCount = 1, - ExtraButtonIndexCount = 2, - ExtraAxisIndexCount = 1, - HasInputMapper = 1, - StringTruncationFlags = (uint)GameInputStringTruncationFlags.PnpPath, - HardwareVersion = new GameInputNativeVersionInfo { Major = 1, Minor = 2, Build = 3, Revision = 4 }, - FirmwareVersion = new GameInputNativeVersionInfo { Major = 5, Minor = 6, Build = 7, Revision = 8 } - }; - - byte* deviceId = nativeInfo.DeviceId; - byte* deviceRootId = nativeInfo.DeviceRootId; - byte* containerId = nativeInfo.ContainerId; - byte* displayName = nativeInfo.DisplayName; - byte* pnpPath = nativeInfo.PnpPath; - byte* extraButtonIndexes = nativeInfo.ExtraButtonIndexes; - byte* extraAxisIndexes = nativeInfo.ExtraAxisIndexes; - - WriteUtf8(deviceId, 65, "00112233445566778899AABBCCDDEEFF"); - WriteUtf8(deviceRootId, 65, "FFEEDDCCBBAA99887766554433221100"); - WriteUtf8(containerId, 39, "{00112233-4455-6677-8899-AABBCCDDEEFF}"); - WriteUtf8(displayName, 256, "DualSense Wireless Controller"); - WriteUtf8(pnpPath, 512, @"HID\VID_054C&PID_0CE6"); - extraButtonIndexes[0] = 4; - extraButtonIndexes[1] = 5; - extraAxisIndexes[0] = 6; - - GameInputDeviceInfo info = nativeInfo.ToDeviceInfo(); - - Assert.Equal("00112233445566778899AABBCCDDEEFF", info.DeviceId); - Assert.Equal("FFEEDDCCBBAA99887766554433221100", info.DeviceRootId); - Assert.Equal("{00112233-4455-6677-8899-AABBCCDDEEFF}", info.ContainerId); - Assert.Equal("DualSense Wireless Controller", info.GetDisplayName()); - Assert.Equal(@"HID\VID_054C&PID_0CE6", info.PnpPath); - Assert.Equal((ushort)0x054C, info.VendorId); - Assert.Equal((ushort)0x0CE6, info.ProductId); - Assert.Equal((ushort)7, info.RevisionNumber); - Assert.Equal(GameInputStringTruncationFlags.PnpPath, info.StringTruncationFlags); - Assert.Equal(new GameInputVersionInfo(1, 2, 3, 4), info.HardwareVersion); - Assert.Equal(new GameInputVersionInfo(5, 6, 7, 8), info.FirmwareVersion); - Assert.True(info.GamepadCapabilities.HasInputMapper); - Assert.Equal(GameInputGamepadButtons.A | GameInputGamepadButtons.B | GameInputGamepadButtons.PaddleLeft1, info.GamepadCapabilities.SupportedLayout); - Assert.Equal(new byte[] { 4, 5 }, info.GamepadCapabilities.ExtraButtonIndexes); - Assert.Equal(new byte[] { 6 }, info.GamepadCapabilities.ExtraAxisIndexes); - } - - private static GameInputNativeShimInfo CreateNativeShimInfo() - => new() - { - AbiVersion = 3, - GameInputApiVersion = 0x12345678, - PointerSize = GameInputAbiInfo.Managed.PointerSize, - ShimInfoSize = GameInputAbiInfo.Managed.ShimInfoSize, - RuntimeProbeInfoSize = GameInputAbiInfo.Managed.RuntimeProbeInfoSize, - DeviceInfoSize = GameInputAbiInfo.Managed.DeviceInfoSize, - GamepadStateSize = GameInputAbiInfo.Managed.GamepadStateSize, - DiagnosticsSnapshotSize = GameInputAbiInfo.Managed.DiagnosticsSnapshotSize, - LoadedModuleKind = (uint)GameInputShimModuleKind.SystemGameInput - }; - - private static string FindRepositoryRoot() - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - - while (directory != null) - { - string candidate = Path.Combine(directory.FullName, "src", "InputBox", "Core", "Input", "GameInputNative.cs"); - if (File.Exists(candidate)) - { - return directory.FullName; - } - - directory = directory.Parent; - } - - throw new InvalidOperationException("找不到 InputBox repository root。"); - } - - private static unsafe void WriteUtf8(byte* destination, int destinationLength, string value) - { - byte[] bytes = Encoding.UTF8.GetBytes(value); - int count = Math.Min(bytes.Length, destinationLength - 1); - - for (int i = 0; i < count; i++) - { - destination[i] = bytes[i]; - } - - destination[count] = 0; - } -} diff --git a/tests/InputBox.Tests/GamepadControllerFactoryTests.cs b/tests/InputBox.Tests/GamepadControllerFactoryTests.cs index d2ccda6..dbacc67 100644 --- a/tests/InputBox.Tests/GamepadControllerFactoryTests.cs +++ b/tests/InputBox.Tests/GamepadControllerFactoryTests.cs @@ -54,14 +54,14 @@ public async Task CreateAsync_GameInputProviderAndFactorySucceeds_UsesGameInputC } /// - /// 使用者設定為 GameInput 但 shim 或 runtime 初始化失敗時,應退避為 XInput 並保留原始例外。 + /// 使用者設定為 GameInput 但 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."); + InvalidOperationException failure = new("GameInput runtime unavailable."); GamepadControllerCreationResult result = await GamepadControllerFactory.CreateAsync( AppSettings.GamepadProvider.GameInput, @@ -248,4 +248,4 @@ private void TouchRegisteredEvents() _ = RightTriggerRepeat; } } -} \ No newline at end of file +} diff --git a/tests/InputBox.Tests/GamepadControllerPauseTests.cs b/tests/InputBox.Tests/GamepadControllerPauseTests.cs index d1d3ba7..070d623 100644 --- a/tests/InputBox.Tests/GamepadControllerPauseTests.cs +++ b/tests/InputBox.Tests/GamepadControllerPauseTests.cs @@ -1,6 +1,8 @@ using InputBox.Core.Configuration; using InputBox.Core.Input; using InputBox.Core.Interop; +using InputWeave.GameInput; +using InputWeave.GameInput.Interop; using System.Reflection; using Xunit; @@ -87,8 +89,8 @@ public void Pause_GameInputController_ClearsTransientRuntimeState() SetPrivateField(controller, "_rtRepeatCounter", 6); SetPrivateField(controller, "_currentRTRepeatInterval", 1); SetPrivateField(controller, "_rsRepeatDirection", 1); - SetPrivateField(controller, "_repeatDirection", GameInputGamepadButtons.DPadRight); - SetPrivateField(controller, "_previousProcessedButtons", GameInputGamepadButtons.DPadRight); + SetPrivateField(controller, "_repeatDirection", GameInputGamepadButtons.GameInputGamepadDPadRight); + SetPrivateField(controller, "_previousProcessedButtons", GameInputGamepadButtons.GameInputGamepadDPadRight); SetPrivateField(controller, "_hasPreviousState", true); controller.Pause(); @@ -128,7 +130,7 @@ public void PrimeResumeStateFromSnapshot_WhenIdle_ClearsNeutralGate() SetPrivateField(controller, "_requireNeutralBeforeInput", true); AppSettings.GamepadConfigSnapshot config = AppSettings.Current.GamepadSettings; - GamepadStateSnapshot idleState = CreateGameInputSnapshot(new GameInputGamepadState()); + GamepadReadingSnapshot idleState = CreateGameInputSnapshot(new GameInputGamepadState()); _ = primeMethod.Invoke(controller, [idleState, config]); @@ -238,7 +240,7 @@ public void IsConnectedStatus_GameInputDeviceStatus_FiltersUnavailableDevices() BindingFlags.Static | BindingFlags.NonPublic) ?? throw new InvalidOperationException("找不到 GameInputGamepadController.IsConnectedStatus。"); - Assert.True((bool)method.Invoke(null, [GameInputDeviceStatus.Connected])!); + Assert.True((bool)method.Invoke(null, [GameInputDeviceStatus.GameInputDeviceConnected])!); Assert.False((bool)method.Invoke(null, [(GameInputDeviceStatus)0])!); } @@ -323,14 +325,9 @@ private static void InvokeClearAllEvents(object controller) /// /// 原始 GameInput 狀態。 /// 包裝後的快照物件。 - private static GamepadStateSnapshot CreateGameInputSnapshot(GameInputGamepadState state) + private static GamepadReadingSnapshot CreateGameInputSnapshot(GameInputGamepadState state) { - return (GamepadStateSnapshot)Activator.CreateInstance( - typeof(GamepadStateSnapshot), - BindingFlags.Instance | BindingFlags.NonPublic, - binder: null, - args: [state], - culture: null)!; + return new GamepadReadingSnapshot(0, state); } /// @@ -369,4 +366,4 @@ private static T GetPrivateField(object target, string name) return (T)value; } -} \ No newline at end of file +} diff --git a/tests/InputBox.Tests/GamepadFaceButtonProfileTests.cs b/tests/InputBox.Tests/GamepadFaceButtonProfileTests.cs index ceca928..5826a8b 100644 --- a/tests/InputBox.Tests/GamepadFaceButtonProfileTests.cs +++ b/tests/InputBox.Tests/GamepadFaceButtonProfileTests.cs @@ -55,7 +55,7 @@ public void ResolveEffectiveLayout_AutoWithNintendoGameInput_ReturnsNintendo() } /// - /// Auto 模式即使只取得自有 shim 保留的 Sony VID/PID,也應解析為 PlayStation 國際配置。 + /// Auto 模式即使只取得 GameInput 裝置識別保留的 Sony VID/PID,也應解析為 PlayStation 國際配置。 /// [Fact] public void ResolveEffectiveLayout_AutoWithSonyVidIdentity_ReturnsPlayStationCrossConfirm() @@ -70,7 +70,7 @@ public void ResolveEffectiveLayout_AutoWithSonyVidIdentity_ReturnsPlayStationCro } /// - /// Auto 模式即使只取得自有 shim 保留的 Nintendo VID/PID,也應解析為 Nintendo 配置。 + /// Auto 模式即使只取得 GameInput 裝置識別保留的 Nintendo VID/PID,也應解析為 Nintendo 配置。 /// [Fact] public void ResolveEffectiveLayout_AutoWithNintendoVidIdentity_ReturnsNintendo() @@ -320,4 +320,4 @@ public void MenuCheckedMode_AutoWithSonyDevice_ReturnsEffectivePlayStationLayout Assert.Equal(AppSettings.GamepadFaceButtonMode.Xbox, checkedMode); } -} \ No newline at end of file +} diff --git a/tests/InputBox.Tests/InputBox.Tests.csproj b/tests/InputBox.Tests/InputBox.Tests.csproj index 3e2e3cc..0b89395 100644 --- a/tests/InputBox.Tests/InputBox.Tests.csproj +++ b/tests/InputBox.Tests/InputBox.Tests.csproj @@ -13,6 +13,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md index d0fe79c..4e396ff 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -20,12 +20,12 @@ | `FloatingPointFormatConverterTests` | `FloatingPointFormatConverter` 字串轉換 | 16 | | `FormInputStateManagerTests` | `FormInputStateManager` 輸入狀態切換 | 15 | | `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載),含正負方向對稱性守門(硬體平等原則) | 14 | -| `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 shim/runtime 不可用時退避至 XInput | 3 | -| `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput missing reading 斷線重列舉、裝置狀態過濾、震動停止安全性、`ClearAllEvents` 肩鍵釋放訂閱清除與原生對話框切換時的殘留輸入回歸保護 | 10 | -| `GameInputPrimitivesTests` | 自有 GameInput shim 受控資料模型,涵蓋官方 v3 gamepad button 位元、timestamp 診斷欄位、edge detection timestamp 忽略、runtime probe/ABI size 診斷、Gamepad-only 邊界、P/Invoke export 宣告清單與擴充 device info / capabilities 轉換 | 10 | +| `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 GameInput 執行階段不可用時退避至 XInput | 3 | +| `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput 讀取缺失斷線重列舉、裝置狀態過濾、震動停止安全性、`ClearAllEvents` 肩鍵釋放訂閱清除與原生對話框切換時的殘留輸入回歸保護 | 10 | +| `GameInputDirectUsageTests` | `GameInputGamepadController` 直接使用 `InputWeave.GameInput` 遊戲控制器介面的守門、官方 v3 按鍵位元、快照邊緣偵測、穩定裝置識別與震動參數保留 | 7 | | `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 | +| `GamepadFaceButtonProfileTests` | `GamepadFaceButtonProfile` 的 Auto 解析、手動覆寫優先權、GameInput 裝置識別保留 VID/PID 時的 Sony/Nintendo 判斷,以及 Xbox / PlayStation / Nintendo 模式的按鍵標示、助記詞同步、資源化字串、主畫面說明文字、目前生效配置顯示、標題列提示、選單勾選邏輯與 PlayStation ○/× 確認模式回歸保護 | 25 | | `GamepadShoulderShortcutArbiterTests` | `GamepadShoulderShortcutArbiter` 的肩鍵單按、連發、修飾鍵與雙肩鍵組合仲裁回歸保護 | 4 | | `GamepadMappedDirectionGuardTests` | `GamepadMappedDirectionGuard` 全方向幽靈保護的封鎖/解除節奏 | 2 | | `GamepadRepeatSettingsTests` | `GamepadRepeatSettings` 預設值與 `Validate()` | 7 | @@ -45,7 +45,7 @@ | `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 | | `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 | | `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 | -| **合計** | | **386** | +| **合計** | | **383** | ## 二、執行方式 🚀 @@ -109,7 +109,7 @@ xUnit v3 為每個 `[Fact]` 建立獨立的測試類別實例,`IDisposable.Dis ### 4. 第三方測試函式庫與授權 📦 -本測試專案會使用第三方函式庫作為測試框架、Coverage 與 WinForms UI 冒煙測試用途。 +本測試專案會使用第三方函式庫作為測試框架、覆蓋率、WinForms UI 冒煙測試與 GameInput 介面守門用途。 > 下列名稱已對齊 [InputBox.Tests.csproj](InputBox.Tests.csproj) 中的 NuGet 套件名稱;若 GitHub 原始碼儲存庫名稱不同,會另外標示為「原始碼儲存庫」。 @@ -121,6 +121,7 @@ xUnit v3 為每個 `[Fact]` 建立獨立的測試類別實例,`IDisposable.Dis - Microsoft.Testing.Extensions.CodeCoverage:原始碼儲存庫為 [microsoft/codecoverage](https://github.com/microsoft/codecoverage),由 [Microsoft](https://github.com/microsoft) 及其 [貢獻者](https://github.com/microsoft/codecoverage/graphs/contributors) 開發並採用 [MIT License](https://github.com/microsoft/codecoverage/blob/main/LICENSE) 授權,用於測試覆蓋率收集。 - FlaUI.Core:原始碼儲存庫為 [FlaUI/FlaUI](https://github.com/FlaUI/FlaUI),由 [Roman Baeriswyl](https://github.com/Roemer) 及其 [貢獻者](https://github.com/FlaUI/FlaUI/graphs/contributors) 開發並採用 [MIT License](https://github.com/FlaUI/FlaUI/blob/main/LICENSE.txt) 授權,作為 Windows UI 自動化核心函式庫。 - FlaUI.UIA3:原始碼儲存庫為 [FlaUI/FlaUI](https://github.com/FlaUI/FlaUI),由 [Roman Baeriswyl](https://github.com/Roemer) 及其 [貢獻者](https://github.com/FlaUI/FlaUI/graphs/contributors) 開發並採用 [MIT License](https://github.com/FlaUI/FlaUI/blob/main/LICENSE.txt) 授權,作為 UIA3 後端,用於 WinForms UI 冒煙測試。 +- InputWeave.GameInput 0.0.1:原始碼儲存庫固定於 [rubujo/InputWeave.GameInput 封裝來源](https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065),由 [rubujo](https://github.com/rubujo) 及其 [貢獻者](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [CC0 1.0 Universal](https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE) 授權,作為 GameInput 直接使用與震動型別守門測試依賴;套件固定於 [../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg](../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg),SHA256 為 `64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805`。 本測試專案的相關說明詳見本文件;主專案授權與完整聲明仍以 [../../README.md](../../README.md) 及 [../../LICENSE](../../LICENSE) 為準。 diff --git a/tools/Validate-GameInputNativeShim.ps1 b/tools/Validate-GameInputNativeShim.ps1 deleted file mode 100644 index 60c49bd..0000000 --- a/tools/Validate-GameInputNativeShim.ps1 +++ /dev/null @@ -1,650 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$NativeShimPath, - - [Parameter(Mandatory = $true)] - [string]$ManagedSourcePath, - - [string]$DumpBinPath -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -function Resolve-RequiredPath { - param( - [Parameter(Mandatory = $true)] - [string]$Path, - - [Parameter(Mandatory = $true)] - [string]$Description - ) - - if (-not (Test-Path -LiteralPath $Path)) { - throw "找不到 $Description:$Path" - } - - return (Resolve-Path -LiteralPath $Path).Path -} - -function Get-DumpBin { - if (-not [string]::IsNullOrWhiteSpace($DumpBinPath)) { - return Resolve-RequiredPath -Path $DumpBinPath -Description 'dumpbin.exe' - } - - $command = Get-Command dumpbin.exe -ErrorAction SilentlyContinue - if ($command) { - return $command.Source - } - - $programFilesX86 = ${env:ProgramFiles(x86)} - if (-not [string]::IsNullOrWhiteSpace($programFilesX86)) { - $vswhere = Join-Path $programFilesX86 'Microsoft Visual Studio\Installer\vswhere.exe' - if (Test-Path -LiteralPath $vswhere) { - $installPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath - if (-not [string]::IsNullOrWhiteSpace($installPath)) { - $toolsRoot = Join-Path $installPath 'VC\Tools\MSVC' - if (Test-Path -LiteralPath $toolsRoot) { - $candidates = Get-ChildItem -LiteralPath $toolsRoot -Directory | - Sort-Object -Property Name -Descending | - ForEach-Object { - Join-Path $_.FullName 'bin\Hostx64\x64\dumpbin.exe' - Join-Path $_.FullName 'bin\Hostx86\x64\dumpbin.exe' - } | - Where-Object { Test-Path -LiteralPath $_ } - - $dumpbin = $candidates | Select-Object -First 1 - if ($dumpbin) { - return $dumpbin - } - } - } - } - } - - throw '找不到 dumpbin.exe,請確認 Visual Studio C++ 工具鏈已安裝。' -} - -function Get-ExpectedExports { - param( - [Parameter(Mandatory = $true)] - [string]$Path - ) - - $source = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 - $matches = [regex]::Matches($source, 'EntryPoint\s*=\s*"(?InputBoxGameInput[^"]+)"') - $exports = @($matches | - ForEach-Object { $_.Groups['name'].Value } | - Sort-Object -Unique) - - if ($exports.Count -eq 0) { - throw "未在 $Path 找到任何 InputBoxGameInput* P/Invoke EntryPoint。" - } - - return @($exports) -} - -function Test-NativeExports { - param( - [Parameter(Mandatory = $true)] - [string]$NativeShim, - - [Parameter(Mandatory = $true)] - [string]$ManagedSource - ) - - $dumpbin = Get-DumpBin - $expectedExports = Get-ExpectedExports -Path $ManagedSource - $dumpbinOutput = & $dumpbin /exports $NativeShim - - if ($LASTEXITCODE -ne 0) { - throw "dumpbin /exports 執行失敗,ExitCode=$LASTEXITCODE。" - } - - $missingExports = @(foreach ($export in $expectedExports) { - if (-not ($dumpbinOutput -match "\b$([regex]::Escape($export))\b")) { - $export - } - }) - - if ($missingExports.Count -gt 0) { - throw "GameInput native shim 缺少必要 export:$($missingExports -join ', ')" - } - - Write-Host "GameInput native exports 驗證通過:$($expectedExports.Count) 個 managed P/Invoke EntryPoint 皆存在。" -} - -function ConvertTo-UInt32HexValue { - param( - [Parameter(Mandatory = $true)] - [int]$Value - ) - - return [BitConverter]::ToUInt32([BitConverter]::GetBytes($Value), 0) -} - -function Invoke-ProbeSmoke { - param( - [Parameter(Mandatory = $true)] - [string]$NativeShim - ) - - $nativeShimLiteral = $NativeShim.Replace('\', '\\').Replace('"', '\"') - $typeName = 'InputBoxGameInputNativeSmoke' + [Guid]::NewGuid().ToString('N') - $source = @" -using System; -using System.Runtime.InteropServices; - -public static unsafe class $typeName -{ - [StructLayout(LayoutKind.Sequential)] - public struct VersionInfo - { - public ushort Major; - public ushort Minor; - public ushort Build; - public ushort Revision; - } - - [StructLayout(LayoutKind.Sequential)] - public unsafe struct ShimInfo - { - public uint AbiVersion; - public uint GameInputApiVersion; - public uint PointerSize; - public uint ShimInfoSize; - public uint RuntimeProbeInfoSize; - public uint DeviceInfoSize; - public uint GamepadStateSize; - public uint DiagnosticsSnapshotSize; - public uint LoadedModuleKind; - public fixed byte LoadedModulePath[512]; - } - - [StructLayout(LayoutKind.Sequential)] - public unsafe struct RuntimeProbeInfo - { - public uint AbiVersion; - public uint GameInputApiVersion; - public uint PointerSize; - public uint ShimInfoSize; - public uint RuntimeProbeInfoSize; - public uint DeviceInfoSize; - public uint GamepadStateSize; - public uint DiagnosticsSnapshotSize; - public uint AttemptedModuleKind; - public uint LoadedModuleKind; - public int LoadLibraryHResult; - public int GetProcAddressHResult; - public int InitializeHResult; - public int FinalHResult; - public uint LoadLibraryWin32Error; - public uint GetProcAddressWin32Error; - public uint InitializeWin32Error; - public uint StringTruncationFlags; - public fixed byte AttemptedModulePath[512]; - public fixed byte LoadedModulePath[512]; - } - - [StructLayout(LayoutKind.Sequential)] - public unsafe struct DeviceInfo - { - public ushort VendorId; - public ushort ProductId; - public ushort RevisionNumber; - public ushort UsagePage; - public ushort UsageId; - public ushort Reserved; - public uint DeviceFamily; - public uint SupportedInput; - public uint SupportedRumbleMotors; - public uint SupportedSystemButtons; - public uint GamepadSupportedLayout; - public uint GamepadExtraButtonCount; - public uint GamepadExtraAxisCount; - public uint ForceFeedbackMotorCount; - public uint InputReportCount; - public uint OutputReportCount; - public uint ExtraButtonCount; - public uint ExtraAxisCount; - public uint ExtraButtonIndexCount; - public uint ExtraAxisIndexCount; - public uint HasInputMapper; - public uint StringTruncationFlags; - public VersionInfo HardwareVersion; - public VersionInfo FirmwareVersion; - public fixed byte ExtraButtonIndexes[32]; - public fixed byte ExtraAxisIndexes[32]; - public fixed byte DeviceId[65]; - public fixed byte DeviceRootId[65]; - public fixed byte ContainerId[39]; - public fixed byte DisplayName[256]; - public fixed byte PnpPath[512]; - } - - [StructLayout(LayoutKind.Sequential)] - public struct GamepadState - { - public ulong Timestamp; - public uint InputKind; - public uint Buttons; - public float LeftTrigger; - public float RightTrigger; - public float LeftThumbstickX; - public float LeftThumbstickY; - public float RightThumbstickX; - public float RightThumbstickY; - } - - [StructLayout(LayoutKind.Sequential)] - public struct DiagnosticsSnapshot - { - public ulong MissingReadingCount; - public ulong RepeatedTimestampCount; - public ulong BackwardTimestampCount; - public ulong DeviceUnavailableRefreshCount; - public ulong LastReadingTimestamp; - public int LastReadHResult; - public uint LastReadDeviceStatus; - public uint Reserved; - } - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputProbeRuntime", CallingConvention = CallingConvention.StdCall)] - public static extern int ProbeRuntime(out RuntimeProbeInfo info); - - public static uint GetPointerSize() => (uint)IntPtr.Size; - - public static uint GetShimInfoSize() => (uint)Marshal.SizeOf(); - - public static uint GetRuntimeProbeInfoSize() => (uint)Marshal.SizeOf(); - - public static uint GetDeviceInfoSize() => (uint)Marshal.SizeOf(); - - public static uint GetGamepadStateSize() => (uint)Marshal.SizeOf(); - - public static uint GetDiagnosticsSnapshotSize() => (uint)Marshal.SizeOf(); -} -"@ - - $smokeType = Add-Type -TypeDefinition $source -Language CSharp -CompilerOptions '/unsafe' -PassThru | - Where-Object { $_.Name -eq $typeName } | - Select-Object -First 1 - if ($smokeType -eq $null) { - throw '無法建立 GameInput native probe smoke 型別。' - } - - $probeType = $smokeType.GetNestedType('RuntimeProbeInfo') - $probe = [Activator]::CreateInstance($probeType) - $arguments = @($probe) - $hr = [int]$smokeType.GetMethod('ProbeRuntime').Invoke($null, $arguments) - $probe = $arguments[0] - - if ($probe.AbiVersion -eq 0) { - throw 'GameInput native probe 未回報 ABI version。' - } - - if ($probe.PointerSize -ne [uint32][IntPtr]::Size) { - throw "GameInput native probe 指標大小不符:native=$($probe.PointerSize), process=$([IntPtr]::Size)。" - } - - $expectedSizes = @{ - PointerSize = [uint32]$smokeType.GetMethod('GetPointerSize').Invoke($null, @()) - ShimInfoSize = [uint32]$smokeType.GetMethod('GetShimInfoSize').Invoke($null, @()) - RuntimeProbeInfoSize = [uint32]$smokeType.GetMethod('GetRuntimeProbeInfoSize').Invoke($null, @()) - DeviceInfoSize = [uint32]$smokeType.GetMethod('GetDeviceInfoSize').Invoke($null, @()) - GamepadStateSize = [uint32]$smokeType.GetMethod('GetGamepadStateSize').Invoke($null, @()) - DiagnosticsSnapshotSize = [uint32]$smokeType.GetMethod('GetDiagnosticsSnapshotSize').Invoke($null, @()) - } - - foreach ($name in $expectedSizes.Keys) { - $actual = [uint32]$probe.$name - $expected = $expectedSizes[$name] - if ($actual -ne $expected) { - throw "GameInput native probe ABI size mismatch: $name native=$actual, managed=$expected。" - } - } - - Write-Host ("GameInput native probe smoke 通過:hr=0x{0:X8}, abi={1}, api=0x{2:X8}, pointer={3}, shimInfoSize={4}, runtimeProbeInfoSize={5}, deviceInfoSize={6}, gamepadStateSize={7}, diagnosticsSnapshotSize={8}, finalHr=0x{9:X8}, initHr=0x{10:X8}" -f ` - (ConvertTo-UInt32HexValue -Value $hr), - $probe.AbiVersion, - $probe.GameInputApiVersion, - $probe.PointerSize, - $probe.ShimInfoSize, - $probe.RuntimeProbeInfoSize, - $probe.DeviceInfoSize, - $probe.GamepadStateSize, - $probe.DiagnosticsSnapshotSize, - (ConvertTo-UInt32HexValue -Value $probe.FinalHResult), - (ConvertTo-UInt32HexValue -Value $probe.InitializeHResult)) - - return [pscustomobject]@{ - HResult = $hr - FinalHResult = [int]$probe.FinalHResult - InitializeHResult = [int]$probe.InitializeHResult - } -} - -function Invoke-LifecycleStressSmoke { - param( - [Parameter(Mandatory = $true)] - [string]$NativeShim, - - [Parameter(Mandatory = $true)] - [pscustomobject]$ProbeInfo - ) - - $nativeShimLiteral = $NativeShim.Replace('\', '\\').Replace('"', '\"') - $typeName = 'InputBoxGameInputNativeLifecycleSmoke' + [Guid]::NewGuid().ToString('N') - $source = @" -using System; -using System.Runtime.InteropServices; -using System.Threading; - -public static unsafe class $typeName -{ - private const uint GameInputKindGamepad = 0x00040000; - private const uint GameInputDeviceStatusAny = 0xFFFFFFFF; - private const uint GameInputEnumerationNone = 0; - private const int HResultNotFound = unchecked((int)0x80070490); - - private static int s_readingCallbackCount; - private static int s_deviceCallbackCount; - - [StructLayout(LayoutKind.Sequential)] - public unsafe struct ShimInfo - { - public uint AbiVersion; - public uint GameInputApiVersion; - public uint PointerSize; - public uint ShimInfoSize; - public uint RuntimeProbeInfoSize; - public uint DeviceInfoSize; - public uint GamepadStateSize; - public uint DiagnosticsSnapshotSize; - public uint LoadedModuleKind; - public fixed byte LoadedModulePath[512]; - } - - [StructLayout(LayoutKind.Sequential)] - public struct GamepadState - { - public ulong Timestamp; - public uint InputKind; - public uint Buttons; - public float LeftTrigger; - public float RightTrigger; - public float LeftThumbstickX; - public float LeftThumbstickY; - public float RightThumbstickX; - public float RightThumbstickY; - } - - [StructLayout(LayoutKind.Sequential)] - public struct DiagnosticsSnapshot - { - public ulong MissingReadingCount; - public ulong RepeatedTimestampCount; - public ulong BackwardTimestampCount; - public ulong DeviceUnavailableRefreshCount; - public ulong LastReadingTimestamp; - public int LastReadHResult; - public uint LastReadDeviceStatus; - public uint Reserved; - } - - [UnmanagedFunctionPointer(CallingConvention.StdCall)] - public delegate void ReadingCallback(IntPtr context, ref GamepadState state); - - [UnmanagedFunctionPointer(CallingConvention.StdCall)] - public delegate void DeviceCallback( - IntPtr context, - IntPtr deviceId, - ulong timestamp, - uint currentStatus, - uint previousStatus); - - private static readonly ReadingCallback ReadingCallbackRoot = OnReadingCallback; - private static readonly DeviceCallback DeviceCallbackRoot = OnDeviceCallback; - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputCreate", CallingConvention = CallingConvention.StdCall)] - public static extern int Create(out IntPtr context); - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputDestroy", CallingConvention = CallingConvention.StdCall)] - public static extern void Destroy(IntPtr context); - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputGetShimInfo", CallingConvention = CallingConvention.StdCall)] - public static extern int GetShimInfo(IntPtr context, out ShimInfo info); - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputRefreshDevices", CallingConvention = CallingConvention.StdCall)] - public static extern int RefreshDevices(IntPtr context); - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputGetDeviceCount", CallingConvention = CallingConvention.StdCall)] - public static extern int GetDeviceCount(IntPtr context); - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputRegisterReadingCallback", CallingConvention = CallingConvention.StdCall)] - public static extern int RegisterReadingCallback( - IntPtr context, - IntPtr deviceId, - uint kind, - ReadingCallback callback, - IntPtr callbackContext, - out ulong callbackToken); - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputRegisterDeviceCallback", CallingConvention = CallingConvention.StdCall)] - public static extern int RegisterDeviceCallback( - IntPtr context, - IntPtr deviceId, - uint kind, - uint statusFilter, - uint enumerationKind, - DeviceCallback callback, - IntPtr callbackContext, - out ulong callbackToken); - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputGetDiagnosticsSnapshot", CallingConvention = CallingConvention.StdCall)] - public static extern int GetDiagnosticsSnapshot(IntPtr context, out DiagnosticsSnapshot snapshot); - - [DllImport("$nativeShimLiteral", EntryPoint = "InputBoxGameInputUnregisterCallback", CallingConvention = CallingConvention.StdCall)] - public static extern int UnregisterCallback(IntPtr context, ulong callbackToken); - - public static int TryCreateAndDestroy() - { - IntPtr context = IntPtr.Zero; - int hr = Create(out context); - - if (context != IntPtr.Zero) - { - Destroy(context); - } - - return hr; - } - - public static string RunStress(int iterations) - { - int explicitUnregisterCount = 0; - int destroyCleanupCount = 0; - int doubleUnregisterNotFoundCount = 0; - int maxDeviceCount = 0; - - for (int i = 0; i < iterations; i++) - { - IntPtr context = IntPtr.Zero; - ulong readingToken = 0; - ulong deviceToken = 0; - bool destroyOwnsCallbacks = (i % 2) == 1; - - try - { - int hr = Create(out context); - ThrowIfFailed(hr, "Create"); - - if (context == IntPtr.Zero) - { - throw new InvalidOperationException("Create 回傳成功但 context 為空。"); - } - - ShimInfo shimInfo; - ThrowIfFailed(GetShimInfo(context, out shimInfo), "GetShimInfo"); - ThrowIfFailed(RefreshDevices(context), "RefreshDevices"); - - int deviceCount = GetDeviceCount(context); - if (deviceCount < 0) - { - throw new InvalidOperationException("GetDeviceCount 回傳負值。"); - } - - maxDeviceCount = Math.Max(maxDeviceCount, deviceCount); - - ThrowIfFailed( - RegisterDeviceCallback( - context, - IntPtr.Zero, - GameInputKindGamepad, - GameInputDeviceStatusAny, - GameInputEnumerationNone, - DeviceCallbackRoot, - IntPtr.Zero, - out deviceToken), - "RegisterDeviceCallback"); - EnsureToken(deviceToken, "RegisterDeviceCallback"); - - ThrowIfFailed( - RegisterReadingCallback( - context, - IntPtr.Zero, - GameInputKindGamepad, - ReadingCallbackRoot, - IntPtr.Zero, - out readingToken), - "RegisterReadingCallback"); - EnsureToken(readingToken, "RegisterReadingCallback"); - - DiagnosticsSnapshot diagnostics; - ThrowIfFailed(GetDiagnosticsSnapshot(context, out diagnostics), "GetDiagnosticsSnapshot"); - - if (!destroyOwnsCallbacks) - { - ThrowIfFailed(UnregisterCallback(context, readingToken), "Unregister reading callback"); - int secondReadingUnregister = UnregisterCallback(context, readingToken); - if (secondReadingUnregister != HResultNotFound) - { - throw new InvalidOperationException( - string.Format( - "第二次解除 reading callback 應回傳 not found,但得到 0x{0:X8}。", - unchecked((uint)secondReadingUnregister))); - } - - doubleUnregisterNotFoundCount++; - ThrowIfFailed(UnregisterCallback(context, deviceToken), "Unregister device callback"); - explicitUnregisterCount++; - readingToken = 0; - deviceToken = 0; - } - else - { - destroyCleanupCount++; - } - } - finally - { - if (context != IntPtr.Zero) - { - Destroy(context); - } - - GC.KeepAlive(ReadingCallbackRoot); - GC.KeepAlive(DeviceCallbackRoot); - } - } - - return string.Format( - "iterations={0}, explicitUnregister={1}, destroyCleanup={2}, doubleUnregisterNotFound={3}, maxDeviceCount={4}, readingCallbacks={5}, deviceCallbacks={6}", - iterations, - explicitUnregisterCount, - destroyCleanupCount, - doubleUnregisterNotFoundCount, - maxDeviceCount, - Volatile.Read(ref s_readingCallbackCount), - Volatile.Read(ref s_deviceCallbackCount)); - } - - private static void OnReadingCallback(IntPtr context, ref GamepadState state) - { - Interlocked.Increment(ref s_readingCallbackCount); - } - - private static void OnDeviceCallback( - IntPtr context, - IntPtr deviceId, - ulong timestamp, - uint currentStatus, - uint previousStatus) - { - Interlocked.Increment(ref s_deviceCallbackCount); - } - - private static void ThrowIfFailed(int hr, string operation) - { - if (hr < 0) - { - throw new InvalidOperationException( - string.Format( - "{0} failed: 0x{1:X8}", - operation, - unchecked((uint)hr))); - } - } - - private static void EnsureToken(ulong callbackToken, string operation) - { - if (callbackToken == 0) - { - throw new InvalidOperationException(operation + " 回傳成功但 callback token 為 0。"); - } - } -} -"@ - - $stressType = Add-Type -TypeDefinition $source -Language CSharp -CompilerOptions '/unsafe' -PassThru | - Where-Object { $_.Name -eq $typeName } | - Select-Object -First 1 - if ($stressType -eq $null) { - throw '無法建立 GameInput native lifecycle smoke 型別。' - } - - $createHr = [int]$stressType.GetMethod('TryCreateAndDestroy').Invoke($null, @()) - if ($createHr -lt 0) { - if ($ProbeInfo.FinalHResult -eq 0 -or $ProbeInfo.InitializeHResult -eq 0) { - throw ("GameInput native lifecycle smoke 建立 context 失敗,但 probe 顯示 runtime 初始化成功:createHr=0x{0:X8}, finalHr=0x{1:X8}, initHr=0x{2:X8}" -f ` - (ConvertTo-UInt32HexValue -Value $createHr), - (ConvertTo-UInt32HexValue -Value $ProbeInfo.FinalHResult), - (ConvertTo-UInt32HexValue -Value $ProbeInfo.InitializeHResult)) - } - - Write-Warning ("GameInput native lifecycle smoke 已略過:runtime/context 不可用,createHr=0x{0:X8}, finalHr=0x{1:X8}, initHr=0x{2:X8}。" -f ` - (ConvertTo-UInt32HexValue -Value $createHr), - (ConvertTo-UInt32HexValue -Value $ProbeInfo.FinalHResult), - (ConvertTo-UInt32HexValue -Value $ProbeInfo.InitializeHResult)) - return - } - - try { - $summary = [string]$stressType.GetMethod('RunStress').Invoke($null, @(16)) - Write-Host "GameInput native lifecycle stress smoke 通過:$summary" - } - catch [System.Reflection.TargetInvocationException] { - if ($_.Exception.InnerException -ne $null) { - throw $_.Exception.InnerException - } - - throw - } -} - -$resolvedNativeShim = Resolve-RequiredPath -Path $NativeShimPath -Description 'GameInput native shim' -$resolvedManagedSource = Resolve-RequiredPath -Path $ManagedSourcePath -Description 'managed GameInputNative.cs' - -Test-NativeExports -NativeShim $resolvedNativeShim -ManagedSource $resolvedManagedSource -$probeInfo = Invoke-ProbeSmoke -NativeShim $resolvedNativeShim -Invoke-LifecycleStressSmoke -NativeShim $resolvedNativeShim -ProbeInfo $probeInfo