Skip to main content

azul_core/
gamepad.rs

1//! POD types for the gamepad / game-controller surface
2//! (SUPER_PLAN_2 §1 feature 6 + research/03 §"Feature 6").
3//!
4//! Cross-platform controller input: `gilrs` on the desktop
5//! (Windows / Linux / macOS), iOS `GCController` + Android `InputDevice`
6//! on mobile (research/03). Defined here in `azul-core` so the manager +
7//! accessors cross the FFI without `azul-layout` as a dependency; the
8//! stateful side lives in `azul_layout::managers::gamepad::GamepadManager`.
9//!
10//! Poll model, like the sensors: the backend keeps a [`GamepadState`]
11//! snapshot per connected pad current, and a callback reads the latest each
12//! frame (`CallbackInfo::get_gamepad_state`) to drive movement / menus.
13//! Button + axis naming follows the SDL / gilrs "standard gamepad" mapping,
14//! so the face buttons are Xbox-style: South = A, East = B, West = X,
15//! North = Y.
16
17/// A connected gamepad's id — stable for the lifetime of the connection,
18/// assigned by the backend on connect. (gilrs `GamepadId` / the platform
19/// device id, normalised to a `u32`.)
20#[repr(C)]
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct GamepadId {
23    pub id: u32,
24}
25
26/// A standard-layout gamepad button. Face buttons are Xbox-style by
27/// position (South = A / Cross, East = B / Circle, West = X / Square,
28/// North = Y / Triangle), so layouts stay consistent across vendors.
29///
30/// The discriminant order is also the bit position in
31/// [`GamepadState::buttons`] — don't reorder without bumping the ABI.
32#[repr(C)]
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub enum GamepadButton {
35    /// Bottom face button (A / Cross).
36    South,
37    /// Right face button (B / Circle).
38    East,
39    /// Top face button (Y / Triangle).
40    North,
41    /// Left face button (X / Square).
42    West,
43    /// Left shoulder button (L1 / LB).
44    LeftBumper,
45    /// Right shoulder button (R1 / RB).
46    RightBumper,
47    /// Left trigger as a digital press (L2 / LT). Analog value: `LeftZ`.
48    LeftTrigger,
49    /// Right trigger as a digital press (R2 / RT). Analog value: `RightZ`.
50    RightTrigger,
51    /// Select / Back / Share.
52    Select,
53    /// Start / Options / Menu.
54    Start,
55    /// Vendor / guide button (Xbox / PS / Home).
56    Mode,
57    /// Left stick click (L3).
58    LeftThumb,
59    /// Right stick click (R3).
60    RightThumb,
61    /// D-pad up.
62    DPadUp,
63    /// D-pad down.
64    DPadDown,
65    /// D-pad left.
66    DPadLeft,
67    /// D-pad right.
68    DPadRight,
69}
70
71/// A gamepad analog axis. Stick axes are in `[-1, 1]` (right / up positive);
72/// trigger axes ([`GamepadAxis::LeftZ`] / [`GamepadAxis::RightZ`]) in
73/// `[0, 1]`.
74#[repr(C)]
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
76pub enum GamepadAxis {
77    /// Left stick horizontal (left −1 … right +1).
78    LeftStickX,
79    /// Left stick vertical (down −1 … up +1).
80    LeftStickY,
81    /// Right stick horizontal.
82    RightStickX,
83    /// Right stick vertical.
84    RightStickY,
85    /// Left trigger pressure (0 … 1).
86    LeftZ,
87    /// Right trigger pressure (0 … 1).
88    RightZ,
89}
90
91/// Snapshot of one gamepad's state. Buttons are a bitset (bit `n` = the
92/// [`GamepadButton`] with discriminant `n`); axes are explicit fields. All
93/// POD / `Copy`, so it crosses the FFI by value.
94#[repr(C)]
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct GamepadState {
97    /// Which pad this snapshot is for.
98    pub id: GamepadId,
99    /// `false` once the pad disconnects (the manager keeps the last slot so
100    /// a callback can observe the disconnect).
101    pub connected: bool,
102    /// Pressed-button bitset — bit `n` set ⇔ the `GamepadButton` with
103    /// discriminant `n` is held. Read via [`GamepadState::is_pressed`].
104    pub buttons: u32,
105    /// Left stick X in `[-1, 1]`.
106    pub left_stick_x: f32,
107    /// Left stick Y in `[-1, 1]`.
108    pub left_stick_y: f32,
109    /// Right stick X in `[-1, 1]`.
110    pub right_stick_x: f32,
111    /// Right stick Y in `[-1, 1]`.
112    pub right_stick_y: f32,
113    /// Left trigger pressure in `[0, 1]`.
114    pub left_z: f32,
115    /// Right trigger pressure in `[0, 1]`.
116    pub right_z: f32,
117}
118
119impl GamepadButton {
120    /// This button's bit in [`GamepadState::buttons`].
121    #[must_use] pub const fn bit(self) -> u32 {
122        1u32 << (self as u32)
123    }
124}
125
126impl GamepadState {
127    /// An empty (disconnected) state for `id` — all buttons up, axes zero.
128    #[must_use] pub const fn empty(id: GamepadId) -> Self {
129        Self {
130            id,
131            connected: false,
132            buttons: 0,
133            left_stick_x: 0.0,
134            left_stick_y: 0.0,
135            right_stick_x: 0.0,
136            right_stick_y: 0.0,
137            left_z: 0.0,
138            right_z: 0.0,
139        }
140    }
141
142    /// Whether `button` is currently held.
143    #[must_use] pub const fn is_pressed(&self, button: GamepadButton) -> bool {
144        self.buttons & button.bit() != 0
145    }
146
147    /// The current value of `axis` (sticks `[-1, 1]`, triggers `[0, 1]`).
148    #[must_use] pub const fn axis(&self, axis: GamepadAxis) -> f32 {
149        match axis {
150            GamepadAxis::LeftStickX => self.left_stick_x,
151            GamepadAxis::LeftStickY => self.left_stick_y,
152            GamepadAxis::RightStickX => self.right_stick_x,
153            GamepadAxis::RightStickY => self.right_stick_y,
154            GamepadAxis::LeftZ => self.left_z,
155            GamepadAxis::RightZ => self.right_z,
156        }
157    }
158}
159
160// FFI Option wrapper for `CallbackInfo::get_gamepad_state(id) ->
161// Option<GamepadState>` (mirrors `OptionSensorReading`).
162impl_option!(
163    GamepadState,
164    OptionGamepadState,
165    [Debug, Clone, Copy, PartialEq]
166);
167
168#[cfg(test)]
169mod autotest_generated {
170    use super::*;
171
172    /// Every `GamepadButton`, in discriminant order. The order here is also
173    /// the asserted bit order — see `bit_matches_documented_abi`.
174    const ALL_BUTTONS: [GamepadButton; 17] = [
175        GamepadButton::South,
176        GamepadButton::East,
177        GamepadButton::North,
178        GamepadButton::West,
179        GamepadButton::LeftBumper,
180        GamepadButton::RightBumper,
181        GamepadButton::LeftTrigger,
182        GamepadButton::RightTrigger,
183        GamepadButton::Select,
184        GamepadButton::Start,
185        GamepadButton::Mode,
186        GamepadButton::LeftThumb,
187        GamepadButton::RightThumb,
188        GamepadButton::DPadUp,
189        GamepadButton::DPadDown,
190        GamepadButton::DPadLeft,
191        GamepadButton::DPadRight,
192    ];
193
194    const ALL_AXES: [GamepadAxis; 6] = [
195        GamepadAxis::LeftStickX,
196        GamepadAxis::LeftStickY,
197        GamepadAxis::RightStickX,
198        GamepadAxis::RightStickY,
199        GamepadAxis::LeftZ,
200        GamepadAxis::RightZ,
201    ];
202
203    /// Bitset of every defined button — bits 0..=16.
204    const ALL_BUTTONS_MASK: u32 = 0x0001_FFFF;
205
206    /// Writes `v` into the field that `GamepadState::axis` reads for `axis`.
207    /// Deliberately mirrors `axis()`; a mis-mapping here would still be caught
208    /// by `axis_reads_each_field_uniquely`, which pokes the fields directly.
209    fn set_axis(s: &mut GamepadState, axis: GamepadAxis, v: f32) {
210        match axis {
211            GamepadAxis::LeftStickX => s.left_stick_x = v,
212            GamepadAxis::LeftStickY => s.left_stick_y = v,
213            GamepadAxis::RightStickX => s.right_stick_x = v,
214            GamepadAxis::RightStickY => s.right_stick_y = v,
215            GamepadAxis::LeftZ => s.left_z = v,
216            GamepadAxis::RightZ => s.right_z = v,
217        }
218    }
219
220    // ------------------------------------------------------------------
221    // GamepadButton::bit  (other)
222    // ------------------------------------------------------------------
223
224    /// no_panic_smoke + shift-overflow guard: `bit()` is `1u32 << (self as
225    /// u32)`, which is UB / a panic in debug the moment a discriminant reaches
226    /// 32. Pin the discriminants to a contiguous 0..17 so adding an 18th..32nd
227    /// button stays safe and a 33rd fails HERE rather than at a user's shift.
228    #[test]
229    fn bit_discriminants_are_contiguous_and_shift_safe() {
230        for (i, b) in ALL_BUTTONS.iter().enumerate() {
231            let d = *b as u32;
232            assert_eq!(
233                d, i as u32,
234                "{b:?} has discriminant {d}, expected {i} — the bitset in \
235                 GamepadState::buttons assumes contiguous discriminants"
236            );
237            assert!(
238                d < 32,
239                "{b:?} discriminant {d} would overflow `1u32 << d` in bit()"
240            );
241        }
242    }
243
244    /// The ABI the doc comment promises ("the discriminant order is also the
245    /// bit position … don't reorder without bumping the ABI"). Hard-coded so a
246    /// reorder is a loud test failure, not a silent remap of every FFI client's
247    /// button bits.
248    #[test]
249    fn bit_matches_documented_abi() {
250        assert_eq!(GamepadButton::South.bit(), 1 << 0);
251        assert_eq!(GamepadButton::East.bit(), 1 << 1);
252        assert_eq!(GamepadButton::North.bit(), 1 << 2);
253        assert_eq!(GamepadButton::West.bit(), 1 << 3);
254        assert_eq!(GamepadButton::LeftBumper.bit(), 1 << 4);
255        assert_eq!(GamepadButton::RightBumper.bit(), 1 << 5);
256        assert_eq!(GamepadButton::LeftTrigger.bit(), 1 << 6);
257        assert_eq!(GamepadButton::RightTrigger.bit(), 1 << 7);
258        assert_eq!(GamepadButton::Select.bit(), 1 << 8);
259        assert_eq!(GamepadButton::Start.bit(), 1 << 9);
260        assert_eq!(GamepadButton::Mode.bit(), 1 << 10);
261        assert_eq!(GamepadButton::LeftThumb.bit(), 1 << 11);
262        assert_eq!(GamepadButton::RightThumb.bit(), 1 << 12);
263        assert_eq!(GamepadButton::DPadUp.bit(), 1 << 13);
264        assert_eq!(GamepadButton::DPadDown.bit(), 1 << 14);
265        assert_eq!(GamepadButton::DPadLeft.bit(), 1 << 15);
266        assert_eq!(GamepadButton::DPadRight.bit(), 1 << 16);
267    }
268
269    /// invariant: each bit is a distinct, non-zero power of two. Two buttons
270    /// sharing a bit would make `is_pressed` report a phantom press.
271    #[test]
272    fn bit_is_a_distinct_power_of_two() {
273        let mut seen = 0u32;
274        for b in ALL_BUTTONS {
275            let bit = b.bit();
276            assert_ne!(bit, 0, "{b:?} maps to bit 0");
277            assert_eq!(bit.count_ones(), 1, "{b:?} bit {bit:#x} is not a single bit");
278            assert_eq!(seen & bit, 0, "{b:?} bit {bit:#x} collides with an earlier button");
279            seen |= bit;
280        }
281        assert_eq!(seen, ALL_BUTTONS_MASK);
282        assert_eq!(seen.count_ones(), ALL_BUTTONS.len() as u32);
283    }
284
285    /// `bit()` is `const fn` — usable in a `const` item / array length. A
286    /// non-const-evaluable body (or an overflowing shift, which is a hard
287    /// compile error in const context) fails to build.
288    #[test]
289    fn bit_is_const_evaluable() {
290        const SOUTH: u32 = GamepadButton::South.bit();
291        const DPAD_RIGHT: u32 = GamepadButton::DPadRight.bit();
292        assert_eq!(SOUTH, 1);
293        assert_eq!(DPAD_RIGHT, 65_536);
294    }
295
296    // ------------------------------------------------------------------
297    // GamepadState::empty  (constructor)
298    // ------------------------------------------------------------------
299
300    /// no_panic + invariants_hold: extreme ids (0, 1, MAX/2, MAX) round-trip
301    /// into the state unchanged and every other field is the documented zero.
302    #[test]
303    fn empty_preserves_id_and_zeroes_everything_else() {
304        for raw in [0, 1, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
305            let s = GamepadState::empty(GamepadId { id: raw });
306            assert_eq!(s.id, GamepadId { id: raw });
307            assert_eq!(s.id.id, raw);
308            assert!(!s.connected, "empty() must start disconnected");
309            assert_eq!(s.buttons, 0);
310        }
311    }
312
313    /// default_is_neutral: an empty state is the neutral element — no button
314    /// reads as pressed and every axis is exactly *positive* zero. The
315    /// `to_bits()` check is the point: a `-0.0` would still compare `== 0.0`
316    /// yet flips the sign of anything a caller multiplies by it.
317    #[test]
318    fn empty_is_neutral_for_every_button_and_axis() {
319        let s = GamepadState::empty(GamepadId { id: 42 });
320        for b in ALL_BUTTONS {
321            assert!(!s.is_pressed(b), "{b:?} reads as pressed in an empty state");
322        }
323        for a in ALL_AXES {
324            assert_eq!(
325                s.axis(a).to_bits(),
326                0.0f32.to_bits(),
327                "axis {a:?} of an empty state is not +0.0 (got {})",
328                s.axis(a)
329            );
330        }
331    }
332
333    /// invariant: `empty()` is a pure function of `id` — same id gives an
334    /// equal state, a different id gives an unequal one (so a stale slot for
335    /// pad 0 can't be mistaken for pad 1's).
336    #[test]
337    fn empty_is_deterministic_and_id_discriminating() {
338        let a = GamepadState::empty(GamepadId { id: 7 });
339        let b = GamepadState::empty(GamepadId { id: 7 });
340        let c = GamepadState::empty(GamepadId { id: 8 });
341        assert_eq!(a, b);
342        assert_ne!(a, c);
343    }
344
345    /// `empty()` is `const fn`, so a backend can build a static slot table.
346    #[test]
347    fn empty_is_const_evaluable() {
348        const S: GamepadState = GamepadState::empty(GamepadId { id: u32::MAX });
349        assert_eq!(S.id.id, u32::MAX);
350        assert_eq!(S.buttons, 0);
351        assert!(!S.connected);
352    }
353
354    // ------------------------------------------------------------------
355    // GamepadState::is_pressed  (predicate)
356    // ------------------------------------------------------------------
357
358    /// basic_true_false + isolation: with exactly one bit set, that button —
359    /// and *only* that button — reads as pressed. Catches an off-by-one shift
360    /// or a bit collision that a single known-true case would miss.
361    #[test]
362    fn is_pressed_isolates_each_single_bit() {
363        for pressed in ALL_BUTTONS {
364            let mut s = GamepadState::empty(GamepadId { id: 0 });
365            s.buttons = pressed.bit();
366            for other in ALL_BUTTONS {
367                assert_eq!(
368                    s.is_pressed(other),
369                    other == pressed,
370                    "buttons={:#x}: is_pressed({other:?}) disagrees with the only \
371                     pressed button {pressed:?}",
372                    s.buttons
373                );
374            }
375        }
376    }
377
378    /// edge_inputs: the two saturating bitsets. `0` = nothing pressed,
379    /// `u32::MAX` = everything pressed. Both are deterministic, neither panics.
380    #[test]
381    fn is_pressed_handles_empty_and_full_bitsets() {
382        let mut s = GamepadState::empty(GamepadId { id: 0 });
383
384        s.buttons = 0;
385        for b in ALL_BUTTONS {
386            assert!(!s.is_pressed(b), "{b:?} pressed with buttons == 0");
387        }
388
389        s.buttons = u32::MAX;
390        for b in ALL_BUTTONS {
391            assert!(s.is_pressed(b), "{b:?} not pressed with buttons == u32::MAX");
392        }
393    }
394
395    /// Adversarial: a backend (or a hostile FFI caller) writes junk into the
396    /// 15 *reserved* high bits, 17..=31. No defined button may light up — the
397    /// mask is per-button, so garbage outside the defined range must be inert.
398    #[test]
399    fn is_pressed_ignores_reserved_high_bits() {
400        let mut s = GamepadState::empty(GamepadId { id: 0 });
401        for junk in [
402            !ALL_BUTTONS_MASK,        // every reserved bit
403            1 << 17,                  // the first reserved bit
404            1 << 31,                  // the sign bit
405            0xDEAD_0000 & !ALL_BUTTONS_MASK,
406        ] {
407            assert_eq!(junk & ALL_BUTTONS_MASK, 0, "test vector {junk:#x} is not reserved-only");
408            s.buttons = junk;
409            for b in ALL_BUTTONS {
410                assert!(
411                    !s.is_pressed(b),
412                    "reserved-bit junk {junk:#x} made {b:?} read as pressed"
413                );
414            }
415        }
416    }
417
418    /// round-trip: encode a button set into the bitset, decode it back through
419    /// `is_pressed` — the decoded set must equal the encoded one, and
420    /// re-encoding must reproduce the exact same bits (encode == decode).
421    #[test]
422    fn is_pressed_bitset_roundtrips() {
423        let subsets: [&[GamepadButton]; 5] = [
424            &[],
425            &[GamepadButton::South],
426            &[GamepadButton::DPadRight],
427            &[
428                GamepadButton::South,
429                GamepadButton::DPadRight,
430                GamepadButton::Mode,
431            ],
432            &ALL_BUTTONS,
433        ];
434
435        for subset in subsets {
436            let encoded = subset.iter().fold(0u32, |acc, b| acc | b.bit());
437
438            let mut s = GamepadState::empty(GamepadId { id: 3 });
439            s.buttons = encoded;
440
441            // Decode through the predicate, then re-encode from what we decoded.
442            let mut re_encoded = 0u32;
443            for (i, b) in ALL_BUTTONS.iter().enumerate() {
444                let decoded = s.is_pressed(*b);
445                assert_eq!(
446                    decoded,
447                    subset.contains(b),
448                    "decode of {encoded:#x}: is_pressed({b:?}) disagrees with the \
449                     encoded subset (bit {i})"
450                );
451                if decoded {
452                    re_encoded |= b.bit();
453                }
454            }
455            assert_eq!(re_encoded, encoded, "re-encode of {encoded:#x} is not bit-stable");
456        }
457    }
458
459    /// `is_pressed` is `const fn` and takes `&self` — usable on a const state.
460    #[test]
461    fn is_pressed_is_const_evaluable() {
462        const S: GamepadState = GamepadState {
463            id: GamepadId { id: 0 },
464            connected: true,
465            buttons: 0b101, // South (bit 0) | North (bit 2)
466            left_stick_x: 0.0,
467            left_stick_y: 0.0,
468            right_stick_x: 0.0,
469            right_stick_y: 0.0,
470            left_z: 0.0,
471            right_z: 0.0,
472        };
473        const SOUTH: bool = S.is_pressed(GamepadButton::South);
474        const EAST: bool = S.is_pressed(GamepadButton::East);
475        const NORTH: bool = S.is_pressed(GamepadButton::North);
476        assert!(SOUTH);
477        assert!(!EAST);
478        assert!(NORTH);
479    }
480
481    /// invariant: `is_pressed` is a read-only view — polling every button
482    /// leaves the snapshot byte-identical.
483    #[test]
484    fn is_pressed_does_not_mutate_the_snapshot() {
485        let mut s = GamepadState::empty(GamepadId { id: 9 });
486        s.buttons = 0x1_0F0F & ALL_BUTTONS_MASK;
487        let before = s;
488        for b in ALL_BUTTONS {
489            let _ = s.is_pressed(b);
490        }
491        assert_eq!(s, before);
492    }
493
494    // ------------------------------------------------------------------
495    // GamepadState::axis  (other)
496    // ------------------------------------------------------------------
497
498    /// The copy-paste trap: `axis()` is a six-arm match over six near-identical
499    /// fields. Give every field a unique sentinel and poke the fields directly
500    /// (never through a helper that could share the same bug), so any two arms
501    /// reading the same field — or reading each other's — fails here.
502    #[test]
503    fn axis_reads_each_field_uniquely() {
504        let s = GamepadState {
505            id: GamepadId { id: 1 },
506            connected: true,
507            buttons: 0,
508            left_stick_x: 1.0,
509            left_stick_y: 2.0,
510            right_stick_x: 3.0,
511            right_stick_y: 4.0,
512            left_z: 5.0,
513            right_z: 6.0,
514        };
515        assert_eq!(s.axis(GamepadAxis::LeftStickX).to_bits(), 1.0f32.to_bits());
516        assert_eq!(s.axis(GamepadAxis::LeftStickY).to_bits(), 2.0f32.to_bits());
517        assert_eq!(s.axis(GamepadAxis::RightStickX).to_bits(), 3.0f32.to_bits());
518        assert_eq!(s.axis(GamepadAxis::RightStickY).to_bits(), 4.0f32.to_bits());
519        assert_eq!(s.axis(GamepadAxis::LeftZ).to_bits(), 5.0f32.to_bits());
520        assert_eq!(s.axis(GamepadAxis::RightZ).to_bits(), 6.0f32.to_bits());
521
522        // …and every axis is pairwise distinct, so no two arms alias one field.
523        for (i, a) in ALL_AXES.iter().enumerate() {
524            for b in ALL_AXES.iter().skip(i + 1) {
525                assert_ne!(
526                    s.axis(*a).to_bits(),
527                    s.axis(*b).to_bits(),
528                    "axes {a:?} and {b:?} read the same field"
529                );
530            }
531        }
532    }
533
534    /// no_panic_smoke over the nasty floats: NaN (incl. a signalling payload),
535    /// ±inf, ±0.0, subnormals, MIN/MAX. `axis()` is a getter, so it must hand
536    /// each one back *bit-for-bit* — no clamping, no NaN canonicalisation,
537    /// no sign-of-zero loss (a `-0.0` silently flipping to `+0.0` would change
538    /// the direction of a caller's `v.signum()` deadzone check).
539    #[test]
540    fn axis_returns_extreme_floats_bit_exact() {
541        let nasty: [f32; 12] = [
542            f32::NAN,
543            -f32::NAN,
544            f32::from_bits(0x7F80_0001), // signalling NaN payload
545            f32::INFINITY,
546            f32::NEG_INFINITY,
547            0.0,
548            -0.0,
549            f32::MIN_POSITIVE,
550            f32::from_bits(1), // smallest subnormal
551            -1.0,
552            f32::MIN,
553            f32::MAX,
554        ];
555
556        for a in ALL_AXES {
557            for v in nasty {
558                let mut s = GamepadState::empty(GamepadId { id: 0 });
559                set_axis(&mut s, a, v);
560                assert_eq!(
561                    s.axis(a).to_bits(),
562                    v.to_bits(),
563                    "axis {a:?} did not return {v:?} bit-exactly (bits {:#x} vs {:#x})",
564                    s.axis(a).to_bits(),
565                    v.to_bits()
566                );
567            }
568            // Writing one axis must not disturb the other five.
569            let mut s = GamepadState::empty(GamepadId { id: 0 });
570            set_axis(&mut s, a, f32::MAX);
571            for other in ALL_AXES.iter().filter(|o| **o != a) {
572                assert_eq!(s.axis(*other).to_bits(), 0.0f32.to_bits());
573            }
574        }
575    }
576
577    /// Boundary + out-of-range: the doc gives sticks `[-1, 1]` and triggers
578    /// `[0, 1]`, but `axis()` is a plain accessor — range enforcement is the
579    /// *backend's* contract, not this getter's. Pin the pass-through so nobody
580    /// "helpfully" adds a silent clamp that would hide a mis-scaling backend.
581    #[test]
582    fn axis_does_not_clamp_out_of_range_values() {
583        for a in ALL_AXES {
584            for v in [-1.0f32, 0.0, 1.0, 1.000_001, -1.000_001, 1e9, -1e9] {
585                let mut s = GamepadState::empty(GamepadId { id: 0 });
586                set_axis(&mut s, a, v);
587                assert_eq!(
588                    s.axis(a).to_bits(),
589                    v.to_bits(),
590                    "axis {a:?} clamped or rounded {v}"
591                );
592            }
593        }
594    }
595
596    /// `axis()` is `const fn`.
597    #[test]
598    fn axis_is_const_evaluable() {
599        const S: GamepadState = GamepadState {
600            id: GamepadId { id: 0 },
601            connected: true,
602            buttons: 0,
603            left_stick_x: 0.0,
604            left_stick_y: 0.0,
605            right_stick_x: 0.0,
606            right_stick_y: 0.0,
607            left_z: 1.0,
608            right_z: 0.0,
609        };
610        const LEFT_Z: f32 = S.axis(GamepadAxis::LeftZ);
611        const RIGHT_Z: f32 = S.axis(GamepadAxis::RightZ);
612        assert_eq!(LEFT_Z.to_bits(), 1.0f32.to_bits());
613        assert_eq!(RIGHT_Z.to_bits(), 0.0f32.to_bits());
614    }
615
616    // ------------------------------------------------------------------
617    // GamepadState / OptionGamepadState — derived-impl invariants
618    // ------------------------------------------------------------------
619
620    /// FFI trap, asserted rather than fixed: `GamepadState` derives
621    /// `PartialEq` over `f32`, so IEEE-754 applies — a snapshot with a NaN axis
622    /// is *not equal to itself*. Callers that dedupe frames with `==` (e.g.
623    /// "skip the callback if the state didn't change") will therefore always
624    /// see a change once a backend reports a NaN axis. Reflexivity holds for
625    /// every non-NaN state.
626    #[test]
627    fn state_equality_is_ieee_not_reflexive_over_nan() {
628        let mut nan_state = GamepadState::empty(GamepadId { id: 0 });
629        nan_state.left_stick_x = f32::NAN;
630        let a = nan_state;
631        let b = nan_state; // a bit-identical copy
632        assert_ne!(a, b, "NaN axis: derived PartialEq is expected to be non-reflexive");
633
634        let mut sane = GamepadState::empty(GamepadId { id: 0 });
635        sane.left_stick_x = 0.5;
636        let c = sane;
637        let d = sane;
638        assert_eq!(c, d);
639    }
640
641    /// round-trip: `GamepadState` -> `Option` -> `OptionGamepadState` -> back.
642    /// This is the wrapper `CallbackInfo::get_gamepad_state` returns across the
643    /// FFI, so encode == decode must hold in both directions, and the default
644    /// must be the "no such pad" case.
645    #[test]
646    fn option_gamepad_state_roundtrips() {
647        assert!(OptionGamepadState::default().is_none());
648        assert!(!OptionGamepadState::default().is_some());
649        assert_eq!(Option::<GamepadState>::from(OptionGamepadState::default()), None);
650
651        let mut s = GamepadState::empty(GamepadId { id: u32::MAX });
652        s.connected = true;
653        s.buttons = ALL_BUTTONS_MASK;
654        s.right_stick_y = -1.0;
655        s.left_z = 1.0;
656
657        let wrapped: OptionGamepadState = Some(s).into();
658        assert!(wrapped.is_some());
659        assert!(!wrapped.is_none());
660        assert_eq!(wrapped.as_option(), Some(&s));
661        assert_eq!(Option::<GamepadState>::from(wrapped), Some(s));
662
663        let none: OptionGamepadState = Option::<GamepadState>::None.into();
664        assert!(none.is_none());
665
666        // `replace` returns the PREVIOUS value (mem::replace semantics).
667        let mut slot = OptionGamepadState::None;
668        let prev = slot.replace(s);
669        assert!(prev.is_none());
670        assert_eq!(slot.as_option(), Some(&s));
671
672        let other = GamepadState::empty(GamepadId { id: 1 });
673        let prev = slot.replace(other);
674        assert_eq!(Option::<GamepadState>::from(prev), Some(s));
675        assert_eq!(slot.as_option(), Some(&other));
676    }
677}