xinput_mapper/
lib.rs

1//! Functional helpers to convert a DInput->XInput YAML mapping into an XInput-like state.
2//! - Pure functions; no hidden global state
3//! - YAML schema matches the one produced by `dinput_mapper`
4//! - Comments are in English by request
5
6mod utils;
7pub use utils::*;
8
9use serde::Deserialize;
10use thiserror::Error;
11
12/* =============================== YAML Types =============================== */
13
14#[derive(Debug, Deserialize)]
15pub struct MappingYaml {
16    pub device: Device,
17    pub axes: Axes,
18    #[serde(default)]
19    pub triggers: Option<Triggers>,
20    #[serde(default)]
21    pub dpad: Option<Dpad>,
22    #[serde(default)]
23    pub buttons: Option<Buttons>,
24}
25
26#[derive(Debug, Deserialize)]
27pub struct Device {
28    pub name: String,
29    pub vid: String,
30    pub pid: String,
31}
32
33#[derive(Debug, Deserialize, Clone, Copy)]
34pub struct Axis {
35    pub report_offset: usize,
36    pub size_bits: u8,
37    pub logical_min: i32,
38    pub logical_max: i32,
39    pub inverted: bool,
40}
41
42#[derive(Debug, Deserialize)]
43pub struct Axes {
44    #[serde(default)]
45    pub left_x: Option<Axis>,
46    #[serde(default)]
47    pub left_y: Option<Axis>,
48    #[serde(default)]
49    pub right_x: Option<Axis>,
50    #[serde(default)]
51    pub right_y: Option<Axis>,
52}
53
54#[derive(Debug, Deserialize, Clone, Copy)]
55pub struct TriggerDef {
56    pub report_offset: usize,
57    pub size_bits: u8,
58    pub logical_min: i32,
59    pub logical_max: i32,
60}
61
62#[derive(Debug, Deserialize)]
63pub struct Triggers {
64    #[serde(default)]
65    pub left_trigger: Option<TriggerDef>,
66    #[serde(default)]
67    pub right_trigger: Option<TriggerDef>,
68    // Optional future: combined axis; ignored if present
69    #[serde(default)]
70    pub combined_axis: Option<Axis>,
71}
72
73#[derive(Debug, Deserialize)]
74pub struct Dpad {
75    #[serde(rename = "type")]
76    pub dtype: String, // "hat"
77    pub report_offset: usize,
78    pub logical_min: i32, // usually 0
79    pub logical_max: i32, // usually 7
80    pub neutral: u8, // e.g., 8 or 15
81}
82
83#[derive(Debug, Deserialize, Default)]
84pub struct Buttons(pub std::collections::BTreeMap<String, usize>);
85
86/* ============================== XInput Types ============================== */
87
88/// XInput button bit flags (matches Windows XINPUT header values)
89#[allow(non_snake_case)]
90pub mod XButtons {
91    pub const DPAD_UP: u16 = 0x0001;
92    pub const DPAD_DOWN: u16 = 0x0002;
93    pub const DPAD_LEFT: u16 = 0x0004;
94    pub const DPAD_RIGHT: u16 = 0x0008;
95    pub const START: u16 = 0x0010;
96    pub const BACK: u16 = 0x0020;
97    pub const LEFT_THUMB: u16 = 0x0040;
98    pub const RIGHT_THUMB: u16 = 0x0080;
99    pub const LEFT_SHOULDER: u16 = 0x0100;
100    pub const RIGHT_SHOULDER: u16 = 0x0200;
101    // 0x0400 and 0x0800 reserved in old headers
102    pub const A: u16 = 0x1000;
103    pub const B: u16 = 0x2000;
104    pub const X: u16 = 0x4000;
105    pub const Y: u16 = 0x8000;
106}
107
108/// Minimal XInput-like state we produce.
109/// - Sticks are i16 in the standard range [-32768, 32767]
110/// - Triggers are u8 [0..255]
111/// - Buttons are packed into a u16 mask using XButtons.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct XInputState {
114    pub buttons: u16,
115    pub left_trigger: u8,
116    pub right_trigger: u8,
117    pub thumb_lx: i16,
118    pub thumb_ly: i16,
119    pub thumb_rx: i16,
120    pub thumb_ry: i16,
121}
122
123/* ================================ Errors ================================= */
124
125/// Distinguish IO vs YAML parse errors for file loading.
126#[derive(Debug, Error)]
127pub enum LoadError {
128    #[error("io error: {0}")] Io(#[from] std::io::Error),
129    #[error("yaml error: {0}")] Yaml(#[from] serde_yaml::Error),
130}
131
132/* =========================== Public API Functions ========================== */
133
134/// Parse YAML string into MappingYaml.
135pub fn parse_mapping_yaml_str(s: &str) -> Result<MappingYaml, serde_yaml::Error> {
136    serde_yaml::from_str::<MappingYaml>(s)
137}
138
139/// Parse YAML file content into MappingYaml.
140/// Returns `LoadError` to separate IO and YAML failures.
141pub fn parse_mapping_yaml_file(path: &std::path::Path) -> Result<MappingYaml, LoadError> {
142    let txt = std::fs::read_to_string(path)?; // io::Error -> LoadError::Io
143    let mapping = parse_mapping_yaml_str(&txt)?; // serde_yaml::Error -> LoadError::Yaml
144    Ok(mapping)
145}
146
147/// Convert a raw HID input report to an XInput-like state using the mapping.
148/// This is a *pure* function: output depends only on (mapping, report).
149pub fn map_report_to_xinput(m: &MappingYaml, report: &[u8]) -> XInputState {
150    // Axes
151    let lx = m.axes.left_x.map(|a| read_axis_to_i16(report, a)).unwrap_or(0);
152    let ly = m.axes.left_y.map(|a| read_axis_to_i16(report, a)).unwrap_or(0);
153    let rx = m.axes.right_x.map(|a| read_axis_to_i16(report, a)).unwrap_or(0);
154    let ry = m.axes.right_y.map(|a| read_axis_to_i16(report, a)).unwrap_or(0);
155
156    // Triggers
157    let (lt, rt) = if let Some(tr) = &m.triggers {
158        (
159            tr.left_trigger.map(|t| read_trigger_to_u8(report, t)).unwrap_or(0),
160            tr.right_trigger.map(|t| read_trigger_to_u8(report, t)).unwrap_or(0),
161        )
162    } else {
163        (0, 0)
164    };
165
166    // Buttons
167    let mut mask: u16 = 0;
168    if let Some(btns) = &m.buttons {
169        for (name, bit_index) in btns.0.iter() {
170            if is_bit_set_flat(report, *bit_index) {
171                match name.as_str() {
172                    "a" => {
173                        mask |= XButtons::A;
174                    }
175                    "b" => {
176                        mask |= XButtons::B;
177                    }
178                    "x" => {
179                        mask |= XButtons::X;
180                    }
181                    "y" => {
182                        mask |= XButtons::Y;
183                    }
184                    "lb" => {
185                        mask |= XButtons::LEFT_SHOULDER;
186                    }
187                    "rb" => {
188                        mask |= XButtons::RIGHT_SHOULDER;
189                    }
190                    "back" => {
191                        mask |= XButtons::BACK;
192                    }
193                    "start" => {
194                        mask |= XButtons::START;
195                    }
196                    "lt_click" | "ls" | "left_thumb" => {
197                        mask |= XButtons::LEFT_THUMB;
198                    }
199                    "rt_click" | "rs" | "right_thumb" => {
200                        mask |= XButtons::RIGHT_THUMB;
201                    }
202                    _ => {}
203                }
204            }
205        }
206    }
207
208    // DPAD (hat)
209    if let Some(h) = &m.dpad {
210        if h.report_offset < report.len() && h.dtype == "hat" {
211            let v = report[h.report_offset];
212            let (up, right, down, left) = decode_hat_8way(v, h.neutral);
213            if up {
214                mask |= XButtons::DPAD_UP;
215            }
216            if right {
217                mask |= XButtons::DPAD_RIGHT;
218            }
219            if down {
220                mask |= XButtons::DPAD_DOWN;
221            }
222            if left {
223                mask |= XButtons::DPAD_LEFT;
224            }
225        }
226    }
227
228    XInputState {
229        buttons: mask,
230        left_trigger: lt,
231        right_trigger: rt,
232        thumb_lx: lx,
233        thumb_ly: ly,
234        thumb_rx: rx,
235        thumb_ry: ry,
236    }
237}
238
239/* ============================== Helper Logic ============================== */
240
241/// Read an axis (8 or 16 bits) and normalize to i16 [-32768..32767].
242/// SAFE inversion: do math in i32, invert there, then clamp to i16 to avoid -i16::MIN overflow.
243fn read_axis_to_i16(report: &[u8], ax: Axis) -> i16 {
244    let raw = read_unsigned(report, ax.report_offset, ax.size_bits).unwrap_or(0);
245
246    // Scale in i32
247    let mut v = scale_i32(raw as i32, ax.logical_min, ax.logical_max, -32768, 32767);
248
249    // Invert in i32 (handles the -32768 case; will be clamped below)
250    if ax.inverted {
251        v = -v;
252    }
253
254    // Clamp to i16 range and cast
255    v = v.clamp(i16::MIN as i32, i16::MAX as i32);
256    v as i16
257}
258
259/// Read a trigger (8 or 16 bits) and normalize to u8 [0..255].
260fn read_trigger_to_u8(report: &[u8], t: TriggerDef) -> u8 {
261    let raw = read_unsigned(report, t.report_offset, t.size_bits).unwrap_or(0);
262    scale_i32(raw as i32, t.logical_min, t.logical_max, 0, 255) as u8
263}
264
265/// Scales value from [src_min..src_max] to [dst_min..dst_max] with clamping.
266fn scale_i32(v: i32, src_min: i32, src_max: i32, dst_min: i32, dst_max: i32) -> i32 {
267    if src_max <= src_min {
268        return if dst_min <= dst_max { dst_min } else { dst_max };
269    }
270    let v_clamped = v.clamp(src_min, src_max);
271    let num = ((v_clamped - src_min) as i64) * ((dst_max - dst_min) as i64);
272    let den = (src_max - src_min) as i64;
273    ((dst_min as i64) + num / den) as i32
274}
275
276/// Read little-endian unsigned of size_bits {8,16}. Returns None if out-of-bounds/unsupported.
277fn read_unsigned(report: &[u8], offset: usize, size_bits: u8) -> Option<u32> {
278    match size_bits {
279        8 =>
280            report
281                .get(offset)
282                .copied()
283                .map(|b| b as u32),
284        16 => {
285            let lo = *report.get(offset)? as u32;
286            let hi = *report.get(offset + 1)? as u32;
287            Some(lo | (hi << 8))
288        }
289        _ => None,
290    }
291}
292
293/// Returns whether a flat bit index (byte*8 + bit) is set in the report.
294fn is_bit_set_flat(report: &[u8], flat_idx: usize) -> bool {
295    let byte = flat_idx / 8;
296    let bit = (flat_idx % 8) as u8;
297    if byte >= report.len() {
298        return false;
299    }
300    (report[byte] & (1 << bit)) != 0
301}
302
303/// Map an 8-way HAT value (0..7) into 4 cardinal booleans. `neutral` is ignored as "no press".
304fn decode_hat_8way(v: u8, neutral: u8) -> (bool, bool, bool, bool) {
305    if v == neutral {
306        return (false, false, false, false);
307    }
308    let up = v == 0 || v == 1 || v == 7;
309    let right = v == 1 || v == 2 || v == 3;
310    let down = v == 3 || v == 4 || v == 5;
311    let left = v == 5 || v == 6 || v == 7;
312    (up, right, down, left)
313}
314
315/* ================================== Tests ================================== */
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use pretty_assertions::assert_eq;
321
322    fn sample_yaml() -> &'static str {
323        r#"device:
324  name: "Sample"
325  vid: "0x1234"
326  pid: "0xabcd"
327axes:
328  left_x: { report_offset: 0, size_bits: 8, logical_min: 0, logical_max: 255, inverted: false }
329  left_y: { report_offset: 1, size_bits: 8, logical_min: 0, logical_max: 255, inverted: true }
330  right_x: { report_offset: 2, size_bits: 8, logical_min: 0, logical_max: 255, inverted: false }
331  right_y: { report_offset: 3, size_bits: 8, logical_min: 0, logical_max: 255, inverted: true }
332triggers:
333  left_trigger: { report_offset: 4, size_bits: 8, logical_min: 0, logical_max: 255 }
334  right_trigger: { report_offset: 5, size_bits: 8, logical_min: 0, logical_max: 255 }
335dpad:
336  type: "hat"
337  report_offset: 6
338  logical_min: 0
339  logical_max: 7
340  neutral: 8
341buttons:
342  a: 56
343  b: 57
344  x: 58
345  y: 59
346  lb: 60
347  rb: 61
348  back: 62
349  start: 63
350  lt_click: 64
351  rt_click: 65
352"#
353    }
354
355    #[test]
356    fn parse_yaml_ok() {
357        let m = parse_mapping_yaml_str(sample_yaml()).unwrap();
358        assert_eq!(m.device.name, "Sample");
359        assert!(m.axes.left_x.is_some());
360        assert!(m.triggers.is_some());
361        assert!(m.dpad.is_some());
362        assert!(m.buttons.is_some());
363    }
364
365    #[test]
366    fn map_report_basic() {
367        let m = parse_mapping_yaml_str(sample_yaml()).unwrap();
368
369        let mut report = vec![0u8; 9];
370        report[0] = 128;
371        report[1] = 128;
372        report[2] = 255;
373        report[3] = 0;
374        report[4] = 200;
375        report[5] = 10;
376        report[6] = 2;
377        report[7] = 0b1000_0011;
378        report[8] = 0b0000_0001;
379
380        let xs = map_report_to_xinput(&m, &report);
381
382        use XButtons::*;
383        let expected_mask = A | B | START | LEFT_THUMB | DPAD_RIGHT;
384        assert_eq!(xs.buttons & expected_mask, expected_mask);
385        assert_eq!(xs.left_trigger, 200);
386        assert_eq!(xs.right_trigger, 10);
387        assert!(xs.thumb_lx >= -512 && xs.thumb_lx <= 512);
388        assert!(xs.thumb_ly >= -512 && xs.thumb_ly <= 512);
389        assert!(xs.thumb_rx > 32000);
390        assert!(xs.thumb_ry > 30000);
391    }
392
393    #[test]
394    fn hat_diagonals() {
395        assert_eq!(super::decode_hat_8way(0, 8), (true, false, false, false));
396        assert_eq!(super::decode_hat_8way(1, 8), (true, true, false, false));
397        assert_eq!(super::decode_hat_8way(3, 8), (false, true, true, false));
398        assert_eq!(super::decode_hat_8way(7, 8), (true, false, false, true));
399        assert_eq!(super::decode_hat_8way(8, 8), (false, false, false, false));
400    }
401}