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