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/device_handle.rs b/crates/buttplug_server/src/device/device_handle.rs index 82644afe7..693ba12ed 100644 --- a/crates/buttplug_server/src/device/device_handle.rs +++ b/crates/buttplug_server/src/device/device_handle.rs @@ -23,8 +23,15 @@ use buttplug_core::{ ButtplugResultFuture, errors::{ButtplugDeviceError, ButtplugError}, message::{ - self, ButtplugMessage, ButtplugServerMessageV4, DeviceFeature, DeviceMessageInfoV4, - InputCommandType, InputType, OutputValue, StopCmdV4, + self, + ButtplugMessage, + ButtplugServerMessageV4, + DeviceFeature, + DeviceMessageInfoV4, + InputCommandType, + InputType, + OutputValue, + StopCmdV4, }, task_span, util::async_manager, @@ -32,7 +39,9 @@ use buttplug_core::{ util::task::TaskGroup, }; use buttplug_server_device_config::{ - DeviceConfigurationManager, ServerDeviceDefinition, ServerDeviceFeatureOutput, + DeviceConfigurationManager, + ServerDeviceDefinition, + ServerDeviceFeatureOutput, UserDeviceIdentifier, }; use dashmap::DashMap; @@ -51,14 +60,17 @@ use uuid::Uuid; use crate::{ ButtplugServerResultFuture, message::{ - ButtplugServerDeviceMessage, checked_input_cmd::CheckedInputCmdV4, - checked_output_cmd::CheckedOutputCmdV4, server_device_attributes::ServerDeviceAttributes, + ButtplugServerDeviceMessage, + checked_input_cmd::CheckedInputCmdV4, + checked_output_cmd::CheckedOutputCmdV4, + server_device_attributes::ServerDeviceAttributes, spec_enums::ButtplugDeviceCommandMessageUnionV4, }, }; use super::{ - InternalDeviceEvent, OutputObservation, + InternalDeviceEvent, + OutputObservation, device_task::{DeviceTaskConfig, DeviceTaskMessage, WRITE_ACK_TIMEOUT, run_owned_device_task}, hardware::{Hardware, HardwareCommand, HardwareConnector, HardwareEvent}, protocol::{ProtocolHandler, ProtocolKeepaliveStrategy, ProtocolSpecializer}, @@ -581,7 +593,14 @@ pub(super) async fn build_device_handle( // put it in an unknown state if anything fails. // Check in the DeviceConfigurationManager to make sure we have attributes for this device. - let definition = if let Some(attrs) = device_config_manager.device_definition(&identifier) { + // Connectors may carry explicit selection metadata naming the base definition they chose + // (e.g. SDL gamepad rumble layout); when present, resolve and reconcile against that base. + // An invalid selection is a connection failure, never a silent fallback to defaults. + let definition = if let Some(selection) = hardware.definition_selection() { + device_config_manager + .device_definition_with_selection(&identifier, selection) + .map_err(|e| ButtplugDeviceError::DeviceConfigurationError(e.to_string()))? + } else if let Some(attrs) = device_config_manager.device_definition(&identifier) { attrs } else { return Err(ButtplugDeviceError::DeviceConfigurationError(format!( diff --git a/crates/buttplug_server/src/device/hardware/mod.rs b/crates/buttplug_server/src/device/hardware/mod.rs index 2e2a84172..3ecb0c83b 100644 --- a/crates/buttplug_server/src/device/hardware/mod.rs +++ b/crates/buttplug_server/src/device/hardware/mod.rs @@ -11,7 +11,11 @@ use std::{collections::HashSet, fmt::Debug, sync::Arc, time::Duration}; use async_trait::async_trait; use buttplug_core::errors::ButtplugDeviceError; -use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + Endpoint, + ProtocolCommunicationSpecifier, +}; use futures::future::BoxFuture; use futures_util::FutureExt; use getset::{CopyGetters, Getters}; @@ -258,6 +262,13 @@ pub struct Hardware { /// Device name #[getset(get = "pub")] name: String, + /// Optional connected-definition selection metadata, set by connectors that + /// pick a device definition themselves (e.g. SDL gamepad rumble layout + /// selection). When present, device configuration resolves against the + /// selected base definition instead of the ordinary identifier lookup. Not + /// persisted and never part of device identity. + #[getset(get = "pub")] + definition_selection: Option, /// Device address #[getset(get = "pub")] address: String, @@ -293,10 +304,18 @@ impl Hardware { message_gap: *message_gap, internal_impl, requires_keepalive, + definition_selection: None, last_write_time: Arc::new(RwLock::new(Instant::now())), } } + /// Attach connected-definition selection metadata (builder style), to be + /// called by the connector before the `Hardware` is shared. + pub fn with_definition_selection(mut self, selection: DeviceDefinitionSelection) -> Self { + self.definition_selection = Some(selection); + self + } + pub async fn time_since_last_write(&self) -> Duration { Instant::now().duration_since(*self.last_write_time.read().await) } diff --git a/crates/buttplug_server/src/device/protocol_impl/mod.rs b/crates/buttplug_server/src/device/protocol_impl/mod.rs index 68fd44825..361d23e48 100644 --- a/crates/buttplug_server/src/device/protocol_impl/mod.rs +++ b/crates/buttplug_server/src/device/protocol_impl/mod.rs @@ -97,6 +97,7 @@ pub mod raw_protocol; pub mod realov; pub mod sakuraneko; pub mod satisfyer; +pub mod sdl_gamepad; pub mod sensee; pub mod sensee_capsule; pub mod sensee_v2; @@ -598,6 +599,10 @@ pub fn get_default_protocol_map() -> HashMap &str { + "sdl-gamepad" + } + + fn create(&self) -> Box { + Box::new(SdlGamepadIdentifier::default()) + } + } +} + +#[derive(Default)] +pub struct SdlGamepadIdentifier {} + +#[async_trait] +impl ProtocolIdentifier for SdlGamepadIdentifier { + async fn identify( + &mut self, + hardware: Arc, + _: ProtocolCommunicationSpecifier, + ) -> Result<(UserDeviceIdentifier, Box), ButtplugDeviceError> { + let identifier = UserDeviceIdentifier::new( + hardware.address(), + "sdl-gamepad", + &Some(hardware.name().to_owned()), + ); + Ok((identifier, Box::new(SdlGamepadInitializer::default()))) + } +} + +#[derive(Default)] +pub struct SdlGamepadInitializer {} + +#[async_trait] +impl ProtocolInitializer for SdlGamepadInitializer { + async fn initialize( + &mut self, + _: Arc, + device_definition: &ServerDeviceDefinition, + ) -> Result, ButtplugDeviceError> { + let layout = + SdlGamepadLayout::from_protocol_variant(device_definition.protocol_variant().as_deref()); + Ok(Arc::new(SdlGamepad::new(layout))) + } +} + +/// SDL3 gamepad rumble protocol. +/// +/// Every vibrate command carries the complete logical state. The handler keeps +/// the last-set speed for all four logical slots and packs all four u16 values +/// (little-endian) into every write packet. The internal packet is 8 bytes: +/// [low-frequency main, high-frequency main, left trigger, right trigger]. +/// +/// Visible feature indexes are mapped by the final device definition's layout: +/// MainOnly maps 0/1 to slots 0/1, TriggersOnly maps 0/1 to slots 2/3, and +/// MainAndTriggers maps 0-3 to slots 0-3. The layout comes from the protocol +/// variant, never from the feature count. Disabled features are filtered before +/// the handler sees them and must not be reinterpreted as different hardware +/// channels. +pub struct SdlGamepad { + layout: SdlGamepadLayout, + slots: Mutex<[u16; 4]>, +} + +impl SdlGamepad { + pub fn new(layout: SdlGamepadLayout) -> Self { + Self { + layout, + slots: Mutex::new([0; 4]), + } + } +} + +impl Default for SdlGamepad { + fn default() -> Self { + Self::new(SdlGamepadLayout::MainOnly) + } +} + +impl ProtocolHandler for SdlGamepad { + fn handle_output_vibrate_cmd( + &self, + feature_index: u32, + feature_id: uuid::Uuid, + speed: u32, + ) -> Result, ButtplugDeviceError> { + if feature_index as usize >= self.layout.channel_count() { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "SdlGamepad".to_owned(), + format!( + "SDL gamepad only has {} vibrate features, got index {feature_index}", + self.layout.channel_count() + ), + )); + } + + let mut slots = self.slots.lock().unwrap(); + let slot = self.layout.logical_slots()[feature_index as usize] as usize; + slots[slot] = speed as u16; + let mut cmd = vec![]; + for speed in slots.iter() { + if cmd.write_u16::(*speed).is_err() { + return Err(ButtplugDeviceError::ProtocolSpecificError( + "SdlGamepad".to_owned(), + "Cannot convert SDL gamepad value for processing".to_owned(), + )); + } + } + + Ok(vec![ + HardwareWriteCmd::new(&[feature_id], Endpoint::Tx, cmd, false).into(), + ]) + } +} + +#[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"), + } + } + + fn packet(values: [u16; 4]) -> Vec { + values + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect() + } + + #[test] + fn sdl_protocol_layout_packets() { + let cases = [ + ( + SdlGamepadLayout::MainOnly, + &[(0, 0x1234u16), (1, 0x5678u16)][..], + ), + ( + SdlGamepadLayout::TriggersOnly, + &[(0, 0x1234u16), (1, 0x5678u16)][..], + ), + ( + SdlGamepadLayout::MainAndTriggers, + &[ + (0, 0x1234u16), + (1, 0x5678u16), + (2, 0x9abcu16), + (3, 0xdef0u16), + ][..], + ), + ]; + + for (layout, writes) in cases { + let handler = SdlGamepad::new(layout); + for &(index, speed) in writes { + let mut expected = [0; 4]; + for &(previous_index, previous_speed) in writes { + if previous_index <= index { + expected[layout.logical_slots()[previous_index as usize] as usize] = previous_speed; + } + if previous_index == index { + break; + } + } + assert_eq!(vibrate(&handler, index, speed as u32), packet(expected)); + } + assert!( + handler + .handle_output_vibrate_cmd(layout.channel_count() as u32, uuid::Uuid::new_v4(), 100,) + .is_err() + ); + } + } + + #[test] + fn sdl_protocol_rejects_out_of_range_feature_per_layout() { + for layout in [ + SdlGamepadLayout::MainOnly, + SdlGamepadLayout::TriggersOnly, + SdlGamepadLayout::MainAndTriggers, + ] { + let error = SdlGamepad::new(layout) + .handle_output_vibrate_cmd(layout.channel_count() as u32, uuid::Uuid::new_v4(), 100) + .expect_err("out-of-range feature should be rejected"); + assert!( + error + .to_string() + .contains(&layout.channel_count().to_string()) + ); + } + } + + #[test] + fn sdl_protocol_stop_zeroes_only_selected_slot() { + let handler = SdlGamepad::new(SdlGamepadLayout::MainAndTriggers); + vibrate(&handler, 0, 0x1234); + vibrate(&handler, 2, 0x5678); + assert_eq!(vibrate(&handler, 0, 0), packet([0, 0, 0x5678, 0])); + } +} diff --git a/crates/buttplug_server_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 dd2dbb182..9ff57006c 100644 --- a/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json +++ b/crates/buttplug_server_device_config/build-config/buttplug-device-config-v5.json @@ -1,7 +1,7 @@ { "version": { "major": 5, - "minor": 52 + "minor": 53 }, "protocols": { "activejoy": { @@ -20599,6 +20599,147 @@ "name": "SayberX Device" } }, + "sdl-gamepad": { + "communication": [ + { + "sdl-gamepad": { + "exists": true + } + } + ], + "configurations": [ + { + "features": [ + { + "description": "Low-frequency rumble", + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "High-frequency rumble", + "id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Left-trigger rumble", + "id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "index": 2, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Right-trigger rumble", + "id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "index": 3, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + } + ], + "id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", + "identifier": [ + "__sdl-rumble-and-triggers" + ], + "name": "SDL Gamepad (Rumble and Triggers)", + "protocol_variant": "sdl-rumble-and-triggers" + }, + { + "features": [ + { + "description": "Left-trigger rumble", + "id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "Right-trigger rumble", + "id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + } + ], + "id": "d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e", + "identifier": [ + "__sdl-triggers-only" + ], + "name": "SDL Gamepad (Triggers Only)", + "protocol_variant": "sdl-triggers-only" + } + ], + "defaults": { + "features": [ + { + "description": "Low-frequency rumble", + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "index": 0, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + }, + { + "description": "High-frequency rumble", + "id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "index": 1, + "output": { + "vibrate": { + "value": [ + 0, + 65535 + ] + } + } + } + ], + "id": "b35f2adf-16bc-4425-9276-5d191aeaf107", + "name": "SDL Gamepad" + } + }, "sensee": { "communication": [ { diff --git a/crates/buttplug_server_device_config/device-config/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..adb9df631 --- /dev/null +++ b/crates/buttplug_server_device_config/device-config/protocols/sdl-gamepad.yml @@ -0,0 +1,84 @@ +defaults: + name: SDL Gamepad + features: + - id: f56852c8-cb3b-4703-90b6-6291df0c6314 + description: Low-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: e13388f9-a1b6-4c4c-a7b4-c68eeed293d8 + description: High-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 1 + id: b35f2adf-16bc-4425-9276-5d191aeaf107 +configurations: +- identifier: + - __sdl-rumble-and-triggers + name: SDL Gamepad (Rumble and Triggers) + id: c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d + protocol_variant: sdl-rumble-and-triggers + features: + - id: f56852c8-cb3b-4703-90b6-6291df0c6314 + description: Low-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: e13388f9-a1b6-4c4c-a7b4-c68eeed293d8 + description: High-frequency rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 1 + - id: a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b + description: Left-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 2 + - id: b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c + description: Right-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 3 +- identifier: + - __sdl-triggers-only + name: SDL Gamepad (Triggers Only) + id: d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e + protocol_variant: sdl-triggers-only + features: + - id: a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b + description: Left-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 0 + - id: b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c + description: Right-trigger rumble + output: + vibrate: + value: + - 0 + - 65535 + index: 1 +communication: +- sdl-gamepad: + exists: true diff --git a/crates/buttplug_server_device_config/device-config/version.yaml b/crates/buttplug_server_device_config/device-config/version.yaml index d38389c90..0b6afabee 100644 --- a/crates/buttplug_server_device_config/device-config/version.yaml +++ b/crates/buttplug_server_device_config/device-config/version.yaml @@ -1,3 +1,3 @@ version: major: 5 - minor: 52 + minor: 53 diff --git a/crates/buttplug_server_device_config/src/device_config_file/mod.rs b/crates/buttplug_server_device_config/src/device_config_file/mod.rs index e6ca7c6f9..bb34d0466 100644 --- a/crates/buttplug_server_device_config/src/device_config_file/mod.rs +++ b/crates/buttplug_server_device_config/src/device_config_file/mod.rs @@ -8,6 +8,7 @@ mod base; mod device; mod feature; +pub(crate) use feature::ConfigUserDeviceFeature; mod protocol; mod user; diff --git a/crates/buttplug_server_device_config/src/device_config_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/device_config_manager.rs b/crates/buttplug_server_device_config/src/device_config_manager.rs index 9dadd22d8..378c34f59 100644 --- a/crates/buttplug_server_device_config/src/device_config_manager.rs +++ b/crates/buttplug_server_device_config/src/device_config_manager.rs @@ -413,6 +413,88 @@ impl DeviceConfigurationManager { index } + /// Resolves a definition when a connector supplies explicit selection metadata. This reconciles + /// even exact cached entries against the selected base before returning, reports invalid or + /// missing selections explicitly rather than silently falling back, preserves user identity, + /// index, overrides, message gap, and surviving feature customizations, and updates the canonical + /// name and base ID. Selection is in-memory only; feature descriptions are not serialized, so + /// reloads use descriptions from the selected base. + pub fn device_definition_with_selection( + &self, + identifier: &UserDeviceIdentifier, + selection: &crate::DeviceDefinitionSelection, + ) -> Result { + if selection.protocol() != identifier.protocol() { + return Err(ButtplugDeviceConfigError::DeviceSelectionInvalid(format!( + "selection protocol '{}' does not match identifier protocol '{}'", + selection.protocol(), + identifier.protocol() + ))); + } + let base_key = BaseDeviceIdentifier::new(selection.protocol(), selection.base_identifier()); + let base_definition = self + .base_device_definitions + .get(&base_key) + .ok_or_else(|| { + ButtplugDeviceConfigError::DeviceSelectionInvalid(format!( + "base definition {:?} not found for protocol '{}'", + base_key, + selection.protocol() + )) + })? + .clone(); + + if let Some(old_definition) = self + .user_device_definitions + .get(identifier) + .map(|x| x.clone()) + { + let mut builder = + ServerDeviceDefinitionBuilder::from_base(&base_definition, old_definition.id(), false); + builder + .name(selection.canonical_name()) + .display_name(old_definition.display_name()) + .allow(old_definition.allow()) + .deny(old_definition.deny()) + .message_gap_ms(old_definition.message_gap_ms()) + .index(old_definition.index()); + for base_feature in base_definition.features().values() { + let feature = if let Some(old_feature) = old_definition + .features() + .values() + .find(|x| x.base_id == Some(base_feature.id())) + { + let mut feature = + crate::device_config_file::ConfigUserDeviceFeature::try_from(old_feature)? + .with_base_feature(base_feature)?; + if !old_feature.description.is_empty() { + feature.description = old_feature.description.clone(); + } + feature + } else { + base_feature.as_new_user_feature() + }; + builder.add_feature(&feature); + } + let rebuilt = builder.finish(); + self + .user_device_definitions + .insert(identifier.clone(), rebuilt.clone()); + Ok(rebuilt) + } else { + let mut builder = + ServerDeviceDefinitionBuilder::from_base(&base_definition, Uuid::new_v4(), true); + builder + .name(selection.canonical_name()) + .index(self.device_index(identifier)); + let definition = builder.finish(); + self + .user_device_definitions + .insert(identifier.clone(), definition.clone()); + Ok(definition) + } + } + pub fn device_definition( &self, identifier: &UserDeviceIdentifier, diff --git a/crates/buttplug_server_device_config/src/device_definitions.rs b/crates/buttplug_server_device_config/src/device_definitions.rs index 57557c2d9..170369c40 100644 --- a/crates/buttplug_server_device_config/src/device_definitions.rs +++ b/crates/buttplug_server_device_config/src/device_definitions.rs @@ -12,6 +12,40 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::server_device_feature::ServerDeviceFeature; + +/// Neutral, protocol-agnostic metadata attached to connected hardware naming the base definition +/// selected by the connector and the device's canonical (hardware-reported) name. A `None` +/// `base_identifier` selects the protocol's default base definition. This value is never persisted +/// and is never part of device identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceDefinitionSelection { + protocol: String, + base_identifier: Option, + canonical_name: String, +} + +impl DeviceDefinitionSelection { + pub fn new(protocol: &str, base_identifier: Option<&str>, canonical_name: &str) -> Self { + Self { + protocol: protocol.to_owned(), + base_identifier: base_identifier.map(str::to_owned), + canonical_name: canonical_name.to_owned(), + } + } + + pub fn protocol(&self) -> &str { + &self.protocol + } + + pub fn base_identifier(&self) -> &Option { + &self.base_identifier + } + + pub fn canonical_name(&self) -> &str { + &self.canonical_name + } +} + #[derive(Debug, Clone, Getters, CopyGetters, Serialize, Deserialize)] pub struct ServerDeviceDefinition { #[getset(get = "pub")] @@ -98,6 +132,12 @@ impl ServerDeviceDefinitionBuilder { self } + /// Sets the canonical (hardware-reported) device name; display_name is the user override and is set separately. + pub fn name(&mut self, name: &str) -> &mut Self { + self.def.name = name.to_owned(); + self + } + pub fn display_name(&mut self, name: &Option) -> &mut Self { self.def.display_name = name.clone(); self diff --git a/crates/buttplug_server_device_config/src/lib.rs b/crates/buttplug_server_device_config/src/lib.rs index ef4fc43ff..34bec66d5 100644 --- a/crates/buttplug_server_device_config/src/lib.rs +++ b/crates/buttplug_server_device_config/src/lib.rs @@ -158,6 +158,8 @@ mod identifiers; pub use identifiers::*; mod device_definitions; pub use device_definitions::*; +mod sdl_layout; +pub use sdl_layout::*; mod server_device_feature; pub use server_device_feature::*; mod endpoint; @@ -180,6 +182,8 @@ pub enum ButtplugDeviceConfigError { /// Base ID not found, cannot match user device/feature to a base device/feature #[error("Device definition with base id {0} not found")] BaseIdNotFound(Uuid), + #[error("Device definition selection is invalid: {0}")] + DeviceSelectionInvalid(String), #[error("Feature vectors between base and user device definitions do not match")] UserFeatureMismatch, #[error("Output value {0} not in range {1}")] diff --git a/crates/buttplug_server_device_config/src/sdl_layout.rs b/crates/buttplug_server_device_config/src/sdl_layout.rs new file mode 100644 index 000000000..7e0657352 --- /dev/null +++ b/crates/buttplug_server_device_config/src/sdl_layout.rs @@ -0,0 +1,101 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +pub const SDL_PROTOCOL_NAME: &str = "sdl-gamepad"; +pub const SDL_MAIN_ONLY_BASE_ID: uuid::Uuid = uuid::uuid!("b35f2adf-16bc-4425-9276-5d191aeaf107"); +pub const SDL_RUMBLE_AND_TRIGGERS_BASE_ID: uuid::Uuid = + uuid::uuid!("c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d"); +pub const SDL_TRIGGERS_ONLY_BASE_ID: uuid::Uuid = + uuid::uuid!("d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e"); +pub const SDL_CHANNEL_LOW_BASE_ID: uuid::Uuid = uuid::uuid!("f56852c8-cb3b-4703-90b6-6291df0c6314"); +pub const SDL_CHANNEL_HIGH_BASE_ID: uuid::Uuid = + uuid::uuid!("e13388f9-a1b6-4c4c-a7b4-c68eeed293d8"); +pub const SDL_CHANNEL_LEFT_TRIGGER_BASE_ID: uuid::Uuid = + uuid::uuid!("a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b"); +pub const SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID: uuid::Uuid = + uuid::uuid!("b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c"); +pub const SDL_RUMBLE_AND_TRIGGERS_SELECTOR: &str = "__sdl-rumble-and-triggers"; +pub const SDL_TRIGGERS_ONLY_SELECTOR: &str = "__sdl-triggers-only"; +pub const SDL_RUMBLE_AND_TRIGGERS_VARIANT: &str = "sdl-rumble-and-triggers"; +pub const SDL_TRIGGERS_ONLY_VARIANT: &str = "sdl-triggers-only"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SdlGamepadLayout { + MainOnly, + TriggersOnly, + MainAndTriggers, +} + +impl SdlGamepadLayout { + pub fn channel_count(self) -> usize { + match self { + Self::MainOnly | Self::TriggersOnly => 2, + Self::MainAndTriggers => 4, + } + } + + /// Logical channel slots in fixed low/high/left-trigger/right-trigger order; positions in this + /// slice are visible feature indexes. Two channels are ambiguous, so layout always comes from + /// the protocol variant, never from feature count. + pub fn logical_slots(self) -> &'static [u8] { + match self { + Self::MainOnly => &[0, 1], + Self::TriggersOnly => &[2, 3], + Self::MainAndTriggers => &[0, 1, 2, 3], + } + } + + pub fn from_protocol_variant(variant: Option<&str>) -> Self { + match variant { + Some(SDL_RUMBLE_AND_TRIGGERS_VARIANT) => Self::MainAndTriggers, + Some(SDL_TRIGGERS_ONLY_VARIANT) => Self::TriggersOnly, + _ => Self::MainOnly, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_count_and_slots() { + assert_eq!(SdlGamepadLayout::MainOnly.channel_count(), 2); + assert_eq!(SdlGamepadLayout::MainOnly.logical_slots(), &[0, 1]); + assert_eq!(SdlGamepadLayout::TriggersOnly.channel_count(), 2); + assert_eq!(SdlGamepadLayout::TriggersOnly.logical_slots(), &[2, 3]); + assert_eq!(SdlGamepadLayout::MainAndTriggers.channel_count(), 4); + assert_eq!( + SdlGamepadLayout::MainAndTriggers.logical_slots(), + &[0, 1, 2, 3] + ); + } + + #[test] + fn protocol_variant_mapping() { + assert_eq!( + SdlGamepadLayout::from_protocol_variant(None), + SdlGamepadLayout::MainOnly + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some("")), + SdlGamepadLayout::MainOnly + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some(SDL_TRIGGERS_ONLY_VARIANT)), + SdlGamepadLayout::TriggersOnly + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some(SDL_RUMBLE_AND_TRIGGERS_VARIANT)), + SdlGamepadLayout::MainAndTriggers + ); + assert_eq!( + SdlGamepadLayout::from_protocol_variant(Some("unknown")), + SdlGamepadLayout::MainOnly + ); + } +} diff --git a/crates/buttplug_server_device_config/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..844c3049e 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,84 @@ // 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!(!feature["description"].as_str().unwrap().is_empty()); + assert_eq!( + feature["description"], + ["Low-frequency rumble", "High-frequency rumble"][i] + ); + assert_eq!(feature["output"]["vibrate"]["value"][0], 0); + assert_eq!(feature["output"]["vibrate"]["value"][1], 65535); + } + let configurations = protocol["configurations"].as_array().unwrap(); + assert_eq!(configurations.len(), 2); + assert_eq!( + configurations[0]["identifier"][0], + "__sdl-rumble-and-triggers" + ); + assert_eq!( + configurations[0]["id"], + "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d" + ); + assert_eq!( + configurations[0]["protocol_variant"], + "sdl-rumble-and-triggers" + ); + assert_eq!(configurations[0]["features"].as_array().unwrap().len(), 4); + assert_eq!(configurations[1]["identifier"][0], "__sdl-triggers-only"); + assert_eq!( + configurations[1]["id"], + "d2e3f4a5-4444-4b8c-9dae-2f3a4b5c6d7e" + ); + assert_eq!(configurations[1]["protocol_variant"], "sdl-triggers-only"); + assert_eq!(configurations[1]["features"].as_array().unwrap().len(), 2); + let communication = protocol["communication"][0]["sdl-gamepad"].clone(); + assert_eq!(communication["exists"], true); +} + #[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_device_config/tests/test_sdl_definition_selection.rs b/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs new file mode 100644 index 000000000..d5fdef406 --- /dev/null +++ b/crates/buttplug_server_device_config/tests/test_sdl_definition_selection.rs @@ -0,0 +1,379 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +use buttplug_core::message::OutputType; +use buttplug_core::util::range::RangeInclusive; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + RangeWithLimit, + SDL_CHANNEL_LEFT_TRIGGER_BASE_ID, + SDL_CHANNEL_LOW_BASE_ID, + SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID, + SDL_MAIN_ONLY_BASE_ID, + SDL_RUMBLE_AND_TRIGGERS_BASE_ID, + SDL_TRIGGERS_ONLY_BASE_ID, + ServerDeviceDefinitionBuilder, + ServerDeviceFeatureOutput, + ServerDeviceFeatureOutputValueProperties, + UserDeviceIdentifier, + load_protocol_configs, + save_user_config, +}; + +fn dcm() -> buttplug_server_device_config::DeviceConfigurationManager { + load_protocol_configs(&None, &None, false) + .unwrap() + .finish() + .unwrap() +} + +#[test] +fn definition_selection_rejects_invalid_base() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new("sdl-gamepad-1", "sdl-gamepad", &None); + let invalid = dcm.device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", Some("__nonexistent-base"), "Pad"), + ); + assert!(matches!( + invalid, + Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) + )); + let mismatch = dcm.device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("other-protocol", None, "Pad"), + ); + assert!(matches!( + mismatch, + Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) + )); +} + +#[test] +fn sdl_selection_idempotent() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-7", + "sdl-gamepad", + &Some("Test Pad 1".to_owned()), + ); + let selection = DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 1", + ); + let first = dcm + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + let second = dcm + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + assert_eq!(first.id(), second.id()); + assert_eq!(first.base_id(), Some(SDL_RUMBLE_AND_TRIGGERS_BASE_ID)); + assert_eq!(first.name(), "Test Pad 1"); + assert_eq!(first.features().len(), 4); + assert_eq!( + first + .features() + .values() + .map(|f| f.id()) + .collect::>(), + second + .features() + .values() + .map(|f| f.id()) + .collect::>() + ); + let cached = dcm.device_definition(&identifier).unwrap(); + assert_eq!(cached.id(), second.id()); + assert_eq!(cached.base_id(), second.base_id()); + assert_eq!( + cached + .features() + .values() + .map(|f| f.id()) + .collect::>(), + second + .features() + .values() + .map(|f| f.id()) + .collect::>() + ); +} + +#[test] +fn sdl_layout_reconciliation_matrix() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new("sdl-gamepad-matrix", "sdl-gamepad", &None); + let main = dcm.device_definition(&identifier).unwrap(); + let main_id = main.id(); + let low = main.features().get(&0).unwrap().id(); + let high = main.features().get(&1).unwrap().id(); + let both = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 1", + ), + ) + .unwrap(); + assert_eq!(both.features().get(&0).unwrap().id(), low); + assert_eq!(both.features().get(&1).unwrap().id(), high); + assert_eq!( + both.features().get(&2).unwrap().base_id, + Some(SDL_CHANNEL_LEFT_TRIGGER_BASE_ID) + ); + assert_eq!( + both.features().get(&3).unwrap().base_id, + Some(SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID) + ); + assert_eq!(both.id(), main_id); + let main_again = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", None, "Test Pad 1"), + ) + .unwrap(); + assert_eq!(main_again.features().len(), 2); + assert_eq!(main_again.features().get(&0).unwrap().id(), low); + assert_eq!(main_again.features().get(&1).unwrap().id(), high); + let triggers = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-triggers-only"), "Test Pad 2"), + ) + .unwrap(); + assert_eq!(triggers.base_id(), Some(SDL_TRIGGERS_ONLY_BASE_ID)); + assert_eq!( + triggers.features().get(&0).unwrap().base_id, + Some(SDL_CHANNEL_LEFT_TRIGGER_BASE_ID) + ); + assert_eq!( + triggers.features().get(&1).unwrap().base_id, + Some(SDL_CHANNEL_RIGHT_TRIGGER_BASE_ID) + ); + assert_ne!(triggers.features().get(&0).unwrap().id(), low); + assert_ne!(triggers.features().get(&1).unwrap().id(), high); + assert_eq!(triggers.name(), "Test Pad 2"); + let restored = dcm + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new( + "sdl-gamepad", + Some("__sdl-rumble-and-triggers"), + "Test Pad 2", + ), + ) + .unwrap(); + assert_ne!(restored.features().get(&0).unwrap().id(), low); + assert_ne!(restored.features().get(&1).unwrap().id(), high); + assert_eq!(restored.id(), main_id); + assert_eq!( + restored.features().get(&0).unwrap().base_id, + Some(SDL_CHANNEL_LOW_BASE_ID) + ); +} + +#[test] +fn non_sdl_definition_resolution_unchanged() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new("COM1", "tcode-v03", &None); + assert_eq!( + dcm.device_definition(&identifier).unwrap().name(), + "TCode v0.3 (Single Linear Axis)" + ); + let result = dcm.device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("tcode-v03", Some("missing"), "TCode"), + ); + assert!(matches!( + result, + Err(buttplug_server_device_config::ButtplugDeviceConfigError::DeviceSelectionInvalid(_)) + )); +} + +fn both_selection(name: &str) -> DeviceDefinitionSelection { + DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-rumble-and-triggers"), name) +} + +fn reload_with(saved: String) -> buttplug_server_device_config::DeviceConfigurationManager { + load_protocol_configs(&None, &Some(saved), false) + .unwrap() + .finish() + .unwrap() +} + +#[test] +fn sdl_legacy_config_roundtrip() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-legacy", + "sdl-gamepad", + &Some("Legacy Pad".to_owned()), + ); + let def = dcm.device_definition(&identifier).unwrap(); + assert_eq!(def.base_id(), Some(SDL_MAIN_ONLY_BASE_ID)); + assert_eq!(def.features().len(), 2); + // Display-name override plus canonical name as hardware would report it. + let mut builder = ServerDeviceDefinitionBuilder::from_user(&def); + builder.display_name(&Some("My Precious Pad".to_owned())); + dcm.add_user_device_definition(&identifier, &builder.finish()); + + let saved = save_user_config(&dcm).unwrap(); + let reloaded = reload_with(saved); + + // Cached user definition reloads with the same identity, features, base and + // display-name override; canonical name falls back to the base default + // because names are not serialized. + let back = reloaded.device_definition(&identifier).unwrap(); + assert_eq!(back.id(), def.id()); + assert_eq!(back.base_id(), Some(SDL_MAIN_ONLY_BASE_ID)); + assert_eq!(back.features().len(), 2); + assert_eq!( + back.features().values().map(|f| f.id()).collect::>(), + def.features().values().map(|f| f.id()).collect::>() + ); + assert_eq!(back.name(), "SDL Gamepad"); + assert_eq!(back.display_name(), &Some("My Precious Pad".to_owned())); + + // A later connection refreshes the canonical name without changing identity. + let reconnected = reloaded + .device_definition_with_selection( + &identifier, + &DeviceDefinitionSelection::new("sdl-gamepad", None, "Legacy Pad"), + ) + .unwrap(); + assert_eq!(reconnected.id(), def.id()); + assert_eq!(reconnected.name(), "Legacy Pad"); +} + +#[test] +fn sdl_selected_config_roundtrip() { + for (selection, expected_base, expected_features) in [ + ( + both_selection("Selected Pad"), + SDL_RUMBLE_AND_TRIGGERS_BASE_ID, + 4, + ), + ( + DeviceDefinitionSelection::new("sdl-gamepad", Some("__sdl-triggers-only"), "Selected Pad"), + SDL_TRIGGERS_ONLY_BASE_ID, + 2, + ), + ] { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-selected", + "sdl-gamepad", + &Some("Selected Pad".to_owned()), + ); + let def = dcm + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + assert_eq!(def.base_id(), Some(expected_base)); + assert_eq!(def.features().len(), expected_features); + + let saved = save_user_config(&dcm).unwrap(); + let reloaded = reload_with(saved); + let back = reloaded + .device_definition_with_selection(&identifier, &selection) + .unwrap(); + assert_eq!(back.id(), def.id()); + assert_eq!(back.base_id(), Some(expected_base)); + assert_eq!(back.protocol_variant(), def.protocol_variant()); + assert_eq!(back.name(), "Selected Pad"); + assert_eq!( + back.features().values().map(|f| f.id()).collect::>(), + def.features().values().map(|f| f.id()).collect::>() + ); + } +} + +#[test] +fn sdl_description_reconciliation_and_reload_contract() { + let dcm = dcm(); + let identifier = UserDeviceIdentifier::new( + "sdl-gamepad-desc", + "sdl-gamepad", + &Some("Desc Pad".to_owned()), + ); + let def = dcm + .device_definition_with_selection(&identifier, &both_selection("Desc Pad")) + .unwrap(); + + // Customize feature 0 with a deliberately nondefault description, feature 1 + // with a user range limit and a disabled flag. + let mut builder = ServerDeviceDefinitionBuilder::from_user(&def); + let mut f0 = def.features().get(&0).unwrap().clone(); + f0.description = "My custom low motor label".to_owned(); + builder.replace_feature(&f0); + let mut f1 = def.features().get(&1).unwrap().clone(); + f1.output = f1 + .output + .iter() + .map(|o| match o { + ServerDeviceFeatureOutput::Vibrate(props) => { + ServerDeviceFeatureOutput::Vibrate(ServerDeviceFeatureOutputValueProperties::new( + RangeWithLimit::new_with_user( + props.value.base.clone(), + Some(RangeInclusive::new(0, 30000)), + ), + true, + )) + } + other => other.clone(), + }) + .collect(); + builder.replace_feature(&f1); + dcm.add_user_device_definition(&identifier, &builder.finish()); + + // In-memory reconciliation preserves the nonempty custom description and + // the user customizations. + let reconn = dcm + .device_definition_with_selection(&identifier, &both_selection("Desc Pad")) + .unwrap(); + assert_eq!( + reconn.features().get(&0).unwrap().description, + "My custom low motor label" + ); + let f1_back = reconn.features().get(&1).unwrap(); + match f1_back.get_output(OutputType::Vibrate).unwrap() { + ServerDeviceFeatureOutput::Vibrate(props) => { + assert_eq!( + (props.value.internal().start(), props.value.internal().end()), + (0, 30000) + ); + assert!(props.disabled); + } + other => panic!("expected vibrate output, got {other:?}"), + } + + // Save/load: descriptions are not serialized, so reload uses the selected + // base's descriptions. User limits and disabled flags persist. + let saved = save_user_config(&dcm).unwrap(); + let reloaded = reload_with(saved); + let back = reloaded + .device_definition_with_selection(&identifier, &both_selection("Desc Pad")) + .unwrap(); + assert_eq!( + back.features().get(&0).unwrap().description, + "Low-frequency rumble" + ); + let f1_reloaded = back.features().get(&1).unwrap(); + match f1_reloaded.get_output(OutputType::Vibrate).unwrap() { + ServerDeviceFeatureOutput::Vibrate(props) => { + assert_eq!( + (props.value.internal().start(), props.value.internal().end()), + (0, 30000) + ); + assert!(props.disabled); + } + other => panic!("expected vibrate output, got {other:?}"), + } +} 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..4646716a1 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/README.md @@ -0,0 +1,131 @@ +# 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 using the `sdl-gamepad` protocol. Each +connection exposes one of these logical channel layouts, selected from SDL's +reported capabilities (which can vary by operating system and transport), not +from a device name or model: + +- **Main-only:** Low-frequency rumble and High-frequency rumble (the classic +'two-channel' layout). +- **Trigger-only:** Left-trigger rumble and Right-trigger rumble at visible + indexes 0 and 1. +- **Both:** all four channels in the order above. + +Each channel is a vibrate feature with a range of 0-65535. A device reporting +neither main rumble nor trigger rumble is skipped. Capability probes are pure +property queries; the device is not permanently excluded, and a later scan +retries it. + +Names come from SDL's `name_for_id` lookup. If lookup fails or returns only +whitespace, the deterministic fallback is `SDL Gamepad {instance_id}`. The +Buttplug address is based on the SDL instance ID. Identity is connection-scoped: +an instance ID and reported name can change after reconnecting, so settings +follow the connection-scoped identity rather than guaranteed physical-hardware +identity. Layout selection is also per connection; changed capabilities take +effect on the next connection. + +When a layout changes, surviving channels retain their user UUIDs, limits, +disabled state, and display-name overrides. Channels removed by the layout +change lose their customizations permanently; if those channels reappear on a +later connection, they start with defaults. Feature descriptions come from the +device configuration on load and are not serialized in saved user configs. + +## How it works + +A single process-lifetime thread owns the SDL3 context. All gamepads are +multiplexed through it: discovery is on-demand `SDL_GetGamepads` enumeration +and removal detection is per-device connected-state polling. The thread never +pumps SDL events (SDL3 documents `SDL_PumpEvents` as main-thread-only, and this +manager does not consume controller input). + +Internally, the protocol-to-hardware packet is 8 bytes: four little-endian +`u16` logical slots in fixed order `[low, high, left_trigger, right_trigger]`. +This is an internal transport detail, not a public wire-protocol change. Only +capability-supported pairs are dispatched to SDL, including zero, stop, and +keepalive commands. + +Rumble is armed with a finite duration because the `sdl3` crate documents that +`u32::MAX` durations overflow and end the effect immediately. The ownership +thread refreshes each active main or trigger pair independently every second +before expiry; zero pairs stop refreshing. This keepalive makes one-shot +commands remain active on controllers that otherwise stop rumbling after a few +seconds. + +Trigger output here is simple SDL trigger rumble via +`SDL_RumbleGamepadTriggers` (currently Xbox-One-class support). It is not +adaptive-trigger resistance or a resistance/force-feedback control. + +## Build prerequisites + +The `sdl3` dependency uses the `build-from-source-static` feature: SDL3 is +downloaded and built (and statically linked) at crate build time. This requires +`cmake` and a C compiler on the build machine: + +- 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` + +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. Buttplug-side behavior (discovery, addressing, command forwarding, +lifecycle, and layout selection) is unit-tested in this crate against mock +drivers/backends. The `examples/sdl3_thread_spike.rs` diagnostic prints the +SDL-reported name, connection state, and both capability booleans without +actuating anything. + +## Manual release validation + +On a supported platform and transport, validate both a **main-only** pad and a +**trigger-capable** pad: + +1. Confirm the SDL-derived name (or deterministic fallback) and independent + channels are visible in a current client. +2. Sustain main rumble long enough to cross multiple one-second keepalive + refreshes; confirm it remains active. +3. On the trigger-capable pad, sustain trigger rumble across keepalive refreshes + and confirm left and right trigger channels independently. +4. Send stop commands and confirm both rumble pairs stop. +5. Disconnect the pad and confirm the client receives disconnection. + +Physical checks had **not** been performed as of this change. Automated tests +cannot prove motor behaviour or the exact number of physical actuators. + +Confirmed on hardware so far: Bluetooth DualSense on macOS discovers and rumbles +(with the one-second keepalive re-arming the effect). This pre-existing note +must not be used to infer trigger-rumble support. + +## Platform support + +- **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. + +## Registration + +This manager is **opt-in everywhere**: + +- In `intiface-engine`, pass `--use-sdl-gamepad`. +- In `buttplug_client_in_process`, enable the non-default + `sdl-gamepad-manager` cargo feature. diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs new file mode 100644 index 000000000..ff014ec6b --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/examples/sdl3_thread_spike.rs @@ -0,0 +1,103 @@ +// 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" + } + }; + // Capability booleans mirror what the production manager reads + // (SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN and + // SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN). Pure + // property queries: they never actuate motors. + // SAFETY: property-table reads of this opened gamepad on the + // thread that exclusively owns it; no concurrent SDL access. + let has_rumble = unsafe { pad.has_rumble() }; + // SAFETY: see above. + let has_trigger_rumble = unsafe { pad.has_rumble_triggers() }; + println!( + "[sdl-thread] gamepad {} '{}' connection: {} rumble: {} trigger-rumble: {}", + id.0, + pad.name().unwrap_or_default(), + connection, + has_rumble, + has_trigger_rumble + ); + // 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..64eadca35 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_comm_manager.rs @@ -0,0 +1,290 @@ +// 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, + gamepad.capabilities, + )), + }) + .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< + ( + Arc, + crate::sdl_task::SdlRumbleCapabilities, + ), + 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(), + capabilities: crate::sdl_task::SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, + } + } + + #[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..10ac101b0 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -0,0 +1,673 @@ +// 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, + SdlRumbleCapabilities, + SdlRumbleState, + 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::{ + DeviceDefinitionSelection, + Endpoint, + ProtocolCommunicationSpecifier, + SDL_PROTOCOL_NAME, + SDL_RUMBLE_AND_TRIGGERS_SELECTOR, + SDL_TRIGGERS_ONLY_SELECTOR, + 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, + capabilities: SdlRumbleCapabilities, +} + +impl SdlGamepadHardwareConnector { + pub(crate) fn new( + backend: Arc, + id: JoystickId, + name: String, + address: String, + capabilities: SdlRumbleCapabilities, + ) -> Self { + Self { + backend, + id, + name, + address, + capabilities, + } + } +} + +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) + .field("capabilities", &self.capabilities) + .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, caps) = self + .backend + .open(self.id) + .await + .map_err(|e| hardware_error("open", e))?; + let base_identifier = match (caps.rumble, caps.trigger_rumble) { + (true, true) => Some(SDL_RUMBLE_AND_TRIGGERS_SELECTOR), + (true, false) => None, + (false, true) => Some(SDL_TRIGGERS_ONLY_SELECTOR), + (false, false) => { + opened.close_now(); + return Err(hardware_error( + "open", + SdlTaskError::NoRumbleCapability(self.id), + )); + } + }; + let hardware_internal = SdlGamepadHardware::new(opened, self.address.clone(), caps); + let hardware = Hardware::new( + &self.name, + &self.address, + &[Endpoint::Tx], + &None, + false, + Box::new(hardware_internal), + ) + .with_definition_selection(DeviceDefinitionSelection::new( + SDL_PROTOCOL_NAME, + base_identifier, + &self.name, + )); + 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>, + capabilities: SdlRumbleCapabilities, + event_sender: broadcast::Sender, + cancellation_token: CancellationToken, +} + +impl SdlGamepadHardware { + fn new( + opened: Arc, + address: String, + capabilities: SdlRumbleCapabilities, + ) -> Self { + let (device_event_sender, _) = broadcast::channel(256); + let token = CancellationToken::new(); + let child = token.child_token(); + 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), + capabilities, + 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(); + let caps = self.capabilities; + async move { + if data.len() != 8 { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad write payload must be 8 bytes (four u16 LE channel values)".to_owned(), + )); + } + let mut cursor = Cursor::new(data); + let state = match ( + cursor.read_u16::(), + cursor.read_u16::(), + cursor.read_u16::(), + cursor.read_u16::(), + ) { + (Ok(low), Ok(high), Ok(left_trigger), Ok(right_trigger)) => SdlRumbleState { + low, + high, + left_trigger, + right_trigger, + }, + _ => { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad write payload must be 8 bytes (four u16 LE channel values)".to_owned(), + )); + } + }; + let [low, high, left, right] = state.slots(); + if !caps.rumble && (low != 0 || high != 0) { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad does not support main rumble".to_owned(), + )); + } + if !caps.trigger_rumble && (left != 0 || right != 0) { + return Err(ButtplugDeviceError::DeviceCommunicationError( + "SDL gamepad does not support trigger rumble".to_owned(), + )); + } + opened + .set_rumble_state(state, 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>, + trigger_calls: Mutex>, + rumble_attempts: Mutex>, + trigger_attempts: Mutex>, + fail: Mutex, + commands: Mutex, + caps: SdlRumbleCapabilities, + closed: Mutex, + removed_tx: watch::Sender, + } + + #[async_trait] + impl SdlOpenedGamepad for MockOpenedGamepad { + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + *self.commands.lock().unwrap() += 1; + let fail = *self.fail.lock().unwrap(); + if self.caps.rumble { + self + .rumble_attempts + .lock() + .unwrap() + .push((state.low, state.high, duration_ms)); + if !fail { + self + .rumble_calls + .lock() + .unwrap() + .push((state.low, state.high, duration_ms)); + } + } + if self.caps.trigger_rumble { + self.trigger_attempts.lock().unwrap().push(( + state.left_trigger, + state.right_trigger, + duration_ms, + )); + if !fail { + self.trigger_calls.lock().unwrap().push(( + state.left_trigger, + state.right_trigger, + duration_ms, + )); + } + } + if fail { + Err(SdlTaskError::Rumble("mock failure".to_owned())) + } else { + Ok(()) + } + } + + async fn close(&self) -> Result<(), SdlTaskError> { + *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<(Arc, SdlRumbleCapabilities), SdlTaskError> { + self + .opened + .lock() + .unwrap() + .clone() + .map(|pad| { + let caps = pad.caps; + (pad as Arc, caps) + }) + .ok_or_else(|| SdlTaskError::Open("no mock gamepad".to_owned())) + } + } + + async fn connect_mock_hardware() -> (Arc, Hardware, Arc) { + connect_mock_caps(SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }) + .await + } + + async fn connect_mock_caps( + caps: SdlRumbleCapabilities, + ) -> (Arc, Hardware, Arc) { + let mock_pad = Arc::new(MockOpenedGamepad { + caps, + rumble_calls: Mutex::new(Vec::new()), + trigger_calls: Mutex::new(Vec::new()), + rumble_attempts: Mutex::new(Vec::new()), + trigger_attempts: Mutex::new(Vec::new()), + fail: Mutex::new(false), + commands: Mutex::new(0), + closed: Mutex::new(0), + removed_tx: watch::channel(false).0, + }); + 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)), + caps, + ); + 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; + + // Main-only pads receive only their supported pair. + hardware + .write_value(&HardwareWriteCmd::new( + &[uuid::Uuid::new_v4()], + Endpoint::Tx, + vec![0x00, 0x80, 0xff, 0x7f, 0, 0, 0, 0], + false, + )) + .await + .expect("write should succeed"); + assert_eq!( + *mock_pad.rumble_calls.lock().unwrap(), + vec![(0x8000, 0x7fff, RUMBLE_DURATION_MS)] + ); + + assert!(mock_pad.trigger_attempts.lock().unwrap().is_empty()); + assert_eq!(*mock_pad.commands.lock().unwrap(), 1); + + // Short payloads error rather than panic. + let err = hardware + .write_value(&HardwareWriteCmd::new( + &[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() + ); + } + + fn packet(data: Vec) -> HardwareWriteCmd { + HardwareWriteCmd::new(&[uuid::Uuid::new_v4()], Endpoint::Tx, data, false) + } + + #[tokio::test] + async fn sdl_hardware_packet_validation() { + for caps in [ + SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, + SdlRumbleCapabilities { + rumble: false, + trigger_rumble: true, + }, + ] { + let (pad, hardware, _) = connect_mock_caps(caps).await; + for bytes in [vec![0; 4], vec![0; 9]] { + assert!(hardware.write_value(&packet(bytes)).await.is_err()); + } + let mut unsupported = vec![0; 8]; + unsupported[if caps.rumble { 4 } else { 0 }] = 1; + let error = hardware + .write_value(&packet(unsupported)) + .await + .unwrap_err() + .to_string(); + assert!(error.contains(if caps.rumble { + "does not support trigger rumble" + } else { + "does not support main rumble" + })); + assert!(pad.rumble_calls.lock().unwrap().is_empty()); + assert!(pad.trigger_calls.lock().unwrap().is_empty()); + assert_eq!(*pad.commands.lock().unwrap(), 0); + } + } + + #[tokio::test] + async fn sdl_backend_supported_pair_dispatch() { + for caps in [ + SdlRumbleCapabilities { + rumble: true, + trigger_rumble: false, + }, + SdlRumbleCapabilities { + rumble: false, + trigger_rumble: true, + }, + SdlRumbleCapabilities { + rumble: true, + trigger_rumble: true, + }, + ] { + let (pad, hardware, _) = connect_mock_caps(caps).await; + let state = SdlRumbleState { + low: if caps.rumble { 500 } else { 0 }, + right_trigger: if caps.trigger_rumble { 700 } else { 0 }, + ..Default::default() + }; + hardware + .write_value(&packet( + state + .slots() + .into_iter() + .flat_map(u16::to_le_bytes) + .collect(), + )) + .await + .unwrap(); + hardware.write_value(&packet(vec![0; 8])).await.unwrap(); + assert_eq!( + pad.rumble_calls.lock().unwrap().len(), + if caps.rumble { 2 } else { 0 } + ); + assert_eq!( + pad.trigger_calls.lock().unwrap().len(), + if caps.trigger_rumble { 2 } else { 0 } + ); + if caps.rumble { + assert_eq!( + *pad.rumble_calls.lock().unwrap(), + vec![(500, 0, RUMBLE_DURATION_MS), (0, 0, RUMBLE_DURATION_MS)] + ); + } + if caps.trigger_rumble { + assert_eq!( + *pad.trigger_calls.lock().unwrap(), + vec![(0, 700, RUMBLE_DURATION_MS), (0, 0, RUMBLE_DURATION_MS)] + ); + } + assert_eq!(*pad.commands.lock().unwrap(), 2); + *pad.fail.lock().unwrap() = true; + assert!(hardware.write_value(&packet(vec![0; 8])).await.is_err()); + assert_eq!( + pad.rumble_attempts.lock().unwrap().len(), + if caps.rumble { 3 } else { 0 } + ); + assert_eq!( + pad.trigger_attempts.lock().unwrap().len(), + if caps.trigger_rumble { 3 } else { 0 } + ); + } + } + + #[tokio::test] + async fn hardware_close_and_drop_close_backend_handle() { + // Explicit disconnect closes the backend handle. + { + 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..8af3dd211 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_task.rs @@ -0,0 +1,2101 @@ +// 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 only for the lifetime of a connection. Conversion to buttplug's +//! string address space (`sdl-gamepad-{instance_id}`) happens only at the +//! communication-manager boundary; reconnects may receive a new instance ID +//! and reported name, so identity is connection-scoped. +//! +//! Rumble is armed with a finite duration (the sdl3 crate documents that +//! `u32::MAX` overflows and ends the effect immediately). Each active main or +//! trigger pair is refreshed independently by the thread before expiry, so +//! one-shot ScalarCmd commands hold indefinitely. + +use sdl3::joystick::JoystickId; +use std::{ + 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; + +/// Which independent rumble pairs an opened gamepad reported. Logical output +/// channels, not physical motor counts; can vary by OS/transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct SdlRumbleCapabilities { + pub rumble: bool, + pub trigger_rumble: bool, +} + +impl SdlRumbleCapabilities { + pub fn any(self) -> bool { + self.rumble || self.trigger_rumble + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct SdlRumbleState { + pub low: u16, + pub high: u16, + pub left_trigger: u16, + pub right_trigger: u16, +} + +impl SdlRumbleState { + pub fn slots(&self) -> [u16; 4] { + [self.low, self.high, self.left_trigger, self.right_trigger] + } +} + +/// A gamepad discovered by a scan, with its SDL-reported name (or the +/// deterministic fallback name when the name lookup failed). +#[derive(Debug, Clone)] +pub(crate) struct SdlGamepadDesc { + pub id: JoystickId, + pub name: String, + pub capabilities: SdlRumbleCapabilities, +} + +/// Construct a [JoystickId] from its raw u32 value. `JoystickId` is a type +/// 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} has no rumble capability")] + NoRumbleCapability(JoystickId), + #[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 has_rumble(&self) -> bool; + fn has_rumble_triggers(&self) -> bool; + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String>; + fn rumble_triggers(&mut self, left: u16, right: u16, duration_ms: u32) -> Result<(), String>; + fn connected(&self) -> bool; + /// Default `Unknown` so fakes only override it where relevant. + fn connection_state(&self) -> DriverConnection { + 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>, + }, + #[cfg(test)] + Shutdown { reply: oneshot::Sender<()> }, + SetRumbleState { + id: JoystickId, + generation: u64, + state: SdlRumbleState, + 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 set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + self + .task + .set_rumble_state(self.id, self.generation, state, 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. +/// +/// The loop retains its own sender, so external handle drops do not stop it. +/// Production lives until process exit; tests explicitly use the Shutdown seam. +/// Channel disconnection is a defensive teardown path. +#[derive(Clone)] +pub(crate) struct SdlTaskHandle { + cmd_tx: mpsc::Sender, +} + +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<(SdlOpenedGamepadHandle, SdlRumbleCapabilities), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Open { id, reply }) + .await? + } + + #[cfg(test)] + async fn shutdown(&self) -> Result<(), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::Shutdown { reply }) + .await + } + + pub(crate) async fn set_rumble_state( + &self, + id: JoystickId, + generation: u64, + state: SdlRumbleState, + duration: u32, + ) -> Result<(), SdlTaskError> { + self + .send_and_await(|reply| SdlCommand::SetRumbleState { + id, + generation, + state, + 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_main: (u16, u16), + main_set_at: u64, + last_triggers: (u16, u16), + triggers_set_at: u64, +} + +/// Pure rumble-refresh decision for one independent main or trigger pair: +/// given the last accepted command, when it was armed, and the current time, +/// decide whether that pair must be re-armed. +/// +/// Zero-speed commands never refresh (the pair is stopped; letting the effect +/// lapse is exactly what we want). Non-zero commands re-arm after +/// [`RUMBLE_KEEPALIVE_INTERVAL_MS`], safely before the finite arm duration lapses. +fn refresh_decision(last_rumble: (u16, u16), last_set_at: u64, now_ms: u64) -> Option<(u16, u16)> { + if last_rumble == (0, 0) { + 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.pad.has_rumble() { + let _ = state.pad.rumble(0, 0, RUMBLE_DURATION_MS); + } + if state.pad.has_rumble_triggers() { + let _ = state.pad.rumble_triggers(0, 0, RUMBLE_DURATION_MS); + } + mark_removed(state); +} + +fn teardown(open_pads: &mut HashMap) { + for (_, state) in open_pads.drain() { + stop_and_drop(state); + } +} + +/// The SDL thread's command loop. +fn sdl_thread_loop( + task_tx: SdlTaskHandle, + 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::new(); + for (id, state) in open_pads.iter() { + if let Some(cmd) = refresh_decision(state.last_main, state.main_set_at, now) { + rumbles_to_refresh.push((*id, false, cmd)); + } + if let Some(cmd) = refresh_decision(state.last_triggers, state.triggers_set_at, now) { + rumbles_to_refresh.push((*id, true, cmd)); + } + } + for (id, triggers, (low, high)) in rumbles_to_refresh { + let Some(state) = open_pads.get_mut(&id) else { + continue; + }; + let result = if triggers { + if !state.pad.has_rumble_triggers() { + continue; + } + state.pad.rumble_triggers(low, high, RUMBLE_DURATION_MS) + } else { + if !state.pad.has_rumble() { + continue; + } + state.pad.rumble(low, high, RUMBLE_DURATION_MS) + }; + match result { + Ok(()) => { + if triggers { + state.triggers_set_at = now; + } else { + state.main_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 { + #[cfg(test)] + SdlCommand::Shutdown { reply } => { + teardown(&mut open_pads); + let _ = reply.send(()); + break; + } + SdlCommand::Scan { reply } => { + let result = driver.enumerate().map_err(|e| { + warn!("SDL gamepad enumeration failed: {}", e); + 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. + let capabilities = if let Some(state) = open_pads.get(&id) { + SdlRumbleCapabilities { + rumble: state.pad.has_rumble(), + trigger_rumble: state.pad.has_rumble_triggers(), + } + } else { + let pad = match driver.open(id) { + Ok(pad) => pad, + Err(e) => { + warn!("SDL gamepad {} probe open failed: {}", id.0, e); + return None; + } + }; + let connection = pad.connection_state(); + #[cfg(target_os = "macos")] + if connection == DriverConnection::Wired { + warn!( + "Skipping wired SDL gamepad {} on macOS: wired rumble is not possible without GCController (pair the controller via Bluetooth instead).", + id.0 + ); + return None; + } + #[cfg(not(target_os = "macos"))] + let _ = connection; + let capabilities = SdlRumbleCapabilities { + rumble: pad.has_rumble(), + trigger_rumble: pad.has_rumble_triggers(), + }; + drop(pad); + if !capabilities.any() { + info!("SDL gamepad {} has no rumble capability, skipping", id.0); + return None; + } + capabilities + }; + let name = match driver.name_for_id(id) { + Ok(name) if !name.trim().is_empty() => name, + Ok(_) => { + warn!("SDL gamepad {} name lookup returned an empty name", id.0); + format!("SDL Gamepad {}", id.0) + }, + Err(e) => { + // A failed name lookup never drops the device: log and + // fall back to a deterministic name. + warn!("SDL gamepad {} name lookup failed: {}", id.0, e); + format!("SDL Gamepad {}", id.0) + } + }; + Some(SdlGamepadDesc { id, name, capabilities }) + }) + .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) => { + let capabilities = SdlRumbleCapabilities { + rumble: pad.has_rumble(), + trigger_rumble: pad.has_rumble_triggers(), + }; + if !capabilities.any() { + drop(pad); + let _ = reply.send(Err(SdlTaskError::NoRumbleCapability(id))); + continue; + } + next_generation += 1; + let generation = next_generation; + let (removed_tx, removed_rx) = watch::channel(false); + open_pads.insert( + id, + OpenPadState { + pad, + generation, + removed_tx, + last_main: (0, 0), + main_set_at: now, + last_triggers: (0, 0), + triggers_set_at: now, + }, + ); + let handle = SdlOpenedGamepadHandle { + id, + generation, + task: task_tx.clone(), + removed_rx, + }; + if reply.send(Ok((handle, capabilities))).is_err() { + // The connect waiter is gone (future cancelled): nobody can + // ever command or close this pad. Drop the lease now instead + // of blocking future opens with AlreadyOpen until the device + // physically disappears. + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } + } + } + Err(e) => { + let _ = reply.send(Err(SdlTaskError::Open(e))); + } + } + } + SdlCommand::SetRumbleState { + id, + generation, + state: desired, + 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 now = clock.now_ms(); + let mut errors = Vec::new(); + if state.pad.has_rumble() { + match state.pad.rumble(desired.low, desired.high, duration) { + Ok(()) => { + state.last_main = (desired.low, desired.high); + state.main_set_at = now; + } + Err(e) => errors.push(format!("main: {e}")), + } + } + if state.pad.has_rumble_triggers() { + match state + .pad + .rumble_triggers(desired.left_trigger, desired.right_trigger, duration) + { + Ok(()) => { + state.last_triggers = (desired.left_trigger, desired.right_trigger); + state.triggers_set_at = now; + } + Err(e) => errors.push(format!("triggers: {e}")), + } + } + if errors.is_empty() { + let _ = reply.send(Ok(())); + } else { + let _ = reply.send(Err(SdlTaskError::Rumble(errors.join("; ")))); + if let Some(state) = open_pads.remove(&id) { + stop_and_drop(state); + } + } + } + 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) => { + info!("SDL gamepad thread command channel closed; exiting."); + teardown(&mut open_pads); + 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, +{ + spawn_sdl_task_inner(factory, clock).map(|(handle, _join)| handle) +} + +#[cfg(test)] +fn spawn_sdl_task_with_join( + factory: F, + clock: Box, +) -> Result<(SdlTaskHandle, std::thread::JoinHandle<()>), SdlTaskInitError> +where + F: FnOnce() -> Result, SdlTaskInitError> + Send + 'static, +{ + spawn_sdl_task_inner(factory, clock) +} + +fn spawn_sdl_task_inner( + factory: F, + clock: Box, +) -> Result<(SdlTaskHandle, std::thread::JoinHandle<()>), SdlTaskInitError> +where + F: FnOnce() -> Result, SdlTaskInitError> + Send + 'static, +{ + let (cmd_tx, cmd_rx) = mpsc::channel::(); + let (init_tx, init_rx) = mpsc::channel::>(); + let loop_tx = SdlTaskHandle { + cmd_tx: cmd_tx.clone(), + }; + let join = 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 }, join)) +} + +// --------------------------------------------------------------------------- +// 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 has_rumble(&self) -> bool { + // SAFETY: Pure property-table read of this opened gamepad, exclusively + // owned by this SDL thread, so no concurrent SDL access is possible. + // Missing properties resolve to false; this does not activate motors. + unsafe { self.pad.has_rumble() } + } + + fn has_rumble_triggers(&self) -> bool { + // SAFETY: Pure property-table read of this opened gamepad, exclusively + // owned by this SDL thread, so no concurrent SDL access is possible. + // Missing properties resolve to false; this does not activate motors. + unsafe { self.pad.has_rumble_triggers() } + } + + fn rumble_triggers(&mut self, left: u16, right: u16, duration_ms: u32) -> Result<(), String> { + self + .pad + .set_rumble_triggers(left, right, duration_ms) + .map_err(|e| e.to_string()) + } + + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String> { + self + .pad + .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 set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError>; + async fn close(&self) -> Result<(), SdlTaskError>; + /// Fire-and-forget close usable from synchronous contexts (e.g. `Drop`). + fn close_now(&self); + /// 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<(Arc, SdlRumbleCapabilities), SdlTaskError>; +} + +/// Production opened-gamepad wrapper over the task handle. +#[derive(Debug)] +struct TaskOpenedGamepad { + handle: SdlOpenedGamepadHandle, +} + +#[async_trait] +impl SdlOpenedGamepad for TaskOpenedGamepad { + async fn set_rumble_state( + &self, + state: SdlRumbleState, + duration_ms: u32, + ) -> Result<(), SdlTaskError> { + self.handle.set_rumble_state(state, duration_ms).await + } + + async fn close(&self) -> Result<(), SdlTaskError> { + 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<(Arc, SdlRumbleCapabilities), SdlTaskError> { + match self.publication { + Ok(handle) => { + let (handle, capabilities) = handle.open(id).await?; + Ok((Arc::new(TaskOpenedGamepad { handle }), capabilities)) + } + 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, + trigger_rumble_fail: bool, + rumble_caps: HashMap, + name_override: HashMap, + rumble_attempts: Vec<(JoystickId, u16, u16, u32)>, + trigger_rumble_attempts: Vec<(JoystickId, u16, u16, u32)>, + trigger_rumble_log: Vec<(JoystickId, u16, u16, u32)>, + wired_ids: Vec, + } + + struct FakeDriver(Arc>); + + struct FakeGamepad { + id: JoystickId, + state: Arc>, + } + + impl DriverGamepad for FakeGamepad { + fn has_rumble(&self) -> bool { + self + .state + .lock() + .unwrap() + .rumble_caps + .get(&self.id) + .map(|caps| caps.rumble) + .unwrap_or(true) + } + + fn has_rumble_triggers(&self) -> bool { + self + .state + .lock() + .unwrap() + .rumble_caps + .get(&self.id) + .map(|caps| caps.trigger_rumble) + .unwrap_or(false) + } + + fn rumble_triggers(&mut self, left: u16, right: u16, duration_ms: u32) -> Result<(), String> { + let mut state = self.state.lock().unwrap(); + state + .trigger_rumble_attempts + .push((self.id, left, right, duration_ms)); + if state.trigger_rumble_fail { + return Err("trigger rumble failed".to_owned()); + } + state + .trigger_rumble_log + .push((self.id, left, right, duration_ms)); + Ok(()) + } + + fn rumble(&mut self, low: u16, high: u16, duration_ms: u32) -> Result<(), String> { + let mut state = self.state.lock().unwrap(); + state + .rumble_attempts + .push((self.id, low, high, duration_ms)); + if state.rumble_fail { + return Err("rumble failed".to_owned()); + } + 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( + state + .name_override + .get(&id) + .cloned() + .unwrap_or_else(|| 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) + } + + fn caps(rumble: bool, trigger_rumble: bool) -> SdlRumbleCapabilities { + SdlRumbleCapabilities { + rumble, + trigger_rumble, + } + } + + async fn barrier(handle: &SdlTaskHandle) { + // Two commands guarantee a loop-top periodic pass after the clock change. + handle.scan().await.unwrap(); + handle.scan().await.unwrap(); + } + + #[tokio::test] + async fn sdl_scan_capability_matrix() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1), id(2), id(3), id(4)], + rumble_caps: HashMap::from([ + (id(1), caps(true, false)), + (id(2), caps(false, true)), + (id(3), caps(true, true)), + (id(4), caps(false, false)), + ]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + let found = handle.scan().await.unwrap(); + assert_eq!( + found + .iter() + .map(|pad| (pad.id, pad.capabilities)) + .collect::>(), + vec![ + (id(1), caps(true, false)), + (id(2), caps(false, true)), + (id(3), caps(true, true)) + ] + ); + assert!(state.lock().unwrap().rumble_attempts.is_empty()); + assert!(state.lock().unwrap().trigger_rumble_attempts.is_empty()); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_probe_failure_retries() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1)], + open_fail_ids: vec![id(1)], + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + assert!(handle.scan().await.unwrap().is_empty()); + state.lock().unwrap().open_fail_ids.clear(); + assert_eq!(handle.scan().await.unwrap().len(), 1); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_connect_rechecks_capabilities() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1)], + rumble_caps: HashMap::from([(id(1), caps(true, true))]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + assert_eq!( + handle.scan().await.unwrap()[0].capabilities, + caps(true, true) + ); + state + .lock() + .unwrap() + .rumble_caps + .insert(id(1), caps(true, false)); + let (opened, actual) = handle.open(id(1)).await.unwrap(); + assert_eq!(actual, caps(true, false)); + opened.close().await.unwrap(); + state + .lock() + .unwrap() + .rumble_caps + .insert(id(1), caps(false, false)); + assert!(matches!( + handle.open(id(1)).await, + Err(SdlTaskError::NoRumbleCapability(_)) + )); + state + .lock() + .unwrap() + .rumble_caps + .insert(id(1), caps(false, true)); + assert_eq!(handle.open(id(1)).await.unwrap().1, caps(false, true)); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_name_fallback_matrix() { + let state = Arc::new(Mutex::new(FakeDriverState { + enumerate_ids: vec![id(1), id(2), id(3)], + name_fail_ids: vec![id(2)], + name_override: HashMap::from([ + (id(1), " Valid Pad ".to_owned()), + (id(3), " \t ".to_owned()), + ]), + ..Default::default() + })); + let handle = spawn_fake(state, FakeClock::default()); + assert_eq!( + handle + .scan() + .await + .unwrap() + .iter() + .map(|p| p.name.as_str()) + .collect::>(), + vec![" Valid Pad ", "SDL Gamepad 2", "SDL Gamepad 3"] + ); + handle.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sdl_lifecycle_pair_matrix() { + for capability in [caps(true, false), caps(false, true), caps(true, true)] { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), capability)]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + let (opened, _) = handle.open(id(1)).await.unwrap(); + opened + .set_rumble_state( + SdlRumbleState { + low: 500, + right_trigger: 700, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .unwrap(); + opened + .set_rumble_state(SdlRumbleState::default(), RUMBLE_DURATION_MS) + .await + .unwrap(); + opened.close().await.unwrap(); + { + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.len(), + if capability.rumble { 3 } else { 0 } + ); + assert_eq!( + state.trigger_rumble_attempts.len(), + if capability.trigger_rumble { 3 } else { 0 } + ); + if capability.rumble { + assert_eq!( + state.rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + } + if capability.trigger_rumble { + assert_eq!( + state.trigger_rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + } + } + handle.shutdown().await.unwrap(); + } + } + + #[tokio::test] + async fn sdl_keepalive_pair_matrix() { + for capability in [caps(true, false), caps(false, true), caps(true, true)] { + for active_triggers in [false, true] { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), capability)]), + ..Default::default() + })); + let clock = FakeClock::default(); + let handle = spawn_fake(state.clone(), clock.clone()); + let (opened, _) = handle.open(id(1)).await.unwrap(); + let desired = SdlRumbleState { + low: if active_triggers { 0 } else { 500 }, + right_trigger: if active_triggers { 700 } else { 0 }, + ..Default::default() + }; + opened + .set_rumble_state(desired, RUMBLE_DURATION_MS) + .await + .unwrap(); + clock.advance_to(RUMBLE_KEEPALIVE_INTERVAL_MS + 1); + barrier(&handle).await; + barrier(&handle).await; + { + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.len(), + if capability.rumble { + if active_triggers { 1 } else { 2 } + } else { + 0 + } + ); + assert_eq!( + state.trigger_rumble_attempts.len(), + if capability.trigger_rumble { + if active_triggers { 2 } else { 1 } + } else { + 0 + } + ); + } + handle.shutdown().await.unwrap(); + } + } + } + + #[tokio::test] + async fn sdl_pair_failure_cleanup() { + for main_failure in [false, true] { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), caps(true, true))]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + let (opened, _) = handle.open(id(1)).await.unwrap(); + let removed = opened.removed(); + let desired = SdlRumbleState { + low: 500, + right_trigger: 700, + ..Default::default() + }; + opened + .set_rumble_state(desired, RUMBLE_DURATION_MS) + .await + .unwrap(); + { + let mut state = state.lock().unwrap(); + state.rumble_fail = main_failure; + state.trigger_rumble_fail = !main_failure; + } + assert!(matches!( + opened.set_rumble_state(desired, RUMBLE_DURATION_MS).await, + Err(SdlTaskError::Rumble(_)) + )); + barrier(&handle).await; + assert!(*removed.borrow()); + assert!(matches!( + opened.set_rumble_state(desired, RUMBLE_DURATION_MS).await, + Err(SdlTaskError::Removed(_)) + )); + { + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!( + state.trigger_rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!(state.rumble_attempts.len(), 3); + assert_eq!(state.trigger_rumble_attempts.len(), 3); + } + handle.shutdown().await.unwrap(); + } + } + + async fn shutdown_case(main_failure: bool) { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(1), caps(true, true))]), + ..Default::default() + })); + let driver_state = state.clone(); + let (handle, join) = spawn_sdl_task_with_join( + move || Ok(Box::new(FakeDriver(driver_state))), + Box::new(FakeClock::default()), + ) + .unwrap(); + let (opened, _) = handle.open(id(1)).await.unwrap(); + let removed = opened.removed(); + opened + .set_rumble_state( + SdlRumbleState { + low: 500, + right_trigger: 700, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .unwrap(); + state.lock().unwrap().rumble_fail = main_failure; + tokio::time::timeout(Duration::from_secs(5), handle.shutdown()) + .await + .unwrap() + .unwrap(); + // Bound the join without an uncancellable blocking task: poll + // `is_finished` on the async timer and only call `join` once the thread + // has actually exited, so a hung thread fails the test instead of + // wedging the test runtime. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if join.is_finished() { + join.join().expect("SDL thread should not panic"); + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "SDL thread did not exit after shutdown" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(*removed.borrow()); + let state = state.lock().unwrap(); + assert_eq!( + state.rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!( + state.trigger_rumble_attempts.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + assert_eq!( + state.trigger_rumble_log.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + if !main_failure { + assert_eq!( + state.rumble_log.last(), + Some(&(id(1), 0, 0, RUMBLE_DURATION_MS)) + ); + } + } + + #[tokio::test] + async fn sdl_task_shutdown_stops_both_pairs() { + shutdown_case(true).await; + } + + #[tokio::test] + async fn sdl_task_shutdown_successful_teardown() { + shutdown_case(false).await; + } + + // ------------------------------------------------------------------- + // Scan / name policy + // ------------------------------------------------------------------- + + #[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 + .set_rumble_state( + id(5), + 0, + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect_err("rumble after close should fail"); + assert!( + 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 + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + opened.close().await.expect("close should succeed"); + 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 + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + state.lock().unwrap().connected.insert(id(14), false); + 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_stale_generation_pair_isolation() { + let state = Arc::new(Mutex::new(FakeDriverState { + rumble_caps: HashMap::from([(id(15), caps(true, true))]), + ..Default::default() + })); + let handle = spawn_fake(state.clone(), FakeClock::default()); + + // First lease: open, rumble, close (device stays connected). + let (stale, _) = handle.open(id(15)).await.expect("open should succeed"); + stale + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + stale.close().await.expect("close should succeed"); + let log_len_after_first_lease = state.lock().unwrap().rumble_log.len(); + + // Second lease for the same still-connected id. + let (fresh, _) = handle.open(id(15)).await.expect("reopen should succeed"); + + // Stale-handle rumble is rejected... + let err = stale + .set_rumble_state( + SdlRumbleState { + low: 1, + high: 1, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect_err("stale rumble must fail"); + assert!(matches!(err, SdlTaskError::Removed(_)), "got {err:?}"); + // ...stale close is an Ok no-op that must NOT tear down the new lease... + stale.close().await.expect("stale close is a no-op ok"); + assert_eq!( + state.lock().unwrap().rumble_attempts.len(), + log_len_after_first_lease + ); + assert_eq!( + state.lock().unwrap().trigger_rumble_attempts.len(), + log_len_after_first_lease + ); + // ...and the fresh lease still works. + fresh + .set_rumble_state( + SdlRumbleState { + low: 50, + high: 50, + ..Default::default() + }, + 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 + .set_rumble_state( + id(6), + 0, + SdlRumbleState { + low: 1, + high: 1, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect_err("rumble after removal should fail"); + assert!(matches!(err, SdlTaskError::Removed(_))); + 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 + .set_rumble_state( + SdlRumbleState { + low: 0x8000, + high: 0x7fff, + ..Default::default() + }, + 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 + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + opened + .set_rumble_state(SdlRumbleState::default(), 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 + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + opened.close().await.expect("close should succeed"); + // 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 + .set_rumble_state( + SdlRumbleState { + low: 100, + high: 100, + ..Default::default() + }, + RUMBLE_DURATION_MS, + ) + .await + .expect("rumble should succeed"); + state.lock().unwrap().connected.insert(id(12), false); + 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_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs new file mode 100644 index 000000000..64e315e71 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/tests/sdl_docs_contract_check.rs @@ -0,0 +1,70 @@ +// Buttplug Rust Source Code File - See https://buttplug.io for more info. +// +// Copyright 2016-2026 Nonpolynomial Labs LLC. All rights reserved. +// +// Licensed under the BSD 3-Clause license. See LICENSE file in the project root +// for full license information. + +#[test] +fn sdl_docs_contract_check() { + let text = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")); + let lower = text.to_ascii_lowercase(); + + assert!(lower.contains("main-only"), "missing main-only layout"); + assert!( + lower.contains("trigger-only"), + "missing trigger-only layout" + ); + assert!(lower.contains("both"), "missing both-capabilities layout"); + assert!( + text.contains("SDL Gamepad {instance_id}") || lower.contains("sdl gamepad {instance_id}"), + "missing deterministic fallback name form" + ); + assert!( + lower.contains("neither main rumble nor trigger rumble") && lower.contains("skipped"), + "missing neither-capability skip policy" + ); + assert!( + lower.contains("pure") + && lower.contains("property queries") + && lower.contains("later scan") + && lower.contains("retries"), + "missing retryable property-probe policy" + ); + assert!( + lower.contains("identity is connection-scoped"), + "missing connection-scoped identity limitation" + ); + assert!( + lower.contains("## manual release validation"), + "missing manual release validation section" + ); + assert!( + lower.contains("8 bytes") && lower.contains("four") && lower.contains("logical slots"), + "missing eight-byte four-slot framing" + ); + assert!( + text.contains("--use-sdl-gamepad"), + "missing --use-sdl-gamepad opt-in flag" + ); + assert!( + text.contains("sdl-gamepad-manager"), + "missing sdl-gamepad-manager opt-in feature" + ); + assert!( + lower.contains("simple sdl trigger rumble") + && lower.contains("not\nadaptive-trigger resistance"), + "missing simple-vs-adaptive trigger distinction" + ); + + assert!( + !lower.contains("4 bytes") && !lower.contains("four-byte"), + "README contains stale four-byte protocol framing claim" + ); + assert!( + !lower.contains("every pad has exactly two motors") + && !lower.contains("every gamepad has exactly two motors") + && !lower.contains("all pads have exactly two motors"), + "README claims every SDL pad has exactly two motors" + ); +} diff --git a/crates/buttplug_tests/tests/test_device_protocols.rs b/crates/buttplug_tests/tests/test_device_protocols.rs index 6cfe03305..06d8ad53a 100644 --- a/crates/buttplug_tests/tests/test_device_protocols.rs +++ b/crates/buttplug_tests/tests/test_device_protocols.rs @@ -7,9 +7,147 @@ mod util; //use buttplug::util::async_manager; +use buttplug_client::{ButtplugClient, ButtplugClientDevice, ButtplugClientEvent}; +use buttplug_core::message::OutputType; +use futures::StreamExt; +use std::time::Duration; use test_case::test_case; use util::device_test::DeviceTestCase; +async fn scan_sdl_case(test_case: &DeviceTestCase) -> (ButtplugClient, ButtplugClientDevice) { + let (server, _channels) = util::device_test::client::client_v4::build_server(test_case); + let client = ButtplugClient::new("SDL advertisement test"); + let mut connector = + buttplug_client_in_process::ButtplugInProcessClientConnectorBuilder::default(); + connector.server(server); + client.connect(connector.finish()).await.unwrap(); + let device = { + let mut events = client.event_stream(); + client.start_scanning().await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(ButtplugClientEvent::DeviceAdded(device)) = events.next().await { + break device; + } + } + }) + .await + .expect("SDL device should be discovered") + }; + (client, device) +} + +#[tokio::test] +async fn sdl_selection_harness_propagates_metadata() { + for (file, expected_count) in [ + ("test_sdl_gamepad_main_trigger.yaml", 4), + ("test_sdl_gamepad.yaml", 2), + ] { + let case = load_test_case(file).await; + let (client, device) = scan_sdl_case(&case).await; + assert_eq!( + device + .device_features() + .values() + .filter(|f| f.feature().contains_output(OutputType::Vibrate)) + .count(), + expected_count + ); + client.disconnect().await.unwrap(); + } +} + +#[tokio::test] +async fn sdl_advertised_definition_v4() { + let case = load_test_case("test_sdl_gamepad_main_trigger.yaml").await; + let (client, device) = scan_sdl_case(&case).await; + let features: Vec<_> = device + .device_features() + .values() + .filter(|f| f.feature().contains_output(OutputType::Vibrate)) + .collect(); + assert_eq!( + features + .iter() + .map(|f| f.feature_index()) + .collect::>(), + vec![0, 1, 2, 3] + ); + assert_eq!( + features + .iter() + .map(|f| f.feature().description().as_str()) + .collect::>(), + vec![ + "Low-frequency rumble", + "High-frequency rumble", + "Left-trigger rumble", + "Right-trigger rumble", + ] + ); + client.disconnect().await.unwrap(); +} + +#[tokio::test] +async fn sdl_advertised_definition_v3() { + use util::device_test::client::client_v3::{client, connector}; + let case = load_test_case("test_sdl_gamepad_main_trigger.yaml").await; + let (server, _channels) = util::device_test::client::client_v4::build_server(&case); + let (client, receiver) = client::ButtplugClient::new("SDL v3 advertisement test"); + let mut connector = connector::ButtplugInProcessClientConnectorBuilder::default(); + connector.server(server); + client.connect(connector.finish(), receiver).await.unwrap(); + let mut events = client.event_stream(); + client.start_scanning().await.unwrap(); + let device = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(client::ButtplugClientEvent::DeviceAdded(device)) = events.next().await { + break device; + } + } + }) + .await + .expect("SDL v3 device should be discovered"); + assert_eq!(device.name(), "sdl-gamepad"); + let attributes = device.scalar_attributes(); + assert_eq!( + attributes.iter().map(|a| *a.index()).collect::>(), + vec![0, 1, 2, 3] + ); + assert!( + attributes + .iter() + .all(|a| *a.actuator_type() == OutputType::Vibrate) + ); + client.disconnect().await.unwrap(); + util::device_test::client::client_v3::run_embedded_test_case(&case).await; +} + +#[tokio::test] +async fn sdl_client_channel_routing() { + let case = load_test_case("test_sdl_gamepad_disabled_channel.yaml").await; + let (client, device) = scan_sdl_case(&case).await; + let features: Vec<_> = device + .device_features() + .values() + .filter(|f| f.feature().contains_output(OutputType::Vibrate)) + .collect(); + assert_eq!(features.len(), 3); + assert_eq!( + features + .iter() + .map(|f| f.feature().description().as_str()) + .collect::>(), + vec![ + "Low-frequency rumble", + "Left-trigger rumble", + "Right-trigger rumble", + ] + ); + client.disconnect().await.unwrap(); + util::device_test::client::client_v4::run_embedded_test_case(&case).await; +} + async fn load_test_case(test_file: &str) -> DeviceTestCase { // Load the file list from the test cases directory let test_file_path = @@ -146,10 +284,13 @@ 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")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_sdl_gamepad_main_trigger.yaml" ; "SDL Gamepad Main And Triggers")] +#[test_case("test_sdl_gamepad_triggers_only.yaml" ; "SDL Gamepad Triggers Only")] #[tokio::test] async fn test_device_protocols_embedded_v4(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -278,6 +419,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,10 +551,13 @@ 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")] #[test_case("test_yiciyuan_protocol_fjb02.yaml" ; "Yiciyuan Protocol - FJB-02")] +#[test_case("test_sdl_gamepad_main_trigger.yaml" ; "SDL Gamepad Main And Triggers")] +#[test_case("test_sdl_gamepad_triggers_only.yaml" ; "SDL Gamepad Triggers Only")] #[tokio::test] async fn test_device_protocols_embedded_v3(test_file: &str) { //tracing_subscriber::fmt::init(); @@ -541,6 +686,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 +809,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 +933,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 +1055,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 +1178,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 +1254,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 +1326,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/client/client_v4/mod.rs b/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs index d624ea086..63bab0826 100644 --- a/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs +++ b/crates/buttplug_tests/tests/util/device_test/client/client_v4/mod.rs @@ -166,7 +166,9 @@ async fn run_test_client_command(command: &TestClientCommand, device: &ButtplugC } } -fn build_server(test_case: &DeviceTestCase) -> (ButtplugServer, Vec) { +pub(crate) fn build_server( + test_case: &DeviceTestCase, +) -> (ButtplugServer, Vec) { let base_cfg = if let Some(device_config_file) = &test_case.device_config_file { let config_file_path = std::path::Path::new( &std::env::var("CARGO_MANIFEST_DIR").expect("Should have manifest path"), diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json b/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json new file mode 100644 index 000000000..462a6462c --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/config/sdl_gamepad_disabled_channel.json @@ -0,0 +1,44 @@ +{ + "version": { "major": 5, "minor": 999 }, + "user_configs": { + "devices": [{ + "identifier": { + "address": "SdlDisabledChannelTest", + "protocol": "sdl-gamepad", + "identifier": "sdl-gamepad" + }, + "config": { + "name": "sdl-gamepad", + "id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", + "base_id": "c1d2e3f4-3333-4a7b-8c9d-1e2f3a4b5c6d", + "features": [ + { + "description": "Low-frequency rumble", + "id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "base_id": "f56852c8-cb3b-4703-90b6-6291df0c6314", + "output": { "vibrate": { "value": [0, 65535] } } + }, + { + "description": "High-frequency rumble", + "id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "base_id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8", + "output": { "vibrate": { "value": [0, 65535], "disabled": true } } + }, + { + "description": "Left-trigger rumble", + "id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "base_id": "a1b2c3d4-1111-4e5f-8a6b-9c0d1e2f3a4b", + "output": { "vibrate": { "value": [0, 65535] } } + }, + { + "description": "Right-trigger rumble", + "id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "base_id": "b2c3d4e5-2222-4f6a-9b7c-0d1e2f3a4b5c", + "output": { "vibrate": { "value": [0, 65535] } } + } + ], + "user_config": { "allow": false, "deny": false, "index": 0 } + } + }] + } +} diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad.yaml new file mode 100644 index 000000000..3dea1fbf2 --- /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, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + # Vibrate high motor (feature 1) at max: 65535 = 0xffff. The packet must + # carry BOTH stored speeds (low motor keeps its previous value). + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + # 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, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Write + endpoint: tx + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml new file mode 100644 index 000000000..86ea4c26b --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_disabled_channel.yaml @@ -0,0 +1,47 @@ +devices: + - identifier: + name: "sdl-gamepad" + address: "SdlDisabledChannelTest" + sdl_selection: "__sdl-rumble-and-triggers" + expected_name: "sdl-gamepad" +user_device_config_file: "sdl_gamepad_disabled_channel.json" +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 0.25 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 2 + Speed: 0.75 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x40, 0x00, 0xc0] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml new file mode 100644 index 000000000..52fb6f035 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_main_trigger.yaml @@ -0,0 +1,58 @@ +devices: + - identifier: + name: "sdl-gamepad" + sdl_selection: "__sdl-rumble-and-triggers" + expected_name: "sdl-gamepad" +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 2 + Speed: 0.25 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x40, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 3 + Speed: 0.75 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x80, 0xff, 0xff, 0x00, 0x40, 0x00, 0xc0] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml new file mode 100644 index 000000000..a6cf5fcb3 --- /dev/null +++ b/crates/buttplug_tests/tests/util/device_test/device_test_case/test_sdl_gamepad_triggers_only.yaml @@ -0,0 +1,32 @@ +devices: + - identifier: + name: "sdl-gamepad" + sdl_selection: "__sdl-triggers-only" + expected_name: "sdl-gamepad" +device_commands: + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 0 + Speed: 0.5 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00] + write_with_response: false + - !Messages + device_index: 0 + messages: + - !Vibrate + - Index: 1 + Speed: 1.0 + - !Commands + device_index: 0 + commands: + - !Write + endpoint: tx + data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff] + write_with_response: false diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs index 9dc6230ec..bb2abe9ce 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device.rs @@ -19,7 +19,12 @@ use buttplug_server::device::hardware::{ HardwareUnsubscribeCmd, HardwareWriteCmd, }; -use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier}; +use buttplug_server_device_config::{ + DeviceDefinitionSelection, + Endpoint, + ProtocolCommunicationSpecifier, + SDL_PROTOCOL_NAME, +}; use async_trait::async_trait; use dashmap::DashSet; @@ -51,6 +56,7 @@ pub enum TestHardwareEvent { pub struct TestHardwareConnector { specifier: ProtocolCommunicationSpecifier, hardware: Option, + sdl_selection: Option, } impl TestHardwareConnector { @@ -59,8 +65,14 @@ impl TestHardwareConnector { Self { specifier, hardware: Some(hardware), + sdl_selection: None, } } + + pub fn with_sdl_selection(mut self, selection: Option) -> Self { + self.sdl_selection = selection; + self + } } impl Debug for TestHardwareConnector { @@ -80,18 +92,21 @@ impl HardwareConnector for TestHardwareConnector { async fn connect(&mut self) -> Result, ButtplugDeviceError> { Ok(Box::new(TestHardwareSpecializer::new( self.hardware.take().expect("Test"), + self.sdl_selection.take(), ))) } } pub struct TestHardwareSpecializer { hardware: Option, + sdl_selection: Option, } impl TestHardwareSpecializer { - fn new(hardware: TestDevice) -> Self { + fn new(hardware: TestDevice, sdl_selection: Option) -> Self { Self { hardware: Some(hardware), + sdl_selection, } } } @@ -104,6 +119,7 @@ impl HardwareSpecializer for TestHardwareSpecializer { ) -> Result { let mut device = self.hardware.take().expect("Test"); let mut endpoints = vec![]; + let mut definition_selection = None; if let Some(ProtocolCommunicationSpecifier::BluetoothLE(btle)) = specifiers .iter() .find(|x| matches!(x, ProtocolCommunicationSpecifier::BluetoothLE(_))) @@ -114,6 +130,16 @@ 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); + definition_selection = self.sdl_selection.as_deref().map(|selection| { + DeviceDefinitionSelection::new(SDL_PROTOCOL_NAME, Some(selection), &device.name()) + }); } let hardware = Hardware::new( &device.name(), @@ -124,7 +150,11 @@ impl HardwareSpecializer for TestHardwareSpecializer { false, Box::new(device), ); - Ok(hardware) + Ok(if let Some(selection) = definition_selection { + hardware.with_definition_selection(selection) + } else { + hardware + }) } } diff --git a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs index 608287b21..2a897247e 100644 --- a/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs +++ b/crates/buttplug_tests/tests/util/test_device_manager/test_device_comm_manager.rs @@ -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}; @@ -50,6 +54,8 @@ pub struct TestDeviceIdentifier { name: String, #[serde(default = "generate_address")] address: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + sdl_selection: Option, } impl TestDeviceIdentifier { @@ -60,6 +66,7 @@ impl TestDeviceIdentifier { Self { name: name.to_owned(), address, + sdl_selection: None, } } @@ -133,11 +140,21 @@ 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) + .with_sdl_selection(identifier.sdl_selection.clone()) } pub struct TestDeviceCommunicationManager { diff --git a/crates/intiface_engine/CHANGELOG.md b/crates/intiface_engine/CHANGELOG.md index 448c12b83..1a1ceafbc 100644 --- a/crates/intiface_engine/CHANGELOG.md +++ b/crates/intiface_engine/CHANGELOG.md @@ -1,3 +1,9 @@ +# 4.2.0 (2026-09-05) + +## Features + +- Add `--use-sdl-gamepad` flag (default off): cross-platform gamepad rumble via SDL3, coexisting with XInput on Windows (a warning is logged when both are enabled, since the same physical controller may appear as two devices). Structural inspiration credit: chiefautism's abandoned PR #860. + # 4.1.0 (2026-07-28) ## Features diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index 6a4d2c4e6..2f52f7668 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -36,6 +36,7 @@ buttplug_server_hwmgr_lovense_dongle = { version = "11.0.0", path = "../buttplug buttplug_server_hwmgr_serial = { version = "11.0.0", path = "../buttplug_server_hwmgr_serial" } buttplug_server_hwmgr_websocket = { version = "11.0.0", path = "../buttplug_server_hwmgr_websocket" } buttplug_server_hwmgr_xinput = { version = "11.0.0", path = "../buttplug_server_hwmgr_xinput" } +buttplug_server_hwmgr_sdl_gamepad = { version = "11.0.0", path = "../buttplug_server_hwmgr_sdl_gamepad" } buttplug_transport_websocket_tungstenite = { version = "11.0.0", path = "../buttplug_transport_websocket_tungstenite" } argh = "0.1.19" log = "0.4.33" diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index 7dd773297..6c14e90ce 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -128,6 +128,11 @@ pub struct IntifaceCLIArguments { #[getset(get_copy = "pub")] use_xinput: bool, + /// turn on sdl gamepad (cross-platform) device support (default off) + #[argh(switch)] + #[getset(get_copy = "pub")] + use_sdl_gamepad: bool, + /// turn on lovense connect app device support (off by default) #[argh(switch)] #[getset(get_copy = "pub")] @@ -250,6 +255,7 @@ impl TryFrom for EngineOptions { .use_lovense_dongle_serial(args.use_lovense_dongle_serial()) .use_lovense_dongle_hid(args.use_lovense_dongle_hid()) .use_xinput(args.use_xinput()) + .use_sdl_gamepad(args.use_sdl_gamepad()) .use_lovense_connect(args.use_lovense_connect()) .use_device_websocket_server(args.use_device_websocket_server()) .max_ping_time(args.max_ping_time()) @@ -362,3 +368,26 @@ async fn main() -> Result<(), IntifaceEngineError> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_use_sdl_gamepad_flows_to_registration() { + // argh parses the flag... + let args = IntifaceCLIArguments::from_args(&["intiface-engine"], &["--use-sdl-gamepad"]) + .expect("flag should parse"); + assert!(args.use_sdl_gamepad()); + // ...and the TryFrom conversion into EngineOptions keeps it. + let options = EngineOptions::try_from(args).expect("options should build"); + assert!(options.use_sdl_gamepad()); + + // Without the flag, it's off. + let args = + IntifaceCLIArguments::from_args(&["intiface-engine"], &[]).expect("empty args should parse"); + assert!(!args.use_sdl_gamepad()); + let options = EngineOptions::try_from(args).expect("options should build"); + assert!(!options.use_sdl_gamepad()); + } +} diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index eb56d5dac..0af4918ea 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -20,6 +20,7 @@ use buttplug_server::{ use buttplug_server_device_config::{DeviceConfigurationManager, load_protocol_configs}; use buttplug_server_hwmgr_btleplug::BtlePlugCommunicationManagerBuilder; use buttplug_server_hwmgr_lovense_connect::LovenseConnectServiceCommunicationManagerBuilder; +use buttplug_server_hwmgr_sdl_gamepad::SdlGamepadCommunicationManagerBuilder; use buttplug_server_hwmgr_websocket::WebsocketServerDeviceCommunicationManagerBuilder; use buttplug_transport_websocket_tungstenite::{ ButtplugWebsocketClientTransport, ButtplugWebsocketServerTransportBuilder, @@ -29,6 +30,61 @@ use tokio::sync::broadcast::Sender; // Device communication manager setup gets its own module because the includes and platform // specifics are such a mess. +/// Warning emitted (on Windows) when both XInput and SDL gamepad managers are +/// enabled: the same physical controller can then appear as two Buttplug +/// devices. Pure decision function so it is testable on every platform; the +/// logging call site is Windows-gated. +pub fn gamepad_dual_manager_warning( + use_xinput: bool, + use_sdl_gamepad: bool, +) -> Option<&'static str> { + if use_xinput && use_sdl_gamepad { + Some( + "Both XInput and SDL gamepad managers are enabled; the same physical controller may appear as two devices.", + ) + } else { + None + } +} + +/// Testable core of [`setup_server_device_comm_managers`]: returns the names +/// of the comm manager builders the options select. The real builder starts +/// hardware managers (which `#[cfg(test)]` cannot easily exercise), so the +/// registration decision is mirrored here and asserted against in tests. +#[cfg(test)] +fn selected_comm_manager_names(args: &EngineOptions) -> Vec<&'static str> { + let mut names = vec![]; + if args.use_bluetooth_le() { + names.push("btleplug"); + } + if args.use_lovense_connect() { + names.push("lovense_connect"); + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + if args.use_lovense_dongle_hid() { + names.push("lovense_dongle_hid"); + } + if args.use_serial_port() { + names.push("serial"); + } + if args.use_hid() { + names.push("hid"); + } + #[cfg(target_os = "windows")] + if args.use_xinput() { + names.push("xinput"); + } + } + if args.use_sdl_gamepad() { + names.push("sdl_gamepad"); + } + if args.use_device_websocket_server() { + names.push("device_websocket_server"); + } + names +} + pub fn setup_server_device_comm_managers( args: &EngineOptions, server_builder: &mut ServerDeviceManagerBuilder, @@ -72,6 +128,22 @@ pub fn setup_server_device_comm_managers( } } } + // Cross-platform gamepad support via SDL3. No OS gate: unlike XInput, the + // SDL manager builds everywhere the engine does. + if args.use_sdl_gamepad() { + info!("Including SDL Gamepad Support"); + server_builder.comm_manager(SdlGamepadCommunicationManagerBuilder::default()); + } + // The same physical controller can be picked up by both managers on + // Windows when both flags are set; warn there, where the overlap exists. + // The decision itself runs on every platform (cheap, keeps the helper + // exercised and testable on all OSes); only the logging is Windows-gated. + if let Some(warning) = gamepad_dual_manager_warning(args.use_xinput(), args.use_sdl_gamepad()) { + #[cfg(target_os = "windows")] + warn!("{}", warning); + #[cfg(not(target_os = "windows"))] + let _ = warning; + } if args.use_device_websocket_server() { info!("Including Websocket Server Device Support"); let mut builder = @@ -202,3 +274,41 @@ pub async fn run_server( ); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::EngineOptionsBuilder; + + #[test] + fn dual_gamepad_warning_truth_table() { + // Some(message) exactly when both managers are on; None otherwise. + assert!(gamepad_dual_manager_warning(true, true).is_some()); + assert!(gamepad_dual_manager_warning(true, false).is_none()); + assert!(gamepad_dual_manager_warning(false, true).is_none()); + assert!(gamepad_dual_manager_warning(false, false).is_none()); + + let message = gamepad_dual_manager_warning(true, true).expect("both flags warn"); + assert!(message.contains("XInput") && message.contains("SDL")); + } + + #[test] + fn engine_registers_sdl_manager_iff_flag() { + let with_sdl = EngineOptionsBuilder::default() + .use_sdl_gamepad(true) + .finish(); + assert!( + selected_comm_manager_names(&with_sdl).contains(&"sdl_gamepad"), + "SDL manager must be registered when the flag is set" + ); + + let without_sdl = EngineOptionsBuilder::default().finish(); + assert!( + !selected_comm_manager_names(&without_sdl).contains(&"sdl_gamepad"), + "SDL manager must not be registered when the flag is unset" + ); + + // On all platforms, no OS gate on SDL registration. + assert!(selected_comm_manager_names(&with_sdl).contains(&"sdl_gamepad")); + } +} diff --git a/crates/intiface_engine/src/options.rs b/crates/intiface_engine/src/options.rs index f55250702..7b5a3915f 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -40,6 +40,8 @@ pub struct EngineOptions { #[getset(get_copy = "pub")] use_xinput: bool, #[getset(get_copy = "pub")] + use_sdl_gamepad: bool, + #[getset(get_copy = "pub")] use_lovense_connect: bool, #[getset(get_copy = "pub")] use_device_websocket_server: bool, @@ -84,6 +86,7 @@ pub struct EngineOptionsExternal { pub use_lovense_dongle_serial: bool, pub use_lovense_dongle_hid: bool, pub use_xinput: bool, + pub use_sdl_gamepad: bool, pub use_lovense_connect: bool, pub use_device_websocket_server: bool, pub use_simulated_devices: bool, @@ -117,6 +120,7 @@ impl From for EngineOptions { use_lovense_dongle_serial: other.use_lovense_dongle_serial, use_lovense_dongle_hid: other.use_lovense_dongle_hid, use_xinput: other.use_xinput, + use_sdl_gamepad: other.use_sdl_gamepad, use_lovense_connect: other.use_lovense_connect, use_device_websocket_server: other.use_device_websocket_server, use_simulated_devices: other.use_simulated_devices, @@ -213,6 +217,11 @@ impl EngineOptionsBuilder { self } + pub fn use_sdl_gamepad(&mut self, value: bool) -> &mut Self { + self.options.use_sdl_gamepad = value; + self + } + pub fn use_lovense_connect(&mut self, value: bool) -> &mut Self { self.options.use_lovense_connect = value; self @@ -292,3 +301,26 @@ impl EngineOptionsBuilder { self.options.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn engine_options_use_sdl_gamepad_defaults_false() { + // Derives Default; the SDL gamepad manager is opt-in. + let options = EngineOptions::default(); + assert!(!options.use_sdl_gamepad()); + + // The external form also defaults off (serde). + let external: EngineOptionsExternal = Default::default(); + let from_external = EngineOptions::from(external); + assert!(!from_external.use_sdl_gamepad()); + + // And the builder setter round-trips. + let options = EngineOptionsBuilder::default() + .use_sdl_gamepad(true) + .finish(); + assert!(options.use_sdl_gamepad()); + } +}