#![deny(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
#![cfg_attr(
not(test),
deny(
clippy::expect_used,
clippy::panic,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::unwrap_used
)
)]
mod input;
mod input_runtime;
mod runtime;
use std::borrow::Cow;
use std::ffi::{CStr, CString};
use std::fmt;
use scs_sdk::{
AnyChannel, Attribute, Channel, ChannelFlags, ChannelValue, ConfigurationId, ConfigurationRef,
FrameStartRef, GameSchemaAvailability, GameSchemaVersion, GameplayEventId, GameplayEventRef,
LogLevel, SdkCall, SdkError, SdkIndex, SdkValue, StringValue, TelemetryApiVersion,
TrailerConfigurationId, TrailerIndex, ValueRef,
};
pub use input::{
InputAxisValue, InputAxisValueError, InputDeviceId, InputDeviceSpec, InputDeviceType,
InputEvent, InputEventFlags, InputEventRequest, InputGameCompatibility, InputGameInfo,
InputIndex, InputPlugin, InputPluginCompatibility, InputPluginContext, InputSpec, InputValue,
InputValueType,
};
pub use scs_sdk as sdk;
pub use scs_sdk_plugin_macros::{export_input_plugin, export_plugin};
pub use scs_sdk::Event as TelemetryEventKind;
pub type PluginResult<T = ()> = Result<T, PluginError>;
pub(crate) const fn telemetry_api_satisfies(
actual: TelemetryApiVersion,
minimum: TelemetryApiVersion,
) -> bool {
actual.major() == minimum.major() && actual.raw() >= minimum.raw()
}
pub(crate) const fn game_schema_satisfies(
actual: GameSchemaVersion,
minimum: GameSchemaVersion,
) -> bool {
actual.major() == minimum.major() && actual.raw() >= minimum.raw()
}
#[derive(Debug)]
pub struct PluginError {
result: SdkError,
message: String,
}
impl PluginError {
#[must_use]
pub fn new(result: SdkError, message: impl Into<String>) -> Self {
Self {
result,
message: message.into(),
}
}
#[must_use]
pub const fn result(&self) -> SdkError {
self.result
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl From<SdkError> for PluginError {
fn from(result: SdkError) -> Self {
Self::new(result, result.to_string())
}
}
impl fmt::Display for PluginError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for PluginError {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Game {
EuroTruckSimulator2,
AmericanTruckSimulator,
Other,
}
pub(crate) fn classify_game_id(id: &CStr) -> Game {
let id_bytes = id.to_bytes_with_nul();
if id_bytes == scs_sdk::sys::SCS_GAME_ID_EUT2 {
Game::EuroTruckSimulator2
} else if id_bytes == scs_sdk::sys::SCS_GAME_ID_ATS {
Game::AmericanTruckSimulator
} else {
Game::Other
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GameInfo {
name: String,
id: String,
kind: Game,
schema_version: GameSchemaVersion,
}
impl GameInfo {
pub(crate) fn new(name: &CStr, id: &CStr, schema_version: GameSchemaVersion) -> Self {
Self {
name: name.to_string_lossy().into_owned(),
id: id.to_string_lossy().into_owned(),
kind: classify_game_id(id),
schema_version,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub const fn kind(&self) -> Game {
self.kind
}
#[must_use]
pub const fn schema_version(&self) -> GameSchemaVersion {
self.schema_version
}
#[must_use]
pub const fn minimum_schema_for(
&self,
availability: GameSchemaAvailability,
) -> Option<GameSchemaVersion> {
match self.kind {
Game::EuroTruckSimulator2 => availability.available_since_ets2(),
Game::AmericanTruckSimulator => availability.available_since_ats(),
Game::Other => None,
}
}
#[must_use]
pub const fn supports(&self, availability: GameSchemaAvailability) -> bool {
match self.minimum_schema_for(availability) {
Some(minimum) => game_schema_satisfies(self.schema_version, minimum),
None => false,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GameCompatibility {
game: Game,
minimum_schema: GameSchemaVersion,
}
impl GameCompatibility {
#[must_use]
pub const fn new(game: Game, minimum_schema: GameSchemaVersion) -> Self {
Self {
game,
minimum_schema,
}
}
#[must_use]
pub const fn game(self) -> Game {
self.game
}
#[must_use]
pub const fn minimum_schema(self) -> GameSchemaVersion {
self.minimum_schema
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PluginCompatibility {
minimum_telemetry_api: TelemetryApiVersion,
games: &'static [GameCompatibility],
}
impl PluginCompatibility {
#[must_use]
pub const fn new(
minimum_telemetry_api: TelemetryApiVersion,
games: &'static [GameCompatibility],
) -> Self {
Self {
minimum_telemetry_api,
games,
}
}
#[must_use]
pub const fn minimum_telemetry_api(self) -> TelemetryApiVersion {
self.minimum_telemetry_api
}
#[must_use]
pub const fn games(self) -> &'static [GameCompatibility] {
self.games
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SubscriptionRequirement {
Required,
Optional,
}
impl SubscriptionRequirement {
pub(crate) const fn tolerates_channel_registration_error(self, error: SdkError) -> bool {
matches!(self, Self::Optional)
&& matches!(error, SdkError::NotFound | SdkError::UnsupportedType)
}
pub(crate) const fn tolerates_event_registration_error(self, error: SdkError) -> bool {
matches!(self, Self::Optional)
&& matches!(error, SdkError::Unsupported | SdkError::NotFound)
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct EventSubscriptionSpec {
pub(crate) event: TelemetryEventKind,
pub(crate) requirement: SubscriptionRequirement,
}
#[derive(Clone, Debug)]
pub(crate) struct SubscriptionSpec {
pub(crate) channel: AnyChannel,
pub(crate) registered_name: CString,
pub(crate) sdk_index: Option<SdkIndex>,
pub(crate) trailer_index: Option<TrailerIndex>,
pub(crate) flags: ChannelFlags,
pub(crate) requirement: SubscriptionRequirement,
}
pub struct PluginContext<'scope> {
call: &'scope SdkCall<'scope>,
game: GameInfo,
events: Option<&'scope mut Vec<EventSubscriptionSpec>>,
subscriptions: Option<&'scope mut Vec<SubscriptionSpec>>,
}
impl<'scope> PluginContext<'scope> {
pub(crate) fn initializing(
call: &'scope SdkCall<'scope>,
game: GameInfo,
events: &'scope mut Vec<EventSubscriptionSpec>,
subscriptions: &'scope mut Vec<SubscriptionSpec>,
) -> Self {
Self {
call,
game,
events: Some(events),
subscriptions: Some(subscriptions),
}
}
pub(crate) fn callback(call: &'scope SdkCall<'scope>, game: GameInfo) -> Self {
Self {
call,
game,
events: None,
subscriptions: None,
}
}
#[must_use]
pub const fn game(&self) -> &GameInfo {
&self.game
}
#[must_use]
pub const fn telemetry_api_version(&self) -> TelemetryApiVersion {
self.call.telemetry_api_version()
}
pub fn log(&self, level: LogLevel, arguments: fmt::Arguments<'_>) {
let rendered = format!("{arguments}").replace('\0', " ");
if let Ok(message) = CString::new(rendered) {
self.call.logger().log(level, &message);
}
}
pub fn message(&self, arguments: fmt::Arguments<'_>) {
self.log(LogLevel::Message, arguments);
}
pub fn warning(&self, arguments: fmt::Arguments<'_>) {
self.log(LogLevel::Warning, arguments);
}
pub fn error(&self, arguments: fmt::Arguments<'_>) {
self.log(LogLevel::Error, arguments);
}
pub fn subscribe_event(&mut self, event: TelemetryEventKind) -> PluginResult {
self.subscribe_event_with_requirement(event, SubscriptionRequirement::Required)
}
pub fn subscribe_event_optional(&mut self, event: TelemetryEventKind) -> PluginResult {
self.subscribe_event_with_requirement(event, SubscriptionRequirement::Optional)
}
fn subscribe_event_with_requirement(
&mut self,
event: TelemetryEventKind,
requirement: SubscriptionRequirement,
) -> PluginResult {
let api_version = self.telemetry_api_version();
let Some(events) = self.events.as_deref_mut() else {
return Err(PluginError::new(
SdkError::NotNow,
"events may only be subscribed during plugin initialization",
));
};
let minimum_api = event.minimum_api_version();
if !telemetry_api_satisfies(api_version, minimum_api)
&& requirement == SubscriptionRequirement::Required
{
return Err(PluginError::new(
SdkError::Unsupported,
format!(
"event {event:?} requires telemetry API {minimum_api}, negotiated {api_version}"
),
));
}
let minimum_schema = self.game.minimum_schema_for(event.availability());
let schema_supported = self.game.supports(event.availability());
if !schema_supported && requirement == SubscriptionRequirement::Required {
let detail = minimum_schema.map_or_else(
|| format!("is not available for {:?}", self.game.kind()),
|minimum| {
format!(
"requires {:?} telemetry schema {minimum}; detected {}",
self.game.kind(),
self.game.schema_version(),
)
},
);
return Err(PluginError::new(
SdkError::Unsupported,
format!("event {event:?} {detail}"),
));
}
if events.iter().any(|candidate| candidate.event == event) {
return Err(PluginError::new(
SdkError::AlreadyRegistered,
format!("duplicate event subscription for {event:?}"),
));
}
events.push(EventSubscriptionSpec { event, requirement });
Ok(())
}
pub fn subscribe<T: ChannelValue>(&mut self, channel: Channel<T>) -> PluginResult {
self.subscribe_with_flags(channel, ChannelFlags::NONE)
}
pub fn subscribe_with_flags<T: ChannelValue>(
&mut self,
channel: Channel<T>,
flags: ChannelFlags,
) -> PluginResult {
self.subscribe_scalar_with_flags(channel, flags, SubscriptionRequirement::Required)
}
pub fn subscribe_optional<T: ChannelValue>(&mut self, channel: Channel<T>) -> PluginResult {
self.subscribe_optional_with_flags(channel, ChannelFlags::NONE)
}
pub fn subscribe_optional_with_flags<T: ChannelValue>(
&mut self,
channel: Channel<T>,
flags: ChannelFlags,
) -> PluginResult {
self.subscribe_scalar_with_flags(channel, flags, SubscriptionRequirement::Optional)
}
fn subscribe_scalar_with_flags<T: ChannelValue>(
&mut self,
channel: Channel<T>,
flags: ChannelFlags,
requirement: SubscriptionRequirement,
) -> PluginResult {
if channel.is_indexed() {
return Err(PluginError::new(
SdkError::InvalidParameter,
format!("indexed channel {:?} requires subscribe_at", channel.name()),
));
}
self.push_subscription(
channel.erase(),
channel.name().to_owned(),
None,
None,
flags,
requirement,
)
}
pub fn subscribe_at<T: ChannelValue>(
&mut self,
channel: Channel<T>,
index: SdkIndex,
) -> PluginResult {
self.subscribe_at_with_flags(channel, index, ChannelFlags::NONE)
}
pub fn subscribe_at_with_flags<T: ChannelValue>(
&mut self,
channel: Channel<T>,
index: SdkIndex,
flags: ChannelFlags,
) -> PluginResult {
self.subscribe_at_with_flags_and_requirement(
channel,
index,
flags,
SubscriptionRequirement::Required,
)
}
pub fn subscribe_at_optional<T: ChannelValue>(
&mut self,
channel: Channel<T>,
index: SdkIndex,
) -> PluginResult {
self.subscribe_at_optional_with_flags(channel, index, ChannelFlags::NONE)
}
pub fn subscribe_at_optional_with_flags<T: ChannelValue>(
&mut self,
channel: Channel<T>,
index: SdkIndex,
flags: ChannelFlags,
) -> PluginResult {
self.subscribe_at_with_flags_and_requirement(
channel,
index,
flags,
SubscriptionRequirement::Optional,
)
}
fn subscribe_at_with_flags_and_requirement<T: ChannelValue>(
&mut self,
channel: Channel<T>,
index: SdkIndex,
flags: ChannelFlags,
requirement: SubscriptionRequirement,
) -> PluginResult {
if !channel.is_indexed() {
return Err(PluginError::new(
SdkError::InvalidParameter,
format!(
"scalar channel {:?} does not accept an SDK index",
channel.name()
),
));
}
self.push_subscription(
channel.erase(),
channel.name().to_owned(),
Some(index),
None,
flags,
requirement,
)
}
pub fn subscribe_trailer<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
) -> PluginResult {
self.subscribe_trailer_with_flags(channel, trailer_index, ChannelFlags::NONE)
}
pub fn subscribe_trailer_with_flags<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
flags: ChannelFlags,
) -> PluginResult {
self.subscribe_trailer_with_flags_and_requirement(
channel,
trailer_index,
flags,
SubscriptionRequirement::Required,
)
}
pub fn subscribe_trailer_optional<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
) -> PluginResult {
self.subscribe_trailer_optional_with_flags(channel, trailer_index, ChannelFlags::NONE)
}
pub fn subscribe_trailer_optional_with_flags<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
flags: ChannelFlags,
) -> PluginResult {
self.subscribe_trailer_with_flags_and_requirement(
channel,
trailer_index,
flags,
SubscriptionRequirement::Optional,
)
}
fn subscribe_trailer_with_flags_and_requirement<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
flags: ChannelFlags,
requirement: SubscriptionRequirement,
) -> PluginResult {
if channel.is_indexed() {
return Err(PluginError::new(
SdkError::InvalidParameter,
"indexed trailer channels require subscribe_trailer_at",
));
}
let name = trailer_channel_name(channel, trailer_index)?;
self.push_subscription(
channel.erase(),
name,
None,
Some(trailer_index),
flags,
requirement,
)
}
pub fn subscribe_trailer_at<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
index: SdkIndex,
) -> PluginResult {
self.subscribe_trailer_at_with_flags(channel, trailer_index, index, ChannelFlags::NONE)
}
pub fn subscribe_trailer_at_with_flags<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
index: SdkIndex,
flags: ChannelFlags,
) -> PluginResult {
self.subscribe_trailer_at_with_flags_and_requirement(
channel,
trailer_index,
index,
flags,
SubscriptionRequirement::Required,
)
}
pub fn subscribe_trailer_at_optional<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
index: SdkIndex,
) -> PluginResult {
self.subscribe_trailer_at_optional_with_flags(
channel,
trailer_index,
index,
ChannelFlags::NONE,
)
}
pub fn subscribe_trailer_at_optional_with_flags<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
index: SdkIndex,
flags: ChannelFlags,
) -> PluginResult {
self.subscribe_trailer_at_with_flags_and_requirement(
channel,
trailer_index,
index,
flags,
SubscriptionRequirement::Optional,
)
}
fn subscribe_trailer_at_with_flags_and_requirement<T: ChannelValue>(
&mut self,
channel: Channel<T>,
trailer_index: TrailerIndex,
index: SdkIndex,
flags: ChannelFlags,
requirement: SubscriptionRequirement,
) -> PluginResult {
if !channel.is_indexed() {
return Err(PluginError::new(
SdkError::InvalidParameter,
"scalar trailer channels do not accept an SDK index",
));
}
let name = trailer_channel_name(channel, trailer_index)?;
self.push_subscription(
channel.erase(),
name,
Some(index),
Some(trailer_index),
flags,
requirement,
)
}
fn push_subscription(
&mut self,
channel: AnyChannel,
registered_name: CString,
sdk_index: Option<SdkIndex>,
trailer_index: Option<TrailerIndex>,
flags: ChannelFlags,
requirement: SubscriptionRequirement,
) -> PluginResult {
let api_version = self.telemetry_api_version();
let descriptor_minimum = self.game.minimum_schema_for(channel.availability());
let descriptor_supported = self.game.supports(channel.availability());
let trailer_minimum = trailer_index.and_then(|_| {
self.game
.minimum_schema_for(scs_sdk::game::capabilities::MULTI_TRAILER)
});
let trailer_supported = trailer_index.is_none()
|| self
.game
.supports(scs_sdk::game::capabilities::MULTI_TRAILER);
let Some(subscriptions) = self.subscriptions.as_deref_mut() else {
return Err(PluginError::new(
SdkError::NotNow,
"channels may only be subscribed during plugin initialization",
));
};
let value_type = channel.value_type();
let minimum_api = value_type.minimum_api_version();
if !telemetry_api_satisfies(api_version, minimum_api)
&& requirement == SubscriptionRequirement::Required
{
return Err(PluginError::new(
SdkError::Unsupported,
format!(
"channel {registered_name:?} requests {value_type:?}, which requires telemetry API {minimum_api}; negotiated {api_version}",
),
));
}
if (!descriptor_supported || !trailer_supported)
&& requirement == SubscriptionRequirement::Required
{
let (capability, minimum) = if descriptor_supported {
("numbered multi-trailer namespace", trailer_minimum)
} else {
("channel descriptor", descriptor_minimum)
};
let detail = minimum.map_or_else(
|| format!("is not available for {:?}", self.game.kind()),
|minimum| {
format!(
"requires {:?} telemetry schema {minimum}; detected {}",
self.game.kind(),
self.game.schema_version(),
)
},
);
return Err(PluginError::new(
SdkError::Unsupported,
format!("{capability} {registered_name:?} {detail}"),
));
}
let duplicate = subscriptions.iter().any(|candidate| {
candidate.registered_name == registered_name
&& candidate.sdk_index == sdk_index
&& candidate.channel.value_type() == channel.value_type()
});
if duplicate {
return Err(PluginError::new(
SdkError::AlreadyRegistered,
format!("duplicate subscription for {registered_name:?}, index {sdk_index:?}"),
));
}
subscriptions.push(SubscriptionSpec {
channel,
registered_name,
sdk_index,
trailer_index,
flags,
requirement,
});
Ok(())
}
}
fn trailer_channel_name<T: ChannelValue>(
channel: Channel<T>,
trailer_index: TrailerIndex,
) -> PluginResult<CString> {
let name = channel.name().to_str().map_err(|error| {
PluginError::new(
SdkError::InvalidParameter,
format!("channel name is not UTF-8: {error}"),
)
})?;
let Some(suffix) = name.strip_prefix("trailer.") else {
return Err(PluginError::new(
SdkError::InvalidParameter,
format!("channel {name:?} is not a trailer channel"),
));
};
CString::new(format!("trailer.{trailer_index}.{suffix}")).map_err(|error| {
PluginError::new(
SdkError::InvalidParameter,
format!("generated trailer channel contains a NUL byte: {error}"),
)
})
}
#[derive(Clone, Copy)]
pub struct ChannelUpdate<'a> {
channel: AnyChannel,
registered_name: &'a CStr,
index: Option<SdkIndex>,
trailer_index: Option<TrailerIndex>,
flags: ChannelFlags,
value: Option<ValueRef<'a>>,
}
impl<'a> ChannelUpdate<'a> {
pub(crate) const fn new(
channel: AnyChannel,
registered_name: &'a CStr,
index: Option<SdkIndex>,
trailer_index: Option<TrailerIndex>,
flags: ChannelFlags,
value: Option<ValueRef<'a>>,
) -> Self {
Self {
channel,
registered_name,
index,
trailer_index,
flags,
value,
}
}
#[must_use]
pub const fn channel(self) -> AnyChannel {
self.channel
}
#[must_use]
pub fn registered_name(self) -> Cow<'a, str> {
self.registered_name.to_string_lossy()
}
#[must_use]
pub const fn index(self) -> Option<SdkIndex> {
self.index
}
#[must_use]
pub const fn trailer_index(self) -> Option<TrailerIndex> {
self.trailer_index
}
#[must_use]
pub const fn flags(self) -> ChannelFlags {
self.flags
}
#[must_use]
pub const fn value_ref(self) -> Option<ValueRef<'a>> {
self.value
}
#[must_use]
pub fn is<T: ChannelValue>(self, channel: Channel<T>) -> bool {
self.channel == channel.erase()
}
#[must_use]
pub fn value<T: ChannelValue>(self, channel: Channel<T>) -> Option<T::Decoded<'a>> {
if !self.is(channel) {
return None;
}
channel.decode(self.value?)
}
}
#[derive(Clone, Copy)]
pub struct ConfigurationEvent<'a> {
inner: ConfigurationRef<'a>,
}
impl<'a> ConfigurationEvent<'a> {
pub(crate) const fn new(inner: ConfigurationRef<'a>) -> Self {
Self { inner }
}
#[must_use]
pub fn is(self, id: ConfigurationId) -> bool {
self.inner.is(id)
}
#[must_use]
pub fn trailer(self) -> Option<TrailerConfigurationId> {
self.inner.trailer()
}
#[must_use]
pub fn trailer_index(self) -> Option<TrailerIndex> {
self.inner.trailer_index()
}
#[must_use]
pub fn is_legacy_trailer(self) -> bool {
self.inner.is_legacy_trailer()
}
#[must_use]
pub fn id(self) -> Cow<'a, str> {
self.inner.id().to_string_lossy()
}
#[must_use]
pub fn has_attributes(self) -> bool {
self.inner.attributes().next().is_some()
}
#[must_use]
pub fn get<T: SdkValue>(self, attribute: Attribute<T>) -> Option<T::Decoded<'a>> {
self.inner.attributes().get(attribute)
}
#[must_use]
pub fn get_at<T: SdkValue>(
self,
attribute: Attribute<T>,
index: SdkIndex,
) -> Option<T::Decoded<'a>> {
self.inner.attributes().get_at(attribute, index)
}
#[must_use]
pub fn string(self, attribute: Attribute<StringValue>) -> Option<Cow<'a, str>> {
self.get(attribute).map(CStr::to_string_lossy)
}
#[must_use]
pub fn string_owned(self, attribute: Attribute<StringValue>) -> Option<String> {
self.string(attribute).map(Cow::into_owned)
}
#[must_use]
pub fn shifter_type(self) -> Option<sdk::configuration::ShifterType> {
let value = self.string(sdk::configuration::attributes::SHIFTER_TYPE)?;
value.parse().ok()
}
#[must_use]
pub fn job_market(self) -> Option<sdk::configuration::JobMarket> {
let value = self.string(sdk::configuration::attributes::JOB_MARKET)?;
value.parse().ok()
}
}
#[derive(Clone, Copy)]
pub struct GameplayEvent<'a> {
inner: GameplayEventRef<'a>,
}
impl<'a> GameplayEvent<'a> {
pub(crate) const fn new(inner: GameplayEventRef<'a>) -> Self {
Self { inner }
}
#[must_use]
pub fn is(self, id: GameplayEventId) -> bool {
self.inner.is(id)
}
#[must_use]
pub fn id(self) -> Cow<'a, str> {
self.inner.id().to_string_lossy()
}
#[must_use]
pub fn get<T: SdkValue>(self, attribute: Attribute<T>) -> Option<T::Decoded<'a>> {
self.inner.attributes().get(attribute)
}
#[must_use]
pub fn get_at<T: SdkValue>(
self,
attribute: Attribute<T>,
index: SdkIndex,
) -> Option<T::Decoded<'a>> {
self.inner.attributes().get_at(attribute, index)
}
#[must_use]
pub fn string(self, attribute: Attribute<StringValue>) -> Option<Cow<'a, str>> {
self.get(attribute).map(CStr::to_string_lossy)
}
#[must_use]
pub fn string_owned(self, attribute: Attribute<StringValue>) -> Option<String> {
self.string(attribute).map(Cow::into_owned)
}
#[must_use]
pub fn fine_offence(self) -> Option<sdk::gameplay::FineOffence> {
let value = self.string(sdk::gameplay::attributes::FINE_OFFENCE)?;
value.parse().ok()
}
}
#[derive(Clone, Copy)]
pub enum TelemetryEvent<'a> {
FrameStart(FrameStartRef<'a>),
FrameEnd,
Paused,
Started,
Configuration(ConfigurationEvent<'a>),
Gameplay(GameplayEvent<'a>),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PluginMetadata {
name: &'static str,
version: &'static str,
}
impl PluginMetadata {
#[must_use]
pub const fn new(name: &'static str, version: &'static str) -> Self {
Self { name, version }
}
#[must_use]
pub const fn name(self) -> &'static str {
self.name
}
#[must_use]
pub const fn version(self) -> &'static str {
self.version
}
}
pub trait TelemetryPlugin: Send + 'static {
fn metadata(&self) -> PluginMetadata;
fn compatibility(&self) -> PluginCompatibility;
fn initialize(&mut self, context: &mut PluginContext<'_>) -> PluginResult;
fn channel(&mut self, _context: &mut PluginContext<'_>, _update: ChannelUpdate<'_>) {}
fn event(&mut self, _context: &mut PluginContext<'_>, _event: TelemetryEvent<'_>) {}
fn shutdown(&mut self, _context: &mut PluginContext<'_>) {}
}
#[doc(hidden)]
pub mod __private {
pub use crate::input_runtime::InputRuntime;
pub use crate::runtime::Runtime;
pub use scs_sdk_sys::{ScsInputInitParams, ScsResult, ScsTelemetryInitParams, ScsU32};
}
#[cfg(test)]
mod tests {
use super::*;
use scs_sdk::channels;
#[test]
fn optional_subscriptions_tolerate_only_capability_absence() {
assert!(
SubscriptionRequirement::Optional
.tolerates_channel_registration_error(SdkError::NotFound)
);
assert!(
SubscriptionRequirement::Optional
.tolerates_channel_registration_error(SdkError::UnsupportedType)
);
for error in [
SdkError::Unsupported,
SdkError::InvalidParameter,
SdkError::AlreadyRegistered,
SdkError::NotNow,
SdkError::Generic,
] {
assert!(
!SubscriptionRequirement::Optional.tolerates_channel_registration_error(error),
"optional registration unexpectedly tolerated {error}"
);
}
for error in [
SdkError::NotFound,
SdkError::UnsupportedType,
SdkError::Generic,
] {
assert!(
!SubscriptionRequirement::Required.tolerates_channel_registration_error(error),
"required registration unexpectedly tolerated {error}"
);
}
for error in [SdkError::Unsupported, SdkError::NotFound] {
assert!(
SubscriptionRequirement::Optional.tolerates_event_registration_error(error),
"optional event registration did not tolerate {error}"
);
}
for error in [
SdkError::InvalidParameter,
SdkError::AlreadyRegistered,
SdkError::UnsupportedType,
SdkError::NotNow,
SdkError::Generic,
] {
assert!(
!SubscriptionRequirement::Optional.tolerates_event_registration_error(error),
"optional event registration unexpectedly tolerated {error}"
);
}
}
#[test]
fn game_info_owns_rust_text_and_classifies_known_ids() {
let ets2 = GameInfo::new(
c"Euro Truck Simulator 2",
c"eut2",
GameSchemaVersion::new(1, 56),
);
assert_eq!(ets2.kind(), Game::EuroTruckSimulator2);
assert_eq!(ets2.name(), "Euro Truck Simulator 2");
assert_eq!(ets2.id(), "eut2");
assert_eq!(ets2.schema_version(), GameSchemaVersion::new(1, 56));
assert!(ets2.supports(sdk::game::capabilities::MULTI_TRAILER));
assert_eq!(
ets2.minimum_schema_for(sdk::game::capabilities::MULTI_TRAILER),
Some(sdk::game::ets2::V1_14)
);
let ats = GameInfo::new(
c"American Truck Simulator",
c"ats",
GameSchemaVersion::new(0, 0),
);
assert_eq!(ats.kind(), Game::AmericanTruckSimulator);
assert!(!ats.supports(channels::truck::ADBLUE.availability()));
let future = GameInfo::new(c"Future Truck", c"future", GameSchemaVersion::new(0, 0));
assert_eq!(future.kind(), Game::Other);
assert_eq!(future.id(), "future");
assert_eq!(
future.minimum_schema_for(sdk::game::capabilities::MULTI_TRAILER),
None
);
assert!(!future.supports(sdk::game::capabilities::MULTI_TRAILER));
}
#[test]
fn trailer_names_follow_the_official_zero_based_scheme() {
let first = trailer_channel_name(channels::trailer::CONNECTED, TrailerIndex::ZERO)
.expect("first trailer name should be valid");
assert_eq!(first.as_c_str(), c"trailer.0.connected");
let last_index = TrailerIndex::new(9).expect("index nine is in the SDK range");
let last = trailer_channel_name(channels::trailer::WHEEL_ROTATION, last_index)
.expect("last trailer name should be valid");
assert_eq!(last.as_c_str(), c"trailer.9.wheel.rotation");
assert_eq!(TrailerIndex::new(10), None);
assert_eq!(
trailer_channel_name(channels::truck::SPEED, TrailerIndex::ZERO)
.expect_err("truck channels must not enter trailer naming")
.result(),
SdkError::InvalidParameter
);
}
#[test]
fn channel_update_requires_the_original_typed_descriptor() {
let raw = scs_sdk_sys::ScsValue {
type_: scs_sdk_sys::SCS_VALUE_TYPE_FLOAT,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValueData {
value_float: scs_sdk_sys::ScsValueFloat { value: 42.5 },
},
};
let raw_value = &raw const raw;
let value = unsafe { ValueRef::from_ptr(raw_value) }.expect("test value should be present");
let update = ChannelUpdate::new(
channels::truck::SPEED.erase(),
c"truck.speed",
None,
None,
ChannelFlags::NONE,
Some(value),
);
assert_eq!(update.value(channels::truck::SPEED), Some(42.5));
assert_eq!(update.value(channels::truck::ENGINE_RPM), None);
assert_eq!(update.registered_name(), "truck.speed");
}
#[test]
fn channel_update_preserves_both_strong_index_domains() {
let raw = scs_sdk_sys::ScsValue {
type_: scs_sdk_sys::SCS_VALUE_TYPE_FLOAT,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValueData {
value_float: scs_sdk_sys::ScsValueFloat { value: 1.25 },
},
};
let raw_value = &raw const raw;
let value = unsafe { ValueRef::from_ptr(raw_value) }.expect("test value should be present");
let sdk_index = SdkIndex::new(2).expect("ordinary SDK index");
let trailer_index = TrailerIndex::new(1).expect("second trailer");
let update = ChannelUpdate::new(
channels::trailer::WHEEL_ROTATION.erase(),
c"trailer.1.wheel.rotation",
Some(sdk_index),
Some(trailer_index),
ChannelFlags::EACH_FRAME,
Some(value),
);
assert_eq!(update.index(), Some(sdk_index));
assert_eq!(update.trailer_index(), Some(trailer_index));
assert_eq!(update.registered_name(), "trailer.1.wheel.rotation");
assert_eq!(update.value(channels::trailer::WHEEL_ROTATION), Some(1.25));
}
#[test]
fn high_level_configuration_values_parse_known_and_preserve_unknown_text() {
for (raw_market, expected) in [
(
c"external_contracts",
Some(sdk::configuration::JobMarket::ExternalContracts),
),
(c"future_market", None),
] {
let attributes = [
scs_sdk_sys::ScsNamedValue {
name: c"job.market".as_ptr(),
index: scs_sdk_sys::SCS_U32_NIL,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValue {
type_: scs_sdk_sys::SCS_VALUE_TYPE_STRING,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValueData {
value_string: scs_sdk_sys::ScsValueString {
value: raw_market.as_ptr(),
},
},
},
},
scs_sdk_sys::ScsNamedValue {
name: std::ptr::null(),
index: 0,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValue {
type_: scs_sdk_sys::SCS_VALUE_TYPE_INVALID,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValueData {
value_u64: scs_sdk_sys::ScsValueU64 { value: 0 },
},
},
},
];
let raw = scs_sdk_sys::ScsTelemetryConfiguration {
id: c"job".as_ptr(),
attributes: attributes.as_ptr(),
};
let inner = unsafe {
ConfigurationRef::from_event_info((&raw const raw).cast::<std::ffi::c_void>())
}
.expect("configuration fixture");
let event = ConfigurationEvent::new(inner);
assert_eq!(event.job_market(), expected);
assert_eq!(
event.string(sdk::configuration::attributes::JOB_MARKET),
Some(Cow::Borrowed(raw_market.to_str().expect("ASCII fixture")))
);
}
}
#[test]
fn high_level_shifter_values_parse_known_and_preserve_unknown_text() {
for (raw_shifter, expected) in [
(c"hshifter", Some(sdk::configuration::ShifterType::HShifter)),
(c"future_shifter", None),
] {
let attributes = [
scs_sdk_sys::ScsNamedValue {
name: c"shifter.type".as_ptr(),
index: scs_sdk_sys::SCS_U32_NIL,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValue {
type_: scs_sdk_sys::SCS_VALUE_TYPE_STRING,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValueData {
value_string: scs_sdk_sys::ScsValueString {
value: raw_shifter.as_ptr(),
},
},
},
},
scs_sdk_sys::ScsNamedValue {
name: std::ptr::null(),
index: 0,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValue {
type_: scs_sdk_sys::SCS_VALUE_TYPE_INVALID,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValueData {
value_u64: scs_sdk_sys::ScsValueU64 { value: 0 },
},
},
},
];
let raw = scs_sdk_sys::ScsTelemetryConfiguration {
id: c"controls".as_ptr(),
attributes: attributes.as_ptr(),
};
let inner = unsafe {
ConfigurationRef::from_event_info((&raw const raw).cast::<std::ffi::c_void>())
}
.expect("configuration fixture");
let event = ConfigurationEvent::new(inner);
assert_eq!(event.shifter_type(), expected);
assert_eq!(
event.string(sdk::configuration::attributes::SHIFTER_TYPE),
Some(Cow::Borrowed(raw_shifter.to_str().expect("ASCII fixture")))
);
}
}
#[test]
fn high_level_gameplay_values_parse_known_and_preserve_unknown_text() {
for (raw_offence, expected) in [
(
c"damaged_vehicle_usage",
Some(sdk::gameplay::FineOffence::DamagedVehicleUsage),
),
(c"future_offence", None),
] {
let attributes = [
scs_sdk_sys::ScsNamedValue {
name: c"fine.offence".as_ptr(),
index: scs_sdk_sys::SCS_U32_NIL,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValue {
type_: scs_sdk_sys::SCS_VALUE_TYPE_STRING,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValueData {
value_string: scs_sdk_sys::ScsValueString {
value: raw_offence.as_ptr(),
},
},
},
},
scs_sdk_sys::ScsNamedValue {
name: std::ptr::null(),
index: 0,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValue {
type_: scs_sdk_sys::SCS_VALUE_TYPE_INVALID,
padding: scs_sdk_sys::ScsPadding::uninit(),
value: scs_sdk_sys::ScsValueData {
value_u64: scs_sdk_sys::ScsValueU64 { value: 0 },
},
},
},
];
let raw = scs_sdk_sys::ScsTelemetryGameplayEvent {
id: c"player.fined".as_ptr(),
attributes: attributes.as_ptr(),
};
let inner = unsafe {
GameplayEventRef::from_event_info((&raw const raw).cast::<std::ffi::c_void>())
}
.expect("gameplay fixture");
let event = GameplayEvent::new(inner);
assert_eq!(event.fine_offence(), expected);
assert_eq!(
event.string(sdk::gameplay::attributes::FINE_OFFENCE),
Some(Cow::Borrowed(raw_offence.to_str().expect("ASCII fixture")))
);
}
}
}