Skip to main content

scs_sdk/
event.rs

1use core::ffi::{CStr, c_void};
2use core::marker::PhantomData;
3
4use crate::{
5    Attribute, ConfigurationId, GameSchemaAvailability, GameplayEventId, SdkIndex, SdkValue,
6    TelemetryApiVersion, TrailerConfigurationId, TrailerIndex, ValueRef, game, sys,
7};
8
9/// Telemetry event which may be registered with the SCS SDK.
10///
11/// This type is the single typed representation of an SDK event identifier.
12/// Higher framework layers may re-export it under an application-facing name,
13/// but must not mirror its variants in a second enum: doing so would create a
14/// second event catalog which could drift when SCS adds another event.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16#[repr(u32)]
17pub enum Event {
18    FrameStart = sys::SCS_TELEMETRY_EVENT_FRAME_START,
19    FrameEnd = sys::SCS_TELEMETRY_EVENT_FRAME_END,
20    Paused = sys::SCS_TELEMETRY_EVENT_PAUSED,
21    Started = sys::SCS_TELEMETRY_EVENT_STARTED,
22    Configuration = sys::SCS_TELEMETRY_EVENT_CONFIGURATION,
23    Gameplay = sys::SCS_TELEMETRY_EVENT_GAMEPLAY,
24}
25
26impl Event {
27    /// Raw event discriminator passed to SCS registration functions and back
28    /// to the registered callback.
29    #[must_use]
30    pub const fn raw(self) -> sys::ScsEvent {
31        self as sys::ScsEvent
32    }
33
34    /// Oldest Telemetry API which defines this event identifier.
35    ///
36    /// SCS SDK 1.14 documents gameplay events as an API 1.01 addition. The
37    /// other identifiers belong to the original API 1.00 event set. Keeping
38    /// this capability metadata beside the canonical event enum ensures that
39    /// registration policy and the raw numeric identifier cannot acquire
40    /// separate, independently maintained event inventories.
41    #[must_use]
42    pub const fn minimum_api_version(self) -> TelemetryApiVersion {
43        match self {
44            Self::Gameplay => TelemetryApiVersion::V1_01,
45            Self::FrameStart
46            | Self::FrameEnd
47            | Self::Paused
48            | Self::Started
49            | Self::Configuration => TelemetryApiVersion::V1_00,
50        }
51    }
52
53    /// Oldest per-game schema which can emit this SDK event kind.
54    ///
55    /// Gameplay callbacks require both Telemetry API 1.01 and the game schema
56    /// which introduced gameplay events. Keeping those checks separate avoids
57    /// comparing unrelated version domains. All original lifecycle and
58    /// configuration event kinds exist from the first published game schemas.
59    #[must_use]
60    pub const fn availability(self) -> GameSchemaAvailability {
61        match self {
62            Self::Gameplay => game::capabilities::GAMEPLAY_EVENTS,
63            Self::FrameStart
64            | Self::FrameEnd
65            | Self::Paused
66            | Self::Started
67            | Self::Configuration => {
68                GameSchemaAvailability::new(Some(game::ets2::V1_00), Some(game::ats::V1_00))
69            }
70        }
71    }
72}
73
74#[derive(Clone, Copy)]
75pub struct FrameStartRef<'a> {
76    raw: &'a sys::ScsTelemetryFrameStart,
77}
78
79impl FrameStartRef<'_> {
80    /// Borrows frame-start data supplied by SCS.
81    ///
82    /// # Safety
83    ///
84    /// `event_info` must be the non-null pointer supplied for a frame-start
85    /// event. It must be correctly aligned, point to an initialized
86    /// [`sys::ScsTelemetryFrameStart`], and remain valid for the returned view.
87    #[must_use]
88    pub unsafe fn from_event_info(event_info: *const c_void) -> Option<Self> {
89        // SAFETY: The caller guarantees that a non-null pointer is aligned,
90        // initialized as `ScsTelemetryFrameStart`, and valid for the view's
91        // lifetime. `as_ref` additionally maps a null pointer to `None`.
92        unsafe { event_info.cast::<sys::ScsTelemetryFrameStart>().as_ref() }.map(|raw| Self { raw })
93    }
94
95    #[must_use]
96    pub const fn flags(self) -> sys::ScsU32 {
97        self.raw.flags
98    }
99
100    #[must_use]
101    pub const fn render_time(self) -> sys::ScsTimestamp {
102        self.raw.render_time
103    }
104
105    #[must_use]
106    pub const fn simulation_time(self) -> sys::ScsTimestamp {
107        self.raw.simulation_time
108    }
109
110    #[must_use]
111    pub const fn paused_simulation_time(self) -> sys::ScsTimestamp {
112        self.raw.paused_simulation_time
113    }
114
115    #[must_use]
116    pub const fn timer_restarted(self) -> bool {
117        self.raw.flags & sys::SCS_TELEMETRY_FRAME_START_FLAG_TIMER_RESTART != 0
118    }
119}
120
121#[derive(Clone, Copy)]
122pub struct NamedValueRef<'a> {
123    raw: &'a sys::ScsNamedValue,
124}
125
126impl<'a> NamedValueRef<'a> {
127    #[must_use]
128    pub fn name(self) -> &'a CStr {
129        // SAFETY: A non-terminal named value always has a valid name pointer.
130        unsafe { CStr::from_ptr(self.raw.name) }
131    }
132
133    #[must_use]
134    pub fn index(self) -> Option<SdkIndex> {
135        if self.raw.index == sys::SCS_U32_NIL {
136            None
137        } else {
138            SdkIndex::new(self.raw.index)
139        }
140    }
141
142    #[must_use]
143    pub fn value(self) -> ValueRef<'a> {
144        ValueRef::from_ref(&self.raw.value)
145    }
146}
147
148#[derive(Clone)]
149pub struct NamedValues<'a> {
150    current: *const sys::ScsNamedValue,
151    marker: PhantomData<&'a sys::ScsNamedValue>,
152}
153
154impl<'a> NamedValues<'a> {
155    /// Creates an iterator over a null-name-terminated SCS attribute array.
156    ///
157    /// # Safety
158    ///
159    /// `attributes` must point to an initialized, contiguous SDK array terminated
160    /// by an entry with a null `name`. Every preceding name must point to a valid
161    /// NUL-terminated string, every value tag must match its initialized union
162    /// member, and the entire array and its referenced data must remain alive for
163    /// `'a`.
164    #[must_use]
165    pub unsafe fn from_ptr(attributes: *const sys::ScsNamedValue) -> Self {
166        Self {
167            current: attributes,
168            marker: PhantomData,
169        }
170    }
171
172    #[must_use]
173    pub fn find(self, name: &CStr) -> Option<NamedValueRef<'a>> {
174        self.filter(|attribute| attribute.index().is_none())
175            .find(|attribute| attribute.name() == name)
176    }
177
178    /// Finds one indexed attribute by its name and zero-based SDK index.
179    #[must_use]
180    pub fn find_at(self, name: &CStr, index: SdkIndex) -> Option<NamedValueRef<'a>> {
181        self.filter(|attribute| attribute.index() == Some(index))
182            .find(|attribute| attribute.name() == name)
183    }
184
185    /// Decodes a scalar attribute using its typed descriptor.
186    #[must_use]
187    pub fn get<T: SdkValue>(self, attribute: Attribute<T>) -> Option<T::Decoded<'a>> {
188        if attribute.is_indexed() {
189            return None;
190        }
191        attribute.decode(self.find(attribute.name())?.value())
192    }
193
194    /// Decodes one member of an indexed attribute using its typed descriptor.
195    #[must_use]
196    pub fn get_at<T: SdkValue>(
197        self,
198        attribute: Attribute<T>,
199        index: SdkIndex,
200    ) -> Option<T::Decoded<'a>> {
201        if !attribute.is_indexed() {
202            return None;
203        }
204        attribute.decode(self.find_at(attribute.name(), index)?.value())
205    }
206}
207
208impl<'a> Iterator for NamedValues<'a> {
209    type Item = NamedValueRef<'a>;
210
211    fn next(&mut self) -> Option<Self::Item> {
212        // SAFETY: `from_ptr` requires a contiguous array that remains valid
213        // through its null-name terminator. `current` starts at its first entry
214        // and advances only after observing a non-terminal entry.
215        let raw = unsafe { self.current.as_ref() }?;
216        if raw.name.is_null() {
217            return None;
218        }
219
220        // SAFETY: A non-null name identifies a non-terminal entry, so the
221        // constructor contract guarantees that the next contiguous entry
222        // exists, possibly as the required terminator.
223        self.current = unsafe { self.current.add(1) };
224        Some(NamedValueRef { raw })
225    }
226}
227
228#[derive(Clone, Copy)]
229pub struct ConfigurationRef<'a> {
230    raw: &'a sys::ScsTelemetryConfiguration,
231}
232
233impl<'a> ConfigurationRef<'a> {
234    /// Borrows configuration event data supplied by SCS.
235    ///
236    /// # Safety
237    ///
238    /// `event_info` must be the non-null pointer supplied for a configuration
239    /// event. The structure must be correctly aligned and initialized; its ID
240    /// must be a valid NUL-terminated string, and its attributes must satisfy
241    /// [`NamedValues::from_ptr`]. All referenced data must remain alive for `'a`.
242    #[must_use]
243    pub unsafe fn from_event_info(event_info: *const c_void) -> Option<Self> {
244        // SAFETY: The caller guarantees that a non-null pointer is aligned,
245        // initialized as `ScsTelemetryConfiguration`, and valid together with
246        // all referenced strings and attributes for the returned lifetime.
247        unsafe { event_info.cast::<sys::ScsTelemetryConfiguration>().as_ref() }
248            .map(|raw| Self { raw })
249    }
250
251    #[must_use]
252    pub fn id(self) -> &'a CStr {
253        // SAFETY: The SDK guarantees a valid ID string for configuration events.
254        unsafe { CStr::from_ptr(self.raw.id) }
255    }
256
257    /// Tests this event against a typed configuration identifier.
258    #[must_use]
259    pub fn is(self, id: ConfigurationId) -> bool {
260        self.id() == id.name()
261    }
262
263    /// Classifies an unnumbered or numbered trailer configuration ID.
264    ///
265    /// Canonical numbered IDs use an unsigned decimal index without leading
266    /// zeroes. Malformed names, custom configuration IDs, and values outside
267    /// the SDK's `0..10` trailer range return `None` rather than being confused
268    /// with the legacy compatibility alias.
269    #[must_use]
270    pub fn trailer(self) -> Option<TrailerConfigurationId> {
271        let id = self.id();
272        if id == crate::configuration::ids::TRAILER.name() {
273            return Some(TrailerConfigurationId::Legacy);
274        }
275        let digits = id.to_bytes().strip_prefix(b"trailer.")?;
276        if digits.is_empty() || (digits.len() > 1 && digits[0] == b'0') {
277            return None;
278        }
279
280        let mut raw = 0_u32;
281        for digit in digits {
282            if !digit.is_ascii_digit() {
283                return None;
284            }
285            raw = raw.checked_mul(10)?.checked_add(u32::from(*digit - b'0'))?;
286        }
287        TrailerIndex::new(raw).map(TrailerConfigurationId::Numbered)
288    }
289
290    /// Returns the numbered trailer index, excluding the legacy `trailer` ID.
291    #[must_use]
292    pub fn trailer_index(self) -> Option<TrailerIndex> {
293        self.trailer()?.index()
294    }
295
296    /// Whether this event uses the legacy unnumbered `trailer` identity.
297    #[must_use]
298    pub fn is_legacy_trailer(self) -> bool {
299        self.trailer()
300            .is_some_and(TrailerConfigurationId::is_legacy)
301    }
302
303    #[must_use]
304    pub fn attributes(self) -> NamedValues<'a> {
305        // SAFETY: The SDK guarantees a terminated attribute array.
306        unsafe { NamedValues::from_ptr(self.raw.attributes) }
307    }
308}
309
310#[derive(Clone, Copy)]
311pub struct GameplayEventRef<'a> {
312    raw: &'a sys::ScsTelemetryGameplayEvent,
313}
314
315impl<'a> GameplayEventRef<'a> {
316    /// Borrows gameplay event data supplied by SCS.
317    ///
318    /// # Safety
319    ///
320    /// `event_info` must be the non-null pointer supplied for a gameplay event.
321    /// The structure must be correctly aligned and initialized; its ID must be a
322    /// valid NUL-terminated string, and its attributes must satisfy
323    /// [`NamedValues::from_ptr`]. All referenced data must remain alive for `'a`.
324    #[must_use]
325    pub unsafe fn from_event_info(event_info: *const c_void) -> Option<Self> {
326        // SAFETY: The caller guarantees that a non-null pointer is aligned,
327        // initialized as `ScsTelemetryGameplayEvent`, and valid together with
328        // all referenced strings and attributes for the returned lifetime.
329        unsafe { event_info.cast::<sys::ScsTelemetryGameplayEvent>().as_ref() }
330            .map(|raw| Self { raw })
331    }
332
333    #[must_use]
334    pub fn id(self) -> &'a CStr {
335        // SAFETY: The SDK guarantees a valid ID string for gameplay events.
336        unsafe { CStr::from_ptr(self.raw.id) }
337    }
338
339    /// Tests this event against a typed gameplay event identifier.
340    #[must_use]
341    pub fn is(self, id: GameplayEventId) -> bool {
342        self.id() == id.name()
343    }
344
345    #[must_use]
346    pub fn attributes(self) -> NamedValues<'a> {
347        // SAFETY: The SDK guarantees a terminated attribute array.
348        unsafe { NamedValues::from_ptr(self.raw.attributes) }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn event_capabilities_follow_the_official_api_history() {
358        assert_eq!(
359            Event::FrameStart.raw(),
360            sys::SCS_TELEMETRY_EVENT_FRAME_START
361        );
362        assert_eq!(Event::FrameEnd.raw(), sys::SCS_TELEMETRY_EVENT_FRAME_END);
363        assert_eq!(Event::Paused.raw(), sys::SCS_TELEMETRY_EVENT_PAUSED);
364        assert_eq!(Event::Started.raw(), sys::SCS_TELEMETRY_EVENT_STARTED);
365        assert_eq!(
366            Event::Configuration.raw(),
367            sys::SCS_TELEMETRY_EVENT_CONFIGURATION
368        );
369        assert_eq!(Event::Gameplay.raw(), sys::SCS_TELEMETRY_EVENT_GAMEPLAY);
370
371        assert_eq!(
372            Event::FrameStart.minimum_api_version(),
373            TelemetryApiVersion::V1_00
374        );
375        assert_eq!(
376            Event::FrameEnd.minimum_api_version(),
377            TelemetryApiVersion::V1_00
378        );
379        assert_eq!(
380            Event::Paused.minimum_api_version(),
381            TelemetryApiVersion::V1_00
382        );
383        assert_eq!(
384            Event::Started.minimum_api_version(),
385            TelemetryApiVersion::V1_00
386        );
387        assert_eq!(
388            Event::Configuration.minimum_api_version(),
389            TelemetryApiVersion::V1_00
390        );
391        assert_eq!(
392            Event::Gameplay.minimum_api_version(),
393            TelemetryApiVersion::V1_01
394        );
395
396        for event in [
397            Event::FrameStart,
398            Event::FrameEnd,
399            Event::Paused,
400            Event::Started,
401            Event::Configuration,
402        ] {
403            assert_eq!(
404                event.availability().available_since_ets2(),
405                Some(game::ets2::V1_00)
406            );
407            assert_eq!(
408                event.availability().available_since_ats(),
409                Some(game::ats::V1_00)
410            );
411        }
412        assert_eq!(
413            Event::Gameplay.availability().available_since_ets2(),
414            Some(game::ets2::V1_14)
415        );
416        assert_eq!(
417            Event::Gameplay.availability().available_since_ats(),
418            Some(game::ats::V1_01)
419        );
420    }
421
422    #[test]
423    fn iterates_and_finds_unindexed_attributes() {
424        let cargo_name = c"cargo";
425        let cargo_value = c"盆栽花朵";
426        let attributes = [
427            sys::ScsNamedValue {
428                name: cargo_name.as_ptr(),
429                index: sys::SCS_U32_NIL,
430                padding: sys::ScsPadding::uninit(),
431                value: sys::ScsValue {
432                    type_: sys::SCS_VALUE_TYPE_STRING,
433                    padding: sys::ScsPadding::uninit(),
434                    value: sys::ScsValueData {
435                        value_string: sys::ScsValueString {
436                            value: cargo_value.as_ptr(),
437                        },
438                    },
439                },
440            },
441            sys::ScsNamedValue {
442                name: core::ptr::null(),
443                index: 0,
444                padding: sys::ScsPadding::uninit(),
445                value: sys::ScsValue {
446                    type_: sys::SCS_VALUE_TYPE_INVALID,
447                    padding: sys::ScsPadding::uninit(),
448                    value: sys::ScsValueData {
449                        value_u64: sys::ScsValueU64 { value: 0 },
450                    },
451                },
452            },
453        ];
454
455        // SAFETY: `attributes` is contiguous, ends with a null-name entry, and
456        // its name, string value, and storage outlive the iterator.
457        let values = unsafe { NamedValues::from_ptr(attributes.as_ptr()) };
458        let cargo = values
459            .find(cargo_name)
460            .expect("cargo attribute should exist");
461
462        assert_eq!(cargo.value().as_c_str(), Some(cargo_value));
463        assert_eq!(cargo.index(), None);
464    }
465
466    #[test]
467    fn indexed_attribute_lookup_uses_the_strong_sdk_index_domain() {
468        let slot = c"slot.gear";
469        let attributes = [
470            sys::ScsNamedValue {
471                name: slot.as_ptr(),
472                index: 2,
473                padding: sys::ScsPadding::uninit(),
474                value: sys::ScsValue {
475                    type_: sys::SCS_VALUE_TYPE_S32,
476                    padding: sys::ScsPadding::uninit(),
477                    value: sys::ScsValueData {
478                        value_s32: sys::ScsValueS32 { value: 7 },
479                    },
480                },
481            },
482            sys::ScsNamedValue {
483                name: core::ptr::null(),
484                index: 0,
485                padding: sys::ScsPadding::uninit(),
486                value: sys::ScsValue {
487                    type_: sys::SCS_VALUE_TYPE_INVALID,
488                    padding: sys::ScsPadding::uninit(),
489                    value: sys::ScsValueData {
490                        value_u64: sys::ScsValueU64 { value: 0 },
491                    },
492                },
493            },
494        ];
495
496        // SAFETY: `attributes` is contiguous, ends with a null-name entry, and
497        // its initialized signed-32 value remains live during iteration.
498        let values = unsafe { NamedValues::from_ptr(attributes.as_ptr()) };
499        let index = SdkIndex::new(2).expect("ordinary array index");
500        assert_eq!(
501            values
502                .clone()
503                .get_at(crate::configuration::attributes::SLOT_GEAR, index),
504            Some(7)
505        );
506        assert!(values.find(slot).is_none());
507        assert_eq!(SdkIndex::new(sys::SCS_U32_NIL), None);
508    }
509
510    #[test]
511    fn trailer_configuration_ids_require_the_canonical_numbered_form() {
512        let terminator = [sys::ScsNamedValue {
513            name: core::ptr::null(),
514            index: 0,
515            padding: sys::ScsPadding::uninit(),
516            value: sys::ScsValue {
517                type_: sys::SCS_VALUE_TYPE_INVALID,
518                padding: sys::ScsPadding::uninit(),
519                value: sys::ScsValueData {
520                    value_u64: sys::ScsValueU64 { value: 0 },
521                },
522            },
523        }];
524
525        for (id, expected) in [
526            (c"trailer", Some(TrailerConfigurationId::Legacy)),
527            (
528                c"trailer.0",
529                Some(TrailerConfigurationId::Numbered(TrailerIndex::ZERO)),
530            ),
531            (
532                c"trailer.9",
533                Some(TrailerConfigurationId::Numbered(
534                    TrailerIndex::new(9).expect("last trailer index"),
535                )),
536            ),
537            (c"trailer.00", None),
538            (c"trailer.01", None),
539            (c"trailer.10", None),
540            (c"trailer.-1", None),
541            (c"trailer.foo", None),
542            (c"trailer.", None),
543            (c"truck", None),
544        ] {
545            let raw = sys::ScsTelemetryConfiguration {
546                id: id.as_ptr(),
547                attributes: terminator.as_ptr(),
548            };
549            let event_info = (&raw const raw).cast::<c_void>();
550            // SAFETY: `raw` is aligned and initialized for this iteration; its
551            // ID and terminated attribute array outlive the borrowed event.
552            let event = unsafe { ConfigurationRef::from_event_info(event_info) }
553                .expect("configuration fixture");
554            assert_eq!(event.trailer(), expected, "configuration id {id:?}");
555            assert_eq!(
556                event.trailer_index(),
557                expected.and_then(TrailerConfigurationId::index)
558            );
559            assert_eq!(
560                event.is_legacy_trailer(),
561                expected.is_some_and(TrailerConfigurationId::is_legacy)
562            );
563        }
564    }
565
566    #[test]
567    fn frame_start_ignores_uninitialized_alignment_storage() {
568        let frame = sys::ScsTelemetryFrameStart {
569            flags: sys::SCS_TELEMETRY_FRAME_START_FLAG_TIMER_RESTART,
570            padding: sys::ScsPadding::uninit(),
571            render_time: 11,
572            simulation_time: 12,
573            paused_simulation_time: 13,
574        };
575        let pointer = (&raw const frame).cast::<c_void>();
576        // SAFETY: `pointer` addresses the live, aligned frame fixture. The
577        // accessors read only initialized fields and deliberately skip padding.
578        let frame = unsafe { FrameStartRef::from_event_info(pointer) }.expect("frame start");
579
580        assert!(frame.timer_restarted());
581        assert_eq!(frame.render_time(), 11);
582        assert_eq!(frame.simulation_time(), 12);
583        assert_eq!(frame.paused_simulation_time(), 13);
584    }
585}