Skip to main content

scs_sdk/
input.rs

1//! Safe typed interpretation of the SCS Input SDK 1.00 interface.
2//!
3//! Input devices are registered only during `scs_input_init`. Once active, SCS
4//! repeatedly calls each device's event callback on the game main thread until
5//! the callback reports `NotFound` for the current frame. This module keeps the
6//! registration capability scoped to initialization and represents the only
7//! supported event value types—bool and float—without exposing the raw union.
8
9use core::ffi::{CStr, c_void};
10use core::fmt;
11use core::marker::PhantomData;
12use core::mem::MaybeUninit;
13
14use crate::{InputApiVersion, InputGameVersion, ScopedLogger, SdkError, SdkResult, sys};
15
16/// Input-device class declared by the official SDK.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18#[repr(u32)]
19pub enum InputDeviceType {
20    /// Generic device whose inputs can be bound in the game UI.
21    Generic = sys::SCS_INPUT_DEVICE_TYPE_GENERIC,
22    /// Device whose input names map directly to supported game mixes.
23    Semantical = sys::SCS_INPUT_DEVICE_TYPE_SEMANTICAL,
24}
25
26impl InputDeviceType {
27    #[must_use]
28    pub const fn raw(self) -> sys::ScsInputDeviceType {
29        self as sys::ScsInputDeviceType
30    }
31}
32
33/// Value representation accepted for an input-device entry.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum InputValueType {
36    Bool,
37    /// Normalized finite axis represented by [`InputAxisValue`].
38    Float,
39}
40
41impl InputValueType {
42    #[must_use]
43    pub const fn raw(self) -> sys::ScsValueType {
44        match self {
45            Self::Bool => sys::SCS_VALUE_TYPE_BOOL,
46            Self::Float => sys::SCS_VALUE_TYPE_FLOAT,
47        }
48    }
49}
50
51/// A finite normalized value for one float input axis.
52///
53/// The raw Input API describes this representation only as a `float`. The game
54/// consumes bindable axes in the inclusive `-1.0..=1.0` interval, where zero is
55/// the center and the endpoints are the two maximum directions. Real ETS2
56/// validation showed that a value below `-1.0` still crosses the ABI boundary
57/// successfully but is interpreted by the input UI as the neutral center.
58/// Keeping the normalized domain in this type prevents safe plugins from
59/// emitting those semantically invalid values.
60///
61/// Construction rejects NaN and infinities as well as finite out-of-range
62/// values. Values are never silently clamped because that would hide a device
63/// conversion error and would not match the observed game behavior.
64#[derive(Clone, Copy, Debug, PartialEq)]
65pub struct InputAxisValue(f32);
66
67impl InputAxisValue {
68    /// Maximum movement in the negative direction.
69    pub const MIN: Self = Self(-1.0);
70    /// Neutral centered position.
71    pub const CENTER: Self = Self(0.0);
72    /// Maximum movement in the positive direction.
73    pub const MAX: Self = Self(1.0);
74
75    /// Validates one normalized axis value.
76    ///
77    /// Both positive and negative zero are accepted and preserved exactly.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`InputAxisValueError::NotFinite`] for NaN or either infinity,
82    /// and [`InputAxisValueError::OutOfRange`] for a finite value outside the
83    /// inclusive `-1.0..=1.0` interval.
84    pub fn new(value: f32) -> Result<Self, InputAxisValueError> {
85        if !value.is_finite() {
86            return Err(InputAxisValueError::NotFinite);
87        }
88        if !(-1.0..=1.0).contains(&value) {
89            return Err(InputAxisValueError::OutOfRange);
90        }
91        Ok(Self(value))
92    }
93
94    /// Returns the validated SDK float representation.
95    #[must_use]
96    pub const fn get(self) -> f32 {
97        self.0
98    }
99}
100
101impl TryFrom<f32> for InputAxisValue {
102    type Error = InputAxisValueError;
103
104    fn try_from(value: f32) -> Result<Self, Self::Error> {
105        Self::new(value)
106    }
107}
108
109impl From<InputAxisValue> for f32 {
110    fn from(value: InputAxisValue) -> Self {
111        value.get()
112    }
113}
114
115/// Why a raw float could not become a normalized [`InputAxisValue`].
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub enum InputAxisValueError {
118    /// The value was NaN, positive infinity, or negative infinity.
119    NotFinite,
120    /// The finite value was below `-1.0` or above `1.0`.
121    OutOfRange,
122}
123
124impl fmt::Display for InputAxisValueError {
125    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126        match self {
127            Self::NotFinite => formatter.write_str("input axis value is not finite"),
128            Self::OutOfRange => {
129                formatter.write_str("input axis value is outside the inclusive -1.0 to 1.0 range")
130            }
131        }
132    }
133}
134
135/// Strong zero-based input index limited by the official per-device maximum.
136#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
137pub struct InputIndex(u32);
138
139impl InputIndex {
140    pub const MAX_COUNT: u32 = sys::SCS_INPUT_MAX_INPUT_COUNT;
141
142    #[must_use]
143    pub const fn new(raw: u32) -> Option<Self> {
144        if raw < Self::MAX_COUNT {
145            Some(Self(raw))
146        } else {
147            None
148        }
149    }
150
151    #[must_use]
152    pub const fn raw(self) -> u32 {
153        self.0
154    }
155}
156
157/// Flags supplied when SCS requests the next event from a device.
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159pub struct InputEventFlags(u32);
160
161impl InputEventFlags {
162    #[must_use]
163    pub const fn from_raw(raw: u32) -> Self {
164        Self(raw)
165    }
166
167    #[must_use]
168    pub const fn raw(self) -> u32 {
169        self.0
170    }
171
172    #[must_use]
173    pub const fn first_in_frame(self) -> bool {
174        self.0 & sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_IN_FRAME != 0
175    }
176
177    #[must_use]
178    pub const fn first_after_activation(self) -> bool {
179        self.0 & sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_AFTER_ACTIVATION != 0
180    }
181}
182
183/// Safe event value returned by an application input provider.
184///
185/// Float events require a validated [`InputAxisValue`]; an arbitrary raw float
186/// cannot cross the safe application boundary.
187///
188/// ```compile_fail
189/// use scs_sdk::input::InputValue;
190///
191/// let _invalid = InputValue::Float(-2.0);
192/// ```
193#[derive(Clone, Copy, Debug, PartialEq)]
194pub enum InputValue {
195    Bool(bool),
196    /// One normalized float axis. Arbitrary `f32` values must first pass
197    /// [`InputAxisValue::new`], so every safe event is finite and in range.
198    Float(InputAxisValue),
199}
200
201impl InputValue {
202    #[must_use]
203    pub const fn value_type(self) -> InputValueType {
204        match self {
205            Self::Bool(_) => InputValueType::Bool,
206            Self::Float(_) => InputValueType::Float,
207        }
208    }
209}
210
211/// One safe input event to write into the buffer supplied by SCS.
212#[derive(Clone, Copy, Debug, PartialEq)]
213pub struct InputEvent {
214    index: InputIndex,
215    value: InputValue,
216}
217
218impl InputEvent {
219    #[must_use]
220    pub const fn new(index: InputIndex, value: InputValue) -> Self {
221        Self { index, value }
222    }
223
224    #[must_use]
225    pub const fn index(self) -> InputIndex {
226        self.index
227    }
228
229    #[must_use]
230    pub const fn value(self) -> InputValue {
231        self.value
232    }
233
234    /// Writes this event into a live SDK output buffer after type validation.
235    ///
236    /// The official callback contract gives the plugin a reusable output
237    /// buffer and requires it to populate the index plus the union member that
238    /// matches the registered input type. This method deliberately leaves the
239    /// inactive union tail untouched instead of replacing the entire raw
240    /// structure. Besides matching the official sample, that avoids copying
241    /// bytes for inactive or future-extension storage whose contents have no
242    /// meaning for the current value.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`SdkError::InvalidParameter`] when `output` is null or when the
247    /// event value does not match the value type registered for that input.
248    ///
249    /// # Safety
250    ///
251    /// `output` must identify the live `scs_input_event_t` supplied to the
252    /// current input callback. The callback must be executing directly on the
253    /// SCS game main thread.
254    pub unsafe fn write_to(
255        self,
256        output: *mut sys::ScsInputEvent,
257        expected_type: InputValueType,
258    ) -> SdkResult {
259        if output.is_null() || self.value.value_type() != expected_type {
260            return Err(SdkError::InvalidParameter);
261        }
262
263        // SAFETY: The null and registered-type checks above succeeded, and the
264        // caller guarantees that `output` points to the live writable event
265        // buffer for this direct main-thread callback. `addr_of_mut!` performs
266        // raw field projection without creating a reference to the possibly
267        // partially initialized structure or union. We initialize the index
268        // and exactly the active member selected by the validated Rust value;
269        // no inactive member or future-extension byte is read or written.
270        unsafe {
271            core::ptr::addr_of_mut!((*output).input_index).write(self.index.raw());
272            match self.value {
273                InputValue::Bool(value) => {
274                    core::ptr::addr_of_mut!((*output).value.value_bool).write(sys::ScsValueBool {
275                        value: u8::from(value),
276                    });
277                }
278                InputValue::Float(value) => {
279                    core::ptr::addr_of_mut!((*output).value.value_float)
280                        .write(sys::ScsValueFloat { value: value.get() });
281                }
282            }
283        }
284        Ok(())
285    }
286}
287
288/// Header-shaped description of one input, with C-string lifetimes retained.
289#[repr(transparent)]
290pub struct InputDeviceInput<'a> {
291    raw: sys::ScsInputDeviceInput,
292    lifetime: PhantomData<(&'a CStr, &'a CStr)>,
293}
294
295impl<'a> InputDeviceInput<'a> {
296    #[must_use]
297    pub const fn new(name: &'a CStr, display_name: &'a CStr, value_type: InputValueType) -> Self {
298        Self {
299            raw: sys::ScsInputDeviceInput {
300                name: name.as_ptr(),
301                display_name: display_name.as_ptr(),
302                value_type: value_type.raw(),
303                padding: MaybeUninit::uninit(),
304            },
305            lifetime: PhantomData,
306        }
307    }
308
309    #[must_use]
310    pub const fn value_type(&self) -> Option<InputValueType> {
311        match self.raw.value_type {
312            sys::SCS_VALUE_TYPE_BOOL => Some(InputValueType::Bool),
313            sys::SCS_VALUE_TYPE_FLOAT => Some(InputValueType::Float),
314            _ => None,
315        }
316    }
317}
318
319/// Fully described raw registration whose callback invariants were established
320/// by an audited upper layer.
321pub struct InputDeviceRegistration<'a> {
322    raw: sys::ScsInputDevice,
323    lifetime: PhantomData<&'a [InputDeviceInput<'a>]>,
324}
325
326impl<'a> InputDeviceRegistration<'a> {
327    /// Creates a device registration for one initialization-scoped SDK call.
328    ///
329    /// # Errors
330    ///
331    /// Returns [`SdkError::InvalidParameter`] when the input slice is empty,
332    /// exceeds [`InputIndex::MAX_COUNT`], or cannot be represented by the raw
333    /// SDK count field.
334    ///
335    /// # Safety
336    ///
337    /// `callback_context` must remain valid for every callback until SCS has
338    /// automatically unregistered the device before `scs_input_shutdown`.
339    /// Both callbacks must contain panics, obey the game-main-thread contract,
340    /// and validate every foreign pointer before dereferencing it.
341    pub unsafe fn new(
342        name: &'a CStr,
343        display_name: &'a CStr,
344        device_type: InputDeviceType,
345        inputs: &'a [InputDeviceInput<'a>],
346        callback_context: *mut c_void,
347        active_callback: Option<sys::ScsInputActiveCallback>,
348        event_callback: sys::ScsInputEventCallback,
349    ) -> SdkResult<Self> {
350        if inputs.is_empty() || inputs.len() > sys::SCS_INPUT_MAX_INPUT_COUNT as usize {
351            return Err(SdkError::InvalidParameter);
352        }
353        let input_count = u32::try_from(inputs.len()).map_err(|_| SdkError::InvalidParameter)?;
354        Ok(Self {
355            raw: sys::ScsInputDevice {
356                name: name.as_ptr(),
357                display_name: display_name.as_ptr(),
358                type_: device_type.raw(),
359                input_count,
360                inputs: inputs.as_ptr().cast::<sys::ScsInputDeviceInput>(),
361                callback_context,
362                input_active_callback: active_callback,
363                input_event_callback: event_callback,
364            },
365            lifetime: PhantomData,
366        })
367    }
368}
369
370#[derive(Clone, Copy)]
371struct InputSessionTable {
372    version: InputApiVersion,
373    logger: sys::ScsLog,
374}
375
376/// Typed view over input initialization parameters supplied by SCS.
377pub struct InputApi<'a> {
378    raw: &'a sys::ScsInputInitParamsV100,
379    version: InputApiVersion,
380    not_send_sync: PhantomData<*mut ()>,
381}
382
383/// Inert handle retained for direct input callbacks and shutdown logging.
384#[derive(Clone, Copy)]
385pub struct InputSession {
386    table: InputSessionTable,
387}
388
389/// Capabilities valid during one direct input callback or shutdown call.
390///
391/// The raw SDK permits calls back into the game only while SCS is directly
392/// invoking plugin code on the game main thread. This token therefore carries
393/// an invariant lifetime and a raw-pointer marker so it cannot be stored, sent
394/// to another thread, or used as a global capability.
395///
396/// ```compile_fail
397/// use scs_sdk::input::InputCall;
398///
399/// fn require_send<T: Send>() {}
400///
401/// require_send::<InputCall<'static>>();
402/// ```
403pub struct InputCall<'scope> {
404    table: InputSessionTable,
405    scope: PhantomData<&'scope mut ()>,
406    not_send_sync: PhantomData<*mut ()>,
407}
408
409/// Initialization-only input capability which can register devices.
410pub struct InputInitCall<'scope> {
411    call: InputCall<'scope>,
412    register_device: sys::ScsInputRegisterDevice,
413}
414
415impl<'a> InputApi<'a> {
416    pub const SUPPORTED_VERSIONS: &'static [InputApiVersion] = &[InputApiVersion::V1_00];
417
418    #[must_use]
419    pub const fn supports_version(version: InputApiVersion) -> bool {
420        version.raw() == InputApiVersion::V1_00.raw()
421    }
422
423    /// Creates a typed view over the input initialization parameters.
424    ///
425    /// # Errors
426    ///
427    /// Returns [`SdkError::Unsupported`] for any input API version other than
428    /// the audited 1.00 layout, or [`SdkError::InvalidParameter`] when `params`
429    /// is null.
430    ///
431    /// # Safety
432    ///
433    /// For input API 1.00, `params` must point to the live matching structure
434    /// supplied by SCS for the duration of this main-thread initialization.
435    pub unsafe fn from_raw(
436        version: InputApiVersion,
437        params: *const sys::ScsInputInitParams,
438    ) -> SdkResult<Self> {
439        if !Self::supports_version(version) {
440            return Err(SdkError::Unsupported);
441        }
442        // SAFETY: The caller guarantees the matching v1.00 layout after the
443        // exact supported-version check above.
444        let raw = unsafe { params.cast::<sys::ScsInputInitParamsV100>().as_ref() }
445            .ok_or(SdkError::InvalidParameter)?;
446        Ok(Self {
447            raw,
448            version,
449            not_send_sync: PhantomData,
450        })
451    }
452
453    #[must_use]
454    pub const fn version(&self) -> InputApiVersion {
455        self.version
456    }
457
458    #[must_use]
459    pub fn game_name(&self) -> &'a CStr {
460        // SAFETY: SCS documents this pointer as non-null and NUL-terminated for
461        // the complete initialization call.
462        unsafe { CStr::from_ptr(self.raw.common.game_name) }
463    }
464
465    #[must_use]
466    pub fn game_id(&self) -> &'a CStr {
467        // SAFETY: SCS documents this pointer as non-null and NUL-terminated for
468        // the complete initialization call.
469        unsafe { CStr::from_ptr(self.raw.common.game_id) }
470    }
471
472    #[must_use]
473    pub const fn game_version(&self) -> InputGameVersion {
474        InputGameVersion::from_raw(self.raw.common.game_version)
475    }
476
477    #[must_use]
478    pub const fn session(&self) -> InputSession {
479        InputSession {
480            table: InputSessionTable {
481                version: self.version,
482                logger: self.raw.common.log,
483            },
484        }
485    }
486
487    pub fn with_init_call<R>(
488        &self,
489        operation: impl for<'scope> FnOnce(&InputInitCall<'scope>) -> R,
490    ) -> R {
491        let call = InputInitCall {
492            call: InputCall {
493                table: self.session().table,
494                scope: PhantomData,
495                not_send_sync: PhantomData,
496            },
497            register_device: self.raw.register_device,
498        };
499        operation(&call)
500    }
501}
502
503impl InputSession {
504    /// Creates a callback-scoped capability during a direct call from SCS.
505    ///
506    /// The higher-ranked callback keeps the created [`InputCall`] inside the
507    /// direct SDK callback scope:
508    ///
509    /// ```compile_fail
510    /// use scs_sdk::input::{InputCall, InputSession};
511    ///
512    /// fn leak(session: InputSession) -> &'static InputCall<'static> {
513    ///     unsafe { session.with_call(|call| call) }
514    /// }
515    /// ```
516    ///
517    /// # Safety
518    ///
519    /// The caller must be executing synchronously in an input callback or
520    /// shutdown entry point on the game main thread while this session is live.
521    pub unsafe fn with_call<R>(
522        self,
523        operation: impl for<'scope> FnOnce(&InputCall<'scope>) -> R,
524    ) -> R {
525        let call = InputCall {
526            table: self.table,
527            scope: PhantomData,
528            not_send_sync: PhantomData,
529        };
530        operation(&call)
531    }
532}
533
534impl InputCall<'_> {
535    #[must_use]
536    pub const fn input_api_version(&self) -> InputApiVersion {
537        self.table.version
538    }
539
540    #[must_use]
541    pub const fn logger(&self) -> ScopedLogger<'_> {
542        ScopedLogger::from_raw(self.table.logger)
543    }
544}
545
546impl InputInitCall<'_> {
547    #[must_use]
548    pub const fn input_api_version(&self) -> InputApiVersion {
549        self.call.input_api_version()
550    }
551
552    #[must_use]
553    pub const fn logger(&self) -> ScopedLogger<'_> {
554        self.call.logger()
555    }
556
557    /// Registers one input device during `scs_input_init`.
558    ///
559    /// # Errors
560    ///
561    /// Returns the SDK error reported by SCS when the device name, input list,
562    /// callbacks, or current lifecycle state are rejected by the game.
563    pub fn register_device(&self, device: &InputDeviceRegistration<'_>) -> SdkResult {
564        // SAFETY: `InputDeviceRegistration::new` established the callback and
565        // context invariants. This type is constructible only during the
566        // higher-ranked initialization scope, and SCS fully consumes the
567        // descriptor arrays before returning from this call.
568        let result = unsafe { (self.register_device)(&raw const device.raw) };
569        SdkError::from_code(result)
570    }
571}
572
573/// Input API game-version constants declared by the SDK 1.14 headers.
574pub mod game {
575    use crate::{InputGameVersion, sys};
576
577    pub mod ets2 {
578        use super::{InputGameVersion, sys};
579
580        pub const V1_00: InputGameVersion =
581            InputGameVersion::from_raw(sys::SCS_INPUT_EUT2_GAME_VERSION_1_00);
582        pub const CURRENT: InputGameVersion = V1_00;
583    }
584
585    pub mod ats {
586        use super::{InputGameVersion, sys};
587
588        pub const V1_00: InputGameVersion =
589            InputGameVersion::from_raw(sys::SCS_INPUT_ATS_GAME_VERSION_1_00);
590        pub const CURRENT: InputGameVersion = V1_00;
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    extern crate std;
597
598    use core::ffi::c_void;
599    use core::sync::atomic::{AtomicUsize, Ordering};
600    use std::vec::Vec;
601
602    use super::*;
603
604    static REGISTRATIONS: AtomicUsize = AtomicUsize::new(0);
605
606    unsafe extern "system" fn fake_log(_level: sys::ScsLogType, _message: sys::ScsString) {}
607
608    unsafe extern "system" fn fake_event(
609        _event: *mut sys::ScsInputEvent,
610        _flags: u32,
611        _context: *mut c_void,
612    ) -> sys::ScsResult {
613        sys::SCS_RESULT_NOT_FOUND
614    }
615
616    unsafe extern "system" fn fake_register(_device: *const sys::ScsInputDevice) -> sys::ScsResult {
617        REGISTRATIONS.fetch_add(1, Ordering::Relaxed);
618        sys::SCS_RESULT_OK
619    }
620
621    fn raw_api() -> sys::ScsInputInitParamsV100 {
622        sys::ScsInputInitParamsV100 {
623            common: sys::ScsSdkInitParamsV100 {
624                game_name: c"Game".as_ptr(),
625                game_id: c"eut2".as_ptr(),
626                game_version: sys::SCS_INPUT_EUT2_GAME_VERSION_1_00,
627                padding: MaybeUninit::uninit(),
628                log: fake_log,
629            },
630            register_device: fake_register,
631        }
632    }
633
634    #[test]
635    fn input_api_accepts_only_the_audited_v100_layout() {
636        let raw = raw_api();
637        let unsupported =
638            unsafe { InputApi::from_raw(InputApiVersion::new(1, 1), (&raw const raw).cast()) };
639        assert_eq!(unsupported.err(), Some(SdkError::Unsupported));
640
641        let api = unsafe { InputApi::from_raw(InputApiVersion::V1_00, (&raw const raw).cast()) }
642            .expect("v1.00 should be supported");
643        assert_eq!(api.game_version(), game::ets2::V1_00);
644    }
645
646    #[test]
647    fn registration_and_event_writing_preserve_types() {
648        REGISTRATIONS.store(0, Ordering::Relaxed);
649        let raw = raw_api();
650        let api = unsafe { InputApi::from_raw(InputApiVersion::V1_00, (&raw const raw).cast()) }
651            .expect("v1.00 should be supported");
652        let inputs = [InputDeviceInput::new(
653            c"button",
654            c"Button",
655            InputValueType::Bool,
656        )];
657        let device = unsafe {
658            InputDeviceRegistration::new(
659                c"device",
660                c"Device",
661                InputDeviceType::Generic,
662                &inputs,
663                core::ptr::null_mut(),
664                None,
665                fake_event,
666            )
667        }
668        .expect("device should be valid");
669        api.with_init_call(|call| call.register_device(&device))
670            .expect("registration should succeed");
671        assert_eq!(REGISTRATIONS.load(Ordering::Relaxed), 1);
672
673        let mut output = MaybeUninit::<sys::ScsInputEvent>::zeroed();
674        let event = InputEvent::new(
675            InputIndex::new(0).expect("zero is valid"),
676            InputValue::Bool(true),
677        );
678        unsafe { event.write_to(output.as_mut_ptr(), InputValueType::Bool) }
679            .expect("matching value type should write");
680        // SAFETY: The buffer was fully initialized with zeroes before
681        // `write_to`, which then initialized the index and active bool member.
682        let output = unsafe { output.assume_init() };
683        assert_eq!(output.input_index, 0);
684        // SAFETY: This event was registered and written as a bool above.
685        let value = unsafe { output.value.value_bool.value };
686        assert_eq!(value, 1);
687    }
688
689    #[test]
690    fn event_writing_rejects_null_and_value_type_mismatch() {
691        let index = InputIndex::new(0).expect("zero is valid");
692        let bool_event = InputEvent::new(index, InputValue::Bool(true));
693        let mut output = MaybeUninit::<sys::ScsInputEvent>::uninit();
694
695        let null_result =
696            unsafe { bool_event.write_to(core::ptr::null_mut(), InputValueType::Bool) };
697        assert_eq!(null_result, Err(SdkError::InvalidParameter));
698
699        let mismatch = unsafe { bool_event.write_to(output.as_mut_ptr(), InputValueType::Float) };
700        assert_eq!(mismatch, Err(SdkError::InvalidParameter));
701    }
702
703    #[test]
704    fn event_writing_initializes_only_the_active_union_storage() {
705        const SENTINEL: u8 = 0xA5;
706
707        let mut float_output = MaybeUninit::<sys::ScsInputEvent>::uninit();
708        // SAFETY: `float_output` provides aligned storage for exactly one raw
709        // event, and writing bytes is valid even before the typed value is
710        // initialized. The resulting sentinel bit pattern is valid opaque
711        // storage for the raw C union and lets this test observe which bytes
712        // `write_to` changes without assuming the whole event afterward.
713        unsafe {
714            core::ptr::write_bytes(
715                float_output.as_mut_ptr().cast::<u8>(),
716                SENTINEL,
717                core::mem::size_of::<sys::ScsInputEvent>(),
718            );
719        }
720        let event = InputEvent::new(
721            InputIndex::new(3).expect("three is valid"),
722            InputValue::Float(InputAxisValue::new(-0.625).expect("value is normalized")),
723        );
724
725        unsafe { event.write_to(float_output.as_mut_ptr(), InputValueType::Float) }
726            .expect("matching float value should write");
727
728        // SAFETY: Every byte in the backing storage was initialized to the
729        // sentinel before the field writes. Reading those initialized bytes as
730        // a byte slice neither selects nor reads a typed union member.
731        let float_bytes = unsafe {
732            core::slice::from_raw_parts(
733                float_output.as_ptr().cast::<u8>(),
734                core::mem::size_of::<sys::ScsInputEvent>(),
735            )
736        };
737        assert_eq!(&float_bytes[..4], &3_u32.to_ne_bytes());
738        assert_eq!(&float_bytes[4..8], &(-0.625_f32).to_ne_bytes());
739        assert!(float_bytes[8..].iter().all(|byte| *byte == SENTINEL));
740
741        let mut bool_output = MaybeUninit::<sys::ScsInputEvent>::uninit();
742        // SAFETY: The same aligned, in-bounds byte initialization argument as
743        // for `float_output` applies to this independent event buffer.
744        unsafe {
745            core::ptr::write_bytes(
746                bool_output.as_mut_ptr().cast::<u8>(),
747                SENTINEL,
748                core::mem::size_of::<sys::ScsInputEvent>(),
749            );
750        }
751        let event = InputEvent::new(
752            InputIndex::new(7).expect("seven is valid"),
753            InputValue::Bool(true),
754        );
755
756        unsafe { event.write_to(bool_output.as_mut_ptr(), InputValueType::Bool) }
757            .expect("matching bool value should write");
758
759        // SAFETY: The full backing allocation was initialized to the sentinel
760        // before `write_to` changed the index and one bool byte.
761        let bool_bytes = unsafe {
762            core::slice::from_raw_parts(
763                bool_output.as_ptr().cast::<u8>(),
764                core::mem::size_of::<sys::ScsInputEvent>(),
765            )
766        };
767        assert_eq!(&bool_bytes[..4], &7_u32.to_ne_bytes());
768        assert_eq!(bool_bytes[4], 1);
769        assert!(bool_bytes[5..].iter().all(|byte| *byte == SENTINEL));
770    }
771
772    #[test]
773    fn input_axis_value_accepts_only_the_finite_normalized_domain() {
774        for value in [-1.0, -0.625, -0.0, 0.0, 0.625, 1.0] {
775            let normalized = InputAxisValue::new(value).expect("value should be normalized");
776            assert_eq!(normalized.get().to_bits(), value.to_bits());
777            assert_eq!(f32::from(normalized).to_bits(), value.to_bits());
778            assert_eq!(InputAxisValue::try_from(value), Ok(normalized));
779        }
780
781        for value in [-1.000_000_1, -2.0, 1.000_000_1, 2.0] {
782            assert_eq!(
783                InputAxisValue::new(value),
784                Err(InputAxisValueError::OutOfRange)
785            );
786        }
787
788        for value in [f32::NAN, f32::NEG_INFINITY, f32::INFINITY] {
789            assert_eq!(
790                InputAxisValue::new(value),
791                Err(InputAxisValueError::NotFinite)
792            );
793        }
794
795        assert_eq!(InputAxisValue::MIN.get().to_bits(), (-1.0_f32).to_bits());
796        assert_eq!(InputAxisValue::CENTER.get().to_bits(), 0.0_f32.to_bits());
797        assert_eq!(InputAxisValue::MAX.get().to_bits(), 1.0_f32.to_bits());
798    }
799
800    #[test]
801    fn device_registration_rejects_empty_and_too_many_inputs() {
802        let empty = unsafe {
803            InputDeviceRegistration::new(
804                c"device",
805                c"Device",
806                InputDeviceType::Generic,
807                &[],
808                core::ptr::null_mut(),
809                None,
810                fake_event,
811            )
812        };
813        assert_eq!(empty.err(), Some(SdkError::InvalidParameter));
814
815        let inputs: Vec<_> = (0..=InputIndex::MAX_COUNT)
816            .map(|_| InputDeviceInput::new(c"button", c"Button", InputValueType::Bool))
817            .collect();
818        let too_many = unsafe {
819            InputDeviceRegistration::new(
820                c"device",
821                c"Device",
822                InputDeviceType::Generic,
823                &inputs,
824                core::ptr::null_mut(),
825                None,
826                fake_event,
827            )
828        };
829        assert_eq!(too_many.err(), Some(SdkError::InvalidParameter));
830    }
831
832    #[test]
833    fn input_event_flags_decode_known_bits_and_preserve_unknown_bits() {
834        let flags = InputEventFlags::from_raw(
835            sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_IN_FRAME
836                | sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_AFTER_ACTIVATION
837                | 0x8000_0000,
838        );
839
840        assert!(flags.first_in_frame());
841        assert!(flags.first_after_activation());
842        assert_eq!(
843            flags.raw(),
844            sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_IN_FRAME
845                | sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_AFTER_ACTIVATION
846                | 0x8000_0000
847        );
848    }
849
850    #[test]
851    fn input_device_value_type_does_not_treat_unknown_as_float() {
852        let mut input = InputDeviceInput::new(c"axis", c"Axis", InputValueType::Float);
853        assert_eq!(input.value_type(), Some(InputValueType::Float));
854
855        input.raw.value_type = sys::SCS_VALUE_TYPE_INVALID;
856        assert_eq!(input.value_type(), None);
857    }
858}