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') 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_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 } diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index d1a54d2ae..59db9da62 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; @@ -593,6 +594,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() + ); + } +} 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/build-config/buttplug-device-config-v5.json b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json index 865f1ad6f..a43fa873c 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": 42 + "minor": 43 }, "protocols": { "activejoy": { @@ -20565,6 +20565,45 @@ "name": "SayberX Device" } }, + "sdl-gamepad": { + "communication": [ + { + "sdl-gamepad": { + "exists": true + } + } + ], + "defaults": { + "features": [ + { + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "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/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/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index 9fea0a6bd..2ef5bf34a 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: 42 + minor: 43 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")] 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..df1585d56 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/CHANGELOG.md @@ -0,0 +1,10 @@ +# 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. + +## 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 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/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..be73b633b --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -0,0 +1,77 @@ +# 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. Confirmed on +hardware so far: Bluetooth DualSense on macOS discovers and rumbles (with the +one-second keepalive re-arming the effect). + +## 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 +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..3f13a3f2d --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -0,0 +1,92 @@ +// 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() { + // 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(|| { + 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)"); + 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()); + // 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); + } + } + 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..f5da035c7 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -0,0 +1,1422 @@ +// 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; + +/// 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; + +/// 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. +/// 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` +/// 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_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_KEEPALIVE_INTERVAL_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() + .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) => { + // 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) + } + }; + Some(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() + } + + 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 +/// 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> { + // 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"); + 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, + wired_ids: Vec, + } + + 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 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 + .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"); + } + + // 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 + // ------------------------------------------------------------------- + + #[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_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_KEEPALIVE_INTERVAL_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_KEEPALIVE_INTERVAL_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_KEEPALIVE_INTERVAL_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_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"); + } + 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_KEEPALIVE_INTERVAL_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_KEEPALIVE_INTERVAL_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:?}"); + } +} 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) } 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 c7de7e420..c2d131cc9 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -123,6 +123,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")] @@ -246,6 +251,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()) @@ -323,3 +329,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 73ba239a5..056f074c2 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 = @@ -197,3 +269,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 f76e1adaa..119ed9daf 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -42,6 +42,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, @@ -87,6 +89,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, @@ -121,6 +124,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, @@ -217,6 +221,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 @@ -301,3 +310,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()); + } +}