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}