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