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 check above and the caller's contract establish that
264        // `output` points to a live, writable event buffer. `addr_of_mut!` only
265        // projects the field address and does not create a reference.
266        let input_index = unsafe { core::ptr::addr_of_mut!((*output).input_index) };
267        // SAFETY: `input_index` addresses the writable index field projected
268        // above, and writing it does not read any uninitialized storage.
269        unsafe { input_index.write(self.index.raw()) };
270
271        // SAFETY: The same live-buffer contract permits projecting the union's
272        // address without reading it or selecting an inactive member.
273        let output_value = unsafe { core::ptr::addr_of_mut!((*output).value) };
274        match self.value {
275            InputValue::Bool(value) => {
276                let output_bool = output_value.cast::<sys::ScsValueBool>();
277                // SAFETY: A C union's fields start at the union address. The
278                // validated bool variant selects exactly this active member.
279                unsafe {
280                    output_bool.write(sys::ScsValueBool {
281                        value: u8::from(value),
282                    });
283                };
284            }
285            InputValue::Float(value) => {
286                let output_float = output_value.cast::<sys::ScsValueFloat>();
287                // SAFETY: A C union's fields start at the union address. The
288                // validated float variant selects exactly this active member.
289                unsafe { output_float.write(sys::ScsValueFloat { value: value.get() }) };
290            }
291        }
292        Ok(())
293    }
294}
295
296/// Header-shaped description of one input, with C-string lifetimes retained.
297#[repr(transparent)]
298pub struct InputDeviceInput<'a> {
299    raw: sys::ScsInputDeviceInput,
300    lifetime: PhantomData<(&'a CStr, &'a CStr)>,
301}
302
303impl<'a> InputDeviceInput<'a> {
304    #[must_use]
305    pub const fn new(name: &'a CStr, display_name: &'a CStr, value_type: InputValueType) -> Self {
306        Self {
307            raw: sys::ScsInputDeviceInput {
308                name: name.as_ptr(),
309                display_name: display_name.as_ptr(),
310                value_type: value_type.raw(),
311                padding: MaybeUninit::uninit(),
312            },
313            lifetime: PhantomData,
314        }
315    }
316
317    #[must_use]
318    pub const fn value_type(&self) -> Option<InputValueType> {
319        match self.raw.value_type {
320            sys::SCS_VALUE_TYPE_BOOL => Some(InputValueType::Bool),
321            sys::SCS_VALUE_TYPE_FLOAT => Some(InputValueType::Float),
322            _ => None,
323        }
324    }
325}
326
327/// Fully described raw registration whose callback invariants were established
328/// by an audited upper layer.
329pub struct InputDeviceRegistration<'a> {
330    raw: sys::ScsInputDevice,
331    lifetime: PhantomData<&'a [InputDeviceInput<'a>]>,
332}
333
334impl<'a> InputDeviceRegistration<'a> {
335    /// Creates a device registration for one initialization-scoped SDK call.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`SdkError::InvalidParameter`] when the input slice is empty,
340    /// exceeds [`InputIndex::MAX_COUNT`], or cannot be represented by the raw
341    /// SDK count field.
342    ///
343    /// # Safety
344    ///
345    /// `callback_context` must remain valid for every callback until SCS has
346    /// automatically unregistered the device before `scs_input_shutdown`.
347    /// Both callbacks must contain panics, obey the game-main-thread contract,
348    /// and validate every foreign pointer before dereferencing it.
349    pub unsafe fn new(
350        name: &'a CStr,
351        display_name: &'a CStr,
352        device_type: InputDeviceType,
353        inputs: &'a [InputDeviceInput<'a>],
354        callback_context: *mut c_void,
355        active_callback: Option<sys::ScsInputActiveCallback>,
356        event_callback: sys::ScsInputEventCallback,
357    ) -> SdkResult<Self> {
358        if inputs.is_empty() || inputs.len() > sys::SCS_INPUT_MAX_INPUT_COUNT as usize {
359            return Err(SdkError::InvalidParameter);
360        }
361        let input_count = u32::try_from(inputs.len()).map_err(|_| SdkError::InvalidParameter)?;
362        Ok(Self {
363            raw: sys::ScsInputDevice {
364                name: name.as_ptr(),
365                display_name: display_name.as_ptr(),
366                type_: device_type.raw(),
367                input_count,
368                inputs: inputs.as_ptr().cast::<sys::ScsInputDeviceInput>(),
369                callback_context,
370                input_active_callback: active_callback,
371                input_event_callback: event_callback,
372            },
373            lifetime: PhantomData,
374        })
375    }
376}
377
378#[derive(Clone, Copy)]
379struct InputSessionTable {
380    version: InputApiVersion,
381    logger: sys::ScsLog,
382}
383
384/// Typed view over input initialization parameters supplied by SCS.
385pub struct InputApi<'a> {
386    raw: &'a sys::ScsInputInitParamsV100,
387    version: InputApiVersion,
388    not_send_sync: PhantomData<*mut ()>,
389}
390
391/// Inert handle retained for direct input callbacks and shutdown logging.
392#[derive(Clone, Copy)]
393pub struct InputSession {
394    table: InputSessionTable,
395}
396
397/// Capabilities valid during one direct input callback or shutdown call.
398///
399/// The raw SDK permits calls back into the game only while SCS is directly
400/// invoking plugin code on the game main thread. This token therefore carries
401/// an invariant lifetime and a raw-pointer marker so it cannot be stored, sent
402/// to another thread, or used as a global capability.
403///
404/// ```compile_fail
405/// use scs_sdk::input::InputCall;
406///
407/// fn require_send<T: Send>() {}
408///
409/// require_send::<InputCall<'static>>();
410/// ```
411pub struct InputCall<'scope> {
412    table: InputSessionTable,
413    scope: PhantomData<&'scope mut ()>,
414    not_send_sync: PhantomData<*mut ()>,
415}
416
417/// Initialization-only input capability which can register devices.
418pub struct InputInitCall<'scope> {
419    call: InputCall<'scope>,
420    register_device: sys::ScsInputRegisterDevice,
421}
422
423impl<'a> InputApi<'a> {
424    pub const SUPPORTED_VERSIONS: &'static [InputApiVersion] = &[InputApiVersion::V1_00];
425
426    #[must_use]
427    pub const fn supports_version(version: InputApiVersion) -> bool {
428        version.raw() == InputApiVersion::V1_00.raw()
429    }
430
431    /// Creates a typed view over the input initialization parameters.
432    ///
433    /// # Errors
434    ///
435    /// Returns [`SdkError::Unsupported`] for any input API version other than
436    /// the audited 1.00 layout, or [`SdkError::InvalidParameter`] when `params`
437    /// is null.
438    ///
439    /// # Safety
440    ///
441    /// For input API 1.00, `params` must point to the live matching structure
442    /// supplied by SCS for the duration of this main-thread initialization.
443    pub unsafe fn from_raw(
444        version: InputApiVersion,
445        params: *const sys::ScsInputInitParams,
446    ) -> SdkResult<Self> {
447        if !Self::supports_version(version) {
448            return Err(SdkError::Unsupported);
449        }
450        // SAFETY: The caller guarantees the matching v1.00 layout after the
451        // exact supported-version check above.
452        let raw = unsafe { params.cast::<sys::ScsInputInitParamsV100>().as_ref() }
453            .ok_or(SdkError::InvalidParameter)?;
454        Ok(Self {
455            raw,
456            version,
457            not_send_sync: PhantomData,
458        })
459    }
460
461    #[must_use]
462    pub const fn version(&self) -> InputApiVersion {
463        self.version
464    }
465
466    #[must_use]
467    pub fn game_name(&self) -> &'a CStr {
468        // SAFETY: SCS documents this pointer as non-null and NUL-terminated for
469        // the complete initialization call.
470        unsafe { CStr::from_ptr(self.raw.common.game_name) }
471    }
472
473    #[must_use]
474    pub fn game_id(&self) -> &'a CStr {
475        // SAFETY: SCS documents this pointer as non-null and NUL-terminated for
476        // the complete initialization call.
477        unsafe { CStr::from_ptr(self.raw.common.game_id) }
478    }
479
480    #[must_use]
481    pub const fn game_version(&self) -> InputGameVersion {
482        InputGameVersion::from_raw(self.raw.common.game_version)
483    }
484
485    #[must_use]
486    pub const fn session(&self) -> InputSession {
487        InputSession {
488            table: InputSessionTable {
489                version: self.version,
490                logger: self.raw.common.log,
491            },
492        }
493    }
494
495    pub fn with_init_call<R>(
496        &self,
497        operation: impl for<'scope> FnOnce(&InputInitCall<'scope>) -> R,
498    ) -> R {
499        let call = InputInitCall {
500            call: InputCall {
501                table: self.session().table,
502                scope: PhantomData,
503                not_send_sync: PhantomData,
504            },
505            register_device: self.raw.register_device,
506        };
507        operation(&call)
508    }
509}
510
511impl InputSession {
512    /// Creates a callback-scoped capability during a direct call from SCS.
513    ///
514    /// The higher-ranked callback keeps the created [`InputCall`] inside the
515    /// direct SDK callback scope:
516    ///
517    /// ```compile_fail
518    /// use scs_sdk::input::{InputCall, InputSession};
519    ///
520    /// fn leak(session: InputSession) -> &'static InputCall<'static> {
521    ///     unsafe { session.with_call(|call| call) }
522    /// }
523    /// ```
524    ///
525    /// # Safety
526    ///
527    /// The caller must be executing synchronously in an input callback or
528    /// shutdown entry point on the game main thread while this session is live.
529    pub unsafe fn with_call<R>(
530        self,
531        operation: impl for<'scope> FnOnce(&InputCall<'scope>) -> R,
532    ) -> R {
533        let call = InputCall {
534            table: self.table,
535            scope: PhantomData,
536            not_send_sync: PhantomData,
537        };
538        operation(&call)
539    }
540}
541
542impl InputCall<'_> {
543    #[must_use]
544    pub const fn input_api_version(&self) -> InputApiVersion {
545        self.table.version
546    }
547
548    #[must_use]
549    pub const fn logger(&self) -> ScopedLogger<'_> {
550        ScopedLogger::from_raw(self.table.logger)
551    }
552}
553
554impl InputInitCall<'_> {
555    #[must_use]
556    pub const fn input_api_version(&self) -> InputApiVersion {
557        self.call.input_api_version()
558    }
559
560    #[must_use]
561    pub const fn logger(&self) -> ScopedLogger<'_> {
562        self.call.logger()
563    }
564
565    /// Registers one input device during `scs_input_init`.
566    ///
567    /// # Errors
568    ///
569    /// Returns the SDK error reported by SCS when the device name, input list,
570    /// callbacks, or current lifecycle state are rejected by the game.
571    pub fn register_device(&self, device: &InputDeviceRegistration<'_>) -> SdkResult {
572        // SAFETY: `InputDeviceRegistration::new` established the callback and
573        // context invariants. This type is constructible only during the
574        // higher-ranked initialization scope, and SCS fully consumes the
575        // descriptor arrays before returning from this call.
576        let result = unsafe { (self.register_device)(&raw const device.raw) };
577        SdkError::from_code(result)
578    }
579}
580
581/// Input API game-version constants declared by the SDK 1.14 headers.
582pub mod game {
583    use crate::{InputGameVersion, sys};
584
585    pub mod ets2 {
586        use super::{InputGameVersion, sys};
587
588        pub const V1_00: InputGameVersion =
589            InputGameVersion::from_raw(sys::SCS_INPUT_EUT2_GAME_VERSION_1_00);
590        pub const CURRENT: InputGameVersion = V1_00;
591    }
592
593    pub mod ats {
594        use super::{InputGameVersion, sys};
595
596        pub const V1_00: InputGameVersion =
597            InputGameVersion::from_raw(sys::SCS_INPUT_ATS_GAME_VERSION_1_00);
598        pub const CURRENT: InputGameVersion = V1_00;
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    extern crate std;
605
606    use core::ffi::c_void;
607    use core::sync::atomic::{AtomicUsize, Ordering};
608    use std::vec::Vec;
609
610    use super::*;
611
612    static REGISTRATIONS: AtomicUsize = AtomicUsize::new(0);
613
614    unsafe extern "system" fn fake_log(_level: sys::ScsLogType, _message: sys::ScsString) {}
615
616    unsafe extern "system" fn fake_event(
617        _event: *mut sys::ScsInputEvent,
618        _flags: u32,
619        _context: *mut c_void,
620    ) -> sys::ScsResult {
621        sys::SCS_RESULT_NOT_FOUND
622    }
623
624    unsafe extern "system" fn fake_register(_device: *const sys::ScsInputDevice) -> sys::ScsResult {
625        REGISTRATIONS.fetch_add(1, Ordering::Relaxed);
626        sys::SCS_RESULT_OK
627    }
628
629    fn raw_api() -> sys::ScsInputInitParamsV100 {
630        sys::ScsInputInitParamsV100 {
631            common: sys::ScsSdkInitParamsV100 {
632                game_name: c"Game".as_ptr(),
633                game_id: c"eut2".as_ptr(),
634                game_version: sys::SCS_INPUT_EUT2_GAME_VERSION_1_00,
635                padding: MaybeUninit::uninit(),
636                log: fake_log,
637            },
638            register_device: fake_register,
639        }
640    }
641
642    #[test]
643    fn input_api_accepts_only_the_audited_v100_layout() {
644        let raw = raw_api();
645        // SAFETY: The pointer addresses a live, aligned V1.00 fixture. The
646        // deliberately unsupported version is rejected before its layout is
647        // interpreted, while the backing fixture remains valid either way.
648        let unsupported =
649            unsafe { InputApi::from_raw(InputApiVersion::new(1, 1), (&raw const raw).cast()) };
650        assert_eq!(unsupported.err(), Some(SdkError::Unsupported));
651
652        // SAFETY: `raw` is the exact initialized V1.00 structure and its
653        // strings and function table remain live for the borrowed API view.
654        let api = unsafe { InputApi::from_raw(InputApiVersion::V1_00, (&raw const raw).cast()) }
655            .expect("v1.00 should be supported");
656        assert_eq!(api.game_version(), game::ets2::V1_00);
657    }
658
659    #[test]
660    fn registration_and_event_writing_preserve_types() {
661        REGISTRATIONS.store(0, Ordering::Relaxed);
662        let raw = raw_api();
663        // SAFETY: `raw` is the exact initialized V1.00 structure and remains
664        // live through registration and event writing in this test.
665        let api = unsafe { InputApi::from_raw(InputApiVersion::V1_00, (&raw const raw).cast()) }
666            .expect("v1.00 should be supported");
667        let inputs = [InputDeviceInput::new(
668            c"button",
669            c"Button",
670            InputValueType::Bool,
671        )];
672        // SAFETY: The device and input descriptors outlive registration. The
673        // fixture callbacks have the exact ABI, never unwind, and neither
674        // callback dereferences the intentionally null context.
675        let device = unsafe {
676            InputDeviceRegistration::new(
677                c"device",
678                c"Device",
679                InputDeviceType::Generic,
680                &inputs,
681                core::ptr::null_mut(),
682                None,
683                fake_event,
684            )
685        }
686        .expect("device should be valid");
687        api.with_init_call(|call| call.register_device(&device))
688            .expect("registration should succeed");
689        assert_eq!(REGISTRATIONS.load(Ordering::Relaxed), 1);
690
691        let mut output = MaybeUninit::<sys::ScsInputEvent>::zeroed();
692        let event = InputEvent::new(
693            InputIndex::new(0).expect("zero is valid"),
694            InputValue::Bool(true),
695        );
696        // SAFETY: `output` is aligned writable storage for one event and stays
697        // live for the call; the expected type matches the event's bool value.
698        unsafe { event.write_to(output.as_mut_ptr(), InputValueType::Bool) }
699            .expect("matching value type should write");
700        // SAFETY: The buffer was fully initialized with zeroes before
701        // `write_to`, which then initialized the index and active bool member.
702        let output = unsafe { output.assume_init() };
703        assert_eq!(output.input_index, 0);
704        // SAFETY: This event was registered and written as a bool above.
705        let value = unsafe { output.value.value_bool.value };
706        assert_eq!(value, 1);
707    }
708
709    #[test]
710    fn event_writing_rejects_null_and_value_type_mismatch() {
711        let index = InputIndex::new(0).expect("zero is valid");
712        let bool_event = InputEvent::new(index, InputValue::Bool(true));
713        let mut output = MaybeUninit::<sys::ScsInputEvent>::uninit();
714
715        // SAFETY: A null output is explicitly permitted as an error input and
716        // is rejected before any pointer projection or write occurs.
717        let null_result =
718            unsafe { bool_event.write_to(core::ptr::null_mut(), InputValueType::Bool) };
719        assert_eq!(null_result, Err(SdkError::InvalidParameter));
720
721        // SAFETY: `output` is valid aligned storage, and the deliberate type
722        // mismatch is rejected before the uninitialized storage is accessed.
723        let mismatch = unsafe { bool_event.write_to(output.as_mut_ptr(), InputValueType::Float) };
724        assert_eq!(mismatch, Err(SdkError::InvalidParameter));
725    }
726
727    #[test]
728    fn event_writing_initializes_only_the_active_union_storage() {
729        const SENTINEL: u8 = 0xA5;
730
731        let mut float_output = MaybeUninit::<sys::ScsInputEvent>::uninit();
732        // SAFETY: `float_output` provides aligned storage for exactly one raw
733        // event, and writing bytes is valid even before the typed value is
734        // initialized. The resulting sentinel bit pattern is valid opaque
735        // storage for the raw C union and lets this test observe which bytes
736        // `write_to` changes without assuming the whole event afterward.
737        unsafe {
738            core::ptr::write_bytes(
739                float_output.as_mut_ptr().cast::<u8>(),
740                SENTINEL,
741                core::mem::size_of::<sys::ScsInputEvent>(),
742            );
743        }
744        let event = InputEvent::new(
745            InputIndex::new(3).expect("three is valid"),
746            InputValue::Float(InputAxisValue::new(-0.625).expect("value is normalized")),
747        );
748
749        // SAFETY: The byte initialization above made the complete aligned
750        // event storage writable and observable; the expected type matches the
751        // float event, so `write_to` selects only that active union member.
752        unsafe { event.write_to(float_output.as_mut_ptr(), InputValueType::Float) }
753            .expect("matching float value should write");
754
755        // SAFETY: Every byte in the backing storage was initialized to the
756        // sentinel before the field writes. Reading those initialized bytes as
757        // a byte slice neither selects nor reads a typed union member.
758        let float_bytes = unsafe {
759            core::slice::from_raw_parts(
760                float_output.as_ptr().cast::<u8>(),
761                core::mem::size_of::<sys::ScsInputEvent>(),
762            )
763        };
764        assert_eq!(&float_bytes[..4], &3_u32.to_ne_bytes());
765        assert_eq!(&float_bytes[4..8], &(-0.625_f32).to_ne_bytes());
766        assert!(float_bytes[8..].iter().all(|byte| *byte == SENTINEL));
767
768        let mut bool_output = MaybeUninit::<sys::ScsInputEvent>::uninit();
769        // SAFETY: The same aligned, in-bounds byte initialization argument as
770        // for `float_output` applies to this independent event buffer.
771        unsafe {
772            core::ptr::write_bytes(
773                bool_output.as_mut_ptr().cast::<u8>(),
774                SENTINEL,
775                core::mem::size_of::<sys::ScsInputEvent>(),
776            );
777        }
778        let event = InputEvent::new(
779            InputIndex::new(7).expect("seven is valid"),
780            InputValue::Bool(true),
781        );
782
783        // SAFETY: The byte initialization above made the complete aligned
784        // event storage writable and observable; the expected type matches the
785        // bool event, so `write_to` selects only that active union member.
786        unsafe { event.write_to(bool_output.as_mut_ptr(), InputValueType::Bool) }
787            .expect("matching bool value should write");
788
789        // SAFETY: The full backing allocation was initialized to the sentinel
790        // before `write_to` changed the index and one bool byte.
791        let bool_bytes = unsafe {
792            core::slice::from_raw_parts(
793                bool_output.as_ptr().cast::<u8>(),
794                core::mem::size_of::<sys::ScsInputEvent>(),
795            )
796        };
797        assert_eq!(&bool_bytes[..4], &7_u32.to_ne_bytes());
798        assert_eq!(bool_bytes[4], 1);
799        assert!(bool_bytes[5..].iter().all(|byte| *byte == SENTINEL));
800    }
801
802    #[test]
803    fn input_axis_value_accepts_only_the_finite_normalized_domain() {
804        for value in [-1.0, -0.625, -0.0, 0.0, 0.625, 1.0] {
805            let normalized = InputAxisValue::new(value).expect("value should be normalized");
806            assert_eq!(normalized.get().to_bits(), value.to_bits());
807            assert_eq!(f32::from(normalized).to_bits(), value.to_bits());
808            assert_eq!(InputAxisValue::try_from(value), Ok(normalized));
809        }
810
811        for value in [-1.000_000_1, -2.0, 1.000_000_1, 2.0] {
812            assert_eq!(
813                InputAxisValue::new(value),
814                Err(InputAxisValueError::OutOfRange)
815            );
816        }
817
818        for value in [f32::NAN, f32::NEG_INFINITY, f32::INFINITY] {
819            assert_eq!(
820                InputAxisValue::new(value),
821                Err(InputAxisValueError::NotFinite)
822            );
823        }
824
825        assert_eq!(InputAxisValue::MIN.get().to_bits(), (-1.0_f32).to_bits());
826        assert_eq!(InputAxisValue::CENTER.get().to_bits(), 0.0_f32.to_bits());
827        assert_eq!(InputAxisValue::MAX.get().to_bits(), 1.0_f32.to_bits());
828    }
829
830    #[test]
831    fn device_registration_rejects_empty_and_too_many_inputs() {
832        // SAFETY: The callback has the exact ABI and never unwinds or touches
833        // the null context. The empty input list is rejected before any
834        // registration can publish these borrowed descriptors.
835        let empty = unsafe {
836            InputDeviceRegistration::new(
837                c"device",
838                c"Device",
839                InputDeviceType::Generic,
840                &[],
841                core::ptr::null_mut(),
842                None,
843                fake_event,
844            )
845        };
846        assert_eq!(empty.err(), Some(SdkError::InvalidParameter));
847
848        let inputs: Vec<_> = (0..=InputIndex::MAX_COUNT)
849            .map(|_| InputDeviceInput::new(c"button", c"Button", InputValueType::Bool))
850            .collect();
851        // SAFETY: The callback/context contract matches the empty case, and
852        // the oversized list is rejected before its borrowed storage can be
853        // published to SCS.
854        let too_many = unsafe {
855            InputDeviceRegistration::new(
856                c"device",
857                c"Device",
858                InputDeviceType::Generic,
859                &inputs,
860                core::ptr::null_mut(),
861                None,
862                fake_event,
863            )
864        };
865        assert_eq!(too_many.err(), Some(SdkError::InvalidParameter));
866    }
867
868    #[test]
869    fn input_event_flags_decode_known_bits_and_preserve_unknown_bits() {
870        let flags = InputEventFlags::from_raw(
871            sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_IN_FRAME
872                | sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_AFTER_ACTIVATION
873                | 0x8000_0000,
874        );
875
876        assert!(flags.first_in_frame());
877        assert!(flags.first_after_activation());
878        assert_eq!(
879            flags.raw(),
880            sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_IN_FRAME
881                | sys::SCS_INPUT_EVENT_CALLBACK_FLAG_FIRST_AFTER_ACTIVATION
882                | 0x8000_0000
883        );
884    }
885
886    #[test]
887    fn input_device_value_type_does_not_treat_unknown_as_float() {
888        let mut input = InputDeviceInput::new(c"axis", c"Axis", InputValueType::Float);
889        assert_eq!(input.value_type(), Some(InputValueType::Float));
890
891        input.raw.value_type = sys::SCS_VALUE_TYPE_INVALID;
892        assert_eq!(input.value_type(), None);
893    }
894}