Skip to main content

scs_sdk/
api.rs

1use core::ffi::{CStr, c_void};
2use core::marker::PhantomData;
3
4use crate::{Event, GameSchemaVersion, SdkError, SdkResult, TelemetryApiVersion, sys};
5
6/// An inert handle to the game's logger.
7///
8/// The handle can be stored as part of a plugin session, but it deliberately
9/// has no logging methods. Logging is only available through [`SdkCall`], which
10/// is scoped to one direct call from the game.
11#[derive(Clone, Copy)]
12pub(crate) struct LoggerHandle(sys::ScsLog);
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15#[repr(i32)]
16pub enum LogLevel {
17    Message = sys::SCS_LOG_TYPE_MESSAGE,
18    Warning = sys::SCS_LOG_TYPE_WARNING,
19    Error = sys::SCS_LOG_TYPE_ERROR,
20}
21
22#[derive(Clone, Copy)]
23pub(crate) struct ApiTable {
24    pub(crate) version: TelemetryApiVersion,
25    pub(crate) logger: LoggerHandle,
26    pub(crate) register_for_event: sys::ScsTelemetryRegisterForEvent,
27    pub(crate) unregister_from_event: sys::ScsTelemetryUnregisterFromEvent,
28    pub(crate) register_for_channel: sys::ScsTelemetryRegisterForChannel,
29    pub(crate) unregister_from_channel: sys::ScsTelemetryUnregisterFromChannel,
30}
31
32impl ApiTable {
33    const fn from_raw(version: TelemetryApiVersion, raw: &sys::ScsTelemetryInitParamsV101) -> Self {
34        Self {
35            version,
36            logger: LoggerHandle(raw.common.log),
37            register_for_event: raw.register_for_event,
38            unregister_from_event: raw.unregister_from_event,
39            register_for_channel: raw.register_for_channel,
40            unregister_from_channel: raw.unregister_from_channel,
41        }
42    }
43}
44
45/// A typed view over the initialization parameters supplied by SCS.
46pub struct TelemetryApi<'a> {
47    raw: &'a sys::ScsTelemetryInitParamsV101,
48    table: ApiTable,
49    // The SDK API is main-thread-only. Do not allow this view to cross threads.
50    not_send_sync: PhantomData<*mut ()>,
51}
52
53/// A copyable, inert session handle used by callbacks after initialization.
54///
55/// Calling [`TelemetrySession::with_call`] is unsafe because the caller must
56/// prove that the current stack is executing as a direct call from SCS on the
57/// game's main thread.
58///
59/// The scoped capability cannot be returned from the higher-ranked closure:
60///
61/// ```compile_fail
62/// use scs_sdk::{SdkCall, TelemetrySession};
63///
64/// fn leak(session: TelemetrySession) -> &'static SdkCall<'static> {
65///     unsafe { session.with_call(|call| call) }
66/// }
67/// ```
68#[derive(Clone, Copy)]
69pub struct TelemetrySession {
70    pub(crate) table: ApiTable,
71}
72
73/// Capabilities available during one direct call from the game.
74///
75/// The lifetime is higher-ranked by the constructors in this module, so a
76/// scope cannot be returned, stored, or moved into another thread by safe code.
77///
78/// `SdkCall` is deliberately neither `Send` nor `Sync`:
79///
80/// ```compile_fail
81/// use scs_sdk::SdkCall;
82///
83/// fn assert_send<T: Send>() {}
84/// fn assert_sync<T: Sync>() {}
85///
86/// assert_send::<SdkCall<'static>>();
87/// assert_sync::<SdkCall<'static>>();
88/// ```
89pub struct SdkCall<'scope> {
90    pub(crate) table: ApiTable,
91    scope: PhantomData<&'scope mut ()>,
92    not_send_sync: PhantomData<*mut ()>,
93}
94
95/// Logger access tied to the current [`SdkCall`] scope.
96#[derive(Clone, Copy)]
97pub struct ScopedLogger<'scope> {
98    raw: sys::ScsLog,
99    scope: PhantomData<&'scope SdkCall<'scope>>,
100}
101
102impl ScopedLogger<'_> {
103    pub(crate) const fn from_raw(raw: sys::ScsLog) -> Self {
104        Self {
105            raw,
106            scope: PhantomData,
107        }
108    }
109}
110
111/// Initialization-parameter layout selected for one audited API version.
112///
113/// SCS SDK 1.14 defines separate names for the 1.00 and 1.01 parameter types,
114/// but the latter is a typedef of the former. Keeping the layout selection as
115/// an explicit internal enum gives a future API version a mandatory review
116/// point: it must either reuse an audited layout deliberately or add a new
117/// match arm with its own structure before any foreign pointer is read.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119enum TelemetryInitLayout {
120    V100,
121}
122
123/// One exact mapping from an accepted API version to its audited foreign
124/// initialization layout.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126struct TelemetryApiAdapter {
127    version: TelemetryApiVersion,
128    layout: TelemetryInitLayout,
129}
130
131/// Sole version-support table for the typed wrapper.
132///
133/// Version discovery and layout selection both derive from these entries. A
134/// future API therefore cannot be advertised without simultaneously choosing
135/// an audited adapter, and runtime code never carries a parallel whitelist.
136const TELEMETRY_API_ADAPTERS: [TelemetryApiAdapter; 2] = [
137    TelemetryApiAdapter {
138        version: TelemetryApiVersion::V1_00,
139        layout: TelemetryInitLayout::V100,
140    },
141    TelemetryApiAdapter {
142        version: TelemetryApiVersion::V1_01,
143        layout: TelemetryInitLayout::V100,
144    },
145];
146
147const fn adapter_versions() -> [TelemetryApiVersion; TELEMETRY_API_ADAPTERS.len()] {
148    let mut versions = [TelemetryApiVersion::from_raw(0); TELEMETRY_API_ADAPTERS.len()];
149    let mut index = 0;
150    while index < TELEMETRY_API_ADAPTERS.len() {
151        versions[index] = TELEMETRY_API_ADAPTERS[index].version;
152        index += 1;
153    }
154    versions
155}
156
157const SUPPORTED_TELEMETRY_API_VERSIONS: [TelemetryApiVersion; TELEMETRY_API_ADAPTERS.len()] =
158    adapter_versions();
159
160impl<'a> TelemetryApi<'a> {
161    /// Telemetry API versions with initialization layouts audited by this crate.
162    ///
163    /// This list describes wrapper capability, not a product plugin's minimum
164    /// requirements. A framework may understand API 1.00 while a particular
165    /// plugin still requires API 1.01 for gameplay events or signed 64-bit
166    /// values.
167    pub const SUPPORTED_VERSIONS: &'static [TelemetryApiVersion] =
168        &SUPPORTED_TELEMETRY_API_VERSIONS;
169
170    /// Returns whether this wrapper has an audited initialization adapter for
171    /// the supplied version.
172    ///
173    /// The check is an exact whitelist. It deliberately does not accept an
174    /// unknown minor version merely because SCS normally uses minor increments
175    /// for additive changes: the initialization structure still needs to be
176    /// compared with the new official header first.
177    #[must_use]
178    pub const fn supports_version(version: TelemetryApiVersion) -> bool {
179        Self::init_layout(version).is_some()
180    }
181
182    const fn init_layout(version: TelemetryApiVersion) -> Option<TelemetryInitLayout> {
183        let mut index = 0;
184        while index < TELEMETRY_API_ADAPTERS.len() {
185            let adapter = TELEMETRY_API_ADAPTERS[index];
186            if adapter.version.raw() == version.raw() {
187                return Some(adapter.layout);
188            }
189            index += 1;
190        }
191        None
192    }
193
194    /// Creates a typed view over the initialization parameters supplied by SCS.
195    ///
196    /// # Safety
197    ///
198    /// For a supported `version`, `params` must point to the live matching
199    /// telemetry initialization structure supplied by the game. An unsupported
200    /// version is rejected before `params` is inspected. The returned view may
201    /// only be used synchronously during the direct initialization call from
202    /// SCS, on the game's main thread. The pointed-to function table and strings
203    /// must remain valid for that use.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`SdkError::Unsupported`] before reading `params` when `version`
208    /// has no audited adapter. Returns [`SdkError::InvalidParameter`] when the
209    /// version is supported but `params` is null.
210    pub unsafe fn from_raw(
211        version: TelemetryApiVersion,
212        params: *const sys::ScsTelemetryInitParams,
213    ) -> SdkResult<Self> {
214        // Select an exact audited layout before inspecting params. Both
215        // versions currently select V100 because the SDK declares V101 as its
216        // typedef. A future layout gets a separate arm rather than falling
217        // through a numeric-range compatibility guess.
218        let Some(layout) = Self::init_layout(version) else {
219            return Err(SdkError::Unsupported);
220        };
221        let raw = match layout {
222            TelemetryInitLayout::V100 => {
223                // SAFETY: The caller guarantees that params matches the
224                // supported version; init_layout establishes that V100 is the
225                // audited concrete structure for that version.
226                unsafe { params.cast::<sys::ScsTelemetryInitParamsV100>().as_ref() }
227                    .ok_or(SdkError::InvalidParameter)?
228            }
229        };
230        Ok(Self {
231            raw,
232            table: ApiTable::from_raw(version, raw),
233            not_send_sync: PhantomData,
234        })
235    }
236
237    /// Telemetry API version represented by this initialized view.
238    #[must_use]
239    pub const fn version(&self) -> TelemetryApiVersion {
240        self.table.version
241    }
242
243    #[must_use]
244    pub const fn raw(&self) -> &sys::ScsTelemetryInitParamsV101 {
245        self.raw
246    }
247
248    #[must_use]
249    pub fn game_name(&self) -> &'a CStr {
250        // SAFETY: SCS guarantees a non-null, NUL-terminated string for the
251        // duration of the initialization call.
252        unsafe { CStr::from_ptr(self.raw.common.game_name) }
253    }
254
255    #[must_use]
256    pub fn game_id(&self) -> &'a CStr {
257        // SAFETY: SCS guarantees a non-null, NUL-terminated string for the
258        // duration of the initialization call.
259        unsafe { CStr::from_ptr(self.raw.common.game_id) }
260    }
261
262    #[must_use]
263    pub const fn game_schema_version(&self) -> GameSchemaVersion {
264        GameSchemaVersion::from_raw(self.raw.common.game_version)
265    }
266
267    /// Returns an inert session handle for later callback entry points.
268    #[must_use]
269    pub const fn session(&self) -> TelemetrySession {
270        TelemetrySession { table: self.table }
271    }
272
273    /// Executes an operation with the capabilities valid during initialization.
274    ///
275    /// The higher-ranked lifetime prevents the operation from returning or
276    /// storing the scoped capability.
277    pub fn with_call<R>(&self, operation: impl for<'scope> FnOnce(&SdkCall<'scope>) -> R) -> R {
278        let call = SdkCall {
279            table: self.table,
280            scope: PhantomData,
281            not_send_sync: PhantomData,
282        };
283        operation(&call)
284    }
285}
286
287impl TelemetrySession {
288    /// Executes an operation with the capabilities valid during a direct SCS
289    /// callback, initialization call, or shutdown call.
290    ///
291    /// # Safety
292    ///
293    /// The caller must be executing synchronously on the game's main thread as
294    /// a direct call from SCS, while the telemetry session is active. The
295    /// operation must not let the scoped capability escape its closure and must
296    /// not unwind across the SDK ABI boundary.
297    pub unsafe fn with_call<R>(
298        self,
299        operation: impl for<'scope> FnOnce(&SdkCall<'scope>) -> R,
300    ) -> R {
301        let call = SdkCall {
302            table: self.table,
303            scope: PhantomData,
304            not_send_sync: PhantomData,
305        };
306        operation(&call)
307    }
308}
309
310impl SdkCall<'_> {
311    /// Telemetry API version negotiated for the current plugin session.
312    ///
313    /// The value travels with the copied function table, so upper layers do
314    /// not need a second runtime field which could drift from the adapter that
315    /// produced this call capability.
316    #[must_use]
317    pub const fn telemetry_api_version(&self) -> TelemetryApiVersion {
318        self.table.version
319    }
320
321    #[must_use]
322    pub const fn logger(&self) -> ScopedLogger<'_> {
323        ScopedLogger::from_raw(self.table.logger.0)
324    }
325
326    /// Registers an SDK event callback.
327    ///
328    /// # Safety
329    ///
330    /// `callback` must implement the exact SDK ABI, must uphold the validity
331    /// requirements for the event-specific `event_info` pointer, and must not
332    /// unwind across the ABI boundary. `context` must remain valid for every
333    /// callback invocation. This method must be called from initialization or
334    /// an SCS event callback other than the callback for `event`, never from a
335    /// worker thread or a channel callback.
336    ///
337    /// # Errors
338    ///
339    /// Returns the result reported by the game's registration function.
340    pub unsafe fn register_event(
341        &self,
342        event: Event,
343        callback: sys::ScsTelemetryEventCallback,
344        context: *mut c_void,
345    ) -> SdkResult {
346        // SAFETY: The caller upholds the SDK callback and context contract.
347        let result = unsafe { (self.table.register_for_event)(event.raw(), callback, context) };
348        SdkError::from_code(result)
349    }
350
351    /// Unregisters an SDK event callback.
352    ///
353    /// # Safety
354    ///
355    /// Must be called from initialization, shutdown, or an SCS event callback.
356    /// Context storage associated with the old registration must remain alive
357    /// until this function succeeds and no invocation of that callback is active.
358    ///
359    /// # Errors
360    ///
361    /// Returns the result reported by the game's unregistration function.
362    pub unsafe fn unregister_event(&self, event: Event) -> SdkResult {
363        // SAFETY: The caller upholds the SDK call-site restriction.
364        let result = unsafe { (self.table.unregister_from_event)(event.raw()) };
365        SdkError::from_code(result)
366    }
367}
368
369impl ScopedLogger<'_> {
370    pub fn log(self, level: LogLevel, message: &CStr) {
371        // SAFETY: The scope proves that this call is made during a direct SDK
372        // invocation, and the C string remains alive for the duration of it.
373        unsafe { (self.raw)(level as sys::ScsLogType, message.as_ptr()) };
374    }
375
376    pub fn message(self, message: &CStr) {
377        self.log(LogLevel::Message, message);
378    }
379
380    pub fn warning(self, message: &CStr) {
381        self.log(LogLevel::Warning, message);
382    }
383
384    pub fn error(self, message: &CStr) {
385        self.log(LogLevel::Error, message);
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use core::ffi::c_void;
392    use core::sync::atomic::{AtomicUsize, Ordering};
393
394    use super::*;
395
396    static LOG_CALLS: AtomicUsize = AtomicUsize::new(0);
397    static EVENT_REGISTRATIONS: AtomicUsize = AtomicUsize::new(0);
398
399    unsafe extern "system" fn fake_log(_level: sys::ScsLogType, _message: sys::ScsString) {
400        LOG_CALLS.fetch_add(1, Ordering::Relaxed);
401    }
402
403    unsafe extern "system" fn fake_event_callback(
404        _event: sys::ScsEvent,
405        _event_info: *const c_void,
406        _context: sys::ScsContext,
407    ) {
408    }
409
410    unsafe extern "system" fn fake_register_event(
411        _event: sys::ScsEvent,
412        _callback: sys::ScsTelemetryEventCallback,
413        _context: sys::ScsContext,
414    ) -> sys::ScsResult {
415        EVENT_REGISTRATIONS.fetch_add(1, Ordering::Relaxed);
416        sys::SCS_RESULT_OK
417    }
418
419    unsafe extern "system" fn fake_unregister_event(_event: sys::ScsEvent) -> sys::ScsResult {
420        sys::SCS_RESULT_OK
421    }
422
423    unsafe extern "system" fn fake_register_channel(
424        _name: sys::ScsString,
425        _index: sys::ScsU32,
426        _type: sys::ScsValueType,
427        _flags: sys::ScsU32,
428        _callback: sys::ScsTelemetryChannelCallback,
429        _context: sys::ScsContext,
430    ) -> sys::ScsResult {
431        sys::SCS_RESULT_OK
432    }
433
434    unsafe extern "system" fn fake_unregister_channel(
435        _name: sys::ScsString,
436        _index: sys::ScsU32,
437        _type: sys::ScsValueType,
438    ) -> sys::ScsResult {
439        sys::SCS_RESULT_OK
440    }
441
442    fn parameters() -> sys::ScsTelemetryInitParamsV101 {
443        sys::ScsTelemetryInitParamsV101 {
444            common: sys::ScsSdkInitParamsV100 {
445                game_name: c"Euro Truck Simulator 2".as_ptr(),
446                game_id: c"eut2".as_ptr(),
447                game_version: 0x0001_003c,
448                padding: sys::ScsPadding::uninit(),
449                log: fake_log,
450            },
451            register_for_event: fake_register_event,
452            unregister_from_event: fake_unregister_event,
453            register_for_channel: fake_register_channel,
454            unregister_from_channel: fake_unregister_channel,
455        }
456    }
457
458    #[test]
459    fn logging_and_registration_require_a_scoped_call() {
460        LOG_CALLS.store(0, Ordering::Relaxed);
461        EVENT_REGISTRATIONS.store(0, Ordering::Relaxed);
462        let parameters = parameters();
463        let pointer = (&raw const parameters).cast::<sys::ScsTelemetryInitParams>();
464        let api = unsafe { TelemetryApi::from_raw(TelemetryApiVersion::V1_01, pointer) }
465            .expect("valid function table");
466
467        assert_eq!(api.game_id(), c"eut2");
468        assert_eq!(api.game_schema_version(), GameSchemaVersion::new(1, 60));
469        api.with_call(|call| {
470            call.logger().message(c"initializing");
471            unsafe {
472                call.register_event(Event::Started, fake_event_callback, core::ptr::null_mut())
473            }
474            .expect("event registration");
475        });
476
477        let session = api.session();
478        unsafe {
479            session.with_call(|call| call.logger().message(c"callback"));
480        }
481
482        assert_eq!(LOG_CALLS.load(Ordering::Relaxed), 2);
483        assert_eq!(EVENT_REGISTRATIONS.load(Ordering::Relaxed), 1);
484    }
485
486    #[test]
487    fn unaudited_api_version_is_rejected_before_reading_parameters() {
488        assert_eq!(
489            TelemetryApi::SUPPORTED_VERSIONS,
490            &[TelemetryApiVersion::V1_00, TelemetryApiVersion::V1_01],
491        );
492        assert!(TelemetryApi::supports_version(TelemetryApiVersion::V1_00));
493        assert!(TelemetryApi::supports_version(TelemetryApiVersion::V1_01));
494        assert!(!TelemetryApi::supports_version(TelemetryApiVersion::new(
495            1, 2
496        )));
497
498        let result = unsafe {
499            TelemetryApi::from_raw(
500                TelemetryApiVersion::new(1, 2),
501                core::ptr::null::<sys::ScsTelemetryInitParams>(),
502            )
503        };
504
505        assert!(matches!(result, Err(SdkError::Unsupported)));
506    }
507}