Skip to main content

scs_sdk_plugin/
lib.rs

1//! Safe application framework for SCS Telemetry SDK plugins.
2//!
3//! This crate owns the parts of an SCS plugin which inherently require unsafe
4//! Rust: exported ABI symbols, raw callback trampolines, stable opaque context
5//! addresses, tagged-union borrowing, lifecycle sequencing, registration
6//! rollback, and panic containment. Application crates implement
7//! [`TelemetryPlugin`] and export it with [`export_plugin!`] using ordinary safe
8//! Rust only.
9//!
10//! # Layering
11//!
12//! - `scs-sdk-sys` mirrors the C ABI and header constants;
13//! - `scs-sdk` supplies typed descriptors and callback-time borrowed views;
14//! - this crate turns those pieces into a safe plugin lifecycle;
15//! - `scs-sdk-plugin-macros` emits the two symbols discovered by the game.
16//!
17//! # Threading model
18//!
19//! The official SDK invokes initialization, telemetry callbacks, and shutdown
20//! on the game main thread. The runtime nevertheless serializes its global
21//! state with a mutex so Rust's process-wide statics remain sound and poisoned
22//! locks can be recovered deterministically. Plugin implementations are
23//! required to be [`Send`] because they are stored behind that synchronized
24//! boundary; the framework does not itself move callbacks onto worker threads.
25
26#![deny(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
27#![cfg_attr(
28    not(test),
29    deny(
30        clippy::expect_used,
31        clippy::panic,
32        clippy::todo,
33        clippy::unimplemented,
34        clippy::unreachable,
35        clippy::unwrap_used
36    )
37)]
38
39mod input;
40mod input_runtime;
41mod runtime;
42
43use std::borrow::Cow;
44use std::ffi::{CStr, CString};
45use std::fmt;
46
47use scs_sdk::{
48    AnyChannel, Attribute, Channel, ChannelFlags, ChannelValue, ConfigurationId, ConfigurationRef,
49    FrameStartRef, GameSchemaAvailability, GameSchemaVersion, GameplayEventId, GameplayEventRef,
50    LogLevel, SdkCall, SdkError, SdkIndex, SdkValue, StringValue, TelemetryApiVersion,
51    TrailerConfigurationId, TrailerIndex, ValueRef,
52};
53
54pub use input::{
55    InputAxisValue, InputAxisValueError, InputDeviceId, InputDeviceSpec, InputDeviceType,
56    InputEvent, InputEventFlags, InputEventRequest, InputGameCompatibility, InputGameInfo,
57    InputIndex, InputPlugin, InputPluginCompatibility, InputPluginContext, InputSpec, InputValue,
58    InputValueType,
59};
60/// Typed descriptor and value layer used when implementing plugin hooks.
61///
62/// Re-exporting the middle layer keeps application manifests dependent on the
63/// framework crate alone while preserving its normal module organization.
64pub use scs_sdk as sdk;
65pub use scs_sdk_plugin_macros::{export_input_plugin, export_plugin};
66
67/// Application-facing name for the canonical [`sdk::Event`] descriptor.
68///
69/// This is a re-export rather than a framework-owned mirror enum. Event
70/// identifiers and their minimum Telemetry API versions belong to the typed
71/// SDK layer; the plugin framework only records explicit subscriptions and
72/// turns the corresponding callbacks into [`TelemetryEvent`] payloads.
73pub use scs_sdk::Event as TelemetryEventKind;
74
75/// Result type returned by plugin initialization and subscription helpers.
76pub type PluginResult<T = ()> = Result<T, PluginError>;
77
78/// Tests one negotiated Telemetry API against a minimum within the same major
79/// compatibility family.
80///
81/// This policy belongs to the framework rather than the version newtype: the
82/// typed SDK preserves raw future versions, while the plugin runtime decides
83/// when a product or requested capability may use them.
84pub(crate) const fn telemetry_api_satisfies(
85    actual: TelemetryApiVersion,
86    minimum: TelemetryApiVersion,
87) -> bool {
88    actual.major() == minimum.major() && actual.raw() >= minimum.raw()
89}
90
91/// Tests an actual game schema against a descriptor minimum within one major.
92pub(crate) const fn game_schema_satisfies(
93    actual: GameSchemaVersion,
94    minimum: GameSchemaVersion,
95) -> bool {
96    actual.major() == minimum.major() && actual.raw() >= minimum.raw()
97}
98
99/// An initialization failure with both an SDK result code and human-readable
100/// context suitable for the game log.
101///
102/// The SDK ABI only returns a numeric `scs_result_t`. Retaining a message here
103/// lets the runtime log the actual failing subscription or plugin invariant
104/// before converting the error back to that numeric code.
105#[derive(Debug)]
106pub struct PluginError {
107    result: SdkError,
108    message: String,
109}
110
111impl PluginError {
112    /// Creates a framework error which maps to `result` at the ABI boundary.
113    #[must_use]
114    pub fn new(result: SdkError, message: impl Into<String>) -> Self {
115        Self {
116            result,
117            message: message.into(),
118        }
119    }
120
121    /// Returns the SDK error ultimately reported to the game.
122    #[must_use]
123    pub const fn result(&self) -> SdkError {
124        self.result
125    }
126
127    /// Returns the explanatory text logged before initialization is rolled back.
128    #[must_use]
129    pub fn message(&self) -> &str {
130        &self.message
131    }
132}
133
134impl From<SdkError> for PluginError {
135    fn from(result: SdkError) -> Self {
136        Self::new(result, result.to_string())
137    }
138}
139
140impl fmt::Display for PluginError {
141    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142        formatter.write_str(&self.message)
143    }
144}
145
146impl std::error::Error for PluginError {}
147
148/// Recognized games which implement the SCS Telemetry SDK.
149///
150/// Unknown identifiers are retained in [`GameInfo::id`] even when this enum is
151/// [`Game::Other`], allowing future SCS titles and third-party fixtures to be
152/// diagnosed without exposing their C-string representation.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub enum Game {
155    EuroTruckSimulator2,
156    AmericanTruckSimulator,
157    Other,
158}
159
160pub(crate) fn classify_game_id(id: &CStr) -> Game {
161    // Compare the exact NUL-terminated identifiers declared by the raw SDK
162    // layer. Keeping the official bytes there avoids independent handwritten
163    // `eut2`/`ats` catalogs in telemetry and input framework code.
164    let id_bytes = id.to_bytes_with_nul();
165    if id_bytes == scs_sdk::sys::SCS_GAME_ID_EUT2 {
166        Game::EuroTruckSimulator2
167    } else if id_bytes == scs_sdk::sys::SCS_GAME_ID_ATS {
168        Game::AmericanTruckSimulator
169    } else {
170        Game::Other
171    }
172}
173
174/// Owned identity of the game which loaded the plugin.
175///
176/// SCS only promises that the original C strings remain live during the
177/// initialization call. The framework copies them so every later callback can
178/// inspect the same metadata without extending a foreign pointer's lifetime.
179#[derive(Clone, Debug, PartialEq, Eq)]
180pub struct GameInfo {
181    name: String,
182    id: String,
183    kind: Game,
184    schema_version: GameSchemaVersion,
185}
186
187impl GameInfo {
188    pub(crate) fn new(name: &CStr, id: &CStr, schema_version: GameSchemaVersion) -> Self {
189        Self {
190            name: name.to_string_lossy().into_owned(),
191            id: id.to_string_lossy().into_owned(),
192            kind: classify_game_id(id),
193            schema_version,
194        }
195    }
196
197    /// Human-facing game name supplied by the SDK as Rust text.
198    ///
199    /// Invalid UTF-8 is replaced during initialization because the foreign
200    /// allocation ceases to be valid when that call returns.
201    #[must_use]
202    pub fn name(&self) -> &str {
203        &self.name
204    }
205
206    /// Stable textual identifier such as `eut2` or `ats`.
207    #[must_use]
208    pub fn id(&self) -> &str {
209        &self.id
210    }
211
212    /// Typed classification of the game identifier.
213    #[must_use]
214    pub const fn kind(&self) -> Game {
215        self.kind
216    }
217
218    /// Game-specific telemetry schema supplied by the SDK.
219    ///
220    /// This does not represent the telemetry API ABI or the public game patch.
221    #[must_use]
222    pub const fn schema_version(&self) -> GameSchemaVersion {
223        self.schema_version
224    }
225
226    /// Resolves one SDK descriptor, association, capability, or value history
227    /// entry for the detected game.
228    ///
229    /// Unknown game IDs return `None`: ETS2 and ATS use independent schema
230    /// histories, so applying either known game's minimum to a third game would
231    /// silently invent compatibility evidence.
232    #[must_use]
233    pub const fn minimum_schema_for(
234        &self,
235        availability: GameSchemaAvailability,
236    ) -> Option<GameSchemaVersion> {
237        match self.kind {
238            Game::EuroTruckSimulator2 => availability.available_since_ets2(),
239            Game::AmericanTruckSimulator => availability.available_since_ats(),
240            Game::Other => None,
241        }
242    }
243
244    /// Whether the detected game schema satisfies one official availability
245    /// record within the same schema-major family.
246    ///
247    /// This is the same policy used by runtime registration preflight. It is
248    /// public so application diagnostics and schema-driven UI can query channel,
249    /// association, and enum-value history without reimplementing version or
250    /// game-kind matching.
251    #[must_use]
252    pub const fn supports(&self, availability: GameSchemaAvailability) -> bool {
253        match self.minimum_schema_for(availability) {
254            Some(minimum) => game_schema_satisfies(self.schema_version, minimum),
255            None => false,
256        }
257    }
258}
259
260/// Minimum telemetry schema required for one supported SCS game.
261///
262/// A schema major identifies the compatibility family. Minor versions are
263/// additive according to the SDK contract, so a plugin accepts the declared
264/// minimum and later minor versions within the same major. A different major
265/// is rejected until the plugin explicitly updates this declaration after
266/// reviewing the changed semantics.
267#[derive(Clone, Copy, Debug, PartialEq, Eq)]
268pub struct GameCompatibility {
269    game: Game,
270    minimum_schema: GameSchemaVersion,
271}
272
273impl GameCompatibility {
274    /// Declares support for one recognized game and its minimum schema.
275    ///
276    /// Use one entry per game. The runtime rejects duplicate entries and the
277    /// broad `Game::Other` classification because neither would identify one
278    /// unambiguous compatibility policy.
279    #[must_use]
280    pub const fn new(game: Game, minimum_schema: GameSchemaVersion) -> Self {
281        Self {
282            game,
283            minimum_schema,
284        }
285    }
286
287    /// Recognized game governed by this declaration.
288    #[must_use]
289    pub const fn game(self) -> Game {
290        self.game
291    }
292
293    /// Oldest telemetry schema accepted for this game.
294    #[must_use]
295    pub const fn minimum_schema(self) -> GameSchemaVersion {
296        self.minimum_schema
297    }
298}
299
300/// Explicit runtime requirements declared by one product plugin.
301///
302/// The framework and product answer different compatibility questions:
303///
304/// - scs-sdk decides which foreign API layouts it can decode soundly;
305/// - this declaration states which decoded API and game capabilities the
306///   product actually needs;
307/// - SCS chooses the concrete API version by calling the exported initializer
308///   from newest to oldest.
309///
310/// Ordinary users therefore install one plugin binary and never select an SDK
311/// distribution or ABI version manually.
312#[derive(Clone, Copy, Debug, PartialEq, Eq)]
313pub struct PluginCompatibility {
314    minimum_telemetry_api: TelemetryApiVersion,
315    games: &'static [GameCompatibility],
316}
317
318impl PluginCompatibility {
319    /// Creates an explicit product compatibility declaration.
320    ///
321    /// The games slice must contain at least one recognized game, must not
322    /// contain duplicate game entries, and must not use `Game::Other`. These
323    /// invariants are checked before product initialization so a malformed
324    /// declaration never reaches SDK registration.
325    #[must_use]
326    pub const fn new(
327        minimum_telemetry_api: TelemetryApiVersion,
328        games: &'static [GameCompatibility],
329    ) -> Self {
330        Self {
331            minimum_telemetry_api,
332            games,
333        }
334    }
335
336    /// Oldest Telemetry API whose capabilities the product requires.
337    #[must_use]
338    pub const fn minimum_telemetry_api(self) -> TelemetryApiVersion {
339        self.minimum_telemetry_api
340    }
341
342    /// Per-game schema requirements accepted by the product.
343    #[must_use]
344    pub const fn games(self) -> &'static [GameCompatibility] {
345        self.games
346    }
347}
348
349/// Whether absence of one channel invalidates the complete plugin transaction.
350///
351/// This remains internal because application code expresses the policy through
352/// the explicit `subscribe*` and `subscribe_optional*` method families rather
353/// than constructing an abstract options object.
354#[derive(Clone, Copy, Debug, PartialEq, Eq)]
355pub(crate) enum SubscriptionRequirement {
356    Required,
357    Optional,
358}
359
360impl SubscriptionRequirement {
361    pub(crate) const fn tolerates_channel_registration_error(self, error: SdkError) -> bool {
362        matches!(self, Self::Optional)
363            && matches!(error, SdkError::NotFound | SdkError::UnsupportedType)
364    }
365
366    pub(crate) const fn tolerates_event_registration_error(self, error: SdkError) -> bool {
367        matches!(self, Self::Optional)
368            && matches!(error, SdkError::Unsupported | SdkError::NotFound)
369    }
370}
371
372#[derive(Clone, Copy, Debug)]
373pub(crate) struct EventSubscriptionSpec {
374    pub(crate) event: TelemetryEventKind,
375    pub(crate) requirement: SubscriptionRequirement,
376}
377
378#[derive(Clone, Debug)]
379pub(crate) struct SubscriptionSpec {
380    pub(crate) channel: AnyChannel,
381    pub(crate) registered_name: CString,
382    pub(crate) sdk_index: Option<SdkIndex>,
383    pub(crate) trailer_index: Option<TrailerIndex>,
384    pub(crate) flags: ChannelFlags,
385    pub(crate) requirement: SubscriptionRequirement,
386}
387
388/// Safe capabilities available while the framework calls plugin code.
389///
390/// Logging is valid in every hook. Channel registration is intentionally open
391/// only during [`TelemetryPlugin::initialize`]; calling a subscription method
392/// from another hook returns [`SdkError::NotNow`] without touching the SDK.
393pub struct PluginContext<'scope> {
394    call: &'scope SdkCall<'scope>,
395    game: GameInfo,
396    events: Option<&'scope mut Vec<EventSubscriptionSpec>>,
397    subscriptions: Option<&'scope mut Vec<SubscriptionSpec>>,
398}
399
400impl<'scope> PluginContext<'scope> {
401    pub(crate) fn initializing(
402        call: &'scope SdkCall<'scope>,
403        game: GameInfo,
404        events: &'scope mut Vec<EventSubscriptionSpec>,
405        subscriptions: &'scope mut Vec<SubscriptionSpec>,
406    ) -> Self {
407        Self {
408            call,
409            game,
410            events: Some(events),
411            subscriptions: Some(subscriptions),
412        }
413    }
414
415    pub(crate) fn callback(call: &'scope SdkCall<'scope>, game: GameInfo) -> Self {
416        Self {
417            call,
418            game,
419            events: None,
420            subscriptions: None,
421        }
422    }
423
424    /// Metadata copied from the game's initialization parameters.
425    #[must_use]
426    pub const fn game(&self) -> &GameInfo {
427        &self.game
428    }
429
430    /// Telemetry API version selected by the SCS loader for this session.
431    ///
432    /// This is the negotiated runtime ABI, not the SDK archive version and not
433    /// the game-specific telemetry schema returned by `PluginContext::game`.
434    /// Product code normally relies on `TelemetryPlugin::compatibility` for
435    /// mandatory requirements and reads this value only for an explicit
436    /// optional capability branch.
437    #[must_use]
438    pub const fn telemetry_api_version(&self) -> TelemetryApiVersion {
439        self.call.telemetry_api_version()
440    }
441
442    /// Formats and writes one message to the game log.
443    ///
444    /// Interior NUL characters have no representation in a C string. They are
445    /// replaced with spaces so malformed external text cannot suppress the
446    /// entire log entry or force application code to handle an FFI detail.
447    pub fn log(&self, level: LogLevel, arguments: fmt::Arguments<'_>) {
448        let rendered = format!("{arguments}").replace('\0', " ");
449        if let Ok(message) = CString::new(rendered) {
450            self.call.logger().log(level, &message);
451        }
452    }
453
454    /// Logs an informational message.
455    pub fn message(&self, arguments: fmt::Arguments<'_>) {
456        self.log(LogLevel::Message, arguments);
457    }
458
459    /// Logs a warning message.
460    pub fn warning(&self, arguments: fmt::Arguments<'_>) {
461        self.log(LogLevel::Warning, arguments);
462    }
463
464    /// Logs an error message.
465    pub fn error(&self, arguments: fmt::Arguments<'_>) {
466        self.log(LogLevel::Error, arguments);
467    }
468
469    /// Requests delivery of one telemetry event kind.
470    ///
471    /// Event registration is explicit: the framework never infers it from the
472    /// presence of [`TelemetryPlugin::event`] and does not subscribe to unused
473    /// frame, configuration, or gameplay callbacks. Registration with SCS is
474    /// deferred until [`TelemetryPlugin::initialize`] returns successfully so
475    /// the complete set can be rolled back transactionally.
476    ///
477    /// # Errors
478    ///
479    /// Returns [`SdkError::AlreadyRegistered`] when the same event kind was
480    /// requested twice, or [`SdkError::NotNow`] when called from a callback or
481    /// shutdown hook instead of initialization. A required event introduced by
482    /// a newer game schema returns [`SdkError::Unsupported`] before foreign
483    /// registration, independently from its Telemetry API requirement.
484    pub fn subscribe_event(&mut self, event: TelemetryEventKind) -> PluginResult {
485        self.subscribe_event_with_requirement(event, SubscriptionRequirement::Required)
486    }
487
488    /// Requests delivery of an event when the negotiated API and loading game
489    /// provide it.
490    ///
491    /// An event introduced by a newer API is skipped locally. If SCS reports
492    /// `Unsupported` or `NotFound` while registering it, the remaining required
493    /// transaction continues. Duplicate declarations, wrong lifecycle phase,
494    /// and other SDK failures remain errors.
495    ///
496    /// # Errors
497    ///
498    /// Returns [`SdkError::AlreadyRegistered`] for a duplicate event or
499    /// [`SdkError::NotNow`] outside product initialization. A non-capability
500    /// SDK registration error later aborts the complete transaction.
501    pub fn subscribe_event_optional(&mut self, event: TelemetryEventKind) -> PluginResult {
502        self.subscribe_event_with_requirement(event, SubscriptionRequirement::Optional)
503    }
504
505    fn subscribe_event_with_requirement(
506        &mut self,
507        event: TelemetryEventKind,
508        requirement: SubscriptionRequirement,
509    ) -> PluginResult {
510        let api_version = self.telemetry_api_version();
511        let Some(events) = self.events.as_deref_mut() else {
512            return Err(PluginError::new(
513                SdkError::NotNow,
514                "events may only be subscribed during plugin initialization",
515            ));
516        };
517        let minimum_api = event.minimum_api_version();
518        if !telemetry_api_satisfies(api_version, minimum_api)
519            && requirement == SubscriptionRequirement::Required
520        {
521            return Err(PluginError::new(
522                SdkError::Unsupported,
523                format!(
524                    "event {event:?} requires telemetry API {minimum_api}, negotiated {api_version}"
525                ),
526            ));
527        }
528        let minimum_schema = self.game.minimum_schema_for(event.availability());
529        let schema_supported = self.game.supports(event.availability());
530        if !schema_supported && requirement == SubscriptionRequirement::Required {
531            let detail = minimum_schema.map_or_else(
532                || format!("is not available for {:?}", self.game.kind()),
533                |minimum| {
534                    format!(
535                        "requires {:?} telemetry schema {minimum}; detected {}",
536                        self.game.kind(),
537                        self.game.schema_version(),
538                    )
539                },
540            );
541            return Err(PluginError::new(
542                SdkError::Unsupported,
543                format!("event {event:?} {detail}"),
544            ));
545        }
546        if events.iter().any(|candidate| candidate.event == event) {
547            return Err(PluginError::new(
548                SdkError::AlreadyRegistered,
549                format!("duplicate event subscription for {event:?}"),
550            ));
551        }
552        events.push(EventSubscriptionSpec { event, requirement });
553        Ok(())
554    }
555
556    /// Subscribes to one scalar channel using its canonical SDK name.
557    ///
558    /// Values are delivered only when they change unless `flags` includes
559    /// [`ChannelFlags::EACH_FRAME`]. For an indexed channel, use
560    /// [`PluginContext::subscribe_at`].
561    ///
562    /// # Errors
563    ///
564    /// Returns [`SdkError::InvalidParameter`] when `channel` is indexed,
565    /// [`SdkError::AlreadyRegistered`] for a duplicate subscription, or
566    /// [`SdkError::NotNow`] outside initialization. A requested value
567    /// representation introduced after the negotiated Telemetry API returns
568    /// [`SdkError::Unsupported`] before the SDK registration transaction begins.
569    /// The same result is returned when the built-in descriptor postdates the
570    /// loading game's telemetry schema.
571    pub fn subscribe<T: ChannelValue>(&mut self, channel: Channel<T>) -> PluginResult {
572        self.subscribe_with_flags(channel, ChannelFlags::NONE)
573    }
574
575    /// Subscribes to one scalar channel with explicit SDK delivery flags.
576    ///
577    /// # Errors
578    ///
579    /// Uses the same validation as [`PluginContext::subscribe`].
580    pub fn subscribe_with_flags<T: ChannelValue>(
581        &mut self,
582        channel: Channel<T>,
583        flags: ChannelFlags,
584    ) -> PluginResult {
585        self.subscribe_scalar_with_flags(channel, flags, SubscriptionRequirement::Required)
586    }
587
588    /// Requests a scalar channel when available in the loading game.
589    ///
590    /// Unlike [`PluginContext::subscribe`], absence of the channel or an
591    /// unsupported channel-specific conversion does not abort initialization.
592    /// No callback is delivered for a skipped subscription, so product state
593    /// must retain an explicit default or unavailable representation.
594    ///
595    /// API- and game-schema-level capability checks are also optional: for
596    /// example, an `i64` request is skipped under Telemetry API 1.00, and a
597    /// navigation channel is skipped before ETS2 schema 1.12.
598    /// Invalid descriptor shape, duplicate declarations, and lifecycle misuse
599    /// remain errors because they indicate a malformed plugin declaration.
600    ///
601    /// # Errors
602    ///
603    /// Returns the same descriptor-shape, duplicate, and lifecycle declaration
604    /// errors as [`PluginContext::subscribe`]. Non-capability registration
605    /// failures still abort the complete transaction.
606    pub fn subscribe_optional<T: ChannelValue>(&mut self, channel: Channel<T>) -> PluginResult {
607        self.subscribe_optional_with_flags(channel, ChannelFlags::NONE)
608    }
609
610    /// Requests an optional scalar channel with explicit delivery flags.
611    ///
612    /// # Errors
613    ///
614    /// Uses the declaration validation documented by
615    /// [`PluginContext::subscribe_optional`].
616    pub fn subscribe_optional_with_flags<T: ChannelValue>(
617        &mut self,
618        channel: Channel<T>,
619        flags: ChannelFlags,
620    ) -> PluginResult {
621        self.subscribe_scalar_with_flags(channel, flags, SubscriptionRequirement::Optional)
622    }
623
624    fn subscribe_scalar_with_flags<T: ChannelValue>(
625        &mut self,
626        channel: Channel<T>,
627        flags: ChannelFlags,
628        requirement: SubscriptionRequirement,
629    ) -> PluginResult {
630        if channel.is_indexed() {
631            return Err(PluginError::new(
632                SdkError::InvalidParameter,
633                format!("indexed channel {:?} requires subscribe_at", channel.name()),
634            ));
635        }
636        self.push_subscription(
637            channel.erase(),
638            channel.name().to_owned(),
639            None,
640            None,
641            flags,
642            requirement,
643        )
644    }
645
646    /// Subscribes to one member of an SDK-indexed channel.
647    ///
648    /// The index selects an element such as one truck wheel. It is independent
649    /// from the trailer number embedded by [`PluginContext::subscribe_trailer_at`].
650    ///
651    /// # Errors
652    ///
653    /// Returns [`SdkError::InvalidParameter`] for a scalar descriptor and the
654    /// normal phase/duplicate errors documented by [`PluginContext::subscribe`].
655    pub fn subscribe_at<T: ChannelValue>(
656        &mut self,
657        channel: Channel<T>,
658        index: SdkIndex,
659    ) -> PluginResult {
660        self.subscribe_at_with_flags(channel, index, ChannelFlags::NONE)
661    }
662
663    /// Subscribes to an indexed channel with explicit SDK delivery flags.
664    ///
665    /// # Errors
666    ///
667    /// Uses the same validation as [`PluginContext::subscribe_at`].
668    pub fn subscribe_at_with_flags<T: ChannelValue>(
669        &mut self,
670        channel: Channel<T>,
671        index: SdkIndex,
672        flags: ChannelFlags,
673    ) -> PluginResult {
674        self.subscribe_at_with_flags_and_requirement(
675            channel,
676            index,
677            flags,
678            SubscriptionRequirement::Required,
679        )
680    }
681
682    /// Requests one indexed channel member when available.
683    ///
684    /// `NotFound` and `UnsupportedType` from SCS skip this member without
685    /// affecting required registrations. Descriptor-shape, duplicate, index,
686    /// and lifecycle validation remain strict.
687    ///
688    /// # Errors
689    ///
690    /// Returns the same declaration errors as [`PluginContext::subscribe_at`].
691    /// Non-capability registration failures still abort the transaction.
692    pub fn subscribe_at_optional<T: ChannelValue>(
693        &mut self,
694        channel: Channel<T>,
695        index: SdkIndex,
696    ) -> PluginResult {
697        self.subscribe_at_optional_with_flags(channel, index, ChannelFlags::NONE)
698    }
699
700    /// Requests an optional indexed channel member with delivery flags.
701    ///
702    /// # Errors
703    ///
704    /// Uses the declaration validation documented by
705    /// [`PluginContext::subscribe_at_optional`].
706    pub fn subscribe_at_optional_with_flags<T: ChannelValue>(
707        &mut self,
708        channel: Channel<T>,
709        index: SdkIndex,
710        flags: ChannelFlags,
711    ) -> PluginResult {
712        self.subscribe_at_with_flags_and_requirement(
713            channel,
714            index,
715            flags,
716            SubscriptionRequirement::Optional,
717        )
718    }
719
720    fn subscribe_at_with_flags_and_requirement<T: ChannelValue>(
721        &mut self,
722        channel: Channel<T>,
723        index: SdkIndex,
724        flags: ChannelFlags,
725        requirement: SubscriptionRequirement,
726    ) -> PluginResult {
727        if !channel.is_indexed() {
728            return Err(PluginError::new(
729                SdkError::InvalidParameter,
730                format!(
731                    "scalar channel {:?} does not accept an SDK index",
732                    channel.name()
733                ),
734            ));
735        }
736        self.push_subscription(
737            channel.erase(),
738            channel.name().to_owned(),
739            Some(index),
740            None,
741            flags,
742            requirement,
743        )
744    }
745
746    /// Subscribes to one scalar channel for an explicit trailer in a chain.
747    ///
748    /// The official static macros use `trailer.*`, while multi-trailer
749    /// telemetry is named `trailer.0.*`, `trailer.1.*`, and so on. This helper
750    /// performs that transformation and retains the generated C string for the
751    /// complete SDK registration lifetime.
752    ///
753    /// # Errors
754    ///
755    /// Returns [`SdkError::InvalidParameter`] when the descriptor is not a
756    /// scalar `trailer.*` channel. [`TrailerIndex`] construction already
757    /// enforces the official `0..10` range. The numbered namespace itself
758    /// requires ETS2 schema 1.14 or ATS schema 1.01, even when the underlying
759    /// backward-compatible `trailer.*` descriptor is older.
760    pub fn subscribe_trailer<T: ChannelValue>(
761        &mut self,
762        channel: Channel<T>,
763        trailer_index: TrailerIndex,
764    ) -> PluginResult {
765        self.subscribe_trailer_with_flags(channel, trailer_index, ChannelFlags::NONE)
766    }
767
768    /// Subscribes to a scalar multi-trailer channel with explicit delivery flags.
769    ///
770    /// # Errors
771    ///
772    /// Uses the same validation as [`PluginContext::subscribe_trailer`].
773    pub fn subscribe_trailer_with_flags<T: ChannelValue>(
774        &mut self,
775        channel: Channel<T>,
776        trailer_index: TrailerIndex,
777        flags: ChannelFlags,
778    ) -> PluginResult {
779        self.subscribe_trailer_with_flags_and_requirement(
780            channel,
781            trailer_index,
782            flags,
783            SubscriptionRequirement::Required,
784        )
785    }
786
787    /// Requests one scalar trailer channel when available.
788    ///
789    /// The trailer name is still validated eagerly; the strong index is valid
790    /// by construction. Only SCS `NotFound` and `UnsupportedType` registration
791    /// results, or a value representation newer than the negotiated API, are
792    /// treated as expected absence.
793    ///
794    /// # Errors
795    ///
796    /// Returns the same descriptor, duplicate, and lifecycle declaration errors
797    /// as [`PluginContext::subscribe_trailer`].
798    pub fn subscribe_trailer_optional<T: ChannelValue>(
799        &mut self,
800        channel: Channel<T>,
801        trailer_index: TrailerIndex,
802    ) -> PluginResult {
803        self.subscribe_trailer_optional_with_flags(channel, trailer_index, ChannelFlags::NONE)
804    }
805
806    /// Requests an optional scalar trailer channel with delivery flags.
807    ///
808    /// # Errors
809    ///
810    /// Uses the declaration validation documented by
811    /// [`PluginContext::subscribe_trailer_optional`].
812    pub fn subscribe_trailer_optional_with_flags<T: ChannelValue>(
813        &mut self,
814        channel: Channel<T>,
815        trailer_index: TrailerIndex,
816        flags: ChannelFlags,
817    ) -> PluginResult {
818        self.subscribe_trailer_with_flags_and_requirement(
819            channel,
820            trailer_index,
821            flags,
822            SubscriptionRequirement::Optional,
823        )
824    }
825
826    fn subscribe_trailer_with_flags_and_requirement<T: ChannelValue>(
827        &mut self,
828        channel: Channel<T>,
829        trailer_index: TrailerIndex,
830        flags: ChannelFlags,
831        requirement: SubscriptionRequirement,
832    ) -> PluginResult {
833        if channel.is_indexed() {
834            return Err(PluginError::new(
835                SdkError::InvalidParameter,
836                "indexed trailer channels require subscribe_trailer_at",
837            ));
838        }
839        let name = trailer_channel_name(channel, trailer_index)?;
840        self.push_subscription(
841            channel.erase(),
842            name,
843            None,
844            Some(trailer_index),
845            flags,
846            requirement,
847        )
848    }
849
850    /// Subscribes to one SDK-indexed member of an explicit trailer channel.
851    ///
852    /// For example, trailer index 1 and SDK index 2 select wheel 2 of the second
853    /// trailer. Both strong index domains are zero-based.
854    ///
855    /// # Errors
856    ///
857    /// Returns [`SdkError::InvalidParameter`] unless `channel` is an indexed
858    /// `trailer.*` descriptor. The trailer range and scalar sentinel are
859    /// excluded while constructing [`TrailerIndex`] and [`SdkIndex`].
860    pub fn subscribe_trailer_at<T: ChannelValue>(
861        &mut self,
862        channel: Channel<T>,
863        trailer_index: TrailerIndex,
864        index: SdkIndex,
865    ) -> PluginResult {
866        self.subscribe_trailer_at_with_flags(channel, trailer_index, index, ChannelFlags::NONE)
867    }
868
869    /// Subscribes to an indexed multi-trailer channel with delivery flags.
870    ///
871    /// # Errors
872    ///
873    /// Uses the same validation as [`PluginContext::subscribe_trailer_at`].
874    pub fn subscribe_trailer_at_with_flags<T: ChannelValue>(
875        &mut self,
876        channel: Channel<T>,
877        trailer_index: TrailerIndex,
878        index: SdkIndex,
879        flags: ChannelFlags,
880    ) -> PluginResult {
881        self.subscribe_trailer_at_with_flags_and_requirement(
882            channel,
883            trailer_index,
884            index,
885            flags,
886            SubscriptionRequirement::Required,
887        )
888    }
889
890    /// Requests an indexed member of one trailer channel when available.
891    ///
892    /// Both index domains remain explicit and strictly validated. Expected SDK
893    /// capability absence skips only this declaration.
894    ///
895    /// # Errors
896    ///
897    /// Returns the same descriptor, index, duplicate, and lifecycle declaration
898    /// errors as [`PluginContext::subscribe_trailer_at`].
899    pub fn subscribe_trailer_at_optional<T: ChannelValue>(
900        &mut self,
901        channel: Channel<T>,
902        trailer_index: TrailerIndex,
903        index: SdkIndex,
904    ) -> PluginResult {
905        self.subscribe_trailer_at_optional_with_flags(
906            channel,
907            trailer_index,
908            index,
909            ChannelFlags::NONE,
910        )
911    }
912
913    /// Requests an optional indexed trailer channel with delivery flags.
914    ///
915    /// # Errors
916    ///
917    /// Uses the declaration validation documented by
918    /// [`PluginContext::subscribe_trailer_at_optional`].
919    pub fn subscribe_trailer_at_optional_with_flags<T: ChannelValue>(
920        &mut self,
921        channel: Channel<T>,
922        trailer_index: TrailerIndex,
923        index: SdkIndex,
924        flags: ChannelFlags,
925    ) -> PluginResult {
926        self.subscribe_trailer_at_with_flags_and_requirement(
927            channel,
928            trailer_index,
929            index,
930            flags,
931            SubscriptionRequirement::Optional,
932        )
933    }
934
935    fn subscribe_trailer_at_with_flags_and_requirement<T: ChannelValue>(
936        &mut self,
937        channel: Channel<T>,
938        trailer_index: TrailerIndex,
939        index: SdkIndex,
940        flags: ChannelFlags,
941        requirement: SubscriptionRequirement,
942    ) -> PluginResult {
943        if !channel.is_indexed() {
944            return Err(PluginError::new(
945                SdkError::InvalidParameter,
946                "scalar trailer channels do not accept an SDK index",
947            ));
948        }
949        let name = trailer_channel_name(channel, trailer_index)?;
950        self.push_subscription(
951            channel.erase(),
952            name,
953            Some(index),
954            Some(trailer_index),
955            flags,
956            requirement,
957        )
958    }
959
960    fn push_subscription(
961        &mut self,
962        channel: AnyChannel,
963        registered_name: CString,
964        sdk_index: Option<SdkIndex>,
965        trailer_index: Option<TrailerIndex>,
966        flags: ChannelFlags,
967        requirement: SubscriptionRequirement,
968    ) -> PluginResult {
969        let api_version = self.telemetry_api_version();
970        let descriptor_minimum = self.game.minimum_schema_for(channel.availability());
971        let descriptor_supported = self.game.supports(channel.availability());
972        let trailer_minimum = trailer_index.and_then(|_| {
973            self.game
974                .minimum_schema_for(scs_sdk::game::capabilities::MULTI_TRAILER)
975        });
976        let trailer_supported = trailer_index.is_none()
977            || self
978                .game
979                .supports(scs_sdk::game::capabilities::MULTI_TRAILER);
980        let Some(subscriptions) = self.subscriptions.as_deref_mut() else {
981            return Err(PluginError::new(
982                SdkError::NotNow,
983                "channels may only be subscribed during plugin initialization",
984            ));
985        };
986
987        let value_type = channel.value_type();
988        let minimum_api = value_type.minimum_api_version();
989        if !telemetry_api_satisfies(api_version, minimum_api)
990            && requirement == SubscriptionRequirement::Required
991        {
992            return Err(PluginError::new(
993                SdkError::Unsupported,
994                format!(
995                    "channel {registered_name:?} requests {value_type:?}, which requires telemetry API {minimum_api}; negotiated {api_version}",
996                ),
997            ));
998        }
999        if (!descriptor_supported || !trailer_supported)
1000            && requirement == SubscriptionRequirement::Required
1001        {
1002            let (capability, minimum) = if descriptor_supported {
1003                ("numbered multi-trailer namespace", trailer_minimum)
1004            } else {
1005                ("channel descriptor", descriptor_minimum)
1006            };
1007            let detail = minimum.map_or_else(
1008                || format!("is not available for {:?}", self.game.kind()),
1009                |minimum| {
1010                    format!(
1011                        "requires {:?} telemetry schema {minimum}; detected {}",
1012                        self.game.kind(),
1013                        self.game.schema_version(),
1014                    )
1015                },
1016            );
1017            return Err(PluginError::new(
1018                SdkError::Unsupported,
1019                format!("{capability} {registered_name:?} {detail}"),
1020            ));
1021        }
1022
1023        let duplicate = subscriptions.iter().any(|candidate| {
1024            candidate.registered_name == registered_name
1025                && candidate.sdk_index == sdk_index
1026                && candidate.channel.value_type() == channel.value_type()
1027        });
1028        if duplicate {
1029            return Err(PluginError::new(
1030                SdkError::AlreadyRegistered,
1031                format!("duplicate subscription for {registered_name:?}, index {sdk_index:?}"),
1032            ));
1033        }
1034
1035        subscriptions.push(SubscriptionSpec {
1036            channel,
1037            registered_name,
1038            sdk_index,
1039            trailer_index,
1040            flags,
1041            requirement,
1042        });
1043        Ok(())
1044    }
1045}
1046
1047fn trailer_channel_name<T: ChannelValue>(
1048    channel: Channel<T>,
1049    trailer_index: TrailerIndex,
1050) -> PluginResult<CString> {
1051    let name = channel.name().to_str().map_err(|error| {
1052        PluginError::new(
1053            SdkError::InvalidParameter,
1054            format!("channel name is not UTF-8: {error}"),
1055        )
1056    })?;
1057    let Some(suffix) = name.strip_prefix("trailer.") else {
1058        return Err(PluginError::new(
1059            SdkError::InvalidParameter,
1060            format!("channel {name:?} is not a trailer channel"),
1061        ));
1062    };
1063    CString::new(format!("trailer.{trailer_index}.{suffix}")).map_err(|error| {
1064        PluginError::new(
1065            SdkError::InvalidParameter,
1066            format!("generated trailer channel contains a NUL byte: {error}"),
1067        )
1068    })
1069}
1070
1071/// One safely borrowed channel callback.
1072///
1073/// Numeric and geometry values may be copied out and retained. String values
1074/// borrow storage owned by the game and therefore remain tied to this update's
1075/// callback lifetime.
1076#[derive(Clone, Copy)]
1077pub struct ChannelUpdate<'a> {
1078    channel: AnyChannel,
1079    registered_name: &'a CStr,
1080    index: Option<SdkIndex>,
1081    trailer_index: Option<TrailerIndex>,
1082    flags: ChannelFlags,
1083    value: Option<ValueRef<'a>>,
1084}
1085
1086impl<'a> ChannelUpdate<'a> {
1087    pub(crate) const fn new(
1088        channel: AnyChannel,
1089        registered_name: &'a CStr,
1090        index: Option<SdkIndex>,
1091        trailer_index: Option<TrailerIndex>,
1092        flags: ChannelFlags,
1093        value: Option<ValueRef<'a>>,
1094    ) -> Self {
1095        Self {
1096            channel,
1097            registered_name,
1098            index,
1099            trailer_index,
1100            flags,
1101            value,
1102        }
1103    }
1104
1105    /// Type-erased canonical descriptor originally supplied by the plugin.
1106    #[must_use]
1107    pub const fn channel(self) -> AnyChannel {
1108        self.channel
1109    }
1110
1111    /// Exact name registered with SCS, including a multi-trailer prefix.
1112    ///
1113    /// Official channel names are ASCII. The lossy return type also keeps this
1114    /// method total if a future SDK accepts a non-UTF-8 custom channel name.
1115    #[must_use]
1116    pub fn registered_name(self) -> Cow<'a, str> {
1117        self.registered_name.to_string_lossy()
1118    }
1119
1120    /// Zero-based SDK array index, or `None` for a scalar channel.
1121    #[must_use]
1122    pub const fn index(self) -> Option<SdkIndex> {
1123        self.index
1124    }
1125
1126    /// Zero-based trailer number embedded in the registered name.
1127    #[must_use]
1128    pub const fn trailer_index(self) -> Option<TrailerIndex> {
1129        self.trailer_index
1130    }
1131
1132    /// Delivery flags used for this subscription.
1133    #[must_use]
1134    pub const fn flags(self) -> ChannelFlags {
1135        self.flags
1136    }
1137
1138    /// Raw safe view of the tagged value, absent for `NO_VALUE` subscriptions.
1139    #[must_use]
1140    pub const fn value_ref(self) -> Option<ValueRef<'a>> {
1141        self.value
1142    }
1143
1144    /// Tests whether this update belongs to a particular typed descriptor.
1145    #[must_use]
1146    pub fn is<T: ChannelValue>(self, channel: Channel<T>) -> bool {
1147        self.channel == channel.erase()
1148    }
1149
1150    /// Decodes the update using the supplied typed descriptor.
1151    ///
1152    /// `None` means either the descriptor does not match this registration, the
1153    /// subscription requested `NO_VALUE`, or SCS supplied a different union tag.
1154    #[must_use]
1155    pub fn value<T: ChannelValue>(self, channel: Channel<T>) -> Option<T::Decoded<'a>> {
1156        if !self.is(channel) {
1157            return None;
1158        }
1159        channel.decode(self.value?)
1160    }
1161}
1162
1163/// High-level view over one SDK configuration event.
1164///
1165/// This wrapper keeps the exact zero-allocation typed decoder in `scs-sdk`
1166/// while adding Rust-text accessors for product plugins. All borrowed data is
1167/// still restricted to the current callback.
1168#[derive(Clone, Copy)]
1169pub struct ConfigurationEvent<'a> {
1170    inner: ConfigurationRef<'a>,
1171}
1172
1173impl<'a> ConfigurationEvent<'a> {
1174    pub(crate) const fn new(inner: ConfigurationRef<'a>) -> Self {
1175        Self { inner }
1176    }
1177
1178    /// Tests the event against a typed SDK configuration identifier.
1179    #[must_use]
1180    pub fn is(self, id: ConfigurationId) -> bool {
1181        self.inner.is(id)
1182    }
1183
1184    /// Classifies the legacy or numbered trailer configuration identity.
1185    #[must_use]
1186    pub fn trailer(self) -> Option<TrailerConfigurationId> {
1187        self.inner.trailer()
1188    }
1189
1190    /// Returns the numbered trailer index, excluding the legacy alias.
1191    #[must_use]
1192    pub fn trailer_index(self) -> Option<TrailerIndex> {
1193        self.inner.trailer_index()
1194    }
1195
1196    /// Whether this is the legacy unnumbered `trailer` configuration.
1197    #[must_use]
1198    pub fn is_legacy_trailer(self) -> bool {
1199        self.inner.is_legacy_trailer()
1200    }
1201
1202    /// Configuration identifier as Rust text.
1203    #[must_use]
1204    pub fn id(self) -> Cow<'a, str> {
1205        self.inner.id().to_string_lossy()
1206    }
1207
1208    /// Whether the event contains at least one attribute.
1209    ///
1210    /// SCS sends an empty job configuration when the active job disappears.
1211    #[must_use]
1212    pub fn has_attributes(self) -> bool {
1213        self.inner.attributes().next().is_some()
1214    }
1215
1216    /// Decodes a scalar typed attribute.
1217    #[must_use]
1218    pub fn get<T: SdkValue>(self, attribute: Attribute<T>) -> Option<T::Decoded<'a>> {
1219        self.inner.attributes().get(attribute)
1220    }
1221
1222    /// Decodes one member of an indexed typed attribute.
1223    #[must_use]
1224    pub fn get_at<T: SdkValue>(
1225        self,
1226        attribute: Attribute<T>,
1227        index: SdkIndex,
1228    ) -> Option<T::Decoded<'a>> {
1229        self.inner.attributes().get_at(attribute, index)
1230    }
1231
1232    /// Decodes a string attribute as callback-lifetime Rust text.
1233    #[must_use]
1234    pub fn string(self, attribute: Attribute<StringValue>) -> Option<Cow<'a, str>> {
1235        self.get(attribute).map(CStr::to_string_lossy)
1236    }
1237
1238    /// Copies a string attribute for storage beyond the current callback.
1239    #[must_use]
1240    pub fn string_owned(self, attribute: Attribute<StringValue>) -> Option<String> {
1241        self.string(attribute).map(Cow::into_owned)
1242    }
1243
1244    /// Decodes the documented `controls` shifter type as a Rust enum.
1245    ///
1246    /// `None` covers an absent attribute and a future string which SDK 1.14 did
1247    /// not document. Call [`ConfigurationEvent::string`] with
1248    /// [`sdk::configuration::attributes::SHIFTER_TYPE`] when the original
1249    /// unknown text must be retained for forward-compatible diagnostics.
1250    #[must_use]
1251    pub fn shifter_type(self) -> Option<sdk::configuration::ShifterType> {
1252        let value = self.string(sdk::configuration::attributes::SHIFTER_TYPE)?;
1253        value.parse().ok()
1254    }
1255
1256    /// Decodes the documented active-job market as a Rust enum.
1257    ///
1258    /// Unknown future values remain accessible through the generic string
1259    /// accessor instead of being reclassified as one of the known SDK 1.14
1260    /// variants.
1261    #[must_use]
1262    pub fn job_market(self) -> Option<sdk::configuration::JobMarket> {
1263        let value = self.string(sdk::configuration::attributes::JOB_MARKET)?;
1264        value.parse().ok()
1265    }
1266}
1267
1268/// High-level view over one SDK gameplay event.
1269///
1270/// Gameplay payloads share the same typed attribute descriptors as the middle
1271/// layer but expose dedicated Rust-text helpers for string values.
1272#[derive(Clone, Copy)]
1273pub struct GameplayEvent<'a> {
1274    inner: GameplayEventRef<'a>,
1275}
1276
1277impl<'a> GameplayEvent<'a> {
1278    pub(crate) const fn new(inner: GameplayEventRef<'a>) -> Self {
1279        Self { inner }
1280    }
1281
1282    /// Tests the event against a typed SDK gameplay identifier.
1283    #[must_use]
1284    pub fn is(self, id: GameplayEventId) -> bool {
1285        self.inner.is(id)
1286    }
1287
1288    /// Gameplay event identifier as Rust text.
1289    #[must_use]
1290    pub fn id(self) -> Cow<'a, str> {
1291        self.inner.id().to_string_lossy()
1292    }
1293
1294    /// Decodes a scalar typed attribute.
1295    #[must_use]
1296    pub fn get<T: SdkValue>(self, attribute: Attribute<T>) -> Option<T::Decoded<'a>> {
1297        self.inner.attributes().get(attribute)
1298    }
1299
1300    /// Decodes one member of an indexed typed attribute.
1301    #[must_use]
1302    pub fn get_at<T: SdkValue>(
1303        self,
1304        attribute: Attribute<T>,
1305        index: SdkIndex,
1306    ) -> Option<T::Decoded<'a>> {
1307        self.inner.attributes().get_at(attribute, index)
1308    }
1309
1310    /// Decodes a string attribute as callback-lifetime Rust text.
1311    #[must_use]
1312    pub fn string(self, attribute: Attribute<StringValue>) -> Option<Cow<'a, str>> {
1313        self.get(attribute).map(CStr::to_string_lossy)
1314    }
1315
1316    /// Copies a string attribute for storage beyond the current callback.
1317    #[must_use]
1318    pub fn string_owned(self, attribute: Attribute<StringValue>) -> Option<String> {
1319        self.string(attribute).map(Cow::into_owned)
1320    }
1321
1322    /// Decodes a documented `player.fined` offence value as a Rust enum.
1323    ///
1324    /// An absent attribute and a future additive offence both return `None`.
1325    /// The generic string accessor preserves the original text when callers
1326    /// need to distinguish those cases.
1327    #[must_use]
1328    pub fn fine_offence(self) -> Option<sdk::gameplay::FineOffence> {
1329        let value = self.string(sdk::gameplay::attributes::FINE_OFFENCE)?;
1330        value.parse().ok()
1331    }
1332}
1333
1334/// Safe event payload delivered to [`TelemetryPlugin::event`].
1335///
1336/// Pointer-bearing SDK structures are converted to lifetime-bound views before
1337/// application code runs. Events without a payload use unit variants.
1338#[derive(Clone, Copy)]
1339pub enum TelemetryEvent<'a> {
1340    FrameStart(FrameStartRef<'a>),
1341    FrameEnd,
1342    Paused,
1343    Started,
1344    Configuration(ConfigurationEvent<'a>),
1345    Gameplay(GameplayEvent<'a>),
1346}
1347
1348/// Static identity reported by the framework during plugin lifecycle logging.
1349///
1350/// Metadata is supplied by the product instead of inferred from Rust type names
1351/// or Cargo package names. This keeps the user-facing identity explicit and
1352/// stable even when the implementation type, workspace layout, or crate name
1353/// changes independently.
1354#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1355pub struct PluginMetadata {
1356    name: &'static str,
1357    version: &'static str,
1358}
1359
1360impl PluginMetadata {
1361    /// Creates product metadata from static build information.
1362    ///
1363    /// `env!("CARGO_PKG_VERSION")` is suitable for `version`; the product name
1364    /// should be the stable human-facing plugin identity shown in game logs.
1365    #[must_use]
1366    pub const fn new(name: &'static str, version: &'static str) -> Self {
1367        Self { name, version }
1368    }
1369
1370    /// Stable human-facing plugin name.
1371    #[must_use]
1372    pub const fn name(self) -> &'static str {
1373        self.name
1374    }
1375
1376    /// Product version embedded when the plugin was built.
1377    #[must_use]
1378    pub const fn version(self) -> &'static str {
1379        self.version
1380    }
1381}
1382
1383/// Application-facing telemetry plugin lifecycle.
1384///
1385/// Every method is called synchronously on the SDK thread. The framework catches
1386/// unwinding panics at every ABI entry point; release builds in this workspace
1387/// additionally use `panic = "abort"`, matching a native game plugin's usual
1388/// failure policy.
1389pub trait TelemetryPlugin: Send + 'static {
1390    /// Declares the stable product name and build version used in runtime logs.
1391    ///
1392    /// This method is required so framework diagnostics never silently fall
1393    /// back to an implementation type name or an unrelated workspace package.
1394    fn metadata(&self) -> PluginMetadata;
1395
1396    /// Declares the Telemetry API and per-game schema requirements of this
1397    /// product.
1398    ///
1399    /// This method is required rather than defaulting to the widest possible
1400    /// range. The runtime validates the declaration before invoking initialize,
1401    /// so an incompatible game never reaches product state setup or SDK
1402    /// registration.
1403    fn compatibility(&self) -> PluginCompatibility;
1404
1405    /// Declares subscriptions and initializes plugin-owned state.
1406    ///
1407    /// Returning an error aborts initialization before a successful result is
1408    /// reported to the game. If later SDK registration fails, the framework
1409    /// unregisters the completed prefix and calls [`TelemetryPlugin::shutdown`].
1410    ///
1411    /// # Errors
1412    ///
1413    /// Implementations may return [`PluginError`] to reject the loading game,
1414    /// report invalid configuration, or propagate a subscription failure.
1415    fn initialize(&mut self, context: &mut PluginContext<'_>) -> PluginResult;
1416
1417    /// Receives one subscribed telemetry channel update.
1418    fn channel(&mut self, _context: &mut PluginContext<'_>, _update: ChannelUpdate<'_>) {}
1419
1420    /// Receives one SDK lifecycle, configuration, gameplay, or frame event.
1421    fn event(&mut self, _context: &mut PluginContext<'_>, _event: TelemetryEvent<'_>) {}
1422
1423    /// Releases plugin-owned resources while the SDK logging function is valid.
1424    fn shutdown(&mut self, _context: &mut PluginContext<'_>) {}
1425}
1426
1427/// Items referenced by [`export_plugin!`] expansions.
1428///
1429/// They are public for macro hygiene rather than as an application API. Their
1430/// signatures may evolve together with the macro crate between releases.
1431#[doc(hidden)]
1432pub mod __private {
1433    pub use crate::input_runtime::InputRuntime;
1434    pub use crate::runtime::Runtime;
1435    pub use scs_sdk_sys::{ScsInputInitParams, ScsResult, ScsTelemetryInitParams, ScsU32};
1436}
1437
1438#[cfg(test)]
1439mod tests {
1440    use super::*;
1441    use scs_sdk::channels;
1442
1443    #[test]
1444    fn optional_subscriptions_tolerate_only_capability_absence() {
1445        assert!(
1446            SubscriptionRequirement::Optional
1447                .tolerates_channel_registration_error(SdkError::NotFound)
1448        );
1449        assert!(
1450            SubscriptionRequirement::Optional
1451                .tolerates_channel_registration_error(SdkError::UnsupportedType)
1452        );
1453        for error in [
1454            SdkError::Unsupported,
1455            SdkError::InvalidParameter,
1456            SdkError::AlreadyRegistered,
1457            SdkError::NotNow,
1458            SdkError::Generic,
1459        ] {
1460            assert!(
1461                !SubscriptionRequirement::Optional.tolerates_channel_registration_error(error),
1462                "optional registration unexpectedly tolerated {error}"
1463            );
1464        }
1465        for error in [
1466            SdkError::NotFound,
1467            SdkError::UnsupportedType,
1468            SdkError::Generic,
1469        ] {
1470            assert!(
1471                !SubscriptionRequirement::Required.tolerates_channel_registration_error(error),
1472                "required registration unexpectedly tolerated {error}"
1473            );
1474        }
1475
1476        for error in [SdkError::Unsupported, SdkError::NotFound] {
1477            assert!(
1478                SubscriptionRequirement::Optional.tolerates_event_registration_error(error),
1479                "optional event registration did not tolerate {error}"
1480            );
1481        }
1482        for error in [
1483            SdkError::InvalidParameter,
1484            SdkError::AlreadyRegistered,
1485            SdkError::UnsupportedType,
1486            SdkError::NotNow,
1487            SdkError::Generic,
1488        ] {
1489            assert!(
1490                !SubscriptionRequirement::Optional.tolerates_event_registration_error(error),
1491                "optional event registration unexpectedly tolerated {error}"
1492            );
1493        }
1494    }
1495
1496    #[test]
1497    fn game_info_owns_rust_text_and_classifies_known_ids() {
1498        let ets2 = GameInfo::new(
1499            c"Euro Truck Simulator 2",
1500            c"eut2",
1501            GameSchemaVersion::new(1, 56),
1502        );
1503        assert_eq!(ets2.kind(), Game::EuroTruckSimulator2);
1504        assert_eq!(ets2.name(), "Euro Truck Simulator 2");
1505        assert_eq!(ets2.id(), "eut2");
1506        assert_eq!(ets2.schema_version(), GameSchemaVersion::new(1, 56));
1507        assert!(ets2.supports(sdk::game::capabilities::MULTI_TRAILER));
1508        assert_eq!(
1509            ets2.minimum_schema_for(sdk::game::capabilities::MULTI_TRAILER),
1510            Some(sdk::game::ets2::V1_14)
1511        );
1512
1513        let ats = GameInfo::new(
1514            c"American Truck Simulator",
1515            c"ats",
1516            GameSchemaVersion::new(0, 0),
1517        );
1518        assert_eq!(ats.kind(), Game::AmericanTruckSimulator);
1519        assert!(!ats.supports(channels::truck::ADBLUE.availability()));
1520
1521        let future = GameInfo::new(c"Future Truck", c"future", GameSchemaVersion::new(0, 0));
1522        assert_eq!(future.kind(), Game::Other);
1523        assert_eq!(future.id(), "future");
1524        assert_eq!(
1525            future.minimum_schema_for(sdk::game::capabilities::MULTI_TRAILER),
1526            None
1527        );
1528        assert!(!future.supports(sdk::game::capabilities::MULTI_TRAILER));
1529    }
1530
1531    #[test]
1532    fn trailer_names_follow_the_official_zero_based_scheme() {
1533        let first = trailer_channel_name(channels::trailer::CONNECTED, TrailerIndex::ZERO)
1534            .expect("first trailer name should be valid");
1535        assert_eq!(first.as_c_str(), c"trailer.0.connected");
1536        let last_index = TrailerIndex::new(9).expect("index nine is in the SDK range");
1537        let last = trailer_channel_name(channels::trailer::WHEEL_ROTATION, last_index)
1538            .expect("last trailer name should be valid");
1539        assert_eq!(last.as_c_str(), c"trailer.9.wheel.rotation");
1540        assert_eq!(TrailerIndex::new(10), None);
1541        assert_eq!(
1542            trailer_channel_name(channels::truck::SPEED, TrailerIndex::ZERO)
1543                .expect_err("truck channels must not enter trailer naming")
1544                .result(),
1545            SdkError::InvalidParameter
1546        );
1547    }
1548
1549    #[test]
1550    fn channel_update_requires_the_original_typed_descriptor() {
1551        let raw = scs_sdk_sys::ScsValue {
1552            type_: scs_sdk_sys::SCS_VALUE_TYPE_FLOAT,
1553            padding: scs_sdk_sys::ScsPadding::uninit(),
1554            value: scs_sdk_sys::ScsValueData {
1555                value_float: scs_sdk_sys::ScsValueFloat { value: 42.5 },
1556            },
1557        };
1558        let raw_value = &raw const raw;
1559        // SAFETY: `raw` is aligned and initialized, its float tag selects the
1560        // initialized float member, and it outlives the borrowed update.
1561        let value = unsafe { ValueRef::from_ptr(raw_value) }.expect("test value should be present");
1562        let update = ChannelUpdate::new(
1563            channels::truck::SPEED.erase(),
1564            c"truck.speed",
1565            None,
1566            None,
1567            ChannelFlags::NONE,
1568            Some(value),
1569        );
1570
1571        assert_eq!(update.value(channels::truck::SPEED), Some(42.5));
1572        assert_eq!(update.value(channels::truck::ENGINE_RPM), None);
1573        assert_eq!(update.registered_name(), "truck.speed");
1574    }
1575
1576    #[test]
1577    fn channel_update_preserves_both_strong_index_domains() {
1578        let raw = scs_sdk_sys::ScsValue {
1579            type_: scs_sdk_sys::SCS_VALUE_TYPE_FLOAT,
1580            padding: scs_sdk_sys::ScsPadding::uninit(),
1581            value: scs_sdk_sys::ScsValueData {
1582                value_float: scs_sdk_sys::ScsValueFloat { value: 1.25 },
1583            },
1584        };
1585        let raw_value = &raw const raw;
1586        // SAFETY: `raw` is aligned and initialized, its float tag selects the
1587        // initialized float member, and it outlives the borrowed update.
1588        let value = unsafe { ValueRef::from_ptr(raw_value) }.expect("test value should be present");
1589        let sdk_index = SdkIndex::new(2).expect("ordinary SDK index");
1590        let trailer_index = TrailerIndex::new(1).expect("second trailer");
1591        let update = ChannelUpdate::new(
1592            channels::trailer::WHEEL_ROTATION.erase(),
1593            c"trailer.1.wheel.rotation",
1594            Some(sdk_index),
1595            Some(trailer_index),
1596            ChannelFlags::EACH_FRAME,
1597            Some(value),
1598        );
1599
1600        assert_eq!(update.index(), Some(sdk_index));
1601        assert_eq!(update.trailer_index(), Some(trailer_index));
1602        assert_eq!(update.registered_name(), "trailer.1.wheel.rotation");
1603        assert_eq!(update.value(channels::trailer::WHEEL_ROTATION), Some(1.25));
1604    }
1605
1606    #[test]
1607    fn high_level_configuration_values_parse_known_and_preserve_unknown_text() {
1608        for (raw_market, expected) in [
1609            (
1610                c"external_contracts",
1611                Some(sdk::configuration::JobMarket::ExternalContracts),
1612            ),
1613            (c"future_market", None),
1614        ] {
1615            let attributes = [
1616                scs_sdk_sys::ScsNamedValue {
1617                    name: c"job.market".as_ptr(),
1618                    index: scs_sdk_sys::SCS_U32_NIL,
1619                    padding: scs_sdk_sys::ScsPadding::uninit(),
1620                    value: scs_sdk_sys::ScsValue {
1621                        type_: scs_sdk_sys::SCS_VALUE_TYPE_STRING,
1622                        padding: scs_sdk_sys::ScsPadding::uninit(),
1623                        value: scs_sdk_sys::ScsValueData {
1624                            value_string: scs_sdk_sys::ScsValueString {
1625                                value: raw_market.as_ptr(),
1626                            },
1627                        },
1628                    },
1629                },
1630                scs_sdk_sys::ScsNamedValue {
1631                    name: std::ptr::null(),
1632                    index: 0,
1633                    padding: scs_sdk_sys::ScsPadding::uninit(),
1634                    value: scs_sdk_sys::ScsValue {
1635                        type_: scs_sdk_sys::SCS_VALUE_TYPE_INVALID,
1636                        padding: scs_sdk_sys::ScsPadding::uninit(),
1637                        value: scs_sdk_sys::ScsValueData {
1638                            value_u64: scs_sdk_sys::ScsValueU64 { value: 0 },
1639                        },
1640                    },
1641                },
1642            ];
1643            let raw = scs_sdk_sys::ScsTelemetryConfiguration {
1644                id: c"job".as_ptr(),
1645                attributes: attributes.as_ptr(),
1646            };
1647            // SAFETY: The configuration, ID, contiguous terminated attribute
1648            // array, and selected string member all remain live for `event`.
1649            let inner = unsafe {
1650                ConfigurationRef::from_event_info((&raw const raw).cast::<std::ffi::c_void>())
1651            }
1652            .expect("configuration fixture");
1653            let event = ConfigurationEvent::new(inner);
1654
1655            assert_eq!(event.job_market(), expected);
1656            assert_eq!(
1657                event.string(sdk::configuration::attributes::JOB_MARKET),
1658                Some(Cow::Borrowed(raw_market.to_str().expect("ASCII fixture")))
1659            );
1660        }
1661    }
1662
1663    #[test]
1664    fn high_level_shifter_values_parse_known_and_preserve_unknown_text() {
1665        for (raw_shifter, expected) in [
1666            (c"hshifter", Some(sdk::configuration::ShifterType::HShifter)),
1667            (c"future_shifter", None),
1668        ] {
1669            let attributes = [
1670                scs_sdk_sys::ScsNamedValue {
1671                    name: c"shifter.type".as_ptr(),
1672                    index: scs_sdk_sys::SCS_U32_NIL,
1673                    padding: scs_sdk_sys::ScsPadding::uninit(),
1674                    value: scs_sdk_sys::ScsValue {
1675                        type_: scs_sdk_sys::SCS_VALUE_TYPE_STRING,
1676                        padding: scs_sdk_sys::ScsPadding::uninit(),
1677                        value: scs_sdk_sys::ScsValueData {
1678                            value_string: scs_sdk_sys::ScsValueString {
1679                                value: raw_shifter.as_ptr(),
1680                            },
1681                        },
1682                    },
1683                },
1684                scs_sdk_sys::ScsNamedValue {
1685                    name: std::ptr::null(),
1686                    index: 0,
1687                    padding: scs_sdk_sys::ScsPadding::uninit(),
1688                    value: scs_sdk_sys::ScsValue {
1689                        type_: scs_sdk_sys::SCS_VALUE_TYPE_INVALID,
1690                        padding: scs_sdk_sys::ScsPadding::uninit(),
1691                        value: scs_sdk_sys::ScsValueData {
1692                            value_u64: scs_sdk_sys::ScsValueU64 { value: 0 },
1693                        },
1694                    },
1695                },
1696            ];
1697            let raw = scs_sdk_sys::ScsTelemetryConfiguration {
1698                id: c"controls".as_ptr(),
1699                attributes: attributes.as_ptr(),
1700            };
1701            // SAFETY: The configuration, ID, contiguous terminated attribute
1702            // array, and selected string member all remain live for `event`.
1703            let inner = unsafe {
1704                ConfigurationRef::from_event_info((&raw const raw).cast::<std::ffi::c_void>())
1705            }
1706            .expect("configuration fixture");
1707            let event = ConfigurationEvent::new(inner);
1708
1709            assert_eq!(event.shifter_type(), expected);
1710            assert_eq!(
1711                event.string(sdk::configuration::attributes::SHIFTER_TYPE),
1712                Some(Cow::Borrowed(raw_shifter.to_str().expect("ASCII fixture")))
1713            );
1714        }
1715    }
1716
1717    #[test]
1718    fn high_level_gameplay_values_parse_known_and_preserve_unknown_text() {
1719        for (raw_offence, expected) in [
1720            (
1721                c"damaged_vehicle_usage",
1722                Some(sdk::gameplay::FineOffence::DamagedVehicleUsage),
1723            ),
1724            (c"future_offence", None),
1725        ] {
1726            let attributes = [
1727                scs_sdk_sys::ScsNamedValue {
1728                    name: c"fine.offence".as_ptr(),
1729                    index: scs_sdk_sys::SCS_U32_NIL,
1730                    padding: scs_sdk_sys::ScsPadding::uninit(),
1731                    value: scs_sdk_sys::ScsValue {
1732                        type_: scs_sdk_sys::SCS_VALUE_TYPE_STRING,
1733                        padding: scs_sdk_sys::ScsPadding::uninit(),
1734                        value: scs_sdk_sys::ScsValueData {
1735                            value_string: scs_sdk_sys::ScsValueString {
1736                                value: raw_offence.as_ptr(),
1737                            },
1738                        },
1739                    },
1740                },
1741                scs_sdk_sys::ScsNamedValue {
1742                    name: std::ptr::null(),
1743                    index: 0,
1744                    padding: scs_sdk_sys::ScsPadding::uninit(),
1745                    value: scs_sdk_sys::ScsValue {
1746                        type_: scs_sdk_sys::SCS_VALUE_TYPE_INVALID,
1747                        padding: scs_sdk_sys::ScsPadding::uninit(),
1748                        value: scs_sdk_sys::ScsValueData {
1749                            value_u64: scs_sdk_sys::ScsValueU64 { value: 0 },
1750                        },
1751                    },
1752                },
1753            ];
1754            let raw = scs_sdk_sys::ScsTelemetryGameplayEvent {
1755                id: c"player.fined".as_ptr(),
1756                attributes: attributes.as_ptr(),
1757            };
1758            // SAFETY: The gameplay event, ID, contiguous terminated attribute
1759            // array, and selected string member all remain live for `event`.
1760            let inner = unsafe {
1761                GameplayEventRef::from_event_info((&raw const raw).cast::<std::ffi::c_void>())
1762            }
1763            .expect("gameplay fixture");
1764            let event = GameplayEvent::new(inner);
1765
1766            assert_eq!(event.fine_offence(), expected);
1767            assert_eq!(
1768                event.string(sdk::gameplay::attributes::FINE_OFFENCE),
1769                Some(Cow::Borrowed(raw_offence.to_str().expect("ASCII fixture")))
1770            );
1771        }
1772    }
1773}