From 9f4972d018b5383da6818d2cf86a3b96f82d049b Mon Sep 17 00:00:00 2001 From: Phaedrus Date: Fri, 17 Apr 2026 17:54:47 -0400 Subject: [PATCH 01/16] fix(driver): resolve compilation errors in driver core - ffb_handler: fix unsafe block visibility and force feedback logic - input_report: correct axis usage and type handling - ioctl: fix IOCTL command definitions and payload handling - hid_descriptor: correct axis usage comments --- crates/sideblinder-driver/src/ffb_handler.rs | 43 ++++++++++++--- .../sideblinder-driver/src/hid_descriptor.rs | 2 +- crates/sideblinder-driver/src/input_report.rs | 15 ++--- crates/sideblinder-driver/src/ioctl.rs | 55 +++++++++++++++---- 4 files changed, 86 insertions(+), 29 deletions(-) diff --git a/crates/sideblinder-driver/src/ffb_handler.rs b/crates/sideblinder-driver/src/ffb_handler.rs index f8f7816..0c42856 100644 --- a/crates/sideblinder-driver/src/ffb_handler.rs +++ b/crates/sideblinder-driver/src/ffb_handler.rs @@ -47,8 +47,15 @@ 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). +#[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 } @@ -58,20 +65,24 @@ impl FfbQueue { /// 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 +96,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) @@ -148,4 +162,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..025f862 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, diff --git a/crates/sideblinder-driver/src/input_report.rs b/crates/sideblinder-driver/src/input_report.rs index fa82b47..4d057f6 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 ────────────────────────────────────────────────────────────────── diff --git a/crates/sideblinder-driver/src/ioctl.rs b/crates/sideblinder-driver/src/ioctl.rs index cb40993..05b926a 100644 --- a/crates/sideblinder-driver/src/ioctl.rs +++ b/crates/sideblinder-driver/src/ioctl.rs @@ -17,6 +17,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 +42,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 +110,7 @@ pub unsafe extern "C" fn evt_io_internal_device_control( _ => STATUS_NOT_SUPPORTED, }; - macros::call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status); + call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status); } // ── Individual handlers ─────────────────────────────────────────────────────── @@ -93,7 +124,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,7 +141,7 @@ unsafe fn handle_get_device_descriptor(request: WDFREQUEST, out_len: usize) -> N needed, ); - macros::call_unsafe_wdf_function_binding!( + call_unsafe_wdf_function_binding!( WdfRequestSetInformation, request, needed as u64 @@ -126,7 +157,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,7 +174,7 @@ unsafe fn handle_get_report_descriptor(request: WDFREQUEST, out_len: usize) -> N REPORT_DESCRIPTOR_LEN, ); - macros::call_unsafe_wdf_function_binding!( + call_unsafe_wdf_function_binding!( WdfRequestSetInformation, request, REPORT_DESCRIPTOR_LEN as u64 @@ -160,7 +191,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,7 +208,7 @@ unsafe fn handle_get_device_attributes(request: WDFREQUEST, out_len: usize) -> N (*attrs).ProductID = PID; (*attrs).VersionNumber = VERSION; - macros::call_unsafe_wdf_function_binding!( + call_unsafe_wdf_function_binding!( WdfRequestSetInformation, request, needed as u64 @@ -193,7 +224,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,7 +239,7 @@ 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!( + call_unsafe_wdf_function_binding!( WdfRequestSetInformation, request, REPORT_LEN as u64 @@ -224,7 +255,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 +283,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 +302,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 { From 54f52aba8eb6170031ac52d36af4fe72a73b2915 Mon Sep 17 00:00:00 2001 From: Phaedrus Date: Fri, 17 Apr 2026 17:54:51 -0400 Subject: [PATCH 02/16] fix(driver): fix macro visibility and module structure - Inline WDF macro for proper visibility across modules - Fix module exports and macro accessibility - Correct unsafe block scoping --- crates/sideblinder-driver/src/lib.rs | 30 +++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/sideblinder-driver/src/lib.rs b/crates/sideblinder-driver/src/lib.rs index c2af5d2..d25f3bf 100644 --- a/crates/sideblinder-driver/src/lib.rs +++ b/crates/sideblinder-driver/src/lib.rs @@ -4,6 +4,18 @@ //! 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 +23,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 +46,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 +65,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 +84,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 +96,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 +111,4 @@ unsafe extern "C" fn evt_driver_device_add( status } + From 6904eb3b1daaec5644363c4edfbf34f08cf29438 Mon Sep 17 00:00:00 2001 From: Phaedrus Date: Fri, 17 Apr 2026 17:54:53 -0400 Subject: [PATCH 03/16] fix(driver): clean up build.rs and resolve Clippy lints - Collapse nested if statements for clarity - Replace magic numbers with named constants - Suppress lint attributes where appropriate - Handle unwrap() safely with better error context --- crates/sideblinder-driver/build.rs | 102 ++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) 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() + ); + } + } + } + } + } } From 456c1cbebeef897bf474366759d84b49612c1c36 Mon Sep 17 00:00:00 2001 From: Phaedrus Date: Fri, 17 Apr 2026 17:54:58 -0400 Subject: [PATCH 04/16] feat(ipc): update protocol and bump to 0.9.0 - Rewrite IPC payload structure for safer serialization - Correct payload size expectations with new #[expect] comments - Bump sideblinder-ipc to 0.9.0 - Update CHANGELOG with release notes --- CHANGELOG.md | 5 ++ crates/sideblinder-ipc/Cargo.toml | 2 +- crates/sideblinder-ipc/src/lib.rs | 118 ++++++++++++++++++++++-------- 3 files changed, 94 insertions(+), 31 deletions(-) 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/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"); } } From ed46a99bae3de8145e66b6f434decdbda032f086 Mon Sep 17 00:00:00 2001 From: Phaedrus Date: Fri, 17 Apr 2026 17:55:01 -0400 Subject: [PATCH 05/16] docs: add IPC protocol reference, WDK build guide, and known issues - ipc-protocol.md: document IPC message format and payload structure - wdk-build-troubleshooting.md: comprehensive WDK build error troubleshooting - KNOWN-ISSUES.md: document wdk-sys path handling and Windows Kits edge cases --- docs/KNOWN-ISSUES.md | 47 ++++++++++++ docs/ipc-protocol.md | 90 ++++++++++++++++++++++ docs/wdk-build-troubleshooting.md | 122 ++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 docs/KNOWN-ISSUES.md create mode 100644 docs/ipc-protocol.md create mode 100644 docs/wdk-build-troubleshooting.md 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) From f31580090b69555b57837f80143dbf2b80c8526c Mon Sep 17 00:00:00 2001 From: Phaedrus Date: Fri, 17 Apr 2026 17:55:04 -0400 Subject: [PATCH 06/16] docs: integrate reference code and update project guidelines - Add reference repos for architectural study (vjoy, joystick_gremlin, mw5_ffb, sidewinder-arduino) - Document reference code policies in CLAUDE.md - Update README with reference code section - Update CONTRIBUTING.md with driver build requirements --- .gitmodules | 18 +++++----- CLAUDE.md | 69 ++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 19 +++++++--- README.md | 23 +++++++++++- reference/joystick_gremlin | 1 + reference/mw5_ffb | 1 + reference/sidewinder-arduino | 1 + reference/vjoy | 1 + 8 files changed, 119 insertions(+), 14 deletions(-) create mode 160000 reference/joystick_gremlin create mode 160000 reference/mw5_ffb create mode 160000 reference/sidewinder-arduino create mode 160000 reference/vjoy 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/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/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/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 From ca6c84aa12d2d158be7fd97f990e528d35c6a3f0 Mon Sep 17 00:00:00 2001 From: Phaedrus Date: Fri, 17 Apr 2026 17:55:09 -0400 Subject: [PATCH 07/16] ci/build: configure WDK installation, LLVM 17, and build serialization - Add WDK installation via NuGet in CI (with diagnostics script) - Install LLVM 17 for kernel-mode driver builds - Configure static CRT linking for Windows MSVC targets - Set CARGO_BUILD_JOBS=1 to prevent wdk-macros lock contention - Add NuGet.Config for Microsoft feed access - Document -j 1 requirement in .cargo/config.toml and CLAUDE.md - Update Renovate config for WDK package updates --- .cargo/config.toml | 6 + .github/renovate.json5 | 17 ++ .github/scripts/diagnose-wdk-build.ps1 | 34 +++ .github/workflows/ci.yml | 118 +++++++++- Cargo.lock | 306 +++++++++++++++++++++++-- Cargo.toml | 1 + NuGet.Config | 6 + crates/sideblinder-driver/Cargo.toml | 10 +- 8 files changed, 473 insertions(+), 25 deletions(-) create mode 100644 .github/scripts/diagnose-wdk-build.ps1 create mode 100644 NuGet.Config 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..1382475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,15 @@ name: CI +# NOTE: every Windows job below forces `-j 1` / CARGO_BUILD_JOBS=1. This is +# NOT a performance tuning — removing it will break the build. `wdk-macros` +# 0.5.1 (a transitive dep via `wdk` through `sideblinder-driver`) 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. 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] @@ -8,7 +18,7 @@ on: jobs: clippy: name: Clippy - runs-on: windows-latest + runs-on: windows-2022 permissions: contents: read steps: @@ -23,15 +33,47 @@ jobs: toolchain: 1.94.1 components: clippy + - name: Install LLVM 17 + uses: egor-tensin/setup-llvm@v1 + with: + version: 17 + + - 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 -j 1 --all-targets --all-features -- -D warnings + env: + CARGO_BUILD_JOBS: 1 test: name: Test - runs-on: windows-latest + runs-on: windows-2022 needs: clippy permissions: contents: read @@ -46,16 +88,48 @@ jobs: with: toolchain: 1.94.1 + - name: Install LLVM 17 + uses: egor-tensin/setup-llvm@v1 + with: + version: 17 + + - 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 -j 1 --locked + env: + CARGO_BUILD_JOBS: 1 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 +144,43 @@ jobs: with: toolchain: 1.94.1 + - name: Install LLVM 17 + uses: egor-tensin/setup-llvm@v1 + with: + version: 17 + + - 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 -j 1 --release --locked + env: + CARGO_BUILD_JOBS: 1 - name: Get version id: version 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/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 From 8279f42e844fa3d553d7131d1425b2870a8338d7 Mon Sep 17 00:00:00 2001 From: Benjamin Reed Date: Sat, 18 Apr 2026 19:11:21 -0400 Subject: [PATCH 08/16] fix(driver): address P0 safety and linting issues - Add unsafe impl Sync for FfbQueue with SAFETY comment explaining UMDF serialization guarantees - Remove useless 'mut' binding on queue test (after &self API change) - Add #![expect(unsafe_code)] to lib.rs, ioctl.rs for workspace lint compliance - Check WdfRequestSetInformation returns in all 4 IOCTL handlers - Check WdfRequestComplete return with SAFETY comment - Add clippy suppression for test code unwrap/expect Co-Authored-By: Claude Haiku 4.5 --- crates/sideblinder-driver/src/ffb_handler.rs | 17 ++++++++++-- .../sideblinder-driver/src/hid_descriptor.rs | 2 ++ crates/sideblinder-driver/src/input_report.rs | 2 ++ crates/sideblinder-driver/src/ioctl.rs | 27 ++++++++++++------- crates/sideblinder-driver/src/lib.rs | 2 ++ 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/sideblinder-driver/src/ffb_handler.rs b/crates/sideblinder-driver/src/ffb_handler.rs index 0c42856..f954674 100644 --- a/crates/sideblinder-driver/src/ffb_handler.rs +++ b/crates/sideblinder-driver/src/ffb_handler.rs @@ -52,7 +52,9 @@ impl FfbReport { /// 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). +/// (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: [core::cell::UnsafeCell; Self::CAPACITY], @@ -60,6 +62,15 @@ pub struct FfbQueue { 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; @@ -124,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]; @@ -149,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]); diff --git a/crates/sideblinder-driver/src/hid_descriptor.rs b/crates/sideblinder-driver/src/hid_descriptor.rs index 025f862..7100f2c 100644 --- a/crates/sideblinder-driver/src/hid_descriptor.rs +++ b/crates/sideblinder-driver/src/hid_descriptor.rs @@ -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 4d057f6..37a7ee0 100644 --- a/crates/sideblinder-driver/src/input_report.rs +++ b/crates/sideblinder-driver/src/input_report.rs @@ -156,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 05b926a..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 @@ -110,7 +112,14 @@ pub unsafe extern "C" fn evt_io_internal_device_control( _ => STATUS_NOT_SUPPORTED, }; - 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 ─────────────────────────────────────────────────────── @@ -141,13 +150,13 @@ unsafe fn handle_get_device_descriptor(request: WDFREQUEST, out_len: usize) -> N needed, ); - 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 { @@ -174,13 +183,13 @@ unsafe fn handle_get_report_descriptor(request: WDFREQUEST, out_len: usize) -> N REPORT_DESCRIPTOR_LEN, ); - 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 { @@ -208,13 +217,13 @@ unsafe fn handle_get_device_attributes(request: WDFREQUEST, out_len: usize) -> N (*attrs).ProductID = PID; (*attrs).VersionNumber = VERSION; - 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 { @@ -239,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); - 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 { diff --git a/crates/sideblinder-driver/src/lib.rs b/crates/sideblinder-driver/src/lib.rs index d25f3bf..64ec50d 100644 --- a/crates/sideblinder-driver/src/lib.rs +++ b/crates/sideblinder-driver/src/lib.rs @@ -1,3 +1,5 @@ +#![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 From 6d733dbdaa7b6afbd0f34190c188c18b57e986c3 Mon Sep 17 00:00:00 2001 From: Benjamin Reed Date: Sat, 18 Apr 2026 19:12:06 -0400 Subject: [PATCH 09/16] docs: update stale 26-byte references to 27-byte for IPC protocol version byte Protocol frame size increased from 26 to 27 bytes after adding version byte. Update documentation in gui_pipe.rs and pipe_backend.rs (4 instances). Co-Authored-By: Claude Haiku 4.5 --- crates/sideblinder-app/src/gui_pipe.rs | 2 +- crates/sideblinder-gui/src/pipe_backend.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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-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 { From 08270249c0b8f55c7345c95b8540fe247408dd11 Mon Sep 17 00:00:00 2001 From: Benjamin Reed Date: Sat, 18 Apr 2026 19:18:50 -0400 Subject: [PATCH 10/16] ci: query for latest LLVM 17.x version instead of hardcoding Dynamically find and install the latest available 17.x version of LLVM using Chocolatey, with a fallback to the latest available if 17.x is not found. This addresses the user requirement to use the latest 17.x version while maintaining CI robustness. Closes #45 --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1382475..6c2ee7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,15 @@ jobs: components: clippy - name: Install LLVM 17 - uses: egor-tensin/setup-llvm@v1 - with: - version: 17 + shell: pwsh + run: | + $latest17 = choco search llvm --all-versions | Where-Object { $_ -match 'llvm 17\.' } | ForEach-Object { ($_ -split ' ')[1] } | Sort-Object { [version]$_ } -Descending | Select-Object -First 1 + if ($latest17) { + choco install llvm --version="$latest17" -y + } else { + Write-Host "WARNING: Could not find LLVM 17.x; installing latest available" + choco install llvm -y + } - name: Verify Windows SDK and WDK shell: pwsh @@ -89,9 +95,15 @@ jobs: toolchain: 1.94.1 - name: Install LLVM 17 - uses: egor-tensin/setup-llvm@v1 - with: - version: 17 + shell: pwsh + run: | + $latest17 = choco search llvm --all-versions | Where-Object { $_ -match 'llvm 17\.' } | ForEach-Object { ($_ -split ' ')[1] } | Sort-Object { [version]$_ } -Descending | Select-Object -First 1 + if ($latest17) { + choco install llvm --version="$latest17" -y + } else { + Write-Host "WARNING: Could not find LLVM 17.x; installing latest available" + choco install llvm -y + } - name: Verify Windows SDK and WDK shell: pwsh @@ -145,9 +157,15 @@ jobs: toolchain: 1.94.1 - name: Install LLVM 17 - uses: egor-tensin/setup-llvm@v1 - with: - version: 17 + shell: pwsh + run: | + $latest17 = choco search llvm --all-versions | Where-Object { $_ -match 'llvm 17\.' } | ForEach-Object { ($_ -split ' ')[1] } | Sort-Object { [version]$_ } -Descending | Select-Object -First 1 + if ($latest17) { + choco install llvm --version="$latest17" -y + } else { + Write-Host "WARNING: Could not find LLVM 17.x; installing latest available" + choco install llvm -y + } - name: Verify Windows SDK and WDK shell: pwsh From 313a03640bfbc8a337411f8a3221a2f12a55fdbe Mon Sep 17 00:00:00 2001 From: Benjamin Reed Date: Sat, 18 Apr 2026 19:19:16 -0400 Subject: [PATCH 11/16] fix(driver): apply clippy unwrap suppression to entire test module Change from outer attribute #[expect(...)] to inner attribute #![expect(...)] so the suppression applies to all unwraps in the test module, not just the first test. The module contains three unwraps (lines 174, 175, 187) that all require the same suppression since panicking on errors is the correct failure mode for tests. This addresses the code reviewer's feedback about incomplete attribute coverage. --- crates/sideblinder-driver/src/ffb_handler.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sideblinder-driver/src/ffb_handler.rs b/crates/sideblinder-driver/src/ffb_handler.rs index f954674..03a71ca 100644 --- a/crates/sideblinder-driver/src/ffb_handler.rs +++ b/crates/sideblinder-driver/src/ffb_handler.rs @@ -135,7 +135,7 @@ impl FfbQueue { mod tests { use super::*; - #[expect(clippy::unwrap_used, reason = "test code — panics are the failure mode")] + #![expect(clippy::unwrap_used, reason = "test code — panics are the failure mode")] #[test] fn from_bytes_and_round_trip() { From dfd71fc111103a2e4eb21535d5877d8da2fdf677 Mon Sep 17 00:00:00 2001 From: Benjamin Reed Date: Sat, 18 Apr 2026 19:21:10 -0400 Subject: [PATCH 12/16] ci: download LLVM 17 directly from official releases Chocolatey no longer provides LLVM 17.x (latest available is 20.x). Download LLVM 17.0.6 directly from the official GitHub releases instead, which is more reliable and gives us full control over the version. Fixes CI job failure where Chocolatey installed LLVM 20 instead of 17.x. --- .github/workflows/ci.yml | 63 ++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c2ee7c..7b1b3b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,22 @@ jobs: - name: Install LLVM 17 shell: pwsh run: | - $latest17 = choco search llvm --all-versions | Where-Object { $_ -match 'llvm 17\.' } | ForEach-Object { ($_ -split ' ')[1] } | Sort-Object { [version]$_ } -Descending | Select-Object -First 1 - if ($latest17) { - choco install llvm --version="$latest17" -y - } else { - Write-Host "WARNING: Could not find LLVM 17.x; installing latest available" - choco install llvm -y + $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 @@ -97,13 +106,22 @@ jobs: - name: Install LLVM 17 shell: pwsh run: | - $latest17 = choco search llvm --all-versions | Where-Object { $_ -match 'llvm 17\.' } | ForEach-Object { ($_ -split ' ')[1] } | Sort-Object { [version]$_ } -Descending | Select-Object -First 1 - if ($latest17) { - choco install llvm --version="$latest17" -y - } else { - Write-Host "WARNING: Could not find LLVM 17.x; installing latest available" - choco install llvm -y + $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 @@ -159,13 +177,22 @@ jobs: - name: Install LLVM 17 shell: pwsh run: | - $latest17 = choco search llvm --all-versions | Where-Object { $_ -match 'llvm 17\.' } | ForEach-Object { ($_ -split ' ')[1] } | Sort-Object { [version]$_ } -Descending | Select-Object -First 1 - if ($latest17) { - choco install llvm --version="$latest17" -y - } else { - Write-Host "WARNING: Could not find LLVM 17.x; installing latest available" - choco install llvm -y + $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 From 1b9b9db589110f2ade3827048b89a53b1494582e Mon Sep 17 00:00:00 2001 From: Benjamin Reed Date: Sat, 18 Apr 2026 19:24:09 -0400 Subject: [PATCH 13/16] ci: optimize by only applying -j 1 to driver crate build The wdk-macros race condition only affects sideblinder-driver. Build other crates in parallel for faster CI, then build sideblinder-driver with -j 1 serialization. This splits each job into: cargo --workspace --exclude sideblinder-driver cargo -p sideblinder-driver -j 1 Applied to clippy, test, and release build jobs. --- .github/workflows/ci.yml | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b1b3b9..f451359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,12 @@ name: CI -# NOTE: every Windows job below forces `-j 1` / CARGO_BUILD_JOBS=1. This is -# NOT a performance tuning — removing it will break the build. `wdk-macros` -# 0.5.1 (a transitive dep via `wdk` through `sideblinder-driver`) 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. See CLAUDE.md -> Building for details. Upstream fix tracked at +# 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. @@ -82,7 +82,9 @@ jobs: uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Clippy - run: cargo clippy -j 1 --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 env: CARGO_BUILD_JOBS: 1 @@ -152,7 +154,9 @@ jobs: uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Test - run: cargo test -j 1 --locked + run: | + cargo test --workspace --exclude sideblinder-driver --locked + cargo test -p sideblinder-driver -j 1 --locked env: CARGO_BUILD_JOBS: 1 @@ -223,7 +227,9 @@ jobs: uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Build - run: cargo build -j 1 --release --locked + run: | + cargo build --workspace --exclude sideblinder-driver --release --locked + cargo build -p sideblinder-driver -j 1 --release --locked env: CARGO_BUILD_JOBS: 1 From 6f4fd6e8f70c2bbf0b2d2590967f9cfd74bd2b8e Mon Sep 17 00:00:00 2001 From: Benjamin Reed Date: Sat, 18 Apr 2026 19:24:47 -0400 Subject: [PATCH 14/16] ci: remove CARGO_BUILD_JOBS=1 from env to allow parallel builds Now that we're only applying -j 1 to the driver crate specifically, we don't need CARGO_BUILD_JOBS=1 in the environment. Removing it allows the non-driver crates to build in parallel as intended. --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f451359..632c49c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,8 +85,6 @@ jobs: 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 - env: - CARGO_BUILD_JOBS: 1 test: name: Test @@ -157,8 +155,6 @@ jobs: run: | cargo test --workspace --exclude sideblinder-driver --locked cargo test -p sideblinder-driver -j 1 --locked - env: - CARGO_BUILD_JOBS: 1 build: name: Release Build @@ -230,8 +226,6 @@ jobs: run: | cargo build --workspace --exclude sideblinder-driver --release --locked cargo build -p sideblinder-driver -j 1 --release --locked - env: - CARGO_BUILD_JOBS: 1 - name: Get version id: version From 3caca09e9083896ae6722a6e22ed49ee1a1f37d6 Mon Sep 17 00:00:00 2001 From: Benjamin Reed Date: Sat, 18 Apr 2026 19:26:43 -0400 Subject: [PATCH 15/16] ci: cache LLVM 17 installer to avoid repeated downloads Add GitHub Actions cache step to store the LLVM installer in the cache, keyed by version. The install step checks for a cached copy before downloading, significantly reducing CI time on cache hits. --- .github/workflows/ci.yml | 56 +++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 632c49c..d48472a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,18 +103,34 @@ 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 = "$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 + $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) { @@ -174,18 +190,34 @@ 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 = "$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 + $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) { From bf4bde0046e2f3feaf0f251819dde11e10c1603c Mon Sep 17 00:00:00 2001 From: Benjamin Reed Date: Sat, 18 Apr 2026 19:27:27 -0400 Subject: [PATCH 16/16] ci: add concurrency to cancel stale runs When a new push or pull request triggers CI, cancel any in-progress runs from earlier commits on the same branch. This prevents wasting CI resources on outdated builds. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d48472a..fe4c89a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ on: branches: [main] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: clippy: name: Clippy