From 8fb952daf2787b8fb1c61aa00a324566ebe11f2f Mon Sep 17 00:00:00 2001 From: ada Date: Sun, 5 Apr 2026 01:12:57 +0300 Subject: [PATCH 1/2] Add cross-platform gamepad rumble support via SDL2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate: buttplug_server_hwmgr_sdl_gamepad Adds SDL2-based gamepad haptics as a buttplug device communication manager. Xbox, PlayStation, and Switch controllers appear as XInput-compatible buttplug devices with dual vibration motors (left/right, 65535 steps each). Works on macOS (GCController backend), Windows (XInput/DirectInput), and Linux (evdev) — all from a single SDL2 codebase. Usage: intiface-engine --use-sdl-gamepad --websocket-port 12345 Tested with Xbox Series X controller on macOS via Bluetooth. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 1 + .../Cargo.toml | 31 ++ .../src/lib.rs | 21 ++ .../src/sdl_gamepad_comm_manager.rs | 122 ++++++++ .../src/sdl_gamepad_hardware.rs | 289 ++++++++++++++++++ crates/intiface_engine/Cargo.toml | 1 + crates/intiface_engine/src/bin/main.rs | 8 +- crates/intiface_engine/src/buttplug_server.rs | 7 + crates/intiface_engine/src/options.rs | 9 + 9 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_comm_manager.rs create mode 100644 crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs diff --git a/Cargo.toml b/Cargo.toml index c2722d35a..9b2089dfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ 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/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml new file mode 100644 index 000000000..d7c093500 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "buttplug_server_hwmgr_sdl_gamepad" +version = "10.0.2" +authors = ["chiefautism"] +description = "Buttplug Hardware Manager - SDL2 Gamepad (cross-platform rumble for Xbox/PS/Switch controllers)" +license = "BSD-3-Clause" +homepage = "http://buttplug.io" +repository = "https://github.com/chiefautism/buttplug.git" +keywords = ["gamepad", "rumble", "haptics", "controller", "teledildonics"] +edition = "2024" + +[lib] +name = "buttplug_server_hwmgr_sdl_gamepad" +path = "src/lib.rs" + +[dependencies] +buttplug_core = { version = "10.0.2", path = "../buttplug_core", default-features = false } +buttplug_server = { version = "10.0.2", path = "../buttplug_server", default-features = false } +buttplug_server_device_config = { version = "10.0.3", path = "../buttplug_server_device_config" } +futures = "0.3.32" +futures-util = "0.3.32" +log = "0.4.29" +tokio = { version = "1.50.0", features = ["sync", "time"] } +async-trait = "0.1.89" +tracing = "0.1.44" +thiserror = "2.0.18" +sdl2 = { version = "0.37", features = ["bundled", "static-link"] } +byteorder = "1.5.0" +tokio-util = "0.7.18" +strum = "0.28.0" +strum_macros = "0.28.0" 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..7c729d40e --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/lib.rs @@ -0,0 +1,21 @@ +// Buttplug SDL2 Gamepad Hardware Manager +// +// Cross-platform gamepad rumble/haptics support via SDL2. +// Works on macOS (GCController backend), Windows (XInput/DirectInput), +// and Linux (evdev) — all from a single codebase. +// +// Copyright 2026 chiefautism. BSD-3-Clause license. + +#[macro_use] +extern crate log; + +#[macro_use] +extern crate strum_macros; + +mod sdl_gamepad_comm_manager; +mod sdl_gamepad_hardware; + +pub use sdl_gamepad_comm_manager::{ + SdlGamepadCommunicationManager, + SdlGamepadCommunicationManagerBuilder, +}; diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_comm_manager.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_comm_manager.rs new file mode 100644 index 000000000..fec3eb2e3 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_comm_manager.rs @@ -0,0 +1,122 @@ +// Buttplug SDL2 Gamepad Communication Manager +// +// Scans for connected game controllers via SDL2's GameController API. +// SDL2 types are !Send, so scanning runs on a dedicated thread. + +use super::sdl_gamepad_hardware::SdlGamepadHardwareConnector; +use async_trait::async_trait; +use buttplug_core::errors::ButtplugDeviceError; +use buttplug_server::device::hardware::communication::{ + HardwareCommunicationManager, + HardwareCommunicationManagerBuilder, + HardwareCommunicationManagerEvent, + TimedRetryCommunicationManager, + TimedRetryCommunicationManagerImpl, +}; +use tokio::sync::mpsc; + +#[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, +} + +impl SdlGamepadCommunicationManager { + fn new(sender: mpsc::Sender) -> Self { + Self { sender } + } +} + +/// Info about a discovered gamepad, sent from the SDL scan thread. +struct GamepadInfo { + joystick_index: u32, + name: String, +} + +#[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"); + + // SDL types are !Send, so we scan on a dedicated std thread and send results back. + let (tx, rx) = std::sync::mpsc::channel::(); + + std::thread::spawn(move || { + let sdl = match sdl2::init() { + Ok(s) => s, + Err(e) => { + error!("SDL init failed: {e}"); + return; + } + }; + let gc = match sdl.game_controller() { + Ok(gc) => gc, + Err(e) => { + error!("SDL GameController init failed: {e}"); + return; + } + }; + let num = gc.num_joysticks().unwrap_or(0); + for i in 0..num { + if !gc.is_game_controller(i) { + continue; + } + let name = gc.name_for_index(i).unwrap_or_else(|_| format!("Gamepad {i}")); + let _ = tx.send(GamepadInfo { + joystick_index: i, + name, + }); + } + }); + + // Collect results (thread exits quickly after enumeration) + // Small delay to let the thread finish + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + while let Ok(info) = rx.try_recv() { + let address = format!("sdl-gamepad-{}", info.joystick_index); + debug!("SDL Gamepad found: {} (index {})", info.name, info.joystick_index); + + let device_creator = Box::new(SdlGamepadHardwareConnector::new( + info.joystick_index, + info.name.clone(), + )); + + if self + .sender + .send(HardwareCommunicationManagerEvent::DeviceFound { + name: info.name, + address, + creator: device_creator, + }) + .await + .is_err() + { + error!("Error sending device found from SDL Gamepad manager."); + break; + } + } + + Ok(()) + } + + fn can_scan(&self) -> bool { + true + } +} 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..4dbce1791 --- /dev/null +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -0,0 +1,289 @@ +// Buttplug SDL2 Gamepad Hardware Implementation +// +// Wraps an SDL2 GameController as a buttplug device with vibrate support. +// write_value() receives left/right motor speeds as u16 LE and calls set_rumble(). + +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, +}; +use buttplug_server_device_config::{Endpoint, ProtocolCommunicationSpecifier, XInputSpecifier}; +use byteorder::{LittleEndian, ReadBytesExt}; +use futures::future::{self, BoxFuture, FutureExt}; +use std::{ + fmt::{self, Debug}, + io::Cursor, + sync::Arc, +}; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; + +pub struct SdlGamepadHardwareConnector { + joystick_index: u32, + name: String, +} + +impl SdlGamepadHardwareConnector { + pub fn new(joystick_index: u32, name: String) -> Self { + Self { + joystick_index, + name, + } + } +} + +impl Debug for SdlGamepadHardwareConnector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SdlGamepadHardwareConnector") + .field("joystick_index", &self.joystick_index) + .field("name", &self.name) + .finish() + } +} + +#[async_trait] +impl HardwareConnector for SdlGamepadHardwareConnector { + fn specifier(&self) -> ProtocolCommunicationSpecifier { + // Reuse XInput specifier — gamepad protocol is the same (left/right motor u16) + ProtocolCommunicationSpecifier::XInput(XInputSpecifier::default()) + } + + async fn connect(&mut self) -> Result, ButtplugDeviceError> { + debug!("Creating SDL gamepad device for index {}", self.joystick_index); + + let hardware_internal = SdlGamepadHardware::new(self.joystick_index)?; + let address = format!("sdl-gamepad-{}", self.joystick_index); + + let hardware = Hardware::new( + &self.name, + &address, + &[Endpoint::Tx], + &None, + false, + Box::new(hardware_internal), + ); + + Ok(Box::new(GenericHardwareSpecializer::new(hardware))) + } +} + +/// Holds SDL context and GameController handle. +/// SDL2 GameController is not Send, so we wrap in a thread-local approach +/// using a dedicated thread for SDL operations. +struct SdlWorker { + /// Channel to send rumble commands to the SDL thread + cmd_tx: std::sync::mpsc::Sender, +} + +enum SdlCommand { + Rumble { + left: u16, + right: u16, + duration_ms: u32, + }, + Stop, + Quit, +} + +impl SdlWorker { + fn new(joystick_index: u32) -> Result { + let (cmd_tx, cmd_rx) = std::sync::mpsc::channel::(); + + // SDL must be used from a single thread. Spawn a dedicated thread. + std::thread::spawn(move || { + let sdl = match sdl2::init() { + Ok(s) => s, + Err(e) => { + error!("SDL init failed in worker: {e}"); + return; + } + }; + let gc_subsystem = match sdl.game_controller() { + Ok(gc) => gc, + Err(e) => { + error!("SDL GameController init failed: {e}"); + return; + } + }; + let mut controller = match gc_subsystem.open(joystick_index) { + Ok(c) => c, + Err(e) => { + error!("Failed to open gamepad {joystick_index}: {e}"); + return; + } + }; + + info!( + "SDL Gamepad worker started for '{}' (index {}), rumble: {}", + controller.name(), + joystick_index, + controller.has_rumble() + ); + + // Process commands until Quit + loop { + match cmd_rx.recv() { + Ok(SdlCommand::Rumble { + left, + right, + duration_ms, + }) => { + if let Err(e) = controller.set_rumble(left, right, duration_ms) { + warn!("SDL rumble failed: {e}"); + } + } + Ok(SdlCommand::Stop) => { + let _ = controller.set_rumble(0, 0, 0); + } + Ok(SdlCommand::Quit) | Err(_) => { + let _ = controller.set_rumble(0, 0, 0); + break; + } + } + } + debug!("SDL Gamepad worker exiting for index {joystick_index}"); + }); + + Ok(Self { cmd_tx }) + } + + fn rumble(&self, left: u16, right: u16, duration_ms: u32) { + let _ = self.cmd_tx.send(SdlCommand::Rumble { + left, + right, + duration_ms, + }); + } + + fn stop(&self) { + // Duration must be > 0 for SDL to actually process the rumble-off + let _ = self.cmd_tx.send(SdlCommand::Rumble { left: 0, right: 0, duration_ms: 10 }); + } +} + +impl Drop for SdlWorker { + fn drop(&mut self) { + let _ = self.cmd_tx.send(SdlCommand::Quit); + } +} + +pub struct SdlGamepadHardware { + worker: Arc, + event_sender: broadcast::Sender, + cancellation_token: CancellationToken, +} + +impl SdlGamepadHardware { + pub fn new(joystick_index: u32) -> Result { + let (event_sender, _) = broadcast::channel(256); + let token = CancellationToken::new(); + + let worker = SdlWorker::new(joystick_index)?; + + Ok(Self { + worker: Arc::new(worker), + event_sender, + cancellation_token: token, + }) + } +} + +impl Clone for SdlGamepadHardware { + fn clone(&self) -> Self { + Self { + worker: self.worker.clone(), + event_sender: self.event_sender.clone(), + cancellation_token: self.cancellation_token.clone(), + } + } +} + +impl Debug for SdlGamepadHardware { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SdlGamepadHardware").finish() + } +} + +impl HardwareInternal for SdlGamepadHardware { + fn event_stream(&self) -> broadcast::Receiver { + self.event_sender.subscribe() + } + + fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + self.worker.stop(); + future::ready(Ok(())).boxed() + } + + fn read_value( + &self, + _msg: &HardwareReadCmd, + ) -> BoxFuture<'static, Result> { + // No battery reading support for SDL gamepads (yet) + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL Gamepad does not support read".to_owned(), + ))) + .boxed() + } + + fn write_value( + &self, + msg: &HardwareWriteCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + // Data format: [left_motor_u16_le, right_motor_u16_le] + // Same as XInput protocol + let data = msg.data().clone(); + let worker = self.worker.clone(); + + async move { + let mut cursor = Cursor::new(data); + let left_motor_speed = cursor + .read_u16::() + .expect("Packed in protocol, infallible"); + let right_motor_speed = cursor + .read_u16::() + .expect("Packed in protocol, infallible"); + + // SDL rumble duration: use 1000ms as default, the next write_value will override + worker.rumble(left_motor_speed, right_motor_speed, 1000); + Ok(()) + } + .boxed() + } + + fn subscribe( + &self, + _msg: &HardwareSubscribeCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL Gamepad does not support subscribe".to_owned(), + ))) + .boxed() + } + + fn unsubscribe( + &self, + _msg: &HardwareUnsubscribeCmd, + ) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { + future::ready(Err(ButtplugDeviceError::UnhandledCommand( + "SDL Gamepad does not support unsubscribe".to_owned(), + ))) + .boxed() + } +} + +impl Drop for SdlGamepadHardware { + fn drop(&mut self) { + self.cancellation_token.cancel(); + } +} diff --git a/crates/intiface_engine/Cargo.toml b/crates/intiface_engine/Cargo.toml index d0e715a0d..b4f3769ef 100644 --- a/crates/intiface_engine/Cargo.toml +++ b/crates/intiface_engine/Cargo.toml @@ -36,6 +36,7 @@ buttplug_server_hwmgr_lovense_dongle = { version = "10.0.2", path = "../buttplug buttplug_server_hwmgr_serial = { version = "10.0.2", path = "../buttplug_server_hwmgr_serial" } buttplug_server_hwmgr_websocket = { version = "10.0.2", path = "../buttplug_server_hwmgr_websocket" } buttplug_server_hwmgr_xinput = { version = "10.0.2", path = "../buttplug_server_hwmgr_xinput" } +buttplug_server_hwmgr_sdl_gamepad = { version = "10.0.2", path = "../buttplug_server_hwmgr_sdl_gamepad" } buttplug_transport_websocket_tungstenite = { version = "10.0.2", path = "../buttplug_transport_websocket_tungstenite" } argh = "0.1.18" log = "0.4.29" diff --git a/crates/intiface_engine/src/bin/main.rs b/crates/intiface_engine/src/bin/main.rs index c7de7e420..fd675908a 100644 --- a/crates/intiface_engine/src/bin/main.rs +++ b/crates/intiface_engine/src/bin/main.rs @@ -118,11 +118,16 @@ pub struct IntifaceCLIArguments { #[getset(get_copy = "pub")] use_lovense_dongle_hid: bool, - /// turn off xinput gamepad device support (windows only) + /// turn on xinput gamepad device support (windows only) #[argh(switch)] #[getset(get_copy = "pub")] use_xinput: bool, + /// turn on SDL2 gamepad rumble support (cross-platform: macOS, Windows, Linux) + #[argh(switch)] + #[getset(get_copy = "pub")] + use_sdl_gamepad: bool, + /// turn on lovense connect app device support (off by default) #[argh(switch)] #[getset(get_copy = "pub")] @@ -246,6 +251,7 @@ impl TryFrom for EngineOptions { .use_lovense_dongle_serial(args.use_lovense_dongle_serial()) .use_lovense_dongle_hid(args.use_lovense_dongle_hid()) .use_xinput(args.use_xinput()) + .use_sdl_gamepad(args.use_sdl_gamepad()) .use_lovense_connect(args.use_lovense_connect()) .use_device_websocket_server(args.use_device_websocket_server()) .max_ping_time(args.max_ping_time()) diff --git a/crates/intiface_engine/src/buttplug_server.rs b/crates/intiface_engine/src/buttplug_server.rs index 8cd501a52..23eb86adf 100644 --- a/crates/intiface_engine/src/buttplug_server.rs +++ b/crates/intiface_engine/src/buttplug_server.rs @@ -71,6 +71,13 @@ pub fn setup_server_device_comm_managers( server_builder.comm_manager(XInputDeviceCommunicationManagerBuilder::default()); } } + { + use buttplug_server_hwmgr_sdl_gamepad::SdlGamepadCommunicationManagerBuilder; + if args.use_sdl_gamepad() { + info!("Including SDL Gamepad Support (cross-platform rumble)"); + server_builder.comm_manager(SdlGamepadCommunicationManagerBuilder::default()); + } + } } if args.use_device_websocket_server() { info!("Including Websocket Server Device Support"); diff --git a/crates/intiface_engine/src/options.rs b/crates/intiface_engine/src/options.rs index 857d96b3f..a5a68c9b8 100644 --- a/crates/intiface_engine/src/options.rs +++ b/crates/intiface_engine/src/options.rs @@ -42,6 +42,8 @@ pub struct EngineOptions { #[getset(get_copy = "pub")] use_xinput: bool, #[getset(get_copy = "pub")] + use_sdl_gamepad: bool, + #[getset(get_copy = "pub")] use_lovense_connect: bool, #[getset(get_copy = "pub")] use_device_websocket_server: bool, @@ -83,6 +85,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 device_websocket_server_port: Option, @@ -115,6 +118,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, device_websocket_server_port: other.device_websocket_server_port, @@ -209,6 +213,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 From 574c503d88ef328e616326ae92a1a816bf8b0c6c Mon Sep 17 00:00:00 2001 From: ada Date: Sun, 5 Apr 2026 01:25:57 +0300 Subject: [PATCH 2/2] Fix SDL gamepad stop: drain pending commands before applying rumble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buttplug sends separate write_value calls for each motor (left then right). Without draining, a stop sequence would send rumble(65535, 0, 10000) then rumble(0, 0, 10000) — the first call re-activates vibration before the second can cancel it. Now the SDL worker thread waits 5ms and drains all pending commands before applying, so both motor values arrive as a single set_rumble() call. Also: SDL on macOS requires duration > 0 to override active rumble, so stop now sends rumble(0, 0, 10000) instead of rumble(0, 0, 1). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sdl_gamepad_hardware.rs | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs index 4dbce1791..d900fe835 100644 --- a/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs +++ b/crates/buttplug_server_hwmgr_sdl_gamepad/src/sdl_gamepad_hardware.rs @@ -131,23 +131,47 @@ impl SdlWorker { controller.has_rumble() ); - // Process commands until Quit + // Process commands until Quit. + // Buttplug sends separate write_value calls for each motor, so we + // drain all pending commands before applying rumble to avoid + // intermediate states (e.g. left=65535,right=0 followed by left=0,right=0). loop { match cmd_rx.recv() { Ok(SdlCommand::Rumble { - left, - right, - duration_ms, + mut left, + mut right, + mut duration_ms, }) => { + // Small delay to let both motor commands arrive before processing + std::thread::sleep(std::time::Duration::from_millis(5)); + // Drain any additional pending commands + while let Ok(next) = cmd_rx.try_recv() { + match next { + SdlCommand::Rumble { left: l, right: r, duration_ms: d } => { + left = l; + right = r; + duration_ms = d; + } + SdlCommand::Stop => { + left = 0; + right = 0; + duration_ms = 10000; + } + SdlCommand::Quit => { + let _ = controller.set_rumble(0, 0, 10000); + return; + } + } + } if let Err(e) = controller.set_rumble(left, right, duration_ms) { warn!("SDL rumble failed: {e}"); } } Ok(SdlCommand::Stop) => { - let _ = controller.set_rumble(0, 0, 0); + let _ = controller.set_rumble(0, 0, 10000); } Ok(SdlCommand::Quit) | Err(_) => { - let _ = controller.set_rumble(0, 0, 0); + let _ = controller.set_rumble(0, 0, 10000); break; } } @@ -221,7 +245,8 @@ impl HardwareInternal for SdlGamepadHardware { } fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> { - self.worker.stop(); + info!("SDL Gamepad: disconnect() called — stopping rumble"); + self.worker.rumble(0, 0, 10000); future::ready(Ok(())).boxed() } @@ -254,8 +279,10 @@ impl HardwareInternal for SdlGamepadHardware { .read_u16::() .expect("Packed in protocol, infallible"); - // SDL rumble duration: use 1000ms as default, the next write_value will override - worker.rumble(left_motor_speed, right_motor_speed, 1000); + info!("SDL Gamepad: write_value left={} right={}", left_motor_speed, right_motor_speed); + + // Always use long duration — SDL on macOS ignores short-duration zero rumble. + worker.rumble(left_motor_speed, right_motor_speed, 10000); Ok(()) } .boxed()