From 7b780e893f82bc4c820712f20f727e1cc9d5c76c Mon Sep 17 00:00:00 2001 From: Scott Velez <162245270+SVappsLAB@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:01:33 -0700 Subject: [PATCH 1/5] add memory layout logging --- .../DataProviders/DataProviderBase.cs | 72 +++++++++++++++++++ .../DataProviders/IBTDataProvider.cs | 30 +++++++- .../DataProviders/LiveDataProvider.cs | 4 +- 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs index 01b2db9..63185b4 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs @@ -52,6 +52,15 @@ internal abstract unsafe class DataProviderBase : IAsyncDisposable VarHeaderDictionary? _varHeaders; int _lastSessionInfoUpdate = -1; // latest session info update counter + // last layout we logged. used to detect when a region moves or resizes, so + // we only log the layout when it actually changes (see LogLayoutIfChanged) + int _loggedNumVars = -1; + int _loggedBufLen = -1; + int _loggedVarHeaderOffset = -1; + int _loggedSessionInfoOffset = -1; + int _loggedSessionInfoLen = -1; + int _loggedFirstBufOffset = -1; + protected MemoryMappedFile? _mmFile; protected MemoryMappedViewAccessor? _viewAccessor; @@ -112,8 +121,71 @@ public irsdk_header GetHeader() _telemetryDataBuffer = new byte[_header.bufLen]; } + if (_logger.IsEnabled(LogLevel.Debug)) + LogLayoutIfChanged(); + return _header; } + + // log where each region of the memory mapped data lives + protected void LogLayoutIfChanged() + { + var firstBufOffset = _header.GetVarBuf(0).bufOffset; + + var changed = + _header.numVars != _loggedNumVars || + _header.bufLen != _loggedBufLen || + _header.varHeaderOffset != _loggedVarHeaderOffset || + _header.sessionInfoOffset != _loggedSessionInfoOffset || + _header.sessionInfoLen != _loggedSessionInfoLen || + firstBufOffset != _loggedFirstBufOffset; + + if (!changed) + return; + + var isFirstLog = _loggedNumVars == -1; + + _loggedNumVars = _header.numVars; + _loggedBufLen = _header.bufLen; + _loggedVarHeaderOffset = _header.varHeaderOffset; + _loggedSessionInfoOffset = _header.sessionInfoOffset; + _loggedSessionInfoLen = _header.sessionInfoLen; + _loggedFirstBufOffset = firstBufOffset; + + LogLayout(isFirstLog ? "initial" : "changed"); + } + + void LogLayout(string reason) + { + var source = GetType().Name; + + _logger.LogDebug("{source} layout ({reason}): ver={ver}, status={status}, tickRate={tickRate}, sessionInfoUpdate={sessionInfoUpdate}", + source, reason, _header.ver, _header.status, _header.tickRate, _header.sessionInfoUpdate); + + _logger.LogDebug("{source} layout ({reason}): sizeof(header)={headerSize}, sizeof(diskSubHeader)={diskSubHeaderSize}, sizeof(varHeader)={varHeaderSize}, sizeof(varBuf)={varBufSize}", + source, reason, sizeof(irsdk_header), sizeof(irsdk_diskSubHeader), sizeof(irsdk_varHeader), sizeof(irsdk_varBuf)); + + // varHeader array: numVars entries, each sizeof(irsdk_varHeader) bytes + var varHeaderBytes = (long)_header.numVars * sizeof(irsdk_varHeader); + _logger.LogDebug("{source} layout ({reason}): varHeaderOffset={varHeaderOffset}, numVars={numVars}, varHeaderBytes={varHeaderBytes}, varHeaderEnd={varHeaderEnd}", + source, reason, _header.varHeaderOffset, _header.numVars, varHeaderBytes, _header.varHeaderOffset + varHeaderBytes); + + // session info yaml. 'sessionInfoLen' is the length in use, which may be smaller than what the irsdk reserved + _logger.LogDebug("{source} layout ({reason}): sessionInfoOffset={sessionInfoOffset}, sessionInfoLen={sessionInfoLen}, sessionInfoEnd={sessionInfoEnd}", + source, reason, _header.sessionInfoOffset, _header.sessionInfoLen, (long)_header.sessionInfoOffset + _header.sessionInfoLen); + + // telemetry buffers + var numBuf = Math.Min(_header.numBuf, irSDKDefines.Constants.IRSDK_MAX_BUFS); + _logger.LogDebug("{source} layout ({reason}): numBuf={numBuf}, bufLen={bufLen}, curBuf={curBuf}, curBufTickCount={curBufTickCount}", + source, reason, _header.numBuf, _header.bufLen, _header.curBuf, _header.curBufTickCount); + + for (var i = 0; i < numBuf; i++) + { + var varBuf = _header.GetVarBuf(i); + _logger.LogDebug("{source} layout ({reason}): varBuf[{index}] bufOffset={bufOffset}, bufEnd={bufEnd}, tickCount={tickCount}, tickCountBegin={tickCountBegin}, pad={pad}", + source, reason, i, varBuf.bufOffset, (long)varBuf.bufOffset + _header.bufLen, varBuf.tickCount, varBuf.tickCountBegin, varBuf.pad); + } + } public string GetSessionInfoYaml() { var header = GetHeader(); diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs index 1a767ed..f9959e4 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs @@ -55,14 +55,42 @@ public override void OpenDataSource() _viewAccessor = _mmFile.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); _viewAccessor!.SafeMemoryMappedViewHandle.AcquirePointer(ref _dataPtr); - // read header + // read header _header = GetHeader(); _numRecords = GetNumRecordsInIBTFile(); + if (_logger.IsEnabled(LogLevel.Debug)) + LogIBTFileLayout(); + _governor.StartPlayback(); } + // the base class logs the regions common to live and IBT data. + // add the the disk sub header, and the record region till end of the file + void LogIBTFileLayout() + { + var diskSubHeader = GetDiskSubHeader(); + + _logger.LogDebug("IBT layout: file={file}", _ibtOptions.IbtFilePath); + + // the disk sub header sits immediately after the header + _logger.LogDebug("IBT layout: diskSubHeaderOffset={diskSubHeaderOffset}, diskSubHeaderEnd={diskSubHeaderEnd}, sessionStartDate={sessionStartDate}, sessionStartTime={sessionStartTime}, sessionEndTime={sessionEndTime}, sessionLapCount={sessionLapCount}, sessionRecordCount={sessionRecordCount}", + sizeof(irsdk_header), sizeof(irsdk_header) + sizeof(irsdk_diskSubHeader), + diskSubHeader.sessionStartDate, diskSubHeader.sessionStartTime, diskSubHeader.sessionEndTime, + diskSubHeader.sessionLapCount, diskSubHeader.sessionRecordCount); + + // records are written one after the other, starting at the first (only) buffer offset + var firstRecordOffset = _header.GetVarBuf(0).bufOffset; + var recordBytes = (long)diskSubHeader.sessionRecordCount * _header.bufLen; + var computedFileLength = firstRecordOffset + recordBytes; + var actualFileLength = new FileInfo(_ibtOptions.IbtFilePath).Length; + + _logger.LogDebug("IBT layout: firstRecordOffset={firstRecordOffset}, recordCount={recordCount}, bufLen={bufLen}, recordBytes={recordBytes}, computedFileLength={computedFileLength}, actualFileLength={actualFileLength}, matches={matches}", + firstRecordOffset, diskSubHeader.sessionRecordCount, _header.bufLen, recordBytes, + computedFileLength, actualFileLength, computedFileLength == actualFileLength); + } + public int GetNumRecordsInIBTFile() { var numRecs = GetDiskSubHeader().sessionRecordCount; diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/LiveDataProvider.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/LiveDataProvider.cs index d5bb4a4..4b1e22d 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/LiveDataProvider.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/LiveDataProvider.cs @@ -42,7 +42,9 @@ public override void OpenDataSource() _viewAccessor = _mmFile.CreateViewAccessor(); _viewAccessor!.SafeMemoryMappedViewHandle.AcquirePointer(ref _dataPtr); - // read header + _logger.LogDebug("live layout: memoryMappedFile={memMapFile}", IRSDK_MemMapFileName); + + // read header _header = GetHeader(); // data ready event From 831d99785d4e0ca65b4c665ddf3480a103a7cdf6 Mon Sep 17 00:00:00 2001 From: Scott Velez <162245270+SVappsLAB@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:16:32 +0000 Subject: [PATCH 2/5] update docs --- ARCHITECTURE.md | 328 ++++++++++++++++++++++++++++++++++++++++++++++++ NUGET.md | 12 ++ README.md | 169 +++++-------------------- 3 files changed, 373 insertions(+), 136 deletions(-) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..cda602c --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,328 @@ +# Architecture and Design + +This document describes the SDK's threading model, data pipeline, memory layout, buffering policy, and performance instrumentation. + +For installation and everyday usage, see the [README](./README.md). For direct stream access and multi-consumer patterns, see [Advanced Usage](./docs/ADVANCED.md). + +## Table of Contents + +- [Design Principles](#design-principles) +- [Data Flow](#data-flow) +- [Data Layout](#data-layout) +- [Compile-Time Code Generation](#compile-time-code-generation) +- [Data Streaming and Buffering](#data-streaming-and-buffering) +- [Performance Characteristics](#performance-characteristics) +- [Performance Monitoring](#performance-monitoring) + +## Design Principles + +- **Compile-time telemetry types**: The source generator emits the requested telemetry schema; the runtime does not discover it through reflection. +- **Bounded, non-blocking streams**: Producers do not wait for slow consumers. Full channels discard the oldest unread item. +- **Independent processing paths**: Telemetry decoding and session-info parsing execute on separate tasks. +- **Recency over completeness**: The real-time path preserves current data at the cost of older unread samples under sustained backpressure. + +## Data Flow + +iRacing exposes live telemetry through shared memory at 60 Hz and persists recorded telemetry in IBT files. Both sources enter the same provider pipeline and feed independent output streams. + +```mermaid +graph TB + subgraph "Data Sources" + iRacing[iRacing Simulator
60Hz Live Data] + IBT[IBT File
Historical Data] + end + + subgraph Input ["Task1 - Input Processing"] + ProcessingDecoding[Processing - Decoding] + end + + MainTask[Telemetry Data] + + subgraph SessionInfo [Task2 - Session Info] + SessionTask[Session Info Processing
YAML Parsing] + end + + subgraph "High Performance Data Streams" + TelemetryStream[TelemetryData] + SessionStream[SessionData] + RawStream[SessionDataYaml] + end + + iRacing --> Input + IBT --> Input + + Input --> MainTask + Input --> SessionInfo + + MainTask --> TelemetryStream + SessionTask --> SessionStream + SessionTask --> RawStream + + classDef dataSource fill:#e1f5fe + classDef processingSteps fill:#f3e5f5 + classDef stream fill:#e8f5e8 + + class iRacing,IBT dataSource + class ProcessingDecoding,SessionTask processingSteps + class TelemetryStream,SessionStream,RawStream stream +``` + +**Task1** handles source reads and telemetry decoding. **Task2** publishes raw session YAML and parses it into `SessionData`. The separation keeps YAML parsing latency out of the telemetry path. + +Live data and IBT playback share a data-provider abstraction and expose the same client API. + +## Data Layout + +The live memory-mapped file and IBT files use the same iRacing structures. The SDK reads both directly from unmanaged memory. + +The header is an index of offsets and counts. Region extents are derived from the referenced metadata. + +```mermaid +graph LR + subgraph HDR["irsdk_header - 112 bytes, at offset 0"] + VHO["varHeaderOffset
numVars"] + SIO["sessionInfoOffset
sessionInfoLen"] + BUF["varBuf[i].bufOffset
numBuf, bufLen"] + end + + VHO --> VH["irsdk_varHeader[numVars]
144 bytes each
name, type, offset, count"] + SIO --> SI["session info
YAML text"] + BUF --> TB["telemetry buffer
one row of bufLen bytes"] + + VH -. "describes the fields inside" .-> TB + + classDef header fill:#e1f5fe + classDef region fill:#e8f5e8 + class VHO,SIO,BUF header + class VH,SI,TB region +``` + +### IBT file layout + +An IBT file contains fixed metadata followed by a contiguous sequence of telemetry records: + +``` + ┌────────────────────────────────────┐ +0 │ irsdk_header 112 B │ + ├────────────────────────────────────┤ +112 │ irsdk_diskSubHeader 32 B │ IBT only + ├────────────────────────────────────┤ +144 │ irsdk_varHeader[numVars] │ numVars x 144 B + │ │ + ├────────────────────────────────────┤ +sessionInfo │ session info (YAML) │ sessionInfoLen B +Offset │ │ + ├────────────────────────────────────┤ +varBuf[0] │ record 0 │ bufLen B +.bufOffset ├────────────────────────────────────┤ + │ record 1 │ bufLen B + ├────────────────────────────────────┤ + │ ... │ + ├────────────────────────────────────┤ + │ record n-1 │ n = sessionRecordCount + └────────────────────────────────────┘ = end of file +``` + +Regions are packed without padding or alignment: + +``` +varHeaderOffset == 144 (immediately after the sub header) +sessionInfoOffset == 144 + numVars * 144 (immediately after the varHeaders) +bufOffset == sessionInfoOffset + sessionInfoLen (immediately after the YAML) +fileLength == bufOffset + sessionRecordCount * bufLen +``` + +Example layout with 264 variables and 123,937 records: + +``` +offset size region +───────────────────────────────────────────────────────────────── +0 112 irsdk_header +112 32 irsdk_diskSubHeader +144 38,016 irsdk_varHeader[264] 264 x 144 +38,160 26,463 session info (YAML) +64,623 127,655,110 records 123,937 x 1,030 + ───────────── + end of file 127,719,733 +``` + + +### Live in-memory layout + +The live map (`Local\IRSDKMemMapFileName`) uses the same header, session info, variable headers, and telemetry data, but reserves fixed-capacity regions. Header offsets are stable during a session. `sessionInfoLen` is the reserved capacity, not the current YAML string length. + + +``` + ┌────────────────────────────────────┐ +0 │ irsdk_header 112 B │ + ├────────────────────────────────────┤ +112 │ session info (YAML) │ 512 KB reserved + │ ...null padded... │ + ├────────────────────────────────────┤ +524,400 │ irsdk_varHeader[numVars] │ 576 KB reserved + │ ...unused... │ room for 4,096 entries + ├────────────────────────────────────┤ +1,114,224 │ telemetry buffer 0 │ 24 KB reserved + ├────────────────────────────────────┤ +1,138,800 │ telemetry buffer 1 │ 24 KB reserved + ├────────────────────────────────────┤ +1,163,376 │ telemetry buffer 2 │ 24 KB reserved + └────────────────────────────────────┘ 1,187,952 +``` + +iRacing rotates writes across `numBuf` telemetry buffers. + +| Field | Meaning | +|---|---| +| `curBuf` | index of the buffer most recently written | +| `curBufTickCount` | tick count of that buffer | +| `varBuf[i].tickCountBegin` | set *before* a write starts, where `tickCount` is set *after* it completes | + + +### Reading a telemetry row + +A telemetry row contains `bufLen` bytes of packed values. Each `irsdk_varHeader` entry defines a variable's type, offset, and element count: + +``` +irsdk_varHeader - 144 bytes a row of bufLen bytes +┌──────────────────────────┐ ┌─────────────────────────┐ +│ type (irsdk_VarType) │ 0 │ SessionTime (double) │ +│ offset ─────────────────┼──────┐ ├─────────────────────────┤ +│ count (array length) │ │ 8 │ SessionTick (int) │ +│ countAsTime │ │ ├─────────────────────────┤ +│ name[32] "Speed" │ │ 12 │ ... │ +│ desc[64] │ │ ├─────────────────────────┤ +│ unit[32] "m/s" │ └────────> n │ Speed (float) │ +└──────────────────────────┘ ├─────────────────────────┤ + │ ... │ + └─────────────────────────┘ +``` + +Element sizes are 1 byte for `irsdk_char` and `irsdk_bool`, 4 bytes for `irsdk_int`, `irsdk_bitField`, and `irsdk_float`, and 8 bytes for `irsdk_double`. A variable occupies `count * elementSize` bytes at `offset`; `count > 1` denotes an array, including the per-car `CarIdx*` variables. + +### Structure sizes + +| Structure | Size | Live | IBT | +|---|---|---|---| +| `irsdk_header` | 112 bytes | yes | yes | +| `irsdk_diskSubHeader` | 32 bytes | | yes, at offset 112 | +| `irsdk_varHeader` | 144 bytes | yes | yes | +| `irsdk_varBuf` | 16 bytes | yes | yes | + +## Compile-Time Code Generation + +For schemas known at compile time, the SDK uses a Roslyn source generator. A `RequiredTelemetryVars` attribute defines the requested schema: + +```csharp +[RequiredTelemetryVars([TelemetryVar.IsOnTrackCar, TelemetryVar.RPM, TelemetryVar.Speed, TelemetryVar.PlayerTrackSurface])] +``` + +The generator emits a `TelemetryData` record struct containing those fields: + +```csharp +public record struct TelemetryData +{ + public bool? IsOnTrackCar { get; init; } + public float? RPM { get; init; } + public float? Speed { get; init; } + public TrackLocation? PlayerTrackSurface { get; init; } +} +``` + +The generated type provides the following runtime characteristics: + +- No reflection or dictionary lookup in the telemetry path. +- Variable names and types are validated at compile time. +- Only requested variables are decoded. +- The generated API is available to the compiler and IDE tooling. + +Each sample is copied from mapped memory into a reused buffer. The live provider detects writes that overlap the copy and retries to avoid exposing a torn sample. Requested fields are then decoded from that buffer through `ReadOnlySpan`, without per-field copies or allocations. + +For schemas selected at runtime, `GetTelemetryVariables()` exposes the available variable metadata and `GetValue(string)` reads a value by name. Dynamic lookup requires `TelemetryDeliveryMode.Synchronous`; `DynamicTelemetryData` can be used as the client type when no generated fields are required. + +## Data Streaming and Buffering + +In the default `TelemetryDeliveryMode.Async` mode, application-facing streams use bounded channels with a drop-oldest policy. + +- Each application-facing channel has capacity for 60 items. For telemetry, this is equivalent to one second at the live 60 Hz rate. +- Reads are FIFO and destructive. +- A write to a full channel discards the oldest unread sample. +- Memory usage and producer latency remain bounded when a consumer falls behind. + +This policy favors recency over complete delivery. Consumers that require lossless processing must read the streams promptly and provide an appropriate downstream buffer; see [Advanced Usage](./ADVANCED.md). + +Async mode supports handler-based consumption through `Monitor(handlers, ct)` and direct access to each stream. In `TelemetryDeliveryMode.Synchronous`, telemetry samples are delivered inline to `OnTelemetryUpdate`; the producer does not read the next sample until the handler completes. `TelemetryData` is unavailable in synchronous mode. Both delivery modes accept cancellation through `CancellationToken`. + +## Performance Characteristics + +- IBT processing throughput exceeds 600,000 records per second in the referenced benchmark. +- The same benchmark measured approximately twice the throughput of the previous event-based implementation. +- Bounded channels prevent consumer backpressure from blocking source reads. +- The telemetry path has near-zero steady-state allocation. + +Actual throughput depends on the requested schema, consumer work, storage, and host hardware. + +## Performance Monitoring + +The SDK publishes runtime instrumentation through `System.Diagnostics.Metrics`. + +### Available Metrics + +Available instruments: + +- `telemetry_records_processed_total`: processed telemetry records +- `telemetry_processing_duration_microseconds`: telemetry processing duration +- `sessioninfo_records_processed_total`: processed session-info updates +- `sessioninfo_processing_duration_milliseconds`: session-info processing duration + +### Enabling Metrics with Dependency Injection + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +[RequiredTelemetryVars([TelemetryVar.Speed, TelemetryVar.RPM])] +public class Program +{ + public static async Task Main(string[] args) + { + var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => + { + services.AddMetrics(); + services.AddLogging(logging => logging.AddConsole()); + }) + .Build(); + + var logger = host.Services.GetRequiredService>(); + var meterFactory = host.Services.GetRequiredService(); + + var clientOptions = new ClientOptions { MeterFactory = meterFactory }; + await using var client = TelemetryClient.Create(logger, null, clientOptions); + } +} +``` + +### Monitoring with dotnet-counters + +Any `System.Diagnostics.Metrics` consumer can collect these instruments. For example, `dotnet-counters` can attach to a running process: + +```bash +# Monitor all SDK metrics for a running application named "YourApp" +dotnet-counters monitor --name "YourApp" --counters SVappsLAB.iRacingTelemetrySDK + +# Sample output: +# [SVappsLAB.iRacingTelemetrySDK] +# telemetry_records_processed_total 45,231 +# sessioninfo_records_processed_total 12 +``` + +Processing rates, latency distributions, and dropped-record counts identify whether the producer or a downstream consumer is the limiting stage. + +## Related Documentation + +- **[README](./README.md)** - Installation, quick start, and telemetry variables +- **[Advanced Usage](./docs/ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior +- **[Migration Guide](./docs/MIGRATION_GUIDE.md)** - Upgrading from early pre-1.0 releases +- **[SDK usage guide for agents](./docs/ai/SDK_USAGE.md)** - Recommended usage for consumer applications diff --git a/NUGET.md b/NUGET.md index 6b798b7..9bcf6f3 100644 --- a/NUGET.md +++ b/NUGET.md @@ -13,6 +13,17 @@ High-performance .NET SDK for accessing **live telemetry data** from iRacing sim - **Robust Buffering**: 60-sample ring buffer (1 second at 60Hz) ensures reliable data delivery even when your app can't keep pace - **Simulator Control**: Send commands *to* the simulator - cameras, replay, pit service, chat, telemetry recording, and more +## How It Compares + +Most .NET iRacing libraries are thin wrappers over the simulator's shared-memory layout — variables looked up by string name at runtime, data delivered through events on the caller's thread. This SDK treats telemetry as a **typed, high-throughput data stream** instead: + +- **Compile-time generated structs** instead of runtime string/dictionary lookups — typos are build errors, and only the variables you declare are ever decoded +- **Async data streams with bounded backpressure** instead of events on the caller's thread — your processing speed never stalls data collection +- **The same strongly-typed API for live and IBT data**, rather than separate code paths +- **Built-in metrics** (`System.Diagnostics.Metrics`) so you can see processing rates and dropped records in production + +Full comparison and design rationale: [Architecture and Design](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/ARCHITECTURE.md) + ## Architecture Benefits **Production-Ready Data Streaming:** @@ -142,6 +153,7 @@ See the [SimControl sample](https://github.com/SVappsLAB/iRacingTelemetrySDK/tre - **[Getting Started Guide](https://github.com/SVappsLAB/iRacingTelemetrySDK#readme)** - Setup and basic usage - **[Sample Projects](https://github.com/SVappsLAB/iRacingTelemetrySDK/tree/main/Samples)** - Basic monitoring, data export, track analysis +- **[Architecture and Design](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/ARCHITECTURE.md)** - Threading model, buffering, performance, and metrics - **[Advanced Usage](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior - **[Migration Guide](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/MIGRATION_GUIDE.md)** - Upgrading from previous versions - **[GitHub Repository](https://github.com/SVappsLAB/iRacingTelemetrySDK)** - Source code and releases diff --git a/README.md b/README.md index 85835c1..7b74c83 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e ## Table of Contents - [Features](#features) +- [How It Compares](#how-it-compares) - [Requirements](#requirements) - [Quick Example](#quick-example) - [Getting Started](#getting-started) @@ -17,10 +18,9 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e - [Controlling the Simulator](#controlling-the-simulator) - [Samples](#samples) - [Advanced Usage](./docs/ADVANCED.md) +- [Architecture and Design](./ARCHITECTURE.md) - [Documentation](#documentation) - [AI-Assisted Development](#ai-assisted-development) -- [Performance and Design](#performance-and-design) -- [Performance Monitoring](#performance-monitoring) - [Building from Source](#building-from-source) - [License](#license) @@ -36,6 +36,36 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e - **Pause and Resume**: Control data flow while background processing continues - **Sim Control**: Remotely control the simulator (pit commands, replay, cameras, chat, and more) +## How It Compares + +There are several .NET libraries for reading iRacing telemetry. Most are thin wrappers over the iRacing shared-memory layout: you look variables up by string name at runtime, receive data through events on the caller's thread, and get whatever the raw SDK gives you. That works, and for simple tools it's enough. + +This SDK takes a different approach — it treats telemetry as a **typed, high-throughput data stream** rather than a bag of named values. + +| | **iRacingTelemetrySDK** | **Typical .NET iRacing libraries** | +|---|---|---| +| **Variable access** | Compile-time generated `TelemetryData` struct — only the variables you declare | Runtime lookup by string name or dictionary indexing | +| **Type safety** | Enum-based selection, validated at build time, full IntelliSense | Strings resolved at runtime; typos surface as runtime errors or nulls | +| **API model** | Async data streams with `async`/`await` and automatic backpressure | Event handlers or manual polling loops | +| **Threading** | Dedicated background tasks for collection and YAML parsing — your handler never blocks the data source | Processing commonly runs on the caller's thread | +| **Backpressure** | Bounded 60-sample ring buffer with drop-oldest; slow consumers cost bounded data, never memory | Often unbounded or unspecified | +| **Live + IBT parity** | Identical strongly-typed API for both | Frequently separate code paths, or live-only | +| **Throughput** | 600,000+ records/sec on IBT playback | Rarely a stated design goal | +| **Observability** | Built-in `System.Diagnostics.Metrics` counters and histograms | Typically none | +| **Simulator control** | Pit, replay, camera, chat, and broadcast commands included | Varies; often read-only | +| **AI agent support** | Dedicated agent-facing usage and reference docs | Rare | + +Not every project needs this. If you want a handful of values on a secondary display, a simpler wrapper is fine. If you're building a **real-time dashboard, a race engineering tool, or an analysis pipeline that replays full sessions**, the differences above are the ones that show up. + +**Why it's fast:** + +- **Source-generated structs** eliminate runtime reflection and string lookups from the hot path — only the variables you asked for are ever decoded +- **Lock-free bounded streams** keep telemetry current under load instead of queueing unboundedly behind a slow consumer +- **Zero-copy reads** via `ReadOnlySpan` over mapped memory, keeping steady-state allocation and GC pressure near zero +- **Independent background tasks** mean CPU-intensive session-info YAML parsing never stalls the 60Hz telemetry path + +📖 Full details, including the threading model, buffering semantics, and built-in metrics: **[Architecture and Design](./ARCHITECTURE.md)** + ## Requirements - **.NET 8.0+** @@ -264,6 +294,7 @@ See [Samples Directory](./Samples/README.md) for ready-to-run example projects i ## Documentation +- **[Architecture and Design](./ARCHITECTURE.md)** - Threading model, data streaming and buffering, performance characteristics, and built-in metrics - **[Advanced Usage](./docs/ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior - **[Migration Guide](./docs/MIGRATION_GUIDE.md)** - Upgrading from early pre-1.0 releases @@ -282,140 +313,6 @@ https://raw.githubusercontent.com/SVappsLAB/iRacingTelemetrySDK/main/docs/ai/SDK and https://raw.githubusercontent.com/SVappsLAB/iRacingTelemetrySDK/main/docs/ai/SDK_REFERENCE.md for advanced patterns. ``` -## Performance and Design - -The SDK is designed for high performance with bounded async data streaming that keeps telemetry current under load. - -### Data Streaming Benefits - -**Data Safety:** -- **60-sample ring buffer** with FIFO (First-In-First-Out) and destructive-read semantics -- Provides **up to 1 second** of buffering at iRacing's 60Hz update rate -- When the buffer fills, **oldest unread samples are automatically dropped** to make room for new data -- **Drop-oldest strategy** ensures the SDK never blocks and prioritizes the most recent telemetry - -**Performance:** -- **Non-blocking bounded streams** reduce contention and keep telemetry current under load -- **Asynchronous processing** keeps your main thread responsive -- **~2x performance improvement** over traditional event-based approaches -- **600,000+ records/sec** processing capability for IBT files - -**Flexibility:** -- **Two consumption patterns**: Handler callbacks (`Monitor(handlers, ct)`) or direct stream access -- **Independent streams**: Consume only the data types you need -- **Cancellation support**: Graceful shutdown with `CancellationToken` - -### Architecture Overview - -**Key Design Principles:** - -- **Strongly Typed Telemetry Data**: Use strong type checking throughout -- **Non-blocking Streams**: All real-time data flows through asynchronous data streams -- **Separation of Concerns**: Telemetry data (high-frequency, time-critical) and Session Info processing (low frequency, CPU-intensive) run independently -- **Background Processing**: CPU-intensive YAML parsing runs in an independent async processing task so telemetry data handling stays responsive. - -```mermaid -graph TB - subgraph "Data Sources" - iRacing[iRacing Simulator
60Hz Live Data] - IBT[IBT File
Historical Data] - end - - subgraph "Task1 - Telemetry Data" - MainTask[Telemetry Data Stream] - end - - subgraph "Task2 - Session Info" - SessionTask[Session Info Processing
YAML Parsing] - end - - subgraph "High Performance Data Streams" - TelemetryStream[TelemetryData] - SessionStream[SessionData] - RawStream[SessionDataYaml] - end - - iRacing --> MainTask - IBT --> MainTask - - MainTask --> TelemetryStream - MainTask --> SessionTask - SessionTask --> SessionStream - SessionTask --> RawStream - - classDef dataSource fill:#e1f5fe - classDef processing fill:#f3e5f5 - classDef stream fill:#e8f5e8 - - class iRacing,IBT dataSource - class MainTask,SessionTask processing - class TelemetryStream,SessionStream,RawStream stream -``` - - -### Performance Monitoring - -The SDK includes built-in support for System.Diagnostics.Metrics to help monitor performance and diagnose issues. - -#### Available Metrics - -The SDK exposes several metrics automatically: - -- **telemetry_records_processed_total**: Counter of processed telemetry records -- **telemetry_records_dropped_total**: Counter of dropped records (when channels are full) -- **telemetry_processing_duration_microseconds**: Histogram of telemetry processing time -- **sessioninfo_records_processed_total**: Counter of session info updates processed -- **sessioninfo_processing_duration_milliseconds**: Histogram of session info processing time - -#### Enabling Metrics with Dependency Injection - -```csharp -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -[RequiredTelemetryVars([TelemetryVar.Speed, TelemetryVar.RPM])] -public class Program -{ - public static async Task Main(string[] args) - { - var host = Host.CreateDefaultBuilder(args) - .ConfigureServices((context, services) => - { - services.AddMetrics(); // Enable metrics support - services.AddLogging(logging => logging.AddConsole()); - }) - .Build(); - - var logger = host.Services.GetRequiredService>(); - var meterFactory = host.Services.GetRequiredService(); - - // Create client with metrics support - var clientOptions = new ClientOptions { MeterFactory = meterFactory }; - await using var client = TelemetryClient.Create(logger, null, clientOptions); - - // Your telemetry code here... - } -} -``` - -#### Monitoring with dotnet-counters - -Use any monitoring tool that supports System.Diagnostics.Metrics, like OpenTelemetry or the free Microsoft provided `dotnet-counters` tool to monitor SDK performance in real-time: - -```bash -# Monitor all SDK metrics for a running application named "YourApp" -dotnet-counters monitor --name "YourApp" --counters SVappsLAB.iRacingTelemetrySDK - -# Sample output: -# [SVappsLAB.iRacingTelemetrySDK] -# telemetry_records_processed_total 45,231 -# telemetry_records_dropped_total 0 -# sessioninfo_records_processed_total 12 -``` - -This helps identify performance bottlenecks, monitor processing rates, and detect if records are being dropped due to slow consumption. - ## Building from Source If you've cloned or forked the repository, you can build, test, and package the SDK with the standard .NET CLI from the repository root. From 254163645b373652b970162453750d78481b7d3e Mon Sep 17 00:00:00 2001 From: Scott Velez <162245270+SVappsLAB@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:19:24 +0000 Subject: [PATCH 3/5] don't resolve the IBT buffer through the live-only curBuf field --- .../DataProviders/DataProviderBase.cs | 6 +++++- Sdk/SVappsLAB.iRacingTelemetrySDK/irSDK_defines.cs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs index 63185b4..013bd54 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs @@ -262,9 +262,13 @@ public string GetSessionInfoYaml() // with IBT files, the 'recNum' tells us which data record in the mmf we should read + // + // IBT files always have a single buffer, and don't populate 'curBuf' (it is + // only written by the sim, for live data). read varBuf[0] directly, rather + // than resolving through the live-only field protected void CopyNewTelemetryDataToBuffer(int recNum = 0) { - var offset = _header.GetMostRecentBuffer().bufOffset + recNum * _header.bufLen; + var offset = _header.GetVarBuf(0).bufOffset + recNum * _header.bufLen; var ros = new ReadOnlySpan(_dataPtr + offset, _header.bufLen); ros.CopyTo(_telemetryDataBuffer); } diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/irSDK_defines.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/irSDK_defines.cs index bf7f353..7da1f54 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/irSDK_defines.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/irSDK_defines.cs @@ -90,8 +90,8 @@ internal unsafe struct irsdk_header public irsdk_varBuf varBuf4; #region methods - public irsdk_varBuf GetMostRecentBuffer() => GetVarBuf(GetMostRecentBufferIndex()); - + // NOTE: 'curBuf' is a live-only field. IBT files leave it zero, so this is + // only meaningful for live data. IBT readers should use varBuf[0] directly public int GetMostRecentBufferIndex() { // use curBuf to find the most recently written buffer From e8dda08f35feebcea18d5055a0702b15541bd82c Mon Sep 17 00:00:00 2001 From: Scott Velez <162245270+SVappsLAB@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:14:18 +0000 Subject: [PATCH 4/5] add string-based dynamic variable lookup --- AGENTS.md | 4 +- NUGET.md | 17 +- README.md | 78 +++-- .../MinimalExampleAsync.csproj} | 0 .../Program.cs | 9 +- .../Properties/launchSettings.json | 8 + .../MinimalExampleSync.csproj | 15 + Samples/MinimalExampleSync/Program.cs | 82 +++++ .../Properties/launchSettings.json | 16 +- Samples/README.md | 9 +- Samples/Samples.slnx | 3 +- Sdk/SVappsLAB.iRacingTelemetrySDK.slnx | 3 + .../DataProviders/DataProviderBase.cs | 26 +- .../ITelemetryClient.cs | 90 +++++- .../TelemetryClient.cs | 295 ++++++++++++------ .../TelemetryHandlers.cs | 3 - Sdk/tests/README.md | 18 +- .../SmokeTests/Base/Base.DynamicLookup.cs | 190 +++++++++++ Sdk/tests/SmokeTests/IBT/IBT.cs | 223 ++++++++++++- Sdk/tests/SmokeTests/Live/Live.cs | 54 ++++ ARCHITECTURE.md => docs/ARCHITECTURE.md | 59 ++-- docs/MIGRATION_GUIDE.md | 2 +- docs/ai/SDK_REFERENCE.md | 14 + docs/ai/SDK_USAGE.md | 31 ++ global.json | 5 + 25 files changed, 1059 insertions(+), 195 deletions(-) rename Samples/{MinimalExample/MinimalExample.csproj => MinimalExampleAsync/MinimalExampleAsync.csproj} (100%) rename Samples/{MinimalExample => MinimalExampleAsync}/Program.cs (88%) create mode 100644 Samples/MinimalExampleAsync/Properties/launchSettings.json create mode 100644 Samples/MinimalExampleSync/MinimalExampleSync.csproj create mode 100644 Samples/MinimalExampleSync/Program.cs rename Samples/{MinimalExample => MinimalExampleSync}/Properties/launchSettings.json (83%) create mode 100644 Sdk/tests/SmokeTests/Base/Base.DynamicLookup.cs rename ARCHITECTURE.md => docs/ARCHITECTURE.md (90%) create mode 100644 global.json diff --git a/AGENTS.md b/AGENTS.md index 1bc4b57..344abaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,13 +19,15 @@ For consumer-app usage patterns, read `docs/ai/SDK_USAGE.md` first and `docs/ai/ ```powershell dotnet build .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx dotnet build .\Samples\Samples.slnx -dotnet test .\Sdk\tests\UnitTests\UnitTests.csproj --no-build +dotnet test --project .\Sdk\tests\UnitTests\UnitTests.csproj --no-build dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait Category=ibt dotnet pack .\Sdk\SVappsLAB.iRacingTelemetrySDK\SVappsLAB.iRacingTelemetrySDK.csproj ``` Run `Category=live` smoke tests only with iRacing running in an active Windows session. Run `Category=manual` tests only when the local developer setup matches the test requirements. +`global.json` pins `test.runner` to `Microsoft.Testing.Platform`, so `dotnet test` needs `--project`/`--solution` (a bare positional path is rejected) and does not understand VSTest-style `--filter`. Use `dotnet run --project`, not `dotnet test`, for `SmokeTests` - its `launchSettings.json` injects a default `--filter-class` that `dotnet test` combines with (rather than overrides with) any `--filter-trait` you add, silently yielding "zero tests ran". See `Sdk/tests/README.md` for details. + ## Style - Target .NET 8 for the SDK. diff --git a/NUGET.md b/NUGET.md index 9bcf6f3..694aadf 100644 --- a/NUGET.md +++ b/NUGET.md @@ -1,13 +1,14 @@ -# iRacing Telemetry SDK for C# .NET +# iRacing SDK for C# .NET -High-performance .NET SDK for accessing **live telemetry data** from iRacing simulator and **IBT file playback**. Features compile-time code generation for strongly-typed telemetry access with lock-free performance optimizations. +High-performance .NET SDK for accessing **live telemetry data** from iRacing simulator, **IBT file playback**, and **simulator control**. Features compile-time code generation for strongly-typed telemetry access with lock-free performance optimizations, plus a command API for driving cameras, replay, pit service, and more. ## Why Use This SDK? - **Type Safety**: Enum-based telemetry variables with IntelliSense/Copilot support and compile-time validation - **High Performance**: Processes 600,000+ telemetry records/second with lock-free data streaming architecture - **Background Processing**: Dedicated threads for telemetry collection and processing - your app's processing speed never blocks the streaming telemetry data -- **Modern Async API**: Async data streams with async/await patterns and automatic backpressure handling +- **Modern Async API**: Async data streams with async/await patterns, bounded buffering, and automatic overload handling +- **Dynamic Variable Lookup**: Look up any telemetry variable by name at runtime with `GetValue(string)` - no compile-time struct required - **Live Telemetry**: Real-time access to speed, RPM, tire data, and 200+ variables during iRacing sessions - **IBT File Support**: Cross-platform playback of historical telemetry using the same strongly-typed API - **Robust Buffering**: 60-sample ring buffer (1 second at 60Hz) ensures reliable data delivery even when your app can't keep pace @@ -18,11 +19,11 @@ High-performance .NET SDK for accessing **live telemetry data** from iRacing sim Most .NET iRacing libraries are thin wrappers over the simulator's shared-memory layout — variables looked up by string name at runtime, data delivered through events on the caller's thread. This SDK treats telemetry as a **typed, high-throughput data stream** instead: - **Compile-time generated structs** instead of runtime string/dictionary lookups — typos are build errors, and only the variables you declare are ever decoded -- **Async data streams with bounded backpressure** instead of events on the caller's thread — your processing speed never stalls data collection -- **The same strongly-typed API for live and IBT data**, rather than separate code paths -- **Built-in metrics** (`System.Diagnostics.Metrics`) so you can see processing rates and dropped records in production +- **Non-blocking async data streams with bounded backpressure** instead of callbacks that block further frame processing until your handler returns — your processing speed never stalls data collection +- **The same strongly-typed API for live and IBT playback** — most alternatives are live-only, or treat file playback as a separate path +- **Built-in `System.Diagnostics.Metrics`** integration (rates, dropped records, histograms), so telemetry health plugs directly into your existing observability stack -Full comparison and design rationale: [Architecture and Design](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/ARCHITECTURE.md) +Full comparison and design rationale: [Architecture and Design](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/ARCHITECTURE.md) ## Architecture Benefits @@ -153,7 +154,7 @@ See the [SimControl sample](https://github.com/SVappsLAB/iRacingTelemetrySDK/tre - **[Getting Started Guide](https://github.com/SVappsLAB/iRacingTelemetrySDK#readme)** - Setup and basic usage - **[Sample Projects](https://github.com/SVappsLAB/iRacingTelemetrySDK/tree/main/Samples)** - Basic monitoring, data export, track analysis -- **[Architecture and Design](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/ARCHITECTURE.md)** - Threading model, buffering, performance, and metrics +- **[Architecture and Design](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/ARCHITECTURE.md)** - Threading model, buffering, performance, and metrics - **[Advanced Usage](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior - **[Migration Guide](https://github.com/SVappsLAB/iRacingTelemetrySDK/blob/main/docs/MIGRATION_GUIDE.md)** - Upgrading from previous versions - **[GitHub Repository](https://github.com/SVappsLAB/iRacingTelemetrySDK)** - Source code and releases diff --git a/README.md b/README.md index 7b74c83..eb08d53 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e - [Controlling the Simulator](#controlling-the-simulator) - [Samples](#samples) - [Advanced Usage](./docs/ADVANCED.md) -- [Architecture and Design](./ARCHITECTURE.md) +- [Architecture and Design](./docs/ARCHITECTURE.md) - [Documentation](#documentation) - [AI-Assisted Development](#ai-assisted-development) - [Building from Source](#building-from-source) @@ -31,45 +31,45 @@ Perfect for building **real-time dashboards**, **data analysis tools**, **race e - **Background Processing**: Dedicated threads for telemetry collection and processing - your app's processing speed never blocks the streaming telemetry data - **Live Telemetry**: Real-time access to 200+ variables including speed, RPM, tire data during iRacing sessions - **IBT File Playback**: Analyze historical telemetry using the same API as live data -- **Modern Async API**: Async data streams with automatic backpressure handling +- **Modern Async API**: Async data streams with bounded buffering and automatic overload handling +- **Dynamic Variable Lookup**: Look up any telemetry variable by name at runtime via `GetValue(string)` - useful when the variable set isn't known at compile time - **Built-in Metrics**: Integrated performance monitoring via System.Diagnostics.Metrics - **Pause and Resume**: Control data flow while background processing continues - **Sim Control**: Remotely control the simulator (pit commands, replay, cameras, chat, and more) ## How It Compares -There are several .NET libraries for reading iRacing telemetry. Most are thin wrappers over the iRacing shared-memory layout: you look variables up by string name at runtime, receive data through events on the caller's thread, and get whatever the raw SDK gives you. That works, and for simple tools it's enough. +There are several .NET libraries for reading iRacing telemetry. +Most are thin wrappers over the iRacing shared-memory layout: variables are done by string name at runtime, receive data through events on the caller's thread. -This SDK takes a different approach — it treats telemetry as a **typed, high-throughput data stream** rather than a bag of named values. +This SDK takes a different approach. It treats telemetry as a **typed, high-throughput data stream** rather than a bag of named values. | | **iRacingTelemetrySDK** | **Typical .NET iRacing libraries** | |---|---|---| | **Variable access** | Compile-time generated `TelemetryData` struct — only the variables you declare | Runtime lookup by string name or dictionary indexing | | **Type safety** | Enum-based selection, validated at build time, full IntelliSense | Strings resolved at runtime; typos surface as runtime errors or nulls | -| **API model** | Async data streams with `async`/`await` and automatic backpressure | Event handlers or manual polling loops | -| **Threading** | Dedicated background tasks for collection and YAML parsing — your handler never blocks the data source | Processing commonly runs on the caller's thread | -| **Backpressure** | Bounded 60-sample ring buffer with drop-oldest; slow consumers cost bounded data, never memory | Often unbounded or unspecified | +| **API model** | Async data streams with `async`/`await`, bounded buffering, and automatic overload handling | Blocking event handlers or manual polling loops | +| **Threading** | Dedicated background tasks for collection and YAML parsing — your handler never blocks the data source | Callbacks commonly block further frame processing until they return | +| **Backpressure** | Bounded 60-sample ring buffer with drop-oldest; slow consumers cost bounded data, never memory | Not supported | | **Live + IBT parity** | Identical strongly-typed API for both | Frequently separate code paths, or live-only | -| **Throughput** | 600,000+ records/sec on IBT playback | Rarely a stated design goal | -| **Observability** | Built-in `System.Diagnostics.Metrics` counters and histograms | Typically none | -| **Simulator control** | Pit, replay, camera, chat, and broadcast commands included | Varies; often read-only | +| **Throughput** | 600,000+ records/sec on IBT playback | Unknown | +| **Observability** | Built-in `System.Diagnostics.Metrics` counters and histograms | Not supported | +| **Simulator control** | Pit, replay, camera, chat, and broadcast commands included | Varies | | **AI agent support** | Dedicated agent-facing usage and reference docs | Rare | -Not every project needs this. If you want a handful of values on a secondary display, a simpler wrapper is fine. If you're building a **real-time dashboard, a race engineering tool, or an analysis pipeline that replays full sessions**, the differences above are the ones that show up. **Why it's fast:** - **Source-generated structs** eliminate runtime reflection and string lookups from the hot path — only the variables you asked for are ever decoded - **Lock-free bounded streams** keep telemetry current under load instead of queueing unboundedly behind a slow consumer -- **Zero-copy reads** via `ReadOnlySpan` over mapped memory, keeping steady-state allocation and GC pressure near zero +- **Minimal-allocation reads** — each sample is copied once into a reused buffer (guarding against iRacing overwriting it mid-read), then decoded field-by-field via `ReadOnlySpan` with no further allocations, keeping steady-state GC pressure near zero - **Independent background tasks** mean CPU-intensive session-info YAML parsing never stalls the 60Hz telemetry path -📖 Full details, including the threading model, buffering semantics, and built-in metrics: **[Architecture and Design](./ARCHITECTURE.md)** +Full details, including the threading model, buffering semantics, and built-in metrics: **[Architecture and Design](./docs/ARCHITECTURE.md)** ## Requirements - **.NET 8.0+** -- **Windows** for live iRacing telemetry ## Quick Example @@ -242,20 +242,51 @@ All telemetry properties are nullable (`float?`, `int?`, `bool?`) to accurately **Recommended patterns for handling null values:** ```csharp -// ✅ Null-conditional formatting +// Null-conditional formatting var speedDisplay = $"Speed: {data.Speed?.ToString("F1") ?? "N/A"}"; -// ✅ Direct arithmetic (preserves null semantics) +// Direct arithmetic (preserves null semantics) var speedMph = data.Speed * 2.23694f; // Result is null if Speed is null -// ✅ Explicit null handling +// Explicit null handling var speed = data.Speed ?? 0f; var hasValue = data.Speed.HasValue; -// ✅ Boolean checks +// Boolean checks if (data.IsOnTrackCar == true) { /* ... */ } ``` +### Dynamic Variable Lookup + +For scenarios where the telemetry variables you need aren't known until runtime, like a dashboard that lets end users pick which values to display, or an integration (such as a game engine) that prefers a string-keyed lookup over a compile-time generated struct - use `GetValue(string)` instead of (or alongside) the strongly-typed `T`: + +```csharp +// no RequiredTelemetryVars needed - use the DynamicTelemetryData placeholder +// GetValue requires Synchronous delivery mode - see below +var clientOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }; +await using var client = TelemetryClient.Create(logger, ibtOptions: null, clientOptions); + +var handlers = new TelemetryHandlers +{ + OnTelemetryUpdate = _ => + { + if (client.GetValue("Speed") is float speed) + { + Console.WriteLine($"Speed: {speed}"); + } + return Task.CompletedTask; + } +}; + +await client.Monitor(handlers, cts.Token); +``` + +`GetValue` matches names case-insensitively and works for any variable reported by `GetTelemetryVariables()`. It returns an 'object' or a 'null' if the variable is unknown. + +The SDK only works in one delivery mode at a time, you can't create the client in one mode and read data in the other. `GetValue` is only usable when the client is created with `new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }`. + +Note: in `Synchronous` mode, each sample is blocking and awaited directly by your handler before the next record is read. + ## Controlling the Simulator In addition to reading telemetry, the SDK can send commands *to* the simulator. Commands are grouped by feature @@ -294,7 +325,7 @@ See [Samples Directory](./Samples/README.md) for ready-to-run example projects i ## Documentation -- **[Architecture and Design](./ARCHITECTURE.md)** - Threading model, data streaming and buffering, performance characteristics, and built-in metrics +- **[Architecture and Design](./docs/ARCHITECTURE.md)** - Threading model, data streaming and buffering, performance characteristics, and built-in metrics - **[Advanced Usage](./docs/ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior - **[Migration Guide](./docs/MIGRATION_GUIDE.md)** - Upgrading from early pre-1.0 releases @@ -334,7 +365,7 @@ Tests are split into categories so you can run subsets based on your environment ```bash # unit tests only -dotnet test .\Sdk\tests\UnitTests\UnitTests.csproj +dotnet test --project .\Sdk\tests\UnitTests\UnitTests.csproj # repeatable offline smoke tests using bundled IBT files dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait Category=ibt @@ -343,19 +374,18 @@ dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait Category=live # all test projects, including tests that may require live/manual setup -dotnet test .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx +dotnet test --solution .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx ``` - See [Sdk/tests/README.md](./Sdk/tests/README.md) for manual test commands and filtering notes. ### Running the samples ```bash # live iRacing data -dotnet run --project .\Samples\MinimalExample\MinimalExample.csproj +dotnet run --project .\Samples\MinimalExampleAsync\MinimalExampleAsync.csproj # IBT file playback -dotnet run --project .\Samples\MinimalExample\MinimalExample.csproj -- path\to\file.ibt +dotnet run --project .\Samples\MinimalExampleAsync\MinimalExampleAsync.csproj -- path\to\file.ibt ``` See the [Samples](./Samples/README.md) directory for the individual example projects. diff --git a/Samples/MinimalExample/MinimalExample.csproj b/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj similarity index 100% rename from Samples/MinimalExample/MinimalExample.csproj rename to Samples/MinimalExampleAsync/MinimalExampleAsync.csproj diff --git a/Samples/MinimalExample/Program.cs b/Samples/MinimalExampleAsync/Program.cs similarity index 88% rename from Samples/MinimalExample/Program.cs rename to Samples/MinimalExampleAsync/Program.cs index 4bc5f5b..2660b37 100644 --- a/Samples/MinimalExample/Program.cs +++ b/Samples/MinimalExampleAsync/Program.cs @@ -1,5 +1,5 @@ /** - * Copyright (C) 2024-2025 Scott Velez + * Copyright (C) 2024-2026 Scott Velez * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ using Microsoft.Extensions.Logging; using SVappsLAB.iRacingTelemetrySDK; -namespace MinimalExample; +namespace MinimalExampleAsync; // 1. Define the telemetry variables you want to track [RequiredTelemetryVars([TelemetryVar.Speed, TelemetryVar.RPM])] internal class Program @@ -26,14 +26,15 @@ public static async Task Main(string[] args) { // 2. Create logger var logger = LoggerFactory.Create(builder => builder.AddConsole()) - .CreateLogger("MinimalExample"); + .CreateLogger("MinimalExampleAsync"); // 3. Choose data source IBTOptions? ibtOptions = null; // null for live telemetry from iRacing // = new IBTOptions("gt3_spa.ibt"); IBT filepath for file playback ibtOptions = new IBTOptions(args[0]); - // 4. Create telemetry client + // 4. Create telemetry client - using the default high throughput Async delivery mode. + // This mode is best for applications that know the telemetry variables they want to track at compile time. await using var client = TelemetryClient.Create(logger, ibtOptions); // 5. Use cancellation token for proper shutdown diff --git a/Samples/MinimalExampleAsync/Properties/launchSettings.json b/Samples/MinimalExampleAsync/Properties/launchSettings.json new file mode 100644 index 0000000..d610fc3 --- /dev/null +++ b/Samples/MinimalExampleAsync/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "MinimalExampleAsync": { + "commandName": "Project", + "commandLineArgs": "\"..\\..\\..\\..\\data\\formulair04_tsukuba 2kfull 2024-01-09 17-26-10.ibt\"" + } + } +} \ No newline at end of file diff --git a/Samples/MinimalExampleSync/MinimalExampleSync.csproj b/Samples/MinimalExampleSync/MinimalExampleSync.csproj new file mode 100644 index 0000000..16850ca --- /dev/null +++ b/Samples/MinimalExampleSync/MinimalExampleSync.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + diff --git a/Samples/MinimalExampleSync/Program.cs b/Samples/MinimalExampleSync/Program.cs new file mode 100644 index 0000000..5ff1a9c --- /dev/null +++ b/Samples/MinimalExampleSync/Program.cs @@ -0,0 +1,82 @@ +/** + * Copyright (C) 2024-2026 Scott Velez + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +using Microsoft.Extensions.Logging; +using SVappsLAB.iRacingTelemetrySDK; + +namespace MinimalExampleSync; + +internal class Program +{ + public static async Task Main(string[] args) + { + // 1. Create logger + var logger = LoggerFactory.Create(builder => builder.AddConsole()) + .CreateLogger("MinimalExampleSync"); + + // 2. Choose data source + IBTOptions? ibtOptions = null; // null for live telemetry from iRacing + // = new IBTOptions("gt3_spa.ibt"); IBT filepath for file playback + ibtOptions = new IBTOptions(args[0]); + + // 3. Create telemetry client in Synchronous delivery mode. + // Sync mode is slower than the default Async mode, but it can be used if the telemetry variables + // are not known ahead of time. Such as applications that allow the user to select which + // telemetry variables to display at runtime. + // + // See MinimalExampleAsync for the default, higher-throughput mode. + var clientOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }; + await using var client = TelemetryClient.Create(logger, ibtOptions, clientOptions); + + // 4. Use cancellation token for proper shutdown + using var cts = new CancellationTokenSource(); + + // 5. Enable graceful shutdown with Ctrl+C + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + // 6. Define handlers + var handlers = new TelemetryHandlers + { + OnTelemetryUpdate = _ => + { + // 8. look up telemetry dynamically by name, instead of strongly-typed properties + var speed = client.GetValue("Speed"); + var rpm = client.GetValue("RPM"); + Console.WriteLine($"Speed: {speed}, RPM: {rpm}"); + return Task.CompletedTask; + }, + OnSessionInfoUpdate = session => + { + var driverCount = session.DriverInfo?.Drivers?.Count ?? 0; + Console.WriteLine($"Drivers: {driverCount}"); + return Task.CompletedTask; + }, + OnConnectStateChanged = state => + { + Console.WriteLine($"Connection: {state}"); + return Task.CompletedTask; + }, + OnError = error => + { + Console.WriteLine($"Error: {error.Message}"); + return Task.CompletedTask; + } + }; + + // 9. Monitor telemetry data stream until cancellation + await client.Monitor(handlers, cts.Token); + } +} diff --git a/Samples/MinimalExample/Properties/launchSettings.json b/Samples/MinimalExampleSync/Properties/launchSettings.json similarity index 83% rename from Samples/MinimalExample/Properties/launchSettings.json rename to Samples/MinimalExampleSync/Properties/launchSettings.json index 267b794..008d904 100644 --- a/Samples/MinimalExample/Properties/launchSettings.json +++ b/Samples/MinimalExampleSync/Properties/launchSettings.json @@ -1,8 +1,8 @@ -{ - "profiles": { - "MinimalExample": { - "commandName": "Project", - "commandLineArgs": "\"..\\..\\..\\..\\data\\formulair04_tsukuba 2kfull 2024-01-09 17-26-10.ibt\"" - } - } -} \ No newline at end of file +{ + "profiles": { + "MinimalExampleSync": { + "commandName": "Project", + "commandLineArgs": "\"..\\..\\..\\..\\data\\formulair04_tsukuba 2kfull 2024-01-09 17-26-10.ibt\"" + } + } +} diff --git a/Samples/README.md b/Samples/README.md index 07611ab..1451a4c 100644 --- a/Samples/README.md +++ b/Samples/README.md @@ -6,9 +6,14 @@ All of these examples can be used with a running 'live' instance of iRacing, or All samples define a `TelemetryHandlers` variable and pass it to `Monitor(handlers, ct)` for telemetry consumption. For setup and basic usage see the [main README](../README.md); for direct stream access and other advanced patterns see [Advanced Usage](../docs/ADVANCED.md). -* [MinimalExample](./MinimalExample/) +* [MinimalExampleAsync](./MinimalExampleAsync/) - The smallest end-to-end example. A good starting point that shows how to create a client and handle telemetry, session info, connection state, and error callbacks via `Monitor(handlers, ct)`. + A small end-to-end example. A good starting point that shows how to create a client and handle telemetry, session info, connection state, and error callbacks via `Monitor(handlers, ct)`. Uses the default `TelemetryDeliveryMode.Async` and the strongly-typed `TelemetryData` struct for strong static typing and maximum efficency and performance. + +* [MinimalExampleSync](./MinimalExampleSync/) + + Same shape as MinimalExampleAsync, but looks up telemetry variables dynamically with `GetValue("")` instead of a strongly-typed property. + Synchronous mode allows you to query for any variable, at runtime, at the cost of throughput. See [SDK_REFERENCE.md](../docs/ai/SDK_REFERENCE.md#telemetry-delivery-mode) for details on the tradeoffs. * [DumpVariables_DumpSessionInfo](./DumpVariables_DumpSessionInfo/) diff --git a/Samples/Samples.slnx b/Samples/Samples.slnx index f41b7a2..6795240 100644 --- a/Samples/Samples.slnx +++ b/Samples/Samples.slnx @@ -7,7 +7,8 @@ - + + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx b/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx index f27b5d7..3890314 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx @@ -10,6 +10,9 @@ + + + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs index 013bd54..ff7cb09 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/DataProviderBase.cs @@ -35,7 +35,7 @@ internal enum irsdk_StatusField internal class VarHeaderDictionary : Dictionary { - public VarHeaderDictionary() : base(StringComparer.InvariantCultureIgnoreCase) + public VarHeaderDictionary() : base(StringComparer.OrdinalIgnoreCase) { } } @@ -198,7 +198,15 @@ public string GetSessionInfoYaml() public object? GetVarValue(string varName) { - if (!_varHeaders!.TryGetValue(varName, out irsdk_varHeader vh)) + // headers/buffer are populated lazily by GetHeader() once the provider has read at least one + // header from the data source + if (_varHeaders == null || _telemetryDataBuffer == null) + { + _logger.LogDebug("Telemetry data not yet available; ignoring lookup for [{varName}]", varName); + return null; + } + + if (!_varHeaders.TryGetValue(varName, out irsdk_varHeader vh)) { _logger.LogDebug("Telemetry variable [{varName}] not found in data provider", varName); return null; @@ -252,6 +260,20 @@ public string GetSessionInfoYaml() return val; } + // maps irsdk_VarType -> CLR Type mapping + internal static Type GetClrType(irsdk_VarType type, int count) => type switch + { + // a single char returns the raw byte; a char buffer decodes to one string (a text field, not + // an array of separate strings) + irsdk_VarType.irsdk_char => count > 1 ? typeof(string) : typeof(byte), + irsdk_VarType.irsdk_bool => count > 1 ? typeof(bool[]) : typeof(bool), + // bitField is packed into the same int/int[] path as irsdk_int above + irsdk_VarType.irsdk_int or irsdk_VarType.irsdk_bitField => count > 1 ? typeof(int[]) : typeof(int), + irsdk_VarType.irsdk_float => count > 1 ? typeof(float[]) : typeof(float), + irsdk_VarType.irsdk_double => count > 1 ? typeof(double[]) : typeof(double), + _ => throw new NotImplementedException($"{type} not implemented") + }; + // wait for iRacing to signal there is new data public abstract Task WaitForDataReady(TimeSpan timeSpan, CancellationToken cancellationToken = default); diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/ITelemetryClient.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/ITelemetryClient.cs index 08b7b31..af8336b 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/ITelemetryClient.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/ITelemetryClient.cs @@ -14,10 +14,6 @@ * limitations under the License. **/ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using SVappsLAB.iRacingTelemetrySDK.SimControl; namespace SVappsLAB.iRacingTelemetrySDK; @@ -161,6 +157,12 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// and may cause undefined behavior. For callback-based consumption, use the /// overload. /// + /// + /// Not available in Synchronous delivery mode: when the client is created with + /// set to , samples + /// are delivered directly to instead of being queued on + /// this stream. Accessing this property in that mode throws . + /// /// /// /// @@ -178,18 +180,61 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// /// A read-only list of telemetry variables. Returns an empty list if called before /// the data provider has been initialized (typically during or after the first - /// Monitor() call in live mode, or immediately after construction for IBT mode). + /// Monitor() call, for both live and IBT mode). /// /// /// Initialization Timing: In live mode, variable headers become /// available only after iRacing is running and the first data update occurs. In IBT mode, - /// they're available immediately after construction. + /// they become available once Monitor() opens the file /// Thread Safety: This method is thread-safe and can be called /// concurrently with Monitor(), though results may vary depending on initialization state. /// /// Thrown if the client has been disposed. IReadOnlyList GetTelemetryVariables(); + /// + /// Gets the current value of a telemetry variable by name. + /// + /// + /// The iRacing telemetry variable name (e.g. "Speed", "RPM", "CarIdxTrackSurface"). Names are matched + /// case-insensitively and are not limited to the variables declared on - any + /// variable reported by can be looked up this way. + /// + /// + /// The current value, boxed as a scalar (e.g. a ) or an array (e.g. float[]) + /// depending on the variable, or if is not a known + /// variable or telemetry data is not yet available. + /// + /// + /// + /// Use this for scenarios where the set of variables is only known at runtime - for example, a dashboard + /// that lets end users pick which telemetry values to display, or an integration (such as a game engine) + /// that prefers a dynamic, string-keyed lookup over a compile-time generated struct. See + /// for a placeholder that avoids the need for + /// a RequiredTelemetryVars-annotated struct entirely. + /// + /// + /// Prefer the strongly-typed for variables known at compile time - it is + /// allocation-free. + /// + /// + /// Only usable in mode. The SDK operates + /// in exactly one of two modes, chosen when the client is created via , + /// and does not allow mixing them: in the default mode, production + /// and consumption run on independent tasks, so there would be no way to guarantee this method reflects the + /// same record as the sample passed to + /// - calling it in that mode throws + /// . In mode, this + /// method is guaranteed to reflect exactly the sample passed to the currently-running handler, because the + /// next record isn't read until the handler returns. + /// + /// + /// Thrown if the client has been disposed. + /// + /// Thrown if the client was created with set to + /// . + /// + object? GetValue(string varName); /// /// Monitors the telemetry data and writes data to streams when new data is available. @@ -208,7 +253,14 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// Concurrent Calls: Calling Monitor() while already running will throw /// InvalidOperationException. Ensure the previous Monitor() call has completed before starting /// a new client instance. + /// Synchronous delivery mode: this overload supplies no + /// handler, so it is incompatible with + /// - use + /// instead. /// + /// + /// Thrown immediately if the client was created with . + /// Task Monitor(CancellationToken ct); /// @@ -227,14 +279,24 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// stops monitoring and returns normally after handlers finish. /// /// - /// Handler exceptions fault this method directly. SDK processing errors are delivered to - /// when that handler is provided. + /// Handler exceptions fault this method directly - this holds for both + /// and . SDK + /// processing errors are delivered to when that handler is + /// provided; handler exceptions are never routed there. /// /// /// Handlers are awaited sequentially per stream. Keep handlers fast, especially telemetry /// handlers that may run at 60Hz. /// + /// + /// Synchronous delivery mode: requires + /// to be set; otherwise telemetry samples would be silently discarded. + /// /// + /// + /// Thrown immediately if the client was created with and + /// does not set . + /// Task Monitor(TelemetryHandlers handlers, CancellationToken ct); /// @@ -252,11 +314,23 @@ public interface ITelemetryClient : IAsyncDisposable where T : struct /// /// Pauses stream data writing. Processing continues, but stream writes are suppressed. /// + /// + /// Thread Safety: This method is thread-safe and can be called from any thread. + /// Idempotency: Safe to call multiple times. Calling Pause() when already paused has no effect. + /// Eventual Consistency: Changes are not immediate. A few telemetry samples may be written + /// to streams before the pause takes effect + /// void Pause(); /// /// Resumes stream data writing. /// + /// + /// Thread Safety: This method is thread-safe and can be called from any thread. + /// Idempotency: Safe to call multiple times. Calling Resume() when not paused has no effect. + /// Eventual Consistency: Changes are not immediate. A few telemetry samples may be suppressed + /// before the resume takes effect. + /// void Resume(); /// diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs index e235079..775c8ff 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs @@ -14,22 +14,17 @@ * limitations under the License. **/ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.Metrics; -using System.IO; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using SVappsLAB.iRacingTelemetrySDK.DataProviders; -using SVappsLAB.iRacingTelemetrySDK.irSDKDefines; using SVappsLAB.iRacingTelemetrySDK.Metrics; using SVappsLAB.iRacingTelemetrySDK.SimControl; using SVappsLAB.iRacingTelemetrySDK.YamlParsing; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Threading.Channels; namespace SVappsLAB.iRacingTelemetrySDK; @@ -55,6 +50,35 @@ public record class TelemetryVariable // unit. something like "kg/m^2" public string Units { get; init; } = default!; } + +/// +/// Placeholder telemetry data type for use with when you only need +/// dynamic, string-based variable lookup () +/// and don't want to declare a RequiredTelemetryVars-annotated struct. +/// +/// +/// Useful when embedding the SDK somewhere the Roslyn source generator is inconvenient to use (e.g. some +/// game engine build pipelines), or when building a fully dynamic dashboard where the set of telemetry +/// values is chosen by the end user at runtime rather than fixed at compile time. +/// +/// await using var client = TelemetryClient<DynamicTelemetryData>.Create(logger); +/// await client.Monitor(new TelemetryHandlers<DynamicTelemetryData> +/// { +/// OnTelemetryUpdate = _ => +/// { +/// if (client.GetValue("Speed") is float speed) +/// { +/// // use speed +/// } +/// return Task.CompletedTask; +/// } +/// }, cancellationToken); +/// +/// +public readonly struct DynamicTelemetryData +{ +} + /** * Options for IBT file processing. * @@ -82,11 +106,44 @@ public IBTOptions(string ibtFilePath, int playBackSpeedMultiplier = int.MaxValue } } +/// +/// Controls how telemetry samples are handed off from the background processing loop to +/// , and what that means for +/// . +/// +public enum TelemetryDeliveryMode +{ + /// + /// This is the highly preferred default, high-throughput mode. + /// + Async, + + /// + /// This is the slower, blocking, optional sychronous mode. + /// Only supported via + /// with + /// set - both Monitor(CancellationToken) + /// and a handlers object without OnTelemetryUpdate throw + /// immediately, since telemetry would otherwise be silently discarded. The + /// async-enumerable stream is not populated in this mode. + /// + Synchronous +} + /// /// Configuration options that control various aspects of telemetry processing. /// public class ClientOptions { + /// + /// Controls how telemetry samples are delivered to handlers, and whether + /// is usable at all. Defaults to + /// , in which GetValue throws + /// - use if you + /// need GetValue. + /// + public TelemetryDeliveryMode DeliveryMode { get; init; } = TelemetryDeliveryMode.Async; + /// /// Optional factory for creating metrics to monitor telemetry processing performance. /// Tracks processing duration and record counts. @@ -136,8 +193,17 @@ public class TelemetryClient : ITelemetryClient, IAsyncDisposable where T // telemetry variables caching private IReadOnlyList? _cachedTelemetryVariables; + private VarHeaderDictionary? _cachedTelemetryVariablesSourceHeaders; private readonly object _telemetryVariablesLock = new object(); + private readonly TelemetryDeliveryMode _deliveryMode; + // set only when _deliveryMode == Synchronous; invoked inline by the producer loop + private Func? _syncTelemetryHandler; + + // internal usage only. thrown when _syncTelemetryHandler itself throws. used to distguish + // between handler fault and provider error + private sealed class SyncHandlerFaultException(Exception innerException) : Exception(innerException.Message, innerException); + /// public IAsyncEnumerable ConnectStates => GetConnectStatesEnumerable(); @@ -151,7 +217,12 @@ public class TelemetryClient : ITelemetryClient, IAsyncDisposable where T public IAsyncEnumerable SessionData => GetSessionDataEnumerable(); /// - public IAsyncEnumerable TelemetryData => GetTelemetryDataEnumerable(); + public IAsyncEnumerable TelemetryData => + _deliveryMode == TelemetryDeliveryMode.Synchronous + ? throw new InvalidOperationException( + $"{nameof(TelemetryData)} is not available when {nameof(ClientOptions.DeliveryMode)} is " + + $"{nameof(TelemetryDeliveryMode.Synchronous)} - use {nameof(Monitor)}({nameof(TelemetryHandlers)}, CancellationToken) instead.") + : GetTelemetryDataEnumerable(); public bool IsPaused => _isPaused; @@ -238,6 +309,7 @@ private TelemetryClient(ILogger logger, ClientOptions? clientOptions = null, IBT _logger = logger; _ibtOptions = ibtOptions; + _deliveryMode = clientOptions?.DeliveryMode ?? TelemetryDeliveryMode.Async; // initialize bounded channels var boundedChannelOptions = new BoundedChannelOptions(CHANNEL_SIZE) @@ -272,24 +344,17 @@ private TelemetryClient(ILogger logger, ClientOptions? clientOptions = null, IBT _simControl = new Lazy(() => new SimController(_logger, () => IsConnected)); } - /// - /// starts monitoring telemetry data from either live iRacing or IBT file playback. - /// this method can only be called once per TelemetryClient instance. - /// - /// cancellation token to stop monitoring - /// number of telemetry records processed - /// thrown if Monitor() is called while already running - /// thrown if the client has been disposed - /// - /// IMPORTANT: This method can only be called ONCE per TelemetryClient instance. - /// After Monitor() completes (via cancellation or IBT file EOF), the client cannot be restarted. - /// Create a new client instance for subsequent monitoring sessions. - /// Concurrent Calls: Calling Monitor() while already running will throw - /// InvalidOperationException. Ensure the previous Monitor() call has completed before starting - /// a new client instance. - /// + /// public async Task Monitor(CancellationToken ct) { + if (_deliveryMode == TelemetryDeliveryMode.Synchronous) + { + throw new InvalidOperationException( + $"{nameof(TelemetryDeliveryMode)}.{nameof(TelemetryDeliveryMode.Synchronous)} requires an " + + $"{nameof(TelemetryHandlers.OnTelemetryUpdate)} handler - use " + + $"{nameof(Monitor)}({nameof(TelemetryHandlers)}, CancellationToken) instead."); + } + BeginMonitor(); return await RunMonitor(ct).ConfigureAwait(false); } @@ -299,6 +364,13 @@ public async Task Monitor(TelemetryHandlers handlers, CancellationToken { ArgumentNullException.ThrowIfNull(handlers); + if (_deliveryMode == TelemetryDeliveryMode.Synchronous && handlers.OnTelemetryUpdate == null) + { + throw new InvalidOperationException( + $"{nameof(TelemetryDeliveryMode)}.{nameof(TelemetryDeliveryMode.Synchronous)} requires " + + $"{nameof(TelemetryHandlers.OnTelemetryUpdate)} to be set."); + } + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); BeginMonitor(); @@ -370,9 +442,14 @@ private async Task RunMonitor(CancellationToken ct) } catch (Exception ex) { - _logger.LogError(ex, "Error in processing tasks"); - monitorException = ex; - throw; + // unwrap a synchronous-handler fault so Monitor() sees the original + var toThrow = ex is SyncHandlerFaultException fault ? fault.InnerException! : ex; + + _logger.LogError(toThrow, "Error in processing tasks"); + monitorException = toThrow; + + ExceptionDispatchInfo.Capture(toThrow).Throw(); + throw; // unreachable - Throw() always throws, but the compiler can't tell } } finally @@ -388,7 +465,13 @@ private Task SubscribeToHandlers(TelemetryHandlers handlers, CancellationToke var tasks = new List(); if (handlers.OnTelemetryUpdate != null) - tasks.Add(RunHandlerLoop(HandleTelemetryData(handlers.OnTelemetryUpdate), faultCts)); + { + if (_deliveryMode == TelemetryDeliveryMode.Synchronous) + // delivered inline by the producer loop instead - see DeliverTelemetryData + _syncTelemetryHandler = handlers.OnTelemetryUpdate; + else + tasks.Add(RunHandlerLoop(HandleTelemetryData(handlers.OnTelemetryUpdate), faultCts)); + } if (handlers.OnSessionInfoUpdate != null) tasks.Add(RunHandlerLoop(HandleSessionData(handlers.OnSessionInfoUpdate), faultCts)); @@ -509,20 +592,36 @@ private async Task StartDataProcessorTask(CancellationToken ct) public IReadOnlyList GetTelemetryVariables() { - // fast path - already cached - if (_cachedTelemetryVariables != null) + ObjectDisposedException.ThrowIf(_disposed, this); + + // the provider re-reads and reassigns its header dictionary whenever the var-buffer layout + // changes (see DataProviderBase.GetHeader()), so reference identity doubles as a cheap version + // check - a different instance means the cache is stale and must be rebuilt + var varHeaders = _dataProvider.GetVarHeaders(); + + // fast path - already cached against the current header set + if (_cachedTelemetryVariables != null && ReferenceEquals(_cachedTelemetryVariablesSourceHeaders, varHeaders)) return _cachedTelemetryVariables; + if (varHeaders == null) + { + // not yet initialized (e.g. live mode before iRacing connects) - nothing to cache, so a + // later call once headers are available isn't stuck returning this empty list forever + _logger.LogDebug("Telemetry variable headers not available yet"); + return Array.Empty(); + } + lock (_telemetryVariablesLock) { // double-check pattern - if (_cachedTelemetryVariables != null) + if (_cachedTelemetryVariables != null && ReferenceEquals(_cachedTelemetryVariablesSourceHeaders, varHeaders)) return _cachedTelemetryVariables; try { - IReadOnlyList list = BuildTelemetryVariablesList(); + IReadOnlyList list = BuildTelemetryVariablesList(varHeaders); _cachedTelemetryVariables = list; + _cachedTelemetryVariablesSourceHeaders = varHeaders; return list; } catch (ObjectDisposedException) @@ -537,33 +636,33 @@ public IReadOnlyList GetTelemetryVariables() } } - private unsafe IReadOnlyList BuildTelemetryVariablesList() + /// + public object? GetValue(string varName) { - var list = new List(); - var varHeaders = _dataProvider.GetVarHeaders(); + ObjectDisposedException.ThrowIf(_disposed, this); - if (varHeaders == null) + if (_deliveryMode == TelemetryDeliveryMode.Async) { - _logger.LogWarning("Variable headers are null, returning empty list"); - return list; + throw new InvalidOperationException( + $"{nameof(GetValue)} is not available when {nameof(ClientOptions.DeliveryMode)} is " + + $"{nameof(TelemetryDeliveryMode.Async)} - create the client with " + + $"{nameof(TelemetryDeliveryMode.Synchronous)} delivery mode instead."); } + return _dataProvider.GetVarValue(varName); + } + + private unsafe IReadOnlyList BuildTelemetryVariablesList(VarHeaderDictionary varHeaders) + { + var list = new List(); + foreach (var vh in varHeaders.Values) { try { var tVar = new TelemetryVariable { - Type = vh.type switch - { - irsdk_VarType.irsdk_char => vh.count > 1 ? typeof(string[]) : typeof(string), - irsdk_VarType.irsdk_bool => vh.count > 1 ? typeof(bool[]) : typeof(bool), - irsdk_VarType.irsdk_int => vh.count > 1 ? typeof(int[]) : typeof(int), - irsdk_VarType.irsdk_bitField => vh.count > 1 ? typeof(uint[]) : typeof(uint), - irsdk_VarType.irsdk_float => vh.count > 1 ? typeof(float[]) : typeof(float), - irsdk_VarType.irsdk_double => vh.count > 1 ? typeof(double[]) : typeof(double), - _ => throw new NotImplementedException($"{vh.type} not implemented") - }, + Type = DataProviderBase.GetClrType(vh.type, vh.count), Length = vh.count, IsTimeValue = vh.countAsTime, @@ -583,16 +682,7 @@ private unsafe IReadOnlyList BuildTelemetryVariablesList() return list; } - /// - /// Gets a value indicating whether the client is connected to the telemetry source. - /// - /// - /// true if connected to iRacing (live mode) or an IBT file is open; otherwise false. - /// - /// - /// This property is safe to call from any thread and will return false if the client - /// has been disposed or is not yet initialized. - /// + /// public bool IsConnected { get @@ -617,30 +707,14 @@ public bool IsConnected } } - /// - /// Pauses stream data writing. Processing continues, but stream writes are suppressed. - /// - /// - /// Thread Safety: This method is thread-safe and can be called from any thread. - /// Idempotency: Safe to call multiple times. Calling Pause() when already paused has no effect. - /// Eventual Consistency: Changes are not immediate. A few telemetry samples may be written - /// to streams before the pause takes effect (typically 1-2 samples, ~16-32ms at 60Hz). - /// + /// public void Pause() { _isPaused = true; _logger.LogDebug("Telemetry client paused"); } - /// - /// Resumes stream data writing. - /// - /// - /// Thread Safety: This method is thread-safe and can be called from any thread. - /// Idempotency: Safe to call multiple times. Calling Resume() when not paused has no effect. - /// Eventual Consistency: Changes are not immediate. A few telemetry samples may be suppressed - /// before the resume takes effect (typically 1-2 samples, ~16-32ms at 60Hz). - /// + /// public void Resume() { _isPaused = false; @@ -714,8 +788,17 @@ private async Task Shutdown() } catch (Exception ex) { - _logger.LogError(ex, "Error shutting down main processing task"); - shutdownException ??= ex; + if (ex is SyncHandlerFaultException fault) + { + // in Synchronous mode, a handler fault lives on _dataProcessingTask itself and was + // already thrown so don't surface it a second time here. + _logger.LogDebug(fault.InnerException, "Synchronous telemetry handler fault already reported by Monitor()"); + } + else + { + _logger.LogError(ex, "Error shutting down main processing task"); + shutdownException ??= ex; + } } CompleteAllChannels(); @@ -856,6 +939,13 @@ private async Task ProcessLiveData(CancellationToken ct) _logger.LogDebug("Live data monitoring cancelled after {numUpdates} updates", numUpdates); return numUpdates; } + catch (SyncHandlerFaultException) + { + // synchronous telemetry handler threw - fault Monitor() directly, same as Async + // mode, instead of treating it as a retryable SDK/provider error + _logger.LogDebug("Synchronous telemetry handler threw after {numUpdates} updates; faulting monitoring", numUpdates); + throw; + } catch (Exception e) { _logger.LogError(e, "Error processing live data"); @@ -931,9 +1021,7 @@ private async Task WaitForData(CancellationToken ct) // suppress events if paused or disposed if (!IsPaused) { - // write to channel - var telemetryData = GetTelemetryDataSample(); - _telemetryDataChannel.Writer.TryWrite(telemetryData); + await DeliverTelemetryData(GetTelemetryDataSample()).ConfigureAwait(false); } return true; // data processed @@ -947,6 +1035,34 @@ private T GetTelemetryDataSample() T val = _telemetryAccessor.CreateTelemetryDataSample(_dataProvider); return val; } + + // keep this method synchronous to avoid building an async + private Task DeliverTelemetryData(T telemetryData) + { + if (_deliveryMode == TelemetryDeliveryMode.Synchronous) + { + return _syncTelemetryHandler != null + ? InvokeSyncTelemetryHandler(telemetryData) + : Task.CompletedTask; + } + + _telemetryDataChannel.Writer.TryWrite(telemetryData); + return Task.CompletedTask; + } + + private async Task InvokeSyncTelemetryHandler(T telemetryData) + { + try + { + await _syncTelemetryHandler!(telemetryData).ConfigureAwait(false); + } + catch (Exception ex) + { + // this is the caller's handler exception, not an SDK/provider error - wrap it so + // catch blocks fault Monitor() instead of treating it as a retryable error routed to OnError + throw new SyncHandlerFaultException(ex); + } + } private async Task ProcessIbtData(CancellationToken token) { int numRecords = 0; @@ -990,9 +1106,7 @@ private async Task ProcessIbtData(CancellationToken token) // suppress events if paused or disposed if (!IsPaused) { - // write to channel - var telemetryData = GetTelemetryDataSample(); - _telemetryDataChannel.Writer.TryWrite(telemetryData); + await DeliverTelemetryData(GetTelemetryDataSample()).ConfigureAwait(false); } // Apply playback speed delay if configured @@ -1019,6 +1133,11 @@ private async Task ProcessIbtData(CancellationToken token) _logger.LogDebug("IBT data processing cancelled after {numRecords} records", numRecords); return numRecords; } + catch (SyncHandlerFaultException) + { + _logger.LogDebug("Synchronous telemetry handler threw after {numRecords} records; faulting monitoring", numRecords); + throw; + } catch (Exception e) { _logger.LogError(e, "Error playing IBT file after {numRecords} records", numRecords); diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryHandlers.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryHandlers.cs index 57afca2..37894bf 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryHandlers.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryHandlers.cs @@ -14,9 +14,6 @@ * limitations under the License. **/ -using System; -using System.Threading.Tasks; - namespace SVappsLAB.iRacingTelemetrySDK; /// diff --git a/Sdk/tests/README.md b/Sdk/tests/README.md index 400e6a8..c5b430c 100644 --- a/Sdk/tests/README.md +++ b/Sdk/tests/README.md @@ -5,7 +5,7 @@ Unit tests cover individual classes and functions without requiring iRacing or live telemetry. ```powershell -dotnet test .\Sdk\tests\UnitTests\UnitTests.csproj +dotnet test --project .\Sdk\tests\UnitTests\UnitTests.csproj ``` ## Offline IBT Smoke Tests @@ -38,7 +38,19 @@ dotnet run --project .\Sdk\tests\UnitTests\UnitTests.csproj -- --filter-trait Ca The full solution test run may include tests that require live iRacing data or local manual-test setup. ```powershell -dotnet test .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx +dotnet test --solution .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx ``` -Do not rely on `dotnet test --filter unit`, `--filter ibt`, or `--filter live` with the current Microsoft.Testing.Platform setup; those VSTest-style filters are ignored. Use `--filter-trait Category=...` with `dotnet run --project` for filtered MTP test runs. +This repo pins `test.runner` to `Microsoft.Testing.Platform` in `global.json` (required on the .NET 10 SDK, +where `dotnet test`'s legacy VSTest bridge no longer supports MTP-based projects like these xUnit v3 ones). +Two consequences: + +- `dotnet test` requires `--project ` or `--solution ` - a bare positional path is rejected. +- `--filter unit` / `--filter ibt` / `--filter live` (VSTest-style filters) are not supported at all; use + `--filter-trait Category=...` instead (append it after `--`, e.g. `dotnet test --project X.csproj -- + --filter-trait Category=unit`). + +For `SmokeTests` specifically, prefer `dotnet run --project` over `dotnet test` as shown above: +`SmokeTests`' `Properties/launchSettings.json` supplies a default `--filter-class` argument that `dotnet +test` combines with (rather than overrides with) any `--filter-trait` you pass on the command line, which +silently produces "zero tests ran" instead of an error. `dotnet run --project` does not have this problem. diff --git a/Sdk/tests/SmokeTests/Base/Base.DynamicLookup.cs b/Sdk/tests/SmokeTests/Base/Base.DynamicLookup.cs new file mode 100644 index 0000000..5a66c0b --- /dev/null +++ b/Sdk/tests/SmokeTests/Base/Base.DynamicLookup.cs @@ -0,0 +1,190 @@ +/** + * Copyright (C) 2024-2026 Scott Velez + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +**/ + +using SVappsLAB.iRacingTelemetrySDK; + +namespace SmokeTests; + +public abstract partial class Base where T : class +{ + /// + /// verifies the string-based dynamic lookup (GetValue) surface. Requires . + /// + protected async Task BaseVerifyDynamicLookupShape(ITelemetryClient client, int timeoutSecs = 5) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs)); + + var sampleChecked = false; + + await client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = telemetryData => + { + // only need one sample + if (sampleChecked) + return Task.CompletedTask; + sampleChecked = true; + + // scalar float - type only, plus case-insensitive name matching + Assert.IsType(client.GetValue("RPM")); + Assert.IsType(client.GetValue("rpm")); + Assert.IsType(client.GetValue("Rpm")); + + // scalar int + Assert.IsType(client.GetValue("SessionNum")); + + // array (enum-typed in the generated struct, plain int[] via dynamic lookup) + // CarIdxTrackSurface can legitimately be unavailable (null) - see Base.cs's BasicMonitoring check + var dynamicTrackSurface = client.GetValue("CarIdxTrackSurface"); + if (telemetryData.CarIdxTrackSurface == null) + { + Assert.Null(dynamicTrackSurface); + } + else + { + var trackSurface = Assert.IsType(dynamicTrackSurface); + Assert.Equal(telemetryData.CarIdxTrackSurface.Length, trackSurface.Length); + } + + // unknown variable name + Assert.Null(client.GetValue("NotARealVariableName")); + + cts.Cancel(); + return Task.CompletedTask; + }, + }, + cts.Token); + + Assert.True(sampleChecked, "Telemetry data was not received within the timeout period."); + } + + /// + /// verifies the string-based dynamic lookup (GetValue) returns values that exactly agree with the + /// strongly-typed, source-generated struct for the same sample. Only valid under + /// , which guarantees GetValue() reflects the sample + /// delivered to the handler for the handler's full duration. + /// + protected async Task BaseVerifyDynamicLookupMatchesTypedValues(ITelemetryClient client, int timeoutSecs = 5) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs)); + + var sampleChecked = false; + + await client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = telemetryData => + { + // only need one sample + if (sampleChecked) + return Task.CompletedTask; + sampleChecked = true; + + // scalar float + Assert.Equal(telemetryData.RPM, Assert.IsType(client.GetValue("RPM"))); + + // scalar int + Assert.Equal(telemetryData.SessionNum, Assert.IsType(client.GetValue("SessionNum"))); + + // array (enum-typed in the generated struct, plain int[] via dynamic lookup) + // CarIdxTrackSurface can legitimately be unavailable (null) - see Base.cs's BasicMonitoring check + var dynamicTrackSurface = client.GetValue("CarIdxTrackSurface"); + if (telemetryData.CarIdxTrackSurface == null) + { + Assert.Null(dynamicTrackSurface); + } + else + { + var trackSurface = Assert.IsType(dynamicTrackSurface); + Assert.Equal(telemetryData.CarIdxTrackSurface.Length, trackSurface.Length); + for (var i = 0; i < trackSurface.Length; i++) + { + Assert.Equal((int)telemetryData.CarIdxTrackSurface[i], trackSurface[i]); + } + } + + // unknown variable name + Assert.Null(client.GetValue("NotARealVariableName")); + + cts.Cancel(); + return Task.CompletedTask; + }, + }, + cts.Token); + + Assert.True(sampleChecked, "Telemetry data was not received within the timeout period."); + } + + /// + /// verifies that every variable's advertised from + /// agrees with the runtime type + /// actually returns for that variable. Catches drift like + /// advertising string[] while returning a single decoded string, or uint while + /// returning int. Requires , since GetValue() + /// throws in . + /// + protected async Task BaseVerifyTelemetryVariableTypesMatchGetValue(ITelemetryClient client, int timeoutSecs = 5) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSecs)); + + var sampleChecked = false; + + await client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = _ => + { + // only need one sample + if (sampleChecked) + return Task.CompletedTask; + sampleChecked = true; + + var variables = client.GetTelemetryVariables(); + Assert.NotEmpty(variables); + + var mismatches = new List(); + foreach (var v in variables) + { + // a variable can legitimately have no value yet for this sample - GetValue() + // returning null for it isn't a type mismatch + var value = client.GetValue(v.Name); + if (value == null) + continue; + + var actualType = value.GetType(); + if (actualType != v.Type) + { + mismatches.Add($"{v.Name}: advertised {v.Type?.Name ?? "null"}, actual {actualType.Name}"); + } + } + + if (mismatches.Count > 0) + { + Assert.Fail( + $"GetTelemetryVariables().Type disagreed with GetValue()'s runtime type for " + + $"{mismatches.Count} variable(s):\n{string.Join("\n", mismatches)}"); + } + + cts.Cancel(); + return Task.CompletedTask; + }, + }, + cts.Token); + + Assert.True(sampleChecked, "Telemetry data was not received within the timeout period."); + } +} diff --git a/Sdk/tests/SmokeTests/IBT/IBT.cs b/Sdk/tests/SmokeTests/IBT/IBT.cs index 2ab6d8b..c001bdf 100644 --- a/Sdk/tests/SmokeTests/IBT/IBT.cs +++ b/Sdk/tests/SmokeTests/IBT/IBT.cs @@ -28,6 +28,9 @@ public class IBT : Base { const int TIMEOUT_SECS = 5; + // resolve relative to the test assembly's own location + private static readonly string IbtDataDirectory = Path.Combine(AppContext.BaseDirectory, "data", "ibt"); + public IBT(ITestOutputHelper output) : base(output) { } @@ -46,8 +49,7 @@ public static TheoryData>> { var testData = new TheoryData>>(); - var ibtDirectory = @"data\ibt"; - var ibtFiles = Directory.GetFiles(ibtDirectory, "*.ibt"); + var ibtFiles = Directory.GetFiles(IbtDataDirectory, "*.ibt"); foreach (var ibtFile in ibtFiles) { @@ -69,6 +71,89 @@ public override async Task BasicMonitoring(string _mode, Func.Create( + _logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + Assert.Null(client.GetValue("RPM")); + } + + [Fact] + public async Task GetValueInAsyncMode_Throws() + { + // the SDK only works in one delivery mode at a time - GetValue() is unusable in the default + // Async mode regardless of whether Monitor() has started, since production and consumption + // run on independent tasks and there is no way to guarantee it reflects the delivered sample. + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + await using var client = TelemetryClient.Create(_logger, new IBTOptions(ibtFile)); + + Assert.Throws(() => client.GetValue("RPM")); + } + + [Fact] + public async Task GetValueAfterDispose_Throws() + { + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + var client = TelemetryClient.Create(_logger, new IBTOptions(ibtFile)); + await client.DisposeAsync(); + + Assert.Throws(() => client.GetValue("RPM")); + } + + [Fact] + public async Task GetTelemetryVariablesBeforeMonitorStarted_DoesNotPermanentlyCacheEmpty() + { + // calling GetTelemetryVariables() before the provider has read headers must + // return an empty list without caching it - once Monitor() starts and headers become + // available, later calls must return the real list, not the stale empty one. + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + await using var client = TelemetryClient.Create(_logger, new IBTOptions(ibtFile)); + + Assert.Empty(client.GetTelemetryVariables()); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(TIMEOUT_SECS)); + var sampleReceived = false; + + await client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = _ => + { + sampleReceived = true; + cts.Cancel(); + return Task.CompletedTask; + } + }, + cts.Token); + + Assert.True(sampleReceived); + Assert.NotEmpty(client.GetTelemetryVariables()); + } + + [Fact] + public async Task GetTelemetryVariablesAfterDispose_Throws() + { + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + var client = TelemetryClient.Create(_logger, new IBTOptions(ibtFile)); + await client.DisposeAsync(); + + Assert.Throws(() => client.GetTelemetryVariables()); + } + [Fact] public async Task InvalidFileThrows() { @@ -99,6 +184,67 @@ public async Task VerifyAllVariablesAreCovered(string mode, Func> clientFactory) + { + _ = mode; // used only to name the test cases in the test runner display + + // GetValue() only works in Synchronous mode - see GetValueInAsyncMode_Throws. + await using var client = clientFactory(_logger); + await BaseVerifyDynamicLookupShape(client, TIMEOUT_SECS); + } + + [Theory] + [MemberData(nameof(SyncTestModes))] + public async Task VerifyTelemetryVariableTypesMatchGetValue(string mode, Func> clientFactory) + { + _ = mode; // used only to name the test cases in the test runner display + + // GetValue() only works in Synchronous mode - see GetValueInAsyncMode_Throws. + await using var client = clientFactory(_logger); + await BaseVerifyTelemetryVariableTypesMatchGetValue(client, TIMEOUT_SECS); + } + + /// + /// same file set as , but configured for + /// delivery, which guarantees GetValue() matches the + /// sample delivered to the handler even under unthrottled (as-fast-as-possible) IBT playback. + /// + public static TheoryData>> SyncTestModes + { + get + { + var testData = new TheoryData>>(); + + var ibtFiles = Directory.GetFiles(IbtDataDirectory, "*.ibt"); + + foreach (var ibtFile in ibtFiles) + { + var fileName = Path.GetFileNameWithoutExtension(ibtFile); + testData.Add( + $"IBT (sync) - {fileName}", + logger => TelemetryClient.Create( + logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }) + ); + } + + return testData; + } + } + + [Theory] + [MemberData(nameof(SyncTestModes))] + public async Task VerifyDynamicLookupMatchesTypedValues_SynchronousMode(string mode, Func> clientFactory) + { + _ = mode; // used only to name the test cases in the test runner display + + await using var client = clientFactory(_logger); + await BaseVerifyDynamicLookupMatchesTypedValues(client, TIMEOUT_SECS); + } + [Theory] [MemberData(nameof(TestModes))] public async Task MonitorCancellationCompletesDirectStreams(string mode, Func> clientFactory) @@ -145,11 +291,76 @@ public async Task HandlerExceptionFaultsMonitor(string mode, Func.Create( + _logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(TIMEOUT_SECS)); + + var errorsReceived = new List(); + + var actual = await Assert.ThrowsAsync(() => + client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = _ => throw new InvalidOperationException("handler failed"), + OnError = e => + { + errorsReceived.Add(e); + return Task.CompletedTask; + } + }, + cts.Token)); + + Assert.Equal("handler failed", actual.Message); + Assert.Empty(errorsReceived); + } + + [Fact] + public async Task SynchronousModeWithoutTelemetryHandler_MonitorNoHandlersOverload_Throws() + { + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + await using var client = TelemetryClient.Create( + _logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + await Assert.ThrowsAsync(() => client.Monitor(CancellationToken.None)); + } + + [Fact] + public async Task SynchronousModeWithoutTelemetryHandler_MonitorHandlersOverload_Throws() + { + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); + + await using var client = TelemetryClient.Create( + _logger, + new IBTOptions(ibtFile), + new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + await Assert.ThrowsAsync(() => + client.Monitor( + new TelemetryHandlers + { + OnSessionInfoUpdate = _ => Task.CompletedTask + }, + CancellationToken.None)); + } + [Fact] public async Task MultipleHandlers_OneThrows_FaultsMonitorPromptly() { - // regression test: with multiple handlers registered, a throwing handler must fault Monitor promptly. - var ibtFile = Directory.GetFiles(@"data\ibt", "raygr22*").First(); + // with multiple handlers registered, a throwing handler must fault Monitor promptly. + var ibtFile = Directory.GetFiles(IbtDataDirectory, "raygr22*").First(); await using var client = TelemetryClient.Create( _logger, @@ -179,7 +390,7 @@ public async Task MultipleHandlers_OneThrows_FaultsMonitorPromptly() [Fact] public async Task HungHandlerFaultsMonitorAfterShutdownTimeout() { - var ibtFile = Directory.GetFiles(@"data\ibt", "*.ibt").First(); + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); await using var client = TelemetryClient.Create( _logger, @@ -205,7 +416,7 @@ public async Task HungHandlerFaultsMonitorAfterShutdownTimeout() [Fact] public async Task HandlerMonitorRejectsCompletedClientBeforeStartingHandlers() { - var ibtFile = Directory.GetFiles(@"data\ibt", "*.ibt").First(); + var ibtFile = Directory.GetFiles(IbtDataDirectory, "*.ibt").First(); await using var client = TelemetryClient.Create( _logger, diff --git a/Sdk/tests/SmokeTests/Live/Live.cs b/Sdk/tests/SmokeTests/Live/Live.cs index 084d066..77b493f 100644 --- a/Sdk/tests/SmokeTests/Live/Live.cs +++ b/Sdk/tests/SmokeTests/Live/Live.cs @@ -62,4 +62,58 @@ public async Task VerifyModelMatchesRawYaml(string mode, Func.Create( + _logger, + ibtOptions: null, + clientOptions: new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + await BaseVerifyTelemetryVariableTypesMatchGetValue(client, TIMEOUT_SECS); + } + + [Fact] + public async Task HandlerExceptionFaultsMonitor_SynchronousMode() + { + // a synchronous-mode handler exception must fault Monitor() directly, + // exactly like Async mode, and must never be routed to OnError. Requires iRacing running + // with an active session sending telemetry (see Sdk/tests/README.md). + await using var client = TelemetryClient.Create( + _logger, + ibtOptions: null, + clientOptions: new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(TIMEOUT_SECS)); + + var errorsReceived = new List(); + + var actual = await Assert.ThrowsAsync(() => + client.Monitor( + new TelemetryHandlers + { + OnTelemetryUpdate = _ => throw new InvalidOperationException("handler failed"), + OnError = e => + { + errorsReceived.Add(e); + return Task.CompletedTask; + } + }, + cts.Token)); + + Assert.Equal("handler failed", actual.Message); + Assert.Empty(errorsReceived); + } + + [Fact] + public async Task SynchronousModeWithoutTelemetryHandler_Throws() + { + await using var client = TelemetryClient.Create( + _logger, + ibtOptions: null, + clientOptions: new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }); + + await Assert.ThrowsAsync(() => client.Monitor(CancellationToken.None)); + } } diff --git a/ARCHITECTURE.md b/docs/ARCHITECTURE.md similarity index 90% rename from ARCHITECTURE.md rename to docs/ARCHITECTURE.md index cda602c..ad2f390 100644 --- a/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ This document describes the SDK's threading model, data pipeline, memory layout, buffering policy, and performance instrumentation. -For installation and everyday usage, see the [README](./README.md). For direct stream access and multi-consumer patterns, see [Advanced Usage](./docs/ADVANCED.md). +For installation and everyday usage, see the [README](../README.md). For direct stream access and multi-consumer patterns, see [Advanced Usage](./ADVANCED.md). ## Table of Contents @@ -26,45 +26,32 @@ For installation and everyday usage, see the [README](./README.md). For direct s iRacing exposes live telemetry through shared memory at 60 Hz and persists recorded telemetry in IBT files. Both sources enter the same provider pipeline and feed independent output streams. ```mermaid -graph TB - subgraph "Data Sources" - iRacing[iRacing Simulator
60Hz Live Data] - IBT[IBT File
Historical Data] +flowchart LR + subgraph Sources + Live["iRacing
live shared memory"] + IBT["IBT file"] end - subgraph Input ["Task1 - Input Processing"] - ProcessingDecoding[Processing - Decoding] + subgraph Task1["Task 1: Read data"] + Read["Read and decode"] end - MainTask[Telemetry Data] - - subgraph SessionInfo [Task2 - Session Info] - SessionTask[Session Info Processing
YAML Parsing] + subgraph Task2["Task 2: Process session info"] + Parse["Publish raw YAML
and parse it"] end - subgraph "High Performance Data Streams" - TelemetryStream[TelemetryData] - SessionStream[SessionData] - RawStream[SessionDataYaml] + subgraph Streams["Application streams"] + Telemetry["TelemetryData"] + RawSession["SessionDataYaml"] + Session["SessionData"] end - iRacing --> Input - IBT --> Input - - Input --> MainTask - Input --> SessionInfo - - MainTask --> TelemetryStream - SessionTask --> SessionStream - SessionTask --> RawStream - - classDef dataSource fill:#e1f5fe - classDef processingSteps fill:#f3e5f5 - classDef stream fill:#e8f5e8 - - class iRacing,IBT dataSource - class ProcessingDecoding,SessionTask processingSteps - class TelemetryStream,SessionStream,RawStream stream + Live --> Read + IBT --> Read + Read --> Telemetry + Read -- "session YAML" --> Parse + Parse --> RawSession + Parse --> Session ``` **Task1** handles source reads and telemetry decoding. **Task2** publishes raw session YAML and parses it into `SessionData`. The separation keeps YAML parsing latency out of the telemetry path. @@ -322,7 +309,7 @@ Processing rates, latency distributions, and dropped-record counts identify whet ## Related Documentation -- **[README](./README.md)** - Installation, quick start, and telemetry variables -- **[Advanced Usage](./docs/ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior -- **[Migration Guide](./docs/MIGRATION_GUIDE.md)** - Upgrading from early pre-1.0 releases -- **[SDK usage guide for agents](./docs/ai/SDK_USAGE.md)** - Recommended usage for consumer applications +- **[README](../README.md)** - Installation, quick start, and telemetry variables +- **[Advanced Usage](./ADVANCED.md)** - Direct stream access, multiple consumers, and cancellation behavior +- **[Migration Guide](./MIGRATION_GUIDE.md)** - Upgrading from early pre-1.0 releases +- **[SDK usage guide for agents](./ai/SDK_USAGE.md)** - Recommended usage for consumer applications diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index f7e4b1c..2564ed2 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -121,7 +121,7 @@ If a callback registered via `Monitor(handlers, ct)` does not return within 5 se - [ ] Drop `.WithCancellation(ct)` from normal `await foreach` loops on the client's streams. - [ ] Audit handlers for callbacks that may take longer than 5 seconds. -See `Samples/MinimalExample/` for a complete v2.0 reference. +See `Samples/MinimalExampleAsync/` for a complete v2.0 reference. --- diff --git a/docs/ai/SDK_REFERENCE.md b/docs/ai/SDK_REFERENCE.md index 33ef706..1d9d68f 100644 --- a/docs/ai/SDK_REFERENCE.md +++ b/docs/ai/SDK_REFERENCE.md @@ -35,12 +35,26 @@ await using var realtimeClient = TelemetryClient.Create(logger, r // Metrics support. var clientOptions = new ClientOptions { MeterFactory = meterFactory }; await using var metricsClient = TelemetryClient.Create(logger, ibtOptions, clientOptions); + +// Synchronous delivery - required to call GetValue(); it throws InvalidOperationException under +// the default Async mode. See "Telemetry Delivery Mode" below. +var syncOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }; +await using var syncClient = TelemetryClient.Create(logger, ibtOptions, syncOptions); ``` Validate IBT file paths before creating `IBTOptions` when paths come from user input. The client constructor throws `FileNotFoundException` for missing files. `playBackSpeedMultiplier: 1` runs IBT playback at real-time speed. The default, `int.MaxValue`, processes as fast as possible. Choose `1` for visualization-style apps and the default for batch analysis. +### Telemetry Delivery Mode + +`ClientOptions.DeliveryMode` controls how samples move from the background read loop to `OnTelemetryUpdate`, and the SDK only operates in one mode at a time - you cannot create a client in one mode and read its data with the other's mechanism: + +- **`TelemetryDeliveryMode.Async` (default).** Samples are pushed onto a 60-item drop-oldest channel and consumed by an independent task - high throughput, but production and consumption are decoupled. Because there'd be no way to guarantee it reflects the same record as the `T` sample the handler is currently processing, `GetValue()` is disallowed in this mode and throws `InvalidOperationException`. +- **`TelemetryDeliveryMode.Synchronous`.** Each sample is awaited directly by `OnTelemetryUpdate` from the same loop that reads it - the next record isn't read until the handler returns. `GetValue()` only works in this mode, and is guaranteed to match the delivered sample for the handler's full duration. Trade-off: processing/playback speed is bounded by handler speed, and the raw `TelemetryData` async-enumerable stream throws `InvalidOperationException` if accessed (there's nothing to queue - use the handler-based `Monitor` overload). + +Choose `Synchronous` only when code needs `GetValue()` - whether alone or mixed with the strongly-typed `T` in the same handler; otherwise `Async` is the better default. + ## Handler Callback Pattern Use this for most applications. diff --git a/docs/ai/SDK_USAGE.md b/docs/ai/SDK_USAGE.md index 4df8e20..6c2145a 100644 --- a/docs/ai/SDK_USAGE.md +++ b/docs/ai/SDK_USAGE.md @@ -78,6 +78,7 @@ public class Program 10. Live telemetry requires Windows and a running iRacing session. If unsure or running on Linux/macOS, default to IBT mode for portability. 11. Target .NET 8.0 or newer. 12. `OnError` is for SDK processing errors only. Exceptions thrown inside your handlers fault `Monitor(...)` directly; they are not sent to `OnError`. Wrap recoverable per-item work in `try/catch` inside the handler. +13. If the variable set is not known at compile time, use `TelemetryClient` and `GetValue(string)` instead of `RequiredTelemetryVars` + `TelemetryData`. `GetValue` requires `ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }` - it throws `InvalidOperationException` under the default `Async` mode. ## Error Handling @@ -127,6 +128,36 @@ sim.Camera.SwitchToPosition(CameraFocus.AtLeader, 1, 1); sim.Replay.Search(ReplaySearchMode.NextIncident); // only while out of the car ``` +## Dynamic Variable Lookup + +Use this only when the variable set is not known at compile time (e.g. a user-configurable dashboard) or when a `RequiredTelemetryVars`-annotated struct is impractical (e.g. some game engine build pipelines). Otherwise use the Default Pattern above. + +```csharp +// no RequiredTelemetryVars needed - use the DynamicTelemetryData placeholder +// GetValue requires Synchronous delivery mode - see below +var clientOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }; +await using var client = TelemetryClient.Create(logger, ibtOptions: null, clientOptions); + +var handlers = new TelemetryHandlers +{ + OnTelemetryUpdate = _ => + { + if (client.GetValue("Speed") is float speed) + { + Console.WriteLine($"Speed: {speed}"); + } + return Task.CompletedTask; + } +}; + +await client.Monitor(handlers, cts.Token); +``` + +- `GetValue(varName)` matches names case-insensitively and works for any variable reported by `GetTelemetryVariables()`, not just ones declared on `T`. +- Returns a boxed scalar (e.g. `float`) or array (e.g. `float[]`), or `null` if the variable is unknown or telemetry data isn't yet available. +- Prefer `T` for variables known at compile time; it's allocation-free. +- The SDK only works in one delivery mode at a time - `GetValue` is only usable when the client is created with `new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }`; calling it under the default `TelemetryDeliveryMode.Async` throws `InvalidOperationException`, since production and consumption run on independent tasks there and there'd be no way to guarantee `GetValue` matches the `T` sample delivered to the handler currently running. In `Synchronous` mode, each sample is awaited directly by the handler before the next record is read, so `GetValue` always matches the delivered sample - at the cost of throughput (see `SDK_REFERENCE.md`). + ## When To Use Direct Streams Use direct `IAsyncEnumerable` streams only when the user needs custom backpressure, separate consumers, or maximum IBT processing performance. For those patterns, read: diff --git a/global.json b/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} From 952efe334ce5812a29c3684d9870767f0ce968d7 Mon Sep 17 00:00:00 2001 From: Scott Velez <162245270+SVappsLAB@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:01:47 -0700 Subject: [PATCH 5/5] bump version --- .../DumpVariables_DumpSessionInfo.csproj | 2 +- Samples/LocationAndWarnings/LocationAndWarnings.csproj | 2 +- Samples/MinimalExampleAsync/MinimalExampleAsync.csproj | 2 +- Samples/SimControl/SimControl.csproj | 2 +- Samples/SpeedRPMGear/SpeedRPMGear.csproj | 2 +- .../SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj | 2 +- .../SVappsLAB.iRacingTelemetrySDK.csproj | 6 +++--- Sdk/tests/SmokeTests/SmokeTests.csproj | 8 ++++---- Sdk/tests/UnitTests/UnitTests.csproj | 8 ++++---- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj b/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj index d1dbc82..c19ab02 100644 --- a/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj +++ b/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj @@ -9,7 +9,7 @@ - + diff --git a/Samples/LocationAndWarnings/LocationAndWarnings.csproj b/Samples/LocationAndWarnings/LocationAndWarnings.csproj index d1dbc82..c19ab02 100644 --- a/Samples/LocationAndWarnings/LocationAndWarnings.csproj +++ b/Samples/LocationAndWarnings/LocationAndWarnings.csproj @@ -9,7 +9,7 @@ - + diff --git a/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj b/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj index d1dbc82..c19ab02 100644 --- a/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj +++ b/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj @@ -9,7 +9,7 @@ - + diff --git a/Samples/SimControl/SimControl.csproj b/Samples/SimControl/SimControl.csproj index 69ddc39..7df6a9b 100644 --- a/Samples/SimControl/SimControl.csproj +++ b/Samples/SimControl/SimControl.csproj @@ -9,6 +9,6 @@ - + diff --git a/Samples/SpeedRPMGear/SpeedRPMGear.csproj b/Samples/SpeedRPMGear/SpeedRPMGear.csproj index d12b54c..2a80474 100644 --- a/Samples/SpeedRPMGear/SpeedRPMGear.csproj +++ b/Samples/SpeedRPMGear/SpeedRPMGear.csproj @@ -11,7 +11,7 @@ - + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj index 7baa1a5..f23159d 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj @@ -7,7 +7,7 @@ - + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj b/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj index 8f35c1d..1631603 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj @@ -24,8 +24,8 @@ - - + + @@ -43,7 +43,7 @@ iRacing iRacingSDK irsdk IBT telemetry SDK API sim-control replay broadcast NUGET.md LICENSE - 2.2.0 + 2.3.0 Scott Velez SVappsLAB Copyright (c) 2026 SVappsLAB diff --git a/Sdk/tests/SmokeTests/SmokeTests.csproj b/Sdk/tests/SmokeTests/SmokeTests.csproj index 3829258..63e61f4 100644 --- a/Sdk/tests/SmokeTests/SmokeTests.csproj +++ b/Sdk/tests/SmokeTests/SmokeTests.csproj @@ -46,13 +46,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/Sdk/tests/UnitTests/UnitTests.csproj b/Sdk/tests/UnitTests/UnitTests.csproj index a50b043..3a68bc5 100644 --- a/Sdk/tests/UnitTests/UnitTests.csproj +++ b/Sdk/tests/UnitTests/UnitTests.csproj @@ -27,15 +27,15 @@ - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - +