Skip to content
Open

Sdl3 #949

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions crates/buttplug_client_in_process/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand All @@ -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"
Expand Down
63 changes: 55 additions & 8 deletions crates/buttplug_client_in_process/src/in_process_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,41 @@ 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")]
{
use buttplug_server_hwmgr_websocket::WebsocketServerDeviceCommunicationManagerBuilder;
device_manager_builder.comm_manager(
WebsocketServerDeviceCommunicationManagerBuilder::default().listen_on_all_interfaces(true),
);
registered.push("websocket-server");
}
#[cfg(all(
feature = "serial-manager",
Expand All @@ -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",
Expand All @@ -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
}
5 changes: 5 additions & 0 deletions crates/buttplug_server/src/device/protocol_impl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -593,6 +594,10 @@ pub fn get_default_protocol_map() -> HashMap<String, Arc<dyn ProtocolIdentifierF
);
add_to_protocol_map(&mut map, xibao::setup::XibaoIdentifierFactory::default());
add_to_protocol_map(&mut map, xinput::setup::XInputIdentifierFactory::default());
add_to_protocol_map(
&mut map,
sdl_gamepad::setup::SdlGamepadIdentifierFactory::default(),
);
add_to_protocol_map(
&mut map,
xiuxiuda::setup::XiuxiudaIdentifierFactory::default(),
Expand Down
118 changes: 118 additions & 0 deletions crates/buttplug_server/src/device/protocol_impl/sdl_gamepad.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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_server_device_config::Endpoint;
use byteorder::LittleEndian;

use crate::device::{
hardware::{HardwareCommand, HardwareWriteCmd},
protocol::{ProtocolHandler, generic_protocol_setup},
};
use buttplug_core::errors::ButtplugDeviceError;
use byteorder::WriteBytesExt;
use std::sync::atomic::{AtomicU16, Ordering};

generic_protocol_setup!(SdlGamepad, "sdl-gamepad");

/// SDL3 gamepad rumble protocol.
///
/// Like XInput, every vibrate command carries the *complete* motor state: the
/// handler keeps the last-set speed of both motors and packs both u16 values
/// (little-endian) into every write packet, so every `write_value` is a full
/// command and no batching/drain step is needed on the hardware side.
///
/// Packet layout (4 bytes, little-endian):
/// bytes 0-1: low-frequency motor speed (feature 0), 0-65535
/// bytes 2-3: high-frequency motor speed (feature 1), 0-65535
#[derive(Default)]
pub struct SdlGamepad {
speeds: [AtomicU16; 2],
}

impl ProtocolHandler for SdlGamepad {
fn handle_output_vibrate_cmd(
&self,
feature_index: u32,
_feature_id: uuid::Uuid,
speed: u32,
) -> Result<Vec<HardwareCommand>, ButtplugDeviceError> {
if feature_index > 1 {
return Err(ButtplugDeviceError::ProtocolSpecificError(
"SdlGamepad".to_owned(),
format!("SDL gamepad only has 2 vibrate features, got index {feature_index}"),
));
}
self.speeds[feature_index as usize].store(speed as u16, Ordering::Relaxed);
let mut cmd = vec![];
if cmd
.write_u16::<LittleEndian>(self.speeds[0].load(Ordering::Relaxed))
.is_err()
|| cmd
.write_u16::<LittleEndian>(self.speeds[1].load(Ordering::Relaxed))
.is_err()
{
return Err(ButtplugDeviceError::ProtocolSpecificError(
"SdlGamepad".to_owned(),
"Cannot convert SDL gamepad value for processing".to_owned(),
));
}
Ok(vec![
HardwareWriteCmd::new(&[_feature_id], Endpoint::Tx, cmd, false).into(),
])
}
}

#[cfg(test)]
mod tests {
use super::*;

fn vibrate(handler: &SdlGamepad, feature_index: u32, speed: u32) -> Vec<u8> {
let cmds = handler
.handle_output_vibrate_cmd(feature_index, uuid::Uuid::new_v4(), speed)
.expect("vibrate command should build");
assert_eq!(cmds.len(), 1);
match &cmds[0] {
HardwareCommand::Write(write_cmd) => {
assert_eq!(write_cmd.endpoint(), Endpoint::Tx);
write_cmd.data().clone()
}
_ => panic!("expected a write command"),
}
}

#[test]
fn sdl_gamepad_packs_both_motor_states() {
let handler = SdlGamepad::default();

// Feature 0 (low motor) only: high motor stays 0.
let packet = vibrate(&handler, 0, 0x8000);
assert_eq!(packet, vec![0x00, 0x80, 0x00, 0x00]);

// Feature 1 (high motor) now set: packet must carry BOTH stored speeds,
// proving the handler is stateful across commands.
let packet = vibrate(&handler, 1, 0x7fff);
assert_eq!(packet, vec![0x00, 0x80, 0xff, 0x7f]);

// Updating feature 0 again keeps feature 1's stored speed.
let packet = vibrate(&handler, 0, 0x1234);
assert_eq!(packet, vec![0x34, 0x12, 0xff, 0x7f]);

// Speeds clamp to u16 in the same way as XInput (store as u16).
let packet = vibrate(&handler, 0, 0xffff);
assert_eq!(packet, vec![0xff, 0xff, 0xff, 0x7f]);
}

#[test]
fn sdl_gamepad_rejects_out_of_range_feature() {
let handler = SdlGamepad::default();
assert!(
handler
.handle_output_vibrate_cmd(2, uuid::Uuid::new_v4(), 100)
.is_err()
);
}
}
6 changes: 6 additions & 0 deletions crates/buttplug_server_device_config/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/buttplug_server_device_config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": {
"major": 5,
"minor": 42
"minor": 43
},
"protocols": {
"activejoy": {
Expand Down Expand Up @@ -20565,6 +20565,45 @@
"name": "SayberX Device"
}
},
"sdl-gamepad": {
"communication": [
{
"sdl-gamepad": {
"exists": true
}
}
],
"defaults": {
"features": [
{
"id": "f56852c8-cb3b-4703-90b6-6291df0c6314",
"index": 0,
"output": {
"vibrate": {
"value": [
0,
65535
]
}
}
},
{
"id": "e13388f9-a1b6-4c4c-a7b4-c68eeed293d8",
"index": 1,
"output": {
"vibrate": {
"value": [
0,
65535
]
}
}
}
],
"id": "b35f2adf-16bc-4425-9276-5d191aeaf107",
"name": "SDL Gamepad"
}
},
"sensee": {
"communication": [
{
Expand Down
Loading