Skip to main content

scdsu_core/
lib.rs

1//! Core library for `steam-controller-dsu`.
2//!
3//! This library crate provides the ability to run CemuHook (DSU) server which supplies controller
4//! input state over a UDP connection to various video game console emulators. The main focus is supporting
5//! the Steam Controller 2026 (Triton).
6
7pub mod devices;
8pub mod dsu;
9pub mod errors;
10pub mod reader;
11pub mod server;
12
13pub use server::ServerConfig;
14
15use std::sync::Arc;
16use std::sync::atomic;
17use std::time::Duration;
18
19use crate::devices::Device;
20use crate::errors::{DeviceError, ServerError};
21
22pub(crate) const READ_ATOMIC_BOOL_ORDERING: atomic::Ordering = atomic::Ordering::Relaxed;
23const CONTROLLER_OPEN_RETRY_DELAY_SEC: u64 = 5;
24
25/// Run the server loop until `running` is `false`.
26///
27/// Accepts an [`AtomicBool`](std::sync::atomic::AtomicBool) within an `Arc<>` for signaling when
28/// the server should shut down (set to `false`).
29///
30/// Accepts `config` and `device_config` for specifying ['ServerConfig'](server::ServerConfig) and
31/// ['DeviceConfig'](devices::DeviceConfig).
32pub fn run_server(
33    running: Arc<atomic::AtomicBool>,
34    config: server::ServerConfig,
35    device_config: devices::DeviceConfig,
36) -> Result<(), ServerError> {
37    let mut api = hidapi::HidApi::new()?;
38
39    loop {
40        if !running.load(READ_ATOMIC_BOOL_ORDERING) {
41            return Ok(());
42        }
43
44        if let Err(e) = api.refresh_devices() {
45            log::warn!("Failed to refresh HID device list: {e}");
46        }
47
48        let Some(device) = open_controller_with_retry(
49            running.clone(),
50            &mut api,
51            device_config.clone(),
52            config.device_path.as_deref(),
53        ) else {
54            // Interrupted by signal
55            return Ok(());
56        };
57
58        log::info!("Controller opened. Initializing...");
59        if let Err(e) = device.initialize() {
60            log::error!("Failed to initialize device: {e}");
61            sleep_interruptible(&running, Duration::from_secs(3));
62            continue;
63        }
64        log::info!(
65            "Device initialized. Starting CemuHook server on {}:{} ...",
66            config.bind_addr,
67            config.port
68        );
69
70        // Start the device reader and cemuhook udp server
71        //
72
73        let (reader_handle, rx) = reader::spawn_reader(running.clone(), device);
74
75        let udp_server = server::Server::new(running.clone(), config.clone())?;
76
77        match udp_server.run(rx) {
78            Ok((recv_result, send_result)) => {
79                if let Err(e) = recv_result {
80                    log::error!("UDP server receive loop error: {e}");
81                }
82                if let Err(err) = send_result {
83                    log::error!("UDP server send thread panicked: {err:?}");
84                }
85            }
86            Err(e) => {
87                log::error!("Failed to start the UDP server: {e}");
88            }
89        }
90
91        if let Err(err) = reader_handle.join() {
92            log::error!("Reader thread panicked: {err:?}");
93        }
94
95        if !running.load(READ_ATOMIC_BOOL_ORDERING) {
96            return Ok(());
97        }
98
99        log::info!("Server shut down. Waiting 3 seconds before reconnect...");
100        sleep_interruptible(&running, Duration::from_secs(3));
101    }
102}
103
104/// Runs a debug loop, dumping DSU-compatible frames to stdout for debugging purposes. Like
105/// [`run_server`], it runs until `running` is false.
106///
107/// Accepts an [`AtomicBool`](std::sync::atomic::AtomicBool) within an `Arc<>` for signaling when
108/// the server should shut down. Optionally accepts a `device_path` and `device_config`.
109pub fn run_debug_dump(
110    running: Arc<atomic::AtomicBool>,
111    device_path: Option<&str>,
112    device_config: Option<devices::DeviceConfig>,
113) -> Result<(), DeviceError> {
114    let api = hidapi::HidApi::new()?;
115    let device_config = device_config.unwrap_or_default();
116
117    // If more devices are ever supported, add selection logic
118    let device = devices::triton::Triton::find(device_config, &api, device_path)?;
119
120    log::info!("Controller opened. Running initialization...");
121    device.initialize()?;
122    log::info!("Initialized. Dumping frames...");
123
124    let (reader_handle, rx) = reader::spawn_reader(running.clone(), device);
125
126    while running.load(READ_ATOMIC_BOOL_ORDERING) {
127        match rx.recv() {
128            Ok(frame) => {
129                let buttons_pressed: Vec<&str> = [
130                    ("A", frame.a),
131                    ("B", frame.b),
132                    ("X", frame.x),
133                    ("Y", frame.y),
134                    ("L1", frame.l1),
135                    ("R1", frame.r1),
136                    ("L2", frame.l2),
137                    ("R2", frame.r2),
138                    ("L3", frame.l3),
139                    ("R3", frame.r3),
140                    ("Options", frame.options),
141                    ("Share", frame.share),
142                    ("Home", frame.home),
143                    ("QAM", frame.touch),
144                ]
145                .iter()
146                .filter(|(_, p)| *p)
147                .map(|(n, _)| *n)
148                .collect();
149
150                let dpad_pressed: Vec<&str> = [
151                    ("Up", frame.dpad_up),
152                    ("Down", frame.dpad_down),
153                    ("Left", frame.dpad_left),
154                    ("Right", frame.dpad_right),
155                ]
156                .iter()
157                .filter(|(_, p)| *p)
158                .map(|(n, _)| *n)
159                .collect();
160
161                let buttons_str = if buttons_pressed.is_empty() {
162                    "none".to_string()
163                } else {
164                    buttons_pressed.join(" ")
165                };
166                let dpad_str = if dpad_pressed.is_empty() {
167                    "none".to_string()
168                } else {
169                    dpad_pressed.join(" ")
170                };
171
172                println!(
173                    "Buttons: {buttons_str}\n\
174                     DPad:    {dpad_str}\n\
175                     Sticks:  L({:4},{:4})  R({:4},{:4})\n\
176                     Triggers: L2={:3}  R2={:3}\n\
177                     Accel:   ({:7.3},{:7.3},{:7.3}) g\n\
178                     Gyro:    ({:8.1},{:8.1},{:8.1}) dps",
179                    frame.left_stick_x,
180                    frame.left_stick_y,
181                    frame.right_stick_x,
182                    frame.right_stick_y,
183                    frame.analog_l2,
184                    frame.analog_r2,
185                    frame.accel_x,
186                    frame.accel_y,
187                    frame.accel_z,
188                    frame.gyro_x,
189                    frame.gyro_y,
190                    frame.gyro_z
191                );
192            }
193            Err(e) => {
194                log::error!("Recv error: {e}");
195                break;
196            }
197        }
198    }
199
200    drop(rx);
201    if let Err(err) = reader_handle.join() {
202        log::error!("Reader thread panicked: {err:?}");
203    }
204
205    log::info!("Debug dump finished.");
206    Ok(())
207}
208
209/// Open a controller with unlimited retries
210/// Returns `None` if interrupted.
211fn open_controller_with_retry(
212    running: Arc<atomic::AtomicBool>,
213    api: &mut hidapi::HidApi,
214    device_config: devices::DeviceConfig,
215    device_path: Option<&str>,
216) -> Option<impl devices::Device + use<>> {
217    loop {
218        if !running.load(READ_ATOMIC_BOOL_ORDERING) {
219            return None;
220        }
221
222        // If more devices are ever supported, add selection logic
223
224        // Refresh devices in case any were dis/reconnected
225        if let Err(err) = api.refresh_devices() {
226            log::error!("HidApi failed to refresh_devices: {err:?}");
227            return None;
228        }
229
230        match devices::triton::Triton::find(device_config.clone(), api, device_path) {
231            Ok(d) => return Some(d),
232            Err(e) => {
233                log::warn!(
234                    "Failed to open controller: {e}. Retrying in {} seconds...",
235                    CONTROLLER_OPEN_RETRY_DELAY_SEC
236                );
237                sleep_interruptible(
238                    &running,
239                    Duration::from_secs(CONTROLLER_OPEN_RETRY_DELAY_SEC),
240                );
241            }
242        }
243    }
244}
245
246/// Sleep in 100 ms increments while `running`.
247pub(crate) fn sleep_interruptible(running: &atomic::AtomicBool, total: Duration) {
248    let start = std::time::Instant::now();
249    while start.elapsed() < total {
250        if !running.load(READ_ATOMIC_BOOL_ORDERING) {
251            return;
252        }
253        std::thread::sleep(Duration::from_millis(100).min(total - start.elapsed()));
254    }
255}