From 136a670a4f7cc706afe73e584c0d2f9351019f10 Mon Sep 17 00:00:00 2001 From: Scott Velez <162245270+SVappsLAB@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:41:04 -0700 Subject: [PATCH] AOT compatibility --- NUGET.md | 2 +- README.md | 4 +- .../DumpVariables_DumpSessionInfo.csproj | 4 +- .../LocationAndWarnings.csproj | 4 +- .../MeterListener/MeterListener.csproj | 4 +- .../OpenTelemetry/OpenTelemetry.csproj | 8 +- .../MinimalExampleAsync.csproj | 4 +- .../MinimalExampleSync.csproj | 4 +- Samples/SimControl/SimControl.csproj | 4 +- Samples/SpeedRPMGear/SpeedRPMGear.csproj | 8 +- .../CodeGen.cs | 677 ++++++------------ ...appsLAB.iRacingTelemetrySDK.CodeGen.csproj | 5 +- .../tools/README.md | 21 - .../tools/vars/csv-to-cs.py | 67 -- .../tools/vars/findDistinctVars.py | 64 -- .../TelemetryClient_Flags.cs | 1 - Sdk/SVappsLAB.iRacingTelemetrySDK.slnx | 1 + .../DataProviders/IBTDataProvider.cs | 19 +- .../GeneratedTelemetryData.cs | 98 +++ .../IBTPlayback/Governor.cs | 99 +-- .../SVappsLAB.iRacingTelemetrySDK.csproj | 9 +- .../TelemetryClient.cs | 44 +- .../TelemetryDataAccessor.cs | 163 ++--- .../YamlParsing/SessionInfoYamlContext.cs | 50 ++ .../YamlParsing/YamlParser.cs | 8 +- Sdk/tests/AotSmokeTest/AotSmokeTest.csproj | 20 + Sdk/tests/AotSmokeTest/Program.cs | 348 +++++++++ Sdk/tests/README.md | 10 +- Sdk/tests/SmokeTests/Aot/Aot.cs | 126 ++++ Sdk/tests/SmokeTests/Live/Live.cs | 2 +- Sdk/tests/SmokeTests/SmokeTests.csproj | 11 +- .../UnitTests/CodeGen/CodeGeneratorTests.cs | 296 +++++++- .../CodeGen/GeneratorRoslynVersionTests.cs | 40 ++ Sdk/tests/UnitTests/IBTPlaybackGovernor.cs | 2 +- .../Metrics/TelemetryPipelineMetricsTests.cs | 26 +- Sdk/tests/UnitTests/UnitTests.csproj | 4 +- .../YamlParsing/YamlStaticContextTests.cs | 74 ++ 37 files changed, 1470 insertions(+), 861 deletions(-) delete mode 100644 Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/README.md delete mode 100644 Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/vars/csv-to-cs.py delete mode 100644 Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/vars/findDistinctVars.py create mode 100644 Sdk/SVappsLAB.iRacingTelemetrySDK/GeneratedTelemetryData.cs create mode 100644 Sdk/SVappsLAB.iRacingTelemetrySDK/YamlParsing/SessionInfoYamlContext.cs create mode 100644 Sdk/tests/AotSmokeTest/AotSmokeTest.csproj create mode 100644 Sdk/tests/AotSmokeTest/Program.cs create mode 100644 Sdk/tests/SmokeTests/Aot/Aot.cs create mode 100644 Sdk/tests/UnitTests/CodeGen/GeneratorRoslynVersionTests.cs create mode 100644 Sdk/tests/UnitTests/YamlParsing/YamlStaticContextTests.cs diff --git a/NUGET.md b/NUGET.md index e8fbeff..1d42b62 100644 --- a/NUGET.md +++ b/NUGET.md @@ -19,7 +19,7 @@ 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 +- **Compile-time generated structs** instead of looking variables up by string name in your code — typos are build errors, and only the variables you declare are ever decoded - **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 diff --git a/README.md b/README.md index 5c33eb9..eb4bb3d 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,9 @@ This SDK takes a different approach. It treats telemetry as a **typed, high-thro **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 +- **Source-generated structs** eliminate runtime reflection 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 -- **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 +- **Low-allocation reads** — each sample is copied once into a reused buffer (guarding against iRacing overwriting it mid-read), then only the requested variables are decoded, so per-sample allocation is limited to those values (a boxed scalar or a copied array each) - **Independent background tasks** mean CPU-intensive session-info YAML parsing never stalls the 60Hz telemetry path Full details on the threading model and buffering semantics: **[Architecture and Design](./docs/ARCHITECTURE.md)**. For the built-in metrics and how to collect them: **[Metrics and Diagnostics](./docs/METRICS.md)** diff --git a/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj b/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj index 70dd805..b884f72 100644 --- a/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj +++ b/Samples/DumpVariables_DumpSessionInfo/DumpVariables_DumpSessionInfo.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Samples/LocationAndWarnings/LocationAndWarnings.csproj b/Samples/LocationAndWarnings/LocationAndWarnings.csproj index 70dd805..b884f72 100644 --- a/Samples/LocationAndWarnings/LocationAndWarnings.csproj +++ b/Samples/LocationAndWarnings/LocationAndWarnings.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Samples/Metrics/MeterListener/MeterListener.csproj b/Samples/Metrics/MeterListener/MeterListener.csproj index ed8b4d3..2fdbcd1 100644 --- a/Samples/Metrics/MeterListener/MeterListener.csproj +++ b/Samples/Metrics/MeterListener/MeterListener.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Samples/Metrics/OpenTelemetry/OpenTelemetry.csproj b/Samples/Metrics/OpenTelemetry/OpenTelemetry.csproj index f07dcfd..72e659e 100644 --- a/Samples/Metrics/OpenTelemetry/OpenTelemetry.csproj +++ b/Samples/Metrics/OpenTelemetry/OpenTelemetry.csproj @@ -14,10 +14,10 @@ - - - - + + + + diff --git a/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj b/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj index 70dd805..b884f72 100644 --- a/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj +++ b/Samples/MinimalExampleAsync/MinimalExampleAsync.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Samples/MinimalExampleSync/MinimalExampleSync.csproj b/Samples/MinimalExampleSync/MinimalExampleSync.csproj index ed8b4d3..2fdbcd1 100644 --- a/Samples/MinimalExampleSync/MinimalExampleSync.csproj +++ b/Samples/MinimalExampleSync/MinimalExampleSync.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Samples/SimControl/SimControl.csproj b/Samples/SimControl/SimControl.csproj index 44f68f2..8a5273c 100644 --- a/Samples/SimControl/SimControl.csproj +++ b/Samples/SimControl/SimControl.csproj @@ -8,7 +8,7 @@ - - + + diff --git a/Samples/SpeedRPMGear/SpeedRPMGear.csproj b/Samples/SpeedRPMGear/SpeedRPMGear.csproj index d942af3..175fa81 100644 --- a/Samples/SpeedRPMGear/SpeedRPMGear.csproj +++ b/Samples/SpeedRPMGear/SpeedRPMGear.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/CodeGen.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/CodeGen.cs index 6dabb8b..21739ca 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/CodeGen.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/CodeGen.cs @@ -15,9 +15,9 @@ **/ using System; -using System.Collections.Concurrent; +using System.Collections; using System.Collections.Generic; -using System.Diagnostics; +using System.Collections.Immutable; using System.Linq; using System.Text; using System.Threading; @@ -27,13 +27,64 @@ namespace SVappsLAB.iRacingTelemetrySDK; +// the pipeline model. each type is value-equatable +internal record struct TelemetryProperty(string Name, string TypeName, string ReaderCall); -public record struct VarsToGenerate(VarType[] vars, (TelemetryVar variable, Location location)[] duplicates, Location attributeLocation); -public record struct VarType(TelemetryVar variable, Type type, Location location); +// a Location that compares by value. Location itself holds a SyntaxTree reference changes with each edit +internal record struct LocationInfo(string FilePath, TextSpan Span, LinePositionSpan LineSpan) +{ + public Location ToLocation() => Location.Create(FilePath, Span, LineSpan); + + public static LocationInfo? From(Location? location) => + location is null || location.SourceTree is null + ? null + : new LocationInfo(location.SourceTree.FilePath, location.SourceSpan, location.GetLineSpan().Span); +} + +internal record struct DiagnosticInfo(DiagnosticDescriptor Descriptor, LocationInfo? Location, string? Argument) +{ + public Diagnostic ToDiagnostic() => + Diagnostic.Create(Descriptor, Location?.ToLocation(), Argument is null ? [] : [Argument]); +} + +// properties and diagnostics are kept apart so that an edit which only moves the attribute +internal record struct TelemetryModel(EquatableArray Properties, EquatableArray Diagnostics, LocationInfo? AttributeLocation); + +// immutable array that compares by contents +internal readonly struct EquatableArray : IEquatable>, IEnumerable where T : IEquatable +{ + readonly T[]? _items; + + public EquatableArray(T[] items) => _items = items; + + public int Length => _items?.Length ?? 0; + + public bool Equals(EquatableArray other) => AsSpan().SequenceEqual(other.AsSpan()); + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + var hash = 17; + foreach (var item in AsSpan()) + hash = unchecked(hash * 31 + item.GetHashCode()); + return hash; + } + + public ReadOnlySpan AsSpan() => _items.AsSpan(); + + public IEnumerator GetEnumerator() => ((IEnumerable)(_items ?? [])).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} [Generator(LanguageNames.CSharp)] public class CodeGenerator : IIncrementalGenerator { + const string AttributeMetadataName = "SVappsLAB.iRacingTelemetrySDK.RequiredTelemetryVarsAttribute"; + + // tracking names, so tests can check which steps Roslyn reused + public const string ModelStep = "TelemetryModel"; + public const string PropertiesStep = "TelemetryProperties"; + // marker attribute const string MarkerAttribute = @" #nullable enable @@ -53,12 +104,39 @@ public RequiredTelemetryVarsAttribute(TelemetryVar[]? vars) } }"; - static readonly Lazy _iRacingData = new Lazy(() => new iRacingVars()); - static readonly ConcurrentDictionary _typeCache = new ConcurrentDictionary(); - - // performance telemetry - static readonly ConcurrentDictionary _performanceCounters = new ConcurrentDictionary(); - static readonly ConcurrentDictionary _performanceTimes = new ConcurrentDictionary(); + static readonly Dictionary _catalog = new iRacingVars().Vars; + + static readonly DiagnosticDescriptor DuplicateVarName = new( + id: "DuplicateVarName", + title: "Duplicate Variable Name", + messageFormat: "Duplicate telemetry variable name found: \"{0}\"", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + static readonly DiagnosticDescriptor EmptyVarList = new( + id: "EmptyVarList", + title: "Empty Variable List", + messageFormat: "No telemetry variables requested. The generated 'TelemetryData' type will have no properties.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); + + static readonly DiagnosticDescriptor UnknownVarName = new( + id: "UnknownVarName", + title: "Unknown Variable Name", + messageFormat: "Unknown telemetry variable name: \"{0}\". Check spelling.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + static readonly DiagnosticDescriptor MultipleAttributes = new( + id: "MultipleRequiredTelemetryVars", + title: "Multiple RequiredTelemetryVars Attributes", + messageFormat: "Only one class per project may use [RequiredTelemetryVars]; it defines the project's single 'TelemetryData' type", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -66,266 +144,178 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterPostInitializationOutput(static ctx => ctx.AddSource( "MarkerAttribute.g.cs", SourceText.From(MarkerAttribute, Encoding.UTF8))); - // find our marker attribute - var pipeline = context.SyntaxProvider.ForAttributeWithMetadataName( - "SVappsLAB.iRacingTelemetrySDK.RequiredTelemetryVarsAttribute", - predicate: predMatcher, - transform: tranformer) - .Where(static m => m is not null); - - context.RegisterSourceOutput(pipeline, - static (spc, source) => Execute(spc, source)); - } - - private bool predMatcher(SyntaxNode sn, CancellationToken _ct) - { - var x = sn is ClassDeclarationSyntax; - return x; - } - private VarsToGenerate? tranformer(GeneratorAttributeSyntaxContext context, CancellationToken _ct) - { - var stopwatch = Stopwatch.StartNew(); - IncrementCounter(TelemetryConstants.COUNTER_TRANSFORMER_CALLS); + // one model per annotated class + var models = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeMetadataName, + // a class or record class + predicate: static (node, _) => node is ClassDeclarationSyntax or RecordDeclarationSyntax, + transform: static (ctx, ct) => CreateModel(ctx, ct)) + .WithTrackingName(ModelStep) + .Collect(); - try - { - var attribute = ValidateAndGetAttribute(context); - if (attribute == null) - return null; - - // an empty variable list is valid - it generates an empty TelemetryData, - // useful for connection-state-only apps that never read telemetry - var variableLocations = ExtractVariableLocations(context, attribute); + // diagnostics, from every annotated class + context.RegisterSourceOutput(models, static (spc, models) => ReportDiagnostics(spc, models)); - var duplicatesWithLocations = FindDuplicateVariables(variableLocations); - var varList = ProcessVariables(variableLocations); + // the source, from the first annotated class only. only one TelemetryData type allowed + var properties = models + .Select(static (models, _) => models.IsEmpty ? (EquatableArray?)null : models[0].Properties) + .WithTrackingName(PropertiesStep); - IncrementCounter(TelemetryConstants.COUNTER_VARIABLES_PROCESSED, varList.Length); - return new VarsToGenerate(varList, duplicatesWithLocations, GetAttributeLocation(context)); - } - finally + context.RegisterSourceOutput(properties, static (spc, properties) => { - stopwatch.Stop(); - RecordTime(TelemetryConstants.TIME_TRANSFORMER_DURATION, stopwatch.Elapsed); - } + if (properties is { } props) + spc.AddSource("iRacingTelemetrySDK.g.cs", GenerateSourceCode(props)); + }); } - private AttributeData? ValidateAndGetAttribute(GeneratorAttributeSyntaxContext context) + static TelemetryModel CreateModel(GeneratorAttributeSyntaxContext context, CancellationToken ct) { - if (context.Attributes.Length == 0) - return null; - - var attr = context.Attributes[0]; - if (attr.ConstructorArguments.Length == 0) - return null; + var attribute = context.Attributes[0]; + var attributeSyntax = attribute.ApplicationSyntaxReference?.GetSyntax(ct) as AttributeSyntax; + var attributeLocation = LocationInfo.From(attributeSyntax?.GetLocation()); - return attr; - } - - private List<(TelemetryVar variable, Location location)> ExtractVariableLocations(GeneratorAttributeSyntaxContext context, AttributeData attribute) - { - var variableLocations = new List<(TelemetryVar variable, Location location)>(); + var properties = new List(); + var diagnostics = new List(); - for (int argIndex = 0; argIndex < attribute.ConstructorArguments.Length; argIndex++) - { - var arg = attribute.ConstructorArguments[argIndex]; - for (int valueIndex = 0; valueIndex < arg.Values.Length; valueIndex++) - { - var value = arg.Values[valueIndex]; - if (value.Value is int enumValue && Enum.IsDefined(typeof(TelemetryVar), enumValue)) - { - var telemetryVar = (TelemetryVar)enumValue; - var location = GetVariableLocation(context, argIndex, valueIndex); - variableLocations.Add((telemetryVar, location)); - } - } - } + // a missing or unbindable argument is already a compiler error. generate an empty TelemetryData + if (attribute.ConstructorArguments.Length != 1 || attribute.ConstructorArguments[0].Kind != TypedConstantKind.Array) + return new TelemetryModel(new([]), new([]), attributeLocation); - return variableLocations; - } + // [RequiredTelemetryVars(null)] is treated as an empty list + var arg = attribute.ConstructorArguments[0]; + var values = arg.IsNull ? ImmutableArray.Empty : arg.Values; - private (TelemetryVar variable, Location location)[] FindDuplicateVariables(List<(TelemetryVar variable, Location location)> variableLocations) - { - var duplicateGroups = variableLocations - .GroupBy(x => x.variable) - .Where(g => g.Count() > 1) - .ToArray(); + // an empty list is valid but often an accident, so surface an informational diagnostic + if (values.IsEmpty) + diagnostics.Add(new DiagnosticInfo(EmptyVarList, attributeLocation, null)); - // only report the first occurrence of each duplicate variable name - var duplicatesWithLocations = duplicateGroups - .Select(g => g.First()) - .ToArray(); + var elements = GetElementSyntax(attributeSyntax); + var seen = new HashSet(); + var duplicatesReported = new HashSet(); - if (duplicatesWithLocations.Length > 0) + for (int i = 0; i < values.Length; i++) { - IncrementCounter(TelemetryConstants.COUNTER_DUPLICATE_VARIABLES_FOUND); - } + var location = i < elements.Count ? LocationInfo.From(elements[i].GetLocation()) : attributeLocation; - return duplicatesWithLocations; - } + // an undefined enum value (e.g. (TelemetryVar)999999), or one the catalog doesn't know + if (values[i].Value is not int raw || !_catalog.TryGetValue((TelemetryVar)raw, out var item)) + { + var name = values[i].Value is int r && Enum.IsDefined(typeof(TelemetryVar), r) + ? ((TelemetryVar)r).ToString() + : values[i].Value?.ToString() ?? "null"; + diagnostics.Add(new DiagnosticInfo(UnknownVarName, location, name)); + continue; + } - private VarType[] ProcessVariables(List<(TelemetryVar variable, Location location)> variableLocations) - { - var varList = new VarType[variableLocations.Count]; + var variable = (TelemetryVar)raw; + if (!seen.Add(variable)) + { + if (duplicatesReported.Add(variable)) + diagnostics.Add(new DiagnosticInfo(DuplicateVarName, location, variable.ToString())); + continue; + } - for (int i = 0; i < variableLocations.Count; i++) - { - var (variable, location) = variableLocations[i]; - varList[i] = ProcessSingleVariable(variable, location); + properties.Add(CreateProperty(variable, item)); } - return varList; - } - - private VarType ProcessSingleVariable(TelemetryVar variable, Location location) - { - if (_iRacingData.Value.Vars.TryGetValue(variable, out var varItem)) - { - var type = GetVariableType(varItem); - return new VarType(variable, type, location); - } - else - { - IncrementCounter(TelemetryConstants.COUNTER_UNKNOWN_VARIABLES); - return new VarType(variable, typeof(Exception), location); - } + return new TelemetryModel(new(properties.ToArray()), new(diagnostics.ToArray()), attributeLocation); } - private Type GetVariableType(object varItem) + // the syntax for each element of the attribute's array argument, used to place diagnostics + static IReadOnlyList GetElementSyntax(AttributeSyntax? attributeSyntax) { - // use reflection to get properties since we can't use dynamic in source generators - var nameProperty = varItem.GetType().GetProperty("Name"); - var typeProperty = varItem.GetType().GetProperty("Type"); - var lengthProperty = varItem.GetType().GetProperty("Length"); - - var name = nameProperty?.GetValue(varItem)?.ToString() ?? ""; - var type = (int)(typeProperty?.GetValue(varItem) ?? 0); - var length = (int)(lengthProperty?.GetValue(varItem) ?? 1); - - var cacheKey = $"{name}_{type}_{length}"; - - // track cache hits/misses - bool cacheHit = _typeCache.ContainsKey(cacheKey); - if (cacheHit) IncrementCounter(TelemetryConstants.COUNTER_CACHE_HITS); - else IncrementCounter(TelemetryConstants.COUNTER_CACHE_MISSES); - - return _typeCache.GetOrAdd(cacheKey, _ => type switch + var expression = attributeSyntax?.ArgumentList?.Arguments.FirstOrDefault()?.Expression; + return expression switch { - 0 => length == 1 ? typeof(byte) : typeof(byte[]), - 1 => length == 1 ? typeof(bool) : typeof(bool[]), - 2 => GetIntOrEnumType(name, length), - 3 => GetFlagType(name, length), - 4 => length == 1 ? typeof(float) : typeof(float[]), - 5 => length == 1 ? typeof(double) : typeof(double[]), - _ => throw new NotImplementedException(), - }); + // [TelemetryVar.A, TelemetryVar.B] + CollectionExpressionSyntax collection => collection.Elements, + // new[] { TelemetryVar.A, TelemetryVar.B } + ImplicitArrayCreationExpressionSyntax implicitArray => implicitArray.Initializer.Expressions, + // new TelemetryVar[] { TelemetryVar.A, TelemetryVar.B } + ArrayCreationExpressionSyntax { Initializer: { } initializer } => initializer.Expressions, + _ => [] + }; } - private static void Execute(SourceProductionContext spc, VarsToGenerate? vars) + static TelemetryProperty CreateProperty(TelemetryVar variable, VarItem item) { - var stopwatch = Stopwatch.StartNew(); - IncrementCounter(TelemetryConstants.COUNTER_EXECUTE_CALLS); - - try + // the wire type (irsdk_VarType) decides the storage; some int and bitfield + // vars have semantic enum. bitfields without one fall back to int + var enumType = item.Type switch { - if (vars is null) - return; - - var values = vars.Value.vars; - var duplicates = vars.Value.duplicates; - - ReportDuplicateVariableDiagnostics(spc, duplicates); - ReportUnknownVariableDiagnostics(spc, values); - - // an empty list is valid but often an accident, so surface an - // informational diagnostic - if (values.Length == 0) - { - spc.ReportDiagnostic(CreateEmptyVariableListDiagnostic(vars.Value.attributeLocation)); - } - - // filter out duplicates and invalid variables before generating code - var validValues = FilterValidVariables(values, duplicates); - - // always generate TelemetryData when the attribute is present, even with - // no valid variables - the type is required to instantiate TelemetryClient - var varList = GenerateVariableList(validValues); - var code = GenerateSourceCode(varList); - - spc.AddSource("iRacingTelemetrySDK.g.cs", code); - IncrementCounter(TelemetryConstants.COUNTER_SOURCES_GENERATED); - } - finally + 2 => GetEnumTypeForVariable(item.Name), + 3 => GetFlagTypeForVariable(item.Name), + _ => null + }; + var elementType = enumType ?? item.Type switch { - stopwatch.Stop(); - RecordTime(TelemetryConstants.TIME_EXECUTE_DURATION, stopwatch.Elapsed); - LogMetricsToDebug(); - } + 0 => "byte", + 1 => "bool", + 2 or 3 => "int", + 4 => "float", + 5 => "double", + _ => throw new NotSupportedException($"Unknown irsdk_VarType {item.Type} for {item.Name}") + }; + + if (item.Length > 1) + return new TelemetryProperty(variable.ToString(), $"{elementType}[]", $"Array<{elementType}>"); + + return new TelemetryProperty(variable.ToString(), elementType, enumType is null ? $"Value<{elementType}>" : $"Enum<{elementType}>"); } - private static VarType[] FilterValidVariables(VarType[] values, (TelemetryVar variable, Location location)[] duplicates) + static string? GetEnumTypeForVariable(string varName) => varName switch { - var duplicateVariables = new HashSet(duplicates.Select(d => d.variable)); - var seenVariables = new HashSet(); - var validVariables = new List(); - - foreach (var variable in values) - { - // skip if it's a duplicate or unknown variable - if (duplicateVariables.Contains(variable.variable) || variable.type == typeof(Exception)) - continue; - - // skip if we've already seen this variable - if (!seenVariables.Add(variable.variable)) - continue; - - validVariables.Add(variable); - } - - return validVariables.ToArray(); - } + "CarIdxTrackSurface" => nameof(TrackLocation), + "CarIdxTrackSurfaceMaterial" => nameof(TrackSurface), + "CarLeftRight" => nameof(CarLeftRight), + "PaceMode" => nameof(PaceMode), + "PlayerCarPitSvStatus" => nameof(PitServiceStatus), + "PlayerIncidents" => nameof(IncidentFlags), + "PlayerTrackSurface" => nameof(TrackLocation), + "PlayerTrackSurfaceMaterial" => nameof(TrackSurface), + "SessionState" => nameof(SessionState), + "TrackWetness" => nameof(TrackWetness), + _ => null + }; - private static void ReportDuplicateVariableDiagnostics(SourceProductionContext spc, (TelemetryVar variable, Location location)[] duplicates) + static string? GetFlagTypeForVariable(string varName) => varName switch { - foreach (var (variable, location) in duplicates) - { - var diagnostic = CreateDuplicateVariableDiagnostic(variable.ToString(), location); - spc.ReportDiagnostic(diagnostic); - } - } + "CamCameraState" => nameof(CameraState), + "CarIdxPaceFlags" => nameof(PaceFlags), + "CarIdxSessionFlags" => nameof(SessionFlags), + "EngineWarnings" => nameof(EngineWarnings), + "PitSvFlags" => nameof(PitServiceFlags), + "SessionFlags" => nameof(SessionFlags), + _ => null + }; - private static void ReportUnknownVariableDiagnostics(SourceProductionContext spc, VarType[] values) + static void ReportDiagnostics(SourceProductionContext spc, ImmutableArray models) { - foreach (var item in values) + for (int i = 0; i < models.Length; i++) { - if (item.type == typeof(Exception)) + if (i > 0) { - var diagnostic = CreateUnknownVariableDiagnostic(item.variable.ToString(), item.location); - spc.ReportDiagnostic(diagnostic); + // the extra class's own diagnostics would be noise, its list is never generated + spc.ReportDiagnostic(new DiagnosticInfo(MultipleAttributes, models[i].AttributeLocation, null).ToDiagnostic()); + continue; } + + foreach (var diagnostic in models[i].Diagnostics) + spc.ReportDiagnostic(diagnostic.ToDiagnostic()); } } - private static string GenerateVariableList(VarType[] values) + static string GenerateSourceCode(EquatableArray properties) { - var sb = new StringBuilder(); - for (int i = 0; i < values.Length; i++) - { - var item = values[i]; + // nullable properties: a var missing from the current session/ibt reads as null + var varList = string.Join("\n", + properties.Select(p => $" public {p.TypeName}? {p.Name} {{ get; init; }}")); - if (i > 0) - sb.AppendLine(); - - // Generate property declarations with nullable types - var nullableFriendlyTypeName = GetNullableFriendlyTypeName(item.type); - var propertyDeclaration = $" public {nullableFriendlyTypeName} {item.variable} {{ get; init; }}"; - sb.Append(propertyDeclaration); - } - return sb.ToString(); - } + // property initializers for IGeneratedTelemetryData.Read. populates + // TelemetryData without reflection, so it works with AOT + var readList = string.Join("\n", + properties.Select(p => $" {p.Name} = reader.{p.ReaderCall}(\"{p.Name}\"),")); - private static string GenerateSourceCode(string varList) - { return $$""" #nullable enable using System; @@ -334,240 +324,17 @@ namespace SVappsLAB.iRacingTelemetrySDK { /// /// Represents iRacing telemetry data with strongly-typed properties. - /// This record is generated based on the RequiredTelemetryVarsAttribute usage. - /// Properties return null when the corresponding telemetry variable is not available. /// - public record struct TelemetryData + public record struct TelemetryData : IGeneratedTelemetryData { {{varList}} - } - } - """; - } - - private static Diagnostic CreateDuplicateVariableDiagnostic(string name, Location location) - { - return Diagnostic.Create( - new DiagnosticDescriptor( - id: "DuplicateVarName", - title: "Duplicate Variable Name", - messageFormat: "Duplicate telemetry variable name found: \"{0}\".", - category: "Usage", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true), - location, - name); - } - - private static Diagnostic CreateEmptyVariableListDiagnostic(Location location) - { - return Diagnostic.Create( - new DiagnosticDescriptor( - id: "EmptyVarList", - title: "Empty Variable List", - messageFormat: "No telemetry variables requested. The generated 'TelemetryData' type will have no properties.", - category: "Usage", - defaultSeverity: DiagnosticSeverity.Info, - isEnabledByDefault: true), - location); - } - - private static Diagnostic CreateUnknownVariableDiagnostic(string name, Location location) - { - return Diagnostic.Create( - new DiagnosticDescriptor( - id: "UnknownVarName", - title: "Unknown Variable Name", - messageFormat: "Unknown telemetry variable name: \"{0}\". Check spelling.", - category: "Usage", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true), - location, - name); - } - - private static Location GetAttributeLocation(GeneratorAttributeSyntaxContext context) - { - var attributeSyntax = context.TargetNode.DescendantNodes() - .OfType() - .FirstOrDefault(a => a.Name.ToString().Contains("RequiredTelemetryVars")); - - return attributeSyntax?.GetLocation() ?? Location.None; - } - - private static Location GetVariableLocation(GeneratorAttributeSyntaxContext context, int argIndex, int valueIndex) - { - var attributeSyntax = context.TargetNode.DescendantNodes() - .OfType() - .FirstOrDefault(a => a.Name.ToString().Contains("RequiredTelemetryVars")); - if (attributeSyntax?.ArgumentList?.Arguments == null || argIndex >= attributeSyntax.ArgumentList.Arguments.Count) - return Location.None; - - var argument = attributeSyntax.ArgumentList.Arguments[argIndex]; - - // handle array initializer syntax like new string[] { "var1", "var2" } - if (argument.Expression is ImplicitArrayCreationExpressionSyntax implicitArray) - { - if (implicitArray.Initializer?.Expressions != null && valueIndex < implicitArray.Initializer.Expressions.Count) - { - var expression = implicitArray.Initializer.Expressions[valueIndex]; - return expression.GetLocation(); - } - } - // handle explicit array creation syntax like new string[] { "var1", "var2" } - else if (argument.Expression is ArrayCreationExpressionSyntax arrayCreation) - { - if (arrayCreation.Initializer?.Expressions != null && valueIndex < arrayCreation.Initializer.Expressions.Count) - { - var expression = arrayCreation.Initializer.Expressions[valueIndex]; - return expression.GetLocation(); - } - } - // handle collection expression syntax like ["var1", "var2"] - else if (argument.Expression is CollectionExpressionSyntax collectionExpression) - { - if (collectionExpression.Elements != null && valueIndex < collectionExpression.Elements.Count) - { - var element = collectionExpression.Elements[valueIndex]; - if (element is ExpressionElementSyntax expressionElement) - { - return expressionElement.Expression.GetLocation(); + TelemetryData IGeneratedTelemetryData.Read(TelemetryValueReader reader) => new TelemetryData + { + {{readList}} + }; } } - } - // handle direct string literal - else if (valueIndex == 0) - { - return argument.Expression.GetLocation(); - } - - return Location.None; - } - - private Type GetIntOrEnumType(string varName, int length) - { - var enumType = GetEnumTypeForVariable(varName); - return CreateTypeForLength(enumType ?? typeof(int), length); - } - - private Type GetFlagType(string varName, int length) - { - var flagType = GetFlagTypeForVariable(varName); - if (flagType == null) - throw new NotImplementedException($"Unknown flag type: {varName}"); - - return CreateTypeForLength(flagType, length); - } - - private static Type? GetEnumTypeForVariable(string varName) => varName switch - { - "CarIdxTrackSurface" => typeof(TrackLocation), - "CarIdxTrackSurfaceMaterial" => typeof(TrackSurface), - "CarLeftRight" => typeof(CarLeftRight), - "PaceMode" => typeof(PaceMode), - "PlayerCarPitSvStatus" => typeof(PitServiceStatus), - "PlayerTrackSurface" => typeof(TrackLocation), - "PlayerTrackSurfaceMaterial" => typeof(TrackSurface), - "SessionState" => typeof(SessionState), - "TrackWetness" => typeof(TrackWetness), - _ => null - }; - - private static Type? GetFlagTypeForVariable(string varName) => varName switch - { - "CamCameraState" => typeof(CameraState), - "CarIdxPaceFlags" => typeof(PaceFlags), - "CarIdxSessionFlags" => typeof(SessionFlags), - "EngineWarnings" => typeof(EngineWarnings), - "PitSvFlags" => typeof(PitServiceFlags), - "PlayerIncidents" => typeof(IncidentFlags), - "SessionFlags" => typeof(SessionFlags), - _ => null - }; - - private static Type CreateTypeForLength(Type baseType, int length) - { - return length == 1 ? baseType : baseType.MakeArrayType(); - } - - private static readonly Dictionary TypeNameMappings = new() - { - ["Int32"] = "int", - ["Single"] = "float", - ["Double"] = "double", - ["Boolean"] = "bool", - ["Byte"] = "byte", - ["String"] = "string" - }; - - private static string GetFriendlyTypeName(Type type) - { - if (type.IsArray) - { - var elementType = type.GetElementType(); - return elementType != null ? $"{GetFriendlyTypeName(elementType)}[]" : "object[]"; - } - - return TypeNameMappings.TryGetValue(type.Name, out var friendlyName) ? friendlyName : type.Name; - } - - private static string GetNullableFriendlyTypeName(Type type) - { - var baseName = GetFriendlyTypeName(type); - return $"{baseName}?"; - } - - // telemetry helpers - private static void IncrementCounter(string key, long increment = 1) => _performanceCounters.AddOrUpdate(key, increment, (k, v) => v + increment); - private static void RecordTime(string key, TimeSpan elapsed) => _performanceTimes.AddOrUpdate(key, elapsed, (k, v) => v + elapsed); - - // dump performance metrics (debugging/monitoring) - internal static Dictionary GetPerformanceMetrics() - { - var metrics = new Dictionary(); - - // add counter metrics - foreach (var counter in _performanceCounters) - { - metrics[$"counter_{counter.Key}"] = counter.Value; - } - - // add timing metrics - foreach (var time in _performanceTimes) - { - metrics[$"time_{time.Key}_ms"] = time.Value.TotalMilliseconds; - } - - // add system metrics - metrics["cache_size"] = _typeCache.Count; - metrics["data_loaded"] = _iRacingData.IsValueCreated; - - return metrics; - } - private static void LogMetricsToDebug() - { - var metrics = GetPerformanceMetrics(); - foreach (var metric in metrics) - { - System.Diagnostics.Debug.WriteLine($"CodeGen Metric: {metric.Key} = {metric.Value}"); - } + """; } } -internal static class TelemetryConstants -{ - // counter metrics - public const string COUNTER_TRANSFORMER_CALLS = "transformer_calls"; - public const string COUNTER_EXECUTE_CALLS = "execute_calls"; - public const string COUNTER_VARIABLES_PROCESSED = "variables_processed"; - public const string COUNTER_CACHE_HITS = "cache_hits"; - public const string COUNTER_CACHE_MISSES = "cache_misses"; - public const string COUNTER_UNKNOWN_VARIABLES = "unknown_variables"; - public const string COUNTER_DUPLICATE_VARIABLES_FOUND = "duplicate_variables_found"; - public const string COUNTER_SOURCES_GENERATED = "sources_generated"; - - // time metrics - public const string TIME_TRANSFORMER_DURATION = "transformer_duration"; - public const string TIME_EXECUTE_DURATION = "execute_duration"; -} - diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj index f23159d..9eca183 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/SVappsLAB.iRacingTelemetrySDK.CodeGen.csproj @@ -4,10 +4,13 @@ netstandard2.0 true true + + $(NoWarn);RS2008 + - + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/README.md b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/README.md deleted file mode 100644 index 3280f15..0000000 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Tools - -## vars - -Some python scripts to help with the generation of the `iRacingData.cs` file used during code generation. - -* findDistinctVars.py - - Reads a glob of *.csv files (from various variable dumps of live and ibt files), and outputs a single CSV file with the unique/distinct set of variables - -* csv-to-cs.py - - Reads a CSV file of variables and outputs a snippet of C# code, that can be merged with the `iRacingData.cs` file - -### usage - -```bash -python findDistinctVars.py "variableDumps/*.csv" "distinctVars.csv" -python csv-to-cs.py "distinctVars.csv" "varDictData.cs" -``` -use merge tool to manually merge `varDictData.cs` with `iRacingData.cs` diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/vars/csv-to-cs.py b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/vars/csv-to-cs.py deleted file mode 100644 index a8bcd2a..0000000 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/vars/csv-to-cs.py +++ /dev/null @@ -1,67 +0,0 @@ -#** - # 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.CodeAnalysis; -#** - -import csv -import argparse - -# Set up argument parser -parser = argparse.ArgumentParser(description='convert csv file to formatted txt file.') -parser.add_argument('input_csv', type=str, help='input csv file path') -parser.add_argument('output_txt', type=str, help='output txt file path') - -args = parser.parse_args() - -type_mapping = { - 'String': 0, - 'String[]': 0, - 'Boolean': 1, - 'Boolean[]': 1, - 'Int32': 2, - 'Int32[]': 2, - 'UInt32': 3, - 'UInt32[]': 3, - 'Single': 4, - 'Single[]': 4, - 'Double': 5, - 'Double[]': 5 -} - -# read CSV -with open(args.input_csv, mode='r', newline='') as csv_file: - reader = csv.DictReader(csv_file) - - output_lines = [] - - # each row - for row in reader: - name = row['name'] - type_ = row['type'] - length = row['length'] - isTimeValue = row['isTimeValue'].lower() == 'true' # boolean - desc = row['desc'] - units = row['units'] - - # output type num - output_type = type_mapping.get(type_) - - formatted_line = f'{{ "{name}", new VarItem("{name}", {output_type}, {length}, {str(isTimeValue).lower()}, "{desc}", "{units}") }},' - output_lines.append(formatted_line) - -# write -with open(args.output_txt, mode='w') as txt_file: - txt_file.write('\n'.join(output_lines) + '\n') - -print(f'Formatted output saved to {args.output_txt}') diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/vars/findDistinctVars.py b/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/vars/findDistinctVars.py deleted file mode 100644 index 313a71d..0000000 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.CodeGen/tools/vars/findDistinctVars.py +++ /dev/null @@ -1,64 +0,0 @@ -#** - # 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.CodeAnalysis; -#** - -import csv -import glob -import argparse -import sys - -parser = argparse.ArgumentParser(description='process csv files based on a glob pattern.') -parser.add_argument('path', type=str, help='glob pattern for matching csv files (e.g., /path/to/*.csv)') -parser.add_argument('output', type=str, help='output csv file name') - -args = parser.parse_args() - -path = args.path -output_file = args.output - -csv_files = glob.glob(path) - -# dictionary of rows -unique_rows = {} - -# read and process all CSV files -for file in csv_files: - with open(file, mode='r', newline='') as f: - reader = csv.reader(f) - headers = next(reader) - headers = [header.strip() for header in headers] # strip whitespace from headers - - reader = csv.DictReader(f, fieldnames=headers) - - for row in reader: - name = row['name'] # use 'name' column to check for duplicates - if name not in unique_rows: - unique_rows[name] = row # store row if 'name' is not already in dictionary - -# sort -sorted_rows = sorted(unique_rows.values(), key=lambda row: row['name']) - - -# header order -header_order = ['name', 'type', 'length', 'isTimeValue', 'desc', 'units'] - -# write distinct rows to the output CSV -with open(output_file, mode='w', newline='') as f: - if sorted_rows: - writer = csv.DictWriter(f, fieldnames=header_order) - writer.writeheader() - writer.writerows(sorted_rows) - -print(f'distinct sorted data saved to {output_file}') diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.EnumsAndFlags/TelemetryClient_Flags.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK.EnumsAndFlags/TelemetryClient_Flags.cs index b4041bb..9d4d50c 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.EnumsAndFlags/TelemetryClient_Flags.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK.EnumsAndFlags/TelemetryClient_Flags.cs @@ -105,7 +105,6 @@ public enum PaceFlags WavedAround = 0x0004, }; -[Flags] public enum IncidentFlags { // first byte is incident report flag diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx b/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx index 72c647b..77b8326 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK.slnx @@ -16,6 +16,7 @@ + diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs index 0bf5bfb..cb10ec7 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/DataProviders/IBTDataProvider.cs @@ -70,7 +70,8 @@ public override void OpenDataSource() if (_logger.IsEnabled(LogLevel.Debug)) LogIBTFileLayout(); - _governor.StartPlayback(); + // pace records at the rate they were written + _governor.StartPlayback(_header.tickRate); } // validate @@ -162,17 +163,19 @@ public int GetNumRecordsInIBTFile(long fileLength) // wait for iRacing to signal there is new data public override Task WaitForDataReady(TimeSpan _timeSpan, CancellationToken cancellationToken = default) { - return IBTDataProviderAsyncHelper.WaitForDataReady(_governor, _currentRecord, this); + return IBTDataProviderAsyncHelper.WaitForDataReady(_governor, _currentRecord, this, cancellationToken); } + // reads the next record into the buffer. returns false at end of file, when nothing was read internal bool ProcessNextRecord() { - CopyNewTelemetryDataToBuffer(_currentRecord); + if (_currentRecord >= _numRecords) + return false; + CopyNewTelemetryDataToBuffer(_currentRecord); _currentRecord++; - // return true if there is more data to process - return _currentRecord < _numRecords; + return true; } irsdk_diskSubHeader GetDiskSubHeader() @@ -195,10 +198,10 @@ public override ValueTask DisposeAsync() // Helper class to handle async operations outside unsafe context internal static class IBTDataProviderAsyncHelper { - public static async Task WaitForDataReady(IPlaybackGovernor governor, int currentRecord, IBTDataProvider provider) + public static async Task WaitForDataReady(IPlaybackGovernor governor, int currentRecord, IBTDataProvider provider, CancellationToken cancellationToken) { - // throttle playback speed - await governor.GovernSpeed(currentRecord).ConfigureAwait(false); + // wait until the record is due. the governor is the only thing pacing playback + await governor.GovernSpeed(currentRecord, cancellationToken).ConfigureAwait(false); // measured after the wait, so any delay in the read loop shows up as lag governor.RecordLag(currentRecord); diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/GeneratedTelemetryData.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/GeneratedTelemetryData.cs new file mode 100644 index 0000000..e955fb6 --- /dev/null +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/GeneratedTelemetryData.cs @@ -0,0 +1,98 @@ +/** + * 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.DataProviders; +using System.ComponentModel; +using System.Globalization; + +namespace SVappsLAB.iRacingTelemetrySDK; + +[EditorBrowsable(EditorBrowsableState.Never)] +public interface IGeneratedTelemetryData where T : struct +{ + /// Builds a sample from the current telemetry values. + T Read(TelemetryValueReader reader); +} + +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class TelemetryValueReader +{ + readonly IDataProvider _dataProvider; + readonly ILogger _logger; + + internal TelemetryValueReader(IDataProvider dataProvider, ILogger logger) + { + _dataProvider = dataProvider; + _logger = logger; + } + + internal IDataProvider DataProvider => _dataProvider; + + public TValue? Value(string name) where TValue : struct, IConvertible + { + var raw = _dataProvider.GetVarValue(name); + if (raw is null) return null; + if (raw is TValue value) return value; + + try + { + return (TValue)Convert.ChangeType(raw, typeof(TValue), CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + LogConversionFailure(name, raw, ex); + return null; + } + } + + public TEnum? Enum(string name) where TEnum : struct, Enum + { + var raw = _dataProvider.GetVarValue(name); + if (raw is null) return null; + + try + { + return (TEnum)System.Enum.ToObject(typeof(TEnum), (int)raw); + } + catch (Exception ex) + { + LogConversionFailure(name, raw, ex); + return null; + } + } + + public TElement[]? Array(string name) + { + var raw = _dataProvider.GetVarValue(name); + if (raw is null) return null; + + try + { + // an int[] casts to an array of an int-backed enum without copying + return (TElement[])raw; + } + catch (Exception ex) + { + LogConversionFailure(name, raw, ex); + return null; + } + } + + void LogConversionFailure(string name, object raw, Exception ex) => + _logger.LogWarning("Failed to set property {PropertyName} with value '{RawValue}': {Error}", + name, raw, ex.Message); +} diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/IBTPlayback/Governor.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/IBTPlayback/Governor.cs index 1f1523a..6093097 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/IBTPlayback/Governor.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/IBTPlayback/Governor.cs @@ -16,32 +16,30 @@ using System; using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace SVappsLAB.iRacingTelemetrySDK.IBTPlayback; -internal record GovernorStats(long elapsedMs, int CurrentRecord, int TargetRecord, double OldDelay, double NewDelay); - internal interface IPlaybackGovernor { - public void StartPlayback(); - public Task GovernSpeed(int recNum); + public void StartPlayback(int recordsPerSecond = SimpleGovernor.DEFAULT_RECORDS_PER_SECOND); + public Task GovernSpeed(int recNum, CancellationToken ct = default); public void RecordLag(int recNum); - public GovernorStats GetStats(); } internal class SimpleGovernor : IPlaybackGovernor { - const int STANDARD_HZ = 60; - const double STANDARD_MS_PER_RECORD = 1000d / STANDARD_HZ; - ILogger _logger; - int _playbackSpeedMultiplier; - double _adjustmentAmountInMs; - TimeSpan _delayTimeSpan; // current delay amount - Stopwatch _stopwatch = new Stopwatch(); - GovernorStats? _governorStats; + internal const int DEFAULT_RECORDS_PER_SECOND = 60; + + readonly ILogger _logger; + readonly int _playbackSpeedMultiplier; readonly Action? _onPlaybackLag; + readonly Stopwatch _stopwatch = new Stopwatch(); + double _msPerRecord; + + bool IsMaxSpeed => _playbackSpeedMultiplier == int.MaxValue; // onPlaybackLag is called from RecordLag with how far behind its scheduled time a record is being read public SimpleGovernor(ILogger logger, int playbackSpeedMultiplier, Action? onPlaybackLag = null) @@ -49,77 +47,48 @@ public SimpleGovernor(ILogger logger, int playbackSpeedMultiplier, Action _governorStats ?? throw new InvalidOperationException("No stats available."); - - public void StartPlayback() + // recordsPerSecond is the file's tick rate - the rate the records were written at + public void StartPlayback(int recordsPerSecond = DEFAULT_RECORDS_PER_SECOND) { - // at startup, the processing time seems to be about 1/4 of what we need to match the playback speed - // so we start with an artificially small delay that is 1/4 of the standard delay. - // the normal governor logic will align us with the correct target delay - _delayTimeSpan = TimeSpan.FromMilliseconds(STANDARD_MS_PER_RECORD / _playbackSpeedMultiplier) / 4; - _stopwatch.Start(); + if (recordsPerSecond <= 0) + { + _logger.LogWarning("IBT file has tick rate {tickRate}, playing back at {defaultRate} records per second", + recordsPerSecond, DEFAULT_RECORDS_PER_SECOND); + recordsPerSecond = DEFAULT_RECORDS_PER_SECOND; + } + + _msPerRecord = 1000d / recordsPerSecond / _playbackSpeedMultiplier; + _stopwatch.Restart(); } - public Task GovernSpeed(int recNum) + public async Task GovernSpeed(int recNum, CancellationToken ct = default) { if (!_stopwatch.IsRunning) throw new InvalidOperationException("Playback has not been started."); - // short circuit if we are at max speed - if (_playbackSpeedMultiplier == int.MaxValue) - { - return Task.CompletedTask; - } + // no schedule at max speed. returning before the first await completes synchronously, + // so the unpaced path costs nothing per record + if (IsMaxSpeed) + return; - // every 30 records, (1/2 of the 60hz rate), recalculate the delay amount to account for drift - if (recNum != 0) - { - if (recNum % (STANDARD_HZ / 2) == 0) - { - CalculateGoverningDelay(recNum); - } - } - // slow down processing to match the playback speed - return Task.Delay(_delayTimeSpan); + // Task.Delay can wake early, wait until the deadline has really passed + double waitMs; + while ((waitMs = DueMs(recNum) - _stopwatch.Elapsed.TotalMilliseconds) > 0) + await Task.Delay(TimeSpan.FromMilliseconds(waitMs), ct).ConfigureAwait(false); } // reports how far behind its scheduled time a record is being read, zero when on time. // nothing is reported at max speed, where there is no schedule public void RecordLag(int recNum) { - if (_onPlaybackLag == null || _playbackSpeedMultiplier == int.MaxValue) + if (_onPlaybackLag == null || IsMaxSpeed) return; - var dueMs = recNum * STANDARD_MS_PER_RECORD / _playbackSpeedMultiplier; - var lagMs = Math.Max(0, _stopwatch.Elapsed.TotalMilliseconds - dueMs); + var lagMs = Math.Max(0, _stopwatch.Elapsed.TotalMilliseconds - DueMs(recNum)); _onPlaybackLag(TimeSpan.FromMilliseconds(lagMs)); } - void CalculateGoverningDelay(int currentRecNum) - { - var elapsed = _stopwatch.ElapsedMilliseconds; - var targetRecNum = elapsed / (STANDARD_MS_PER_RECORD / _playbackSpeedMultiplier); - - var oldDelay = _delayTimeSpan; - if (currentRecNum < targetRecNum) - { - if (_delayTimeSpan.TotalMilliseconds > _adjustmentAmountInMs) - _delayTimeSpan -= TimeSpan.FromMilliseconds(_adjustmentAmountInMs); - else - _delayTimeSpan = TimeSpan.Zero; - } - else - { - _delayTimeSpan += TimeSpan.FromMilliseconds(_adjustmentAmountInMs); - } - - _governorStats = new GovernorStats(elapsed, currentRecNum, (int)targetRecNum, oldDelay.TotalMilliseconds, _delayTimeSpan.TotalMilliseconds); - - _logger.LogDebug("calc governor. stats: {stats}", _governorStats); - } + double DueMs(int recNum) => recNum * _msPerRecord; } - - diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj b/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj index b1c8d78..f43140d 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/SVappsLAB.iRacingTelemetrySDK.csproj @@ -1,8 +1,9 @@ - + true true + true @@ -16,6 +17,9 @@ <_Parameter1>SmokeTests + + <_Parameter1>AotSmokeTest + @@ -26,6 +30,7 @@ + @@ -43,7 +48,7 @@ iRacing iRacingSDK irsdk IBT telemetry SDK API sim-control replay broadcast NUGET.md LICENSE - 2.5.0 + 2.6.0 Scott Velez SVappsLAB Copyright (c) 2026 SVappsLAB diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs index 701779e..9211295 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryClient.cs @@ -20,6 +20,7 @@ using SVappsLAB.iRacingTelemetrySDK.SimControl; using SVappsLAB.iRacingTelemetrySDK.YamlParsing; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; @@ -75,8 +76,10 @@ public record class TelemetryVariable /// }, cancellationToken); /// /// -public readonly struct DynamicTelemetryData +public readonly struct DynamicTelemetryData : IGeneratedTelemetryData { + // carries no values, so there's nothing to read + DynamicTelemetryData IGeneratedTelemetryData.Read(TelemetryValueReader reader) => default; } /** @@ -119,7 +122,7 @@ public enum TelemetryDeliveryMode Async, /// - /// This is the slower, blocking, optional sychronous mode. + /// This is the slower, blocking, optional sychronous mode. /// Only supported via /// with /// set - both Monitor(CancellationToken) @@ -151,7 +154,8 @@ public class ClientOptions public IMeterFactory? MeterFactory { get; init; } } -public class TelemetryClient : ITelemetryClient, IAsyncDisposable where T : struct +// annotation on T keeps the public properties from being trimmed +public class TelemetryClient<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T> : ITelemetryClient, IAsyncDisposable where T : struct { const int DATA_READY_TIMEOUT_MS = 30; const int INITIALIZATION_DELAY_MS = 1000; @@ -174,6 +178,9 @@ public class TelemetryClient : ITelemetryClient, IAsyncDisposable where T private ILogger _logger; private volatile bool _isInitialized = false; + + // typed session info is parsed only when OnSessionInfoUpdate handler is active + private volatile bool _parseSessionInfo = true; private volatile bool _isPaused = false; private bool _lastConnectionStatus = false; @@ -214,7 +221,14 @@ private sealed class SyncHandlerFaultException(Exception innerException) : Excep public IAsyncEnumerable SessionDataYaml => GetRawSessionDataEnumerable(); /// - public IAsyncEnumerable SessionData => GetSessionDataEnumerable(); + public IAsyncEnumerable SessionData + { + get + { + _parseSessionInfo = true; + return GetSessionDataEnumerable(); + } + } /// public IAsyncEnumerable TelemetryData => @@ -404,6 +418,8 @@ public async Task Monitor(TelemetryHandlers handlers, CancellationToken BeginMonitor(); + _parseSessionInfo = handlers.OnSessionInfoUpdate != null; + var subscriptionTask = SubscribeToHandlers(handlers, linkedCts); var monitorTask = RunMonitor(linkedCts.Token); @@ -889,6 +905,11 @@ private async Task ProcessSessionInfoChannel(CancellationToken ct) { _rawSessionDataChannel.Writer.TryWrite(rawYaml); + if (!_parseSessionInfo) + { + continue; + } + var sessionInfo = ParseSessionInfo(rawYaml); if (sessionInfo != null) { @@ -1150,11 +1171,8 @@ private async Task ProcessIbtData(CancellationToken token) } } - // Process with respect for playback speed multiplier - var playbackDelay = _ibtOptions?.PlayBackSpeedMultiplier == int.MaxValue ? TimeSpan.Zero : - TimeSpan.FromMilliseconds(16.67 / (_ibtOptions?.PlayBackSpeedMultiplier ?? 1)); // ~60 FPS base rate - - // loop until we are at eof, cancelled, or shutdown requested + // loop until we are at eof, cancelled, or shutdown requested. WaitForDataReady + // paces the records to the requested playback speed for (numRecords = 0; !token.IsCancellationRequested; numRecords++) { var dataAvailable = await _dataProvider.WaitForDataReady(TimeSpan.Zero, token).ConfigureAwait(false); @@ -1172,12 +1190,6 @@ private async Task ProcessIbtData(CancellationToken token) await DeliverTelemetryData(new(GetTelemetryDataSample(), acquiredTimestamp)).ConfigureAwait(false); } - // Apply playback speed delay if configured - if (playbackDelay > TimeSpan.Zero) - { - await Task.Delay(playbackDelay, token).ConfigureAwait(false); - } - // Yield periodically for responsive cancellation if (numRecords % 100 == 0) { @@ -1187,7 +1199,7 @@ private async Task ProcessIbtData(CancellationToken token) } sw.Stop(); - var recsPerSec = Math.Round(numRecords / (sw.ElapsedMilliseconds + 1) * 1000f, 1); // +1 to avoid division by zero + var recsPerSec = Math.Round(numRecords * 1000d / (sw.ElapsedMilliseconds + 1), 1); // +1 to avoid division by zero var minsOfData = Math.Round(numRecords / 60f / 60f, 2); _logger.LogInformation("processed {numRecords} IBT telemetry records ({minsOfData} mins worth of session data), in {milliseconds}ms. ({rate} recs/sec)", numRecords, minsOfData, sw.ElapsedMilliseconds, recsPerSec); } diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryDataAccessor.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryDataAccessor.cs index 0672529..a14f19b 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryDataAccessor.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/TelemetryDataAccessor.cs @@ -1,12 +1,12 @@ /** * 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. @@ -15,38 +15,57 @@ **/ using System; -using System.Collections.Concurrent; -using System.Linq.Expressions; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Reflection; -using System.Runtime.CompilerServices; using Microsoft.Extensions.Logging; using SVappsLAB.iRacingTelemetrySDK.DataProviders; namespace SVappsLAB.iRacingTelemetrySDK; /// -/// High-performance accessor for telemetry data that eliminates boxing/unboxing overhead -/// through ref-based operations and compiled expression trees. +/// Builds telemetry data samples of type from the data provider. /// /// The telemetry data struct type -internal sealed class TelemetryDataAccessor where T : struct +internal sealed class TelemetryDataAccessor<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T> where T : struct { private readonly ILogger _logger; + private TelemetryValueReader? _reader; + private readonly IGeneratedTelemetryData? _generated; private readonly PropertyAccessor[] _propertyAccessors; public TelemetryDataAccessor(ILogger logger) { _logger = logger; - _propertyAccessors = CompilePropertyAccessors(); + + if (default(T) is IGeneratedTelemetryData generated) + { + _generated = generated; + _propertyAccessors = []; + } + else + { + _propertyAccessors = GetPropertyAccessors(); + } } /// - /// Creates a new telemetry data sample with high performance, avoiding boxing/unboxing + /// Creates a new telemetry data sample from the provider's current values /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] public T CreateTelemetryDataSample(IDataProvider dataProvider) { - var telemetryData = default(T); + if (_generated != null) + { + // the provider is fixed for a client's lifetime, so the reader is too + if (_reader?.DataProvider != dataProvider) + { + _reader = new TelemetryValueReader(dataProvider, _logger); + } + return _generated.Read(_reader); + } + + // reflection fallback. properties are set on one boxed copy, then unboxed + object telemetryData = default(T); foreach (var accessor in _propertyAccessors) { @@ -55,8 +74,7 @@ public T CreateTelemetryDataSample(IDataProvider dataProvider) try { - var convertedValue = accessor.ConvertValue(rawValue); - accessor.SetValue(ref telemetryData, convertedValue); + accessor.SetValue(telemetryData, rawValue); } catch (Exception ex) { @@ -65,11 +83,10 @@ public T CreateTelemetryDataSample(IDataProvider dataProvider) } } - return telemetryData; + return (T)telemetryData; } - - private static PropertyAccessor[] CompilePropertyAccessors() + private static PropertyAccessor[] GetPropertyAccessors() { var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); var accessors = new PropertyAccessor[properties.Length]; @@ -82,110 +99,36 @@ private static PropertyAccessor[] CompilePropertyAccessors() return accessors; } - private delegate void RefSetter(ref TStruct instance, object value) where TStruct : struct; - private sealed class PropertyAccessor { - private static readonly ConcurrentDictionary> _converterCache - = new ConcurrentDictionary>(); - - public string PropertyName { get; } - public Type PropertyType { get; } - public Type UnderlyingType { get; } - public bool IsEnum { get; } - public bool IsNullable { get; } + private readonly PropertyInfo _property; + private readonly Type _targetType; + private readonly Type _underlyingType; - private readonly RefSetter _refSetter; - private readonly Func _converter; + public string PropertyName => _property.Name; public PropertyAccessor(PropertyInfo property) { - PropertyName = property.Name; - PropertyType = property.PropertyType; - UnderlyingType = Nullable.GetUnderlyingType(PropertyType) ?? PropertyType; - IsEnum = UnderlyingType.IsEnum; - IsNullable = PropertyType != UnderlyingType; - - _refSetter = CompileRefPropertySetter(property); - _converter = GetOrCompileConverter(PropertyType, UnderlyingType, IsEnum); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void SetValue(ref T instance, object value) - { - _refSetter(ref instance, value); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public object ConvertValue(object value) - { - return _converter(value); + _property = property; + _targetType = property.PropertyType; + _underlyingType = Nullable.GetUnderlyingType(_targetType) ?? _targetType; } - private static RefSetter CompileRefPropertySetter(PropertyInfo property) - { - var instanceParam = Expression.Parameter(typeof(T).MakeByRefType(), "instance"); - var valueParam = Expression.Parameter(typeof(object), "value"); - - var valueCast = Expression.Convert(valueParam, property.PropertyType); - var propertyAccess = Expression.Property(instanceParam, property); - var assignment = Expression.Assign(propertyAccess, valueCast); - - var lambda = Expression.Lambda>( - assignment, instanceParam, valueParam); - - return lambda.Compile(); - } + public void SetValue(object boxedInstance, object rawValue) => + _property.SetValue(boxedInstance, ConvertValue(rawValue)); - private static Func GetOrCompileConverter(Type targetType, Type underlyingType, bool isEnum) + private object ConvertValue(object value) { - return _converterCache.GetOrAdd(targetType, _ => CompileConverter(targetType, underlyingType, isEnum)); - } - - private static Func CompileConverter(Type targetType, Type underlyingType, bool isEnum) - { - var valueParam = Expression.Parameter(typeof(object), "value"); - Expression conversionExpr; - - if (isEnum) - { - // Handle enum conversion: if value is int, convert to enum - var valueAsInt = Expression.Convert(valueParam, typeof(int)); - conversionExpr = Expression.Call( - typeof(Enum).GetMethod(nameof(Enum.ToObject), new[] { typeof(Type), typeof(object) })!, - Expression.Constant(underlyingType), - Expression.Convert(valueAsInt, typeof(object))); - conversionExpr = Expression.Convert(conversionExpr, targetType); - } - else if (targetType.IsArray) - { - // Handle array types: direct cast, no conversion needed - conversionExpr = Expression.Convert(valueParam, targetType); - } - else if (targetType == typeof(object)) - { - // No conversion needed - conversionExpr = valueParam; - } - else if (Nullable.GetUnderlyingType(targetType) != null) - { - // Handle nullable types: convert to underlying type first, then to nullable - var underlyingConvertMethod = typeof(Convert).GetMethod(nameof(Convert.ChangeType), new[] { typeof(object), typeof(Type) }); - var underlyingConversion = Expression.Call(underlyingConvertMethod!, valueParam, Expression.Constant(underlyingType)); - conversionExpr = Expression.Convert(underlyingConversion, targetType); - } - else - { - // Standard type conversion - var convertMethod = typeof(Convert).GetMethod(nameof(Convert.ChangeType), new[] { typeof(object), typeof(Type) }); - conversionExpr = Expression.Call(convertMethod!, valueParam, Expression.Constant(targetType)); - conversionExpr = Expression.Convert(conversionExpr, targetType); - } + // enums (and flags) arrive as their int value + if (_underlyingType.IsEnum) + return Enum.ToObject(_underlyingType, (int)value); - var lambda = Expression.Lambda>( - Expression.Convert(conversionExpr, typeof(object)), valueParam); + // arrays and object properties take the provider's value as is + if (_targetType.IsArray || _targetType == typeof(object)) + return value; - return lambda.Compile(); + // nullable types box as their underlying value, so converting to that is enough + return Convert.ChangeType(value, _underlyingType, CultureInfo.InvariantCulture); } } } diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/YamlParsing/SessionInfoYamlContext.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/YamlParsing/SessionInfoYamlContext.cs new file mode 100644 index 0000000..de8fd4d --- /dev/null +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/YamlParsing/SessionInfoYamlContext.cs @@ -0,0 +1,50 @@ +/** + * 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 System.ComponentModel; +using YamlDotNet.Serialization; + +namespace SVappsLAB.iRacingTelemetrySDK.YamlParsing; + +// compile-time (de)serialization metadata for the session info models +// include each type. a type that's missing fails at runtime +[YamlStaticContext] +[YamlSerializable(typeof(TelemetrySessionInfo))] +[YamlSerializable(typeof(WeekendInfo))] +[YamlSerializable(typeof(WeekendOptions))] +[YamlSerializable(typeof(TelemetryOptions))] +[YamlSerializable(typeof(SessionInfo))] +[YamlSerializable(typeof(Session))] +[YamlSerializable(typeof(Session.QualifyPosition))] +[YamlSerializable(typeof(Session.ResultPosition))] +[YamlSerializable(typeof(Session.ResultFastestLap))] +[YamlSerializable(typeof(QualifyResultsInfo))] +[YamlSerializable(typeof(QualifyResultsInfo.Result))] +[YamlSerializable(typeof(CameraInfo))] +[YamlSerializable(typeof(Group))] +[YamlSerializable(typeof(Camera))] +[YamlSerializable(typeof(RadioInfo))] +[YamlSerializable(typeof(Radio))] +[YamlSerializable(typeof(Frequency))] +[YamlSerializable(typeof(DriverInfo))] +[YamlSerializable(typeof(Driver))] +[YamlSerializable(typeof(DriverTire))] +[YamlSerializable(typeof(SplitTimeInfo))] +[YamlSerializable(typeof(Sector))] +[EditorBrowsable(EditorBrowsableState.Never)] +public partial class SessionInfoYamlContext : YamlDotNet.Serialization.StaticContext +{ +} diff --git a/Sdk/SVappsLAB.iRacingTelemetrySDK/YamlParsing/YamlParser.cs b/Sdk/SVappsLAB.iRacingTelemetrySDK/YamlParsing/YamlParser.cs index 53328ae..954e7c1 100644 --- a/Sdk/SVappsLAB.iRacingTelemetrySDK/YamlParsing/YamlParser.cs +++ b/Sdk/SVappsLAB.iRacingTelemetrySDK/YamlParsing/YamlParser.cs @@ -1,12 +1,12 @@ /** * 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. @@ -35,7 +35,7 @@ internal class YamlParser : ISessionInfoParser public YamlParser(ILogger logger) { _logger = logger; - _yamlDeserializer = new DeserializerBuilder() + _yamlDeserializer = new StaticDeserializerBuilder(new SessionInfoYamlContext()) #if RELEASE .IgnoreUnmatchedProperties() #endif diff --git a/Sdk/tests/AotSmokeTest/AotSmokeTest.csproj b/Sdk/tests/AotSmokeTest/AotSmokeTest.csproj new file mode 100644 index 0000000..6638e2e --- /dev/null +++ b/Sdk/tests/AotSmokeTest/AotSmokeTest.csproj @@ -0,0 +1,20 @@ + + + + + Exe + false + true + true + + $(NoWarn);CS0436 + + + + + + + + + + diff --git a/Sdk/tests/AotSmokeTest/Program.cs b/Sdk/tests/AotSmokeTest/Program.cs new file mode 100644 index 0000000..03941bd --- /dev/null +++ b/Sdk/tests/AotSmokeTest/Program.cs @@ -0,0 +1,348 @@ +/** + * 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 Microsoft.Extensions.Logging.Abstractions; +using SVappsLAB.iRacingTelemetrySDK; +using SVappsLAB.iRacingTelemetrySDK.DataProviders; +using SVappsLAB.iRacingTelemetrySDK.irSDKDefines; + +namespace AotSmokeTest; + +// one variable of each kind the generated reader handles +[RequiredTelemetryVars([ + TelemetryVar.IsOnTrack, // bool + TelemetryVar.SessionNum, // int + TelemetryVar.SessionState, // enum + TelemetryVar.EngineWarnings, // flags + TelemetryVar.CarIdxTrackSurface, // enum array + TelemetryVar.SteeringWheelTorque_ST, // float array + TelemetryVar.RPM, // float + TelemetryVar.SessionTime, // double + ])] +internal class Program +{ + // manual (not source generated), populated through the reflection fallback + public struct ManualTelemetryData + { + public float RPM { get; set; } + public double? SessionTime { get; set; } + public SessionState? SessionState { get; set; } + public TrackLocation[]? CarIdxTrackSurface { get; set; } + } + + const string ConversionFailureMessage = "Failed to set property"; + const int MaxReportedMismatches = 10; + + static readonly List Failures = []; + + // where telemetry comes from: an IBT file played to the end, or live iRacing for a fixed time + sealed record Source(IBTOptions? IbtOptions, TimeSpan LiveDuration) + { + public CancellationTokenSource CreateRunToken() => + IbtOptions is null ? new CancellationTokenSource(LiveDuration) : new CancellationTokenSource(); + } + + public static async Task Main(string[] args) + { + Source source; + var liveSeconds = 5; + if (args is ["--live"] || (args is ["--live", var seconds] && int.TryParse(seconds, out liveSeconds))) + { + source = new Source(null, TimeSpan.FromSeconds(liveSeconds)); + } + else if (args is [var ibtFile] && !ibtFile.StartsWith("--")) + { + source = new Source(new IBTOptions(ibtFile), TimeSpan.Zero); + } + else + { + Console.WriteLine("usage: AotSmokeTest "); + Console.WriteLine(" AotSmokeTest --live [seconds] (default 5)"); + return 2; + } + + using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Warning)); + var logger = new CountingLogger(loggerFactory.CreateLogger("AotSmokeTest")); + // synchronous to let a handler read the raw provider values (GetValue) + var clientOptions = new ClientOptions { DeliveryMode = TelemetryDeliveryMode.Synchronous }; + + CheckReaderConversions(); + await CheckGeneratedTelemetryData(logger, source, clientOptions); + await CheckManualTelemetryData(logger, source, clientOptions); + + // the SDK logs a warning and leaves the property null when a value can't be converted + var conversionFailures = logger.Count(ConversionFailureMessage); + Check(conversionFailures == 0, $"telemetry: {conversionFailures} conversion failure warning(s) logged"); + + foreach (var failure in Failures) + { + Console.WriteLine($"FAIL: {failure}"); + } + Console.WriteLine(Failures.Count == 0 ? "PASS" : $"{Failures.Count} check(s) failed"); + return Failures.Count == 0 ? 0 : 1; + } + + // compares every field of every sample with the provider's raw value for that record + static async Task CheckGeneratedTelemetryData(ILogger logger, Source source, ClientOptions clientOptions) + { + await using var client = TelemetryClient.Create(logger, source.IbtOptions, clientOptions); + using var cts = source.CreateRunToken(); + + var samples = 0; + var compare = new ProviderComparison("generated", () => client.GetTelemetryVariables()); + TelemetryData last = default; + TelemetrySessionInfo? session = null; + var errors = 0; + + await client.Monitor(new TelemetryHandlers + { + OnTelemetryUpdate = data => + { + samples++; + last = data; + + compare.Scalar(samples, "IsOnTrack", data.IsOnTrack, client.GetValue("IsOnTrack")); + compare.Scalar(samples, "SessionNum", data.SessionNum, client.GetValue("SessionNum")); + compare.Enum(samples, "SessionState", data.SessionState, client.GetValue("SessionState")); + compare.Enum(samples, "EngineWarnings", data.EngineWarnings, client.GetValue("EngineWarnings")); + compare.Array(samples, "CarIdxTrackSurface", data.CarIdxTrackSurface, client.GetValue("CarIdxTrackSurface")); + compare.Array(samples, "SteeringWheelTorque_ST", data.SteeringWheelTorque_ST, client.GetValue("SteeringWheelTorque_ST")); + compare.Scalar(samples, "RPM", data.RPM, client.GetValue("RPM")); + compare.Scalar(samples, "SessionTime", data.SessionTime, client.GetValue("SessionTime")); + return Task.CompletedTask; + }, + OnSessionInfoUpdate = info => + { + session = info; + return Task.CompletedTask; + }, + OnError = ex => + { + errors++; + Console.WriteLine($"error: {ex}"); + return Task.CompletedTask; + }, + }, cts.Token); + + Console.WriteLine($"generated: {samples} samples, last RPM={last.RPM} SessionTime={last.SessionTime} " + + $"SessionState={last.SessionState} EngineWarnings={last.EngineWarnings} IsOnTrack={last.IsOnTrack} SessionNum={last.SessionNum}"); + Console.WriteLine($"generated: present: {string.Join(", ", compare.Present.Order())}"); + Console.WriteLine($"session info: track={session?.WeekendInfo?.TrackName} drivers={session?.DriverInfo?.Drivers?.Count} " + + $"sessions={session?.SessionInfo?.Sessions?.Count}"); + + Check(samples > 0, "generated: no telemetry samples"); + Check(compare.Declared.Contains("RPM") && compare.Declared.Contains("SessionTime"), + "generated: the source doesn't declare RPM and SessionTime, so there's nothing to compare"); + compare.Report(); + + Check(session?.WeekendInfo?.TrackName is { Length: > 0 }, "session info: WeekendInfo not parsed"); + Check(session?.DriverInfo?.Drivers is { Count: > 0 }, "session info: DriverInfo.Drivers not parsed"); + Check(session?.SessionInfo?.Sessions is { Count: > 0 }, "session info: SessionInfo.Sessions not parsed"); + Check(errors == 0, $"generated: {errors} errors reported"); + } + + static async Task CheckManualTelemetryData(ILogger logger, Source source, ClientOptions clientOptions) + { + await using var client = TelemetryClient.Create(logger, source.IbtOptions, clientOptions); + using var cts = source.CreateRunToken(); + + var samples = 0; + var compare = new ProviderComparison("manual", () => client.GetTelemetryVariables()); + + await client.Monitor(new TelemetryHandlers + { + OnTelemetryUpdate = data => + { + samples++; + + // RPM isn't nullable, so a missing value reads as 0 + compare.Scalar(samples, "RPM", (float?)data.RPM, client.GetValue("RPM"), missingAs: 0f); + compare.Scalar(samples, "SessionTime", data.SessionTime, client.GetValue("SessionTime")); + compare.Enum(samples, "SessionState", data.SessionState, client.GetValue("SessionState")); + compare.Array(samples, "CarIdxTrackSurface", data.CarIdxTrackSurface, client.GetValue("CarIdxTrackSurface")); + return Task.CompletedTask; + }, + }, cts.Token); + + Console.WriteLine($"manual: {samples} samples, present: {string.Join(", ", compare.Present.Order())}"); + + Check(samples > 0, "manual: no telemetry samples"); + compare.Report(); + } + + // generated Read + static void CheckReaderConversions() + { + var logger = new CountingLogger(NullLogger.Instance); + var provider = new FakeDataProvider(new() + { + ["IsOnTrack"] = true, + ["SessionNum"] = (short)2, // converted to int + ["SessionState"] = (int)SessionState.Racing, + ["EngineWarnings"] = (int)(EngineWarnings.WaterTempWarning | EngineWarnings.PitSpeedLimiter), + ["CarIdxTrackSurface"] = new[] { -1, 3, 1 }, // int[] read as TrackLocation[] + ["SteeringWheelTorque_ST"] = new[] { 1.5f, -2.25f }, + ["RPM"] = 7250.5d, // converted to float + ["SessionTime"] = "not a number", // can't convert: null and a warning + }); + + var data = Read(new TelemetryValueReader(provider, logger)); + Console.WriteLine($"reader: {data}"); + + Check(data.IsOnTrack == true, $"reader: IsOnTrack = {data.IsOnTrack}, expected True"); + Check(data.SessionNum == 2, $"reader: SessionNum = {data.SessionNum}, expected 2 (short converted to int)"); + Check(data.SessionState == SessionState.Racing, $"reader: SessionState = {data.SessionState}, expected Racing"); + Check(data.EngineWarnings == (EngineWarnings.WaterTempWarning | EngineWarnings.PitSpeedLimiter), + $"reader: EngineWarnings = {data.EngineWarnings}, expected WaterTempWarning, PitSpeedLimiter"); + Check(data.CarIdxTrackSurface is [TrackLocation.NotInWorld, TrackLocation.OnTrack, TrackLocation.InPitStall], + $"reader: CarIdxTrackSurface = [{Join(data.CarIdxTrackSurface)}], expected [NotInWorld, OnTrack, InPitStall]"); + Check(data.SteeringWheelTorque_ST is [1.5f, -2.25f], + $"reader: SteeringWheelTorque_ST = [{Join(data.SteeringWheelTorque_ST)}], expected [1.5, -2.25]"); + Check(data.RPM == 7250.5f, $"reader: RPM = {data.RPM}, expected 7250.5 (double converted to float)"); + Check(data.SessionTime is null, $"reader: SessionTime = {data.SessionTime}, expected null for an unconvertible value"); + var conversionFailures = logger.Count(ConversionFailureMessage); + Check(conversionFailures == 1, $"reader: {conversionFailures} conversion failure warning(s), expected 1 (SessionTime)"); + + // with nothing in the provider, every variable is missing and every property is null + var empty = Read(new TelemetryValueReader(new FakeDataProvider([]), logger)); + Check(empty == default, $"reader: expected every property null when no variables are present, got {empty}"); + } + + static TelemetryData Read(TelemetryValueReader reader) => + ((IGeneratedTelemetryData)default(TelemetryData)).Read(reader); + + static string Join(T[]? values) => values is null ? "null" : string.Join(", ", values); + + static void Check(bool condition, string failure) + { + if (!condition) Failures.Add(failure); + } + + // finds differences between struct property and the provider's raw value + sealed class ProviderComparison(string label, Func> getDeclaredVariables) + { + int _mismatches; + readonly List _examples = []; + readonly HashSet _compared = []; + HashSet? _declared; + + // variables the source declares, read on the first sample (once the headers are available) + public IReadOnlySet Declared => _declared ?? []; + + // variables the provider had a value for in at least one sample + public HashSet Present { get; } = []; + + // missingAs: what a non-nullable property holds when the variable is missing + public void Scalar(int sample, string name, T? actual, object? raw, T? missingAs = null) where T : struct + { + Track(name, raw); + T? expected = raw is null ? missingAs : (T)Convert.ChangeType(raw, typeof(T)); + if (!EqualityComparer.Default.Equals(actual, expected)) + Add(sample, name, actual?.ToString(), expected?.ToString()); + } + + // enums and flags arrive as their int value + public void Enum(int sample, string name, TEnum? actual, object? raw) where TEnum : struct, System.Enum + { + Track(name, raw); + int? actualValue = actual is null ? null : Convert.ToInt32(actual.Value); + var expected = (int?)raw; + if (actualValue != expected) + Add(sample, name, actual?.ToString(), expected?.ToString()); + } + + public void Array(int sample, string name, T[]? actual, object? raw) + { + Track(name, raw); + var expected = (System.Array?)raw; + var same = (actual, expected) switch + { + (null, null) => true, + (null, _) or (_, null) => false, + // an enum array element compares by its int value + _ => actual.Length == expected.Length && + actual.Select((value, i) => Convert.ToDouble(value).Equals(Convert.ToDouble(expected.GetValue(i)))).All(equal => equal), + }; + if (!same) + Add(sample, name, actual is null ? null : $"[{Join(actual)}]", + expected is null ? null : $"[{string.Join(", ", expected.Cast())}]"); + } + + void Track(string name, object? raw) + { + _declared ??= getDeclaredVariables().Select(v => v.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + _compared.Add(name); + if (raw is not null) Present.Add(name); + } + + void Add(int sample, string name, string? actual, string? expected) + { + _mismatches++; + if (_examples.Count < MaxReportedMismatches) + _examples.Add($"sample {sample}: {name} = {actual ?? "null"}, provider has {expected ?? "null"}"); + } + + public void Report() + { + foreach (var example in _examples) + Console.WriteLine($"{label} mismatch: {example}"); + Check(_mismatches == 0, $"{label}: {_mismatches} value(s) differ from the provider"); + + foreach (var name in _compared) + { + if (Declared.Contains(name)) + Check(Present.Contains(name), $"{label}: {name} is declared by the source but never had a value"); + else + Check(!Present.Contains(name), $"{label}: {name} isn't declared by the source but had a value"); + } + } + } + + // counts log messages, so silent conversion failures (logged as warnings) can be detected + sealed class CountingLogger(ILogger inner) : ILogger + { + readonly List _messages = []; + + public int Count(string text) + { + lock (_messages) return _messages.Count(m => m.Contains(text)); + } + + public IDisposable? BeginScope(TState state) where TState : notnull => inner.BeginScope(state); + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (_messages) _messages.Add(formatter(state, exception)); + inner.Log(logLevel, eventId, state, exception, formatter); + } + } + + // serves fixed variable values to the reader. only GetVarValue is used + sealed class FakeDataProvider(Dictionary values) : IDataProvider + { + public object? GetVarValue(string varName) => values.GetValueOrDefault(varName); + + public bool IsConnected => true; + public void OpenDataSource() { } + public bool IsSessionInfoUpdated() => false; + public irsdk_header GetHeader() => throw new NotSupportedException(); + public string GetSessionInfoYaml() => throw new NotSupportedException(); + public VarHeaderDictionary? GetVarHeaders() => null; + public Task WaitForDataReady(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.FromResult(false); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/Sdk/tests/README.md b/Sdk/tests/README.md index 618daae..6e2ee5f 100644 --- a/Sdk/tests/README.md +++ b/Sdk/tests/README.md @@ -16,6 +16,14 @@ IBT smoke tests use bundled `.ibt` files under `Sdk/tests/SmokeTests/data/ibt` a dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait Category=ibt ``` +## Native AOT Smoke Tests + +`AotSmokeTest` is a console app published with native AOT. + +```powershell +dotnet run --project .\Sdk\tests\SmokeTests\SmokeTests.csproj -- --filter-trait Category=aot +``` + ## Live Smoke Tests Live tests require iRacing running on Windows in an active session. Load a track and car, get into the car so the simulator sends telemetry data, then run: @@ -40,7 +48,7 @@ dotnet run --project .\Sdk\tests\UnitTests\UnitTests.csproj -- --filter-trait Ca ## All Tests -The full solution test run skips live tests when iRacing isn't running and never runs explicit (manual) tests. +The full solution test run skips live and AOT tests when iRacing isn't running ```powershell dotnet test --solution .\Sdk\SVappsLAB.iRacingTelemetrySDK.slnx diff --git a/Sdk/tests/SmokeTests/Aot/Aot.cs b/Sdk/tests/SmokeTests/Aot/Aot.cs new file mode 100644 index 0000000..1b07b15 --- /dev/null +++ b/Sdk/tests/SmokeTests/Aot/Aot.cs @@ -0,0 +1,126 @@ +/** + * 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 System.Diagnostics; +using System.Reflection; + +namespace SmokeTests; + +// publishes native AOT then run the exe against IBT file and live iRacing +[Trait("Category", "aot")] +public class Aot +{ + const int LIVE_SECS = 5; + static readonly TimeSpan PublishTimeout = TimeSpan.FromMinutes(10); + static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5); + + static readonly string IbtDataDirectory = Path.Combine(AppContext.BaseDirectory, "data", "ibt"); + static readonly string PublishDirectory = Path.Combine(AppContext.BaseDirectory, "aot-publish"); + + // native AOT needs vswhere.exe + static readonly string VsInstallerDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft Visual Studio", "Installer"); + + // published once per test run, shared by every test + static readonly Lazy> PublishedExe = new(PublishAsync); + + readonly ITestOutputHelper _output; + + public Aot(ITestOutputHelper output) + { + _output = output; + Assert.SkipUnless(OperatingSystem.IsWindows(), "native AOT smoke tests publish for win-x64"); + } + + public static TheoryData IbtFiles => + new(Directory.GetFiles(IbtDataDirectory, "*.ibt").Select(Path.GetFileName).Order()!); + + [Theory] + [MemberData(nameof(IbtFiles))] + public async Task NativeAot_Ibt(string ibtFile) + { + var exe = await PublishedExe.Value; + await RunAsync(exe, Path.Combine(IbtDataDirectory, ibtFile)); + } + + [Fact] + public async Task NativeAot_Live() + { + Assert.SkipUnless(Live.IsIRacingConnected(), "iRacing is not running with an active session"); + + var exe = await PublishedExe.Value; + await RunAsync(exe, "--live", LIVE_SECS.ToString()); + } + + async Task RunAsync(string exe, params string[] args) + { + var (exitCode, output) = await RunProcessAsync(exe, args, RunTimeout); + _output.WriteLine(output); + + Assert.True(exitCode == 0, $"AotSmokeTest exited with {exitCode}\n{output}"); + } + + static async Task PublishAsync() + { + var project = typeof(Aot).Assembly.GetCustomAttributes() + .Single(a => a.Key == "AotSmokeTestProject").Value!; + + var (exitCode, output) = await RunProcessAsync("dotnet", + ["publish", project, "-c", "Release", "-r", "win-x64", "-o", PublishDirectory], + PublishTimeout); + + // the project treats warnings as errors, so a trim/AOT warning fails the publish too + Assert.True(exitCode == 0, $"native AOT publish failed ({exitCode})\n{output}"); + + return Path.Combine(PublishDirectory, "AotSmokeTest.exe"); + } + + static async Task<(int ExitCode, string Output)> RunProcessAsync(string fileName, IEnumerable args, TimeSpan timeout) + { + var startInfo = new ProcessStartInfo(fileName) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + if (Directory.Exists(VsInstallerDirectory)) + { + startInfo.Environment["PATH"] = VsInstallerDirectory + Path.PathSeparator + startInfo.Environment["PATH"]; + } + + using var process = Process.Start(startInfo)!; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(timeout); + + var stdout = process.StandardOutput.ReadToEndAsync(cts.Token); + var stderr = process.StandardError.ReadToEndAsync(cts.Token); + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + throw; + } + + return (process.ExitCode, (await stdout) + (await stderr)); + } +} diff --git a/Sdk/tests/SmokeTests/Live/Live.cs b/Sdk/tests/SmokeTests/Live/Live.cs index 98da90d..d2dd9de 100644 --- a/Sdk/tests/SmokeTests/Live/Live.cs +++ b/Sdk/tests/SmokeTests/Live/Live.cs @@ -30,7 +30,7 @@ public Live(ITestOutputHelper output) : base(output) Assert.SkipUnless(IsIRacingConnected(), "iRacing is not running with an active session"); } - static bool IsIRacingConnected() + internal static bool IsIRacingConnected() { const int STATUS_OFFSET = 4; // irsdk_header.status follows the int 'ver' field const int STATUS_CONNECTED = 1; diff --git a/Sdk/tests/SmokeTests/SmokeTests.csproj b/Sdk/tests/SmokeTests/SmokeTests.csproj index fb4ce72..d4e13b2 100644 --- a/Sdk/tests/SmokeTests/SmokeTests.csproj +++ b/Sdk/tests/SmokeTests/SmokeTests.csproj @@ -46,11 +46,18 @@ - + + <_Parameter1>AotSmokeTestProject + <_Parameter2>$(MSBuildThisFileDirectory)..\AotSmokeTest\AotSmokeTest.csproj + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Sdk/tests/UnitTests/CodeGen/CodeGeneratorTests.cs b/Sdk/tests/UnitTests/CodeGen/CodeGeneratorTests.cs index c234147..c474cc3 100644 --- a/Sdk/tests/UnitTests/CodeGen/CodeGeneratorTests.cs +++ b/Sdk/tests/UnitTests/CodeGen/CodeGeneratorTests.cs @@ -25,28 +25,31 @@ namespace UnitTests.CodeGen; // asserts on the generated TelemetryData type and reported diagnostics public class CodeGeneratorTests { - static (Compilation output, ImmutableArray generatorDiagnostics) RunGenerator(string source) - { - // reference the runtime assemblies of this test process plus the - // EnumsAndFlags assembly (for the TelemetryVar enum) - var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) - .Split(Path.PathSeparator) - .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) - .Append(MetadataReference.CreateFromFile(typeof(TelemetryVar).Assembly.Location)) - .ToArray(); - - var compilation = CSharpCompilation.Create( - "GeneratorTestAssembly", - [CSharpSyntaxTree.ParseText(source)], - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + // reference the runtime assemblies of this test process plus the + // EnumsAndFlags assembly (for the TelemetryVar enum) + static readonly MetadataReference[] References = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(TelemetryVar).Assembly.Location)) + .ToArray(); + static CSharpCompilation CreateCompilation(params string[] sources) => CSharpCompilation.Create( + "GeneratorTestAssembly", + sources.Select(s => CSharpSyntaxTree.ParseText(s)), + References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + static (Compilation output, ImmutableArray generatorDiagnostics) RunGenerator(params string[] sources) + { var driver = CSharpGeneratorDriver.Create(new CodeGenerator()); - driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + driver.RunGeneratorsAndUpdateCompilation(CreateCompilation(sources), out var outputCompilation, out var diagnostics, TestContext.Current.CancellationToken); return (outputCompilation, diagnostics); } + static void AssertCompilesWithoutErrors(Compilation output) => + Assert.Empty(output.GetDiagnostics(TestContext.Current.CancellationToken).Where(d => d.Severity == DiagnosticSeverity.Error)); + [Fact] public void EmptyVariableList_GeneratesEmptyTelemetryData() { @@ -89,4 +92,265 @@ public class Program { } Assert.Empty(output.GetDiagnostics(TestContext.Current.CancellationToken).Where(d => d.Severity == DiagnosticSeverity.Error)); Assert.DoesNotContain(diagnostics, d => d.Id == "EmptyVarList"); } + + // irsdk_int vars (wire type 2) map through the enum table, irsdk_bitField + // vars (wire type 3) through the flag table, mirroring irsdk_defines.h + [Theory] + [InlineData(nameof(TelemetryVar.PlayerIncidents), typeof(IncidentFlags))] + [InlineData(nameof(TelemetryVar.SessionState), typeof(SessionState))] + [InlineData(nameof(TelemetryVar.EngineWarnings), typeof(EngineWarnings))] + [InlineData(nameof(TelemetryVar.Gear), typeof(int))] + public void Variable_GeneratesExpectedPropertyType(string varName, Type expectedType) + { + var (output, _) = RunGenerator($$""" + using SVappsLAB.iRacingTelemetrySDK; + + [RequiredTelemetryVars([TelemetryVar.{{varName}}])] + public class Program { } + """); + + var telemetryData = output.GetTypeByMetadataName("SVappsLAB.iRacingTelemetrySDK.TelemetryData"); + Assert.NotNull(telemetryData); + + var property = Assert.Single(telemetryData.GetMembers(varName).OfType()); + var propertyType = property.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + ? nullable.TypeArguments[0] + : property.Type; + var expected = output.GetTypeByMetadataName(expectedType.FullName!); + + Assert.Equal(expected, propertyType, SymbolEqualityComparer.Default); + } + + // per-car (length 64) vars generate arrays of the same element type + [Theory] + [InlineData(nameof(TelemetryVar.CarIdxLapDistPct), typeof(float))] + [InlineData(nameof(TelemetryVar.CarIdxTrackSurface), typeof(TrackLocation))] + [InlineData(nameof(TelemetryVar.CarIdxSessionFlags), typeof(SessionFlags))] + public void ArrayVariable_GeneratesExpectedElementType(string varName, Type expectedElementType) + { + var (output, _) = RunGenerator($$""" + using SVappsLAB.iRacingTelemetrySDK; + + [RequiredTelemetryVars([TelemetryVar.{{varName}}])] + public class Program { } + """); + + var telemetryData = output.GetTypeByMetadataName("SVappsLAB.iRacingTelemetrySDK.TelemetryData"); + Assert.NotNull(telemetryData); + + var property = Assert.Single(telemetryData.GetMembers(varName).OfType()); + var arrayType = Assert.IsAssignableFrom(property.Type); + var expected = output.GetTypeByMetadataName(expectedElementType.FullName!); + + Assert.Equal(expected, arrayType.ElementType, SymbolEqualityComparer.Default); + AssertCompilesWithoutErrors(output); + } + + [Fact] + public void RecordClass_GeneratesTelemetryData() + { + var (output, _) = RunGenerator(""" + using SVappsLAB.iRacingTelemetrySDK; + + [RequiredTelemetryVars([TelemetryVar.Speed])] + public record Program; + """); + + var telemetryData = output.GetTypeByMetadataName("SVappsLAB.iRacingTelemetrySDK.TelemetryData"); + Assert.NotNull(telemetryData); + Assert.Single(telemetryData.GetMembers("Speed").OfType()); + AssertCompilesWithoutErrors(output); + } + + [Fact] + public void MultipleAnnotatedClasses_GeneratesFromFirstAndReportsTheRest() + { + var first = """ + using SVappsLAB.iRacingTelemetrySDK; + + [RequiredTelemetryVars([TelemetryVar.Speed])] + public class First { } + """; + var second = """ + using SVappsLAB.iRacingTelemetrySDK; + + [RequiredTelemetryVars([TelemetryVar.RPM])] + public class Second { } + """; + + var driver = CSharpGeneratorDriver.Create(new CodeGenerator()) + .RunGeneratorsAndUpdateCompilation(CreateCompilation(first, second), out var output, out var diagnostics, TestContext.Current.CancellationToken); + + // no generator exception (previously CS8785, a duplicate hint name) + Assert.Null(driver.GetRunResult().Results[0].Exception); + + var error = Assert.Single(diagnostics, d => d.Id == "MultipleRequiredTelemetryVars"); + Assert.Equal(DiagnosticSeverity.Error, error.Severity); + Assert.Equal(second.IndexOf("RequiredTelemetryVars(", StringComparison.Ordinal), error.Location.SourceSpan.Start); + + // TelemetryData comes from the first class only, so nothing else fails to compile + var telemetryData = output.GetTypeByMetadataName("SVappsLAB.iRacingTelemetrySDK.TelemetryData"); + Assert.NotNull(telemetryData); + var property = Assert.Single(telemetryData.GetMembers().OfType()); + Assert.Equal("Speed", property.Name); + AssertCompilesWithoutErrors(output); + } + + [Fact] + public void NullVariableList_GeneratesEmptyTelemetryData() + { + var driver = CSharpGeneratorDriver.Create(new CodeGenerator()) + .RunGeneratorsAndUpdateCompilation(CreateCompilation(""" + using SVappsLAB.iRacingTelemetrySDK; + + [RequiredTelemetryVars(null)] + public class Program { } + """), out var output, out var diagnostics, TestContext.Current.CancellationToken); + + Assert.Null(driver.GetRunResult().Results[0].Exception); + Assert.Single(diagnostics, d => d.Id == "EmptyVarList"); + + var telemetryData = output.GetTypeByMetadataName("SVappsLAB.iRacingTelemetrySDK.TelemetryData"); + Assert.NotNull(telemetryData); + Assert.Empty(telemetryData.GetMembers().OfType()); + AssertCompilesWithoutErrors(output); + } + + [Fact] + public void UndefinedEnumValue_ReportsUnknownVarNameAtTheElement() + { + var source = """ + using SVappsLAB.iRacingTelemetrySDK; + + [RequiredTelemetryVars([(TelemetryVar)999999, TelemetryVar.Speed])] + public class Program { } + """; + var (output, diagnostics) = RunGenerator(source); + + var error = Assert.Single(diagnostics, d => d.Id == "UnknownVarName"); + Assert.Contains("999999", error.GetMessage()); + Assert.Equal(source.IndexOf("(TelemetryVar)999999", StringComparison.Ordinal), error.Location.SourceSpan.Start); + + // the valid var is still generated + var telemetryData = output.GetTypeByMetadataName("SVappsLAB.iRacingTelemetrySDK.TelemetryData"); + Assert.NotNull(telemetryData); + Assert.Single(telemetryData.GetMembers("Speed").OfType()); + } + + [Fact] + public void DuplicateVariable_ReportsOnceAndGeneratesOneProperty() + { + var source = """ + using SVappsLAB.iRacingTelemetrySDK; + + [RequiredTelemetryVars([TelemetryVar.Speed, TelemetryVar.RPM, TelemetryVar.Speed, TelemetryVar.Speed])] + public class Program { } + """; + var (output, diagnostics) = RunGenerator(source); + + var error = Assert.Single(diagnostics, d => d.Id == "DuplicateVarName"); + Assert.Equal(source.LastIndexOf("TelemetryVar.Speed, TelemetryVar.Speed", StringComparison.Ordinal), error.Location.SourceSpan.Start); + + var telemetryData = output.GetTypeByMetadataName("SVappsLAB.iRacingTelemetrySDK.TelemetryData"); + Assert.NotNull(telemetryData); + Assert.Single(telemetryData.GetMembers("Speed").OfType()); + Assert.Single(telemetryData.GetMembers("RPM").OfType()); + } + + [Fact] + public void AliasedAttribute_ReportsDiagnosticsAtTheElement() + { + var source = """ + using SVappsLAB.iRacingTelemetrySDK; + using Vars = SVappsLAB.iRacingTelemetrySDK.RequiredTelemetryVarsAttribute; + + [Vars([(TelemetryVar)999999])] + public class Program { } + """; + var (_, diagnostics) = RunGenerator(source); + + var error = Assert.Single(diagnostics, d => d.Id == "UnknownVarName"); + Assert.Equal(source.IndexOf("(TelemetryVar)999999", StringComparison.Ordinal), error.Location.SourceSpan.Start); + } + + // incremental generation: edits that don't change the requested vars must not re-emit the source + + const string IncrementalSource = """ + using SVappsLAB.iRacingTelemetrySDK; + + [RequiredTelemetryVars([TelemetryVar.Speed, TelemetryVar.RPM])] + public class Program + { + public int Method() => 1; + } + """; + + static GeneratorRunResult RunTwice(string source, Func edit, string? otherFile = null, Func? otherEdit = null) + { + string[] Sources(string s, string? o) => o is null ? [s] : [s, o]; + + var compilation = CreateCompilation(Sources(source, otherFile)); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new CodeGenerator().AsSourceGenerator()], + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + driver = driver.RunGenerators(compilation, TestContext.Current.CancellationToken); + + // replace the trees in place so unchanged trees keep their identity, like an IDE edit + var edited = compilation.ReplaceSyntaxTree(compilation.SyntaxTrees.First(), + CSharpSyntaxTree.ParseText(edit(source), cancellationToken: TestContext.Current.CancellationToken)); + if (otherFile is not null && otherEdit is not null) + edited = edited.ReplaceSyntaxTree(edited.SyntaxTrees.Last(), + CSharpSyntaxTree.ParseText(otherEdit(otherFile), cancellationToken: TestContext.Current.CancellationToken)); + + return driver.RunGenerators(edited, TestContext.Current.CancellationToken).GetRunResult().Results[0]; + } + + static void AssertSourceNotRegenerated(GeneratorRunResult result) + { + var properties = Assert.Single(result.TrackedSteps[CodeGenerator.PropertiesStep]); + Assert.All(properties.Outputs, o => Assert.Contains(o.Reason, new[] { IncrementalStepRunReason.Cached, IncrementalStepRunReason.Unchanged })); + + Assert.All(result.TrackedOutputSteps.SelectMany(s => s.Value).SelectMany(s => s.Outputs), + o => Assert.Equal(IncrementalStepRunReason.Cached, o.Reason)); + } + + [Fact] + public void Incremental_EditInAnnotatedClassBody_ReusesModel() + { + var result = RunTwice(IncrementalSource, s => s.Replace("=> 1;", "=> 2;")); + + // the transform reran (its file changed) but produced an equal model + var model = Assert.Single(result.TrackedSteps[CodeGenerator.ModelStep]); + Assert.All(model.Outputs, o => Assert.Equal(IncrementalStepRunReason.Unchanged, o.Reason)); + AssertSourceNotRegenerated(result); + } + + [Fact] + public void Incremental_EditInOtherFile_ReusesEverything() + { + var result = RunTwice(IncrementalSource, s => s, + otherFile: "public class Other { public int M() => 1; }", + otherEdit: s => s.Replace("=> 1;", "=> 2;")); + + AssertSourceNotRegenerated(result); + } + + [Fact] + public void Incremental_AttributeMovedOnly_DoesNotRegenerateSource() + { + // shifting the attribute changes diagnostic locations but not the generated source + var result = RunTwice(IncrementalSource, s => "\n\n" + s); + + var properties = Assert.Single(result.TrackedSteps[CodeGenerator.PropertiesStep]); + Assert.All(properties.Outputs, o => Assert.Equal(IncrementalStepRunReason.Unchanged, o.Reason)); + } + + [Fact] + public void Incremental_VariableAdded_RegeneratesSource() + { + var result = RunTwice(IncrementalSource, s => s.Replace("TelemetryVar.RPM", "TelemetryVar.RPM, TelemetryVar.Gear")); + + var properties = Assert.Single(result.TrackedSteps[CodeGenerator.PropertiesStep]); + Assert.All(properties.Outputs, o => Assert.Equal(IncrementalStepRunReason.Modified, o.Reason)); + } } diff --git a/Sdk/tests/UnitTests/CodeGen/GeneratorRoslynVersionTests.cs b/Sdk/tests/UnitTests/CodeGen/GeneratorRoslynVersionTests.cs new file mode 100644 index 0000000..33318ab --- /dev/null +++ b/Sdk/tests/UnitTests/CodeGen/GeneratorRoslynVersionTests.cs @@ -0,0 +1,40 @@ +/** + * 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 UnitTests.CodeGen; + +// the generator runs inside the consumer's compiler, which must be at least the Roslyn +// version the generator was built against. an older compiler skips the generator with +// CS9057 and TelemetryData is never generated. 4.8 is the compiler in the .NET 8.0.100 SDK +public class GeneratorRoslynVersionTests +{ + static readonly Version MaxRoslynVersion = new(4, 8, 0, 0); + + [Theory] + [InlineData("Microsoft.CodeAnalysis")] + [InlineData("Microsoft.CodeAnalysis.CSharp")] + public void Generator_ReferencesRoslynNoNewerThanNet8Sdk(string assemblyName) + { + var reference = Assert.Single(typeof(CodeGenerator).Assembly.GetReferencedAssemblies(), a => a.Name == assemblyName); + + Assert.True(reference.Version <= MaxRoslynVersion, + $"the generator references {assemblyName} {reference.Version}, newer than {MaxRoslynVersion}. " + + "consumers on an older .NET SDK would fail with CS9057 - keep the CodeGen project's " + + "Microsoft.CodeAnalysis.CSharp reference at the oldest version that has the APIs it needs"); + } +} diff --git a/Sdk/tests/UnitTests/IBTPlaybackGovernor.cs b/Sdk/tests/UnitTests/IBTPlaybackGovernor.cs index 95d7f27..e87b296 100644 --- a/Sdk/tests/UnitTests/IBTPlaybackGovernor.cs +++ b/Sdk/tests/UnitTests/IBTPlaybackGovernor.cs @@ -80,7 +80,7 @@ public async Task GovernorTests(int speedMultiplier, int secsOfDataToSimulate) sw.Start(); for (int i = 0; i < recsToProcess; i++) { - await g.GovernSpeed(i); + await g.GovernSpeed(i, TestContext.Current.CancellationToken); } sw.Stop(); diff --git a/Sdk/tests/UnitTests/Metrics/TelemetryPipelineMetricsTests.cs b/Sdk/tests/UnitTests/Metrics/TelemetryPipelineMetricsTests.cs index bac917e..85557d1 100644 --- a/Sdk/tests/UnitTests/Metrics/TelemetryPipelineMetricsTests.cs +++ b/Sdk/tests/UnitTests/Metrics/TelemetryPipelineMetricsTests.cs @@ -248,7 +248,8 @@ public async Task SessionInfo_RecordsSizeAndParseAttemptsForEachParse() await client.Monitor(new TelemetryHandlers { - OnTelemetryUpdate = _ => Task.CompletedTask + OnTelemetryUpdate = _ => Task.CompletedTask, + OnSessionInfoUpdate = _ => Task.CompletedTask }, TestContext.Current.CancellationToken); var processed = collector.Sum(MetricNames.SessionInfoRecordsProcessed); @@ -263,6 +264,29 @@ await client.Monitor(new TelemetryHandlers Assert.InRange(collector.Median(MetricNames.SessionInfoParseAttempts), 1, 2); } + [Fact] + public async Task SessionInfo_NotParsedWhenOnlyRawYamlIsHandled() + { + using var factory = new TestMeterFactory(); + await using var client = CreateClient(factory); + using var collector = new Collector(factory); + + var rawUpdates = 0; + await client.Monitor(new TelemetryHandlers + { + OnTelemetryUpdate = _ => Task.CompletedTask, + OnRawSessionInfoUpdate = _ => + { + Interlocked.Increment(ref rawUpdates); + return Task.CompletedTask; + } + }, TestContext.Current.CancellationToken); + + Assert.True(rawUpdates > 0, "the raw session info yaml was not delivered"); + Assert.Equal(0, collector.Sum(MetricNames.SessionInfoRecordsProcessed)); + Assert.Equal(0, collector.Count(MetricNames.SessionInfoParseAttempts)); + } + [Fact] public async Task StreamConsumer_IsMeasuredLikeTheHandlerPath() { diff --git a/Sdk/tests/UnitTests/UnitTests.csproj b/Sdk/tests/UnitTests/UnitTests.csproj index 06326f3..ee10a39 100644 --- a/Sdk/tests/UnitTests/UnitTests.csproj +++ b/Sdk/tests/UnitTests/UnitTests.csproj @@ -40,11 +40,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Sdk/tests/UnitTests/YamlParsing/YamlStaticContextTests.cs b/Sdk/tests/UnitTests/YamlParsing/YamlStaticContextTests.cs new file mode 100644 index 0000000..80a9cdc --- /dev/null +++ b/Sdk/tests/UnitTests/YamlParsing/YamlStaticContextTests.cs @@ -0,0 +1,74 @@ +/** + * 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.Abstractions; +using SVappsLAB.iRacingTelemetrySDK; +using SVappsLAB.iRacingTelemetrySDK.DataProviders; +using SVappsLAB.iRacingTelemetrySDK.YamlParsing; +using YamlDotNet.Serialization; + +namespace UnitTests.YamlParsing; + +public class YamlStaticContextTests +{ + static readonly IDeserializer ReflectionDeserializer = new DeserializerBuilder().Build(); + static readonly IDeserializer StaticDeserializer = new StaticDeserializerBuilder(new SessionInfoYamlContext()).Build(); + + // reflection serializer, used to turn both object graphs into comparable text + static readonly ISerializer Serializer = new SerializerBuilder().Build(); + + [Fact] + public void StaticDeserializer_ValidYaml_MatchesReflection() => + AssertSameModel(File.ReadAllText("data/valid.yaml")); + + [Fact] + public void StaticDeserializer_QuotedValuesYaml_MatchesReflection() => + AssertSameModel(new QuoteValuesYamlPreparationStrategy().Prepare(File.ReadAllText("data/invalid-unescapedChars.yaml"))); + + [Fact] + public async Task StaticDeserializer_IbtSessionInfo_MatchesReflection() + { + await using var provider = new IBTDataProvider(NullLogger.Instance, new IBTOptions("data/ibt/metrics-playback.ibt")); + provider.OpenDataSource(); + + AssertSameModel(provider.GetSessionInfoYaml()); + } + +#if DEBUG + // flag unmatched properties + [Theory] + [InlineData("WeekendInfo:\n TrackName: spa up\n BrandNewWeekendProperty: 1\n")] + [InlineData("BrandNewSection:\n Value: 1\n")] + [InlineData("DriverInfo:\n Drivers:\n - CarIdx: 0\n BrandNewDriverProperty: x\n")] + public void Parse_UnknownProperty_ThrowsInDebug(string yaml) + { + var parser = new YamlParser(NullLogger.Instance); + + var ex = Assert.ThrowsAny(() => parser.Parse(yaml)); + Assert.Contains("BrandNew", ex.Message); + } +#endif + + static void AssertSameModel(string yaml) + { + var expected = ReflectionDeserializer.Deserialize(yaml); + var actual = StaticDeserializer.Deserialize(yaml); + + Assert.NotNull(actual.WeekendInfo); + Assert.NotNull(actual.DriverInfo); + Assert.Equal(Serializer.Serialize(expected), Serializer.Serialize(actual)); + } +}