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