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        unsafe { event_info.cast::<sys::ScsTelemetryFrameStart>().as_ref() }.map(|raw| Self { raw })
90    }
91
92    #[must_use]
93    pub const fn flags(self) -> sys::ScsU32 {
94        self.raw.flags
95    }
96
97    #[must_use]
98    pub const fn render_time(self) -> sys::ScsTimestamp {
99        self.raw.render_time
100    }
101
102    #[must_use]
103    pub const fn simulation_time(self) -> sys::ScsTimestamp {
104        self.raw.simulation_time
105    }
106
107    #[must_use]
108    pub const fn paused_simulation_time(self) -> sys::ScsTimestamp {
109        self.raw.paused_simulation_time
110    }
111
112    #[must_use]
113    pub const fn timer_restarted(self) -> bool {
114        self.raw.flags & sys::SCS_TELEMETRY_FRAME_START_FLAG_TIMER_RESTART != 0
115    }
116}
117
118#[derive(Clone, Copy)]
119pub struct NamedValueRef<'a> {
120    raw: &'a sys::ScsNamedValue,
121}
122
123impl<'a> NamedValueRef<'a> {
124    #[must_use]
125    pub fn name(self) -> &'a CStr {
126        // SAFETY: A non-terminal named value always has a valid name pointer.
127        unsafe { CStr::from_ptr(self.raw.name) }
128    }
129
130    #[must_use]
131    pub fn index(self) -> Option<SdkIndex> {
132        if self.raw.index == sys::SCS_U32_NIL {
133            None
134        } else {
135            SdkIndex::new(self.raw.index)
136        }
137    }
138
139    #[must_use]
140    pub fn value(self) -> ValueRef<'a> {
141        ValueRef::from_ref(&self.raw.value)
142    }
143}
144
145#[derive(Clone)]
146pub struct NamedValues<'a> {
147    current: *const sys::ScsNamedValue,
148    marker: PhantomData<&'a sys::ScsNamedValue>,
149}
150
151impl<'a> NamedValues<'a> {
152    /// Creates an iterator over a null-name-terminated SCS attribute array.
153    ///
154    /// # Safety
155    ///
156    /// `attributes` must point to an initialized, contiguous SDK array terminated
157    /// by an entry with a null `name`. Every preceding name must point to a valid
158    /// NUL-terminated string, every value tag must match its initialized union
159    /// member, and the entire array and its referenced data must remain alive for
160    /// `'a`.
161    #[must_use]
162    pub unsafe fn from_ptr(attributes: *const sys::ScsNamedValue) -> Self {
163        Self {
164            current: attributes,
165            marker: PhantomData,
166        }
167    }
168
169    #[must_use]
170    pub fn find(self, name: &CStr) -> Option<NamedValueRef<'a>> {
171        self.filter(|attribute| attribute.index().is_none())
172            .find(|attribute| attribute.name() == name)
173    }
174
175    /// Finds one indexed attribute by its name and zero-based SDK index.
176    #[must_use]
177    pub fn find_at(self, name: &CStr, index: SdkIndex) -> Option<NamedValueRef<'a>> {
178        self.filter(|attribute| attribute.index() == Some(index))
179            .find(|attribute| attribute.name() == name)
180    }
181
182    /// Decodes a scalar attribute using its typed descriptor.
183    #[must_use]
184    pub fn get<T: SdkValue>(self, attribute: Attribute<T>) -> Option<T::Decoded<'a>> {
185        if attribute.is_indexed() {
186            return None;
187        }
188        attribute.decode(self.find(attribute.name())?.value())
189    }
190
191    /// Decodes one member of an indexed attribute using its typed descriptor.
192    #[must_use]
193    pub fn get_at<T: SdkValue>(
194        self,
195        attribute: Attribute<T>,
196        index: SdkIndex,
197    ) -> Option<T::Decoded<'a>> {
198        if !attribute.is_indexed() {
199            return None;
200        }
201        attribute.decode(self.find_at(attribute.name(), index)?.value())
202    }
203}
204
205impl<'a> Iterator for NamedValues<'a> {
206    type Item = NamedValueRef<'a>;
207
208    fn next(&mut self) -> Option<Self::Item> {
209        let raw = unsafe { self.current.as_ref() }?;
210        if raw.name.is_null() {
211            return None;
212        }
213
214        self.current = unsafe { self.current.add(1) };
215        Some(NamedValueRef { raw })
216    }
217}
218
219#[derive(Clone, Copy)]
220pub struct ConfigurationRef<'a> {
221    raw: &'a sys::ScsTelemetryConfiguration,
222}
223
224impl<'a> ConfigurationRef<'a> {
225    /// Borrows configuration event data supplied by SCS.
226    ///
227    /// # Safety
228    ///
229    /// `event_info` must be the non-null pointer supplied for a configuration
230    /// event. The structure must be correctly aligned and initialized; its ID
231    /// must be a valid NUL-terminated string, and its attributes must satisfy
232    /// [`NamedValues::from_ptr`]. All referenced data must remain alive for `'a`.
233    #[must_use]
234    pub unsafe fn from_event_info(event_info: *const c_void) -> Option<Self> {
235        unsafe { event_info.cast::<sys::ScsTelemetryConfiguration>().as_ref() }
236            .map(|raw| Self { raw })
237    }
238
239    #[must_use]
240    pub fn id(self) -> &'a CStr {
241        // SAFETY: The SDK guarantees a valid ID string for configuration events.
242        unsafe { CStr::from_ptr(self.raw.id) }
243    }
244
245    /// Tests this event against a typed configuration identifier.
246    #[must_use]
247    pub fn is(self, id: ConfigurationId) -> bool {
248        self.id() == id.name()
249    }
250
251    /// Classifies an unnumbered or numbered trailer configuration ID.
252    ///
253    /// Canonical numbered IDs use an unsigned decimal index without leading
254    /// zeroes. Malformed names, custom configuration IDs, and values outside
255    /// the SDK's `0..10` trailer range return `None` rather than being confused
256    /// with the legacy compatibility alias.
257    #[must_use]
258    pub fn trailer(self) -> Option<TrailerConfigurationId> {
259        let id = self.id();
260        if id == crate::configuration::ids::TRAILER.name() {
261            return Some(TrailerConfigurationId::Legacy);
262        }
263        let digits = id.to_bytes().strip_prefix(b"trailer.")?;
264        if digits.is_empty() || (digits.len() > 1 && digits[0] == b'0') {
265            return None;
266        }
267
268        let mut raw = 0_u32;
269        for digit in digits {
270            if !digit.is_ascii_digit() {
271                return None;
272            }
273            raw = raw.checked_mul(10)?.checked_add(u32::from(*digit - b'0'))?;
274        }
275        TrailerIndex::new(raw).map(TrailerConfigurationId::Numbered)
276    }
277
278    /// Returns the numbered trailer index, excluding the legacy `trailer` ID.
279    #[must_use]
280    pub fn trailer_index(self) -> Option<TrailerIndex> {
281        self.trailer()?.index()
282    }
283
284    /// Whether this event uses the legacy unnumbered `trailer` identity.
285    #[must_use]
286    pub fn is_legacy_trailer(self) -> bool {
287        self.trailer()
288            .is_some_and(TrailerConfigurationId::is_legacy)
289    }
290
291    #[must_use]
292    pub fn attributes(self) -> NamedValues<'a> {
293        // SAFETY: The SDK guarantees a terminated attribute array.
294        unsafe { NamedValues::from_ptr(self.raw.attributes) }
295    }
296}
297
298#[derive(Clone, Copy)]
299pub struct GameplayEventRef<'a> {
300    raw: &'a sys::ScsTelemetryGameplayEvent,
301}
302
303impl<'a> GameplayEventRef<'a> {
304    /// Borrows gameplay event data supplied by SCS.
305    ///
306    /// # Safety
307    ///
308    /// `event_info` must be the non-null pointer supplied for a gameplay event.
309    /// The structure must be correctly aligned and initialized; its ID must be a
310    /// valid NUL-terminated string, and its attributes must satisfy
311    /// [`NamedValues::from_ptr`]. All referenced data must remain alive for `'a`.
312    #[must_use]
313    pub unsafe fn from_event_info(event_info: *const c_void) -> Option<Self> {
314        unsafe { event_info.cast::<sys::ScsTelemetryGameplayEvent>().as_ref() }
315            .map(|raw| Self { raw })
316    }
317
318    #[must_use]
319    pub fn id(self) -> &'a CStr {
320        // SAFETY: The SDK guarantees a valid ID string for gameplay events.
321        unsafe { CStr::from_ptr(self.raw.id) }
322    }
323
324    /// Tests this event against a typed gameplay event identifier.
325    #[must_use]
326    pub fn is(self, id: GameplayEventId) -> bool {
327        self.id() == id.name()
328    }
329
330    #[must_use]
331    pub fn attributes(self) -> NamedValues<'a> {
332        // SAFETY: The SDK guarantees a terminated attribute array.
333        unsafe { NamedValues::from_ptr(self.raw.attributes) }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn event_capabilities_follow_the_official_api_history() {
343        assert_eq!(
344            Event::FrameStart.raw(),
345            sys::SCS_TELEMETRY_EVENT_FRAME_START
346        );
347        assert_eq!(Event::FrameEnd.raw(), sys::SCS_TELEMETRY_EVENT_FRAME_END);
348        assert_eq!(Event::Paused.raw(), sys::SCS_TELEMETRY_EVENT_PAUSED);
349        assert_eq!(Event::Started.raw(), sys::SCS_TELEMETRY_EVENT_STARTED);
350        assert_eq!(
351            Event::Configuration.raw(),
352            sys::SCS_TELEMETRY_EVENT_CONFIGURATION
353        );
354        assert_eq!(Event::Gameplay.raw(), sys::SCS_TELEMETRY_EVENT_GAMEPLAY);
355
356        assert_eq!(
357            Event::FrameStart.minimum_api_version(),
358            TelemetryApiVersion::V1_00
359        );
360        assert_eq!(
361            Event::FrameEnd.minimum_api_version(),
362            TelemetryApiVersion::V1_00
363        );
364        assert_eq!(
365            Event::Paused.minimum_api_version(),
366            TelemetryApiVersion::V1_00
367        );
368        assert_eq!(
369            Event::Started.minimum_api_version(),
370            TelemetryApiVersion::V1_00
371        );
372        assert_eq!(
373            Event::Configuration.minimum_api_version(),
374            TelemetryApiVersion::V1_00
375        );
376        assert_eq!(
377            Event::Gameplay.minimum_api_version(),
378            TelemetryApiVersion::V1_01
379        );
380
381        for event in [
382            Event::FrameStart,
383            Event::FrameEnd,
384            Event::Paused,
385            Event::Started,
386            Event::Configuration,
387        ] {
388            assert_eq!(
389                event.availability().available_since_ets2(),
390                Some(game::ets2::V1_00)
391            );
392            assert_eq!(
393                event.availability().available_since_ats(),
394                Some(game::ats::V1_00)
395            );
396        }
397        assert_eq!(
398            Event::Gameplay.availability().available_since_ets2(),
399            Some(game::ets2::V1_14)
400        );
401        assert_eq!(
402            Event::Gameplay.availability().available_since_ats(),
403            Some(game::ats::V1_01)
404        );
405    }
406
407    #[test]
408    fn iterates_and_finds_unindexed_attributes() {
409        let cargo_name = c"cargo";
410        let cargo_value = c"盆栽花朵";
411        let attributes = [
412            sys::ScsNamedValue {
413                name: cargo_name.as_ptr(),
414                index: sys::SCS_U32_NIL,
415                padding: sys::ScsPadding::uninit(),
416                value: sys::ScsValue {
417                    type_: sys::SCS_VALUE_TYPE_STRING,
418                    padding: sys::ScsPadding::uninit(),
419                    value: sys::ScsValueData {
420                        value_string: sys::ScsValueString {
421                            value: cargo_value.as_ptr(),
422                        },
423                    },
424                },
425            },
426            sys::ScsNamedValue {
427                name: core::ptr::null(),
428                index: 0,
429                padding: sys::ScsPadding::uninit(),
430                value: sys::ScsValue {
431                    type_: sys::SCS_VALUE_TYPE_INVALID,
432                    padding: sys::ScsPadding::uninit(),
433                    value: sys::ScsValueData {
434                        value_u64: sys::ScsValueU64 { value: 0 },
435                    },
436                },
437            },
438        ];
439
440        let values = unsafe { NamedValues::from_ptr(attributes.as_ptr()) };
441        let cargo = values
442            .find(cargo_name)
443            .expect("cargo attribute should exist");
444
445        assert_eq!(cargo.value().as_c_str(), Some(cargo_value));
446        assert_eq!(cargo.index(), None);
447    }
448
449    #[test]
450    fn indexed_attribute_lookup_uses_the_strong_sdk_index_domain() {
451        let slot = c"slot.gear";
452        let attributes = [
453            sys::ScsNamedValue {
454                name: slot.as_ptr(),
455                index: 2,
456                padding: sys::ScsPadding::uninit(),
457                value: sys::ScsValue {
458                    type_: sys::SCS_VALUE_TYPE_S32,
459                    padding: sys::ScsPadding::uninit(),
460                    value: sys::ScsValueData {
461                        value_s32: sys::ScsValueS32 { value: 7 },
462                    },
463                },
464            },
465            sys::ScsNamedValue {
466                name: core::ptr::null(),
467                index: 0,
468                padding: sys::ScsPadding::uninit(),
469                value: sys::ScsValue {
470                    type_: sys::SCS_VALUE_TYPE_INVALID,
471                    padding: sys::ScsPadding::uninit(),
472                    value: sys::ScsValueData {
473                        value_u64: sys::ScsValueU64 { value: 0 },
474                    },
475                },
476            },
477        ];
478
479        let values = unsafe { NamedValues::from_ptr(attributes.as_ptr()) };
480        let index = SdkIndex::new(2).expect("ordinary array index");
481        assert_eq!(
482            values
483                .clone()
484                .get_at(crate::configuration::attributes::SLOT_GEAR, index),
485            Some(7)
486        );
487        assert!(values.find(slot).is_none());
488        assert_eq!(SdkIndex::new(sys::SCS_U32_NIL), None);
489    }
490
491    #[test]
492    fn trailer_configuration_ids_require_the_canonical_numbered_form() {
493        let terminator = [sys::ScsNamedValue {
494            name: core::ptr::null(),
495            index: 0,
496            padding: sys::ScsPadding::uninit(),
497            value: sys::ScsValue {
498                type_: sys::SCS_VALUE_TYPE_INVALID,
499                padding: sys::ScsPadding::uninit(),
500                value: sys::ScsValueData {
501                    value_u64: sys::ScsValueU64 { value: 0 },
502                },
503            },
504        }];
505
506        for (id, expected) in [
507            (c"trailer", Some(TrailerConfigurationId::Legacy)),
508            (
509                c"trailer.0",
510                Some(TrailerConfigurationId::Numbered(TrailerIndex::ZERO)),
511            ),
512            (
513                c"trailer.9",
514                Some(TrailerConfigurationId::Numbered(
515                    TrailerIndex::new(9).expect("last trailer index"),
516                )),
517            ),
518            (c"trailer.00", None),
519            (c"trailer.01", None),
520            (c"trailer.10", None),
521            (c"trailer.-1", None),
522            (c"trailer.foo", None),
523            (c"trailer.", None),
524            (c"truck", None),
525        ] {
526            let raw = sys::ScsTelemetryConfiguration {
527                id: id.as_ptr(),
528                attributes: terminator.as_ptr(),
529            };
530            let event =
531                unsafe { ConfigurationRef::from_event_info((&raw const raw).cast::<c_void>()) }
532                    .expect("configuration fixture");
533            assert_eq!(event.trailer(), expected, "configuration id {id:?}");
534            assert_eq!(
535                event.trailer_index(),
536                expected.and_then(TrailerConfigurationId::index)
537            );
538            assert_eq!(
539                event.is_legacy_trailer(),
540                expected.is_some_and(TrailerConfigurationId::is_legacy)
541            );
542        }
543    }
544
545    #[test]
546    fn frame_start_ignores_uninitialized_alignment_storage() {
547        let frame = sys::ScsTelemetryFrameStart {
548            flags: sys::SCS_TELEMETRY_FRAME_START_FLAG_TIMER_RESTART,
549            padding: sys::ScsPadding::uninit(),
550            render_time: 11,
551            simulation_time: 12,
552            paused_simulation_time: 13,
553        };
554        let pointer = (&raw const frame).cast::<c_void>();
555        let frame = unsafe { FrameStartRef::from_event_info(pointer) }.expect("frame start");
556
557        assert!(frame.timer_restarted());
558        assert_eq!(frame.render_time(), 11);
559        assert_eq!(frame.simulation_time(), 12);
560        assert_eq!(frame.paused_simulation_time(), 13);
561    }
562}