Skip to main content

scs_sdk_plugin/
input.rs

1//! Safe application framework for the SCS Input Device API.
2//!
3//! Input devices use a pull model: while a registered device is active, SCS
4//! repeatedly asks the plugin for the next event in the current frame. The
5//! public types in this module keep device declarations, callback identity,
6//! event flags, indices, and values in safe Rust. Raw callback pointers and
7//! the lifetime of their opaque contexts remain owned by the runtime.
8
9use std::ffi::CString;
10use std::fmt;
11
12use scs_sdk::{InputApiVersion, InputGameVersion, LogLevel, ScopedLogger, SdkError};
13
14use crate::{Game, PluginError, PluginMetadata, PluginResult, classify_game_id};
15
16pub use scs_sdk::input::{
17    InputAxisValue, InputAxisValueError, InputDeviceType, InputEvent, InputEventFlags, InputIndex,
18    InputValue, InputValueType,
19};
20
21/// Stable identity assigned to one device declared during plugin initialization.
22///
23/// The value is intentionally not interchangeable with an input index. SCS
24/// calls each device through a distinct opaque context, while [`InputIndex`]
25/// selects one entry inside that device's registered input array.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub struct InputDeviceId(u32);
28
29impl InputDeviceId {
30    pub(crate) const fn from_ordinal(ordinal: u32) -> Self {
31        Self(ordinal)
32    }
33
34    /// Zero-based declaration ordinal, useful for logs and application tables.
35    #[must_use]
36    pub const fn ordinal(self) -> u32 {
37        self.0
38    }
39}
40
41/// One bool or normalized float-axis input exposed by an input device.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct InputSpec {
44    name: &'static str,
45    display_name: &'static str,
46    value_type: InputValueType,
47}
48
49impl InputSpec {
50    /// Describes one input using the exact names consumed by SCS.
51    ///
52    /// Syntax is validated when the containing device is registered. The
53    /// configuration name accepts lowercase ASCII letters, digits, and
54    /// underscores. The display name accepts ASCII letters, digits,
55    /// underscores, spaces, and dots.
56    #[must_use]
57    pub const fn new(
58        name: &'static str,
59        display_name: &'static str,
60        value_type: InputValueType,
61    ) -> Self {
62        Self {
63            name,
64            display_name,
65            value_type,
66        }
67    }
68
69    /// Name persisted by the game or interpreted as a semantical mix name.
70    #[must_use]
71    pub const fn name(self) -> &'static str {
72        self.name
73    }
74
75    /// Human-facing name shown by the game's input UI.
76    #[must_use]
77    pub const fn display_name(self) -> &'static str {
78        self.display_name
79    }
80
81    /// SDK representation expected for events targeting this input.
82    #[must_use]
83    pub const fn value_type(self) -> InputValueType {
84        self.value_type
85    }
86}
87
88/// Explicit declaration of one SCS input device.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct InputDeviceSpec {
91    name: &'static str,
92    display_name: &'static str,
93    device_type: InputDeviceType,
94    inputs: &'static [InputSpec],
95    activity_notifications: bool,
96}
97
98impl InputDeviceSpec {
99    /// Creates a device declaration without the optional activity callback.
100    ///
101    /// Call [`InputDeviceSpec::with_activity_notifications`] when the product
102    /// needs explicit activation and deactivation notifications.
103    #[must_use]
104    pub const fn new(
105        name: &'static str,
106        display_name: &'static str,
107        device_type: InputDeviceType,
108        inputs: &'static [InputSpec],
109    ) -> Self {
110        Self {
111            name,
112            display_name,
113            device_type,
114            inputs,
115            activity_notifications: false,
116        }
117    }
118
119    /// Explicitly requests the optional SCS device-activity callback.
120    #[must_use]
121    pub const fn with_activity_notifications(mut self) -> Self {
122        self.activity_notifications = true;
123        self
124    }
125
126    /// Unique device configuration name.
127    #[must_use]
128    pub const fn name(self) -> &'static str {
129        self.name
130    }
131
132    /// Human-facing device name.
133    #[must_use]
134    pub const fn display_name(self) -> &'static str {
135        self.display_name
136    }
137
138    /// Generic bindable or semantical device class.
139    #[must_use]
140    pub const fn device_type(self) -> InputDeviceType {
141        self.device_type
142    }
143
144    /// Inputs in the exact zero-based order used by [`InputIndex`].
145    #[must_use]
146    pub const fn inputs(self) -> &'static [InputSpec] {
147        self.inputs
148    }
149
150    /// Whether the runtime should register the optional activity callback.
151    #[must_use]
152    pub const fn activity_notifications(self) -> bool {
153        self.activity_notifications
154    }
155}
156
157/// Owned identity and input-specific version of the loading game.
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct InputGameInfo {
160    name: String,
161    id: String,
162    kind: Game,
163    version: InputGameVersion,
164}
165
166impl InputGameInfo {
167    pub(crate) fn new(
168        name: &std::ffi::CStr,
169        id: &std::ffi::CStr,
170        version: InputGameVersion,
171    ) -> Self {
172        Self {
173            name: name.to_string_lossy().into_owned(),
174            id: id.to_string_lossy().into_owned(),
175            kind: classify_game_id(id),
176            version,
177        }
178    }
179
180    /// Human-facing name copied from the initialization parameters.
181    #[must_use]
182    pub fn name(&self) -> &str {
183        &self.name
184    }
185
186    /// Stable textual game identifier such as `eut2` or `ats`.
187    #[must_use]
188    pub fn id(&self) -> &str {
189        &self.id
190    }
191
192    /// Recognized game classification.
193    #[must_use]
194    pub const fn kind(&self) -> Game {
195        self.kind
196    }
197
198    /// Game-specific Input API version, separate from telemetry schema.
199    #[must_use]
200    pub const fn version(&self) -> InputGameVersion {
201        self.version
202    }
203}
204
205/// Minimum Input API game version required for one recognized game.
206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
207pub struct InputGameCompatibility {
208    game: Game,
209    minimum_version: InputGameVersion,
210}
211
212impl InputGameCompatibility {
213    /// Declares one recognized game and its oldest accepted Input API version.
214    #[must_use]
215    pub const fn new(game: Game, minimum_version: InputGameVersion) -> Self {
216        Self {
217            game,
218            minimum_version,
219        }
220    }
221
222    /// Game governed by this compatibility entry.
223    #[must_use]
224    pub const fn game(self) -> Game {
225        self.game
226    }
227
228    /// Oldest accepted game-specific Input API version.
229    #[must_use]
230    pub const fn minimum_version(self) -> InputGameVersion {
231        self.minimum_version
232    }
233}
234
235/// Explicit API and game requirements of one input plugin.
236#[derive(Clone, Copy, Debug, PartialEq, Eq)]
237pub struct InputPluginCompatibility {
238    minimum_input_api: InputApiVersion,
239    games: &'static [InputGameCompatibility],
240}
241
242impl InputPluginCompatibility {
243    /// Creates a product compatibility declaration validated before devices
244    /// are registered with SCS.
245    #[must_use]
246    pub const fn new(
247        minimum_input_api: InputApiVersion,
248        games: &'static [InputGameCompatibility],
249    ) -> Self {
250        Self {
251            minimum_input_api,
252            games,
253        }
254    }
255
256    /// Oldest Input API layout required by the product.
257    #[must_use]
258    pub const fn minimum_input_api(self) -> InputApiVersion {
259        self.minimum_input_api
260    }
261
262    /// Per-game input-version requirements.
263    #[must_use]
264    pub const fn games(self) -> &'static [InputGameCompatibility] {
265        self.games
266    }
267}
268
269/// Data supplied each time SCS asks one device for its next event.
270#[derive(Clone, Copy, Debug, PartialEq, Eq)]
271pub struct InputEventRequest {
272    device: InputDeviceId,
273    flags: InputEventFlags,
274}
275
276impl InputEventRequest {
277    pub(crate) const fn new(device: InputDeviceId, flags: InputEventFlags) -> Self {
278        Self { device, flags }
279    }
280
281    /// Device whose callback SCS is currently polling.
282    #[must_use]
283    pub const fn device(self) -> InputDeviceId {
284        self.device
285    }
286
287    /// Frame and activation boundary flags supplied by SCS.
288    #[must_use]
289    pub const fn flags(self) -> InputEventFlags {
290        self.flags
291    }
292}
293
294/// Safe capabilities available during one input-plugin hook.
295pub struct InputPluginContext<'scope> {
296    logger: ScopedLogger<'scope>,
297    api_version: InputApiVersion,
298    game: InputGameInfo,
299    devices: Option<&'scope mut Vec<InputDeviceSpec>>,
300}
301
302impl<'scope> InputPluginContext<'scope> {
303    pub(crate) fn initializing(
304        logger: ScopedLogger<'scope>,
305        api_version: InputApiVersion,
306        game: InputGameInfo,
307        devices: &'scope mut Vec<InputDeviceSpec>,
308    ) -> Self {
309        Self {
310            logger,
311            api_version,
312            game,
313            devices: Some(devices),
314        }
315    }
316
317    pub(crate) fn callback(
318        logger: ScopedLogger<'scope>,
319        api_version: InputApiVersion,
320        game: InputGameInfo,
321    ) -> Self {
322        Self {
323            logger,
324            api_version,
325            game,
326            devices: None,
327        }
328    }
329
330    /// Loading-game identity copied from the Input API initialization call.
331    #[must_use]
332    pub const fn game(&self) -> &InputGameInfo {
333        &self.game
334    }
335
336    /// Input API version selected by the SCS loader.
337    #[must_use]
338    pub const fn input_api_version(&self) -> InputApiVersion {
339        self.api_version
340    }
341
342    /// Declares one input device and returns its callback identity.
343    ///
344    /// Registration remains explicit: the runtime does not infer a device from
345    /// hook implementations or automatically install activity callbacks.
346    ///
347    /// # Errors
348    ///
349    /// Returns [`SdkError::NotNow`] outside [`InputPlugin::initialize`].
350    /// Invalid names, empty or oversized input arrays, duplicate device/input
351    /// names, and an unrepresentable device ordinal return
352    /// [`SdkError::InvalidParameter`] or [`SdkError::AlreadyRegistered`].
353    pub fn register_device(&mut self, spec: InputDeviceSpec) -> PluginResult<InputDeviceId> {
354        let Some(devices) = self.devices.as_deref_mut() else {
355            return Err(PluginError::new(
356                SdkError::NotNow,
357                "input devices may only be registered during plugin initialization",
358            ));
359        };
360        validate_device_spec(spec)?;
361        if devices.iter().any(|device| device.name() == spec.name()) {
362            return Err(PluginError::new(
363                SdkError::AlreadyRegistered,
364                format!("duplicate input device name {:?}", spec.name()),
365            ));
366        }
367        let ordinal = u32::try_from(devices.len()).map_err(|_| {
368            PluginError::new(
369                SdkError::InvalidParameter,
370                "input device count exceeds the framework identity range",
371            )
372        })?;
373        let id = InputDeviceId(ordinal);
374        devices.push(spec);
375        Ok(id)
376    }
377
378    /// Formats and writes one message to the game log.
379    pub fn log(&self, level: LogLevel, arguments: fmt::Arguments<'_>) {
380        let rendered = format!("{arguments}").replace('\0', " ");
381        if let Ok(message) = CString::new(rendered) {
382            self.logger.log(level, &message);
383        }
384    }
385
386    /// Logs an informational message.
387    pub fn message(&self, arguments: fmt::Arguments<'_>) {
388        self.log(LogLevel::Message, arguments);
389    }
390
391    /// Logs a warning message.
392    pub fn warning(&self, arguments: fmt::Arguments<'_>) {
393        self.log(LogLevel::Warning, arguments);
394    }
395
396    /// Logs an error message.
397    pub fn error(&self, arguments: fmt::Arguments<'_>) {
398        self.log(LogLevel::Error, arguments);
399    }
400}
401
402fn validate_device_spec(spec: InputDeviceSpec) -> PluginResult {
403    if !valid_configuration_name(spec.name()) {
404        return Err(PluginError::new(
405            SdkError::InvalidParameter,
406            format!("invalid input device configuration name {:?}", spec.name()),
407        ));
408    }
409    if !valid_display_name(spec.display_name()) {
410        return Err(PluginError::new(
411            SdkError::InvalidParameter,
412            format!(
413                "invalid input device display name {:?}",
414                spec.display_name()
415            ),
416        ));
417    }
418    if spec.inputs().is_empty() || spec.inputs().len() > InputIndex::MAX_COUNT as usize {
419        return Err(PluginError::new(
420            SdkError::InvalidParameter,
421            format!(
422                "input device {:?} must declare between 1 and {} inputs",
423                spec.name(),
424                InputIndex::MAX_COUNT,
425            ),
426        ));
427    }
428    for (position, input) in spec.inputs().iter().copied().enumerate() {
429        if !valid_configuration_name(input.name()) {
430            return Err(PluginError::new(
431                SdkError::InvalidParameter,
432                format!(
433                    "invalid input name {:?} at position {position} for device {:?}",
434                    input.name(),
435                    spec.name(),
436                ),
437            ));
438        }
439        if !valid_display_name(input.display_name()) {
440            return Err(PluginError::new(
441                SdkError::InvalidParameter,
442                format!(
443                    "invalid input display name {:?} at position {position} for device {:?}",
444                    input.display_name(),
445                    spec.name(),
446                ),
447            ));
448        }
449        if spec.inputs()[..position]
450            .iter()
451            .any(|previous| previous.name() == input.name())
452        {
453            return Err(PluginError::new(
454                SdkError::AlreadyRegistered,
455                format!(
456                    "duplicate input name {:?} for device {:?}",
457                    input.name(),
458                    spec.name(),
459                ),
460            ));
461        }
462    }
463    Ok(())
464}
465
466fn valid_configuration_name(value: &str) -> bool {
467    !value.is_empty()
468        && value
469            .bytes()
470            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
471}
472
473fn valid_display_name(value: &str) -> bool {
474    !value.is_empty()
475        && value.bytes().all(|byte| {
476            byte.is_ascii_alphabetic()
477                || byte.is_ascii_digit()
478                || matches!(byte, b'_' | b' ' | b'.')
479        })
480}
481
482/// Application-facing lifecycle for one SCS input plugin.
483pub trait InputPlugin: Send + 'static {
484    /// Stable product identity used in runtime lifecycle logs.
485    fn metadata(&self) -> PluginMetadata;
486
487    /// Explicit Input API and per-game compatibility requirements.
488    fn compatibility(&self) -> InputPluginCompatibility;
489
490    /// Declares devices and initializes plugin-owned input state.
491    ///
492    /// # Errors
493    ///
494    /// Implementations may return [`PluginError`] to reject configuration or
495    /// propagate a device-declaration failure before SCS registration begins.
496    fn initialize(&mut self, context: &mut InputPluginContext<'_>) -> PluginResult;
497
498    /// Receives an optional activation or deactivation notification.
499    fn device_active(
500        &mut self,
501        _context: &mut InputPluginContext<'_>,
502        _device: InputDeviceId,
503        _active: bool,
504    ) {
505    }
506
507    /// Returns the next event for the polled device, or `None` when the current
508    /// frame has no more events.
509    fn next_input_event(
510        &mut self,
511        _context: &mut InputPluginContext<'_>,
512        _request: InputEventRequest,
513    ) -> Option<InputEvent> {
514        None
515    }
516
517    /// Releases plugin-owned resources after SCS has unregistered all devices.
518    fn shutdown(&mut self, _context: &mut InputPluginContext<'_>) {}
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    const BOOL: InputSpec = InputSpec::new("button", "Button 1", InputValueType::Bool);
526    const DUPLICATES: [InputSpec; 2] = [BOOL, BOOL];
527
528    #[test]
529    fn validates_the_exact_header_name_character_sets() {
530        assert!(valid_configuration_name("device_01"));
531        assert!(!valid_configuration_name("Device"));
532        assert!(!valid_configuration_name("device-name"));
533        assert!(!valid_configuration_name(""));
534
535        assert!(valid_display_name("Example Device 1.0"));
536        assert!(!valid_display_name("Example/Device"));
537        assert!(!valid_display_name("设备"));
538    }
539
540    #[test]
541    fn validates_device_input_count_and_duplicate_names() {
542        let empty = InputDeviceSpec::new("empty", "Empty", InputDeviceType::Generic, &[]);
543        assert_eq!(
544            validate_device_spec(empty)
545                .err()
546                .map(|error| error.result()),
547            Some(SdkError::InvalidParameter)
548        );
549
550        let duplicate = InputDeviceSpec::new(
551            "duplicate",
552            "Duplicate",
553            InputDeviceType::Generic,
554            &DUPLICATES,
555        );
556        assert_eq!(
557            validate_device_spec(duplicate)
558                .err()
559                .map(|error| error.result()),
560            Some(SdkError::AlreadyRegistered)
561        );
562    }
563}