From 3f5c1281536cb091957c112e54450abbc42ee5e0 Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Mon, 25 May 2026 22:38:08 +0800 Subject: [PATCH 1/6] =?UTF-8?q?docs(agent):=20=E9=87=8D=E6=95=B4=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=A6=8F=E7=AF=84=E8=88=87=E6=8A=80=E8=83=BD=E7=B5=90?= =?UTF-8?q?=E6=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依 Codex CLI、Claude Code、GitHub Copilot CLI 與 Antigravity CLI 官方文件收斂共同入口。 移除過時 Gemini 與 Copilot mirror 入口,改以 AGENTS.md、.agents/skills 與 Claude thin bridge 維護單一權威鏈。 --- .agents/skills/inputbox-dev/SKILL.md | 26 ++-- .claude/skills/inputbox-dev/SKILL.md | 16 +++ .github/copilot-instructions.md | 15 --- AGENTS.md | 190 +++++---------------------- CLAUDE.md | 10 +- ENGINEERING_GUIDELINES.md | 10 +- GEMINI.md | 16 --- InputBox.slnx | 10 +- 8 files changed, 81 insertions(+), 212 deletions(-) create mode 100644 .claude/skills/inputbox-dev/SKILL.md delete mode 100644 .github/copilot-instructions.md delete mode 100644 GEMINI.md 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/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..b0ab9f6 100644 --- a/InputBox.slnx +++ b/InputBox.slnx @@ -4,9 +4,12 @@ - - + + + + + @@ -24,8 +27,9 @@ + + - From b56d170bb49514d47f45afcb31d4acbcc4fa4bad Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Tue, 26 May 2026 01:33:55 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(gameinput):=20=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20InputWeave.GameInput?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 InputBox 自有 GameInput native shim 與舊 adapter 型別,改由 GameInputGamepadController 直接使用 repo-local InputWeave.GameInput nupkg。 同步更新 CI/release、授權聲明、工程文件與測試,保留 GameInput 失敗退避 XInput、60 FPS polling、callback 喚醒與震動安全邏輯。 --- .github/workflows/ci.yml | 515 +++-- .github/workflows/release.yml | 1127 +++++----- .gitignore | 3 +- InputBox.slnx | 10 +- NuGet.config | 16 + README.md | 5 +- .../gameinput-hardware-verification.md | 12 +- docs/engineering/gamepad-api.md | 15 +- eng/nuget/InputWeave.GameInput.0.0.1.nupkg | Bin 0 -> 103089 bytes .../InputWeave.GameInput.0.0.1.nupkg.sha256 | 1 + eng/nuget/InputWeave.GameInput_LICENSE.txt | 16 + .../InputBox.GameInput.Native.vcxproj | 88 - .../InputBoxGameInputNative.cpp | 1957 ----------------- src/InputBox.GameInput.Native/README.md | 93 - .../Core/Input/GameInputGamepadController.cs | 557 +++-- src/InputBox/Core/Input/GameInputNative.cs | 763 ------- .../Core/Input/GameInputPrimitives.cs | 725 ------ src/InputBox/Core/Interop/DllResolver.cs | 49 +- src/InputBox/InputBox.csproj | 53 +- .../GameInputDirectUsageTests.cs | 206 ++ .../GameInputPrimitivesTests.cs | 363 --- .../GamepadControllerFactoryTests.cs | 6 +- .../GamepadControllerPauseTests.cs | 21 +- .../GamepadFaceButtonProfileTests.cs | 6 +- tests/InputBox.Tests/InputBox.Tests.csproj | 1 + tests/InputBox.Tests/README.md | 8 +- tools/Validate-GameInputNativeShim.ps1 | 650 ------ 27 files changed, 1481 insertions(+), 5785 deletions(-) create mode 100644 NuGet.config create mode 100644 eng/nuget/InputWeave.GameInput.0.0.1.nupkg create mode 100644 eng/nuget/InputWeave.GameInput.0.0.1.nupkg.sha256 create mode 100644 eng/nuget/InputWeave.GameInput_LICENSE.txt delete mode 100644 src/InputBox.GameInput.Native/InputBox.GameInput.Native.vcxproj delete mode 100644 src/InputBox.GameInput.Native/InputBoxGameInputNative.cpp delete mode 100644 src/InputBox.GameInput.Native/README.md delete mode 100644 src/InputBox/Core/Input/GameInputNative.cs delete mode 100644 src/InputBox/Core/Input/GameInputPrimitives.cs create mode 100644 tests/InputBox.Tests/GameInputDirectUsageTests.cs delete mode 100644 tests/InputBox.Tests/GameInputPrimitivesTests.cs delete mode 100644 tools/Validate-GameInputNativeShim.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 701adb1..6115aa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,266 +1,249 @@ -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: 驗證 repo-local InputWeave.GameInput nupkg + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" + $expected = "64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805" + + if (-not (Test-Path $packagePath)) { + Write-Error "找不到 repo-local InputWeave.GameInput nupkg:$packagePath" + } + + $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Write-Error "InputWeave.GameInput nupkg SHA256 不符。Expected=$expected Actual=$actual" + } + + Write-Host "InputWeave.GameInput nupkg 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..7215782 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,543 +1,584 @@ -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: 驗證 repo-local InputWeave.GameInput nupkg + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" + $expected = "64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805" + + if (-not (Test-Path $packagePath)) { + Write-Error "找不到 repo-local InputWeave.GameInput nupkg:$packagePath" + } + + $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + Write-Error "InputWeave.GameInput nupkg SHA256 不符。Expected=$expected Actual=$actual" + } + + Write-Host "InputWeave.GameInput nupkg 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 '- Package: InputWeave.GameInput 0.0.1' + Add-Content -Path $noticePath -Encoding utf8 -Value '- Source: https://github.com/rubujo/InputWeave.GameInput' + Add-Content -Path $noticePath -Encoding utf8 -Value '- Package commit: 664a9d3e96458a49688ff2255a36f6e073977065' + Add-Content -Path $noticePath -Encoding utf8 -Value '- Package file: eng/nuget/InputWeave.GameInput.0.0.1.nupkg' + Add-Content -Path $noticePath -Encoding utf8 -Value '- SHA256: 64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805' + Add-Content -Path $noticePath -Encoding utf8 -Value '- License: CC0-1.0; see InputWeave.GameInput_LICENSE.txt in this Licenses directory.' + + - 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/InputBox.slnx b/InputBox.slnx index b0ab9f6..afdcf9e 100644 --- a/InputBox.slnx +++ b/InputBox.slnx @@ -9,6 +9,12 @@ + + + + + + @@ -19,9 +25,11 @@ + + @@ -31,9 +39,9 @@ + - diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..d66f404 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 7537236..b4ef549 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):由 InputWeave contributors 開發並採用 [**CC0-1.0**](https://creativecommons.org/publicdomain/zero/1.0/) 授權,提供 GameInput managed runtime/client/device/reading/rumble API。套件固定於 repo-local `eng/nuget/InputWeave.GameInput.0.0.1.nupkg`,package commit `664a9d3e96458a49688ff2255a36f6e073977065`,nupkg 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 0000000000000000000000000000000000000000..da3d81e842d98b364c03789d836d81f92de9309f GIT binary patch literal 103089 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

8-CCJ6garkoAM`H0&XM#<6 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 literal 0 HcmV?d00001 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..7b41d11 --- /dev/null +++ b/eng/nuget/InputWeave.GameInput_LICENSE.txt @@ -0,0 +1,16 @@ +InputWeave.GameInput 0.0.1 +=========================== + +Source: https://github.com/rubujo/InputWeave.GameInput +Package commit: 664a9d3e96458a49688ff2255a36f6e073977065 +Package file: eng/nuget/InputWeave.GameInput.0.0.1.nupkg +Package SHA256: 64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805 +Package license expression: CC0-1.0 + +InputWeave.GameInput is distributed under the Creative Commons CC0 1.0 +Universal public domain dedication. To the extent possible under law, the +authors have dedicated all copyright and related rights in this package to +the public domain worldwide. + +SPDX-License-Identifier: CC0-1.0 +License text: 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..377a328 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,173 @@ 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; + _ = GameInputRuntime.TryProbe(out probeInfo); + + return true; + } + 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 +1261,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 +1273,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 +1287,7 @@ private void Poll() else { _missingReadingFrameCounter = 0; + RecordSuccessfulReading(currentSnapshot, dev); if (!_hasPreviousState || _previousState == null || @@ -1134,6 +1299,7 @@ private void Poll() } catch (Exception ex) { + RecordReadFailure(ex); Debug.WriteLine($"[GameInput] 讀取目前狀態失敗,稍後重新整理裝置:{ex.Message}"); if (HandleMissingCurrentReading()) @@ -1146,9 +1312,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 +1357,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 +1474,7 @@ private bool ShouldRefreshAfterMissingCurrentReading() /// 評估方向幽靈重入狀態:處理「狀態有變化但方向持續誤判」情境。 /// private void EvaluateDirectionalGhostState( - GamepadStateSnapshot state, + GamepadReadingSnapshot state, AppSettings.GamepadConfigSnapshot config) { if (!HasActiveDirectionalRepeat()) @@ -1332,7 +1498,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 +1509,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 +1582,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 +1613,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 +1657,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 +1678,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 +1699,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 +1718,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 +1768,7 @@ private void ResetTransientInputState(bool requireNeutralBeforeInput = true) /// private void TryFindDevice() { - GameInput? gameInput = _gameInput; + GameInputClient? gameInput = _gameInput; if (gameInput == null) { @@ -1617,7 +1783,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 +1794,8 @@ private void TryFindDevice() try { - GameInputDeviceInfo oldInfo = oldDev.GetDeviceInfo(); + GameInputDeviceInfoSnapshot oldInfo = oldDev.GetDeviceInfoSnapshot(); + string oldDeviceId = GetStableDeviceId(oldInfo); bool stillExists = IsDeviceConnected(oldDev); @@ -1643,7 +1812,7 @@ private void TryFindDevice() continue; } - if (candidate.GetDeviceInfo().DeviceId.Equals(oldInfo.DeviceId)) + if (GetStableDeviceId(candidate.GetDeviceInfoSnapshot()).Equals(oldDeviceId, StringComparison.Ordinal)) { stillExists = true; @@ -1704,13 +1873,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 +1956,7 @@ private static bool IsDeviceConnected(GameInputDevice device) { try { - return IsConnectedStatus(device.GetDeviceStatus()); + return IsConnectedStatus(device.Status); } catch (Exception ex) { @@ -1802,7 +1972,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 +1980,7 @@ private static bool IsConnectedStatus(GameInputDeviceStatus status) /// 若有切換至其他裝置,則回傳 true;否則回傳 false。 private bool ScanForActiveDevice() { - GameInput? gameInput = _gameInput; + GameInputClient? gameInput = _gameInput; if (gameInput == null) { @@ -1833,21 +2003,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 +2058,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 +2075,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 +2114,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 +2152,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 +2168,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 +2272,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 +2395,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 +2413,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 +2431,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 +2449,7 @@ private void ApplyStickToButtons( /// 遊戲控制器設定快照 private void DetectRisingEdge( GameInputGamepadButtons currentButtons, - GamepadStateSnapshot currentState, + GamepadReadingSnapshot currentState, float correctedRightThumbX, AppSettings.GamepadConfigSnapshot config) { @@ -2328,32 +2489,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 +2547,7 @@ static void DetectReleased( } bool prevLtDown = _hasPreviousState && - _previousState!.LeftTrigger > AppSettings.GameInputTriggerThreshold; + _previousState!.State.LeftTrigger > AppSettings.GameInputTriggerThreshold; if (IsLeftTriggerHeld && !prevLtDown) @@ -2395,7 +2556,7 @@ static void DetectReleased( } bool prevRtDown = _hasPreviousState && - _previousState!.RightTrigger > AppSettings.GameInputTriggerThreshold; + _previousState!.State.RightTrigger > AppSettings.GameInputTriggerThreshold; if (IsRightTriggerHeld && !prevRtDown) @@ -2419,10 +2580,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 +2594,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 +2606,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 +2695,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 +2946,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 +3154,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 +3206,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..54be7cd 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 | +| `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 GameInput 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 | +| `GameInputDirectUsageTests` | `GameInputGamepadController` 直接使用 `InputWeave.GameInput` gamepad API 的 surface 守門、官方 v3 gamepad button 位元、snapshot edge detection、穩定裝置識別與 rumble 參數保留 | 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** | ## 二、執行方式 🚀 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 From d0bfecc010ccc932b66ecd7a6dc02dbe766b6f06 Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Tue, 26 May 2026 02:00:53 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(nuget):=20=E4=BF=AE=E6=AD=A3=20Linux=20?= =?UTF-8?q?package=20source=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 將 repo-local InputWeave.GameInput source 改為跨平台路徑,並補上 InputWeave wildcard mapping,讓 GitHub Automatic Dependency Submission 在 Ubuntu runner 上能解析本地 nupkg。 --- NuGet.config | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NuGet.config b/NuGet.config index d66f404..b1aeb40 100644 --- a/NuGet.config +++ b/NuGet.config @@ -2,12 +2,15 @@ - + + + + From ccf3e9d75e979a6416b6113b6133ec772577604f Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Tue, 26 May 2026 11:26:49 +0800 Subject: [PATCH 4/6] =?UTF-8?q?docs(readme):=20=E8=A3=9C=E9=BD=8A=20InputW?= =?UTF-8?q?eave=20=E6=8E=88=E6=AC=8A=E9=80=A3=E7=B5=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 讓 README、測試文件與 release ThirdPartyNotices 的 InputWeave.GameInput 來源、contributors、LICENSE 與 package commit 聲明保持一致。 --- .github/workflows/release.yml | 7 ++++--- README.md | 2 +- eng/nuget/InputWeave.GameInput_LICENSE.txt | 6 ++++-- tests/InputBox.Tests/README.md | 3 ++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7215782..8a56eab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -232,11 +232,12 @@ jobs: Add-Content -Path $noticePath -Encoding utf8 -Value '## InputWeave.GameInput' Add-Content -Path $noticePath -Encoding utf8 -Value '' Add-Content -Path $noticePath -Encoding utf8 -Value '- Package: InputWeave.GameInput 0.0.1' - Add-Content -Path $noticePath -Encoding utf8 -Value '- Source: https://github.com/rubujo/InputWeave.GameInput' - Add-Content -Path $noticePath -Encoding utf8 -Value '- Package commit: 664a9d3e96458a49688ff2255a36f6e073977065' + Add-Content -Path $noticePath -Encoding utf8 -Value '- Source: https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065' + Add-Content -Path $noticePath -Encoding utf8 -Value '- Contributors: https://github.com/rubujo/InputWeave.GameInput/graphs/contributors' + Add-Content -Path $noticePath -Encoding utf8 -Value '- Package commit: https://github.com/rubujo/InputWeave.GameInput/commit/664a9d3e96458a49688ff2255a36f6e073977065' Add-Content -Path $noticePath -Encoding utf8 -Value '- Package file: eng/nuget/InputWeave.GameInput.0.0.1.nupkg' Add-Content -Path $noticePath -Encoding utf8 -Value '- SHA256: 64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805' - Add-Content -Path $noticePath -Encoding utf8 -Value '- License: CC0-1.0; see InputWeave.GameInput_LICENSE.txt in this Licenses directory.' + Add-Content -Path $noticePath -Encoding utf8 -Value '- License: CC0-1.0; see InputWeave.GameInput_LICENSE.txt in this Licenses directory and https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE.' - name: 產生執行檔的雜湊值(SHA256) shell: pwsh diff --git a/README.md b/README.md index b4ef549..e6931cb 100644 --- a/README.md +++ b/README.md @@ -592,7 +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):由 InputWeave contributors 開發並採用 [**CC0-1.0**](https://creativecommons.org/publicdomain/zero/1.0/) 授權,提供 GameInput managed runtime/client/device/reading/rumble API。套件固定於 repo-local `eng/nuget/InputWeave.GameInput.0.0.1.nupkg`,package commit `664a9d3e96458a49688ff2255a36f6e073977065`,nupkg SHA256:`64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805`。 +- [InputWeave.GameInput 0.0.1](https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065):由 [InputWeave contributors](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [**CC0-1.0**](https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE) 授權,提供 GameInput managed runtime/client/device/reading/rumble API。套件固定於 repo-local `eng/nuget/InputWeave.GameInput.0.0.1.nupkg`,package commit [`664a9d3e96458a49688ff2255a36f6e073977065`](https://github.com/rubujo/InputWeave.GameInput/commit/664a9d3e96458a49688ff2255a36f6e073977065),nupkg 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 套件授權聲明清單)、`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/eng/nuget/InputWeave.GameInput_LICENSE.txt b/eng/nuget/InputWeave.GameInput_LICENSE.txt index 7b41d11..a92a43b 100644 --- a/eng/nuget/InputWeave.GameInput_LICENSE.txt +++ b/eng/nuget/InputWeave.GameInput_LICENSE.txt @@ -1,11 +1,13 @@ InputWeave.GameInput 0.0.1 =========================== -Source: https://github.com/rubujo/InputWeave.GameInput -Package commit: 664a9d3e96458a49688ff2255a36f6e073977065 +Source: https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065 +Contributors: https://github.com/rubujo/InputWeave.GameInput/graphs/contributors +Package commit: https://github.com/rubujo/InputWeave.GameInput/commit/664a9d3e96458a49688ff2255a36f6e073977065 Package file: eng/nuget/InputWeave.GameInput.0.0.1.nupkg Package SHA256: 64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805 Package license expression: CC0-1.0 +Package license file: https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE InputWeave.GameInput is distributed under the Creative Commons CC0 1.0 Universal public domain dedication. To the extent possible under law, the diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md index 54be7cd..53d0ef1 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -109,7 +109,7 @@ xUnit v3 為每個 `[Fact]` 建立獨立的測試類別實例,`IDisposable.Dis ### 4. 第三方測試函式庫與授權 📦 -本測試專案會使用第三方函式庫作為測試框架、Coverage 與 WinForms UI 冒煙測試用途。 +本測試專案會使用第三方函式庫作為測試框架、Coverage、WinForms UI 冒煙測試與 GameInput API surface 守門用途。 > 下列名稱已對齊 [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 package commit](https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065),由 [InputWeave contributors](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [CC0-1.0](https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE) 授權,作為 GameInput direct usage 與 rumble 型別守門測試依賴;nupkg 固定於 [../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg](../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg),SHA256 為 `64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805`。 本測試專案的相關說明詳見本文件;主專案授權與完整聲明仍以 [../../README.md](../../README.md) 及 [../../LICENSE](../../LICENSE) 為準。 From 8bf1163133df81f8813def16fa02030c89d26f51 Mon Sep 17 00:00:00 2001 From: perditavojo <117562794+perditavojo@users.noreply.github.com> Date: Tue, 26 May 2026 22:02:13 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs(readme):=20=E7=B5=B1=E4=B8=80=20InputW?= =?UTF-8?q?eave=20=E6=8E=88=E6=AC=8A=E7=94=A8=E8=AA=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 調整 README、測試文件與發佈聲明的 InputWeave.GameInput 描述,使用中文優先的授權與來源用語,並只在 SPDX 識別脈絡保留 CC0-1.0。 --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 21 ++++++++++--------- README.md | 2 +- eng/nuget/InputWeave.GameInput_LICENSE.txt | 24 +++++++++++----------- tests/InputBox.Tests/README.md | 10 ++++----- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6115aa0..c972da4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,7 @@ jobs: eng/nuget/*.sha256 .config/dotnet-tools.json - - name: 驗證 repo-local InputWeave.GameInput nupkg + - name: 驗證本儲存庫 InputWeave.GameInput NuGet 套件 shell: pwsh run: | $ErrorActionPreference = 'Stop' @@ -102,15 +102,15 @@ jobs: $expected = "64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805" if (-not (Test-Path $packagePath)) { - Write-Error "找不到 repo-local InputWeave.GameInput nupkg:$packagePath" + Write-Error "找不到本儲存庫 InputWeave.GameInput NuGet 套件:$packagePath" } $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $expected) { - Write-Error "InputWeave.GameInput nupkg SHA256 不符。Expected=$expected Actual=$actual" + Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" } - Write-Host "InputWeave.GameInput nupkg SHA256 驗證通過:$actual" + Write-Host "InputWeave.GameInput 套件 SHA256 驗證通過:$actual" - name: 還原主專案 NuGet 套件 run: dotnet restore ./src/InputBox/InputBox.csproj --force-evaluate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a56eab..5923528 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: dotnet-version: '${{ env.DOTNET_MAJOR_VERSION }}.x' # Release 觸發頻率遠低於 cache TTL(7 天),不快取以避免無效寫入。 - - name: 驗證 repo-local InputWeave.GameInput nupkg + - name: 驗證本儲存庫 InputWeave.GameInput NuGet 套件 shell: pwsh run: | $ErrorActionPreference = 'Stop' @@ -79,15 +79,15 @@ jobs: $expected = "64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805" if (-not (Test-Path $packagePath)) { - Write-Error "找不到 repo-local InputWeave.GameInput nupkg:$packagePath" + Write-Error "找不到本儲存庫 InputWeave.GameInput NuGet 套件:$packagePath" } $actual = (Get-FileHash $packagePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $expected) { - Write-Error "InputWeave.GameInput nupkg SHA256 不符。Expected=$expected Actual=$actual" + Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" } - Write-Host "InputWeave.GameInput nupkg SHA256 驗證通過:$actual" + Write-Host "InputWeave.GameInput 套件 SHA256 驗證通過:$actual" - name: 還原 NuGet 套件 run: dotnet restore ./src/InputBox/InputBox.csproj --force-evaluate @@ -231,13 +231,14 @@ jobs: 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 '- Package: InputWeave.GameInput 0.0.1' - Add-Content -Path $noticePath -Encoding utf8 -Value '- Source: https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065' - Add-Content -Path $noticePath -Encoding utf8 -Value '- Contributors: https://github.com/rubujo/InputWeave.GameInput/graphs/contributors' - Add-Content -Path $noticePath -Encoding utf8 -Value '- Package commit: https://github.com/rubujo/InputWeave.GameInput/commit/664a9d3e96458a49688ff2255a36f6e073977065' - Add-Content -Path $noticePath -Encoding utf8 -Value '- Package file: eng/nuget/InputWeave.GameInput.0.0.1.nupkg' + 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 '- License: CC0-1.0; see InputWeave.GameInput_LICENSE.txt in this Licenses directory and https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE.' + 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 diff --git a/README.md b/README.md index e6931cb..55cdf3d 100644 --- a/README.md +++ b/README.md @@ -592,7 +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):由 [InputWeave contributors](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [**CC0-1.0**](https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE) 授權,提供 GameInput managed runtime/client/device/reading/rumble API。套件固定於 repo-local `eng/nuget/InputWeave.GameInput.0.0.1.nupkg`,package commit [`664a9d3e96458a49688ff2255a36f6e073977065`](https://github.com/rubujo/InputWeave.GameInput/commit/664a9d3e96458a49688ff2255a36f6e073977065),nupkg SHA256:`64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805`。 +- [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 套件授權聲明清單)、`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/eng/nuget/InputWeave.GameInput_LICENSE.txt b/eng/nuget/InputWeave.GameInput_LICENSE.txt index a92a43b..66fddfe 100644 --- a/eng/nuget/InputWeave.GameInput_LICENSE.txt +++ b/eng/nuget/InputWeave.GameInput_LICENSE.txt @@ -1,18 +1,18 @@ InputWeave.GameInput 0.0.1 =========================== -Source: https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065 -Contributors: https://github.com/rubujo/InputWeave.GameInput/graphs/contributors -Package commit: https://github.com/rubujo/InputWeave.GameInput/commit/664a9d3e96458a49688ff2255a36f6e073977065 -Package file: eng/nuget/InputWeave.GameInput.0.0.1.nupkg -Package SHA256: 64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805 -Package license expression: CC0-1.0 -Package license file: https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE +來源: 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 is distributed under the Creative Commons CC0 1.0 -Universal public domain dedication. To the extent possible under law, the -authors have dedicated all copyright and related rights in this package to -the public domain worldwide. +InputWeave.GameInput 採用 Creative Commons CC0 1.0 Universal 公眾領域貢獻宣告。 +在法律允許的最大範圍內,作者已將此套件的著作權與相關權利貢獻至全球公眾領域。 SPDX-License-Identifier: CC0-1.0 -License text: https://creativecommons.org/publicdomain/zero/1.0/legalcode +授權全文: https://creativecommons.org/publicdomain/zero/1.0/legalcode diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md index 53d0ef1..4e396ff 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -20,9 +20,9 @@ | `FloatingPointFormatConverterTests` | `FloatingPointFormatConverter` 字串轉換 | 16 | | `FormInputStateManagerTests` | `FormInputStateManager` 輸入狀態切換 | 15 | | `GamepadDeadzoneHysteresisTests` | `GamepadDeadzoneHysteresis.ResolveDirection`(int / float 多載),含正負方向對稱性守門(硬體平等原則) | 14 | -| `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 GameInput runtime 不可用時退避至 XInput | 3 | -| `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput missing reading 斷線重列舉、裝置狀態過濾、震動停止安全性、`ClearAllEvents` 肩鍵釋放訂閱清除與原生對話框切換時的殘留輸入回歸保護 | 10 | -| `GameInputDirectUsageTests` | `GameInputGamepadController` 直接使用 `InputWeave.GameInput` gamepad API 的 surface 守門、官方 v3 gamepad button 位元、snapshot edge detection、穩定裝置識別與 rumble 參數保留 | 7 | +| `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 解析、手動覆寫優先權、GameInput 裝置識別保留 VID/PID 時的 Sony/Nintendo 判斷,以及 Xbox / PlayStation / Nintendo 模式的按鍵標示、助記詞同步、資源化字串、主畫面說明文字、目前生效配置顯示、標題列提示、選單勾選邏輯與 PlayStation ○/× 確認模式回歸保護 | 25 | @@ -109,7 +109,7 @@ xUnit v3 為每個 `[Fact]` 建立獨立的測試類別實例,`IDisposable.Dis ### 4. 第三方測試函式庫與授權 📦 -本測試專案會使用第三方函式庫作為測試框架、Coverage、WinForms UI 冒煙測試與 GameInput API surface 守門用途。 +本測試專案會使用第三方函式庫作為測試框架、覆蓋率、WinForms UI 冒煙測試與 GameInput 介面守門用途。 > 下列名稱已對齊 [InputBox.Tests.csproj](InputBox.Tests.csproj) 中的 NuGet 套件名稱;若 GitHub 原始碼儲存庫名稱不同,會另外標示為「原始碼儲存庫」。 @@ -121,7 +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 package commit](https://github.com/rubujo/InputWeave.GameInput/tree/664a9d3e96458a49688ff2255a36f6e073977065),由 [InputWeave contributors](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [CC0-1.0](https://github.com/rubujo/InputWeave.GameInput/blob/664a9d3e96458a49688ff2255a36f6e073977065/LICENSE) 授權,作為 GameInput direct usage 與 rumble 型別守門測試依賴;nupkg 固定於 [../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg](../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg),SHA256 為 `64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805`。 +- 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) 為準。 From e43dd6c7b4f67e879d6b1b0cd59eab17e9bec979 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 14:17:27 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(gameinput):=20=E4=BF=AE=E6=AD=A3=20prob?= =?UTF-8?q?e=20=E5=9B=9E=E5=82=B3=E8=88=87=20SHA256=20=E4=BE=86=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/rubujo/InputBox/sessions/db7a8c53-b76f-437a-898e-6a6bc634140f Co-authored-by: perditavojo <117562794+perditavojo@users.noreply.github.com> --- .github/workflows/ci.yml | 7 ++++++- .github/workflows/release.yml | 7 ++++++- src/InputBox/Core/Input/GameInputGamepadController.cs | 4 +--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c972da4..7812be5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,12 +99,17 @@ jobs: run: | $ErrorActionPreference = 'Stop' $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" - $expected = "64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805" + $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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5923528..ad6318b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,12 +76,17 @@ jobs: run: | $ErrorActionPreference = 'Stop' $packagePath = ".\eng\nuget\InputWeave.GameInput.0.0.1.nupkg" - $expected = "64d9ef502853130d4af8df7cc1c2777a67808ef668e41c22fc6b526b83ed2805" + $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" diff --git a/src/InputBox/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs index 377a328..3701043 100644 --- a/src/InputBox/Core/Input/GameInputGamepadController.cs +++ b/src/InputBox/Core/Input/GameInputGamepadController.cs @@ -1071,9 +1071,7 @@ private static bool TryProbeGameInputRuntime(out GameInputRuntimeProbeInfo probe { try { - _ = GameInputRuntime.TryProbe(out probeInfo); - - return true; + return GameInputRuntime.TryProbe(out probeInfo); } catch (Exception ex) {