From 359eb7b8e470e225a624769a47305881ecb9448e Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:25:59 -0700 Subject: [PATCH 01/16] feat(device-config): add sdl-gamepad protocol and communication specifier Adds the SdlGamepadSpecifier enum variant, schema component, protocol YAML (two 0-65535 vibrate features, low/high motors), and the regenerated device config (v5.43). Structural inspiration: chiefautism's abandoned PR #860. --- .../CHANGELOG.md | 6 +++ .../buttplug_server_device_config/Cargo.toml | 2 + .../buttplug-device-config-schema-v5.json | 14 ++++++ .../device-config/protocols/sdl-gamepad.yml | 21 ++++++++ .../src/device_config_file/protocol.rs | 1 + .../src/specifier.rs | 27 ++++++++++ .../tests/test_device_config.rs | 50 ++++++++++++++++++- 7 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml diff --git a/crates/buttplug_server_device_config/CHANGELOG.md b/crates/buttplug_server_device_config/CHANGELOG.md index 6a0d021de..f8f4dc909 100644 --- a/crates/buttplug_server_device_config/CHANGELOG.md +++ b/crates/buttplug_server_device_config/CHANGELOG.md @@ -1,3 +1,9 @@ +# 11.0.1 (2026-09-05) + +## Features + +- Add `sdl-gamepad` protocol and communication specifier: cross-platform gamepad rumble via SDL3 (two 0-65535 vibrate features, low/high frequency motors). Device config version bumped to 5.43. Structural inspiration credit: chiefautism's abandoned PR #860. + # 11.0.0 (2026-07-28) ## Features diff --git a/crates/buttplug_server_device_config/Cargo.toml b/crates/buttplug_server_device_config/Cargo.toml index c6c627d25..a718af398 100644 --- a/crates/buttplug_server_device_config/Cargo.toml +++ b/crates/buttplug_server_device_config/Cargo.toml @@ -44,3 +44,5 @@ buttplug_core = { version = "11.0.0", path = "../buttplug_core" } [dev-dependencies] test-case = "3.3.1" +serde_json = "1.0" +serde_yaml = "0.9" diff --git a/crates/buttplug_server_device_config/device-config/buttplug-device-config-schema-v5.json b/crates/buttplug_server_device_config/device-config/buttplug-device-config-schema-v5.json index a2393161e..040bbfeaf 100644 --- a/crates/buttplug_server_device_config/device-config/buttplug-device-config-schema-v5.json +++ b/crates/buttplug_server_device_config/device-config/buttplug-device-config-schema-v5.json @@ -138,6 +138,14 @@ } } }, + "sdl-gamepad-definition": { + "type": "object", + "properties": { + "exists": { + "type": "boolean" + } + } + }, "lovense-connect-service-definition": { "type": "object", "properties": { @@ -478,6 +486,9 @@ "xinput": { "$ref": "#/components/xinput-definition" }, + "sdl-gamepad": { + "$ref": "#/components/sdl-gamepad-definition" + }, "lovense_connect_service": { "$ref": "#/components/lovense-connect-service-definition" }, @@ -531,6 +542,9 @@ "xinput": { "$ref": "#/components/xinput-definition" }, + "sdl-gamepad": { + "$ref": "#/components/sdl-gamepad-definition" + }, "lovense_connect_service": { "$ref": "#/components/lovense-connect-service-definition" }, diff --git a/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml new file mode 100644 index 000000000..a40e23da1 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml @@ -0,0 +1,21 @@ +defaults: + name: SDL Gamepad + features: + - id: f56852c8-cb3b-4703-90b6-6291df0c6314 + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: e13388f9-a1b6-4c4c-a7b4-c68eeed293d8 + output: + vibrate: + value: + - 0 + - 65535 + index: 1 + id: b35f2adf-16bc-4425-9276-5d191aeaf107 +communication: +- sdl-gamepad: + exists: true diff --git a/crates/buttplug_server_device_config/src/device_config_file/protocol.rs b/crates/buttplug_server_device_config/src/device_config_file/protocol.rs index a3a5366a8..1d49bca24 100644 --- a/crates/buttplug_server_device_config/src/device_config_file/protocol.rs +++ b/crates/buttplug_server_device_config/src/device_config_file/protocol.rs @@ -19,6 +19,7 @@ const KNOWN_COMMUNICATION_SPECIFIERS: &[&str] = &[ "usb", "serial", "xinput", + "sdl-gamepad", "lovense_connect_service", "websocket", "simulated", diff --git a/crates/buttplug_server_device_config/src/specifier.rs b/crates/buttplug_server_device_config/src/specifier.rs index 884b6c5a6..6fdf1c98f 100644 --- a/crates/buttplug_server_device_config/src/specifier.rs +++ b/crates/buttplug_server_device_config/src/specifier.rs @@ -250,6 +250,30 @@ impl PartialEq for XInputSpecifier { } } +/// Specifier for SDL3 gamepad devices +/// +/// Cross-platform gamepad rumble via SDL3. Has no attributes because the +/// SDL gamepad device communication manager handles all device discovery and +/// identification itself, using SDL3 instance IDs as addresses. +#[derive(Serialize, Deserialize, Debug, Clone, Copy)] +pub struct SdlGamepadSpecifier { + // Needed for deserialization but unused. + #[allow(dead_code)] + exists: bool, +} + +impl Default for SdlGamepadSpecifier { + fn default() -> Self { + Self { exists: true } + } +} + +impl PartialEq for SdlGamepadSpecifier { + fn eq(&self, _other: &Self) -> bool { + true + } +} + #[derive( Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, Getters, Setters, MutGetters, )] @@ -377,6 +401,8 @@ pub enum ProtocolCommunicationSpecifier { Serial(SerialSpecifier), #[serde(rename = "xinput")] XInput(XInputSpecifier), + #[serde(rename = "sdl-gamepad")] + SdlGamepad(SdlGamepadSpecifier), #[serde(rename = "lovense_connect_service")] LovenseConnectService(LovenseConnectServiceSpecifier), #[serde(rename = "websocket")] @@ -394,6 +420,7 @@ impl PartialEq for ProtocolCommunicationSpecifier { (BluetoothLE(self_spec), BluetoothLE(other_spec)) => self_spec == other_spec, (HID(self_spec), HID(other_spec)) => self_spec == other_spec, (XInput(self_spec), XInput(other_spec)) => self_spec == other_spec, + (SdlGamepad(self_spec), SdlGamepad(other_spec)) => self_spec == other_spec, (Websocket(self_spec), Websocket(other_spec)) => self_spec == other_spec, (LovenseConnectService(self_spec), LovenseConnectService(other_spec)) => { self_spec == other_spec diff --git a/crates/buttplug_server_device_config/tests/test_device_config.rs b/crates/buttplug_server_device_config/tests/test_device_config.rs index 5abf83bb7..752f9098b 100644 --- a/crates/buttplug_server_device_config/tests/test_device_config.rs +++ b/crates/buttplug_server_device_config/tests/test_device_config.rs @@ -5,9 +5,57 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. -use buttplug_server_device_config::{UserDeviceIdentifier, load_protocol_configs}; +use buttplug_server_device_config::{ + ProtocolCommunicationSpecifier, + SdlGamepadSpecifier, + UserDeviceIdentifier, + load_protocol_configs, +}; use test_case::test_case; +#[test] +fn test_sdl_gamepad_specifier_round_trip() { + // JSON form, as it appears in the generated device config file. + let from_json: ProtocolCommunicationSpecifier = + serde_json::from_str(r#"{"sdl-gamepad": {"exists": true}}"#).unwrap(); + assert_eq!( + from_json, + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + ); + let back_to_json = serde_json::to_string(&from_json).unwrap(); + assert_eq!(back_to_json, r#"{"sdl-gamepad":{"exists":true}}"#); + + // YAML form, as it appears in the protocol definition YAML files. The build + // pipeline (see build.rs) parses YAML straight into serde_json::Value before + // the config structs deserialize from it, so mirror that path here. + let yaml_value: serde_json::Value = + serde_yaml::from_str("- sdl-gamepad:\n exists: true\n").unwrap(); + let from_yaml: ProtocolCommunicationSpecifier = + serde_json::from_value(yaml_value[0].clone()).unwrap(); + assert_eq!( + from_yaml, + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + ); +} + +#[test] +fn test_sdl_gamepad_protocol_in_generated_config() { + let config = std::fs::read_to_string("build-config/buttplug-device-config-v5.json").unwrap(); + let json: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_eq!(json["version"]["major"], 5); + let protocol = &json["protocols"]["sdl-gamepad"]; + assert_eq!(protocol["defaults"]["name"], "SDL Gamepad"); + let features = protocol["defaults"]["features"].as_array().unwrap(); + assert_eq!(features.len(), 2); + for (i, feature) in features.iter().enumerate() { + assert_eq!(feature["index"], i as u64); + assert_eq!(feature["output"]["vibrate"]["value"][0], 0); + assert_eq!(feature["output"]["vibrate"]["value"][1], 65535); + } + let communication = protocol["communication"][0]["sdl-gamepad"].clone(); + assert_eq!(communication["exists"], true); +} + #[test_case("version_only.json" ; "Version Only")] #[test_case("base_aneros_protocol.json" ; "Aneros Protocol")] #[test_case("base_tcode_protocol.json" ; "TCode Protocol")] From 861ff76bcee1f8eddf616b16077c63a48a8eaab2 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:11 -0700 Subject: [PATCH 02/16] feat(server): add SDL gamepad protocol implementation Stateful two-motor handler mirroring the XInput protocol: every vibrate command carries the full motor state as two u16 LE values on Tx, so each write is a complete command with no batching step. --- .../src/device/protocol_impl/mod.rs | 5 + .../src/device/protocol_impl/sdl_gamepad.rs | 118 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 68fd44825..361d23e48 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -97,6 +97,7 @@ pub mod raw_protocol; pub mod realov; pub mod sakuraneko; pub mod satisfyer; +pub mod sdl_gamepad; pub mod sensee; pub mod sensee_capsule; pub mod sensee_v2; @@ -598,6 +599,10 @@ pub fn get_default_protocol_map() -> HashMap Result, ButtplugDeviceError> { + if feature_index > 1 { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "SdlGamepad".to_owned(), + format!("SDL gamepad only has 2 vibrate features, got index {feature_index}"), + )); + } + self.speeds[feature_index as usize].store(speed as u16, Ordering::Relaxed); + let mut cmd = vec![]; + if cmd + .write_u16::(self.speeds[0].load(Ordering::Relaxed)) + .is_err() + || cmd + .write_u16::(self.speeds[1].load(Ordering::Relaxed)) + .is_err() + { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "SdlGamepad".to_owned(), + "Cannot convert SDL gamepad value for processing".to_owned(), + )); + } + Ok(vec![ + HardwareWriteCmd::new(&[_feature_id], Endpoint::Tx, cmd, false).into(), + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vibrate(handler: &SdlGamepad, feature_index: u32, speed: u32) -> Vec { + let cmds = handler + .handle_output_vibrate_cmd(feature_index, uuid::Uuid::new_v4(), speed) + .expect("vibrate command should build"); + assert_eq!(cmds.len(), 1); + match &cmds[0] { + HardwareCommand::Write(write_cmd) => { + assert_eq!(write_cmd.endpoint(), Endpoint::Tx); + write_cmd.data().clone() + } + _ => panic!("expected a write command"), + } + } + + #[test] + fn sdl_gamepad_packs_both_motor_states() { + let handler = SdlGamepad::default(); + + // Feature 0 (low motor) only: high motor stays 0. + let packet = vibrate(&handler, 0, 0x8000); + assert_eq!(packet, vec![0x00, 0x80, 0x00, 0x00]); + + // Feature 1 (high motor) now set: packet must carry BOTH stored speeds, + // proving the handler is stateful across commands. + let packet = vibrate(&handler, 1, 0x7fff); + assert_eq!(packet, vec![0x00, 0x80, 0xff, 0x7f]); + + // Updating feature 0 again keeps feature 1's stored speed. + let packet = vibrate(&handler, 0, 0x1234); + assert_eq!(packet, vec![0x34, 0x12, 0xff, 0x7f]); + + // Speeds clamp to u16 in the same way as XInput (store as u16). + let packet = vibrate(&handler, 0, 0xffff); + assert_eq!(packet, vec![0xff, 0xff, 0xff, 0x7f]); + } + + #[test] + fn sdl_gamepad_rejects_out_of_range_feature() { + let handler = SdlGamepad::default(); + assert!( + handler + .handle_output_vibrate_cmd(2, uuid::Uuid::new_v4(), 100) + .is_err() + ); + } +} From 70ebbd3e86336a38b0efde280998b73ca4902674 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:11 -0700 Subject: [PATCH 03/16] feat(hwmgr): add buttplug_server_hwmgr_sdl_gamepad crate Cross-platform (Windows/macOS/Linux) gamepad rumble via SDL3, statically built from source. One process-lifetime thread owns the SDL context and multiplexes all gamepads addressed by SDL3 instance ID; discovery is on-demand enumeration and removal is connected-state polling - the thread never pumps SDL events. Rumble is armed finitely (60s) and refreshed before expiry so one-shot commands hold indefinitely. Includes the Phase 0 threading spike example (headless init + no-pump enumeration on a spawned thread). Joins the workspace as both a member and default member so plain cargo build compiles it on all three OSes. Structural inspiration: chiefautism's abandoned PR #860. --- CLAUDE.md | 1 + Cargo.toml | 2 + README.md | 1 + .../CHANGELOG.md | 5 + .../Cargo.toml | 48 + .../README.md | 63 + .../examples/sdl3_thread_spike.rs | 54 + .../src/lib.rs | 28 + .../src/sdl_comm_manager.rs | 279 ++++ .../src/sdl_gamepad_hardware.rs | 443 ++++++ .../src/sdl_task.rs | 1324 +++++++++++++++++ 11 files changed, 2248 insertions(+) create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/README.md create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3c7519236..110b836e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ Buttplug is a framework for interfacing with intimate hardware devices. It uses - `serial`, `hid` - USB serial and HID devices - `lovense_dongle`, `lovense_connect` - Lovense-specific (deprecated) - `xinput` - Windows gamepad vibration +- `sdl_gamepad` - Cross-platform gamepad rumble via SDL3 (opt-in) - `websocket` - WebSocket device forwarders - `simulated` - In-process simulated devices (no real hardware; lives in `buttplug_server`) diff --git a/Cargo.toml b/Cargo.toml index 7cf009f62..61c0cbc90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/buttplug_server_hwmgr_websocket", "crates/buttplug_server_hwmgr_webbluetooth", "crates/buttplug_server_hwmgr_xinput", + "crates/buttplug_server_hwmgr_sdl_gamepad", "crates/buttplug_tests", "crates/buttplug_transport_websocket_tungstenite", "crates/buttplug_wasm", @@ -37,6 +38,7 @@ default-members = [ "crates/buttplug_server_hwmgr_serial", "crates/buttplug_server_hwmgr_websocket", "crates/buttplug_server_hwmgr_xinput", + "crates/buttplug_server_hwmgr_sdl_gamepad", "crates/buttplug_tests", "crates/buttplug_transport_websocket_tungstenite", "crates/intiface_engine", diff --git a/README.md b/README.md index b237fe618..23e8699a9 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ This project consists of the following crates: | [buttplug_server_hwmgr_serial](crates/buttplug_server_hwmgr_serial/) | Serial device communication support | | [buttplug_server_hwmgr_websocket](crates/buttplug_server_hwmgr_websocket/) | Websocket device communication suppor, used for devices that may connect in ways not directly supported by other formats | | [buttplug_server_hwmgr_xinput](crates/buttplug_server_hwmgr_xinput/) | XInput gamepad support (windows only) | +| [buttplug_server_hwmgr_sdl_gamepad](crates/buttplug_server_hwmgr_sdl_gamepad/) | Cross-platform gamepad rumble via SDL3 (opt-in) | | [buttplug_tests](crates/buttplug_tests/) | For tests that need the whole framework | | [buttplug_transport_websocket_tungstenite](crates/buttplug_transport_websocket_tungstenite/) | Communications transport for clients/servers using tokio-tungstenite | | [intiface_engine](crates/intiface_engine/) | Command line interface for running a Buttplug server | diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md new file mode 100644 index 000000000..cbaba8dc0 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -0,0 +1,5 @@ +# 11.0.0 (2026-09-05) + +## Features + +- Initial release. Cross-platform (Windows/macOS/Linux) gamepad rumble hardware manager for Buttplug, built on SDL3 via the `sdl3` crate (statically linked, built from source). One process-lifetime thread owns the SDL context and multiplexes all gamepads; devices are addressed by SDL3 instance ID (`sdl-gamepad-{instance_id}`) and present two 0-65535 vibrate features. Structural inspiration credit: chiefautism's abandoned PR #860. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml new file mode 100644 index 000000000..ef3b4e7f1 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "buttplug_server_hwmgr_sdl_gamepad" +version = "11.0.0" +authors = ["Nonpolynomial Labs, LLC "] +description = "Buttplug Intimate Hardware Control Library - SDL3 Gamepad Hardware Manager" +license = "BSD-3-Clause" +homepage = "http://buttplug.io" +repository = "https://github.com/buttplugio/buttplug.git" +readme = "./README.md" +keywords = ["usb", "serial", "hardware", "bluetooth", "teledildonics"] +edition = "2024" +exclude = ["examples/**"] + +[lib] +name = "buttplug_server_hwmgr_sdl_gamepad" +path = "src/lib.rs" +test = true +doctest = true +doc = true + +[[example]] +name = "sdl3_thread_spike" +path = "examples/sdl3_thread_spike.rs" + +[dependencies] +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "11.0.0", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "11.0.0", path = "../buttplug_server_device_config" } +futures = "0.3.33" +futures-util = "0.3.33" +log = "0.4.33" +tokio = { version = "1.53.1", features = ["sync", "time", "rt"] } +async-trait = "0.1.91" +uuid = { version = "1.24.0", features = ["serde", "v4"] } +tracing = "0.1.44" +thiserror = "2.0.19" +byteorder = "1.5.0" +tokio-util = "0.7.19" +sdl3 = { version = "0.18.4", features = ["build-from-source-static"] } +# Direct sdl3-sys dep exists solely to enable `debug-impls` (Debug/Display +# derives on SDL newtypes like JoystickId); features unify with the sdl3 +# crate's own sdl3-sys dependency, so nothing about linking changes. +sdl3-sys = { version = "0.6.8", default-features = false, features = ["debug-impls", "display-impls"] } + +[dev-dependencies] +buttplug_core = { version = "11.0.0", path = "../buttplug_core", default-features = false, features = ["tokio-runtime"] } +tokio = { version = "1.53.1", features = ["rt", "macros", "time", "sync"] } +futures = "0.3.33" diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md new file mode 100644 index 000000000..3617b89fd --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -0,0 +1,63 @@ +# buttplug_server_hwmgr_sdl_gamepad + +Cross-platform (Windows/macOS/Linux) gamepad rumble hardware manager for +[Buttplug](https://buttplug.io), built on SDL3 via the `sdl3` Rust crate. + +Gamepads appear as Buttplug devices with two 0-65535 vibrate features (low and +high frequency rumble motors), identified by the `sdl-gamepad` protocol. Each +device is addressed by its SDL3 instance ID (`sdl-gamepad-{instance_id}`), +which is stable for the lifetime of the connection. + +This manager is **opt-in everywhere**: + +- In `intiface-engine`, pass `--use-sdl-gamepad`. +- In `buttplug_client_in_process`, enable the non-default + `sdl-gamepad-manager` cargo feature. + +## How it works + +A single process-lifetime thread owns the SDL3 context. All gamepads are +multiplexed through it: discovery is on-demand `SDL_GetGamepads` enumeration +and removal detection is per-device connected-state polling. The thread never +pumps SDL events (SDL3 documents `SDL_PumpEvents` as main-thread-only, and +this manager does not consume controller input). + +Rumble is armed with a finite duration and refreshed by the SDL thread before +expiry, so one-shot ScalarCmd commands hold indefinitely. The sdl3 crate +documents that `u32::MAX` durations overflow and end the effect immediately, +so infinite durations are never used. + +## Build prerequisites + +The `sdl3` dependency uses the `build-from-source-static` feature: SDL3 is +downloaded and built (and statically linked) at crate build time. This +requires `cmake` and a C compiler on the build machine: + +- macOS: Xcode command line tools (`xcode-select --install`) +- Linux: `gcc`/`clang` and `cmake` (plus the usual development headers for a + headless SDL3 build; on Debian/Ubuntu `build-essential` and `cmake` suffice + for the joystick/gamepad subsystem) +- Windows: Visual Studio C++ build tools and `cmake` (both present on GitHub + Actions windows runners) + +Static linking keeps the single-binary release pipeline unchanged; expect the +resulting binary to grow by a few MB. + +## Testing without hardware + +CI runners have no physical gamepads and the `sdl3` crate has no simulation +layer. All buttplug-side behavior (discovery, addressing, command forwarding, +lifecycle) is unit-tested in this crate against mock drivers/backends. The +SDL-thread interior is exercised by the `examples/sdl3_thread_spike.rs` +example (headless init + no-pump enumeration on a spawned thread), which CI +runs on all three operating systems. Real-controller behavior — notably +smooth continuous rumble with the refresh-before-expiry scheme — must be +validated manually on each platform before release; see the manual validation +checklist in the repository's pull request for this feature. + +## Coexistence with XInput + +On Windows, both the XInput manager and this manager can be enabled at the +same time; the same physical controller may then appear as two Buttplug +devices (once via each manager). `intiface-engine` logs a warning when both +flags are set. Outside Windows, only this manager is available. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs new file mode 100644 index 000000000..3c9b8d2c3 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -0,0 +1,54 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +// Phase 0 threading spike (automated half). +// +// Verifies the machinery the SDL gamepad manager relies on: +// - sdl3::init() + gamepad subsystem initialize on a dedicated spawned thread +// (not the process main thread), headless (no video subsystem). +// - gamepads() enumerates on demand without any SDL event pumping (an empty +// set is acceptable; CI runners have no controllers). +// - the thread's poll tick runs without crashing. +// +// This cannot prove real-controller behavior; that is the manual, per-OS half +// of the spike documented in the crate README. +use std::thread; +use std::time::Duration; + +fn main() { + let handle = thread::Builder::new() + .name("sdl3-spike".to_string()) + .spawn(|| { + println!("[sdl-thread] setting JOYSTICK_ALLOW_BACKGROUND_EVENTS hint (pre-init)"); + sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + println!("[sdl-thread] sdl3::init()"); + let sdl = sdl3::init().expect("sdl3::init() must work on a dedicated thread"); + println!("[sdl-thread] init OK; initializing gamepad subsystem (headless)"); + let gamepad = sdl + .gamepad() + .expect("gamepad subsystem must initialize headless"); + println!("[sdl-thread] gamepad subsystem OK"); + match gamepad.gamepads() { + Ok(ids) => println!("[sdl-thread] gamepads() -> {} gamepad(s)", ids.len()), + Err(e) => { + eprintln!("[sdl-thread] gamepads() failed: {e:?}"); + std::process::exit(2); + } + } + for i in 0..20 { + let ids = gamepad.gamepads().expect("gamepads() during tick"); + if i % 5 == 0 { + println!("[sdl-thread] tick {}: {} gamepad(s)", i, ids.len()); + } + thread::sleep(Duration::from_millis(100)); + } + println!("[sdl-thread] spike passed"); + }) + .expect("spawn sdl thread"); + handle.join().expect("sdl thread join"); + println!("PASS"); +} diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs new file mode 100644 index 000000000..e7de65e63 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs @@ -0,0 +1,28 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Cross-platform (Windows/macOS/Linux) gamepad rumble hardware manager for +//! Buttplug, built on SDL3. +//! +//! A single process-lifetime thread owns the SDL3 context and multiplexes all +//! gamepads; discovery is on-demand SDL gamepad enumeration and removal +//! detection is per-device connected-state polling. No SDL events are pumped +//! (SDL3 documents `SDL_PumpEvents` as main-thread-only, and this manager +//! does not consume controller input). +//! +//! This manager is opt-in: use `--use-sdl-gamepad` with intiface-engine, or +//! the non-default `sdl-gamepad-manager` cargo feature of +//! buttplug_client_in_process. + +#[macro_use] +extern crate log; + +mod sdl_comm_manager; +mod sdl_gamepad_hardware; +mod sdl_task; + +pub use sdl_comm_manager::{SdlGamepadCommunicationManager, SdlGamepadCommunicationManagerBuilder}; diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs new file mode 100644 index 000000000..b02f8b1b7 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -0,0 +1,279 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Communication manager for SDL3 gamepads. + +use super::{ + sdl_gamepad_hardware::SdlGamepadHardwareConnector, + sdl_task::{SdlGamepadBackend, SdlGamepadDesc, SdlTaskBackend, SdlTaskError}, +}; +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server::device::hardware::communication::{ + HardwareCommunicationManager, + HardwareCommunicationManagerBuilder, + HardwareCommunicationManagerEvent, + TimedRetryCommunicationManager, + TimedRetryCommunicationManagerImpl, +}; +use sdl3::joystick::JoystickId; +use std::sync::Arc; +use tokio::sync::mpsc; + +/// Creates a buttplug device address from an SDL3 instance ID. This is the +/// only place instance IDs become part of the buttplug address space. +pub(crate) fn create_address(id: JoystickId) -> String { + format!("sdl-gamepad-{}", id.0) +} + +#[derive(Default, Clone)] +pub struct SdlGamepadCommunicationManagerBuilder {} + +impl HardwareCommunicationManagerBuilder for SdlGamepadCommunicationManagerBuilder { + fn finish( + &mut self, + sender: mpsc::Sender, + ) -> Box { + Box::new(TimedRetryCommunicationManager::new( + SdlGamepadCommunicationManager::new(sender), + )) + } +} + +pub struct SdlGamepadCommunicationManager { + sender: mpsc::Sender, + backend: Arc, +} + +impl SdlGamepadCommunicationManager { + fn new(sender: mpsc::Sender) -> Self { + Self { + sender, + backend: Arc::new(SdlTaskBackend::global()), + } + } + + /// Real scan work: enumerate via the backend and emit one DeviceFound event + /// per gamepad. Distinguishes transient enumeration failures from a dead + /// event channel so [`scan`](TimedRetryCommunicationManagerImpl::scan) can + /// swallow the former but stop the retry loop on the latter. + async fn enumerate_or_fail(&self) -> Result<(), ScanFailure> { + let gamepads: Vec = self + .backend + .gamepads() + .await + .map_err(|e: SdlTaskError| ScanFailure::Enumeration(device_error("scan", e)))?; + for gamepad in gamepads { + let address = create_address(gamepad.id); + info!( + "SDL gamepad manager found device {} at address {}", + gamepad.name, address + ); + if self + .sender + .send(HardwareCommunicationManagerEvent::DeviceFound { + name: gamepad.name.clone(), + address: address.clone(), + creator: Box::new(SdlGamepadHardwareConnector::new( + self.backend.clone(), + gamepad.id, + gamepad.name, + address, + )), + }) + .await + .is_err() + { + error!("Error sending device found message from SDL gamepad manager."); + return Err(ScanFailure::EventChannelClosed); + } + } + Ok(()) + } + + /// Error-propagating form. Production `scan` uses [`Self::enumerate_or_fail`] + /// to distinguish failure classes; this form exists (and is exercised by + /// tests) to assert the propagation contract: enumeration errors ARE + /// propagated by the internal implementation and only swallowed at the + /// trait boundary. + #[cfg(test)] + async fn enumerate_and_emit(&self) -> Result<(), ButtplugDeviceError> { + self + .enumerate_or_fail() + .await + .map_err(|failure| match failure { + ScanFailure::Enumeration(e) => e, + ScanFailure::EventChannelClosed => device_error("event send", SdlTaskError::ThreadClosed), + }) + } +} + +enum ScanFailure { + Enumeration(ButtplugDeviceError), + /// The event consumer is gone (server shutting down): permanent, the scan + /// loop should stop instead of spinning forever. + EventChannelClosed, +} + +fn device_error(operation: &str, e: SdlTaskError) -> ButtplugDeviceError { + ButtplugDeviceError::DeviceCommunicationError(format!( + "SDL gamepad manager {operation} error: {e}" + )) +} + +#[async_trait] +impl TimedRetryCommunicationManagerImpl for SdlGamepadCommunicationManager { + fn name(&self) -> &'static str { + "SdlGamepadCommunicationManager" + } + + async fn scan(&self) -> Result<(), ButtplugDeviceError> { + trace!("SDL gamepad manager scanning for devices"); + // Transient enumeration failures are deliberately swallowed here with a + // logged warning: TimedRetryCommunicationManager breaks its scan loop on + // any Err while leaving scanning_status() true, so surfacing one would + // silently kill discovery while still reporting "scanning". The retry + // loop simply tries again on its next tick. + // + // A dead event channel is NOT transient (the consumer is gone), so that + // failure is surfaced to deliberately stop the retry loop. + match self.enumerate_or_fail().await { + Ok(()) => {} + Err(ScanFailure::Enumeration(e)) => { + warn!("SDL gamepad manager scan failed, will retry: {e}"); + } + Err(ScanFailure::EventChannelClosed) => { + error!("SDL gamepad manager event channel closed; stopping scan loop."); + return Err(device_error("event send", SdlTaskError::ThreadClosed)); + } + } + Ok(()) + } + + // If SDL failed to initialize at startup (published inert state), the + // manager reports itself unable to scan. + fn can_scan(&self) -> bool { + self.backend.initialized() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sdl_task::{SdlTaskError, joystick_id}; + use std::sync::Mutex as StdMutex; + + /// Mock outer-seam backend: configurable gamepad list / failure. + struct MockBackend { + gamepads: StdMutex, SdlTaskError>>, + } + + #[async_trait] + impl SdlGamepadBackend for MockBackend { + fn initialized(&self) -> bool { + true + } + + async fn gamepads(&self) -> Result, SdlTaskError> { + self.gamepads.lock().unwrap().clone() + } + + async fn open( + &self, + _id: JoystickId, + ) -> Result, SdlTaskError> { + panic!("open is not exercised through this mock") + } + } + + fn manager_with( + gamepads: Result, SdlTaskError>, + ) -> ( + mpsc::Receiver, + SdlGamepadCommunicationManager, + ) { + let (tx, rx) = mpsc::channel(32); + let manager = SdlGamepadCommunicationManager { + sender: tx, + backend: Arc::new(MockBackend { + gamepads: StdMutex::new(gamepads), + }), + }; + (rx, manager) + } + + fn desc(id: u32, name: &str) -> SdlGamepadDesc { + SdlGamepadDesc { + id: joystick_id(id), + name: name.to_owned(), + } + } + + #[tokio::test] + async fn comm_manager_scan_emits_device_found_with_stable_addresses() { + let (mut rx, manager) = manager_with(Ok(vec![ + desc(3, "Xbox Wireless Controller"), + desc(11, "DualSense Wireless Controller"), + ])); + + manager.scan().await.expect("scan should succeed"); + + let event = rx.recv().await.expect("first event"); + let HardwareCommunicationManagerEvent::DeviceFound { name, address, .. } = event else { + panic!("expected DeviceFound, got {event:?}"); + }; + assert_eq!(name, "Xbox Wireless Controller"); + assert_eq!(address, "sdl-gamepad-3"); + + let event = rx.recv().await.expect("second event"); + let HardwareCommunicationManagerEvent::DeviceFound { name, address, .. } = event else { + panic!("expected DeviceFound, got {event:?}"); + }; + assert_eq!(name, "DualSense Wireless Controller"); + assert_eq!(address, "sdl-gamepad-11"); + + // No further events: drop the manager so its event sender closes the + // channel (recv only yields None once every sender is gone). + drop(manager); + assert!(rx.recv().await.is_none()); + } + + #[tokio::test] + async fn comm_manager_scan_swallows_transient_enumeration_error() { + let (mut rx, manager) = manager_with(Err(SdlTaskError::Scan("boom".to_owned()))); + + // Trait-level scan returns Ok with no events (logged warn): a transient + // failure must not break the timed-retry loop. + manager.scan().await.expect("scan must swallow the error"); + + // The internal enumerate_and_emit DOES propagate the error (the swallow + // is only at the trait boundary). + assert!(manager.enumerate_and_emit().await.is_err()); + + // Drop the manager so the event channel closes before checking emptiness. + drop(manager); + assert!(rx.recv().await.is_none()); + + // Recovery on the next scan emits devices; the retry loop stays intact. + let (mut rx2, manager2) = manager_with(Ok(vec![desc(1, "SDL Gamepad 1")])); + manager2.scan().await.expect("scan should succeed"); + let event = rx2.recv().await.expect("event after recovery"); + let HardwareCommunicationManagerEvent::DeviceFound { name, address, .. } = event else { + panic!("expected DeviceFound, got {event:?}"); + }; + assert_eq!(name, "SDL Gamepad 1"); + assert_eq!(address, "sdl-gamepad-1"); + + // A dead event channel (consumer gone) is permanent: scan surfaces Err so + // the timed retry loop stops instead of spinning forever. + drop(rx2); + assert!( + manager2.scan().await.is_err(), + "scan must surface a dead event channel" + ); + } +} diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs new file mode 100644 index 000000000..3080530b3 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -0,0 +1,443 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Hardware connector and hardware implementation for SDL3 gamepads. + +use super::sdl_task::{RUMBLE_DURATION_MS, SdlGamepadBackend, SdlOpenedGamepad, SdlTaskError}; +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server::device::hardware::{ + GenericHardwareSpecializer, + Hardware, + HardwareConnector, + HardwareEvent, + HardwareInternal, + HardwareReadCmd, + HardwareReading, + HardwareSpecializer, + HardwareSubscribeCmd, + HardwareUnsubscribeCmd, + HardwareWriteCmd, + communication::HardwareSpecificError, +}; +use buttplug_server_device_config::{ + Endpoint, + ProtocolCommunicationSpecifier, + SdlGamepadSpecifier, +}; +use byteorder::{LittleEndian, ReadBytesExt}; +use futures::future::{self, BoxFuture, FutureExt}; +use sdl3::joystick::JoystickId; +use std::{ + fmt::{self, Debug}, + io::Cursor, + sync::Arc, +}; +use tokio::sync::{broadcast, watch}; +use tokio_util::sync::CancellationToken; + +pub(crate) struct SdlGamepadHardwareConnector { + backend: Arc, + id: JoystickId, + name: String, + address: String, +} + +impl SdlGamepadHardwareConnector { + pub(crate) fn new( + backend: Arc, + id: JoystickId, + name: String, + address: String, + ) -> Self { + Self { + backend, + id, + name, + address, + } + } +} + +impl Debug for SdlGamepadHardwareConnector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SdlGamepadHardwareConnector") + .field("id", &self.id.0) + .field("name", &self.name) + .finish() + } +} + +pub(crate) fn hardware_error(operation: &str, e: SdlTaskError) -> ButtplugDeviceError { + ButtplugDeviceError::from(ButtplugDeviceError::DeviceSpecificError( + HardwareSpecificError::HardwareSpecificError( + "SdlGamepad".to_string(), + format!("{operation}: {e}"), + ) + .to_string(), + )) +} + +#[async_trait] +impl HardwareConnector for SdlGamepadHardwareConnector { + fn specifier(&self) -> ProtocolCommunicationSpecifier { + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + } + + async fn connect(&mut self) -> Result, ButtplugDeviceError> { + debug!("Emitting a new SDL gamepad device impl ({})", self.address); + let opened = self + .backend + .open(self.id) + .await + .map_err(|e| hardware_error("open", e))?; + let hardware_internal = SdlGamepadHardware::new(opened, self.address.clone()); + let hardware = Hardware::new( + &self.name, + &self.address, + &[Endpoint::Tx], + &None, + false, + Box::new(hardware_internal), + ); + Ok(Box::new(GenericHardwareSpecializer::new(hardware))) + } +} + +/// Watches the backend's removal signal and emits Disconnected on the +/// device's broadcast event stream (pattern from the XInput manager). +async fn watch_removal( + mut removed: watch::Receiver, + sender: broadcast::Sender, + address: String, + cancellation_token: CancellationToken, +) { + loop { + tokio::select! { + _ = cancellation_token.cancelled() => return, + changed = removed.changed() => { + if changed.is_err() { + // Sender dropped along with the SDL-thread state; treat as removed. + break; + } + if *removed.borrow() { + break; + } + } + } + } + info!("SDL gamepad {} has disconnected.", address); + // If this fails, nobody was listening; nothing else to do. + let _ = sender.send(HardwareEvent::Disconnected(address)); +} + +pub(crate) struct SdlGamepadHardware { + opened: Option>, + event_sender: broadcast::Sender, + cancellation_token: CancellationToken, +} + +impl SdlGamepadHardware { + fn new(opened: Arc, address: String) -> Self { + let (device_event_sender, _) = broadcast::channel(256); + let token = CancellationToken::new(); + let child = token.child_token(); + let sender = device_event_sender.clone(); + let removed = opened.removed(); + let watch_address = address.clone(); + buttplug_core::spawn!("SdlGamepadHardware removal watch", async move { + watch_removal(removed, sender, watch_address, child).await; + }); + Self { + opened: Some(opened), + event_sender: device_event_sender, + cancellation_token: token, + } + } + + fn close_opened(&self) { + if let Some(opened) = &self.opened { + opened.close_now(); + } + } +} + +impl HardwareInternal for SdlGamepadHardware { + fn event_stream(&self) -> broadcast::Receiver { + self.event_sender.subscribe() + } + + fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + // Graceful path: tell the SDL thread to close the gamepad and wait for + // it. (Drop uses the fire-and-forget close since it cannot await.) + if let Some(opened) = &self.opened { + let opened = opened.clone(); + return async move { opened.close().await.map_err(|e| hardware_error("close", e)) }.boxed(); + } + future::ready(Ok(())).boxed() + } + + fn read_value( + &self, + _msg: &HardwareReadCmd, + ) -> BoxFuture<'static, Result> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL gamepad hardware does not support read".to_owned(), + ))) + .boxed() + } + + fn write_value( + &self, + msg: &HardwareWriteCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + let Some(opened) = &self.opened else { + return future::ready(Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad hardware is already closed".to_owned(), + ))) + .boxed(); + }; + let opened = opened.clone(); + let data = msg.data().clone(); + async move { + // The protocol guarantees 4 bytes (two u16 LE motor speeds), but a + // short read must error, not panic. + let mut cursor = Cursor::new(data); + let (low, high) = match ( + cursor.read_u16::(), + cursor.read_u16::(), + ) { + (Ok(low), Ok(high)) => (low, high), + _ => { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad write payload must be 4 bytes (two u16 LE motor speeds)".to_owned(), + )); + } + }; + opened + .rumble(low, high, RUMBLE_DURATION_MS) + .await + .map_err(|e| hardware_error("rumble", e)) + } + .boxed() + } + + fn subscribe( + &self, + _msg: &HardwareSubscribeCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL gamepad hardware does not support subscribe".to_owned(), + ))) + .boxed() + } + + fn unsubscribe( + &self, + _msg: &HardwareUnsubscribeCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL gamepad hardware does not support unsubscribe".to_owned(), + ))) + .boxed() + } +} + +impl Drop for SdlGamepadHardware { + fn drop(&mut self) { + self.cancellation_token.cancel(); + self.close_opened(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + sdl_comm_manager::create_address, + sdl_task::{SdlGamepadDesc, SdlOpenedGamepad, SdlTaskError, joystick_id}, + }; + use std::sync::Mutex; + + /// Pure outer-seam mock: records rumble/close calls, signals removal. + #[derive(Debug)] + struct MockOpenedGamepad { + rumble_calls: Mutex>, + closed: Mutex, + removed_tx: watch::Sender, + } + + #[async_trait] + impl SdlOpenedGamepad for MockOpenedGamepad { + async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError> { + self + .rumble_calls + .lock() + .unwrap() + .push((low, high, duration_ms)); + Ok(()) + } + + async fn close(&self) -> Result<(), SdlTaskError> { + *self.closed.lock().unwrap() += 1; + let _ = self.removed_tx.send(true); + Ok(()) + } + + fn close_now(&self) { + *self.closed.lock().unwrap() += 1; + let _ = self.removed_tx.send(true); + } + + fn removed(&self) -> watch::Receiver { + self.removed_tx.subscribe() + } + } + + struct MockBackend { + opened: Mutex>>, + gamepads: Mutex>, + } + + #[async_trait] + impl SdlGamepadBackend for MockBackend { + fn initialized(&self) -> bool { + true + } + + async fn gamepads(&self) -> Result, SdlTaskError> { + Ok(self.gamepads.lock().unwrap().clone()) + } + + async fn open(&self, _id: JoystickId) -> Result, SdlTaskError> { + self + .opened + .lock() + .unwrap() + .clone() + .map(|pad| pad as Arc) + .ok_or_else(|| SdlTaskError::Open("no mock gamepad".to_owned())) + } + } + + async fn connect_mock_hardware() -> (Arc, Hardware, Arc) { + let mock_pad = Arc::new(MockOpenedGamepad { + rumble_calls: Mutex::new(Vec::new()), + closed: Mutex::new(0), + removed_tx: watch::channel(false).0, + }); + let backend = Arc::new(MockBackend { + opened: Mutex::new(Some(mock_pad.clone())), + gamepads: Mutex::new(Vec::new()), + }); + let mut connector = SdlGamepadHardwareConnector::new( + backend.clone(), + joystick_id(21), + "SDL Gamepad".to_owned(), + create_address(joystick_id(21)), + ); + assert_eq!( + connector.specifier(), + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + ); + let mut specializer = connector.connect().await.expect("connect should succeed"); + let hardware = specializer + .specialize(&[connector.specifier()]) + .await + .expect("specialize should succeed"); + assert_eq!(hardware.name(), "SDL Gamepad"); + assert_eq!(hardware.address(), "sdl-gamepad-21"); + assert_eq!(hardware.endpoints(), &[Endpoint::Tx]); + (mock_pad, hardware, backend) + } + + #[tokio::test] + async fn hardware_write_value_forwards_motor_pair() { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + + // 1:1 passthrough of the two parsed u16 LE values. + hardware + .write_value(&HardwareWriteCmd::new( + &[uuid::Uuid::new_v4()], + Endpoint::Tx, + vec![0x00, 0x80, 0xff, 0x7f], + false, + )) + .await + .expect("write should succeed"); + assert_eq!( + *mock_pad.rumble_calls.lock().unwrap(), + vec![(0x8000, 0x7fff, RUMBLE_DURATION_MS)] + ); + + // Short payloads error rather than panic. + let err = hardware + .write_value(&HardwareWriteCmd::new( + &[uuid::Uuid::new_v4()], + Endpoint::Tx, + vec![0x00, 0x80], + false, + )) + .await; + assert!(err.is_err()); + assert_eq!(mock_pad.rumble_calls.lock().unwrap().len(), 1); + + // Other unsupported commands error as unhandled. + assert!( + hardware + .read_value(&HardwareReadCmd::new( + uuid::Uuid::new_v4(), + Endpoint::Tx, + 0, + 0 + )) + .await + .is_err() + ); + } + + #[tokio::test] + async fn hardware_close_and_drop_close_backend_handle() { + // Explicit disconnect closes the backend handle. + { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + hardware + .disconnect() + .await + .expect("disconnect should succeed"); + assert_eq!(*mock_pad.closed.lock().unwrap(), 1); + } + + // Dropping the hardware also closes the backend handle. + { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + drop(hardware); + assert!( + *mock_pad.closed.lock().unwrap() >= 1, + "drop must close the backend handle" + ); + } + } + + #[tokio::test] + async fn hardware_removal_emits_disconnected_event() { + let (mock_pad, hardware, _backend) = connect_mock_hardware().await; + let mut event_stream = hardware.event_stream(); + + // Simulate SDL-side removal. + let _ = mock_pad.removed_tx.send(true); + + let event = tokio::time::timeout(std::time::Duration::from_secs(5), event_stream.recv()) + .await + .expect("disconnected event must arrive within timeout") + .expect("event stream must stay live"); + match event { + HardwareEvent::Disconnected(address) => assert_eq!(address, "sdl-gamepad-21"), + other => panic!("expected Disconnected, got {other:?}"), + } + } +} diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs new file mode 100644 index 000000000..51824a9db --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -0,0 +1,1324 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +//! Single SDL3 ownership thread for the SDL gamepad hardware manager. +//! +//! One dedicated thread owns the entire SDL3 context for the process and +//! multiplexes all gamepads. The thread never pumps SDL events (SDL3 +//! documents `SDL_PumpEvents` as main-thread-only, and this manager does not +//! consume controller input): discovery is on-demand `SDL_GetGamepads` +//! enumeration, and removal detection is per-device connected-state polling. +//! +//! Gamepads are identified by SDL3 instance ID ([`JoystickId`]), which is +//! stable for the lifetime of a connection. Conversion to buttplug's string +//! address space (`sdl-gamepad-{instance_id}`) happens only at the +//! communication-manager boundary. +//! +//! Rumble is armed with a finite duration (the sdl3 crate documents that +//! `u32::MAX` overflows and ends the effect immediately) and refreshed by the +//! thread before expiry, so one-shot ScalarCmd commands hold indefinitely. + +use sdl3::joystick::JoystickId; +use std::{ + collections::HashMap, + sync::{Arc, OnceLock, mpsc}, + time::Duration, +}; +use thiserror::Error; +use tokio::sync::{oneshot, watch}; + +/// Duration (ms) each rumble command is armed for. Finite on purpose: the sdl3 +/// crate documents `u32::MAX` as overflowing and ending the effect immediately. +pub(crate) const RUMBLE_DURATION_MS: u32 = 60_000; + +/// Elapsed time (ms) after which a still-active (non-zero) rumble is re-armed. +/// Comfortably before [`RUMBLE_DURATION_MS`] so the effect never lapses. +const RUMBLE_REFRESH_AFTER_MS: u64 = 50_000; + +/// Interval (ms) at which open gamepads have their connected state polled. +const CONNECTED_POLL_INTERVAL_MS: u64 = 500; + +/// Timeout (ms) of the command-receive wait; also the loop's wake granularity +/// for connected-poll and rumble-refresh checks. +const COMMAND_WAKE_MS: u64 = 100; + +/// A gamepad discovered by a scan, with its SDL-reported name (or the +/// deterministic fallback name when the name lookup failed). +#[derive(Debug, Clone)] +pub(crate) struct SdlGamepadDesc { + pub id: JoystickId, + pub name: String, +} + +/// Construct a [JoystickId] from its raw u32 value. `JoystickId` is a type +/// alias, so its constructor isn't reachable through the alias name. +#[cfg(test)] +pub(crate) fn joystick_id(n: u32) -> JoystickId { + JoystickId::new(n) +} + +#[derive(Debug, Error, Clone)] +pub(crate) enum SdlTaskError { + #[error("SDL initialization failed: {0}")] + Init(String), + #[error("SDL gamepad scan failed: {0}")] + Scan(String), + #[error("SDL gamepad {0} is already open")] + AlreadyOpen(JoystickId), + #[error("SDL gamepad {0} has been removed")] + Removed(JoystickId), + #[error("SDL gamepad open failed: {0}")] + Open(String), + #[error("SDL gamepad rumble failed: {0}")] + Rumble(String), + #[error("SDL gamepad thread is not running")] + ThreadClosed, +} + +#[derive(Debug, Error, Clone)] +#[error("SDL gamepad task failed to initialize: {0}")] +pub(crate) struct SdlTaskInitError(pub String); + +/// Inner seam for the SDL3 calls used by the task. +/// +/// Deliberately **not** `Send`: it is constructed, used, and dropped entirely +/// on the SDL thread (the sdl3 crate's `Sdl` type is `!Send`). Tests provide +/// fake implementations built from shared, `Send` state. +pub(crate) trait SdlDriver { + fn enumerate(&mut self) -> Result, String>; + fn name_for_id(&mut self, id: JoystickId) -> Result; + fn open(&mut self, id: JoystickId) -> Result, String>; +} + +/// An opened gamepad on the SDL thread. Dropping closes it. +pub(crate) trait DriverGamepad { + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String>; + fn connected(&self) -> bool; +} + +/// Clock seam so rumble-refresh and poll timing are unit-testable. `Send` +/// because it moves into the SDL thread at spawn time. +pub(crate) trait SdlClock: Send { + fn now_ms(&self) -> u64; +} + +/// Production clock: monotonic milliseconds since SDL-thread start. Uses +/// `Instant` (not wall-clock `SystemTime`) so a backward clock adjustment can +/// never suppress rumble refresh long enough for the finite arm to lapse. +struct SystemClock { + start: std::time::Instant, +} + +impl SdlClock for SystemClock { + fn now_ms(&self) -> u64 { + self.start.elapsed().as_millis() as u64 + } +} + +enum SdlCommand { + Scan { + reply: oneshot::Sender, SdlTaskError>>, + }, + Open { + id: JoystickId, + reply: oneshot::Sender>, + }, + Rumble { + id: JoystickId, + generation: u64, + low: u16, + high: u16, + duration: u32, + reply: oneshot::Sender>, + }, + Close { + id: JoystickId, + generation: u64, + reply: oneshot::Sender<()>, + }, +} + +/// Handle to an opened gamepad, safe to use from async contexts on any thread. +/// +/// Carries the open's `generation` so that a stale handle (e.g. a clone held +/// across a close/reopen of the same still-connected id) is inert: its rumble +/// commands fail with [`SdlTaskError::Removed`] and its closes are no-ops. +#[derive(Clone)] +pub(crate) struct SdlOpenedGamepadHandle { + id: JoystickId, + generation: u64, + task: SdlTaskHandle, + removed_rx: watch::Receiver, +} + +impl std::fmt::Debug for SdlOpenedGamepadHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SdlOpenedGamepadHandle") + .field("id", &self.id.0) + .finish() + } +} + +impl SdlOpenedGamepadHandle { + /// Receiver that yields `true` when the gamepad is closed or disconnected. + pub(crate) fn removed(&self) -> watch::Receiver { + self.removed_rx.clone() + } + + pub(crate) async fn rumble( + &self, + low: u16, + high: u16, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + self + .task + .rumble(self.id, self.generation, low, high, duration_ms) + .await + } + + pub(crate) async fn close(&self) -> Result<(), SdlTaskError> { + self.task.close(self.id, self.generation).await + } + + /// Fire-and-forget close usable from synchronous contexts (e.g. `Drop`). + pub(crate) fn close_now(&self) { + self.task.close_now(self.id, self.generation); + } +} + +/// Cloneable handle to the SDL thread's command channel. +/// +/// If all handles drop, the thread exits (which drops the SDL context). The +/// process-global publication keeps one handle alive for the process lifetime. +#[derive(Clone)] +pub(crate) struct SdlTaskHandle { + cmd_tx: mpsc::Sender, +} + +impl std::fmt::Debug for SdlTaskHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SdlTaskHandle").finish() + } +} + +impl SdlTaskHandle { + async fn send_and_await( + &self, + make_cmd: impl FnOnce(oneshot::Sender) -> SdlCommand, + ) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self + .cmd_tx + .send(make_cmd(reply_tx)) + .map_err(|_| SdlTaskError::ThreadClosed)?; + reply_rx.await.map_err(|_| SdlTaskError::ThreadClosed) + } + + pub(crate) async fn scan(&self) -> Result, SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Scan { reply }) + .await? + } + + pub(crate) async fn open(&self, id: JoystickId) -> Result { + self + .send_and_await(|reply| SdlCommand::Open { id, reply }) + .await? + } + + pub(crate) async fn rumble( + &self, + id: JoystickId, + generation: u64, + low: u16, + high: u16, + duration: u32, + ) -> Result<(), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Rumble { + id, + generation, + low, + high, + duration, + reply, + }) + .await? + } + + pub(crate) async fn close(&self, id: JoystickId, generation: u64) -> Result<(), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Close { + id, + generation, + reply, + }) + .await?; + Ok(()) + } + + /// Fire-and-forget close usable from synchronous contexts (e.g. `Drop`). + /// Closing an already-closed, removed, or superseded (stale generation) id + /// is a no-op on the thread side. + pub(crate) fn close_now(&self, id: JoystickId, generation: u64) { + // The reply channel is immediately dropped; the thread's reply send is + // ignored (the receiver may already be gone). + let (reply_tx, _) = oneshot::channel(); + if self + .cmd_tx + .send(SdlCommand::Close { + id, + generation, + reply: reply_tx, + }) + .is_err() + { + warn!( + "SDL gamepad thread already stopped; cannot close gamepad {}", + id.0 + ); + } + } +} + +struct OpenPadState { + pad: Box, + generation: u64, + removed_tx: watch::Sender, + last_rumble: (u16, u16), + last_set_at: u64, +} + +/// Pure rumble-refresh decision: given the last accepted rumble command, when +/// it was armed, and the current time, decide whether it must be re-armed. +/// +/// Zero-speed commands never refresh (the gamepad is stopped; letting the +/// effect lapse is exactly what we want). Non-zero commands re-arm after +/// [`RUMBLE_REFRESH_AFTER_MS`], safely before the finite arm duration lapses. +fn refresh_decision(last_rumble: (u16, u16), last_set_at: u64, now_ms: u64) -> Option<(u16, u16)> { + if last_rumble == (0, 0) { + return None; + } + if now_ms.saturating_sub(last_set_at) >= RUMBLE_REFRESH_AFTER_MS { + Some(last_rumble) + } else { + None + } +} + +fn mark_removed(state: OpenPadState) { + // Receiver may already be gone; that's fine. + let _ = state.removed_tx.send(true); + // Dropping the state drops the DriverGamepad, closing the OS handle. +} + +/// Best-effort stop of an actively rumbling gamepad before its pad is +/// dropped. Rumble is armed with a finite duration, so hardware quiets even +/// if this fails, but an explicit stop avoids up to a full arm period of +/// vibration after a disconnect while rumbling. +fn stop_and_drop(mut state: OpenPadState) { + if state.last_rumble != (0, 0) { + let _ = state.pad.rumble(0, 0, RUMBLE_DURATION_MS); + } + mark_removed(state); +} + +/// The SDL thread's command loop. +fn sdl_thread_loop( + task_tx: SdlTaskHandle, + mut driver: Box, + clock: Box, + cmd_rx: mpsc::Receiver, +) { + let mut open_pads: HashMap = HashMap::new(); + let mut last_poll_ms: u64 = 0; + // Monotonic per-open lease counter: lets the thread reject commands from + // handles belonging to a superseded open of the same id. + let mut next_generation: u64 = 0; + loop { + let now = clock.now_ms(); + + // Periodic work runs on every wake (command or timeout), so tests can + // drive it deterministically by advancing the injected clock and sending + // a probe command. + if now.saturating_sub(last_poll_ms) >= CONNECTED_POLL_INTERVAL_MS { + last_poll_ms = now; + let mut removed = Vec::new(); + for (id, state) in open_pads.iter_mut() { + if !state.pad.connected() { + info!("SDL gamepad {} has disconnected.", id.0); + removed.push(*id); + } + } + for id in removed { + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } + } + } + + // Refresh any non-zero rumble whose re-arm deadline has arrived. Errors + // are treated as device loss: mark removed and drop the pad. + let mut rumbles_to_refresh: Vec<(JoystickId, (u16, u16))> = Vec::new(); + for (id, state) in open_pads.iter() { + if let Some(cmd) = refresh_decision(state.last_rumble, state.last_set_at, now) { + rumbles_to_refresh.push((*id, cmd)); + } + } + for (id, (low, high)) in rumbles_to_refresh { + let Some(state) = open_pads.get_mut(&id) else { + continue; + }; + match state.pad.rumble(low, high, RUMBLE_DURATION_MS) { + Ok(()) => { + state.last_set_at = now; + } + Err(e) => { + warn!("SDL gamepad {} rumble refresh failed: {}", id.0, e); + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } + } + } + } + + // Wait for the next command (or wake timeout), then handle it. + match cmd_rx.recv_timeout(Duration::from_millis(COMMAND_WAKE_MS)) { + Ok(cmd) => match cmd { + SdlCommand::Scan { reply } => { + let result = driver.enumerate().map_err(|e| { + warn!("SDL gamepad enumeration failed: {}", e); + SdlTaskError::Scan(e) + }); + let reply_value = result.map(|ids| { + ids + .into_iter() + .map(|id| { + let name = match driver.name_for_id(id) { + Ok(name) => name, + Err(e) => { + // A failed name lookup never drops the device: log and + // fall back to a deterministic name. + warn!("SDL gamepad {} name lookup failed: {}", id.0, e); + format!("SDL Gamepad {}", id.0) + } + }; + SdlGamepadDesc { id, name } + }) + .collect::>() + }); + let _ = reply.send(reply_value); + } + SdlCommand::Open { id, reply } => { + if open_pads.contains_key(&id) { + // Single lease per id: a duplicate open only happens after the + // previous device fully disconnected and closed, and rejecting + // keeps Close { id } unambiguous. + let _ = reply.send(Err(SdlTaskError::AlreadyOpen(id))); + continue; + } + match driver.open(id) { + Ok(pad) => { + next_generation += 1; + let generation = next_generation; + let (removed_tx, removed_rx) = watch::channel(false); + open_pads.insert( + id, + OpenPadState { + pad, + generation, + removed_tx, + last_rumble: (0, 0), + last_set_at: now, + }, + ); + let handle = SdlOpenedGamepadHandle { + id, + generation, + task: task_tx.clone(), + removed_rx, + }; + if reply.send(Ok(handle)).is_err() { + // The connect waiter is gone (future cancelled): nobody can + // ever command or close this pad. Drop the lease now instead + // of blocking future opens with AlreadyOpen until the device + // physically disappears. + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } + } + } + Err(e) => { + let _ = reply.send(Err(SdlTaskError::Open(e))); + } + } + } + SdlCommand::Rumble { + id, + generation, + low, + high, + duration, + reply, + } => { + let Some(state) = open_pads.get_mut(&id) else { + let _ = reply.send(Err(SdlTaskError::Removed(id))); + continue; + }; + if state.generation != generation { + // Stale handle from a superseded open of the same id. + let _ = reply.send(Err(SdlTaskError::Removed(id))); + continue; + } + let reply_value = state + .pad + .rumble(low, high, duration) + .map_err(|e| SdlTaskError::Rumble(e)); + if reply_value.is_ok() { + state.last_rumble = (low, high); + state.last_set_at = clock.now_ms(); + } + let _ = reply.send(reply_value); + } + SdlCommand::Close { + id, + generation, + reply, + } => { + // Idempotent: closing an already-closed, removed, or superseded id + // is a no-op that still replies Ok. + if let Some(state) = open_pads.remove(&id) { + if state.generation == generation { + stop_and_drop(state); + } else { + // Stale close: reinstate the newer lease untouched. + open_pads.insert(id, state); + } + } + let _ = reply.send(()); + } + }, + Err(mpsc::RecvTimeoutError::Timeout) => { + // Plain wake; periodic work will be re-checked at the top of the loop. + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + // All handles dropped; shut the thread (and SDL context) down. + info!("SDL gamepad thread command channel closed; exiting."); + for (_, state) in open_pads.drain() { + stop_and_drop(state); + } + break; + } + } + } +} + +/// Spawn the SDL thread, running `factory` on it to build the driver. +/// +/// Only the `Send` factory closure moves into the new thread; every SDL value +/// it produces stays there for its whole lifetime. The returned handle is +/// non-global (tests spawn their own instances with fake drivers). +pub(crate) fn spawn_sdl_task( + factory: F, + clock: Box, +) -> Result +where + F: FnOnce() -> Result, SdlTaskInitError> + Send + 'static, +{ + let (cmd_tx, cmd_rx) = mpsc::channel::(); + let (init_tx, init_rx) = mpsc::channel::>(); + let loop_tx = SdlTaskHandle { + cmd_tx: cmd_tx.clone(), + }; + std::thread::Builder::new() + .name("buttplug-sdl-gamepad".to_string()) + .spawn(move || { + let driver = match factory() { + Ok(driver) => { + if init_tx.send(Ok(())).is_err() { + // Caller went away; still run so the thread doesn't dangle. + } + driver + } + Err(e) => { + let _ = init_tx.send(Err(e)); + return; + } + }; + sdl_thread_loop(loop_tx, driver, clock, cmd_rx); + }) + .map_err(|e| SdlTaskInitError(format!("failed to spawn SDL thread: {e}")))?; + // Startup handshake: blocks only for the duration of SDL initialization. + init_rx + .recv() + .map_err(|_| SdlTaskInitError("SDL thread exited before initialization".to_owned()))? + .map_err(|e| e)?; + Ok(SdlTaskHandle { cmd_tx }) +} + +// --------------------------------------------------------------------------- +// Production driver: real SDL3 calls, confined to the SDL thread. +// --------------------------------------------------------------------------- + +struct Sdl3Driver { + // Held to keep SDL alive; dropping the last reference would SDL_Quit, which + // only happens at thread exit. + _sdl: sdl3::Sdl, + gamepads: sdl3::GamepadSubsystem, +} + +impl SdlDriver for Sdl3Driver { + fn enumerate(&mut self) -> Result, String> { + self.gamepads.gamepads().map_err(|e| e.to_string()) + } + + fn name_for_id(&mut self, id: JoystickId) -> Result { + self.gamepads.name_for_id(id).map_err(|e| e.to_string()) + } + + fn open(&mut self, id: JoystickId) -> Result, String> { + self + .gamepads + .open(id) + .map(|pad| Box::new(Sdl3Gamepad { pad }) as Box) + .map_err(|e| e.to_string()) + } +} + +struct Sdl3Gamepad { + pad: sdl3::gamepad::Gamepad, +} + +impl DriverGamepad for Sdl3Gamepad { + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String> { + self + .pad + .set_rumble(low, high, duration_ms) + .map_err(|e| e.to_string()) + } + + fn connected(&self) -> bool { + self.pad.connected() + } +} + +/// Production factory: sets the background-events hint (SDL guidance is to do +/// this before initialization so hotplug works while unfocused/headless), +/// initializes SDL + the gamepad subsystem, and builds the driver. +fn production_sdl_factory() -> Result, SdlTaskInitError> { + sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + let sdl = sdl3::init().map_err(|e| SdlTaskInitError(e.to_string()))?; + let gamepads = sdl.gamepad().map_err(|e| SdlTaskInitError(e.to_string()))?; + Ok(Box::new(Sdl3Driver { + _sdl: sdl, + gamepads, + })) +} + +// --------------------------------------------------------------------------- +// Process-global publication. +// --------------------------------------------------------------------------- + +type PublishedSdlTask = Result, Arc>; + +static GLOBAL_SDL_TASK: OnceLock = OnceLock::new(); + +/// Publication decision: run the factory once, publish a usable handle on +/// success, or a permanent, logged inert state on failure. Retrying +/// `SDL_Init` after a failure mid-process is not attempted. +/// +/// Generic over the cell so tests can exercise the decision on a local +/// `OnceLock` without mutating the process-global one. +fn publish_sdl_task(cell: &OnceLock, factory: F) -> &PublishedSdlTask +where + F: FnOnce() -> Result, +{ + cell.get_or_init(|| match factory() { + Ok(handle) => { + info!("SDL gamepad manager initialized."); + Ok(Arc::new(handle)) + } + Err(e) => { + error!("SDL gamepad manager failed to initialize and is disabled: {e}"); + Err(Arc::new(e)) + } + }) +} + +/// The process-lifetime SDL task. First use spawns the thread; the handle is +/// never dropped, so the thread (and SDL context) lives until process exit. +pub(crate) fn global_sdl_task() -> &'static PublishedSdlTask { + publish_sdl_task(&GLOBAL_SDL_TASK, || { + spawn_sdl_task( + production_sdl_factory, + Box::new(SystemClock { + start: std::time::Instant::now(), + }), + ) + }) +} + +// --------------------------------------------------------------------------- +// Outer seam: async backend over the task handle. +// --------------------------------------------------------------------------- + +use async_trait::async_trait; + +/// An opened gamepad as seen by the hardware layer: mockable, with no SDL +/// dependency. Production wraps [`SdlOpenedGamepadHandle`]. +#[async_trait] +pub(crate) trait SdlOpenedGamepad: Send + Sync + std::fmt::Debug { + async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError>; + async fn close(&self) -> Result<(), SdlTaskError>; + /// Fire-and-forget close usable from synchronous contexts (e.g. `Drop`). + fn close_now(&self); + /// Receiver that yields `true` when the gamepad is closed or disconnected. + fn removed(&self) -> watch::Receiver; +} + +/// Async gamepad surface used by the communication manager and hardware. +/// +/// Production wraps [`SdlTaskHandle`]; tests provide mock implementations so +/// all buttplug-side behavior can be tested without SDL or hardware. The SDL +/// thread's internal invariants are tested separately through the +/// [`SdlDriver`] seam against the real command loop. +#[async_trait] +pub(crate) trait SdlGamepadBackend: Send + Sync { + /// Whether the underlying SDL task initialized successfully. + fn initialized(&self) -> bool; + async fn gamepads(&self) -> Result, SdlTaskError>; + async fn open(&self, id: JoystickId) -> Result, SdlTaskError>; +} + +/// Production opened-gamepad wrapper over the task handle. +#[derive(Debug)] +struct TaskOpenedGamepad { + handle: SdlOpenedGamepadHandle, +} + +#[async_trait] +impl SdlOpenedGamepad for TaskOpenedGamepad { + async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError> { + self.handle.rumble(low, high, duration_ms).await + } + + async fn close(&self) -> Result<(), SdlTaskError> { + self.handle.close().await + } + + fn close_now(&self) { + self.handle.close_now(); + } + + fn removed(&self) -> watch::Receiver { + self.handle.removed() + } +} + +/// Production backend over the process-global SDL task. +pub(crate) struct SdlTaskBackend { + publication: &'static PublishedSdlTask, +} + +impl SdlTaskBackend { + pub(crate) fn global() -> Self { + Self { + publication: global_sdl_task(), + } + } +} + +#[async_trait] +impl SdlGamepadBackend for SdlTaskBackend { + fn initialized(&self) -> bool { + self.publication.is_ok() + } + + async fn gamepads(&self) -> Result, SdlTaskError> { + match self.publication { + Ok(handle) => handle.scan().await, + Err(e) => Err(SdlTaskError::Init(e.to_string())), + } + } + + async fn open(&self, id: JoystickId) -> Result, SdlTaskError> { + match self.publication { + Ok(handle) => Ok(Arc::new(TaskOpenedGamepad { + handle: handle.open(id).await?, + })), + Err(e) => Err(SdlTaskError::Init(e.to_string())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Mutex, + atomic::{AtomicU64, Ordering}, + }; + + // ------------------------------------------------------------------- + // Fakes + // ------------------------------------------------------------------- + + #[derive(Default)] + struct FakeDriverState { + enumerate_ids: Vec, + enumerate_fail: bool, + name_fail_ids: Vec, + open_fail_ids: Vec, + connected: HashMap, + // Log of (id, low, high, duration) rumble calls. + rumble_log: Vec<(JoystickId, u16, u16, u32)>, + rumble_fail: bool, + } + + struct FakeDriver(Arc>); + + struct FakeGamepad { + id: JoystickId, + state: Arc>, + } + + impl DriverGamepad for FakeGamepad { + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String> { + let mut state = self.state.lock().unwrap(); + if state.rumble_fail { + return Err("rumble failed".to_owned()); + } + state.rumble_log.push((self.id, low, high, duration_ms)); + Ok(()) + } + + fn connected(&self) -> bool { + *self + .state + .lock() + .unwrap() + .connected + .get(&self.id) + .unwrap_or(&true) + } + } + + impl SdlDriver for FakeDriver { + fn enumerate(&mut self) -> Result, String> { + let state = self.0.lock().unwrap(); + if state.enumerate_fail { + Err("enumeration failed".to_owned()) + } else { + Ok(state.enumerate_ids.clone()) + } + } + + fn name_for_id(&mut self, id: JoystickId) -> Result { + let state = self.0.lock().unwrap(); + if state.name_fail_ids.contains(&id) { + Err("name lookup failed".to_owned()) + } else { + Ok(format!("SDL Fake Pad {}", id.0)) + } + } + + fn open(&mut self, id: JoystickId) -> Result, String> { + let state = self.0.lock().unwrap(); + if state.open_fail_ids.contains(&id) { + Err("open failed".to_owned()) + } else { + Ok(Box::new(FakeGamepad { + id, + state: self.0.clone(), + })) + } + } + } + + /// Injected clock: an atomic millisecond counter the test advances. + #[derive(Clone, Default)] + struct FakeClock(Arc); + + impl SdlClock for FakeClock { + fn now_ms(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } + } + + impl FakeClock { + fn advance_to(&self, ms: u64) { + self.0.store(ms, Ordering::SeqCst); + } + } + + fn spawn_fake(state: Arc>, clock: FakeClock) -> SdlTaskHandle { + spawn_sdl_task( + move || { + let state = state; + Ok(Box::new(FakeDriver(state)) as Box) + }, + Box::new(clock), + ) + .expect("fake driver factory always succeeds") + } + + fn id(n: u32) -> JoystickId { + joystick_id(n) + } + + // ------------------------------------------------------------------- + // Scan / name policy + // ------------------------------------------------------------------- + + #[tokio::test] + async fn sdl_task_scan_replies_enumeration_error_and_recovers() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1)], + enumerate_fail: true, + ..Default::default() + })); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock); + + // Failing enumeration surfaces as an Err reply. + let err = handle.scan().await.expect_err("scan should fail"); + assert!(matches!(err, SdlTaskError::Scan(_)), "got {err:?}"); + + // The same task recovers once the driver is healthy again. + state.lock().unwrap().enumerate_fail = false; + let descs = handle.scan().await.expect("scan should recover"); + assert_eq!(descs.len(), 1); + assert_eq!(descs[0].id, id(1)); + assert_eq!(descs[0].name, "SDL Fake Pad 1"); + } + + #[tokio::test] + async fn sdl_task_name_fallback_on_lookup_failure() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(2), id(3)], + name_fail_ids: vec![id(3)], + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + + let descs = handle.scan().await.expect("scan should succeed"); + assert_eq!(descs.len(), 2); + assert_eq!(descs[0].name, "SDL Fake Pad 2"); + // Failed name lookup falls back to the deterministic name; the device is + // still returned. + assert_eq!(descs[1].name, "SDL Gamepad 3"); + } + + // ------------------------------------------------------------------- + // Open / close / rumble lifecycle + // ------------------------------------------------------------------- + + #[tokio::test] + async fn sdl_task_rejects_duplicate_open() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let handle = spawn_fake(state, FakeClock::default()); + + handle.open(id(3)).await.expect("first open should succeed"); + let err = handle + .open(id(3)) + .await + .expect_err("duplicate open should fail"); + assert!( + matches!(err, SdlTaskError::AlreadyOpen(found) if found == id(3)), + "got {err:?}" + ); + } + + #[tokio::test] + async fn sdl_task_close_is_idempotent() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let handle = spawn_fake(state, FakeClock::default()); + + let opened = handle.open(id(4)).await.expect("open should succeed"); + let removed = opened.removed(); + opened.close().await.expect("close should succeed"); + assert!(*removed.borrow()); + + // Closing the same id again is Ok. + handle + .close(id(4), 1) + .await + .expect("second close should be ok"); + // Closing a never-opened id is Ok too. + handle + .close(id(99), 1) + .await + .expect("unknown close should be ok"); + } + + #[tokio::test] + async fn sdl_task_rumble_after_removal_errors() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let handle = spawn_fake(state, FakeClock::default()); + + handle.open(id(5)).await.expect("open should succeed"); + handle.close(id(5), 1).await.expect("close should succeed"); + + let err = handle + .rumble(id(5), 0, 100, 100, RUMBLE_DURATION_MS) + .await + .expect_err("rumble after close should fail"); + assert!( + matches!(err, SdlTaskError::Removed(found) if found == id(5)), + "got {err:?}" + ); + } + + #[tokio::test] + async fn sdl_task_close_stops_active_rumble() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + + // Explicit close while rumbling emits a zero-speed stop before the pad + // is dropped, so hardware does not vibrate out the remaining arm period. + let opened = handle.open(id(13)).await.expect("open should succeed"); + opened + .rumble(100, 100, RUMBLE_DURATION_MS) + .await + .expect("rumble should succeed"); + opened.close().await.expect("close should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(13), 100, 100, RUMBLE_DURATION_MS), + (id(13), 0, 0, RUMBLE_DURATION_MS), + ] + ); + + // Connected-state removal while rumbling stops too. + let opened = handle.open(id(14)).await.expect("open should succeed"); + opened + .rumble(100, 100, RUMBLE_DURATION_MS) + .await + .expect("rumble should succeed"); + state.lock().unwrap().connected.insert(id(14), false); + clock.advance_to(CONNECTED_POLL_INTERVAL_MS * 10); + handle.scan().await.expect("probe scan should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log.last(), + Some(&(id(14), 0, 0, RUMBLE_DURATION_MS)), + "removal must stop active rumble" + ); + } + + #[tokio::test] + async fn sdl_task_cancelled_open_does_not_leak_lease() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let handle = spawn_fake(state, FakeClock::default()); + + // Simulate a connect future cancelled mid-flight: the reply receiver is + // dropped before the thread answers the Open. + let (reply_tx, reply_rx) = oneshot::channel(); + drop(reply_rx); + handle + .cmd_tx + .send(SdlCommand::Open { + id: id(9), + reply: reply_tx, + }) + .expect("send open command"); + // Probe until the open has been processed. + handle.scan().await.expect("probe scan should succeed"); + + // The abandoned lease must have been cleaned up, so a real open succeeds + // instead of being rejected as AlreadyOpen forever. + handle + .open(id(9)) + .await + .expect("open after cancelled open must succeed"); + } + + #[tokio::test] + async fn sdl_task_stale_generation_is_inert() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let handle = spawn_fake(state.clone(), FakeClock::default()); + + // First lease: open, rumble, close (device stays connected). + let stale = handle.open(id(15)).await.expect("open should succeed"); + stale + .rumble(100, 100, RUMBLE_DURATION_MS) + .await + .expect("rumble should succeed"); + stale.close().await.expect("close should succeed"); + let log_len_after_first_lease = state.lock().unwrap().rumble_log.len(); + + // Second lease for the same still-connected id. + let fresh = handle.open(id(15)).await.expect("reopen should succeed"); + + // Stale-handle rumble is rejected... + let err = stale + .rumble(1, 1, RUMBLE_DURATION_MS) + .await + .expect_err("stale rumble must fail"); + assert!(matches!(err, SdlTaskError::Removed(_)), "got {err:?}"); + // ...stale close is an Ok no-op that must NOT tear down the new lease... + stale.close().await.expect("stale close is a no-op ok"); + // ...and the fresh lease still works. + fresh + .rumble(50, 50, RUMBLE_DURATION_MS) + .await + .expect("fresh lease rumble should succeed"); + + let log = state.lock().unwrap().rumble_log.clone(); + assert_eq!(log.len(), log_len_after_first_lease + 1); + assert_eq!(log.last(), Some(&(id(15), 50, 50, RUMBLE_DURATION_MS))); + // Explicitly verify the fresh lease is still open. + let err = handle + .open(id(15)) + .await + .expect_err("id still leased by fresh handle"); + assert!(matches!(err, SdlTaskError::AlreadyOpen(_))); + } + + #[tokio::test] + async fn sdl_task_connected_poll_marks_removed() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + + let opened = handle.open(id(6)).await.expect("open should succeed"); + let mut removed = opened.removed(); + + // Flip the device to disconnected, then advance the clock past the poll + // interval and wake the loop with a scan probe. The poll runs on every + // wake before commands are drained, so the removal must be observable by + // the time the probe replies. + state.lock().unwrap().connected.insert(id(6), false); + clock.advance_to(CONNECTED_POLL_INTERVAL_MS + 1); + handle.scan().await.expect("probe scan should succeed"); + + loop { + if *removed.borrow() { + break; + } + // Poll interval wake-ups also happen on the plain timeout path; wait + // for them without hanging forever on a bug. + tokio::time::timeout(Duration::from_secs(5), removed.changed()) + .await + .expect("removed signal must arrive within timeout") + .expect("watch channel must stay live"); + } + assert!(*removed.borrow()); + + // After removal, rumble reports the typed Removed error, and close stays + // idempotent. + let err = handle + .rumble(id(6), 0, 1, 1, RUMBLE_DURATION_MS) + .await + .expect_err("rumble after removal should fail"); + assert!(matches!(err, SdlTaskError::Removed(_))); + handle + .close(id(6), 1) + .await + .expect("close after removal is ok"); + } + + // ------------------------------------------------------------------- + // Rumble refresh + // ------------------------------------------------------------------- + + #[test] + fn sdl_task_refresh_deadline_pure_function() { + // Zero-speed commands never refresh. + assert_eq!(refresh_decision((0, 0), 0, 1_000_000), None); + // Before the deadline: no refresh. + assert_eq!( + refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_REFRESH_AFTER_MS - 1), + None + ); + // At the deadline: re-arm with the same speeds. + assert_eq!( + refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_REFRESH_AFTER_MS), + Some((100, 200)) + ); + // Long past the deadline (e.g. after a stall): still re-arms. + assert_eq!( + refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_DURATION_MS as u64 * 10), + Some((100, 200)) + ); + // Clock never goes backwards: saturating subtraction, not panic. + assert_eq!(refresh_decision((1, 1), 5_000, 1_000), None); + } + + #[tokio::test] + async fn sdl_task_refresh_rearms_before_expiry_at_loop_level() { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + + let opened = handle.open(id(7)).await.expect("open should succeed"); + opened + .rumble(0x8000, 0x7fff, RUMBLE_DURATION_MS) + .await + .expect("initial rumble should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![(id(7), 0x8000, 0x7fff, RUMBLE_DURATION_MS)], + "initial non-zero command arms exactly once" + ); + + // Just before the refresh deadline: no re-arm. + clock.advance_to(RUMBLE_REFRESH_AFTER_MS - 1); + handle.scan().await.expect("probe scan should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log.len(), + 1, + "no re-arm before the deadline" + ); + + // Reaching the deadline triggers exactly one re-send with the same + // parameters, comfortably before the finite arm lapses. + clock.advance_to(RUMBLE_REFRESH_AFTER_MS); + handle.scan().await.expect("probe scan should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(7), 0x8000, 0x7fff, RUMBLE_DURATION_MS), + (id(7), 0x8000, 0x7fff, RUMBLE_DURATION_MS), + ] + ); + + // Not due again immediately: one probe wakes, no further re-arm. + handle.scan().await.expect("probe scan should succeed"); + assert_eq!(state.lock().unwrap().rumble_log.len(), 2); + } + + #[tokio::test] + async fn sdl_task_refresh_stops_on_zero_close_removal_at_loop_level() { + // (a) A zero-speed command stops refreshing. + { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + let opened = handle.open(id(10)).await.expect("open should succeed"); + opened + .rumble(100, 100, RUMBLE_DURATION_MS) + .await + .expect("rumble should succeed"); + opened + .rumble(0, 0, RUMBLE_DURATION_MS) + .await + .expect("zero rumble should succeed"); + assert_eq!(state.lock().unwrap().rumble_log.len(), 2); + for t in [ + RUMBLE_REFRESH_AFTER_MS, + RUMBLE_REFRESH_AFTER_MS * 2, + RUMBLE_REFRESH_AFTER_MS * 3, + ] { + clock.advance_to(t); + handle.scan().await.expect("probe scan should succeed"); + } + assert_eq!( + state.lock().unwrap().rumble_log.len(), + 2, + "zero rumble must not be refreshed" + ); + } + + // (b) Close stops refreshing. + { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + let opened = handle.open(id(11)).await.expect("open should succeed"); + opened + .rumble(100, 100, RUMBLE_DURATION_MS) + .await + .expect("rumble should succeed"); + opened.close().await.expect("close should succeed"); + // Close while rumbling emits the zero-speed stop, then nothing more. + clock.advance_to(RUMBLE_REFRESH_AFTER_MS * 2); + handle.scan().await.expect("probe scan should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(11), 100, 100, RUMBLE_DURATION_MS), + (id(11), 0, 0, RUMBLE_DURATION_MS), + ], + "closed gamepad must not be refreshed" + ); + } + + // (c) Connected-state removal stops refreshing. + { + let state = Arc::new(Mutex::new(FakeDriverState::default())); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + let opened = handle.open(id(12)).await.expect("open should succeed"); + opened + .rumble(100, 100, RUMBLE_DURATION_MS) + .await + .expect("rumble should succeed"); + state.lock().unwrap().connected.insert(id(12), false); + clock.advance_to(RUMBLE_REFRESH_AFTER_MS * 2); + handle.scan().await.expect("probe scan should succeed"); + assert_eq!( + state.lock().unwrap().rumble_log, + vec![ + (id(12), 100, 100, RUMBLE_DURATION_MS), + (id(12), 0, 0, RUMBLE_DURATION_MS), + ], + "removed gamepad must not be refreshed" + ); + } + } + + // ------------------------------------------------------------------- + // Publication / init failure + // ------------------------------------------------------------------- + + #[test] + fn sdl_task_init_failure_publishes_inert_state() { + // Publication decision exercised on a LOCAL cell; the process-global + // OnceLock is never touched by tests. + let cell: OnceLock = OnceLock::new(); + let published = publish_sdl_task(&cell, || Err(SdlTaskInitError("no SDL here".to_owned()))); + let err = published + .as_ref() + .expect_err("init failure must publish Err"); + assert_eq!(err.0, "no SDL here"); + + // A backend over the inert publication reports cannot-scan and errors on + // use. + let leaked: &'static PublishedSdlTask = Box::leak(Box::new(cell.get().unwrap().clone())); + let backend = SdlTaskBackend { + publication: leaked, + }; + assert!(!backend.initialized()); + + // Second publication attempt returns the same, inert result (no retry). + let again = publish_sdl_task(&cell, || panic!("must not be called again")); + assert!(again.is_err()); + } + + #[tokio::test] + async fn sdl_task_backend_over_inert_publication_errors_on_use() { + let cell: &'static OnceLock = Box::leak(Box::new(OnceLock::new())); + publish_sdl_task(cell, || Err(SdlTaskInitError("nope".to_owned()))); + let backend = SdlTaskBackend { + publication: cell.get().unwrap(), + }; + assert!(!backend.initialized()); + let err = backend + .gamepads() + .await + .expect_err("inert backend must not scan"); + assert!(matches!(err, SdlTaskError::Init(_)), "got {err:?}"); + let err = backend + .open(id(1)) + .await + .expect_err("inert backend must not open"); + assert!(matches!(err, SdlTaskError::Init(_)), "got {err:?}"); + } +} From 62faba90eece901bde606f3e80e155e50ac30816 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:18 -0700 Subject: [PATCH 04/16] test: add SDL gamepad device protocol test case Registered in the v1-v4 embedded/json lists. v0 is excluded with a comment: SingleMotorVibrateCmd broadcasts one speed to all motors and cannot express the per-motor addressing this case verifies. Test harness gains an sdl-gamepad specifier branch (Tx-only endpoints) alongside the BLE default. --- .../tests/test_device_protocols.rs | 15 ++++++ .../device_test_case/test_sdl_gamepad.yaml | 52 +++++++++++++++++++ .../util/test_device_manager/test_device.rs | 7 +++ .../test_device_comm_manager.rs | 21 ++++++-- 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 6cfe03305..afc159159 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -146,6 +146,7 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -278,6 +279,7 @@ async fn test_device_protocols_embedded_v4(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -409,6 +411,7 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -541,6 +544,7 @@ async fn test_device_protocols_embedded_v3(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -663,6 +667,7 @@ async fn test_device_protocols_json_v3(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -786,6 +791,7 @@ async fn test_device_protocols_embedded_v2(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -907,6 +913,7 @@ async fn test_device_protocols_json_v2(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -1029,6 +1036,7 @@ async fn test_device_protocols_embedded_v1(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] #[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -1104,6 +1112,10 @@ async fn test_device_protocols_json_v1(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] //#[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +// v0 excluded: SingleMotorVibrateCmd broadcasts one speed to all motors and +// cannot express the per-motor addressing this test verifies (same reason +// multi-motor Lovense Edge is excluded from the v0 lists). +//#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] @@ -1172,6 +1184,9 @@ async fn test_device_protocols_embedded_v0(test_file: &str) { #[test_case("test_wevibe_pivot.yaml" ; "WeVibe Protocol (Legacy) - Pivot")] //#[test_case("test_wevibe_vector.yaml" ; "WeVibe Protocol (8bit) - Vector")] #[test_case("test_xibao_protocol.yaml" ; "Xibao Protocol")] +// v0 excluded: SingleMotorVibrateCmd broadcasts one speed to all motors and +// cannot express the per-motor addressing this test verifies. +//#[test_case("test_sdl_gamepad.yaml" ; "SDL Gamepad Protocol")] #[test_case("test_xiuxiuda_protocol.yaml" ; "Xiuxiuda Protocol")] #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml new file mode 100644 index 000000000..b64a133a6 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml @@ -0,0 +1,52 @@ +devices: + - identifier: + name: "sdl-gamepad" + expected_name: "SDL Gamepad" +device_commands: + # Vibrate low motor (feature 0) at 0.5: ceil(65535 * 0.5) = 32768 = 0x8000. + # High motor stays at 0, and both speeds are packed little-endian. + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00] + write_with_response: false + # Vibrate high motor (feature 1) at max: 65535 = 0xffff. The packet must + # carry BOTH stored speeds (low motor keeps its previous value). + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff] + write_with_response: false + # Stop zeroes both motors; the stop path emits one write per feature, each + # carrying the full current motor state. + - !Messages + device_index: 0 + messages: + - !Stop + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x00, 0xff, 0xff] + write_with_response: false + - !Write + endpoint: tx + data: [0x00, 0x00, 0x00, 0x00] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs index 9dc6230ec..6c8cb1be3 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs @@ -114,6 +114,13 @@ impl HardwareSpecializer for TestHardwareSpecializer { endpoints.push(*endpoint); } } + } else if let Some(ProtocolCommunicationSpecifier::SdlGamepad(_)) = specifiers + .iter() + .find(|x| matches!(x, ProtocolCommunicationSpecifier::SdlGamepad(_))) + { + // SDL gamepad hardware only exposes the Tx endpoint. + device.add_endpoint(&Endpoint::Tx); + endpoints.push(Endpoint::Tx); } let hardware = Hardware::new( &device.name(), diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs index 608287b21..8f516d29f 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs @@ -20,7 +20,11 @@ use buttplug_server::device::hardware::communication::{ HardwareCommunicationManagerBuilder, HardwareCommunicationManagerEvent, }; -use buttplug_server_device_config::{BluetoothLESpecifier, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + BluetoothLESpecifier, + ProtocolCommunicationSpecifier, + SdlGamepadSpecifier, +}; use futures::future::{self, FutureExt}; use log::*; use serde::{Deserialize, Serialize}; @@ -133,9 +137,18 @@ fn new_uninitialized_ble_test_device( fail_disconnect: bool, ) -> TestHardwareConnector { let address = identifier.address.clone(); - let specifier = ProtocolCommunicationSpecifier::BluetoothLE( - BluetoothLESpecifier::new_from_device(&identifier.name, &HashMap::new(), &[]), - ); + // Test devices are BLE by default. The "sdl-gamepad" identifier name is the + // sentinel for SDL gamepad test devices, which present the SDL gamepad + // specifier so the sdl-gamepad protocol matches them. + let specifier = if identifier.name == "sdl-gamepad" { + ProtocolCommunicationSpecifier::SdlGamepad(SdlGamepadSpecifier::default()) + } else { + ProtocolCommunicationSpecifier::BluetoothLE(BluetoothLESpecifier::new_from_device( + &identifier.name, + &HashMap::new(), + &[], + )) + }; let hardware = TestDevice::new(&identifier.name, &address, device_channel, fail_disconnect); TestHardwareConnector::new(specifier, hardware) } From baa2b57f9d6d6a445e68a5a2f51446e655e72b52 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:18 -0700 Subject: [PATCH 05/16] feat(engine): add --use-sdl-gamepad flag (default off) Registers the SDL gamepad communication manager on all platforms (no OS gate, unlike XInput) and logs a warning on Windows when both gamepad managers are enabled, since the same physical controller may then appear as two devices. Flag plumbing, default-false options, and the registration decision are unit-tested. --- crates/intiface_engine/CHANGELOG.md | 6 + crates/intiface_engine/Cargo.toml | 1 + crates/intiface_engine/src/bin/main.rs | 29 +++++ crates/intiface_engine/src/buttplug_server.rs | 110 ++++++++++++++++++ crates/intiface_engine/src/options.rs | 32 +++++ 5 files changed, 178 insertions(+) diff --git a/crates/intiface_engine/CHANGELOG.md b/crates/intiface_engine/CHANGELOG.md index 448c12b83..1a1ceafbc 100644 --- a/crates/intiface_engine/CHANGELOG.md +++ b/crates/intiface_engine/CHANGELOG.md @@ -1,3 +1,9 @@ +# 4.2.0 (2026-09-05) + +## Features + +- Add `--use-sdl-gamepad` flag (default off): cross-platform gamepad rumble via SDL3, coexisting with XInput on Windows (a warning is logged when both are enabled, since the same physical controller may appear as two devices). Structural inspiration credit: chiefautism's abandoned PR #860. + # 4.1.0 (2026-07-28) ## Features diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 6a4d2c4e6..2f52f7668 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -36,6 +36,7 @@ buttplug_server_hwmgr_lovense_dongle = { version = "11.0.0", path = "../buttplug buttplug_server_hwmgr_serial = { version = "11.0.0", path = "../buttplug_server_hwmgr_serial" } buttplug_server_hwmgr_websocket = { version = "11.0.0", path = "../buttplug_server_hwmgr_websocket" } buttplug_server_hwmgr_xinput = { version = "11.0.0", path = "../buttplug_server_hwmgr_xinput" } +buttplug_server_hwmgr_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad" } buttplug_transport_websocket_tungstenite = { version = "11.0.0", path = "../buttplug_transport_websocket_tungstenite" } argh = "0.1.19" log = "0.4.33" diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index 7dd773297..6c14e90ce 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -128,6 +128,11 @@ pub struct IntifaceCLIArguments { #[getset(get_copy = "pub")] use_xinput: bool, + /// turn on sdl gamepad (cross-platform) device support (default off) + #[argh(switch)] + #[getset(get_copy = "pub")] + use_sdl_gamepad: bool, + /// turn on lovense connect app device support (off by default) #[argh(switch)] #[getset(get_copy = "pub")] @@ -250,6 +255,7 @@ impl TryFrom for EngineOptions { .use_lovense_dongle_serial(args.use_lovense_dongle_serial()) .use_lovense_dongle_hid(args.use_lovense_dongle_hid()) .use_xinput(args.use_xinput()) + .use_sdl_gamepad(args.use_sdl_gamepad()) .use_lovense_connect(args.use_lovense_connect()) .use_device_websocket_server(args.use_device_websocket_server()) .max_ping_time(args.max_ping_time()) @@ -362,3 +368,26 @@ async fn main() -> Result<(), IntifaceEngineError> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_use_sdl_gamepad_flows_to_registration() { + // argh parses the flag... + let args = IntifaceCLIArguments::from_args(&["intiface-engine"], &["--use-sdl-gamepad"]) + .expect("flag should parse"); + assert!(args.use_sdl_gamepad()); + // ...and the TryFrom conversion into EngineOptions keeps it. + let options = EngineOptions::try_from(args).expect("options should build"); + assert!(options.use_sdl_gamepad()); + + // Without the flag, it's off. + let args = + IntifaceCLIArguments::from_args(&["intiface-engine"], &[]).expect("empty args should parse"); + assert!(!args.use_sdl_gamepad()); + let options = EngineOptions::try_from(args).expect("options should build"); + assert!(!options.use_sdl_gamepad()); + } +} diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index eb56d5dac..0af4918ea 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -20,6 +20,7 @@ use buttplug_server::{ use buttplug_server_device_config::{DeviceConfigurationManager, load_protocol_configs}; use buttplug_server_hwmgr_btleplug::BtlePlugCommunicationManagerBuilder; use buttplug_server_hwmgr_lovense_connect::LovenseConnectServiceCommunicationManagerBuilder; +use buttplug_server_hwmgr_sdl_gamepad::SdlGamepadCommunicationManagerBuilder; use buttplug_server_hwmgr_websocket::WebsocketServerDeviceCommunicationManagerBuilder; use buttplug_transport_websocket_tungstenite::{ ButtplugWebsocketClientTransport, ButtplugWebsocketServerTransportBuilder, @@ -29,6 +30,61 @@ use tokio::sync::broadcast::Sender; // Device communication manager setup gets its own module because the includes and platform // specifics are such a mess. +/// Warning emitted (on Windows) when both XInput and SDL gamepad managers are +/// enabled: the same physical controller can then appear as two Buttplug +/// devices. Pure decision function so it is testable on every platform; the +/// logging call site is Windows-gated. +pub fn gamepad_dual_manager_warning( + use_xinput: bool, + use_sdl_gamepad: bool, +) -> Option<&'static str> { + if use_xinput && use_sdl_gamepad { + Some( + "Both XInput and SDL gamepad managers are enabled; the same physical controller may appear as two devices.", + ) + } else { + None + } +} + +/// Testable core of [`setup_server_device_comm_managers`]: returns the names +/// of the comm manager builders the options select. The real builder starts +/// hardware managers (which `#[cfg(test)]` cannot easily exercise), so the +/// registration decision is mirrored here and asserted against in tests. +#[cfg(test)] +fn selected_comm_manager_names(args: &EngineOptions) -> Vec<&'static str> { + let mut names = vec![]; + if args.use_bluetooth_le() { + names.push("btleplug"); + } + if args.use_lovense_connect() { + names.push("lovense_connect"); + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + if args.use_lovense_dongle_hid() { + names.push("lovense_dongle_hid"); + } + if args.use_serial_port() { + names.push("serial"); + } + if args.use_hid() { + names.push("hid"); + } + #[cfg(target_os = "windows")] + if args.use_xinput() { + names.push("xinput"); + } + } + if args.use_sdl_gamepad() { + names.push("sdl_gamepad"); + } + if args.use_device_websocket_server() { + names.push("device_websocket_server"); + } + names +} + pub fn setup_server_device_comm_managers( args: &EngineOptions, server_builder: &mut ServerDeviceManagerBuilder, @@ -72,6 +128,22 @@ pub fn setup_server_device_comm_managers( } } } + // Cross-platform gamepad support via SDL3. No OS gate: unlike XInput, the + // SDL manager builds everywhere the engine does. + if args.use_sdl_gamepad() { + info!("Including SDL Gamepad Support"); + server_builder.comm_manager(SdlGamepadCommunicationManagerBuilder::default()); + } + // The same physical controller can be picked up by both managers on + // Windows when both flags are set; warn there, where the overlap exists. + // The decision itself runs on every platform (cheap, keeps the helper + // exercised and testable on all OSes); only the logging is Windows-gated. + if let Some(warning) = gamepad_dual_manager_warning(args.use_xinput(), args.use_sdl_gamepad()) { + #[cfg(target_os = "windows")] + warn!("{}", warning); + #[cfg(not(target_os = "windows"))] + let _ = warning; + } if args.use_device_websocket_server() { info!("Including Websocket Server Device Support"); let mut builder = @@ -202,3 +274,41 @@ pub async fn run_server( ); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::EngineOptionsBuilder; + + #[test] + fn dual_gamepad_warning_truth_table() { + // Some(message) exactly when both managers are on; None otherwise. + assert!(gamepad_dual_manager_warning(true, true).is_some()); + assert!(gamepad_dual_manager_warning(true, false).is_none()); + assert!(gamepad_dual_manager_warning(false, true).is_none()); + assert!(gamepad_dual_manager_warning(false, false).is_none()); + + let message = gamepad_dual_manager_warning(true, true).expect("both flags warn"); + assert!(message.contains("XInput") && message.contains("SDL")); + } + + #[test] + fn engine_registers_sdl_manager_iff_flag() { + let with_sdl = EngineOptionsBuilder::default() + .use_sdl_gamepad(true) + .finish(); + assert!( + selected_comm_manager_names(&with_sdl).contains(&"sdl_gamepad"), + "SDL manager must be registered when the flag is set" + ); + + let without_sdl = EngineOptionsBuilder::default().finish(); + assert!( + !selected_comm_manager_names(&without_sdl).contains(&"sdl_gamepad"), + "SDL manager must not be registered when the flag is unset" + ); + + // On all platforms, no OS gate on SDL registration. + assert!(selected_comm_manager_names(&with_sdl).contains(&"sdl_gamepad")); + } +} diff --git a/crates/intiface_engine/src/options.rs b/crates/intiface_engine/src/options.rs index f55250702..7b5a3915f 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -40,6 +40,8 @@ pub struct EngineOptions { #[getset(get_copy = "pub")] use_xinput: bool, #[getset(get_copy = "pub")] + use_sdl_gamepad: bool, + #[getset(get_copy = "pub")] use_lovense_connect: bool, #[getset(get_copy = "pub")] use_device_websocket_server: bool, @@ -84,6 +86,7 @@ pub struct EngineOptionsExternal { pub use_lovense_dongle_serial: bool, pub use_lovense_dongle_hid: bool, pub use_xinput: bool, + pub use_sdl_gamepad: bool, pub use_lovense_connect: bool, pub use_device_websocket_server: bool, pub use_simulated_devices: bool, @@ -117,6 +120,7 @@ impl From for EngineOptions { use_lovense_dongle_serial: other.use_lovense_dongle_serial, use_lovense_dongle_hid: other.use_lovense_dongle_hid, use_xinput: other.use_xinput, + use_sdl_gamepad: other.use_sdl_gamepad, use_lovense_connect: other.use_lovense_connect, use_device_websocket_server: other.use_device_websocket_server, use_simulated_devices: other.use_simulated_devices, @@ -213,6 +217,11 @@ impl EngineOptionsBuilder { self } + pub fn use_sdl_gamepad(&mut self, value: bool) -> &mut Self { + self.options.use_sdl_gamepad = value; + self + } + pub fn use_lovense_connect(&mut self, value: bool) -> &mut Self { self.options.use_lovense_connect = value; self @@ -292,3 +301,26 @@ impl EngineOptionsBuilder { self.options.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engine_options_use_sdl_gamepad_defaults_false() { + // Derives Default; the SDL gamepad manager is opt-in. + let options = EngineOptions::default(); + assert!(!options.use_sdl_gamepad()); + + // The external form also defaults off (serde). + let external: EngineOptionsExternal = Default::default(); + let from_external = EngineOptions::from(external); + assert!(!from_external.use_sdl_gamepad()); + + // And the builder setter round-trips. + let options = EngineOptionsBuilder::default() + .use_sdl_gamepad(true) + .finish(); + assert!(options.use_sdl_gamepad()); + } +} From 6ad37d32a80a1a0eb9aeb7a1a3f901025f502b4a Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:24 -0700 Subject: [PATCH 06/16] feat(client): opt-in sdl-gamepad-manager feature for in-process client Non-default feature (SDL3 source build is too heavy for default library consumers): enabling it registers the SDL gamepad manager, cross-platform with no OS gate. Manager registration is refactored into a named-returns helper asserted by a feature-gated test. --- crates/buttplug_client_in_process/Cargo.toml | 4 ++ .../src/in_process_client.rs | 63 ++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/buttplug_client_in_process/Cargo.toml b/crates/buttplug_client_in_process/Cargo.toml index 77513c348..24c9026b2 100644 --- a/crates/buttplug_client_in_process/Cargo.toml +++ b/crates/buttplug_client_in_process/Cargo.toml @@ -28,6 +28,9 @@ lovense-connect-service-manager=["buttplug_server_hwmgr_lovense_connect"] serial-manager=["buttplug_server_hwmgr_serial"] websocket-manager=["buttplug_server_hwmgr_websocket"] xinput-manager=["buttplug_server_hwmgr_xinput"] +# Opt-in cross-platform gamepad manager via SDL3. Deliberately NOT in default: +# building SDL3 from source is too heavy for default library consumers. +sdl-gamepad-manager=["buttplug_server_hwmgr_sdl_gamepad"] tokio-runtime = ["buttplug_core/tokio-runtime", "buttplug_client/tokio-runtime", "buttplug_server/tokio-runtime"] wasm = ["buttplug_core/wasm", "buttplug_client/wasm", "buttplug_server/wasm"] @@ -43,6 +46,7 @@ buttplug_server_hwmgr_lovense_dongle = { version = "11.0.0", path = "../buttplug buttplug_server_hwmgr_serial = { version = "11.0.0", path = "../buttplug_server_hwmgr_serial", optional = true} buttplug_server_hwmgr_websocket = { version = "11.0.0", path = "../buttplug_server_hwmgr_websocket", optional = true} buttplug_server_hwmgr_xinput = { version = "11.0.0", path = "../buttplug_server_hwmgr_xinput", optional = true} +buttplug_server_hwmgr_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad", optional = true} futures = "0.3.33" futures-util = "0.3.33" thiserror = "2.0.19" diff --git a/crates/buttplug_client_in_process/src/in_process_client.rs b/crates/buttplug_client_in_process/src/in_process_client.rs index a6996ba64..7bbcaea50 100644 --- a/crates/buttplug_client_in_process/src/in_process_client.rs +++ b/crates/buttplug_client_in_process/src/in_process_client.rs @@ -50,10 +50,33 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { .unwrap(); let mut device_manager_builder = ServerDeviceManagerBuilder::new(dcm); + register_comm_managers(&mut device_manager_builder); + let server_builder = ButtplugServerBuilder::new(device_manager_builder.finish().unwrap()); + let server = server_builder.finish().unwrap(); + let connector = ButtplugInProcessClientConnectorBuilder::default() + .server(server) + .finish(); + let client = ButtplugClient::new(client_name); + client.connect(connector).await.unwrap(); + client +} + +/// Registers every comm manager selected by this crate's cargo features, and +/// returns the names of the managers that were registered so tests can assert +/// feature wiring (single source of truth: `in_process_client` uses this and +/// ignores the result). +// With no manager features enabled (how e.g. buttplug_tests consumes this +// crate), nothing is registered and the builder parameter goes unused. +#[allow(unused_mut, unused_variables)] +fn register_comm_managers( + device_manager_builder: &mut ServerDeviceManagerBuilder, +) -> Vec<&'static str> { + let mut registered = vec![]; #[cfg(feature = "btleplug-manager")] { use buttplug_server_hwmgr_btleplug::BtlePlugCommunicationManagerBuilder; device_manager_builder.comm_manager(BtlePlugCommunicationManagerBuilder::default()); + registered.push("btleplug"); } #[cfg(feature = "websocket-manager")] { @@ -61,6 +84,7 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { device_manager_builder.comm_manager( WebsocketServerDeviceCommunicationManagerBuilder::default().listen_on_all_interfaces(true), ); + registered.push("websocket-server"); } #[cfg(all( feature = "serial-manager", @@ -69,12 +93,14 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { { use buttplug_server_hwmgr_serial::SerialPortCommunicationManagerBuilder; device_manager_builder.comm_manager(SerialPortCommunicationManagerBuilder::default()); + registered.push("serial"); } #[cfg(feature = "lovense-connect-service-manager")] { use buttplug_server_hwmgr_lovense_connect::LovenseConnectServiceCommunicationManagerBuilder; device_manager_builder .comm_manager(LovenseConnectServiceCommunicationManagerBuilder::default()); + registered.push("lovense-connect-service"); } #[cfg(all( feature = "lovense-dongle-manager", @@ -83,18 +109,39 @@ pub async fn in_process_client(client_name: &str) -> ButtplugClient { { use buttplug_server_hwmgr_lovense_dongle::LovenseHIDDongleCommunicationManagerBuilder; device_manager_builder.comm_manager(LovenseHIDDongleCommunicationManagerBuilder::default()); + registered.push("lovense-dongle"); } #[cfg(all(feature = "xinput-manager", target_os = "windows"))] { use buttplug_server_hwmgr_xinput::XInputDeviceCommunicationManagerBuilder; device_manager_builder.comm_manager(XInputDeviceCommunicationManagerBuilder::default()); + registered.push("xinput"); + } + // SDL gamepad manager is opt-in (not in the default feature set) and, unlike + // XInput, is cross-platform: no OS gate. + #[cfg(feature = "sdl-gamepad-manager")] + { + use buttplug_server_hwmgr_sdl_gamepad::SdlGamepadCommunicationManagerBuilder; + device_manager_builder.comm_manager(SdlGamepadCommunicationManagerBuilder::default()); + registered.push("sdl-gamepad"); + } + registered +} + +#[cfg(all(test, feature = "sdl-gamepad-manager"))] +mod tests { + use super::*; + + #[test] + fn feature_registers_sdl_manager() { + let dcm = DeviceConfigurationManagerBuilder::default() + .finish() + .unwrap(); + let mut builder = ServerDeviceManagerBuilder::new(dcm); + let registered = register_comm_managers(&mut builder); + assert!( + registered.contains(&"sdl-gamepad"), + "SDL gamepad manager must be registered when the feature is enabled, got {registered:?}" + ); } - let server_builder = ButtplugServerBuilder::new(device_manager_builder.finish().unwrap()); - let server = server_builder.finish().unwrap(); - let connector = ButtplugInProcessClientConnectorBuilder::default() - .server(server) - .finish(); - let client = ButtplugClient::new(client_name); - client.connect(connector).await.unwrap(); - client } From 66be45d09ad856cac82ecb5386ae98cb2363b985 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sat, 5 Sep 2026 18:26:24 -0700 Subject: [PATCH 07/16] build(ci): exercise SDL gamepad feature, dependency isolation, and spike On all three OS matrix entries: run the in-process client tests with --features sdl-gamepad-manager, assert via cargo tree that sdl3 is absent from default features and present with the feature, and run the SDL3 headless threading spike example (automated half of the Phase 0 gate; CI runners have no controllers, so an empty gamepad set passes). --- .github/workflows/main.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c75c463c7..98cebd903 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -56,6 +56,27 @@ jobs: run: cargo build - name: Run tests run: cargo test + # SDL gamepad manager: opt-in library feature. Compile + run the + # feature-gated registration test, and assert sdl3 stays out of the + # default dependency graph. + - name: Test SDL gamepad opt-in feature + shell: bash + run: | + cargo test -p buttplug_client_in_process --features sdl-gamepad-manager + if cargo tree -e features -p buttplug_client_in_process | grep -Eq '(^|[[:space:]])sdl3 v[0-9]'; then + echo "::error::sdl3 leaked into buttplug_client_in_process default features" + exit 1 + fi + cargo tree -e features -p buttplug_client_in_process --features sdl-gamepad-manager | grep -Eq '(^|[[:space:]])sdl3 v[0-9]' || { + echo "::error::sdl3 missing from buttplug_client_in_process with sdl-gamepad-manager enabled" + exit 1 + } + # SDL3 threading spike (automated half): headless init + no-pump + # enumeration on a dedicated spawned thread, on every CI OS. Empty gamepad + # set is acceptable (CI runners have no controllers). + - name: SDL3 threading spike + shell: bash + run: cargo run -p buttplug_server_hwmgr_sdl_gamepad --example sdl3_thread_spike # Only run doc gen on windows. It has the most code to build anyways, all other projects are a subset of it. - name: Run doc gen if: startsWith(matrix.os, 'windows') From 00c2cd3064516a281922d761b2c601a62b897bef Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 6 Sep 2026 15:48:01 -0700 Subject: [PATCH 08/16] fix(hwmgr): disable MFI gamepad routing on macOS so hidapi discovers devices SDL3 on macOS routes wired gamepads to GCController (MFI) by default: the hidapi device drivers decline wired pads (DevSrvsID paths) while MFI is enabled, and GCController discovery only fires via Cocoa runloop notifications - which a headless, no-video process never spins. Result: zero gamepads ever discovered, even on the main thread with event pumping. Verified against SDL 3.4.14 source (SDL_PLATFORM_MACOS && SDL_JOYSTICK_MFI guard in SDL_hidapi_xboxone.c) and on hardware: with SDL_JOYSTICK_MFI=0 the wired Xbox One S enumerates via hidapi and set_rumble succeeds. iOS keeps the MFI default (GCController is the only backend there). The spike example mirrors the factory's platform policy. Windows discovery failure remains under investigation (likely analogous WGI behavior; needs an on-hardware SDL_LOGGING=3 run). --- .../examples/sdl3_thread_spike.rs | 4 ++++ .../src/sdl_task.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs index 3c9b8d2c3..2aab773db 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -25,6 +25,10 @@ fn main() { .spawn(|| { println!("[sdl-thread] setting JOYSTICK_ALLOW_BACKGROUND_EVENTS hint (pre-init)"); sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + // Mirror the production factory's platform policy (see + // production_sdl_factory in src/sdl_task.rs for the full rationale). + #[cfg(target_os = "macos")] + sdl3::hint::set(sdl3::hint::names::JOYSTICK_MFI, "0"); println!("[sdl-thread] sdl3::init()"); let sdl = sdl3::init().expect("sdl3::init() must work on a dedicated thread"); println!("[sdl-thread] init OK; initializing gamepad subsystem (headless)"); diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 51824a9db..b3c8f6c7e 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -610,8 +610,21 @@ impl DriverGamepad for Sdl3Gamepad { /// Production factory: sets the background-events hint (SDL guidance is to do /// this before initialization so hotplug works while unfocused/headless), /// initializes SDL + the gamepad subsystem, and builds the driver. +/// +/// On macOS, SDL3 routes wired gamepads to GCController (MFI) by default, and +/// hidapi device drivers decline them while MFI is enabled (see the +/// `SDL_PLATFORM_MACOS && SDL_JOYSTICK_MFI` guard in SDL's hidapi drivers: +/// wired pads enumerate with DevSrvsID paths). GCController discovery is +/// delivered through Cocoa runloop notifications, which this headless, +/// no-video process never spins - so with the default policy no gamepads are +/// ever discovered here. Disabling MFI routes gamepads to hidapi, which +/// enumerates synchronously and works headless (verified on hardware: a wired +/// Xbox One S enumerates and `set_rumble` succeeds with this hint). iOS keeps +/// the MFI default, where GCController is the only gamepad backend. fn production_sdl_factory() -> Result, SdlTaskInitError> { sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + #[cfg(target_os = "macos")] + sdl3::hint::set(sdl3::hint::names::JOYSTICK_MFI, "0"); let sdl = sdl3::init().map_err(|e| SdlTaskInitError(e.to_string()))?; let gamepads = sdl.gamepad().map_err(|e| SdlTaskInitError(e.to_string()))?; Ok(Box::new(Sdl3Driver { From 220bae49443fc29e0ce3547dec079ea640f63ad3 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 6 Sep 2026 16:09:23 -0700 Subject: [PATCH 09/16] feat(hwmgr): skip wired gamepads on macOS (Bluetooth-only v1 scope) On-hardware diagnosis (wired Xbox One S, USB): with the MFI=0 hidapi policy, wired pads enumerate but rumble silently fails - Apple exposes only read-only shortened HID reports for them, and the working path (GCController/CoreHaptics) requires a main-thread runloop this architecture deliberately does not host. SDL2 has the same Apple-side guard, so a version switch would not help. Per operator decision, v1 is Bluetooth-only on macOS: scan probes each candidate's SDL connection state and skips Wired pads with a logged explanation pointing users at Bluetooth pairing. Verified on hardware: SDL reports the wired pad as connection Wired. Bluetooth pads and all of Windows/Linux are unaffected. The spike example now enables verbose SDL logging and prints each pad's connection state, so a single run is a complete diagnostic - this is the tool for the still-open Windows zero-device diagnosis (likely the analogous XGIP/WGI claiming dance). --- .../CHANGELOG.md | 5 ++ .../README.md | 12 +++ .../examples/sdl3_thread_spike.rs | 36 ++++++++- .../src/sdl_task.rs | 80 ++++++++++++++++++- 4 files changed, 130 insertions(+), 3 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md index cbaba8dc0..352c77878 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -3,3 +3,8 @@ ## Features - Initial release. Cross-platform (Windows/macOS/Linux) gamepad rumble hardware manager for Buttplug, built on SDL3 via the `sdl3` crate (statically linked, built from source). One process-lifetime thread owns the SDL context and multiplexes all gamepads; devices are addressed by SDL3 instance ID (`sdl-gamepad-{instance_id}`) and present two 0-65535 vibrate features. Structural inspiration credit: chiefautism's abandoned PR #860. + +## Platform notes + +- macOS: **Bluetooth controllers only.** Wired pads are skipped at scan time with a logged explanation: Apple gives hidapi read-only shortened reports for wired gamepads, so rumble cannot work that way, and the working path (GCController) requires a main-thread runloop this architecture does not host. SDL2 shares this Apple limitation. Windows/Linux support wired and Bluetooth controllers. +- Rumble is armed finitely (60s) and refreshed before expiry; an explicit zero-speed stop is sent on close or removal. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index 3617b89fd..4da75130e 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -55,6 +55,18 @@ smooth continuous rumble with the refresh-before-expiry scheme — must be validated manually on each platform before release; see the manual validation checklist in the repository's pull request for this feature. +## Platform support + +- **Windows / Linux**: wired and Bluetooth controllers via SDL's hidapi and + platform backends. +- **macOS**: **Bluetooth controllers only.** Apple exposes wired gamepads to + hidapi with read-only shortened HID reports, so rumble is impossible that + way; working wired rumble requires GCController, whose discovery only fires + from a main-thread runloop that this library deliberately does not host. + Wired pads are skipped at scan time with a logged explanation - pair the + same controller via Bluetooth for full support. (A future main-thread + integration could lift this; the limitation is Apple's, and SDL2 shares it.) + ## Coexistence with XInput On Windows, both the XInput manager and this manager can be enabled at the diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs index 2aab773db..3f13a3f2d 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -20,6 +20,10 @@ use std::thread; use std::time::Duration; fn main() { + // Built-in verbose SDL logging so a single run of this example is a + // complete diagnostic (no SDL_LOGGING env var needed) - essential for + // diagnosing backend claiming on other platforms. + sdl3::log::set_log_priorities(sdl3::log::Priority::Verbose); let handle = thread::Builder::new() .name("sdl3-spike".to_string()) .spawn(|| { @@ -37,7 +41,37 @@ fn main() { .expect("gamepad subsystem must initialize headless"); println!("[sdl-thread] gamepad subsystem OK"); match gamepad.gamepads() { - Ok(ids) => println!("[sdl-thread] gamepads() -> {} gamepad(s)", ids.len()), + Ok(ids) => { + println!("[sdl-thread] gamepads() -> {} gamepad(s)", ids.len()); + // Connection state is what the macOS wired-skip keys on; printing + // it makes every spike run a complete transport diagnostic. + for id in &ids { + match gamepad.open(*id) { + Ok(pad) => { + let connection = match pad.connection_state() { + Ok(sdl3::joystick::ConnectionState::Wired) => "Wired", + Ok(sdl3::joystick::ConnectionState::Wireless) => "Wireless", + Ok(_) => "Unknown", + Err(e) => { + println!( + "[sdl-thread] gamepad {} connection query failed: {e:?}", + id.0 + ); + "Error" + } + }; + println!( + "[sdl-thread] gamepad {} '{}' connection: {}", + id.0, + pad.name().unwrap_or_default(), + connection + ); + // pad drops here, closing the probe handle + } + Err(e) => println!("[sdl-thread] gamepad {} open failed: {e:?}", id.0), + } + } + } Err(e) => { eprintln!("[sdl-thread] gamepads() failed: {e:?}"); std::process::exit(2); diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index b3c8f6c7e..08beef3a6 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -95,9 +95,22 @@ pub(crate) trait SdlDriver { } /// An opened gamepad on the SDL thread. Dropping closes it. +/// Transport of an opened gamepad, as far as SDL reports it. Used on macOS to +/// skip wired pads (see the scan handler for the rationale). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DriverConnection { + Wired, + Wireless, + Unknown, +} + pub(crate) trait DriverGamepad { fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String>; fn connected(&self) -> bool; + /// Default `Unknown` so fakes only override it where relevant. + fn connection_state(&self) -> DriverConnection { + DriverConnection::Unknown + } } /// Clock seam so rumble-refresh and poll timing are unit-testable. `Send` @@ -398,7 +411,31 @@ fn sdl_thread_loop( let reply_value = result.map(|ids| { ids .into_iter() - .map(|id| { + .filter_map(|id| { + // macOS: wired pads enumerate via hidapi but cannot rumble - + // Apple exposes only read-only shortened HID reports for them, + // and working rumble requires GCController, whose discovery + // only fires from a main-thread runloop this architecture + // deliberately does not host. Skip wired pads so no dead + // devices appear; Bluetooth pads work fully. Users with a + // wired controller can pair the same pad via Bluetooth. + #[cfg(target_os = "macos")] + { + if !open_pads.contains_key(&id) { + let wired = match driver.open(id) { + // The probe handle drops immediately, closing it again. + Ok(pad) => pad.connection_state() == DriverConnection::Wired, + Err(_) => false, + }; + if wired { + warn!( + "Skipping wired SDL gamepad {} on macOS: wired rumble is not possible without GCController (pair the controller via Bluetooth instead).", + id.0 + ); + return None; + } + } + } let name = match driver.name_for_id(id) { Ok(name) => name, Err(e) => { @@ -408,7 +445,7 @@ fn sdl_thread_loop( format!("SDL Gamepad {}", id.0) } }; - SdlGamepadDesc { id, name } + Some(SdlGamepadDesc { id, name }) }) .collect::>() }); @@ -605,6 +642,14 @@ impl DriverGamepad for Sdl3Gamepad { fn connected(&self) -> bool { self.pad.connected() } + + fn connection_state(&self) -> DriverConnection { + match self.pad.connection_state() { + Ok(sdl3::joystick::ConnectionState::Wired) => DriverConnection::Wired, + Ok(sdl3::joystick::ConnectionState::Wireless) => DriverConnection::Wireless, + _ => DriverConnection::Unknown, + } + } } /// Production factory: sets the background-events hint (SDL guidance is to do @@ -791,6 +836,7 @@ mod tests { // Log of (id, low, high, duration) rumble calls. rumble_log: Vec<(JoystickId, u16, u16, u32)>, rumble_fail: bool, + wired_ids: Vec, } struct FakeDriver(Arc>); @@ -810,6 +856,15 @@ mod tests { Ok(()) } + fn connection_state(&self) -> DriverConnection { + let state = self.state.lock().unwrap(); + if state.wired_ids.contains(&self.id) { + DriverConnection::Wired + } else { + DriverConnection::Wireless + } + } + fn connected(&self) -> bool { *self .state @@ -927,6 +982,27 @@ mod tests { assert_eq!(descs[1].name, "SDL Gamepad 3"); } + // macOS-only behavior: wired pads are skipped at scan time because their + // rumble cannot work under this architecture (see the scan handler). + #[cfg(target_os = "macos")] + #[tokio::test] + async fn sdl_task_macos_scan_skips_wired_pads() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(20), id(21), id(22)], + wired_ids: vec![id(21)], + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + + let descs = handle.scan().await.expect("scan should succeed"); + // 21 is wired and must be skipped; the wireless pads (and an + // already-leased pad, not applicable here) come through. + assert_eq!( + descs.iter().map(|d| d.id).collect::>(), + vec![id(20), id(22)] + ); + } + // ------------------------------------------------------------------- // Open / close / rumble lifecycle // ------------------------------------------------------------------- From a3ee42f7ff172341f93f79a1c98c515c4bd15081 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 6 Sep 2026 16:16:45 -0700 Subject: [PATCH 10/16] fix(hwmgr): re-arm active rumble every second (keepalive) On-hardware validation (Bluetooth DualSense, macOS) showed rumble stops after a few seconds despite the 60s arm - some controllers do not honor long durations. Re-sending the current non-zero command every second keeps the effect alive; the long finite arm stays as a safety net if a keepalive is missed. Same refresh_decision machinery, tighter interval. --- .../CHANGELOG.md | 2 +- .../README.md | 4 ++- .../src/sdl_task.rs | 32 +++++++++++-------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md index 352c77878..df1585d56 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -7,4 +7,4 @@ ## Platform notes - macOS: **Bluetooth controllers only.** Wired pads are skipped at scan time with a logged explanation: Apple gives hidapi read-only shortened reports for wired gamepads, so rumble cannot work that way, and the working path (GCController) requires a main-thread runloop this architecture does not host. SDL2 shares this Apple limitation. Windows/Linux support wired and Bluetooth controllers. -- Rumble is armed finitely (60s) and refreshed before expiry; an explicit zero-speed stop is sent on close or removal. +- Rumble is armed finitely (60s) and re-armed every second as a keepalive (some controllers, e.g. Bluetooth DualSense, stop early despite a long arm); an explicit zero-speed stop is sent on close or removal. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index 4da75130e..be73b633b 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -53,7 +53,9 @@ example (headless init + no-pump enumeration on a spawned thread), which CI runs on all three operating systems. Real-controller behavior — notably smooth continuous rumble with the refresh-before-expiry scheme — must be validated manually on each platform before release; see the manual validation -checklist in the repository's pull request for this feature. +checklist in the repository's pull request for this feature. Confirmed on +hardware so far: Bluetooth DualSense on macOS discovers and rumbles (with the +one-second keepalive re-arming the effect). ## Platform support diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 08beef3a6..142e1c5c7 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -35,9 +35,13 @@ use tokio::sync::{oneshot, watch}; /// crate documents `u32::MAX` as overflowing and ending the effect immediately. pub(crate) const RUMBLE_DURATION_MS: u32 = 60_000; -/// Elapsed time (ms) after which a still-active (non-zero) rumble is re-armed. -/// Comfortably before [`RUMBLE_DURATION_MS`] so the effect never lapses. -const RUMBLE_REFRESH_AFTER_MS: u64 = 50_000; +/// Interval (ms) at which a still-active (non-zero) rumble is re-armed. A +/// one-second keepalive, not a near-expiry refresh: on-hardware testing +/// showed some controllers (Bluetooth DualSense) stop rumbling after a few +/// seconds despite a long arm, so the current command is simply re-sent +/// every second while active. The long finite arm remains as a safety net +/// if a keepalive is missed. +const RUMBLE_KEEPALIVE_INTERVAL_MS: u64 = 1_000; /// Interval (ms) at which open gamepads have their connected state polled. const CONNECTED_POLL_INTERVAL_MS: u64 = 500; @@ -312,12 +316,12 @@ struct OpenPadState { /// /// Zero-speed commands never refresh (the gamepad is stopped; letting the /// effect lapse is exactly what we want). Non-zero commands re-arm after -/// [`RUMBLE_REFRESH_AFTER_MS`], safely before the finite arm duration lapses. +/// [`RUMBLE_KEEPALIVE_INTERVAL_MS`], safely before the finite arm duration lapses. fn refresh_decision(last_rumble: (u16, u16), last_set_at: u64, now_ms: u64) -> Option<(u16, u16)> { if last_rumble == (0, 0) { return None; } - if now_ms.saturating_sub(last_set_at) >= RUMBLE_REFRESH_AFTER_MS { + if now_ms.saturating_sub(last_set_at) >= RUMBLE_KEEPALIVE_INTERVAL_MS { Some(last_rumble) } else { None @@ -1223,12 +1227,12 @@ mod tests { assert_eq!(refresh_decision((0, 0), 0, 1_000_000), None); // Before the deadline: no refresh. assert_eq!( - refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_REFRESH_AFTER_MS - 1), + refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_KEEPALIVE_INTERVAL_MS - 1), None ); // At the deadline: re-arm with the same speeds. assert_eq!( - refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_REFRESH_AFTER_MS), + refresh_decision((100, 200), 1_000, 1_000 + RUMBLE_KEEPALIVE_INTERVAL_MS), Some((100, 200)) ); // Long past the deadline (e.g. after a stall): still re-arms. @@ -1258,7 +1262,7 @@ mod tests { ); // Just before the refresh deadline: no re-arm. - clock.advance_to(RUMBLE_REFRESH_AFTER_MS - 1); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS - 1); handle.scan().await.expect("probe scan should succeed"); assert_eq!( state.lock().unwrap().rumble_log.len(), @@ -1268,7 +1272,7 @@ mod tests { // Reaching the deadline triggers exactly one re-send with the same // parameters, comfortably before the finite arm lapses. - clock.advance_to(RUMBLE_REFRESH_AFTER_MS); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS); handle.scan().await.expect("probe scan should succeed"); assert_eq!( state.lock().unwrap().rumble_log, @@ -1301,9 +1305,9 @@ mod tests { .expect("zero rumble should succeed"); assert_eq!(state.lock().unwrap().rumble_log.len(), 2); for t in [ - RUMBLE_REFRESH_AFTER_MS, - RUMBLE_REFRESH_AFTER_MS * 2, - RUMBLE_REFRESH_AFTER_MS * 3, + RUMBLE_KEEPALIVE_INTERVAL_MS, + RUMBLE_KEEPALIVE_INTERVAL_MS * 2, + RUMBLE_KEEPALIVE_INTERVAL_MS * 3, ] { clock.advance_to(t); handle.scan().await.expect("probe scan should succeed"); @@ -1327,7 +1331,7 @@ mod tests { .expect("rumble should succeed"); opened.close().await.expect("close should succeed"); // Close while rumbling emits the zero-speed stop, then nothing more. - clock.advance_to(RUMBLE_REFRESH_AFTER_MS * 2); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS * 2); handle.scan().await.expect("probe scan should succeed"); assert_eq!( state.lock().unwrap().rumble_log, @@ -1350,7 +1354,7 @@ mod tests { .await .expect("rumble should succeed"); state.lock().unwrap().connected.insert(id(12), false); - clock.advance_to(RUMBLE_REFRESH_AFTER_MS * 2); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS * 2); handle.scan().await.expect("probe scan should succeed"); assert_eq!( state.lock().unwrap().rumble_log, From 8769d9deef3de1c0215a2ba85854af90b8ed28c2 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 6 Sep 2026 17:03:26 -0700 Subject: [PATCH 11/16] fix(sdl): preserve host Ctrl-C handling --- crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 142e1c5c7..f5da035c7 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -671,6 +671,11 @@ impl DriverGamepad for Sdl3Gamepad { /// Xbox One S enumerates and `set_rumble` succeeds with this hint). iOS keeps /// the MFI default, where GCController is the only gamepad backend. fn production_sdl_factory() -> Result, SdlTaskInitError> { + // SDL installs SIGINT/SIGTERM handlers by default and turns those signals + // into SDL quit events. This backend is headless and intentionally never + // pumps SDL events, so leave signal ownership with the host application + // (intiface-engine uses Tokio's ctrl_c handler). + sdl3::hint::set(sdl3::hint::names::NO_SIGNAL_HANDLERS, "1"); sdl3::hint::set(sdl3::hint::names::JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); #[cfg(target_os = "macos")] sdl3::hint::set(sdl3::hint::names::JOYSTICK_MFI, "0"); From 3abdf0c179152af16b05990f533b674844761d6e Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 14:12:20 -0700 Subject: [PATCH 12/16] Add SDL gamepad layout selection seam to device configuration SDL gamepads expose two, three, or four logical rumble output channels (main low/high, trigger left/right) depending on hardware capabilities. The existing configuration system hardcodes the two-channel main layout and cannot reconcile a cached user definition against a different base definition, so trigger channels could never be advertised. - Add a layout/constant module to the device config crate sharing the SDL base identifiers, protocol variants, selector keys, and the visible-index to logical-channel-slot mapping for the three layouts. - Add a neutral DeviceDefinitionSelection value (selected base key plus canonical hardware name) and an optional carrier on Hardware, so connectors can declare which base definition they selected without SDL dependencies leaking into buttplug_server or identity fields. - Add DeviceConfigurationManager::device_definition_with_selection, which resolves the selected base explicitly, reconciles cached user definitions (preserving user UUID, index, display name, allow/deny, message gap, and surviving-channel customizations; refreshing the canonical name and base id), and errors on invalid selections instead of silently falling back. - Register the four-channel and trigger-only base configurations with fixed UUIDs and channel descriptions alongside the default two- channel base in the SDL protocol YAML. --- .../src/device/device_handle.rs | 33 +- .../src/device/hardware/mod.rs | 21 +- .../device-config/protocols/sdl-gamepad.yml | 63 +++ .../src/device_config_file/mod.rs | 1 + .../src/device_config_manager.rs | 82 ++++ .../src/device_definitions.rs | 40 ++ .../buttplug_server_device_config/src/lib.rs | 4 + .../src/sdl_layout.rs | 101 +++++ .../tests/test_device_config.rs | 27 ++ .../tests/test_sdl_definition_selection.rs | 379 ++++++++++++++++++ 10 files changed, 743 insertions(+), 8 deletions(-) create mode 100644 crates/buttplug_server_device_config/src/sdl_layout.rs create mode 100644 crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs diff --git a/crates/buttplug_server/src/device/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index 82644afe7..693ba12ed 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -23,8 +23,15 @@ use buttplug_core::{ ButtplugResultFuture, errors::{ButtplugDeviceError, ButtplugError}, message::{ - self, ButtplugMessage, ButtplugServerMessageV4, DeviceFeature, DeviceMessageInfoV4, - InputCommandType, InputType, OutputValue, StopCmdV4, + self, + ButtplugMessage, + ButtplugServerMessageV4, + DeviceFeature, + DeviceMessageInfoV4, + InputCommandType, + InputType, + OutputValue, + StopCmdV4, }, task_span, util::async_manager, @@ -32,7 +39,9 @@ use buttplug_core::{ util::task::TaskGroup, }; use buttplug_server_device_config::{ - DeviceConfigurationManager, ServerDeviceDefinition, ServerDeviceFeatureOutput, + DeviceConfigurationManager, + ServerDeviceDefinition, + ServerDeviceFeatureOutput, UserDeviceIdentifier, }; use dashmap::DashMap; @@ -51,14 +60,17 @@ use uuid::Uuid; use crate::{ ButtplugServerResultFuture, message::{ - ButtplugServerDeviceMessage, checked_input_cmd::CheckedInputCmdV4, - checked_output_cmd::CheckedOutputCmdV4, server_device_attributes::ServerDeviceAttributes, + ButtplugServerDeviceMessage, + checked_input_cmd::CheckedInputCmdV4, + checked_output_cmd::CheckedOutputCmdV4, + server_device_attributes::ServerDeviceAttributes, spec_enums::ButtplugDeviceCommandMessageUnionV4, }, }; use super::{ - InternalDeviceEvent, OutputObservation, + InternalDeviceEvent, + OutputObservation, device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, run_owned_device_task}, hardware::{Hardware, HardwareCommand, HardwareConnector, HardwareEvent}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, ProtocolSpecializer}, @@ -581,7 +593,14 @@ pub(super) async fn build_device_handle( // put it in an unknown state if anything fails. // Check in the DeviceConfigurationManager to make sure we have attributes for this device. - let definition = if let Some(attrs) = device_config_manager.device_definition(&identifier) { + // Connectors may carry explicit selection metadata naming the base definition they chose + // (e.g. SDL gamepad rumble layout); when present, resolve and reconcile against that base. + // An invalid selection is a connection failure, never a silent fallback to defaults. + let definition = if let Some(selection) = hardware.definition_selection() { + device_config_manager + .device_definition_with_selection(&identifier, selection) + .map_err(|e| ButtplugDeviceError::DeviceConfigurationError(e.to_string()))? + } else if let Some(attrs) = device_config_manager.device_definition(&identifier) { attrs } else { return Err(ButtplugDeviceError::DeviceConfigurationError(format!( diff --git a/crates/buttplug_server/src/device/hardware/mod.rs b/crates/buttplug_server/src/device/hardware/mod.rs index 2e2a84172..3ecb0c83b 100644 --- a/crates/buttplug_server/src/device/hardware/mod.rs +++ b/crates/buttplug_server/src/device/hardware/mod.rs @@ -11,7 +11,11 @@ use std::{collections::HashSet, fmt::Debug, sync::Arc, time::Duration}; use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; -use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + Endpoint, + ProtocolCommunicationSpecifier, +}; use futures::future::BoxFuture; use futures_util::FutureExt; use getset::{CopyGetters, Getters}; @@ -258,6 +262,13 @@ pub struct Hardware { /// Device name #[getset(get = "pub")] name: String, + /// Optional connected-definition selection metadata, set by connectors that + /// pick a device definition themselves (e.g. SDL gamepad rumble layout + /// selection). When present, device configuration resolves against the + /// selected base definition instead of the ordinary identifier lookup. Not + /// persisted and never part of device identity. + #[getset(get = "pub")] + definition_selection: Option, /// Device address #[getset(get = "pub")] address: String, @@ -293,10 +304,18 @@ impl Hardware { message_gap: *message_gap, internal_impl, requires_keepalive, + definition_selection: None, last_write_time: Arc::new(RwLock::new(Instant::now())), } } + /// Attach connected-definition selection metadata (builder style), to be + /// called by the connector before the `Hardware` is shared. + pub fn with_definition_selection(mut self, selection: DeviceDefinitionSelection) -> Self { + self.definition_selection = Some(selection); + self + } + pub async fn time_since_last_write(&self) -> Duration { Instant::now().duration_since(*self.last_write_time.read().await) } diff --git a/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml index a40e23da1..adb9df631 100644 --- a/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml +++ b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml @@ -2,6 +2,7 @@ defaults: name: SDL Gamepad features: - id: f56852c8-cb3b-4703-90b6-6291df0c6314 + description: Low-frequency rumble output: vibrate: value: @@ -9,6 +10,7 @@ defaults: - 65535 index: 0 - id: e13388f9-a1b6-4c4c-a7b4-c68eeed293d8 + description: High-frequency rumble output: vibrate: value: @@ -16,6 +18,67 @@ defaults: - 65535 index: 1 id: b35f2adf-16bc-4425-9276-5d191aeaf107 +configurations: +- identifier: + - __sdl-rumble-and-triggers + name: SDL Gamepad (Rumble and Triggers) + id: c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d + protocol_variant: sdl-rumble-and-triggers + features: + - id: f56852c8-cb3b-4703-90b6-6291df0c6314 + description: Low-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: e13388f9-a1b6-4c4c-a7b4-c68eeed293d8 + description: High-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 1 + - id: a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b + description: Left-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 2 + - id: b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c + description: Right-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 3 +- identifier: + - __sdl-triggers-only + name: SDL Gamepad (Triggers Only) + id: d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e + protocol_variant: sdl-triggers-only + features: + - id: a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b + description: Left-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c + description: Right-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 1 communication: - sdl-gamepad: exists: true diff --git a/crates/buttplug_server_device_config/src/device_config_file/mod.rs b/crates/buttplug_server_device_config/src/device_config_file/mod.rs index e6ca7c6f9..bb34d0466 100644 --- a/crates/buttplug_server_device_config/src/device_config_file/mod.rs +++ b/crates/buttplug_server_device_config/src/device_config_file/mod.rs @@ -8,6 +8,7 @@ mod base; mod device; mod feature; +pub(crate) use feature::ConfigUserDeviceFeature; mod protocol; mod user; diff --git a/crates/buttplug_server_device_config/src/device_config_manager.rs b/crates/buttplug_server_device_config/src/device_config_manager.rs index 9dadd22d8..378c34f59 100644 --- a/crates/buttplug_server_device_config/src/device_config_manager.rs +++ b/crates/buttplug_server_device_config/src/device_config_manager.rs @@ -413,6 +413,88 @@ impl DeviceConfigurationManager { index } + /// Resolves a definition when a connector supplies explicit selection metadata. This reconciles + /// even exact cached entries against the selected base before returning, reports invalid or + /// missing selections explicitly rather than silently falling back, preserves user identity, + /// index, overrides, message gap, and surviving feature customizations, and updates the canonical + /// name and base ID. Selection is in-memory only; feature descriptions are not serialized, so + /// reloads use descriptions from the selected base. + pub fn device_definition_with_selection( + &self, + identifier: &UserDeviceIdentifier, + selection: &crate::DeviceDefinitionSelection, + ) -> Result { + if selection.protocol() != identifier.protocol() { + return Err(ButtplugDeviceConfigError::DeviceSelectionInvalid(format!( + "selection protocol '{}' does not match identifier protocol '{}'", + selection.protocol(), + identifier.protocol() + ))); + } + let base_key = BaseDeviceIdentifier::new(selection.protocol(), selection.base_identifier()); + let base_definition = self + .base_device_definitions + .get(&base_key) + .ok_or_else(|| { + ButtplugDeviceConfigError::DeviceSelectionInvalid(format!( + "base definition {:?} not found for protocol '{}'", + base_key, + selection.protocol() + )) + })? + .clone(); + + if let Some(old_definition) = self + .user_device_definitions + .get(identifier) + .map(|x| x.clone()) + { + let mut builder = + ServerDeviceDefinitionBuilder::from_base(&base_definition, old_definition.id(), false); + builder + .name(selection.canonical_name()) + .display_name(old_definition.display_name()) + .allow(old_definition.allow()) + .deny(old_definition.deny()) + .message_gap_ms(old_definition.message_gap_ms()) + .index(old_definition.index()); + for base_feature in base_definition.features().values() { + let feature = if let Some(old_feature) = old_definition + .features() + .values() + .find(|x| x.base_id == Some(base_feature.id())) + { + let mut feature = + crate::device_config_file::ConfigUserDeviceFeature::try_from(old_feature)? + .with_base_feature(base_feature)?; + if !old_feature.description.is_empty() { + feature.description = old_feature.description.clone(); + } + feature + } else { + base_feature.as_new_user_feature() + }; + builder.add_feature(&feature); + } + let rebuilt = builder.finish(); + self + .user_device_definitions + .insert(identifier.clone(), rebuilt.clone()); + Ok(rebuilt) + } else { + let mut builder = + ServerDeviceDefinitionBuilder::from_base(&base_definition, Uuid::new_v4(), true); + builder + .name(selection.canonical_name()) + .index(self.device_index(identifier)); + let definition = builder.finish(); + self + .user_device_definitions + .insert(identifier.clone(), definition.clone()); + Ok(definition) + } + } + pub fn device_definition( &self, identifier: &UserDeviceIdentifier, diff --git a/crates/buttplug_server_device_config/src/device_definitions.rs b/crates/buttplug_server_device_config/src/device_definitions.rs index 57557c2d9..170369c40 100644 --- a/crates/buttplug_server_device_config/src/device_definitions.rs +++ b/crates/buttplug_server_device_config/src/device_definitions.rs @@ -12,6 +12,40 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::server_device_feature::ServerDeviceFeature; + +/// Neutral, protocol-agnostic metadata attached to connected hardware naming the base definition +/// selected by the connector and the device's canonical (hardware-reported) name. A `None` +/// `base_identifier` selects the protocol's default base definition. This value is never persisted +/// and is never part of device identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceDefinitionSelection { + protocol: String, + base_identifier: Option, + canonical_name: String, +} + +impl DeviceDefinitionSelection { + pub fn new(protocol: &str, base_identifier: Option<&str>, canonical_name: &str) -> Self { + Self { + protocol: protocol.to_owned(), + base_identifier: base_identifier.map(str::to_owned), + canonical_name: canonical_name.to_owned(), + } + } + + pub fn protocol(&self) -> &str { + &self.protocol + } + + pub fn base_identifier(&self) -> &Option { + &self.base_identifier + } + + pub fn canonical_name(&self) -> &str { + &self.canonical_name + } +} + #[derive(Debug, Clone, Getters, CopyGetters, Serialize, Deserialize)] pub struct ServerDeviceDefinition { #[getset(get = "pub")] @@ -98,6 +132,12 @@ impl ServerDeviceDefinitionBuilder { self } + /// Sets the canonical (hardware-reported) device name; display_name is the user override and is set separately. + pub fn name(&mut self, name: &str) -> &mut Self { + self.def.name = name.to_owned(); + self + } + pub fn display_name(&mut self, name: &Option) -> &mut Self { self.def.display_name = name.clone(); self diff --git a/crates/buttplug_server_device_config/src/lib.rs b/crates/buttplug_server_device_config/src/lib.rs index ef4fc43ff..34bec66d5 100644 --- a/crates/buttplug_server_device_config/src/lib.rs +++ b/crates/buttplug_server_device_config/src/lib.rs @@ -158,6 +158,8 @@ mod identifiers; pub use identifiers::*; mod device_definitions; pub use device_definitions::*; +mod sdl_layout; +pub use sdl_layout::*; mod server_device_feature; pub use server_device_feature::*; mod endpoint; @@ -180,6 +182,8 @@ pub enum ButtplugDeviceConfigError { /// Base ID not found, cannot match user device/feature to a base device/feature #[error("Device definition with base id {0} not found")] BaseIdNotFound(Uuid), + #[error("Device definition selection is invalid: {0}")] + DeviceSelectionInvalid(String), #[error("Feature vectors between base and user device definitions do not match")] UserFeatureMismatch, #[error("Output value {0} not in range {1}")] diff --git a/crates/buttplug_server_device_config/src/sdl_layout.rs b/crates/buttplug_server_device_config/src/sdl_layout.rs new file mode 100644 index 000000000..7e0657352 --- /dev/null +++ b/crates/buttplug_server_device_config/src/sdl_layout.rs @@ -0,0 +1,101 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +pub const SDL_PROTOCOL_NAME: &str = "sdl-gamepad"; +pub const SDL_MAIN_ONLY_BASE_ID: uuid::Uuid = uuid::uuid!("b35f2adf-16bc-4425-9276-5d191aeaf107"); +pub const SDL_RUMBLE_AND_TRIGGERS_BASE_ID: uuid::Uuid = + uuid::uuid!("c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d"); +pub const SDL_TRIGGERS_ONLY_BASE_ID: uuid::Uuid = + uuid::uuid!("d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e"); +pub const SDL_CHANNEL_LOW_BASE_ID: uuid::Uuid = uuid::uuid!("f56852c8-cb3b-4703-90b6-6291df0c6314"); +pub const SDL_CHANNEL_HIGH_BASE_ID: uuid::Uuid = + uuid::uuid!("e13388f9-a1b6-4c4c-a7b4-c68eeed293d8"); +pub const SDL_CHANNEL_LEFT_TRIGGER_BASE_ID: uuid::Uuid = + uuid::uuid!("a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b"); +pub const SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID: uuid::Uuid = + uuid::uuid!("b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c"); +pub const SDL_RUMBLE_AND_TRIGGERS_SELECTOR: &str = "__sdl-rumble-and-triggers"; +pub const SDL_TRIGGERS_ONLY_SELECTOR: &str = "__sdl-triggers-only"; +pub const SDL_RUMBLE_AND_TRIGGERS_VARIANT: &str = "sdl-rumble-and-triggers"; +pub const SDL_TRIGGERS_ONLY_VARIANT: &str = "sdl-triggers-only"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SdlGamepadLayout { + MainOnly, + TriggersOnly, + MainAndTriggers, +} + +impl SdlGamepadLayout { + pub fn channel_count(self) -> usize { + match self { + Self::MainOnly | Self::TriggersOnly => 2, + Self::MainAndTriggers => 4, + } + } + + /// Logical channel slots in fixed low/high/left-trigger/right-trigger order; positions in this + /// slice are visible feature indexes. Two channels are ambiguous, so layout always comes from + /// the protocol variant, never from feature count. + pub fn logical_slots(self) -> &'static [u8] { + match self { + Self::MainOnly => &[0, 1], + Self::TriggersOnly => &[2, 3], + Self::MainAndTriggers => &[0, 1, 2, 3], + } + } + + pub fn from_protocol_variant(variant: Option<&str>) -> Self { + match variant { + Some(SDL_RUMBLE_AND_TRIGGERS_VARIANT) => Self::MainAndTriggers, + Some(SDL_TRIGGERS_ONLY_VARIANT) => Self::TriggersOnly, + _ => Self::MainOnly, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_count_and_slots() { + assert_eq!(SdlGamepadLayout::MainOnly.channel_count(), 2); + assert_eq!(SdlGamepadLayout::MainOnly.logical_slots(), &[0, 1]); + assert_eq!(SdlGamepadLayout::TriggersOnly.channel_count(), 2); + assert_eq!(SdlGamepadLayout::TriggersOnly.logical_slots(), &[2, 3]); + assert_eq!(SdlGamepadLayout::MainAndTriggers.channel_count(), 4); + assert_eq!( + SdlGamepadLayout::MainAndTriggers.logical_slots(), + &[0, 1, 2, 3] + ); + } + + #[test] + fn protocol_variant_mapping() { + assert_eq!( + SdlGamepadLayout::from_protocol_variant(None), + SdlGamepadLayout::MainOnly + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some("")), + SdlGamepadLayout::MainOnly + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some(SDL_TRIGGERS_ONLY_VARIANT)), + SdlGamepadLayout::TriggersOnly + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some(SDL_RUMBLE_AND_TRIGGERS_VARIANT)), + SdlGamepadLayout::MainAndTriggers + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some("unknown")), + SdlGamepadLayout::MainOnly + ); + } +} diff --git a/crates/buttplug_server_device_config/tests/test_device_config.rs b/crates/buttplug_server_device_config/tests/test_device_config.rs index 752f9098b..844c3049e 100644 --- a/crates/buttplug_server_device_config/tests/test_device_config.rs +++ b/crates/buttplug_server_device_config/tests/test_device_config.rs @@ -49,9 +49,36 @@ fn test_sdl_gamepad_protocol_in_generated_config() { assert_eq!(features.len(), 2); for (i, feature) in features.iter().enumerate() { assert_eq!(feature["index"], i as u64); + assert!(!feature["description"].as_str().unwrap().is_empty()); + assert_eq!( + feature["description"], + ["Low-frequency rumble", "High-frequency rumble"][i] + ); assert_eq!(feature["output"]["vibrate"]["value"][0], 0); assert_eq!(feature["output"]["vibrate"]["value"][1], 65535); } + let configurations = protocol["configurations"].as_array().unwrap(); + assert_eq!(configurations.len(), 2); + assert_eq!( + configurations[0]["identifier"][0], + "__sdl-rumble-and-triggers" + ); + assert_eq!( + configurations[0]["id"], + "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d" + ); + assert_eq!( + configurations[0]["protocol_variant"], + "sdl-rumble-and-triggers" + ); + assert_eq!(configurations[0]["features"].as_array().unwrap().len(), 4); + assert_eq!(configurations[1]["identifier"][0], "__sdl-triggers-only"); + assert_eq!( + configurations[1]["id"], + "d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e" + ); + assert_eq!(configurations[1]["protocol_variant"], "sdl-triggers-only"); + assert_eq!(configurations[1]["features"].as_array().unwrap().len(), 2); let communication = protocol["communication"][0]["sdl-gamepad"].clone(); assert_eq!(communication["exists"], true); } diff --git a/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs b/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs new file mode 100644 index 000000000..d5fdef406 --- /dev/null +++ b/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs @@ -0,0 +1,379 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +use buttplug_core::message::OutputType; +use buttplug_core::util::range::RangeInclusive; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + RangeWithLimit, + SDL_CHANNEL_LEFT_TRIGGER_BASE_ID, + SDL_CHANNEL_LOW_BASE_ID, + SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID, + SDL_MAIN_ONLY_BASE_ID, + SDL_RUMBLE_AND_TRIGGERS_BASE_ID, + SDL_TRIGGERS_ONLY_BASE_ID, + ServerDeviceDefinitionBuilder, + ServerDeviceFeatureOutput, + ServerDeviceFeatureOutputValueProperties, + UserDeviceIdentifier, + load_protocol_configs, + save_user_config, +}; + +fn dcm() -> buttplug_server_device_config::DeviceConfigurationManager { + load_protocol_configs(&None, &None, false) + .unwrap() + .finish() + .unwrap() +} + +#[test] +fn definition_selection_rejects_invalid_base() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new("sdl-gamepad-1", "sdl-gamepad", &None); + let invalid = dcm.device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", Some("__nonexistent-base"), "Pad"), + ); + assert!(matches!( + invalid, + Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) + )); + let mismatch = dcm.device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("other-protocol", None, "Pad"), + ); + assert!(matches!( + mismatch, + Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) + )); +} + +#[test] +fn sdl_selection_idempotent() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-7", + "sdl-gamepad", + &Some("Test Pad 1".to_owned()), + ); + let selection = DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 1", + ); + let first = dcm + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + let second = dcm + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + assert_eq!(first.id(), second.id()); + assert_eq!(first.base_id(), Some(SDL_RUMBLE_AND_TRIGGERS_BASE_ID)); + assert_eq!(first.name(), "Test Pad 1"); + assert_eq!(first.features().len(), 4); + assert_eq!( + first + .features() + .values() + .map(|f| f.id()) + .collect::>(), + second + .features() + .values() + .map(|f| f.id()) + .collect::>() + ); + let cached = dcm.device_definition(&identifier).unwrap(); + assert_eq!(cached.id(), second.id()); + assert_eq!(cached.base_id(), second.base_id()); + assert_eq!( + cached + .features() + .values() + .map(|f| f.id()) + .collect::>(), + second + .features() + .values() + .map(|f| f.id()) + .collect::>() + ); +} + +#[test] +fn sdl_layout_reconciliation_matrix() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new("sdl-gamepad-matrix", "sdl-gamepad", &None); + let main = dcm.device_definition(&identifier).unwrap(); + let main_id = main.id(); + let low = main.features().get(&0).unwrap().id(); + let high = main.features().get(&1).unwrap().id(); + let both = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 1", + ), + ) + .unwrap(); + assert_eq!(both.features().get(&0).unwrap().id(), low); + assert_eq!(both.features().get(&1).unwrap().id(), high); + assert_eq!( + both.features().get(&2).unwrap().base_id, + Some(SDL_CHANNEL_LEFT_TRIGGER_BASE_ID) + ); + assert_eq!( + both.features().get(&3).unwrap().base_id, + Some(SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID) + ); + assert_eq!(both.id(), main_id); + let main_again = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", None, "Test Pad 1"), + ) + .unwrap(); + assert_eq!(main_again.features().len(), 2); + assert_eq!(main_again.features().get(&0).unwrap().id(), low); + assert_eq!(main_again.features().get(&1).unwrap().id(), high); + let triggers = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-triggers-only"), "Test Pad 2"), + ) + .unwrap(); + assert_eq!(triggers.base_id(), Some(SDL_TRIGGERS_ONLY_BASE_ID)); + assert_eq!( + triggers.features().get(&0).unwrap().base_id, + Some(SDL_CHANNEL_LEFT_TRIGGER_BASE_ID) + ); + assert_eq!( + triggers.features().get(&1).unwrap().base_id, + Some(SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID) + ); + assert_ne!(triggers.features().get(&0).unwrap().id(), low); + assert_ne!(triggers.features().get(&1).unwrap().id(), high); + assert_eq!(triggers.name(), "Test Pad 2"); + let restored = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 2", + ), + ) + .unwrap(); + assert_ne!(restored.features().get(&0).unwrap().id(), low); + assert_ne!(restored.features().get(&1).unwrap().id(), high); + assert_eq!(restored.id(), main_id); + assert_eq!( + restored.features().get(&0).unwrap().base_id, + Some(SDL_CHANNEL_LOW_BASE_ID) + ); +} + +#[test] +fn non_sdl_definition_resolution_unchanged() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new("COM1", "tcode-v03", &None); + assert_eq!( + dcm.device_definition(&identifier).unwrap().name(), + "TCode v0.3 (Single Linear Axis)" + ); + let result = dcm.device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("tcode-v03", Some("missing"), "TCode"), + ); + assert!(matches!( + result, + Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) + )); +} + +fn both_selection(name: &str) -> DeviceDefinitionSelection { + DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-rumble-and-triggers"), name) +} + +fn reload_with(saved: String) -> buttplug_server_device_config::DeviceConfigurationManager { + load_protocol_configs(&None, &Some(saved), false) + .unwrap() + .finish() + .unwrap() +} + +#[test] +fn sdl_legacy_config_roundtrip() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-legacy", + "sdl-gamepad", + &Some("Legacy Pad".to_owned()), + ); + let def = dcm.device_definition(&identifier).unwrap(); + assert_eq!(def.base_id(), Some(SDL_MAIN_ONLY_BASE_ID)); + assert_eq!(def.features().len(), 2); + // Display-name override plus canonical name as hardware would report it. + let mut builder = ServerDeviceDefinitionBuilder::from_user(&def); + builder.display_name(&Some("My Precious Pad".to_owned())); + dcm.add_user_device_definition(&identifier, &builder.finish()); + + let saved = save_user_config(&dcm).unwrap(); + let reloaded = reload_with(saved); + + // Cached user definition reloads with the same identity, features, base and + // display-name override; canonical name falls back to the base default + // because names are not serialized. + let back = reloaded.device_definition(&identifier).unwrap(); + assert_eq!(back.id(), def.id()); + assert_eq!(back.base_id(), Some(SDL_MAIN_ONLY_BASE_ID)); + assert_eq!(back.features().len(), 2); + assert_eq!( + back.features().values().map(|f| f.id()).collect::>(), + def.features().values().map(|f| f.id()).collect::>() + ); + assert_eq!(back.name(), "SDL Gamepad"); + assert_eq!(back.display_name(), &Some("My Precious Pad".to_owned())); + + // A later connection refreshes the canonical name without changing identity. + let reconnected = reloaded + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", None, "Legacy Pad"), + ) + .unwrap(); + assert_eq!(reconnected.id(), def.id()); + assert_eq!(reconnected.name(), "Legacy Pad"); +} + +#[test] +fn sdl_selected_config_roundtrip() { + for (selection, expected_base, expected_features) in [ + ( + both_selection("Selected Pad"), + SDL_RUMBLE_AND_TRIGGERS_BASE_ID, + 4, + ), + ( + DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-triggers-only"), "Selected Pad"), + SDL_TRIGGERS_ONLY_BASE_ID, + 2, + ), + ] { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-selected", + "sdl-gamepad", + &Some("Selected Pad".to_owned()), + ); + let def = dcm + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + assert_eq!(def.base_id(), Some(expected_base)); + assert_eq!(def.features().len(), expected_features); + + let saved = save_user_config(&dcm).unwrap(); + let reloaded = reload_with(saved); + let back = reloaded + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + assert_eq!(back.id(), def.id()); + assert_eq!(back.base_id(), Some(expected_base)); + assert_eq!(back.protocol_variant(), def.protocol_variant()); + assert_eq!(back.name(), "Selected Pad"); + assert_eq!( + back.features().values().map(|f| f.id()).collect::>(), + def.features().values().map(|f| f.id()).collect::>() + ); + } +} + +#[test] +fn sdl_description_reconciliation_and_reload_contract() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-desc", + "sdl-gamepad", + &Some("Desc Pad".to_owned()), + ); + let def = dcm + .device_definition_with_selection(&identifier, &both_selection("Desc Pad")) + .unwrap(); + + // Customize feature 0 with a deliberately nondefault description, feature 1 + // with a user range limit and a disabled flag. + let mut builder = ServerDeviceDefinitionBuilder::from_user(&def); + let mut f0 = def.features().get(&0).unwrap().clone(); + f0.description = "My custom low motor label".to_owned(); + builder.replace_feature(&f0); + let mut f1 = def.features().get(&1).unwrap().clone(); + f1.output = f1 + .output + .iter() + .map(|o| match o { + ServerDeviceFeatureOutput::Vibrate(props) => { + ServerDeviceFeatureOutput::Vibrate(ServerDeviceFeatureOutputValueProperties::new( + RangeWithLimit::new_with_user( + props.value.base.clone(), + Some(RangeInclusive::new(0, 30000)), + ), + true, + )) + } + other => other.clone(), + }) + .collect(); + builder.replace_feature(&f1); + dcm.add_user_device_definition(&identifier, &builder.finish()); + + // In-memory reconciliation preserves the nonempty custom description and + // the user customizations. + let reconn = dcm + .device_definition_with_selection(&identifier, &both_selection("Desc Pad")) + .unwrap(); + assert_eq!( + reconn.features().get(&0).unwrap().description, + "My custom low motor label" + ); + let f1_back = reconn.features().get(&1).unwrap(); + match f1_back.get_output(OutputType::Vibrate).unwrap() { + ServerDeviceFeatureOutput::Vibrate(props) => { + assert_eq!( + (props.value.internal().start(), props.value.internal().end()), + (0, 30000) + ); + assert!(props.disabled); + } + other => panic!("expected vibrate output, got {other:?}"), + } + + // Save/load: descriptions are not serialized, so reload uses the selected + // base's descriptions. User limits and disabled flags persist. + let saved = save_user_config(&dcm).unwrap(); + let reloaded = reload_with(saved); + let back = reloaded + .device_definition_with_selection(&identifier, &both_selection("Desc Pad")) + .unwrap(); + assert_eq!( + back.features().get(&0).unwrap().description, + "Low-frequency rumble" + ); + let f1_reloaded = back.features().get(&1).unwrap(); + match f1_reloaded.get_output(OutputType::Vibrate).unwrap() { + ServerDeviceFeatureOutput::Vibrate(props) => { + assert_eq!( + (props.value.internal().start(), props.value.internal().end()), + (0, 30000) + ); + assert!(props.disabled); + } + other => panic!("expected vibrate output, got {other:?}"), + } +} From f0af89f3b86e047279ba8e528c11523fbbef1f6d Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 14:37:37 -0700 Subject: [PATCH 13/16] Support independent SDL main and trigger rumble channels end to end SDL gamepads report main rumble and trigger rumble as independent capabilities. Discovery now probes both (pure property reads, no motor activation) and pads with neither capability are not published; failed probes and capability changes are rechecked on later scans and on the actual leased open. The macOS wired-pad exclusion is unchanged. The protocol-to-hardware transport carries the complete four-slot desired state (low, high, left trigger, right trigger as u16 LE, 8 bytes) in one command. The protocol handler derives its visible-index to logical-slot mapping from the final definition's protocol variant, never from feature count. The ownership thread dispatches only pairs the pad reports as supported - including zero, stop, and keepalive calls - tracks last-applied state per pair, refreshes each active pair before expiry, and on any pair failure best-effort zeroes both supported pairs, removes the lease, and delivers removal so no successfully applied half keeps running untracked. Adds a test-only Shutdown command routed through the production loop teardown with a joinable spawn seam, plus deterministic coverage for the capability matrix, probe retries, fresh connect snapshots, name fallbacks (including whitespace-only names), per-pair lifecycle and keepalive, partial-pair failure cleanup, stale-generation isolation, hardware packet validation, and supported-pair dispatch. --- .../src/device/protocol_impl/sdl_gamepad.rs | 235 +++-- .../examples/sdl3_thread_spike.rs | 15 +- .../src/sdl_comm_manager.rs | 13 +- .../src/sdl_gamepad_hardware.rs | 274 +++++- .../src/sdl_task.rs | 858 ++++++++++++++++-- 5 files changed, 1218 insertions(+), 177 deletions(-) diff --git a/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs b/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs index e9825b969..22c2a44ef 100644 --- a/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs +++ b/crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs @@ -5,63 +5,139 @@ // Licensed under the BSD 3-Clause license. See LICENSE file in the project root // for full license information. -use buttplug_server_device_config::Endpoint; -use byteorder::LittleEndian; +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + SdlGamepadLayout, + ServerDeviceDefinition, + UserDeviceIdentifier, +}; +use byteorder::{LittleEndian, WriteBytesExt}; +use std::sync::{Arc, Mutex}; use crate::device::{ - hardware::{HardwareCommand, HardwareWriteCmd}, - protocol::{ProtocolHandler, generic_protocol_setup}, + hardware::{Hardware, HardwareCommand, HardwareWriteCmd}, + protocol::{ProtocolHandler, ProtocolIdentifier, ProtocolIdentifierFactory, ProtocolInitializer}, }; -use buttplug_core::errors::ButtplugDeviceError; -use byteorder::WriteBytesExt; -use std::sync::atomic::{AtomicU16, Ordering}; -generic_protocol_setup!(SdlGamepad, "sdl-gamepad"); +pub mod setup { + use super::*; + + #[derive(Default)] + pub struct SdlGamepadIdentifierFactory {} + + impl ProtocolIdentifierFactory for SdlGamepadIdentifierFactory { + fn identifier(&self) -> &str { + "sdl-gamepad" + } + + fn create(&self) -> Box { + Box::new(SdlGamepadIdentifier::default()) + } + } +} + +#[derive(Default)] +pub struct SdlGamepadIdentifier {} + +#[async_trait] +impl ProtocolIdentifier for SdlGamepadIdentifier { + async fn identify( + &mut self, + hardware: Arc, + _: ProtocolCommunicationSpecifier, + ) -> Result<(UserDeviceIdentifier, Box), ButtplugDeviceError> { + let identifier = UserDeviceIdentifier::new( + hardware.address(), + "sdl-gamepad", + &Some(hardware.name().to_owned()), + ); + Ok((identifier, Box::new(SdlGamepadInitializer::default()))) + } +} + +#[derive(Default)] +pub struct SdlGamepadInitializer {} + +#[async_trait] +impl ProtocolInitializer for SdlGamepadInitializer { + async fn initialize( + &mut self, + _: Arc, + device_definition: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + let layout = + SdlGamepadLayout::from_protocol_variant(device_definition.protocol_variant().as_deref()); + Ok(Arc::new(SdlGamepad::new(layout))) + } +} /// SDL3 gamepad rumble protocol. /// -/// Like XInput, every vibrate command carries the *complete* motor state: the -/// handler keeps the last-set speed of both motors and packs both u16 values -/// (little-endian) into every write packet, so every `write_value` is a full -/// command and no batching/drain step is needed on the hardware side. +/// Every vibrate command carries the complete logical state. The handler keeps +/// the last-set speed for all four logical slots and packs all four u16 values +/// (little-endian) into every write packet. The internal packet is 8 bytes: +/// [low-frequency main, high-frequency main, left trigger, right trigger]. /// -/// Packet layout (4 bytes, little-endian): -/// bytes 0-1: low-frequency motor speed (feature 0), 0-65535 -/// bytes 2-3: high-frequency motor speed (feature 1), 0-65535 -#[derive(Default)] +/// Visible feature indexes are mapped by the final device definition's layout: +/// MainOnly maps 0/1 to slots 0/1, TriggersOnly maps 0/1 to slots 2/3, and +/// MainAndTriggers maps 0-3 to slots 0-3. The layout comes from the protocol +/// variant, never from the feature count. Disabled features are filtered before +/// the handler sees them and must not be reinterpreted as different hardware +/// channels. pub struct SdlGamepad { - speeds: [AtomicU16; 2], + layout: SdlGamepadLayout, + slots: Mutex<[u16; 4]>, +} + +impl SdlGamepad { + pub fn new(layout: SdlGamepadLayout) -> Self { + Self { + layout, + slots: Mutex::new([0; 4]), + } + } +} + +impl Default for SdlGamepad { + fn default() -> Self { + Self::new(SdlGamepadLayout::MainOnly) + } } impl ProtocolHandler for SdlGamepad { fn handle_output_vibrate_cmd( &self, feature_index: u32, - _feature_id: uuid::Uuid, + feature_id: uuid::Uuid, speed: u32, ) -> Result, ButtplugDeviceError> { - if feature_index > 1 { + if feature_index as usize >= self.layout.channel_count() { return Err(ButtplugDeviceError::ProtocolSpecificError( "SdlGamepad".to_owned(), - format!("SDL gamepad only has 2 vibrate features, got index {feature_index}"), + format!( + "SDL gamepad only has {} vibrate features, got index {feature_index}", + self.layout.channel_count() + ), )); } - self.speeds[feature_index as usize].store(speed as u16, Ordering::Relaxed); + + let mut slots = self.slots.lock().unwrap(); + let slot = self.layout.logical_slots()[feature_index as usize] as usize; + slots[slot] = speed as u16; let mut cmd = vec![]; - if cmd - .write_u16::(self.speeds[0].load(Ordering::Relaxed)) - .is_err() - || cmd - .write_u16::(self.speeds[1].load(Ordering::Relaxed)) - .is_err() - { - return Err(ButtplugDeviceError::ProtocolSpecificError( - "SdlGamepad".to_owned(), - "Cannot convert SDL gamepad value for processing".to_owned(), - )); + for speed in slots.iter() { + if cmd.write_u16::(*speed).is_err() { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "SdlGamepad".to_owned(), + "Cannot convert SDL gamepad value for processing".to_owned(), + )); + } } + Ok(vec![ - HardwareWriteCmd::new(&[_feature_id], Endpoint::Tx, cmd, false).into(), + HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, cmd, false).into(), ]) } } @@ -84,35 +160,80 @@ mod tests { } } - #[test] - fn sdl_gamepad_packs_both_motor_states() { - let handler = SdlGamepad::default(); - - // Feature 0 (low motor) only: high motor stays 0. - let packet = vibrate(&handler, 0, 0x8000); - assert_eq!(packet, vec![0x00, 0x80, 0x00, 0x00]); + fn packet(values: [u16; 4]) -> Vec { + values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() + } - // Feature 1 (high motor) now set: packet must carry BOTH stored speeds, - // proving the handler is stateful across commands. - let packet = vibrate(&handler, 1, 0x7fff); - assert_eq!(packet, vec![0x00, 0x80, 0xff, 0x7f]); + #[test] + fn sdl_protocol_layout_packets() { + let cases = [ + ( + SdlGamepadLayout::MainOnly, + &[(0, 0x1234u16), (1, 0x5678u16)][..], + ), + ( + SdlGamepadLayout::TriggersOnly, + &[(0, 0x1234u16), (1, 0x5678u16)][..], + ), + ( + SdlGamepadLayout::MainAndTriggers, + &[ + (0, 0x1234u16), + (1, 0x5678u16), + (2, 0x9abcu16), + (3, 0xdef0u16), + ][..], + ), + ]; - // Updating feature 0 again keeps feature 1's stored speed. - let packet = vibrate(&handler, 0, 0x1234); - assert_eq!(packet, vec![0x34, 0x12, 0xff, 0x7f]); + for (layout, writes) in cases { + let handler = SdlGamepad::new(layout); + for &(index, speed) in writes { + let mut expected = [0; 4]; + for &(previous_index, previous_speed) in writes { + if previous_index <= index { + expected[layout.logical_slots()[previous_index as usize] as usize] = previous_speed; + } + if previous_index == index { + break; + } + } + assert_eq!(vibrate(&handler, index, speed as u32), packet(expected)); + } + assert!( + handler + .handle_output_vibrate_cmd(layout.channel_count() as u32, uuid::Uuid::new_v4(), 100,) + .is_err() + ); + } + } - // Speeds clamp to u16 in the same way as XInput (store as u16). - let packet = vibrate(&handler, 0, 0xffff); - assert_eq!(packet, vec![0xff, 0xff, 0xff, 0x7f]); + #[test] + fn sdl_protocol_rejects_out_of_range_feature_per_layout() { + for layout in [ + SdlGamepadLayout::MainOnly, + SdlGamepadLayout::TriggersOnly, + SdlGamepadLayout::MainAndTriggers, + ] { + let error = SdlGamepad::new(layout) + .handle_output_vibrate_cmd(layout.channel_count() as u32, uuid::Uuid::new_v4(), 100) + .expect_err("out-of-range feature should be rejected"); + assert!( + error + .to_string() + .contains(&layout.channel_count().to_string()) + ); + } } #[test] - fn sdl_gamepad_rejects_out_of_range_feature() { - let handler = SdlGamepad::default(); - assert!( - handler - .handle_output_vibrate_cmd(2, uuid::Uuid::new_v4(), 100) - .is_err() - ); + fn sdl_protocol_stop_zeroes_only_selected_slot() { + let handler = SdlGamepad::new(SdlGamepadLayout::MainAndTriggers); + vibrate(&handler, 0, 0x1234); + vibrate(&handler, 2, 0x5678); + assert_eq!(vibrate(&handler, 0, 0), packet([0, 0, 0x5678, 0])); } } diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs index 3f13a3f2d..ff014ec6b 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -60,11 +60,22 @@ fn main() { "Error" } }; + // Capability booleans mirror what the production manager reads + // (SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN and + // SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN). Pure + // property queries: they never actuate motors. + // SAFETY: property-table reads of this opened gamepad on the + // thread that exclusively owns it; no concurrent SDL access. + let has_rumble = unsafe { pad.has_rumble() }; + // SAFETY: see above. + let has_trigger_rumble = unsafe { pad.has_rumble_triggers() }; println!( - "[sdl-thread] gamepad {} '{}' connection: {}", + "[sdl-thread] gamepad {} '{}' connection: {} rumble: {} trigger-rumble: {}", id.0, pad.name().unwrap_or_default(), - connection + connection, + has_rumble, + has_trigger_rumble ); // pad drops here, closing the probe handle } diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs index b02f8b1b7..64eadca35 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -83,6 +83,7 @@ impl SdlGamepadCommunicationManager { gamepad.id, gamepad.name, address, + gamepad.capabilities, )), }) .await @@ -185,7 +186,13 @@ mod tests { async fn open( &self, _id: JoystickId, - ) -> Result, SdlTaskError> { + ) -> Result< + ( + Arc, + crate::sdl_task::SdlRumbleCapabilities, + ), + SdlTaskError, + > { panic!("open is not exercised through this mock") } } @@ -210,6 +217,10 @@ mod tests { SdlGamepadDesc { id: joystick_id(id), name: name.to_owned(), + capabilities: crate::sdl_task::SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, } } diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs index 3080530b3..10ac101b0 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -7,7 +7,14 @@ //! Hardware connector and hardware implementation for SDL3 gamepads. -use super::sdl_task::{RUMBLE_DURATION_MS, SdlGamepadBackend, SdlOpenedGamepad, SdlTaskError}; +use super::sdl_task::{ + RUMBLE_DURATION_MS, + SdlGamepadBackend, + SdlOpenedGamepad, + SdlRumbleCapabilities, + SdlRumbleState, + SdlTaskError, +}; use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; use buttplug_server::device::hardware::{ @@ -25,8 +32,12 @@ use buttplug_server::device::hardware::{ communication::HardwareSpecificError, }; use buttplug_server_device_config::{ + DeviceDefinitionSelection, Endpoint, ProtocolCommunicationSpecifier, + SDL_PROTOCOL_NAME, + SDL_RUMBLE_AND_TRIGGERS_SELECTOR, + SDL_TRIGGERS_ONLY_SELECTOR, SdlGamepadSpecifier, }; use byteorder::{LittleEndian, ReadBytesExt}; @@ -45,6 +56,7 @@ pub(crate) struct SdlGamepadHardwareConnector { id: JoystickId, name: String, address: String, + capabilities: SdlRumbleCapabilities, } impl SdlGamepadHardwareConnector { @@ -53,12 +65,14 @@ impl SdlGamepadHardwareConnector { id: JoystickId, name: String, address: String, + capabilities: SdlRumbleCapabilities, ) -> Self { Self { backend, id, name, address, + capabilities, } } } @@ -68,6 +82,7 @@ impl Debug for SdlGamepadHardwareConnector { f.debug_struct("SdlGamepadHardwareConnector") .field("id", &self.id.0) .field("name", &self.name) + .field("capabilities", &self.capabilities) .finish() } } @@ -90,12 +105,24 @@ impl HardwareConnector for SdlGamepadHardwareConnector { async fn connect(&mut self) -> Result, ButtplugDeviceError> { debug!("Emitting a new SDL gamepad device impl ({})", self.address); - let opened = self + let (opened, caps) = self .backend .open(self.id) .await .map_err(|e| hardware_error("open", e))?; - let hardware_internal = SdlGamepadHardware::new(opened, self.address.clone()); + let base_identifier = match (caps.rumble, caps.trigger_rumble) { + (true, true) => Some(SDL_RUMBLE_AND_TRIGGERS_SELECTOR), + (true, false) => None, + (false, true) => Some(SDL_TRIGGERS_ONLY_SELECTOR), + (false, false) => { + opened.close_now(); + return Err(hardware_error( + "open", + SdlTaskError::NoRumbleCapability(self.id), + )); + } + }; + let hardware_internal = SdlGamepadHardware::new(opened, self.address.clone(), caps); let hardware = Hardware::new( &self.name, &self.address, @@ -103,7 +130,12 @@ impl HardwareConnector for SdlGamepadHardwareConnector { &None, false, Box::new(hardware_internal), - ); + ) + .with_definition_selection(DeviceDefinitionSelection::new( + SDL_PROTOCOL_NAME, + base_identifier, + &self.name, + )); Ok(Box::new(GenericHardwareSpecializer::new(hardware))) } } @@ -137,12 +169,17 @@ async fn watch_removal( pub(crate) struct SdlGamepadHardware { opened: Option>, + capabilities: SdlRumbleCapabilities, event_sender: broadcast::Sender, cancellation_token: CancellationToken, } impl SdlGamepadHardware { - fn new(opened: Arc, address: String) -> Self { + fn new( + opened: Arc, + address: String, + capabilities: SdlRumbleCapabilities, + ) -> Self { let (device_event_sender, _) = broadcast::channel(256); let token = CancellationToken::new(); let child = token.child_token(); @@ -154,6 +191,7 @@ impl SdlGamepadHardware { }); Self { opened: Some(opened), + capabilities, event_sender: device_event_sender, cancellation_token: token, } @@ -203,23 +241,45 @@ impl HardwareInternal for SdlGamepadHardware { }; let opened = opened.clone(); let data = msg.data().clone(); + let caps = self.capabilities; async move { - // The protocol guarantees 4 bytes (two u16 LE motor speeds), but a - // short read must error, not panic. + if data.len() != 8 { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad write payload must be 8 bytes (four u16 LE channel values)".to_owned(), + )); + } let mut cursor = Cursor::new(data); - let (low, high) = match ( + let state = match ( + cursor.read_u16::(), + cursor.read_u16::(), cursor.read_u16::(), cursor.read_u16::(), ) { - (Ok(low), Ok(high)) => (low, high), + (Ok(low), Ok(high), Ok(left_trigger), Ok(right_trigger)) => SdlRumbleState { + low, + high, + left_trigger, + right_trigger, + }, _ => { return Err(ButtplugDeviceError::DeviceCommunicationError( - "SDL gamepad write payload must be 4 bytes (two u16 LE motor speeds)".to_owned(), + "SDL gamepad write payload must be 8 bytes (four u16 LE channel values)".to_owned(), )); } }; + let [low, high, left, right] = state.slots(); + if !caps.rumble && (low != 0 || high != 0) { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad does not support main rumble".to_owned(), + )); + } + if !caps.trigger_rumble && (left != 0 || right != 0) { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad does not support trigger rumble".to_owned(), + )); + } opened - .rumble(low, high, RUMBLE_DURATION_MS) + .set_rumble_state(state, RUMBLE_DURATION_MS) .await .map_err(|e| hardware_error("rumble", e)) } @@ -267,19 +327,58 @@ mod tests { #[derive(Debug)] struct MockOpenedGamepad { rumble_calls: Mutex>, + trigger_calls: Mutex>, + rumble_attempts: Mutex>, + trigger_attempts: Mutex>, + fail: Mutex, + commands: Mutex, + caps: SdlRumbleCapabilities, closed: Mutex, removed_tx: watch::Sender, } #[async_trait] impl SdlOpenedGamepad for MockOpenedGamepad { - async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError> { - self - .rumble_calls - .lock() - .unwrap() - .push((low, high, duration_ms)); - Ok(()) + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + *self.commands.lock().unwrap() += 1; + let fail = *self.fail.lock().unwrap(); + if self.caps.rumble { + self + .rumble_attempts + .lock() + .unwrap() + .push((state.low, state.high, duration_ms)); + if !fail { + self + .rumble_calls + .lock() + .unwrap() + .push((state.low, state.high, duration_ms)); + } + } + if self.caps.trigger_rumble { + self.trigger_attempts.lock().unwrap().push(( + state.left_trigger, + state.right_trigger, + duration_ms, + )); + if !fail { + self.trigger_calls.lock().unwrap().push(( + state.left_trigger, + state.right_trigger, + duration_ms, + )); + } + } + if fail { + Err(SdlTaskError::Rumble("mock failure".to_owned())) + } else { + Ok(()) + } } async fn close(&self) -> Result<(), SdlTaskError> { @@ -313,20 +412,42 @@ mod tests { Ok(self.gamepads.lock().unwrap().clone()) } - async fn open(&self, _id: JoystickId) -> Result, SdlTaskError> { + async fn open( + &self, + _id: JoystickId, + ) -> Result<(Arc, SdlRumbleCapabilities), SdlTaskError> { self .opened .lock() .unwrap() .clone() - .map(|pad| pad as Arc) + .map(|pad| { + let caps = pad.caps; + (pad as Arc, caps) + }) .ok_or_else(|| SdlTaskError::Open("no mock gamepad".to_owned())) } } async fn connect_mock_hardware() -> (Arc, Hardware, Arc) { + connect_mock_caps(SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }) + .await + } + + async fn connect_mock_caps( + caps: SdlRumbleCapabilities, + ) -> (Arc, Hardware, Arc) { let mock_pad = Arc::new(MockOpenedGamepad { + caps, rumble_calls: Mutex::new(Vec::new()), + trigger_calls: Mutex::new(Vec::new()), + rumble_attempts: Mutex::new(Vec::new()), + trigger_attempts: Mutex::new(Vec::new()), + fail: Mutex::new(false), + commands: Mutex::new(0), closed: Mutex::new(0), removed_tx: watch::channel(false).0, }); @@ -339,6 +460,7 @@ mod tests { joystick_id(21), "SDL Gamepad".to_owned(), create_address(joystick_id(21)), + caps, ); assert_eq!( connector.specifier(), @@ -359,12 +481,12 @@ mod tests { async fn hardware_write_value_forwards_motor_pair() { let (mock_pad, hardware, _backend) = connect_mock_hardware().await; - // 1:1 passthrough of the two parsed u16 LE values. + // Main-only pads receive only their supported pair. hardware .write_value(&HardwareWriteCmd::new( &[uuid::Uuid::new_v4()], Endpoint::Tx, - vec![0x00, 0x80, 0xff, 0x7f], + vec![0x00, 0x80, 0xff, 0x7f, 0, 0, 0, 0], false, )) .await @@ -374,6 +496,9 @@ mod tests { vec![(0x8000, 0x7fff, RUMBLE_DURATION_MS)] ); + assert!(mock_pad.trigger_attempts.lock().unwrap().is_empty()); + assert_eq!(*mock_pad.commands.lock().unwrap(), 1); + // Short payloads error rather than panic. let err = hardware .write_value(&HardwareWriteCmd::new( @@ -400,6 +525,111 @@ mod tests { ); } + fn packet(data: Vec) -> HardwareWriteCmd { + HardwareWriteCmd::new(&[uuid::Uuid::new_v4()], Endpoint::Tx, data, false) + } + + #[tokio::test] + async fn sdl_hardware_packet_validation() { + for caps in [ + SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, + SdlRumbleCapabilities { + rumble: false, + trigger_rumble: true, + }, + ] { + let (pad, hardware, _) = connect_mock_caps(caps).await; + for bytes in [vec![0; 4], vec![0; 9]] { + assert!(hardware.write_value(&packet(bytes)).await.is_err()); + } + let mut unsupported = vec![0; 8]; + unsupported[if caps.rumble { 4 } else { 0 }] = 1; + let error = hardware + .write_value(&packet(unsupported)) + .await + .unwrap_err() + .to_string(); + assert!(error.contains(if caps.rumble { + "does not support trigger rumble" + } else { + "does not support main rumble" + })); + assert!(pad.rumble_calls.lock().unwrap().is_empty()); + assert!(pad.trigger_calls.lock().unwrap().is_empty()); + assert_eq!(*pad.commands.lock().unwrap(), 0); + } + } + + #[tokio::test] + async fn sdl_backend_supported_pair_dispatch() { + for caps in [ + SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, + SdlRumbleCapabilities { + rumble: false, + trigger_rumble: true, + }, + SdlRumbleCapabilities { + rumble: true, + trigger_rumble: true, + }, + ] { + let (pad, hardware, _) = connect_mock_caps(caps).await; + let state = SdlRumbleState { + low: if caps.rumble { 500 } else { 0 }, + right_trigger: if caps.trigger_rumble { 700 } else { 0 }, + ..Default::default() + }; + hardware + .write_value(&packet( + state + .slots() + .into_iter() + .flat_map(u16::to_le_bytes) + .collect(), + )) + .await + .unwrap(); + hardware.write_value(&packet(vec![0; 8])).await.unwrap(); + assert_eq!( + pad.rumble_calls.lock().unwrap().len(), + if caps.rumble { 2 } else { 0 } + ); + assert_eq!( + pad.trigger_calls.lock().unwrap().len(), + if caps.trigger_rumble { 2 } else { 0 } + ); + if caps.rumble { + assert_eq!( + *pad.rumble_calls.lock().unwrap(), + vec![(500, 0, RUMBLE_DURATION_MS), (0, 0, RUMBLE_DURATION_MS)] + ); + } + if caps.trigger_rumble { + assert_eq!( + *pad.trigger_calls.lock().unwrap(), + vec![(0, 700, RUMBLE_DURATION_MS), (0, 0, RUMBLE_DURATION_MS)] + ); + } + assert_eq!(*pad.commands.lock().unwrap(), 2); + *pad.fail.lock().unwrap() = true; + assert!(hardware.write_value(&packet(vec![0; 8])).await.is_err()); + assert_eq!( + pad.rumble_attempts.lock().unwrap().len(), + if caps.rumble { 3 } else { 0 } + ); + assert_eq!( + pad.trigger_attempts.lock().unwrap().len(), + if caps.trigger_rumble { 3 } else { 0 } + ); + } + } + #[tokio::test] async fn hardware_close_and_drop_close_backend_handle() { // Explicit disconnect closes the backend handle. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index f5da035c7..5528d0493 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -50,12 +50,41 @@ const CONNECTED_POLL_INTERVAL_MS: u64 = 500; /// for connected-poll and rumble-refresh checks. const COMMAND_WAKE_MS: u64 = 100; +/// Which independent rumble pairs an opened gamepad reported. Logical output +/// channels, not physical motor counts; can vary by OS/transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct SdlRumbleCapabilities { + pub rumble: bool, + pub trigger_rumble: bool, +} + +impl SdlRumbleCapabilities { + pub fn any(self) -> bool { + self.rumble || self.trigger_rumble + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct SdlRumbleState { + pub low: u16, + pub high: u16, + pub left_trigger: u16, + pub right_trigger: u16, +} + +impl SdlRumbleState { + pub fn slots(&self) -> [u16; 4] { + [self.low, self.high, self.left_trigger, self.right_trigger] + } +} + /// A gamepad discovered by a scan, with its SDL-reported name (or the /// deterministic fallback name when the name lookup failed). #[derive(Debug, Clone)] pub(crate) struct SdlGamepadDesc { pub id: JoystickId, pub name: String, + pub capabilities: SdlRumbleCapabilities, } /// Construct a [JoystickId] from its raw u32 value. `JoystickId` is a type @@ -71,6 +100,8 @@ pub(crate) enum SdlTaskError { Init(String), #[error("SDL gamepad scan failed: {0}")] Scan(String), + #[error("SDL gamepad {0} has no rumble capability")] + NoRumbleCapability(JoystickId), #[error("SDL gamepad {0} is already open")] AlreadyOpen(JoystickId), #[error("SDL gamepad {0} has been removed")] @@ -109,7 +140,10 @@ pub(crate) enum DriverConnection { } pub(crate) trait DriverGamepad { + fn has_rumble(&self) -> bool; + fn has_rumble_triggers(&self) -> bool; fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String>; + fn rumble_triggers(&mut self, left: u16, right: u16, duration_ms: u32) -> Result<(), String>; fn connected(&self) -> bool; /// Default `Unknown` so fakes only override it where relevant. fn connection_state(&self) -> DriverConnection { @@ -142,13 +176,14 @@ enum SdlCommand { }, Open { id: JoystickId, - reply: oneshot::Sender>, + reply: oneshot::Sender>, }, - Rumble { + #[cfg(test)] + Shutdown { reply: oneshot::Sender<()> }, + SetRumbleState { id: JoystickId, generation: u64, - low: u16, - high: u16, + state: SdlRumbleState, duration: u32, reply: oneshot::Sender>, }, @@ -186,15 +221,14 @@ impl SdlOpenedGamepadHandle { self.removed_rx.clone() } - pub(crate) async fn rumble( + pub(crate) async fn set_rumble_state( &self, - low: u16, - high: u16, + state: SdlRumbleState, duration_ms: u32, ) -> Result<(), SdlTaskError> { self .task - .rumble(self.id, self.generation, low, high, duration_ms) + .set_rumble_state(self.id, self.generation, state, duration_ms) .await } @@ -210,8 +244,9 @@ impl SdlOpenedGamepadHandle { /// Cloneable handle to the SDL thread's command channel. /// -/// If all handles drop, the thread exits (which drops the SDL context). The -/// process-global publication keeps one handle alive for the process lifetime. +/// The loop retains its own sender, so external handle drops do not stop it. +/// Production lives until process exit; tests explicitly use the Shutdown seam. +/// Channel disconnection is a defensive teardown path. #[derive(Clone)] pub(crate) struct SdlTaskHandle { cmd_tx: mpsc::Sender, @@ -242,26 +277,34 @@ impl SdlTaskHandle { .await? } - pub(crate) async fn open(&self, id: JoystickId) -> Result { + pub(crate) async fn open( + &self, + id: JoystickId, + ) -> Result<(SdlOpenedGamepadHandle, SdlRumbleCapabilities), SdlTaskError> { self .send_and_await(|reply| SdlCommand::Open { id, reply }) .await? } - pub(crate) async fn rumble( + #[cfg(test)] + async fn shutdown(&self) -> Result<(), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Shutdown { reply }) + .await + } + + pub(crate) async fn set_rumble_state( &self, id: JoystickId, generation: u64, - low: u16, - high: u16, + state: SdlRumbleState, duration: u32, ) -> Result<(), SdlTaskError> { self - .send_and_await(|reply| SdlCommand::Rumble { + .send_and_await(|reply| SdlCommand::SetRumbleState { id, generation, - low, - high, + state, duration, reply, }) @@ -307,8 +350,10 @@ struct OpenPadState { pad: Box, generation: u64, removed_tx: watch::Sender, - last_rumble: (u16, u16), - last_set_at: u64, + last_main: (u16, u16), + main_set_at: u64, + last_triggers: (u16, u16), + triggers_set_at: u64, } /// Pure rumble-refresh decision: given the last accepted rumble command, when @@ -339,12 +384,21 @@ fn mark_removed(state: OpenPadState) { /// if this fails, but an explicit stop avoids up to a full arm period of /// vibration after a disconnect while rumbling. fn stop_and_drop(mut state: OpenPadState) { - if state.last_rumble != (0, 0) { + if state.pad.has_rumble() { let _ = state.pad.rumble(0, 0, RUMBLE_DURATION_MS); } + if state.pad.has_rumble_triggers() { + let _ = state.pad.rumble_triggers(0, 0, RUMBLE_DURATION_MS); + } mark_removed(state); } +fn teardown(open_pads: &mut HashMap) { + for (_, state) in open_pads.drain() { + stop_and_drop(state); + } +} + /// The SDL thread's command loop. fn sdl_thread_loop( task_tx: SdlTaskHandle, @@ -381,19 +435,37 @@ fn sdl_thread_loop( // Refresh any non-zero rumble whose re-arm deadline has arrived. Errors // are treated as device loss: mark removed and drop the pad. - let mut rumbles_to_refresh: Vec<(JoystickId, (u16, u16))> = Vec::new(); + let mut rumbles_to_refresh = Vec::new(); for (id, state) in open_pads.iter() { - if let Some(cmd) = refresh_decision(state.last_rumble, state.last_set_at, now) { - rumbles_to_refresh.push((*id, cmd)); + if let Some(cmd) = refresh_decision(state.last_main, state.main_set_at, now) { + rumbles_to_refresh.push((*id, false, cmd)); + } + if let Some(cmd) = refresh_decision(state.last_triggers, state.triggers_set_at, now) { + rumbles_to_refresh.push((*id, true, cmd)); } } - for (id, (low, high)) in rumbles_to_refresh { + for (id, triggers, (low, high)) in rumbles_to_refresh { let Some(state) = open_pads.get_mut(&id) else { continue; }; - match state.pad.rumble(low, high, RUMBLE_DURATION_MS) { + let result = if triggers { + if !state.pad.has_rumble_triggers() { + continue; + } + state.pad.rumble_triggers(low, high, RUMBLE_DURATION_MS) + } else { + if !state.pad.has_rumble() { + continue; + } + state.pad.rumble(low, high, RUMBLE_DURATION_MS) + }; + match result { Ok(()) => { - state.last_set_at = now; + if triggers { + state.triggers_set_at = now; + } else { + state.main_set_at = now; + } } Err(e) => { warn!("SDL gamepad {} rumble refresh failed: {}", id.0, e); @@ -407,6 +479,12 @@ fn sdl_thread_loop( // Wait for the next command (or wake timeout), then handle it. match cmd_rx.recv_timeout(Duration::from_millis(COMMAND_WAKE_MS)) { Ok(cmd) => match cmd { + #[cfg(test)] + SdlCommand::Shutdown { reply } => { + teardown(&mut open_pads); + let _ = reply.send(()); + break; + } SdlCommand::Scan { reply } => { let result = driver.enumerate().map_err(|e| { warn!("SDL gamepad enumeration failed: {}", e); @@ -423,25 +501,47 @@ fn sdl_thread_loop( // deliberately does not host. Skip wired pads so no dead // devices appear; Bluetooth pads work fully. Users with a // wired controller can pair the same pad via Bluetooth. - #[cfg(target_os = "macos")] - { - if !open_pads.contains_key(&id) { - let wired = match driver.open(id) { - // The probe handle drops immediately, closing it again. - Ok(pad) => pad.connection_state() == DriverConnection::Wired, - Err(_) => false, - }; - if wired { - warn!( - "Skipping wired SDL gamepad {} on macOS: wired rumble is not possible without GCController (pair the controller via Bluetooth instead).", - id.0 - ); + let capabilities = if let Some(state) = open_pads.get(&id) { + SdlRumbleCapabilities { + rumble: state.pad.has_rumble(), + trigger_rumble: state.pad.has_rumble_triggers(), + } + } else { + let pad = match driver.open(id) { + Ok(pad) => pad, + Err(e) => { + warn!("SDL gamepad {} probe open failed: {}", id.0, e); return None; } + }; + let connection = pad.connection_state(); + #[cfg(target_os = "macos")] + if connection == DriverConnection::Wired { + warn!( + "Skipping wired SDL gamepad {} on macOS: wired rumble is not possible without GCController (pair the controller via Bluetooth instead).", + id.0 + ); + return None; } - } + #[cfg(not(target_os = "macos"))] + let _ = connection; + let capabilities = SdlRumbleCapabilities { + rumble: pad.has_rumble(), + trigger_rumble: pad.has_rumble_triggers(), + }; + drop(pad); + if !capabilities.any() { + info!("SDL gamepad {} has no rumble capability, skipping", id.0); + return None; + } + capabilities + }; let name = match driver.name_for_id(id) { - Ok(name) => name, + Ok(name) if !name.trim().is_empty() => name, + Ok(_) => { + warn!("SDL gamepad {} name lookup returned an empty name", id.0); + format!("SDL Gamepad {}", id.0) + }, Err(e) => { // A failed name lookup never drops the device: log and // fall back to a deterministic name. @@ -449,7 +549,7 @@ fn sdl_thread_loop( format!("SDL Gamepad {}", id.0) } }; - Some(SdlGamepadDesc { id, name }) + Some(SdlGamepadDesc { id, name, capabilities }) }) .collect::>() }); @@ -465,6 +565,15 @@ fn sdl_thread_loop( } match driver.open(id) { Ok(pad) => { + let capabilities = SdlRumbleCapabilities { + rumble: pad.has_rumble(), + trigger_rumble: pad.has_rumble_triggers(), + }; + if !capabilities.any() { + drop(pad); + let _ = reply.send(Err(SdlTaskError::NoRumbleCapability(id))); + continue; + } next_generation += 1; let generation = next_generation; let (removed_tx, removed_rx) = watch::channel(false); @@ -474,8 +583,10 @@ fn sdl_thread_loop( pad, generation, removed_tx, - last_rumble: (0, 0), - last_set_at: now, + last_main: (0, 0), + main_set_at: now, + last_triggers: (0, 0), + triggers_set_at: now, }, ); let handle = SdlOpenedGamepadHandle { @@ -484,7 +595,7 @@ fn sdl_thread_loop( task: task_tx.clone(), removed_rx, }; - if reply.send(Ok(handle)).is_err() { + if reply.send(Ok((handle, capabilities))).is_err() { // The connect waiter is gone (future cancelled): nobody can // ever command or close this pad. Drop the lease now instead // of blocking future opens with AlreadyOpen until the device @@ -499,11 +610,10 @@ fn sdl_thread_loop( } } } - SdlCommand::Rumble { + SdlCommand::SetRumbleState { id, generation, - low, - high, + state: desired, duration, reply, } => { @@ -516,15 +626,37 @@ fn sdl_thread_loop( let _ = reply.send(Err(SdlTaskError::Removed(id))); continue; } - let reply_value = state - .pad - .rumble(low, high, duration) - .map_err(|e| SdlTaskError::Rumble(e)); - if reply_value.is_ok() { - state.last_rumble = (low, high); - state.last_set_at = clock.now_ms(); + let now = clock.now_ms(); + let mut errors = Vec::new(); + if state.pad.has_rumble() { + match state.pad.rumble(desired.low, desired.high, duration) { + Ok(()) => { + state.last_main = (desired.low, desired.high); + state.main_set_at = now; + } + Err(e) => errors.push(format!("main: {e}")), + } + } + if state.pad.has_rumble_triggers() { + match state + .pad + .rumble_triggers(desired.left_trigger, desired.right_trigger, duration) + { + Ok(()) => { + state.last_triggers = (desired.left_trigger, desired.right_trigger); + state.triggers_set_at = now; + } + Err(e) => errors.push(format!("triggers: {e}")), + } + } + if errors.is_empty() { + let _ = reply.send(Ok(())); + } else { + let _ = reply.send(Err(SdlTaskError::Rumble(errors.join("; ")))); + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } } - let _ = reply.send(reply_value); } SdlCommand::Close { id, @@ -548,11 +680,8 @@ fn sdl_thread_loop( // Plain wake; periodic work will be re-checked at the top of the loop. } Err(mpsc::RecvTimeoutError::Disconnected) => { - // All handles dropped; shut the thread (and SDL context) down. info!("SDL gamepad thread command channel closed; exiting."); - for (_, state) in open_pads.drain() { - stop_and_drop(state); - } + teardown(&mut open_pads); break; } } @@ -568,6 +697,27 @@ pub(crate) fn spawn_sdl_task( factory: F, clock: Box, ) -> Result +where + F: FnOnce() -> Result, SdlTaskInitError> + Send + 'static, +{ + spawn_sdl_task_inner(factory, clock).map(|(handle, _join)| handle) +} + +#[cfg(test)] +fn spawn_sdl_task_with_join( + factory: F, + clock: Box, +) -> Result<(SdlTaskHandle, std::thread::JoinHandle<()>), SdlTaskInitError> +where + F: FnOnce() -> Result, SdlTaskInitError> + Send + 'static, +{ + spawn_sdl_task_inner(factory, clock) +} + +fn spawn_sdl_task_inner( + factory: F, + clock: Box, +) -> Result<(SdlTaskHandle, std::thread::JoinHandle<()>), SdlTaskInitError> where F: FnOnce() -> Result, SdlTaskInitError> + Send + 'static, { @@ -576,7 +726,7 @@ where let loop_tx = SdlTaskHandle { cmd_tx: cmd_tx.clone(), }; - std::thread::Builder::new() + let join = std::thread::Builder::new() .name("buttplug-sdl-gamepad".to_string()) .spawn(move || { let driver = match factory() { @@ -599,7 +749,7 @@ where .recv() .map_err(|_| SdlTaskInitError("SDL thread exited before initialization".to_owned()))? .map_err(|e| e)?; - Ok(SdlTaskHandle { cmd_tx }) + Ok((SdlTaskHandle { cmd_tx }, join)) } // --------------------------------------------------------------------------- @@ -636,6 +786,27 @@ struct Sdl3Gamepad { } impl DriverGamepad for Sdl3Gamepad { + fn has_rumble(&self) -> bool { + // SAFETY: Pure property-table read of this opened gamepad, exclusively + // owned by this SDL thread, so no concurrent SDL access is possible. + // Missing properties resolve to false; this does not activate motors. + unsafe { self.pad.has_rumble() } + } + + fn has_rumble_triggers(&self) -> bool { + // SAFETY: Pure property-table read of this opened gamepad, exclusively + // owned by this SDL thread, so no concurrent SDL access is possible. + // Missing properties resolve to false; this does not activate motors. + unsafe { self.pad.has_rumble_triggers() } + } + + fn rumble_triggers(&mut self, left: u16, right: u16, duration_ms: u32) -> Result<(), String> { + self + .pad + .set_rumble_triggers(left, right, duration_ms) + .map_err(|e| e.to_string()) + } + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String> { self .pad @@ -740,7 +911,11 @@ use async_trait::async_trait; /// dependency. Production wraps [`SdlOpenedGamepadHandle`]. #[async_trait] pub(crate) trait SdlOpenedGamepad: Send + Sync + std::fmt::Debug { - async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError>; + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError>; async fn close(&self) -> Result<(), SdlTaskError>; /// Fire-and-forget close usable from synchronous contexts (e.g. `Drop`). fn close_now(&self); @@ -759,7 +934,10 @@ pub(crate) trait SdlGamepadBackend: Send + Sync { /// Whether the underlying SDL task initialized successfully. fn initialized(&self) -> bool; async fn gamepads(&self) -> Result, SdlTaskError>; - async fn open(&self, id: JoystickId) -> Result, SdlTaskError>; + async fn open( + &self, + id: JoystickId, + ) -> Result<(Arc, SdlRumbleCapabilities), SdlTaskError>; } /// Production opened-gamepad wrapper over the task handle. @@ -770,8 +948,12 @@ struct TaskOpenedGamepad { #[async_trait] impl SdlOpenedGamepad for TaskOpenedGamepad { - async fn rumble(&self, low: u16, high: u16, duration_ms: u32) -> Result<(), SdlTaskError> { - self.handle.rumble(low, high, duration_ms).await + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + self.handle.set_rumble_state(state, duration_ms).await } async fn close(&self) -> Result<(), SdlTaskError> { @@ -813,11 +995,15 @@ impl SdlGamepadBackend for SdlTaskBackend { } } - async fn open(&self, id: JoystickId) -> Result, SdlTaskError> { + async fn open( + &self, + id: JoystickId, + ) -> Result<(Arc, SdlRumbleCapabilities), SdlTaskError> { match self.publication { - Ok(handle) => Ok(Arc::new(TaskOpenedGamepad { - handle: handle.open(id).await?, - })), + Ok(handle) => { + let (handle, capabilities) = handle.open(id).await?; + Ok((Arc::new(TaskOpenedGamepad { handle }), capabilities)) + } Err(e) => Err(SdlTaskError::Init(e.to_string())), } } @@ -845,6 +1031,12 @@ mod tests { // Log of (id, low, high, duration) rumble calls. rumble_log: Vec<(JoystickId, u16, u16, u32)>, rumble_fail: bool, + trigger_rumble_fail: bool, + rumble_caps: HashMap, + name_override: HashMap, + rumble_attempts: Vec<(JoystickId, u16, u16, u32)>, + trigger_rumble_attempts: Vec<(JoystickId, u16, u16, u32)>, + trigger_rumble_log: Vec<(JoystickId, u16, u16, u32)>, wired_ids: Vec, } @@ -856,8 +1048,47 @@ mod tests { } impl DriverGamepad for FakeGamepad { + fn has_rumble(&self) -> bool { + self + .state + .lock() + .unwrap() + .rumble_caps + .get(&self.id) + .map(|caps| caps.rumble) + .unwrap_or(true) + } + + fn has_rumble_triggers(&self) -> bool { + self + .state + .lock() + .unwrap() + .rumble_caps + .get(&self.id) + .map(|caps| caps.trigger_rumble) + .unwrap_or(false) + } + + fn rumble_triggers(&mut self, left: u16, right: u16, duration_ms: u32) -> Result<(), String> { + let mut state = self.state.lock().unwrap(); + state + .trigger_rumble_attempts + .push((self.id, left, right, duration_ms)); + if state.trigger_rumble_fail { + return Err("trigger rumble failed".to_owned()); + } + state + .trigger_rumble_log + .push((self.id, left, right, duration_ms)); + Ok(()) + } + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String> { let mut state = self.state.lock().unwrap(); + state + .rumble_attempts + .push((self.id, low, high, duration_ms)); if state.rumble_fail { return Err("rumble failed".to_owned()); } @@ -900,7 +1131,13 @@ mod tests { if state.name_fail_ids.contains(&id) { Err("name lookup failed".to_owned()) } else { - Ok(format!("SDL Fake Pad {}", id.0)) + Ok( + state + .name_override + .get(&id) + .cloned() + .unwrap_or_else(|| format!("SDL Fake Pad {}", id.0)), + ) } } @@ -948,6 +1185,345 @@ mod tests { joystick_id(n) } + fn caps(rumble: bool, trigger_rumble: bool) -> SdlRumbleCapabilities { + SdlRumbleCapabilities { + rumble, + trigger_rumble, + } + } + + async fn barrier(handle: &SdlTaskHandle) { + // Two commands guarantee a loop-top periodic pass after the clock change. + handle.scan().await.unwrap(); + handle.scan().await.unwrap(); + } + + #[tokio::test] + async fn sdl_scan_capability_matrix() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1), id(2), id(3), id(4)], + rumble_caps: HashMap::from([ + (id(1), caps(true, false)), + (id(2), caps(false, true)), + (id(3), caps(true, true)), + (id(4), caps(false, false)), + ]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + let found = handle.scan().await.unwrap(); + assert_eq!( + found + .iter() + .map(|pad| (pad.id, pad.capabilities)) + .collect::>(), + vec![ + (id(1), caps(true, false)), + (id(2), caps(false, true)), + (id(3), caps(true, true)) + ] + ); + assert!(state.lock().unwrap().rumble_attempts.is_empty()); + assert!(state.lock().unwrap().trigger_rumble_attempts.is_empty()); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_probe_failure_retries() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1)], + open_fail_ids: vec![id(1)], + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + assert!(handle.scan().await.unwrap().is_empty()); + state.lock().unwrap().open_fail_ids.clear(); + assert_eq!(handle.scan().await.unwrap().len(), 1); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_connect_rechecks_capabilities() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1)], + rumble_caps: HashMap::from([(id(1), caps(true, true))]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + assert_eq!( + handle.scan().await.unwrap()[0].capabilities, + caps(true, true) + ); + state + .lock() + .unwrap() + .rumble_caps + .insert(id(1), caps(true, false)); + let (opened, actual) = handle.open(id(1)).await.unwrap(); + assert_eq!(actual, caps(true, false)); + opened.close().await.unwrap(); + state + .lock() + .unwrap() + .rumble_caps + .insert(id(1), caps(false, false)); + assert!(matches!( + handle.open(id(1)).await, + Err(SdlTaskError::NoRumbleCapability(_)) + )); + state + .lock() + .unwrap() + .rumble_caps + .insert(id(1), caps(false, true)); + assert_eq!(handle.open(id(1)).await.unwrap().1, caps(false, true)); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_name_fallback_matrix() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1), id(2), id(3)], + name_fail_ids: vec![id(2)], + name_override: HashMap::from([ + (id(1), " Valid Pad ".to_owned()), + (id(3), " \t ".to_owned()), + ]), + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + assert_eq!( + handle + .scan() + .await + .unwrap() + .iter() + .map(|p| p.name.as_str()) + .collect::>(), + vec![" Valid Pad ", "SDL Gamepad 2", "SDL Gamepad 3"] + ); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_lifecycle_pair_matrix() { + for capability in [caps(true, false), caps(false, true), caps(true, true)] { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), capability)]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + let (opened, _) = handle.open(id(1)).await.unwrap(); + opened + .set_rumble_state( + SdlRumbleState { + low: 500, + right_trigger: 700, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .unwrap(); + opened + .set_rumble_state(SdlRumbleState::default(), RUMBLE_DURATION_MS) + .await + .unwrap(); + opened.close().await.unwrap(); + { + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.len(), + if capability.rumble { 3 } else { 0 } + ); + assert_eq!( + state.trigger_rumble_attempts.len(), + if capability.trigger_rumble { 3 } else { 0 } + ); + if capability.rumble { + assert_eq!( + state.rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + } + if capability.trigger_rumble { + assert_eq!( + state.trigger_rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + } + } + handle.shutdown().await.unwrap(); + } + } + + #[tokio::test] + async fn sdl_keepalive_pair_matrix() { + for capability in [caps(true, false), caps(false, true), caps(true, true)] { + for active_triggers in [false, true] { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), capability)]), + ..Default::default() + })); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + let (opened, _) = handle.open(id(1)).await.unwrap(); + let desired = SdlRumbleState { + low: if active_triggers { 0 } else { 500 }, + right_trigger: if active_triggers { 700 } else { 0 }, + ..Default::default() + }; + opened + .set_rumble_state(desired, RUMBLE_DURATION_MS) + .await + .unwrap(); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS + 1); + barrier(&handle).await; + barrier(&handle).await; + { + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.len(), + if capability.rumble { + if active_triggers { 1 } else { 2 } + } else { + 0 + } + ); + assert_eq!( + state.trigger_rumble_attempts.len(), + if capability.trigger_rumble { + if active_triggers { 2 } else { 1 } + } else { + 0 + } + ); + } + handle.shutdown().await.unwrap(); + } + } + } + + #[tokio::test] + async fn sdl_pair_failure_cleanup() { + for main_failure in [false, true] { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), caps(true, true))]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + let (opened, _) = handle.open(id(1)).await.unwrap(); + let removed = opened.removed(); + let desired = SdlRumbleState { + low: 500, + right_trigger: 700, + ..Default::default() + }; + opened + .set_rumble_state(desired, RUMBLE_DURATION_MS) + .await + .unwrap(); + { + let mut state = state.lock().unwrap(); + state.rumble_fail = main_failure; + state.trigger_rumble_fail = !main_failure; + } + assert!(matches!( + opened.set_rumble_state(desired, RUMBLE_DURATION_MS).await, + Err(SdlTaskError::Rumble(_)) + )); + barrier(&handle).await; + assert!(*removed.borrow()); + assert!(matches!( + opened.set_rumble_state(desired, RUMBLE_DURATION_MS).await, + Err(SdlTaskError::Removed(_)) + )); + { + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!( + state.trigger_rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!(state.rumble_attempts.len(), 3); + assert_eq!(state.trigger_rumble_attempts.len(), 3); + } + handle.shutdown().await.unwrap(); + } + } + + async fn shutdown_case(main_failure: bool) { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), caps(true, true))]), + ..Default::default() + })); + let driver_state = state.clone(); + let (handle, join) = spawn_sdl_task_with_join( + move || Ok(Box::new(FakeDriver(driver_state))), + Box::new(FakeClock::default()), + ) + .unwrap(); + let (opened, _) = handle.open(id(1)).await.unwrap(); + let removed = opened.removed(); + opened + .set_rumble_state( + SdlRumbleState { + low: 500, + right_trigger: 700, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .unwrap(); + state.lock().unwrap().rumble_fail = main_failure; + tokio::time::timeout(Duration::from_secs(5), handle.shutdown()) + .await + .unwrap() + .unwrap(); + tokio::time::timeout( + Duration::from_secs(5), + tokio::task::spawn_blocking(move || join.join()), + ) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(*removed.borrow()); + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!( + state.trigger_rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!( + state.trigger_rumble_log.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + if !main_failure { + assert_eq!( + state.rumble_log.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + } + } + + #[tokio::test] + async fn sdl_task_shutdown_stops_both_pairs() { + shutdown_case(true).await; + } + + #[tokio::test] + async fn sdl_task_shutdown_successful_teardown() { + shutdown_case(false).await; + } + // ------------------------------------------------------------------- // Scan / name policy // ------------------------------------------------------------------- @@ -1037,7 +1613,7 @@ mod tests { let state = Arc::new(Mutex::new(FakeDriverState::default())); let handle = spawn_fake(state, FakeClock::default()); - let opened = handle.open(id(4)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(4)).await.expect("open should succeed"); let removed = opened.removed(); opened.close().await.expect("close should succeed"); assert!(*removed.borrow()); @@ -1063,7 +1639,16 @@ mod tests { handle.close(id(5), 1).await.expect("close should succeed"); let err = handle - .rumble(id(5), 0, 100, 100, RUMBLE_DURATION_MS) + .set_rumble_state( + id(5), + 0, + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect_err("rumble after close should fail"); assert!( @@ -1080,9 +1665,16 @@ mod tests { // Explicit close while rumbling emits a zero-speed stop before the pad // is dropped, so hardware does not vibrate out the remaining arm period. - let opened = handle.open(id(13)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(13)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("rumble should succeed"); opened.close().await.expect("close should succeed"); @@ -1095,9 +1687,16 @@ mod tests { ); // Connected-state removal while rumbling stops too. - let opened = handle.open(id(14)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(14)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("rumble should succeed"); state.lock().unwrap().connected.insert(id(14), false); @@ -1138,33 +1737,65 @@ mod tests { } #[tokio::test] - async fn sdl_task_stale_generation_is_inert() { - let state = Arc::new(Mutex::new(FakeDriverState::default())); + async fn sdl_stale_generation_pair_isolation() { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(15), caps(true, true))]), + ..Default::default() + })); let handle = spawn_fake(state.clone(), FakeClock::default()); // First lease: open, rumble, close (device stays connected). - let stale = handle.open(id(15)).await.expect("open should succeed"); + let (stale, _) = handle.open(id(15)).await.expect("open should succeed"); stale - .rumble(100, 100, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("rumble should succeed"); stale.close().await.expect("close should succeed"); let log_len_after_first_lease = state.lock().unwrap().rumble_log.len(); // Second lease for the same still-connected id. - let fresh = handle.open(id(15)).await.expect("reopen should succeed"); + let (fresh, _) = handle.open(id(15)).await.expect("reopen should succeed"); // Stale-handle rumble is rejected... let err = stale - .rumble(1, 1, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 1, + high: 1, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect_err("stale rumble must fail"); assert!(matches!(err, SdlTaskError::Removed(_)), "got {err:?}"); // ...stale close is an Ok no-op that must NOT tear down the new lease... stale.close().await.expect("stale close is a no-op ok"); + assert_eq!( + state.lock().unwrap().rumble_attempts.len(), + log_len_after_first_lease + ); + assert_eq!( + state.lock().unwrap().trigger_rumble_attempts.len(), + log_len_after_first_lease + ); // ...and the fresh lease still works. fresh - .rumble(50, 50, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 50, + high: 50, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("fresh lease rumble should succeed"); @@ -1185,7 +1816,7 @@ mod tests { let clock = FakeClock::default(); let handle = spawn_fake(state.clone(), clock.clone()); - let opened = handle.open(id(6)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(6)).await.expect("open should succeed"); let mut removed = opened.removed(); // Flip the device to disconnected, then advance the clock past the poll @@ -1212,7 +1843,16 @@ mod tests { // After removal, rumble reports the typed Removed error, and close stays // idempotent. let err = handle - .rumble(id(6), 0, 1, 1, RUMBLE_DURATION_MS) + .set_rumble_state( + id(6), + 0, + SdlRumbleState { + low: 1, + high: 1, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect_err("rumble after removal should fail"); assert!(matches!(err, SdlTaskError::Removed(_))); @@ -1255,9 +1895,16 @@ mod tests { let clock = FakeClock::default(); let handle = spawn_fake(state.clone(), clock.clone()); - let opened = handle.open(id(7)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(7)).await.expect("open should succeed"); opened - .rumble(0x8000, 0x7fff, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 0x8000, + high: 0x7fff, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("initial rumble should succeed"); assert_eq!( @@ -1299,13 +1946,20 @@ mod tests { let state = Arc::new(Mutex::new(FakeDriverState::default())); let clock = FakeClock::default(); let handle = spawn_fake(state.clone(), clock.clone()); - let opened = handle.open(id(10)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(10)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("rumble should succeed"); opened - .rumble(0, 0, RUMBLE_DURATION_MS) + .set_rumble_state(SdlRumbleState::default(), RUMBLE_DURATION_MS) .await .expect("zero rumble should succeed"); assert_eq!(state.lock().unwrap().rumble_log.len(), 2); @@ -1329,9 +1983,16 @@ mod tests { let state = Arc::new(Mutex::new(FakeDriverState::default())); let clock = FakeClock::default(); let handle = spawn_fake(state.clone(), clock.clone()); - let opened = handle.open(id(11)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(11)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("rumble should succeed"); opened.close().await.expect("close should succeed"); @@ -1353,9 +2014,16 @@ mod tests { let state = Arc::new(Mutex::new(FakeDriverState::default())); let clock = FakeClock::default(); let handle = spawn_fake(state.clone(), clock.clone()); - let opened = handle.open(id(12)).await.expect("open should succeed"); + let (opened, _) = handle.open(id(12)).await.expect("open should succeed"); opened - .rumble(100, 100, RUMBLE_DURATION_MS) + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) .await .expect("rumble should succeed"); state.lock().unwrap().connected.insert(id(12), false); From 657b85822bcbf8bd360e61bda24fb36f2b4f2912 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 14:57:02 -0700 Subject: [PATCH 14/16] Add SDL layout integration coverage, diagnostics, and docs Integration harness: test devices can carry an explicit SDL base selection that flows through the production definition-selection path, so fixtures exercise real capability-driven layout selection rather than prebuilt definitions. Fixtures cover main-only, main+trigger, and trigger-only layouts with eight-byte packet assertions, plus a disabled-channel scenario proving filtered features keep their logical channel slots and receive no commands. Named regressions assert harness metadata propagation, v4 advertised feature order and descriptions straight from the client device, v3 advertisement, and per-channel command routing. Diagnostics: the sdl3_thread_spike example prints both capability booleans alongside name and connection state; it remains purely non-actuating. Docs: the crate README documents the three layouts, capability-based per-connection selection, skip-and-retry policy for output-less devices, naming fallback, connection-scoped identity, settings reconciliation (including that removed channels lose customizations), internal eight-byte framing, simple trigger rumble versus adaptive resistance, and a manual hardware release checklist. A sdl_docs_contract_check test pins the documented contract to the implementation. --- .../README.md | 130 +++++++++++----- .../src/sdl_task.rs | 21 +-- .../tests/sdl_docs_contract_check.rs | 70 +++++++++ .../tests/test_device_protocols.rs | 142 ++++++++++++++++++ .../util/device_test/client/client_v4/mod.rs | 4 +- .../config/sdl_gamepad_disabled_channel.json | 44 ++++++ .../device_test_case/test_sdl_gamepad.yaml | 8 +- .../test_sdl_gamepad_disabled_channel.yaml | 47 ++++++ .../test_sdl_gamepad_main_trigger.yaml | 58 +++++++ .../test_sdl_gamepad_triggers_only.yaml | 32 ++++ .../util/test_device_manager/test_device.rs | 29 +++- .../test_device_comm_manager.rs | 4 + 12 files changed, 534 insertions(+), 55 deletions(-) create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml create mode 100644 crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md index be73b633b..4646716a1 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/README.md +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -3,42 +3,72 @@ Cross-platform (Windows/macOS/Linux) gamepad rumble hardware manager for [Buttplug](https://buttplug.io), built on SDL3 via the `sdl3` Rust crate. -Gamepads appear as Buttplug devices with two 0-65535 vibrate features (low and -high frequency rumble motors), identified by the `sdl-gamepad` protocol. Each -device is addressed by its SDL3 instance ID (`sdl-gamepad-{instance_id}`), -which is stable for the lifetime of the connection. - -This manager is **opt-in everywhere**: - -- In `intiface-engine`, pass `--use-sdl-gamepad`. -- In `buttplug_client_in_process`, enable the non-default - `sdl-gamepad-manager` cargo feature. +Gamepads appear as Buttplug devices using the `sdl-gamepad` protocol. Each +connection exposes one of these logical channel layouts, selected from SDL's +reported capabilities (which can vary by operating system and transport), not +from a device name or model: + +- **Main-only:** Low-frequency rumble and High-frequency rumble (the classic +'two-channel' layout). +- **Trigger-only:** Left-trigger rumble and Right-trigger rumble at visible + indexes 0 and 1. +- **Both:** all four channels in the order above. + +Each channel is a vibrate feature with a range of 0-65535. A device reporting +neither main rumble nor trigger rumble is skipped. Capability probes are pure +property queries; the device is not permanently excluded, and a later scan +retries it. + +Names come from SDL's `name_for_id` lookup. If lookup fails or returns only +whitespace, the deterministic fallback is `SDL Gamepad {instance_id}`. The +Buttplug address is based on the SDL instance ID. Identity is connection-scoped: +an instance ID and reported name can change after reconnecting, so settings +follow the connection-scoped identity rather than guaranteed physical-hardware +identity. Layout selection is also per connection; changed capabilities take +effect on the next connection. + +When a layout changes, surviving channels retain their user UUIDs, limits, +disabled state, and display-name overrides. Channels removed by the layout +change lose their customizations permanently; if those channels reappear on a +later connection, they start with defaults. Feature descriptions come from the +device configuration on load and are not serialized in saved user configs. ## How it works A single process-lifetime thread owns the SDL3 context. All gamepads are multiplexed through it: discovery is on-demand `SDL_GetGamepads` enumeration and removal detection is per-device connected-state polling. The thread never -pumps SDL events (SDL3 documents `SDL_PumpEvents` as main-thread-only, and -this manager does not consume controller input). - -Rumble is armed with a finite duration and refreshed by the SDL thread before -expiry, so one-shot ScalarCmd commands hold indefinitely. The sdl3 crate -documents that `u32::MAX` durations overflow and end the effect immediately, -so infinite durations are never used. +pumps SDL events (SDL3 documents `SDL_PumpEvents` as main-thread-only, and this +manager does not consume controller input). + +Internally, the protocol-to-hardware packet is 8 bytes: four little-endian +`u16` logical slots in fixed order `[low, high, left_trigger, right_trigger]`. +This is an internal transport detail, not a public wire-protocol change. Only +capability-supported pairs are dispatched to SDL, including zero, stop, and +keepalive commands. + +Rumble is armed with a finite duration because the `sdl3` crate documents that +`u32::MAX` durations overflow and end the effect immediately. The ownership +thread refreshes each active main or trigger pair independently every second +before expiry; zero pairs stop refreshing. This keepalive makes one-shot +commands remain active on controllers that otherwise stop rumbling after a few +seconds. + +Trigger output here is simple SDL trigger rumble via +`SDL_RumbleGamepadTriggers` (currently Xbox-One-class support). It is not +adaptive-trigger resistance or a resistance/force-feedback control. ## Build prerequisites The `sdl3` dependency uses the `build-from-source-static` feature: SDL3 is -downloaded and built (and statically linked) at crate build time. This -requires `cmake` and a C compiler on the build machine: +downloaded and built (and statically linked) at crate build time. This requires +`cmake` and a C compiler on the build machine: - macOS: Xcode command line tools (`xcode-select --install`) - Linux: `gcc`/`clang` and `cmake` (plus the usual development headers for a headless SDL3 build; on Debian/Ubuntu `build-essential` and `cmake` suffice for the joystick/gamepad subsystem) -- Windows: Visual Studio C++ build tools and `cmake` (both present on GitHub - Actions windows runners) +- Windows: Visual Studio C++ build tools and `cmake` Static linking keeps the single-binary release pipeline unchanged; expect the resulting binary to grow by a few MB. @@ -46,16 +76,32 @@ resulting binary to grow by a few MB. ## Testing without hardware CI runners have no physical gamepads and the `sdl3` crate has no simulation -layer. All buttplug-side behavior (discovery, addressing, command forwarding, -lifecycle) is unit-tested in this crate against mock drivers/backends. The -SDL-thread interior is exercised by the `examples/sdl3_thread_spike.rs` -example (headless init + no-pump enumeration on a spawned thread), which CI -runs on all three operating systems. Real-controller behavior — notably -smooth continuous rumble with the refresh-before-expiry scheme — must be -validated manually on each platform before release; see the manual validation -checklist in the repository's pull request for this feature. Confirmed on -hardware so far: Bluetooth DualSense on macOS discovers and rumbles (with the -one-second keepalive re-arming the effect). +layer. Buttplug-side behavior (discovery, addressing, command forwarding, +lifecycle, and layout selection) is unit-tested in this crate against mock +drivers/backends. The `examples/sdl3_thread_spike.rs` diagnostic prints the +SDL-reported name, connection state, and both capability booleans without +actuating anything. + +## Manual release validation + +On a supported platform and transport, validate both a **main-only** pad and a +**trigger-capable** pad: + +1. Confirm the SDL-derived name (or deterministic fallback) and independent + channels are visible in a current client. +2. Sustain main rumble long enough to cross multiple one-second keepalive + refreshes; confirm it remains active. +3. On the trigger-capable pad, sustain trigger rumble across keepalive refreshes + and confirm left and right trigger channels independently. +4. Send stop commands and confirm both rumble pairs stop. +5. Disconnect the pad and confirm the client receives disconnection. + +Physical checks had **not** been performed as of this change. Automated tests +cannot prove motor behaviour or the exact number of physical actuators. + +Confirmed on hardware so far: Bluetooth DualSense on macOS discovers and rumbles +(with the one-second keepalive re-arming the effect). This pre-existing note +must not be used to infer trigger-rumble support. ## Platform support @@ -65,13 +111,21 @@ one-second keepalive re-arming the effect). hidapi with read-only shortened HID reports, so rumble is impossible that way; working wired rumble requires GCController, whose discovery only fires from a main-thread runloop that this library deliberately does not host. - Wired pads are skipped at scan time with a logged explanation - pair the - same controller via Bluetooth for full support. (A future main-thread - integration could lift this; the limitation is Apple's, and SDL2 shares it.) + Wired pads are skipped at scan time with a logged explanation - pair the same + controller via Bluetooth for full support. (A future main-thread integration + could lift this; the limitation is Apple's, and SDL2 shares it.) ## Coexistence with XInput -On Windows, both the XInput manager and this manager can be enabled at the -same time; the same physical controller may then appear as two Buttplug -devices (once via each manager). `intiface-engine` logs a warning when both -flags are set. Outside Windows, only this manager is available. +On Windows, both the XInput manager and this manager can be enabled at the same +time; the same physical controller may then appear as two Buttplug devices +(once via each manager). `intiface-engine` logs a warning when both flags are +set. Outside Windows, only this manager is available. + +## Registration + +This manager is **opt-in everywhere**: + +- In `intiface-engine`, pass `--use-sdl-gamepad`. +- In `buttplug_client_in_process`, enable the non-default + `sdl-gamepad-manager` cargo feature. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index 5528d0493..d20a3a3fc 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -14,13 +14,15 @@ //! enumeration, and removal detection is per-device connected-state polling. //! //! Gamepads are identified by SDL3 instance ID ([`JoystickId`]), which is -//! stable for the lifetime of a connection. Conversion to buttplug's string -//! address space (`sdl-gamepad-{instance_id}`) happens only at the -//! communication-manager boundary. +//! stable only for the lifetime of a connection. Conversion to buttplug's +//! string address space (`sdl-gamepad-{instance_id}`) happens only at the +//! communication-manager boundary; reconnects may receive a new instance ID +//! and reported name, so identity is connection-scoped. //! //! Rumble is armed with a finite duration (the sdl3 crate documents that -//! `u32::MAX` overflows and ends the effect immediately) and refreshed by the -//! thread before expiry, so one-shot ScalarCmd commands hold indefinitely. +//! `u32::MAX` overflows and ends the effect immediately). Each active main or +//! trigger pair is refreshed independently by the thread before expiry, so +//! one-shot ScalarCmd commands hold indefinitely. use sdl3::joystick::JoystickId; use std::{ @@ -356,11 +358,12 @@ struct OpenPadState { triggers_set_at: u64, } -/// Pure rumble-refresh decision: given the last accepted rumble command, when -/// it was armed, and the current time, decide whether it must be re-armed. +/// Pure rumble-refresh decision for one independent main or trigger pair: +/// given the last accepted command, when it was armed, and the current time, +/// decide whether that pair must be re-armed. /// -/// Zero-speed commands never refresh (the gamepad is stopped; letting the -/// effect lapse is exactly what we want). Non-zero commands re-arm after +/// Zero-speed commands never refresh (the pair is stopped; letting the effect +/// lapse is exactly what we want). Non-zero commands re-arm after /// [`RUMBLE_KEEPALIVE_INTERVAL_MS`], safely before the finite arm duration lapses. fn refresh_decision(last_rumble: (u16, u16), last_set_at: u64, now_ms: u64) -> Option<(u16, u16)> { if last_rumble == (0, 0) { diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs new file mode 100644 index 000000000..64e315e71 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs @@ -0,0 +1,70 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +#[test] +fn sdl_docs_contract_check() { + let text = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")); + let lower = text.to_ascii_lowercase(); + + assert!(lower.contains("main-only"), "missing main-only layout"); + assert!( + lower.contains("trigger-only"), + "missing trigger-only layout" + ); + assert!(lower.contains("both"), "missing both-capabilities layout"); + assert!( + text.contains("SDL Gamepad {instance_id}") || lower.contains("sdl gamepad {instance_id}"), + "missing deterministic fallback name form" + ); + assert!( + lower.contains("neither main rumble nor trigger rumble") && lower.contains("skipped"), + "missing neither-capability skip policy" + ); + assert!( + lower.contains("pure") + && lower.contains("property queries") + && lower.contains("later scan") + && lower.contains("retries"), + "missing retryable property-probe policy" + ); + assert!( + lower.contains("identity is connection-scoped"), + "missing connection-scoped identity limitation" + ); + assert!( + lower.contains("## manual release validation"), + "missing manual release validation section" + ); + assert!( + lower.contains("8 bytes") && lower.contains("four") && lower.contains("logical slots"), + "missing eight-byte four-slot framing" + ); + assert!( + text.contains("--use-sdl-gamepad"), + "missing --use-sdl-gamepad opt-in flag" + ); + assert!( + text.contains("sdl-gamepad-manager"), + "missing sdl-gamepad-manager opt-in feature" + ); + assert!( + lower.contains("simple sdl trigger rumble") + && lower.contains("not\nadaptive-trigger resistance"), + "missing simple-vs-adaptive trigger distinction" + ); + + assert!( + !lower.contains("4 bytes") && !lower.contains("four-byte"), + "README contains stale four-byte protocol framing claim" + ); + assert!( + !lower.contains("every pad has exactly two motors") + && !lower.contains("every gamepad has exactly two motors") + && !lower.contains("all pads have exactly two motors"), + "README claims every SDL pad has exactly two motors" + ); +} diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index afc159159..06d8ad53a 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -7,9 +7,147 @@ mod util; //use buttplug::util::async_manager; +use buttplug_client::{ButtplugClient, ButtplugClientDevice, ButtplugClientEvent}; +use buttplug_core::message::OutputType; +use futures::StreamExt; +use std::time::Duration; use test_case::test_case; use util::device_test::DeviceTestCase; +async fn scan_sdl_case(test_case: &DeviceTestCase) -> (ButtplugClient, ButtplugClientDevice) { + let (server, _channels) = util::device_test::client::client_v4::build_server(test_case); + let client = ButtplugClient::new("SDL advertisement test"); + let mut connector = + buttplug_client_in_process::ButtplugInProcessClientConnectorBuilder::default(); + connector.server(server); + client.connect(connector.finish()).await.unwrap(); + let device = { + let mut events = client.event_stream(); + client.start_scanning().await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(ButtplugClientEvent::DeviceAdded(device)) = events.next().await { + break device; + } + } + }) + .await + .expect("SDL device should be discovered") + }; + (client, device) +} + +#[tokio::test] +async fn sdl_selection_harness_propagates_metadata() { + for (file, expected_count) in [ + ("test_sdl_gamepad_main_trigger.yaml", 4), + ("test_sdl_gamepad.yaml", 2), + ] { + let case = load_test_case(file).await; + let (client, device) = scan_sdl_case(&case).await; + assert_eq!( + device + .device_features() + .values() + .filter(|f| f.feature().contains_output(OutputType::Vibrate)) + .count(), + expected_count + ); + client.disconnect().await.unwrap(); + } +} + +#[tokio::test] +async fn sdl_advertised_definition_v4() { + let case = load_test_case("test_sdl_gamepad_main_trigger.yaml").await; + let (client, device) = scan_sdl_case(&case).await; + let features: Vec<_> = device + .device_features() + .values() + .filter(|f| f.feature().contains_output(OutputType::Vibrate)) + .collect(); + assert_eq!( + features + .iter() + .map(|f| f.feature_index()) + .collect::>(), + vec![0, 1, 2, 3] + ); + assert_eq!( + features + .iter() + .map(|f| f.feature().description().as_str()) + .collect::>(), + vec![ + "Low-frequency rumble", + "High-frequency rumble", + "Left-trigger rumble", + "Right-trigger rumble", + ] + ); + client.disconnect().await.unwrap(); +} + +#[tokio::test] +async fn sdl_advertised_definition_v3() { + use util::device_test::client::client_v3::{client, connector}; + let case = load_test_case("test_sdl_gamepad_main_trigger.yaml").await; + let (server, _channels) = util::device_test::client::client_v4::build_server(&case); + let (client, receiver) = client::ButtplugClient::new("SDL v3 advertisement test"); + let mut connector = connector::ButtplugInProcessClientConnectorBuilder::default(); + connector.server(server); + client.connect(connector.finish(), receiver).await.unwrap(); + let mut events = client.event_stream(); + client.start_scanning().await.unwrap(); + let device = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(client::ButtplugClientEvent::DeviceAdded(device)) = events.next().await { + break device; + } + } + }) + .await + .expect("SDL v3 device should be discovered"); + assert_eq!(device.name(), "sdl-gamepad"); + let attributes = device.scalar_attributes(); + assert_eq!( + attributes.iter().map(|a| *a.index()).collect::>(), + vec![0, 1, 2, 3] + ); + assert!( + attributes + .iter() + .all(|a| *a.actuator_type() == OutputType::Vibrate) + ); + client.disconnect().await.unwrap(); + util::device_test::client::client_v3::run_embedded_test_case(&case).await; +} + +#[tokio::test] +async fn sdl_client_channel_routing() { + let case = load_test_case("test_sdl_gamepad_disabled_channel.yaml").await; + let (client, device) = scan_sdl_case(&case).await; + let features: Vec<_> = device + .device_features() + .values() + .filter(|f| f.feature().contains_output(OutputType::Vibrate)) + .collect(); + assert_eq!(features.len(), 3); + assert_eq!( + features + .iter() + .map(|f| f.feature().description().as_str()) + .collect::>(), + vec![ + "Low-frequency rumble", + "Left-trigger rumble", + "Right-trigger rumble", + ] + ); + client.disconnect().await.unwrap(); + util::device_test::client::client_v4::run_embedded_test_case(&case).await; +} + async fn load_test_case(test_file: &str) -> DeviceTestCase { // Load the file list from the test cases directory let test_file_path = @@ -151,6 +289,8 @@ async fn load_test_case(test_file: &str) -> DeviceTestCase { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_sdl_gamepad_main_trigger.yaml" ; "SDL Gamepad Main And Triggers")] +#[test_case("test_sdl_gamepad_triggers_only.yaml" ; "SDL Gamepad Triggers Only")] #[tokio::test] async fn test_device_protocols_embedded_v4(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -416,6 +556,8 @@ async fn test_device_protocols_json_v4(test_file: &str) { #[test_case("test_xuanhuan_protocol.yaml" ; "Xuanhuan Protocol")] #[test_case("test_yiciyuan_protocol.yaml" ; "Yiciyuan Protocol")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_sdl_gamepad_main_trigger.yaml" ; "SDL Gamepad Main And Triggers")] +#[test_case("test_sdl_gamepad_triggers_only.yaml" ; "SDL Gamepad Triggers Only")] #[tokio::test] async fn test_device_protocols_embedded_v3(test_file: &str) { //tracing_subscriber::fmt::init(); diff --git a/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs index d624ea086..63bab0826 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs @@ -166,7 +166,9 @@ async fn run_test_client_command(command: &TestClientCommand, device: &ButtplugC } } -fn build_server(test_case: &DeviceTestCase) -> (ButtplugServer, Vec) { +pub(crate) fn build_server( + test_case: &DeviceTestCase, +) -> (ButtplugServer, Vec) { let base_cfg = if let Some(device_config_file) = &test_case.device_config_file { let config_file_path = std::path::Path::new( &std::env::var("CARGO_MANIFEST_DIR").expect("Should have manifest path"), diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json b/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json new file mode 100644 index 000000000..462a6462c --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json @@ -0,0 +1,44 @@ +{ + "version": { "major": 5, "minor": 999 }, + "user_configs": { + "devices": [{ + "identifier": { + "address": "SdlDisabledChannelTest", + "protocol": "sdl-gamepad", + "identifier": "sdl-gamepad" + }, + "config": { + "name": "sdl-gamepad", + "id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", + "base_id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", + "features": [ + { + "description": "Low-frequency rumble", + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "base_id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "output": { "vibrate": { "value": [0, 65535] } } + }, + { + "description": "High-frequency rumble", + "id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "base_id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "output": { "vibrate": { "value": [0, 65535], "disabled": true } } + }, + { + "description": "Left-trigger rumble", + "id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "base_id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "output": { "vibrate": { "value": [0, 65535] } } + }, + { + "description": "Right-trigger rumble", + "id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "base_id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "output": { "vibrate": { "value": [0, 65535] } } + } + ], + "user_config": { "allow": false, "deny": false, "index": 0 } + } + }] + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml index b64a133a6..3dea1fbf2 100644 --- a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml @@ -16,7 +16,7 @@ device_commands: commands: - !Write endpoint: tx - data: [0x00, 0x80, 0x00, 0x00] + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] write_with_response: false # Vibrate high motor (feature 1) at max: 65535 = 0xffff. The packet must # carry BOTH stored speeds (low motor keeps its previous value). @@ -31,7 +31,7 @@ device_commands: commands: - !Write endpoint: tx - data: [0x00, 0x80, 0xff, 0xff] + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00] write_with_response: false # Stop zeroes both motors; the stop path emits one write per feature, each # carrying the full current motor state. @@ -44,9 +44,9 @@ device_commands: commands: - !Write endpoint: tx - data: [0x00, 0x00, 0xff, 0xff] + data: [0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00] write_with_response: false - !Write endpoint: tx - data: [0x00, 0x00, 0x00, 0x00] + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml new file mode 100644 index 000000000..86ea4c26b --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml @@ -0,0 +1,47 @@ +devices: + - identifier: + name: "sdl-gamepad" + address: "SdlDisabledChannelTest" + sdl_selection: "__sdl-rumble-and-triggers" + expected_name: "sdl-gamepad" +user_device_config_file: "sdl_gamepad_disabled_channel.json" +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 0.25 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 2 + Speed: 0.75 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0xc0] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml new file mode 100644 index 000000000..52fb6f035 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml @@ -0,0 +1,58 @@ +devices: + - identifier: + name: "sdl-gamepad" + sdl_selection: "__sdl-rumble-and-triggers" + expected_name: "sdl-gamepad" +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 2 + Speed: 0.25 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x40, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 3 + Speed: 0.75 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x40, 0x00, 0xc0] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml new file mode 100644 index 000000000..a6cf5fcb3 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml @@ -0,0 +1,32 @@ +devices: + - identifier: + name: "sdl-gamepad" + sdl_selection: "__sdl-triggers-only" + expected_name: "sdl-gamepad" +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs index 6c8cb1be3..bb2abe9ce 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs @@ -19,7 +19,12 @@ use buttplug_server::device::hardware::{ HardwareUnsubscribeCmd, HardwareWriteCmd, }; -use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + Endpoint, + ProtocolCommunicationSpecifier, + SDL_PROTOCOL_NAME, +}; use async_trait::async_trait; use dashmap::DashSet; @@ -51,6 +56,7 @@ pub enum TestHardwareEvent { pub struct TestHardwareConnector { specifier: ProtocolCommunicationSpecifier, hardware: Option, + sdl_selection: Option, } impl TestHardwareConnector { @@ -59,8 +65,14 @@ impl TestHardwareConnector { Self { specifier, hardware: Some(hardware), + sdl_selection: None, } } + + pub fn with_sdl_selection(mut self, selection: Option) -> Self { + self.sdl_selection = selection; + self + } } impl Debug for TestHardwareConnector { @@ -80,18 +92,21 @@ impl HardwareConnector for TestHardwareConnector { async fn connect(&mut self) -> Result, ButtplugDeviceError> { Ok(Box::new(TestHardwareSpecializer::new( self.hardware.take().expect("Test"), + self.sdl_selection.take(), ))) } } pub struct TestHardwareSpecializer { hardware: Option, + sdl_selection: Option, } impl TestHardwareSpecializer { - fn new(hardware: TestDevice) -> Self { + fn new(hardware: TestDevice, sdl_selection: Option) -> Self { Self { hardware: Some(hardware), + sdl_selection, } } } @@ -104,6 +119,7 @@ impl HardwareSpecializer for TestHardwareSpecializer { ) -> Result { let mut device = self.hardware.take().expect("Test"); let mut endpoints = vec![]; + let mut definition_selection = None; if let Some(ProtocolCommunicationSpecifier::BluetoothLE(btle)) = specifiers .iter() .find(|x| matches!(x, ProtocolCommunicationSpecifier::BluetoothLE(_))) @@ -121,6 +137,9 @@ impl HardwareSpecializer for TestHardwareSpecializer { // SDL gamepad hardware only exposes the Tx endpoint. device.add_endpoint(&Endpoint::Tx); endpoints.push(Endpoint::Tx); + definition_selection = self.sdl_selection.as_deref().map(|selection| { + DeviceDefinitionSelection::new(SDL_PROTOCOL_NAME, Some(selection), &device.name()) + }); } let hardware = Hardware::new( &device.name(), @@ -131,7 +150,11 @@ impl HardwareSpecializer for TestHardwareSpecializer { false, Box::new(device), ); - Ok(hardware) + Ok(if let Some(selection) = definition_selection { + hardware.with_definition_selection(selection) + } else { + hardware + }) } } diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs index 8f516d29f..2a897247e 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs @@ -54,6 +54,8 @@ pub struct TestDeviceIdentifier { name: String, #[serde(default = "generate_address")] address: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + sdl_selection: Option, } impl TestDeviceIdentifier { @@ -64,6 +66,7 @@ impl TestDeviceIdentifier { Self { name: name.to_owned(), address, + sdl_selection: None, } } @@ -151,6 +154,7 @@ fn new_uninitialized_ble_test_device( }; let hardware = TestDevice::new(&identifier.name, &address, device_channel, fail_disconnect); TestHardwareConnector::new(specifier, hardware) + .with_sdl_selection(identifier.sdl_selection.clone()) } pub struct TestDeviceCommunicationManager { From c5fc5db79d558f6b95e7e8b0a923cc7b2e92c817 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 15:03:53 -0700 Subject: [PATCH 15/16] Bound the shutdown test join without an uncancellable blocking task The shutdown tests waited on the SDL thread join through spawn_blocking inside a tokio timeout, which bounds the wait but cannot cancel the blocking task: a hung thread would still wedge the test runtime after the timeout fired, defeating the bounded-completion guarantee the seam is meant to prove. Poll is_finished on the async timer with a deadline instead, joining only after the thread has actually exited. --- .../src/sdl_task.rs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs index d20a3a3fc..8af3dd211 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -1487,14 +1487,22 @@ mod tests { .await .unwrap() .unwrap(); - tokio::time::timeout( - Duration::from_secs(5), - tokio::task::spawn_blocking(move || join.join()), - ) - .await - .unwrap() - .unwrap() - .unwrap(); + // Bound the join without an uncancellable blocking task: poll + // `is_finished` on the async timer and only call `join` once the thread + // has actually exited, so a hung thread fails the test instead of + // wedging the test runtime. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if join.is_finished() { + join.join().expect("SDL thread should not panic"); + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "SDL thread did not exit after shutdown" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } assert!(*removed.borrow()); let state = state.lock().unwrap(); assert_eq!( From 5958b84628c57deefeb29f139ab2a933b0d3ae35 Mon Sep 17 00:00:00 2001 From: Kyle Machulis Date: Sun, 13 Sep 2026 15:35:55 -0700 Subject: [PATCH 16/16] Regenerate device config after rebase onto origin/dev Merge origin/dev's protocol additions (luvmazer, sexverse-v6, joyhub updates) with the sdl-gamepad definitions in the generated config. --- .../buttplug-device-config-v5.json | 143 +++++++++++++++++- .../device-config/version.yaml | 2 +- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json index dd2dbb182..9ff57006c 100644 --- a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json +++ b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json @@ -1,7 +1,7 @@ { "version": { "major": 5, - "minor": 52 + "minor": 53 }, "protocols": { "activejoy": { @@ -20599,6 +20599,147 @@ "name": "SayberX Device" } }, + "sdl-gamepad": { + "communication": [ + { + "sdl-gamepad": { + "exists": true + } + } + ], + "configurations": [ + { + "features": [ + { + "description": "Low-frequency rumble", + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "High-frequency rumble", + "id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Left-trigger rumble", + "id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "index": 2, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Right-trigger rumble", + "id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "index": 3, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + } + ], + "id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", + "identifier": [ + "__sdl-rumble-and-triggers" + ], + "name": "SDL Gamepad (Rumble and Triggers)", + "protocol_variant": "sdl-rumble-and-triggers" + }, + { + "features": [ + { + "description": "Left-trigger rumble", + "id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Right-trigger rumble", + "id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + } + ], + "id": "d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e", + "identifier": [ + "__sdl-triggers-only" + ], + "name": "SDL Gamepad (Triggers Only)", + "protocol_variant": "sdl-triggers-only" + } + ], + "defaults": { + "features": [ + { + "description": "Low-frequency rumble", + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "High-frequency rumble", + "id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + } + ], + "id": "b35f2adf-16bc-4425-9276-5d191aeaf107", + "name": "SDL Gamepad" + } + }, "sensee": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index d38389c90..0b6afabee 100644 --- a/crates/buttplug_server_device_config/device-config/version.yaml +++ b/crates/buttplug_server_device_config/device-config/version.yaml @@ -1,3 +1,3 @@ version: major: 5 - minor: 52 + minor: 53