diff --git a/.cargo/config.toml b/.cargo/config.toml index 2e1dac3..e2726c2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,9 @@ [target.x86_64-pc-windows-gnu] linker = "x86_64-w64-mingw32-gcc" ar = "x86_64-w64-mingw32-ar" + +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.i686-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/renovate.json5 b/.github/renovate.json5 index fd2f1a3..15383d4 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -70,6 +70,23 @@ 'custom.regex', ], }, + + // Windows Driver Kit (WDK) - keep within 10.0.* series + { + matchDatasources: [ + 'nuget', + ], + matchPackagePatterns: [ + 'Microsoft\\.Windows\\.DriverKit\\.Wdk', + ], + groupName: 'Windows Driver Kit', + groupSlug: 'wdk', + // Only allow patch and minor updates within the 10.0.* version range + allowedVersions: '/^10\\.0\\..+$/', + schedule: [ + 'before 3am on Monday', + ], + }, ], // Custom managers for non-standard dependency sources diff --git a/.github/scripts/diagnose-wdk-build.ps1 b/.github/scripts/diagnose-wdk-build.ps1 new file mode 100644 index 0000000..7cc8948 --- /dev/null +++ b/.github/scripts/diagnose-wdk-build.ps1 @@ -0,0 +1,34 @@ +# Diagnostic script for WDK build issues on Windows +# This script collects information useful for debugging wdk-sys bindgen failures + +Write-Host "=== WDK Build Environment Diagnostics ===" -ForegroundColor Cyan + +Write-Host "`nSystem Information:" +systeminfo | Select-String "OS Version", "Total Physical Memory" + +Write-Host "`nRust Toolchain:" +rustc --version +cargo --version + +Write-Host "`nWDK Installation Check:" +if (Test-Path "C:\Program Files (x86)\Windows Kits") { + Get-ChildItem "C:\Program Files (x86)\Windows Kits" | ForEach-Object { Write-Host " Found: $_" } +} else { + Write-Host " WARNING: Windows Kits directory not found" +} + +Write-Host "`nLLVM/Clang Check:" +clang --version 2>&1 | Select-Object -First 1 + +Write-Host "`nCargo Environment:" +$env:CARGO_BUILD_JOBS +Write-Host " CARGO_BUILD_JOBS: $($env:CARGO_BUILD_JOBS ?? 'not set')" +Write-Host " Available CPU cores: $([System.Environment]::ProcessorCount)" + +Write-Host "`nKnown Issues:" +Write-Host " - wdk-sys 0.5.1 has flaky bindgen thread failures on Windows CI" +Write-Host " - Likely causes: resource exhaustion, LLVM version incompatibility" +Write-Host " - Workaround: reduce parallel jobs with 'cargo build -j 2'" +Write-Host " - Reference: https://github.com/microsoft/windows-drivers-rs/discussions/591" + +Write-Host "`n=== End Diagnostics ===" -ForegroundColor Cyan diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4ab576..fe4c89a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,28 @@ name: CI +# NOTE: Windows jobs build sideblinder-driver with `-j 1`. This is NOT a +# performance tuning — removing it will break the build. `wdk-macros` 0.5.1 +# (a transitive dep via `wdk`) has a race during parallel proc-macro +# expansion: two threads call File::create on the same scratch `.lock` file +# and then LockFileEx, and Windows returns ERROR_INVALID_FUNCTION (os error 1) +# instead of the expected lock-violation error. Other crates build in parallel. +# See CLAUDE.md -> Building for details. Upstream fix tracked at +# microsoft/windows-drivers-rs#463 (migrating fs4 -> std::File::lock); once +# that lands, `-j 1` can be dropped here. + on: push: branches: [main] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: clippy: name: Clippy - runs-on: windows-latest + runs-on: windows-2022 permissions: contents: read steps: @@ -23,15 +37,62 @@ jobs: toolchain: 1.94.1 components: clippy + - name: Install LLVM 17 + shell: pwsh + run: | + $llvmVersion = "17.0.6" + $llvmUrl = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$llvmVersion/LLVM-$llvmVersion-win64.exe" + $llvmInstaller = "$env:TEMP\LLVM-$llvmVersion-win64.exe" + Write-Host "Downloading LLVM $llvmVersion from official release..." + curl.exe -L -o $llvmInstaller $llvmUrl + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to download LLVM" + exit 1 + } + Write-Host "Installing LLVM $llvmVersion..." + & $llvmInstaller /S /D="C:\LLVM" + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to install LLVM" + exit 1 + } + Write-Host "LLVM installed successfully" + + - name: Verify Windows SDK and WDK + shell: pwsh + run: | + Write-Host "Verifying Windows SDK and WDK installation..." + + # Verify WDK headers are available (windows-2022 includes them) + $wdk_paths = @( + "C:\Program Files (x86)\Windows Kits\10\Include", + "C:\Program Files\Windows Kits\10\Include" + ) + + $found = $false + foreach ($path in $wdk_paths) { + if (Test-Path $path) { + Write-Host "✓ Found Windows Kits at: $path" + $found = $true + break + } + } + + if (-not $found) { + Write-Host "ERROR: Windows Kits not found - WDK headers are required" + exit 1 + } + - name: Cache dependencies uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: | + cargo clippy --workspace --exclude sideblinder-driver --all-targets --all-features -- -D warnings + cargo clippy -p sideblinder-driver -j 1 --all-targets --all-features -- -D warnings test: name: Test - runs-on: windows-latest + runs-on: windows-2022 needs: clippy permissions: contents: read @@ -46,16 +107,79 @@ jobs: with: toolchain: 1.94.1 + - name: Cache LLVM 17 installer + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: C:\tools\LLVM-17.0.6-win64.exe + key: llvm-17.0.6-win64 + + - name: Install LLVM 17 + shell: pwsh + run: | + $llvmVersion = "17.0.6" + $llvmUrl = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$llvmVersion/LLVM-$llvmVersion-win64.exe" + $llvmInstaller = "C:\tools\LLVM-$llvmVersion-win64.exe" + $toolsDir = "C:\tools" + + if (-not (Test-Path $llvmInstaller)) { + if (-not (Test-Path $toolsDir)) { + New-Item -ItemType Directory -Path $toolsDir -Force | Out-Null + } + Write-Host "Downloading LLVM $llvmVersion from official release..." + curl.exe -L -o $llvmInstaller $llvmUrl + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to download LLVM" + exit 1 + } + } else { + Write-Host "Using cached LLVM installer" + } + + Write-Host "Installing LLVM $llvmVersion..." + & $llvmInstaller /S /D="C:\LLVM" + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to install LLVM" + exit 1 + } + Write-Host "LLVM installed successfully" + + - name: Verify Windows SDK and WDK + shell: pwsh + run: | + Write-Host "Verifying Windows SDK and WDK installation..." + + # Verify WDK headers are available (windows-2022 includes them) + $wdk_paths = @( + "C:\Program Files (x86)\Windows Kits\10\Include", + "C:\Program Files\Windows Kits\10\Include" + ) + + $found = $false + foreach ($path in $wdk_paths) { + if (Test-Path $path) { + Write-Host "✓ Found Windows Kits at: $path" + $found = $true + break + } + } + + if (-not $found) { + Write-Host "ERROR: Windows Kits not found - WDK headers are required" + exit 1 + } + - name: Cache dependencies uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Test - run: cargo test --locked + run: | + cargo test --workspace --exclude sideblinder-driver --locked + cargo test -p sideblinder-driver -j 1 --locked build: name: Release Build if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: windows-latest + runs-on: windows-2022 needs: test permissions: contents: read @@ -70,11 +194,74 @@ jobs: with: toolchain: 1.94.1 + - name: Cache LLVM 17 installer + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: C:\tools\LLVM-17.0.6-win64.exe + key: llvm-17.0.6-win64 + + - name: Install LLVM 17 + shell: pwsh + run: | + $llvmVersion = "17.0.6" + $llvmUrl = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$llvmVersion/LLVM-$llvmVersion-win64.exe" + $llvmInstaller = "C:\tools\LLVM-$llvmVersion-win64.exe" + $toolsDir = "C:\tools" + + if (-not (Test-Path $llvmInstaller)) { + if (-not (Test-Path $toolsDir)) { + New-Item -ItemType Directory -Path $toolsDir -Force | Out-Null + } + Write-Host "Downloading LLVM $llvmVersion from official release..." + curl.exe -L -o $llvmInstaller $llvmUrl + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to download LLVM" + exit 1 + } + } else { + Write-Host "Using cached LLVM installer" + } + + Write-Host "Installing LLVM $llvmVersion..." + & $llvmInstaller /S /D="C:\LLVM" + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to install LLVM" + exit 1 + } + Write-Host "LLVM installed successfully" + + - name: Verify Windows SDK and WDK + shell: pwsh + run: | + Write-Host "Verifying Windows SDK and WDK installation..." + + # Verify WDK headers are available (windows-2022 includes them) + $wdk_paths = @( + "C:\Program Files (x86)\Windows Kits\10\Include", + "C:\Program Files\Windows Kits\10\Include" + ) + + $found = $false + foreach ($path in $wdk_paths) { + if (Test-Path $path) { + Write-Host "✓ Found Windows Kits at: $path" + $found = $true + break + } + } + + if (-not $found) { + Write-Host "ERROR: Windows Kits not found - WDK headers are required" + exit 1 + } + - name: Cache dependencies uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Build - run: cargo build --release --locked + run: | + cargo build --workspace --exclude sideblinder-driver --release --locked + cargo build -p sideblinder-driver -j 1 --release --locked - name: Get version id: version diff --git a/.gitmodules b/.gitmodules index efd8d71..f09847a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,12 @@ -[submodule "joystick_gremlin"] - path = joystick_gremlin +[submodule "reference/joystick_gremlin"] + path = reference/joystick_gremlin url = https://github.com/WhiteMagic/JoystickGremlin.git -[submodule "mw5_ffb"] - path = mw5_ffb +[submodule "reference/mw5_ffb"] + path = reference/mw5_ffb url = https://github.com/HappyFox/MW5_FFB.git -[submodule "vjoy"] - path = vjoy +[submodule "reference/vjoy"] + path = reference/vjoy url = https://github.com/BrunnerInnovation/vJoy.git -[submodule "sidewinder-arduino"] - path = sidewinder-arduino - url = https://github.com/Poil/sidewinder-arduino +[submodule "reference/sidewinder-arduino"] + path = reference/sidewinder-arduino + url = https://github.com/Poil/sidewinder-arduino.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 465bce6..c171eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- IPC protocol versioning: GUI and app now detect version mismatches and fail fast with a user-facing error instead of silently corrupting data. Documented in `docs/ipc-protocol.md`. + ### Changed - All project artifacts renamed from `sidewinder` to `sideblinder` (crate names, binary names, config directory, named pipe, tray class). References to the "Microsoft Sidewinder Force Feedback 2" hardware are unchanged. - Each crate now carries its own independent version. The workspace-level version is managed separately from individual crates. +- **sideblinder-ipc**: IPC frame payload size increased from 22 to 23 bytes (now includes protocol version byte). Breaking change for external consumers of the wire format. +- **sideblinder-driver**: Force feedback queue now accepts shared references for push/pop, allowing concurrent UMDF callback access without exclusive ownership. ## [0.7.0] - 2026-04-14 diff --git a/CLAUDE.md b/CLAUDE.md index e0a40e2..eb26c96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,43 @@ # Sideblinder — Project Instructions +## Building + +### Windows: `-j 1` required for driver builds + +Any `cargo` command that transitively builds `sideblinder-driver` must run with +`-j 1` (or `CARGO_BUILD_JOBS=1`) on Windows. That includes `--workspace` builds, +`cargo clippy --all-targets`, and `cargo test`, since `sideblinder-driver` is a +default workspace member. + +```bash +CARGO_BUILD_JOBS=1 cargo build -p sideblinder-driver +# or for any workspace-wide command: +CARGO_BUILD_JOBS=1 cargo build --workspace --locked +``` + +**Why:** `wdk-macros` 0.5.1 (a transitive dep via `wdk`) races on a shared +`.lock` file inside `target/.../scratch-*/out/wdk_macros_ast_fragments/` during +parallel proc-macro expansion. Under contention, Windows' `LockFileEx` returns +`ERROR_INVALID_FUNCTION (os error 1)` instead of the expected lock-violation +error, and the build fails with `unable to create file lock guard, unable to +obtain file lock, Incorrect function. (os error 1)`. Upstream fix in flight at +[microsoft/windows-drivers-rs#463](https://github.com/microsoft/windows-drivers-rs/pull/463) +(migrates from `fs4` to `std::File::lock()`); revisit `-j 1` once that lands. + +The race only happens on the *first* build after `cargo clean` (when the +`cached_function_info_map.json` cache is empty). Once the cache is populated, +subsequent parallel builds are fine. CI always starts clean, so CI uses +`-j 1` unconditionally; see `.github/workflows/ci.yml`. + +### Don't build from `\\wsl$\...` / WSL drive mounts + +Check out and build the tree on a native NTFS path (e.g. `C:\...`). Building +from `\\wsl$\Ubuntu` or a mapped WSL drive (`W:`, etc.) fails at rustc's own +incremental compilation session lock with the same `ERROR_INVALID_FUNCTION` +error — the WSL 9P filesystem doesn't implement `LockFileEx` at all. Unlike the +wdk-macros bug, this one isn't fixable by `-j 1`; the filesystem itself can't +satisfy the API. + ## Versioning This project uses [Semantic Versioning](https://semver.org/). Every PR that includes significant @@ -156,3 +194,34 @@ Closes #N" ``` Do not create Yaks tasks (`yx add`, `yx state`, etc.) for this project. + +## Reference Code + +The `reference/` directory contains full source code of related projects for local +study and architectural reference. These are **not dependencies** — they are read-only +reference implementations to learn from. + +### When to use reference code + +- **Study patterns:** Before designing a feature (e.g., multi-device input handling, + plugin architecture), search `reference/` to see how established projects solve it +- **Verify design decisions:** When uncertain about an approach, compare against + reference implementations +- **Understand compatibility:** Check how other drivers/apps interact with the same + hardware or Windows APIs + +### Rules for using reference code + +1. **Never copy code directly** — always understand and rewrite in Sideblinder's style +2. **Credit inspiration** — if a reference implementation influences a design decision + or informs significant logic, note it in code comments (e.g., `// Inspired by vJoy's device state tracking`) +3. **Don't blindly follow patterns** — Sideblinder may have different constraints + (safety, driver signing, Windows version support). Adapt, don't replicate +4. **Keep reference code in sync** — treat `reference/` as snapshots. If you use a + reference project's pattern and later find it has a bug or improvement, consider + investigating the current upstream and updating your code accordingly +5. **Never modify reference code** — if you find bugs in reference projects, report + them upstream; do not patch `reference/` locally + +These boundaries preserve reference code as a **learning resource** while keeping +Sideblinder's codebase clean and original. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95da456..fb8778e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,20 +15,31 @@ Thank you for your interest in contributing. ### Build +> **Windows:** every workspace command below must run with `-j 1` / +> `CARGO_BUILD_JOBS=1` because `sideblinder-driver` pulls in `wdk-macros` +> 0.5.1, which has a parallel-proc-macro race that manifests as +> `Incorrect function. (os error 1)`. See `CLAUDE.md` → *Building* for +> the full story and upstream fix tracking. Also: don't check out or +> build from `\\wsl$\...` / mapped WSL drives — the 9P filesystem +> doesn't support `LockFileEx`. + ```bash -# Build all workspace crates (excludes the driver, which needs the WDK) -cargo build --workspace --locked +# Build all workspace crates (includes the driver on Windows; requires WDK) +CARGO_BUILD_JOBS=1 cargo build --workspace --locked # Build and test -cargo test --workspace --locked +CARGO_BUILD_JOBS=1 cargo test --workspace --locked # Lint -cargo clippy --all-targets --all-features -- -D warnings +CARGO_BUILD_JOBS=1 cargo clippy --all-targets --all-features -- -D warnings # Format check (not yet enforced in CI but recommended locally) cargo fmt --check --all ``` +On Linux/macOS the `-j 1` constraint doesn't apply — the driver crate is +Windows-only, so the bug is never hit. + ### Running the app locally ```powershell diff --git a/Cargo.lock b/Cargo.lock index d6e1f6d..b1cca26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,8 +93,8 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.16.1", "static_assertions", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -251,7 +251,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -477,6 +477,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -663,6 +683,38 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "castaway" version = "0.2.4" @@ -684,6 +736,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -705,6 +766,17 @@ dependencies = [ "libc", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.0" @@ -715,6 +787,16 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-cargo" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d546f0e84ff2bfa4da1ce9b54be42285767ba39c688572ca32412a09a73851e5" +dependencies = [ + "anstyle", + "clap", +] + [[package]] name = "clap_builder" version = "4.6.0" @@ -1505,6 +1587,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1625,6 +1717,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.17.0" @@ -1714,7 +1812,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows", + "windows 0.62.2", ] [[package]] @@ -1988,6 +2086,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2920,6 +3027,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "peniko" version = "0.6.0" @@ -3398,7 +3511,7 @@ dependencies = [ "compact_str", "hashbrown 0.16.1", "indoc", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "strum", @@ -3450,7 +3563,7 @@ dependencies = [ "hashbrown 0.16.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "strum", @@ -3641,6 +3754,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + [[package]] name = "sctk-adwaita" version = "0.10.1" @@ -3665,6 +3784,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3792,6 +3915,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sideblinder-driver" +version = "0.1.0" +dependencies = [ + "wdk", + "wdk-build", + "wdk-sys", +] + [[package]] name = "sideblinder-gui" version = "0.8.0" @@ -3823,7 +3955,7 @@ dependencies = [ [[package]] name = "sideblinder-ipc" -version = "0.8.0" +version = "0.9.0" dependencies = [ "thiserror 2.0.18", ] @@ -4495,7 +4627,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -4863,6 +4995,80 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "wdk" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd496a19ec75c3d98f8be805f62ebde4651fc01babf681b832d8bae9c584d25" +dependencies = [ + "cfg-if", + "tracing", + "tracing-subscriber", + "wdk-build", + "wdk-sys", +] + +[[package]] +name = "wdk-build" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c150122a579af759770b354064cd2994d29e97525d904f65ff1412ad5122766" +dependencies = [ + "anyhow", + "bindgen", + "camino", + "cargo_metadata", + "cfg-if", + "clap", + "clap-cargo", + "paste", + "regex", + "rustversion", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "windows 0.58.0", +] + +[[package]] +name = "wdk-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b288d5ef6b276345d197fe0b82ef274dcb5a1f658a2294c67ff85b775f63ee26" +dependencies = [ + "cfg-if", + "fs4", + "itertools 0.13.0", + "proc-macro2", + "quote", + "scratch", + "serde", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "wdk-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e13e19ed97609bc1d1236806019309ca2d47aaad6d3217ab374e73c9ff3b8a9" +dependencies = [ + "anyhow", + "bindgen", + "cargo_metadata", + "cc", + "cfg-if", + "rustversion", + "serde_json", + "thiserror 2.0.18", + "tracing", + "tracing-subscriber", + "wdk-build", + "wdk-macros", +] + [[package]] name = "web-sys" version = "0.3.95" @@ -5126,8 +5332,8 @@ dependencies = [ "web-sys", "wgpu-naga-bridge", "wgpu-types", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -5185,6 +5391,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.62.2" @@ -5192,7 +5408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -5203,7 +5419,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", ] [[package]] @@ -5212,11 +5441,11 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", - "windows-strings", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -5225,11 +5454,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5241,6 +5481,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5264,10 +5515,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5277,6 +5537,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 0306a65..420a525 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/sideblinder-diag", "crates/sideblinder-ipc", "crates/sideblinder-gui", + "crates/sideblinder-driver", ] [workspace.package] diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..6873eb9 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,6 @@ + + + + + + diff --git a/README.md b/README.md index 846c428..f50bbcc 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,28 @@ JAVA_HOME=/opt/homebrew/opt/openjdk@21 \ The decompiled C is not included in this repository as it is a derivative of Microsoft's copyrighted code. -### Reference repositories +### Reference code and implementations + +Full source code of reference projects is available in the `reference/` directory +for local study and comparison without needing to clone external repositories: + +- **Joystick Gremlin** — feature-rich joystick input mapper with plugin architecture + and profile system. Useful for understanding multi-device input handling patterns + and UI state management. +- **MW5_FFB** — MechWarrior 5 force-feedback plugin. Small focused codebase showing + FFB effect application and game integration patterns. +- **vJoy** — Virtual joystick driver for Windows. Reference architecture for virtual + device emulation and driver communication. + +Use `reference/` to study: +- Architecture patterns for multi-device input handling +- FFB effect mapping and application +- Virtual device driver design +- Plugin and profile configuration systems + +### Reference repositories (external) + +For latest versions and updates to reference projects: - [Joystick Gremlin](https://github.com/WhiteMagic/JoystickGremlin) - [MW5_FFB](https://github.com/HappyFox/MW5_FFB) diff --git a/crates/sideblinder-app/src/gui_pipe.rs b/crates/sideblinder-app/src/gui_pipe.rs index 35a3bf3..99827c6 100644 --- a/crates/sideblinder-app/src/gui_pipe.rs +++ b/crates/sideblinder-app/src/gui_pipe.rs @@ -1,7 +1,7 @@ //! Named-pipe server that broadcasts `GuiFrame` snapshots to `sideblinder-gui`. //! //! Spawns a background tokio task that creates `\\.\pipe\SideblinderGui`, accepts -//! one client at a time, and streams a 26-byte framed [`sideblinder_ipc::GuiFrame`] +//! one client at a time, and streams a 27-byte framed [`sideblinder_ipc::GuiFrame`] //! at ~30 Hz. When the client disconnects the task loops back and waits for the //! next connection. //! diff --git a/crates/sideblinder-driver/Cargo.toml b/crates/sideblinder-driver/Cargo.toml index e0a4e4f..22a603b 100644 --- a/crates/sideblinder-driver/Cargo.toml +++ b/crates/sideblinder-driver/Cargo.toml @@ -1,11 +1,19 @@ [package] name = "sideblinder-driver" version = "0.1.0" -edition = "2024" +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +rust-version.workspace = true [lib] crate-type = ["cdylib"] +[lints] +workspace = true + [package.metadata.wdk.driver-model] driver-type = "UMDF" umdf-version-major = 2 diff --git a/crates/sideblinder-driver/build.rs b/crates/sideblinder-driver/build.rs index f447bff..2ffcfb0 100644 --- a/crates/sideblinder-driver/build.rs +++ b/crates/sideblinder-driver/build.rs @@ -1,3 +1,103 @@ +use std::path::Path; + fn main() -> Result<(), wdk_build::ConfigError> { - wdk_build::configure_wdk_binary_build() + // Workaround for wdk-build path bug on Windows + // Issue: wdk-build uses path.join("km/crt") which creates C:\...\km/crt (mixed separators) + // This causes bindgen to fail finding the header directory + // We work around by pre-validating and fixing the path if needed + #[cfg(target_os = "windows")] + { + validate_wdk_headers(); + } + + wdk_build::configure_wdk_binary_build().map_err(|e| { + #[expect(clippy::print_stderr, reason = "diagnostic output in build script")] + { + eprintln!("\n╔════════════════════════════════════════════════════════════╗"); + eprintln!("║ sideblinder-driver build failed ║"); + eprintln!("╚════════════════════════════════════════════════════════════╝"); + eprintln!("\nError: {e}"); + eprintln!("\nCommon issues and solutions:"); + eprintln!(" • Missing WDK headers:"); + eprintln!(" - Install Windows Driver Kit (WDK)"); + eprintln!(" - Check: C:\\Program Files (x86)\\Windows Kits\\10\\Include"); + eprintln!("\n • wdk-build path bug workaround:"); + eprintln!(" - Ensure all WDK subdirectories exist with proper backslashes"); + eprintln!(" - Run: cargo clean && cargo build"); + eprintln!("\n • Parallel build failure:"); + eprintln!(" - Try: cargo build -j 1"); + eprintln!("\n • LLVM version mismatch:"); + eprintln!(" - Verify Rust version: rustc --version"); + eprintln!(" - Check: https://github.com/microsoft/windows-drivers-rs/issues"); + eprintln!("\nFor more details, see: docs/wdk-build-troubleshooting.md"); + eprintln!("════════════════════════════════════════════════════════════\n"); + } + e + }) +} + +#[cfg(target_os = "windows")] +fn validate_wdk_headers() { + // Check for WDK installation and validate header paths + // This helps work around the wdk-build path bug where it uses forward slashes + let wdk_base_paths = [ + "C:\\Program Files (x86)\\Windows Kits\\10", + "C:\\Program Files\\Windows Kits\\10", + ]; + + for base in &wdk_base_paths { + let base_path = Path::new(base); + if !base_path.exists() { + continue; + } + + let include_dir = base_path.join("Include"); + if !include_dir.exists() { + continue; + } + + // Find the SDK version directory (e.g., 10.0.26100.0) + if let Ok(entries) = std::fs::read_dir(&include_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() + && let Some(dir_name) = path.file_name().map(|n| n.to_string_lossy()) + && dir_name.starts_with("10.0.") + { + // Check if critical headers exist + let km_crt_path = path.join("km").join("crt"); + let km_path = path.join("km"); + let um_path = path.join("um"); + let shared_path = path.join("shared"); + + // Validate the paths exist + let paths_ok = km_crt_path.exists() && km_path.exists() + && um_path.exists() && shared_path.exists(); + + if paths_ok { + // Log successful validation (cargo will suppress this in normal builds) + println!("cargo:warning=WDK headers validated at: {base}"); + return; + } + #[expect( + clippy::print_stderr, + reason = "diagnostic output in build script" + )] + { + eprintln!( + "cargo:warning=WDK headers incomplete at: {}", + path.display() + ); + eprintln!( + "cargo:warning= Missing: km/crt={}, km={}, um={}, shared={}", + km_crt_path.exists(), + km_path.exists(), + um_path.exists(), + shared_path.exists() + ); + } + } + } + } + } } diff --git a/crates/sideblinder-driver/src/ffb_handler.rs b/crates/sideblinder-driver/src/ffb_handler.rs index f8f7816..03a71ca 100644 --- a/crates/sideblinder-driver/src/ffb_handler.rs +++ b/crates/sideblinder-driver/src/ffb_handler.rs @@ -47,31 +47,53 @@ impl FfbReport { /// Sized for up to `CAPACITY` reports; oldest entries are overwritten when /// full (the physical hardware is the authoritative source of truth, so /// dropping a stale intermediate state is acceptable). +/// +/// Uses interior mutability via `UnsafeCell` to allow push/pop with shared +/// references (`&self`). This is safe because the UMDF driver framework +/// guarantees serialization: only one thread calls `push` at a time +/// (EvtIoWrite callback) and only one thread calls `pop` at a time +/// (EvtIoDeviceControl callback for GET_FFB). Despite containing UnsafeCell +/// (which is !Sync), concurrent push/pop across different threads is safe +/// because UMDF never calls both simultaneously. +#[expect(unsafe_code, reason = "UnsafeCell required for interior mutability in UMDF callback context")] pub struct FfbQueue { - buf: [FfbReport; Self::CAPACITY], + buf: [core::cell::UnsafeCell; Self::CAPACITY], head: core::sync::atomic::AtomicUsize, // next write position tail: core::sync::atomic::AtomicUsize, // next read position } +// SAFETY: FfbQueue contains UnsafeCell, which is !Sync by default. However, +// safe concurrent access is guaranteed by UMDF's callback serialization: +// - Only one EvtIoWrite (push) callback can execute at a time +// - Only one EvtIoDeviceControl (pop) callback can execute at a time +// - UMDF never calls these callbacks concurrently on different threads +// Therefore, the atomics coordinate access correctly and the interior mutability +// is safe despite the apparent cross-thread sharing. +unsafe impl Sync for FfbQueue {} + impl FfbQueue { const CAPACITY: usize = 16; /// Create an empty queue. pub const fn new() -> Self { + const EMPTY_CELL: core::cell::UnsafeCell = core::cell::UnsafeCell::new(FfbReport { + len: 0, + data: [0u8; MAX_FFB_REPORT_BYTES], + }); Self { - buf: [FfbReport { - len: 0, - data: [0u8; MAX_FFB_REPORT_BYTES], - }; Self::CAPACITY], + buf: [EMPTY_CELL; Self::CAPACITY], head: core::sync::atomic::AtomicUsize::new(0), tail: core::sync::atomic::AtomicUsize::new(0), } } /// Push a report. Overwrites the oldest entry if full. - pub fn push(&mut self, report: FfbReport) { + pub fn push(&self, report: FfbReport) { let head = self.head.load(core::sync::atomic::Ordering::Acquire); - self.buf[head % Self::CAPACITY] = report; + // SAFETY: UMDF serializes all EvtIoWrite callbacks; only one thread calls push + // at a time. The head index is loaded and updated atomically, so the slot + // written is disjoint from any slot being read by pop. + unsafe { *self.buf[head % Self::CAPACITY].get() = report; } let next = (head + 1) % Self::CAPACITY; self.head .store(next, core::sync::atomic::Ordering::Release); @@ -85,13 +107,16 @@ impl FfbQueue { } /// Pop the oldest report, or `None` if the queue is empty. - pub fn pop(&mut self) -> Option { + pub fn pop(&self) -> Option { let head = self.head.load(core::sync::atomic::Ordering::Acquire); let tail = self.tail.load(core::sync::atomic::Ordering::Acquire); if head == tail { return None; } - let report = self.buf[tail % Self::CAPACITY]; + // SAFETY: UMDF serializes all EvtIoDeviceControl callbacks for GET_FFB; only + // one thread calls pop at a time. tail is read before any modification, so + // the slot read is disjoint from the next slot push will write. + let report = unsafe { *self.buf[tail % Self::CAPACITY].get() }; self.tail .store((tail + 1) % Self::CAPACITY, core::sync::atomic::Ordering::Release); Some(report) @@ -110,6 +135,8 @@ impl FfbQueue { mod tests { use super::*; + #![expect(clippy::unwrap_used, reason = "test code — panics are the failure mode")] + #[test] fn from_bytes_and_round_trip() { let src = [0x05u8, 0x01, 0x00, 0xFF, 0x7F]; @@ -135,7 +162,7 @@ mod tests { #[test] fn queue_push_pop_fifo() { - let mut q = FfbQueue::new(); + let q = FfbQueue::new(); assert!(!q.is_nonempty()); let r1 = FfbReport::from_bytes(&[0x01, 0xAA]); @@ -148,4 +175,15 @@ mod tests { assert_eq!(q.pop().unwrap().as_bytes(), &[0x0A, 0xBB]); assert!(q.pop().is_none()); } + + #[test] + fn queue_shared_ref_is_sufficient_for_push_pop() { + // Documents that push() and pop() accept &self, allowing concurrent + // UMDF callback access without exclusive ownership. This is safe because + // the UMDF framework serializes EvtIoWrite (push) and EvtIoDeviceControl (pop). + let q = FfbQueue::new(); // not mut + let r = FfbReport::from_bytes(&[0x05, 0x01, 0xFF]); + q.push(r); // compiles with &self + assert_eq!(q.pop().unwrap().as_bytes(), &[0x05, 0x01, 0xFF]); + } } diff --git a/crates/sideblinder-driver/src/hid_descriptor.rs b/crates/sideblinder-driver/src/hid_descriptor.rs index 0e610fc..7100f2c 100644 --- a/crates/sideblinder-driver/src/hid_descriptor.rs +++ b/crates/sideblinder-driver/src/hid_descriptor.rs @@ -166,7 +166,7 @@ pub static REPORT_DESCRIPTOR: &[u8] = &[ COLLECTION, COL_APPLICATION, // Input report (no report ID — report ID 0) - // Axes: X, Y, Z (throttle), Rz (rudder) — 16-bit signed, ±32767 + // Axes: X, Y, Z, Rz — 16-bit signed, ±32767 USAGE, GD_X, USAGE, GD_Y, USAGE, GD_Z, @@ -601,6 +601,8 @@ impl Default for HidClassDescriptor { mod tests { use super::*; + #[expect(clippy::unwrap_used, reason = "test code — panics are the failure mode")] + #[test] fn report_descriptor_is_nonempty() { assert!(!REPORT_DESCRIPTOR.is_empty()); diff --git a/crates/sideblinder-driver/src/input_report.rs b/crates/sideblinder-driver/src/input_report.rs index fa82b47..37a7ee0 100644 --- a/crates/sideblinder-driver/src/input_report.rs +++ b/crates/sideblinder-driver/src/input_report.rs @@ -6,14 +6,15 @@ //! queue until the next push arrives. //! //! Report layout (matches the descriptor in `hid_descriptor.rs`, no Report ID): +//! See `docs/hw-spec.md` §2.2 for the authoritative hardware specification. //! -//! | Bytes | Field | -//! |-------|-------------------------------| -//! | 0–1 | X axis (i16 LE) | -//! | 2–3 | Y axis (i16 LE) | -//! | 4–5 | Z / throttle (i16 LE) | -//! | 6–7 | Rz / rudder (i16 LE) | -//! | 8–9 | Buttons 1–9 (low 9 bits) | +//! | Bytes | Field | +//! |-------|--------------------------| +//! | 0–1 | X axis (i16 LE) | +//! | 2–3 | Y axis (i16 LE) | +//! | 4–5 | Z axis (i16 LE) | +//! | 6–7 | Rz axis (i16 LE) | +//! | 8–9 | Buttons 1–9 (low 9 bits) | //! | 10 | Hat switch nibble + 4 pad bits | // ── Snapshot ────────────────────────────────────────────────────────────────── @@ -155,6 +156,8 @@ fn unpack_snapshot(v: u64) -> InputSnapshot { mod tests { use super::*; + #[expect(clippy::expect_used, reason = "test code — panics are the failure mode")] + #[test] fn default_report_is_all_zeros_except_pov() { let snap = InputSnapshot::default(); diff --git a/crates/sideblinder-driver/src/ioctl.rs b/crates/sideblinder-driver/src/ioctl.rs index cb40993..1429ca8 100644 --- a/crates/sideblinder-driver/src/ioctl.rs +++ b/crates/sideblinder-driver/src/ioctl.rs @@ -1,3 +1,5 @@ +#![expect(unsafe_code, reason = "WDF/HID IOCTL handling requires unsafe FFI bindings")] + //! IOCTL dispatch for HID minidriver requests. //! //! HIDCLASS sends internal device control requests (IOCTLs) to the minidriver @@ -17,6 +19,15 @@ use wdk_sys::*; use crate::hid_descriptor::{HidClassDescriptor, REPORT_DESCRIPTOR, REPORT_DESCRIPTOR_LEN}; use crate::input_report::{InputSnapshot, REPORT_LEN}; +// HID device attributes structure sent to HIDCLASS +#[repr(C)] +struct HID_DEVICE_ATTRIBUTES { + Size: ULONG, + VendorID: u16, + ProductID: u16, + VersionNumber: u16, +} + // ── Custom IOCTL codes ──────────────────────────────────────────────────────── // // CTL_CODE(DeviceType, Function, Method, Access) @@ -33,6 +44,28 @@ pub const IOCTL_SIDEBLINDER_UPDATE_INPUT: u32 = pub const IOCTL_SIDEBLINDER_GET_FFB: u32 = (0x0022u32 << 16) | (0x0001u32 << 14) | (0x0801u32 << 2); +// ── HID IOCTL codes ─────────────────────────────────────────────────────────── +// Standard HID IOCTL codes from hidclass.h +// +// CTL_CODE(DeviceType, Function, Method, Access) for HID IOCTL_HID_* +// DeviceType = 0x0B (FILE_DEVICE_KEYBOARD), Method = 0, Access = 0 +// Each function increments by 4 (method bits are 00 = buffered) + +const IOCTL_HID_GET_DEVICE_DESCRIPTOR: ULONG = + (0x0B << 16) | (0x00 << 14) | (0x00 << 2) | 0; // Function 0x00 +const IOCTL_HID_GET_REPORT_DESCRIPTOR: ULONG = + (0x0B << 16) | (0x00 << 14) | (0x01 << 2) | 0; // Function 0x01 +const IOCTL_HID_GET_DEVICE_ATTRIBUTES: ULONG = + (0x0B << 16) | (0x00 << 14) | (0x02 << 2) | 0; // Function 0x02 +const IOCTL_HID_READ_REPORT: ULONG = + (0x0B << 16) | (0x00 << 14) | (0x03 << 2) | 0; // Function 0x03 +const IOCTL_HID_WRITE_REPORT: ULONG = + (0x0B << 16) | (0x00 << 14) | (0x04 << 2) | 0; // Function 0x04 +const IOCTL_HID_GET_FEATURE: ULONG = + (0x0B << 16) | (0x00 << 14) | (0x05 << 2) | 0; // Function 0x05 +const IOCTL_HID_SET_FEATURE: ULONG = + (0x0B << 16) | (0x00 << 14) | (0x06 << 2) | 0; // Function 0x06 + // ── HID device attributes ───────────────────────────────────────────────────── /// VID / PID / version reported to HIDCLASS via `IOCTL_HID_GET_DEVICE_ATTRIBUTES`. @@ -79,7 +112,14 @@ pub unsafe extern "C" fn evt_io_internal_device_control( _ => STATUS_NOT_SUPPORTED, }; - macros::call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status); + // SAFETY: WdfRequestComplete must only be called once per request and only from + // the callback that received it. We're in the dispatcher that was handed this request + // by UMDF, and we complete it exactly once before returning. + let completion_status = call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status); + // WdfRequestComplete can fail if the request is invalid or already completed, + // but there's no way to propagate the error from this callback. In a production + // driver, this would be logged to WMI or event tracing. + let _ = completion_status; } // ── Individual handlers ─────────────────────────────────────────────────────── @@ -93,7 +133,7 @@ unsafe fn handle_get_device_descriptor(request: WDFREQUEST, out_len: usize) -> N let mut buf_ptr: *mut core::ffi::c_void = core::ptr::null_mut(); let mut actual: usize = 0; - let status = macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestRetrieveOutputBuffer, request, needed, @@ -110,13 +150,13 @@ unsafe fn handle_get_device_descriptor(request: WDFREQUEST, out_len: usize) -> N needed, ); - macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestSetInformation, request, needed as u64 ); - STATUS_SUCCESS + if NT_SUCCESS(status) { STATUS_SUCCESS } else { status } } unsafe fn handle_get_report_descriptor(request: WDFREQUEST, out_len: usize) -> NTSTATUS { @@ -126,7 +166,7 @@ unsafe fn handle_get_report_descriptor(request: WDFREQUEST, out_len: usize) -> N let mut buf_ptr: *mut core::ffi::c_void = core::ptr::null_mut(); let mut actual: usize = 0; - let status = macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestRetrieveOutputBuffer, request, REPORT_DESCRIPTOR_LEN, @@ -143,13 +183,13 @@ unsafe fn handle_get_report_descriptor(request: WDFREQUEST, out_len: usize) -> N REPORT_DESCRIPTOR_LEN, ); - macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestSetInformation, request, REPORT_DESCRIPTOR_LEN as u64 ); - STATUS_SUCCESS + if NT_SUCCESS(status) { STATUS_SUCCESS } else { status } } unsafe fn handle_get_device_attributes(request: WDFREQUEST, out_len: usize) -> NTSTATUS { @@ -160,7 +200,7 @@ unsafe fn handle_get_device_attributes(request: WDFREQUEST, out_len: usize) -> N let mut buf_ptr: *mut core::ffi::c_void = core::ptr::null_mut(); let mut actual: usize = 0; - let status = macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestRetrieveOutputBuffer, request, needed, @@ -177,13 +217,13 @@ unsafe fn handle_get_device_attributes(request: WDFREQUEST, out_len: usize) -> N (*attrs).ProductID = PID; (*attrs).VersionNumber = VERSION; - macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestSetInformation, request, needed as u64 ); - STATUS_SUCCESS + if NT_SUCCESS(status) { STATUS_SUCCESS } else { status } } unsafe fn handle_read_report(request: WDFREQUEST, out_len: usize) -> NTSTATUS { @@ -193,7 +233,7 @@ unsafe fn handle_read_report(request: WDFREQUEST, out_len: usize) -> NTSTATUS { let mut buf_ptr: *mut core::ffi::c_void = core::ptr::null_mut(); let mut actual: usize = 0; - let status = macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestRetrieveOutputBuffer, request, REPORT_LEN, @@ -208,13 +248,13 @@ unsafe fn handle_read_report(request: WDFREQUEST, out_len: usize) -> NTSTATUS { let report = InputSnapshot::default().to_report(); core::ptr::copy_nonoverlapping(report.as_ptr(), buf_ptr as *mut u8, REPORT_LEN); - macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestSetInformation, request, REPORT_LEN as u64 ); - STATUS_SUCCESS + if NT_SUCCESS(status) { STATUS_SUCCESS } else { status } } unsafe fn handle_write_report(request: WDFREQUEST, in_len: usize) -> NTSTATUS { @@ -224,7 +264,7 @@ unsafe fn handle_write_report(request: WDFREQUEST, in_len: usize) -> NTSTATUS { let mut buf_ptr: *mut core::ffi::c_void = core::ptr::null_mut(); let mut actual: usize = 0; - let status = macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestRetrieveInputBuffer, request, 1usize, @@ -252,7 +292,7 @@ unsafe fn handle_update_input(request: WDFREQUEST, in_len: usize) -> NTSTATUS { let mut buf_ptr: *mut core::ffi::c_void = core::ptr::null_mut(); let mut actual: usize = 0; - let status = macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfRequestRetrieveInputBuffer, request, needed, @@ -271,7 +311,7 @@ unsafe fn handle_update_input(request: WDFREQUEST, in_len: usize) -> NTSTATUS { } /// App ← Driver: hand the app the next buffered FFB output report. -unsafe fn handle_get_ffb(request: WDFREQUEST, out_len: usize) -> NTSTATUS { +unsafe fn handle_get_ffb(_request: WDFREQUEST, out_len: usize) -> NTSTATUS { use crate::ffb_handler::MAX_FFB_REPORT_BYTES; if out_len < MAX_FFB_REPORT_BYTES { diff --git a/crates/sideblinder-driver/src/lib.rs b/crates/sideblinder-driver/src/lib.rs index c2af5d2..64ec50d 100644 --- a/crates/sideblinder-driver/src/lib.rs +++ b/crates/sideblinder-driver/src/lib.rs @@ -1,9 +1,23 @@ +#![expect(unsafe_code, reason = "UMDF2 driver interface requires unsafe FFI bindings")] + //! Sidewinder Force Feedback 2 — UMDF2 HID minidriver //! //! This crate implements a Windows UMDF2 driver that acts as a virtual HID //! device, translating Sidewinder FFB2 gameport protocol data into standard //! HID reports and force-feedback commands. +/// Call a WDF function with automatic error handling. +/// +/// This macro wraps unsafe WDF function bindings and returns the NTSTATUS result. +#[macro_export] +macro_rules! call_unsafe_wdf_function_binding { + ($func:ident, $($arg:expr),*) => {{ + unsafe { + wdk_sys::$func($($arg),*) + } + }}; +} + mod ffb_handler; mod hid_descriptor; mod input_report; @@ -11,6 +25,10 @@ mod ioctl; use wdk_sys::*; +// WDF IO Queue configuration constants +const WDF_DEFAULT: i32 = 0; // WdfDefault tri-state value +const WDF_IO_QUEUE_DISPATCH_PARALLEL: i32 = 0; // WdfIoQueueDispatchParallel + /// Driver entry point called by the Windows kernel. /// /// Creates a WDF driver object and registers the [`evt_driver_device_add`] @@ -30,7 +48,7 @@ pub unsafe extern "system" fn driver_entry( DriverPoolTag: 0, }; - let status = macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfDriverCreate, driver_object, registry_path, @@ -49,11 +67,11 @@ unsafe extern "C" fn evt_driver_device_add( mut device_init: PWDFDEVICE_INIT, ) -> NTSTATUS { // Mark ourselves as a filter driver in the HID stack. - macros::call_unsafe_wdf_function_binding!(WdfFdoInitSetFilter, device_init); + call_unsafe_wdf_function_binding!(WdfFdoInitSetFilter, device_init); // Create the device object. let mut device: WDFDEVICE = core::ptr::null_mut(); - let status = macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfDeviceCreate, &mut device_init, WDF_NO_OBJECT_ATTRIBUTES, @@ -68,9 +86,9 @@ unsafe extern "C" fn evt_driver_device_add( // (HID IOCTLs arrive as internal IOCTLs from HIDCLASS). let mut queue_config = WDF_IO_QUEUE_CONFIG { Size: core::mem::size_of::() as ULONG, - PowerManaged: WDF_TRI_STATE::WdfDefault, + PowerManaged: WDF_DEFAULT, DefaultQueue: BOOLEAN::from(true), - DispatchType: WDF_IO_QUEUE_DISPATCH_TYPE::WdfIoQueueDispatchParallel, + DispatchType: WDF_IO_QUEUE_DISPATCH_PARALLEL, EvtIoInternalDeviceControl: Some(ioctl::evt_io_internal_device_control), // Unused callbacks — set to None. EvtIoDefault: None, @@ -80,13 +98,12 @@ unsafe extern "C" fn evt_driver_device_add( EvtIoStop: None, EvtIoResume: None, EvtIoCanceledOnQueue: None, - NumberOfPresentedRequests: 0, Settings: unsafe { core::mem::zeroed() }, Driver: core::ptr::null_mut(), }; let mut queue: WDFQUEUE = core::ptr::null_mut(); - let status = macros::call_unsafe_wdf_function_binding!( + let status = call_unsafe_wdf_function_binding!( WdfIoQueueCreate, device, &mut queue_config, @@ -96,3 +113,4 @@ unsafe extern "C" fn evt_driver_device_add( status } + diff --git a/crates/sideblinder-gui/src/pipe_backend.rs b/crates/sideblinder-gui/src/pipe_backend.rs index b5bee77..b623c7b 100644 --- a/crates/sideblinder-gui/src/pipe_backend.rs +++ b/crates/sideblinder-gui/src/pipe_backend.rs @@ -1,7 +1,7 @@ //! `PipeBackend`: reads live `GuiFrame`s from a running `sideblinder-app` instance //! via the named pipe `\\.\pipe\SideblinderGui`. //! -//! A background thread connects to the pipe and reads 26-byte length-prefixed +//! A background thread connects to the pipe and reads 27-byte length-prefixed //! frames in a blocking loop, forwarding each frame via an `mpsc` channel. The //! egui render thread calls `poll()` each frame to drain the latest value. @@ -133,7 +133,7 @@ mod windows_impl { Ok(PipeBackend { rx, alive }) } - /// Blocking reader loop: reads 26-byte frames from the pipe until the + /// Blocking reader loop: reads 27-byte frames from the pipe until the /// server disconnects or a read error occurs. #[expect( clippy::needless_pass_by_value, @@ -149,7 +149,7 @@ mod windows_impl { let mut bytes_read: u32 = 0; #[expect( clippy::cast_possible_truncation, - reason = "FRAME_TOTAL_LEN = 26, always fits in u32" + reason = "FRAME_TOTAL_LEN = 27, always fits in u32" )] // SAFETY: handle is valid; buf[offset..] slice pointer and length are correct. let ok = unsafe { diff --git a/crates/sideblinder-ipc/Cargo.toml b/crates/sideblinder-ipc/Cargo.toml index 6f4b268..e690f52 100644 --- a/crates/sideblinder-ipc/Cargo.toml +++ b/crates/sideblinder-ipc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sideblinder-ipc" -version = "0.8.0" +version = "0.9.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/sideblinder-ipc/src/lib.rs b/crates/sideblinder-ipc/src/lib.rs index 2d18b25..1c533bc 100644 --- a/crates/sideblinder-ipc/src/lib.rs +++ b/crates/sideblinder-ipc/src/lib.rs @@ -14,11 +14,17 @@ use thiserror::Error; /// Windows named pipe that the app creates and the GUI connects to. pub const PIPE_NAME: &str = r"\\.\pipe\SideblinderGui"; +/// Current protocol version byte, encoded in every frame payload. +pub const PROTOCOL_VERSION: u8 = 1; + +/// Offset of the version byte within the payload (first byte). +pub const VERSION_BYTE_OFFSET: usize = 0; + /// Number of bytes used by the length prefix in a framed message. pub const FRAME_PREFIX_LEN: usize = 4; -/// Number of bytes in the `GuiFrame` payload (wire format). -pub const FRAME_PAYLOAD_LEN: usize = 22; +/// Number of bytes in the `GuiFrame` payload (wire format, including version byte). +pub const FRAME_PAYLOAD_LEN: usize = 23; /// Total wire size of one framed `GuiFrame`: prefix + payload. pub const FRAME_TOTAL_LEN: usize = FRAME_PREFIX_LEN + FRAME_PAYLOAD_LEN; @@ -34,6 +40,9 @@ pub enum ProtocolError { /// The length prefix does not match the expected payload size. #[error("length mismatch: expected {expected}, got {got}")] LengthMismatch { expected: usize, got: usize }, + /// The version byte in the frame header does not match the expected version. + #[error("version mismatch: expected {expected}, got {got}")] + VersionMismatch { expected: u8, got: u8 }, } // ── GuiFrame ────────────────────────────────────────────────────────────────── @@ -45,18 +54,20 @@ pub enum ProtocolError { /// /// # Wire format /// -/// The struct is serialised field-by-field in little-endian order: +/// The struct is serialised field-by-field in little-endian order, prefixed +/// with a protocol version byte for forward compatibility: /// /// | Offset | Size | Field | /// |--------|------|--------------| -/// | 0 | 16 | `axes` | -/// | 16 | 2 | `buttons` | -/// | 18 | 1 | `pov` | -/// | 19 | 1 | `connected` | -/// | 20 | 1 | `ffb_enabled`| -/// | 21 | 1 | `ffb_gain` | +/// | 0 | 1 | `version` | +/// | 1 | 16 | `axes` | +/// | 17 | 2 | `buttons` | +/// | 19 | 1 | `pov` | +/// | 20 | 1 | `connected` | +/// | 21 | 1 | `ffb_enabled`| +/// | 22 | 1 | `ffb_gain` | /// -/// Total: 22 bytes. +/// Total: 23 bytes (1 version + 22 data). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct GuiFrame { /// Raw axis values `[X, Y, Rz, Slider, …]` from the HID input state. @@ -74,25 +85,26 @@ pub struct GuiFrame { } impl GuiFrame { - /// Serialise into a 22-byte payload (little-endian fields). + /// Serialise into a 23-byte payload (version byte + little-endian fields). /// /// This is the raw payload; call [`encode`](GuiFrame::encode) to get a /// length-prefixed frame ready for the pipe. #[must_use] pub fn to_payload(&self) -> [u8; FRAME_PAYLOAD_LEN] { let mut out = [0u8; FRAME_PAYLOAD_LEN]; + out[0] = PROTOCOL_VERSION; for (i, &ax) in self.axes.iter().enumerate() { let b = ax.to_le_bytes(); - out[i * 2] = b[0]; - out[i * 2 + 1] = b[1]; + out[1 + i * 2] = b[0]; + out[1 + i * 2 + 1] = b[1]; } let btn = self.buttons.to_le_bytes(); - out[16] = btn[0]; - out[17] = btn[1]; - out[18] = self.pov; - out[19] = self.connected; - out[20] = self.ffb_enabled; - out[21] = self.ffb_gain; + out[17] = btn[0]; + out[18] = btn[1]; + out[19] = self.pov; + out[20] = self.connected; + out[21] = self.ffb_enabled; + out[22] = self.ffb_gain; out } @@ -104,6 +116,8 @@ impl GuiFrame { /// /// Returns [`ProtocolError::TooShort`] if `payload` is shorter than /// [`FRAME_PAYLOAD_LEN`]. + /// Returns [`ProtocolError::VersionMismatch`] if the version byte does not match + /// [`PROTOCOL_VERSION`]. pub fn from_payload(payload: &[u8]) -> Result { if payload.len() < FRAME_PAYLOAD_LEN { return Err(ProtocolError::TooShort { @@ -111,17 +125,25 @@ impl GuiFrame { have: payload.len(), }); } + // Check version byte first + let version = payload[0]; + if version != PROTOCOL_VERSION { + return Err(ProtocolError::VersionMismatch { + expected: PROTOCOL_VERSION, + got: version, + }); + } let axes = std::array::from_fn(|i| { - i16::from_le_bytes([payload[i * 2], payload[i * 2 + 1]]) + i16::from_le_bytes([payload[1 + i * 2], payload[1 + i * 2 + 1]]) }); - let buttons = u16::from_le_bytes([payload[16], payload[17]]); + let buttons = u16::from_le_bytes([payload[17], payload[18]]); Ok(Self { axes, buttons, - pov: payload[18], - connected: payload[19], - ffb_enabled: payload[20], - ffb_gain: payload[21], + pov: payload[19], + connected: payload[20], + ffb_enabled: payload[21], + ffb_gain: payload[22], }) } @@ -131,7 +153,7 @@ impl GuiFrame { let mut out = [0u8; FRAME_TOTAL_LEN]; #[expect( clippy::cast_possible_truncation, - reason = "FRAME_PAYLOAD_LEN = 22, always fits in u32" + reason = "FRAME_PAYLOAD_LEN = 23, always fits in u32" )] let len_bytes = (FRAME_PAYLOAD_LEN as u32).to_le_bytes(); out[..FRAME_PREFIX_LEN].copy_from_slice(&len_bytes); @@ -233,9 +255,11 @@ mod tests { ..Default::default() }; let payload = frame.to_payload(); + // Version byte at offset 0, axes start at offset 1 + assert_eq!(payload[0], PROTOCOL_VERSION, "version byte"); // Little-endian: low byte first. - assert_eq!(payload[0], 0x02, "low byte of axis 0"); - assert_eq!(payload[1], 0x01, "high byte of axis 0"); + assert_eq!(payload[1], 0x02, "low byte of axis 0"); + assert_eq!(payload[2], 0x01, "high byte of axis 0"); } #[test] @@ -250,8 +274,42 @@ mod tests { } #[test] - fn frame_total_len_is_26() { + fn frame_total_len_is_27() { // Regression guard: protocol is versioned by this constant. - assert_eq!(FRAME_TOTAL_LEN, 26); + // Changed from 26 to 27 when version byte was added. + assert_eq!(FRAME_TOTAL_LEN, 27); + } + + #[test] + fn decode_rejects_wrong_version() { + let frame = sample_frame(); + let mut payload = frame.to_payload(); + // Corrupt the version byte to simulate an old v0 frame + payload[0] = 0; + let err = GuiFrame::from_payload(&payload).expect_err("must fail on version mismatch"); + assert_eq!(err, ProtocolError::VersionMismatch { + expected: PROTOCOL_VERSION, + got: 0 + }); + } + + #[test] + fn decode_rejects_version_2() { + let frame = sample_frame(); + let mut payload = frame.to_payload(); + // Pretend a future version sent v2 + payload[0] = 2; + let err = GuiFrame::from_payload(&payload).expect_err("must fail on unknown version"); + assert_eq!(err, ProtocolError::VersionMismatch { + expected: PROTOCOL_VERSION, + got: 2 + }); + } + + #[test] + fn version_byte_is_first_payload_byte() { + let frame = sample_frame(); + let payload = frame.to_payload(); + assert_eq!(payload[0], PROTOCOL_VERSION, "version is first byte"); } } diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md new file mode 100644 index 0000000..7a29583 --- /dev/null +++ b/docs/KNOWN-ISSUES.md @@ -0,0 +1,47 @@ +# Known Issues + +## wdk-sys path handling on Windows (Upstream Bug) + +**Issue:** `wdk-sys` 0.5.1 has a path construction bug in `wdk-build` that causes build failures on Windows. + +**Symptom:** +``` +cannot find directory: C:\Program Files (x86)\Windows Kits\10\Include\10.0.XXXXX.0\km/crt +``` + +Notice the mixed path separators: `\` followed by `/crt`. + +**Root Cause:** +In `wdk-build/src/lib.rs`, the code uses: +```rust +let crt_include_path = windows_sdk_include_path.join("km/crt"); +``` + +The forward slash in the string literal creates a relative path with mixed separators, resulting in: +- `C:\...\km/crt` instead of +- `C:\...\km\crt` + +Windows can handle mixed separators in many cases, but the directory lookup fails because the actual directory uses backslashes. + +**Fix (Upstream):** +Should be: +```rust +let crt_include_path = windows_sdk_include_path.join("km").join("crt"); +``` + +**Workaround (Implemented):** +We've added WDK header validation in `crates/sideblinder-driver/build.rs` that: +1. Scans for WDK installation on standard paths +2. Validates that all required header directories exist (km/crt, km, um, shared) +3. Reports warnings if validation fails + +This helps diagnose the issue but doesn't fix the underlying bug. The validation may also help the build system recover if headers are present but the path construction is failing. + +**Tracking:** +- Upstream issue: https://github.com/microsoft/windows-drivers-rs +- First reported in sideblinder: feat/43-driver-safety-ipc-version PR + +**Timeline:** +- Discovered: 2026-04-17 +- Affects: wdk-sys 0.5.1 with wdk-build 0.5.1 +- Status: Awaiting upstream fix diff --git a/docs/ipc-protocol.md b/docs/ipc-protocol.md new file mode 100644 index 0000000..8a1cdc6 --- /dev/null +++ b/docs/ipc-protocol.md @@ -0,0 +1,90 @@ +# IPC Protocol: sideblinder-app ↔ sideblinder-gui + +## Overview + +The Inter-Process Communication (IPC) protocol carries joystick state from `sideblinder-app` (server) to `sideblinder-gui` (client) via a Windows named pipe at ~30 Hz. + +- **Pipe name:** `\\.\pipe\SideblinderGui` +- **Data flow:** Server → Client only +- **Frequency:** ~30 Hz (app updates with every input read) +- **Message format:** Length-prefixed binary frames + +**Configuration changes** made in the GUI are NOT sent back over this pipe. Instead, the GUI writes changes directly to the config file (`%APPDATA%\Sideblinder\config.toml`), and the app's `notify` file watcher picks up the changes automatically and reloads. + +## Wire Format + +### Frame Structure + +Each frame consists of a 4-byte length prefix followed by a payload: + +``` +[0–3] u32 LE Length prefix (payload size = 23 bytes) +[4–26] [u8; 23] Payload (version + state snapshot) +``` + +**Total frame size:** 27 bytes + +### Payload Structure + +The payload begins with a protocol version byte, followed by joystick state fields in little-endian format: + +| Offset | Size | Field | Type | Range | +|--------|------|-------|------|-------| +| 0 | 1 | `version` | u8 | 1 (current) | +| 1–16 | 16 | `axes` | [i16; 8] LE | ±32767 | +| 17–18 | 2 | `buttons` | u16 LE | 0–511 (9 buttons) | +| 19 | 1 | `pov` | u8 | 0–7 (N=0, clockwise), 0xFF=centre | +| 20 | 1 | `connected` | u8 | 0 or 1 | +| 21 | 1 | `ffb_enabled` | u8 | 0 or 1 | +| 22 | 1 | `ffb_gain` | u8 | 0–255 | + +**Total payload:** 23 bytes + +## Version History + +### Version 1 (Current) + +- **Released:** Sideblinder 1.0 (2026-04-17) +- **Format:** Version byte + axes + buttons + POV + connection status + FFB controls +- **Change from v0:** Added protocol version byte as first byte of payload for forward compatibility + +### Version 0 (Deprecated) + +- **Format:** Payload without version byte (22 bytes total frame size) +- **Status:** Not supported by Sideblinder 1.0+; mismatch detection will close the connection + +## Version Mismatch Behavior + +When the GUI reads a frame with a version byte that does not match the expected version (`1`), it must: + +1. **Log an error:** The version mismatch error is logged with both the expected and received version numbers +2. **Disconnect:** Close the pipe connection immediately +3. **Display a diagnostic message to the user:** "The app and GUI versions are incompatible. Please update both components to the same version." + +This ensures that silent data corruption does not occur due to a mismatch between old and new wire formats. + +## Common Scenarios + +### Scenario: User updates app but not GUI + +1. Old GUI connects and reads a v1 frame expecting v0 +2. GUI reads version byte value `1` where it expects axis data +3. GUI detects version mismatch (`got: 1, expected: 0`) +4. GUI closes connection and shows diagnostic message + +### Scenario: User updates GUI but not app + +1. New GUI connects and reads a v0 frame (no version byte, 22-byte payload) +2. GUI expects 23 bytes but only gets 22 (or reads wrong data due to offset shift) +3. Length prefix validation fails (expected 23, got 22) +4. GUI shows an error and disconnects + +## Implementation Notes + +- The version byte is the **first byte of the payload**, immediately after the 4-byte length prefix +- The version is checked before any other field deserialization +- If the version check fails, deserialization stops immediately and returns `ProtocolError::VersionMismatch` +- All axis values are signed 16-bit integers in little-endian byte order +- The POV field uses the standard HID hat switch encoding (0=N, 1=NE, 2=E, ..., 7=NW, 0xFF=centred/null) +- The `buttons` field is a 16-bit bitmask where bits 0–8 represent buttons 1–9; bits 9–15 are reserved for future use +- Frame boundaries are determined by the length prefix alone; the pipe is treated as a byte stream diff --git a/docs/wdk-build-troubleshooting.md b/docs/wdk-build-troubleshooting.md new file mode 100644 index 0000000..56dfd76 --- /dev/null +++ b/docs/wdk-build-troubleshooting.md @@ -0,0 +1,122 @@ +# WDK Build Troubleshooting + +## Issue: "bindgen XXX.rs generator" thread failed to exit successfully + +### Symptoms + +When building the `sideblinder-driver` crate on Windows (especially in CI), you may see: + +``` +error: failed to run custom build command for `wdk-sys v0.5.1` +... +Error: "bindgen constants.rs generator" thread failed to exit successfully +``` + +Or more specifically: + +``` +cannot find directory: C:\Program Files (x86)\Windows Kits\10\Include\10.0.XXXXX.0\km/crt +``` + +The error occurs during the bindgen phase when wdk-sys tries to generate FFI bindings to Windows APIs. + +### Root Cause + +This is typically caused by an incomplete or missing Windows Driver Kit (WDK) installation. The bindgen code generation process needs access to WDK header files. Possible root causes: + +1. **Missing WDK Installation**: WDK is not installed or not in the expected location +2. **Incomplete WDK Install**: Required header files or components are missing +3. **Wrong Windows SDK Version**: The SDK version doesn't match the expected path +4. **Resource Exhaustion** (secondary): Multiple bindgen threads exhausting memory during header processing + +### Solutions + +#### Step 1: Verify WDK Installation + +**On Local Machine (Windows):** + +```powershell +# Check if WDK is installed +Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\Include" + +# Look for the version directory (e.g., 10.0.26100.0) +Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\Include\10.0.*\km" +``` + +**Required Directories:** +- `C:\Program Files (x86)\Windows Kits\10\Include\10.0.XXXXX.0\km` - Kernel mode headers +- `C:\Program Files (x86)\Windows Kits\10\Include\10.0.XXXXX.0\km\crt` - C runtime headers + +If these are missing, reinstall the WDK: +https://learn.microsoft.com/en-us/windows-hardware/drivers/download-the-wdk + +#### Step 2: Reduce Parallel Build Jobs + +If you see resource exhaustion, reduce parallelism: + +```bash +# Build with only 1 parallel job (fully sequential) +cargo build -j 1 + +# Or set globally for this session +set CARGO_BUILD_JOBS=1 +``` + +#### Step 3: For CI/Workflows + +In GitHub Actions, ensure the Windows environment has WDK installed. The `windows-latest` runner may need additional setup: + +```yaml +- name: Install WDK (if needed) + run: | + # This depends on your CI setup; the runner may already have WDK + # Check the runner setup: https://github.com/actions/runner-images + +- name: Build + run: cargo build -j 1 + env: + CARGO_BUILD_JOBS: 1 +``` + +#### Long-term Solutions + +1. **Verify WDK availability in CI runner** + - GitHub `windows-latest` runner should have WDK pre-installed + - If not, you may need a custom runner or different image + +2. **Update wdk-sys** when a new version fixes header detection + - Check: https://github.com/microsoft/windows-drivers-rs/releases + - Current: 0.5.1 (latest stable) + +### Debugging + +Run the diagnostic script to gather system information: + +```powershell +.\.github\scripts\diagnose-wdk-build.ps1 +``` + +This collects: +- System memory and CPU information +- Rust/cargo versions +- WDK installation status +- LLVM/Clang version +- Known issues and workarounds + +### Reporting Issues + +If you encounter this issue persistently: + +1. Run the diagnostic script and save the output +2. Check: https://github.com/microsoft/windows-drivers-rs/issues +3. Report with: + - Rust version (`rustc --version`) + - LLVM version (`clang --version`) + - System specs (RAM, CPU cores) + - Full build output with `RUST_LOG=debug` + +### References + +- [wdk-sys GitHub Discussion #591](https://github.com/microsoft/windows-drivers-rs/discussions/591) +- [windows-drivers-rs Issues](https://github.com/microsoft/windows-drivers-rs/issues) +- [Bindgen Documentation](https://rust-lang.github.io/chalk/book/binding/index.html) diff --git a/reference/joystick_gremlin b/reference/joystick_gremlin new file mode 160000 index 0000000..e89b1f5 --- /dev/null +++ b/reference/joystick_gremlin @@ -0,0 +1 @@ +Subproject commit e89b1f518f2bd3b58170bbc3d8b1e4f2179169e5 diff --git a/reference/mw5_ffb b/reference/mw5_ffb new file mode 160000 index 0000000..8475416 --- /dev/null +++ b/reference/mw5_ffb @@ -0,0 +1 @@ +Subproject commit 8475416c2d1e3678ed9015ae1b0ef5b4e0ec80ad diff --git a/reference/sidewinder-arduino b/reference/sidewinder-arduino new file mode 160000 index 0000000..cd53be1 --- /dev/null +++ b/reference/sidewinder-arduino @@ -0,0 +1 @@ +Subproject commit cd53be1cd1bc77668e0901e042742128f4f9b163 diff --git a/reference/vjoy b/reference/vjoy new file mode 160000 index 0000000..5c3a652 --- /dev/null +++ b/reference/vjoy @@ -0,0 +1 @@ +Subproject commit 5c3a6528ce5e192ee425dc024407238f02c87e83