diff --git a/src/Wisp.App/BoostGaugeWindow.xaml b/src/Wisp.App/BoostGaugeWindow.xaml index 0e8cb52..468af61 100644 --- a/src/Wisp.App/BoostGaugeWindow.xaml +++ b/src/Wisp.App/BoostGaugeWindow.xaml @@ -16,7 +16,7 @@ Display="{Binding BoostDisplay}" PressureUnit="{Binding SelectedBoostPressureUnit}" ColorNumber="{Binding BoostGaugeColorNumber}" - IsElectricMaterial="{Binding NativeGaugeFrame.IsElectric}" + IsElectricMaterial="{Binding IsElectricPowertrain}" LowBrush="{DynamicResource BoostLowBrush}" MidBrush="{DynamicResource BoostMidBrush}" HighBrush="{DynamicResource BoostHighBrush}" /> diff --git a/src/Wisp.App/DiagnosticsViewModel.cs b/src/Wisp.App/DiagnosticsViewModel.cs index 4095308..e255c54 100644 --- a/src/Wisp.App/DiagnosticsViewModel.cs +++ b/src/Wisp.App/DiagnosticsViewModel.cs @@ -343,13 +343,18 @@ public string DashboardGear public double GForceOffsetX { get => _gForceOffsetX; private set => Set(ref _gForceOffsetX, value); } public double GForceOffsetY { get => _gForceOffsetY; private set => Set(ref _gForceOffsetY, value); } public Point GForceTrailPosition { get => _gForceTrailPosition; private set => Set(ref _gForceTrailPosition, value); } + public bool IsElectricPowertrain => _nativeGaugeFrame.IsElectric; + public NativeGaugeFrame NativeGaugeFrame { get => _nativeGaugeFrame; private set { + var powertrainChanged = value.IsElectric != _nativeGaugeFrame.IsElectric; if (Set(ref _nativeGaugeFrame, value)) { + if (powertrainChanged) + OnPropertyChanged(nameof(IsElectricPowertrain)); OnPropertyChanged(nameof(NativePreviewFrame)); NotifyDashboardFrame(); } diff --git a/src/Wisp.App/NativeHudMemoryResolver.cs b/src/Wisp.App/NativeHudMemoryResolver.cs index f3141dc..b0da36f 100644 --- a/src/Wisp.App/NativeHudMemoryResolver.cs +++ b/src/Wisp.App/NativeHudMemoryResolver.cs @@ -50,11 +50,21 @@ public sealed partial class NativeHudMemoryResolver private ulong _cachedSource; private readonly NativeHudCompatibilityPack _pack; private readonly NativeGaugeDirectResolver _nativeGaugeResolver; + private readonly TimeProvider _gaugeClock; + private static readonly TimeSpan GaugeRetryInterval = TimeSpan.FromMilliseconds(250); + private (ulong Module, ulong Source, int Car, bool Electric)? _missingGauge; + private long _lastGaugeMiss; public NativeHudMemoryResolver(NativeHudCompatibilityPack? pack = null) + : this(TimeProvider.System, pack) + { + } + + internal NativeHudMemoryResolver(TimeProvider gaugeClock, NativeHudCompatibilityPack? pack) { _pack = pack ?? NativeHudBuildContract.BuiltIn; _nativeGaugeResolver = new NativeGaugeDirectResolver(_pack); + _gaugeClock = gaugeClock; } internal NativeHudMemoryResolver(NativeHudCompatibilityPack pack, NativeShiftPerformanceReader shiftReader) @@ -191,10 +201,21 @@ private NativeGaugeDirectResult ReadNativeGaugeWithDiagnostics( IReadOnlyProcessMemory memory, ulong moduleBase, ulong source, bool isElectric, bool forceStructuralValidation, ulong generation, int carOrdinal, string route) { + var identity = (moduleBase, source, carOrdinal, isElectric); + if (_missingGauge == identity && _gaugeClock.GetElapsedTime(_lastGaugeMiss) < GaugeRetryInterval) + return default; + + _missingGauge = null; _nativeGaugeResolver.DiagnosticsEnabled = TachDiagnostics.IsEnabled; try { - return _nativeGaugeResolver.Read(memory, moduleBase, source, isElectric, forceStructuralValidation); + var gauge = _nativeGaugeResolver.Read(memory, moduleBase, source, isElectric, forceStructuralValidation); + if (!gauge.IsAvailable) + { + _missingGauge = identity; + _lastGaugeMiss = _gaugeClock.GetTimestamp(); + } + return gauge; } finally { @@ -206,6 +227,7 @@ private NativeGaugeDirectResult ReadNativeGaugeWithDiagnostics( public void Reset() { _cachedSource = 0; + _missingGauge = null; _nativeGaugeResolver.Reset(); ResetShiftPerformance(); } diff --git a/src/Wisp.App/OverlayWindow.xaml b/src/Wisp.App/OverlayWindow.xaml index e36204c..4d6e6f4 100644 --- a/src/Wisp.App/OverlayWindow.xaml +++ b/src/Wisp.App/OverlayWindow.xaml @@ -135,7 +135,7 @@ TemperatureUnit="{Binding SelectedTireTemperatureUnit}" ReactiveColors="{Binding TireTemperatureReactiveColors}" IsAttached="True" - IsElectricMaterial="{Binding NativeGaugeFrame.IsElectric}" + IsElectricMaterial="{Binding IsElectricPowertrain}" LowBrush="{DynamicResource BoostLowBrush}" MidBrush="{DynamicResource BoostMidBrush}" HighBrush="{DynamicResource BoostHighBrush}" /> @@ -146,7 +146,7 @@ HorizontalAlignment="Left" VerticalAlignment="Top" Visibility="Collapsed" IsHitTestVisible="False" Display="{Binding PowerTorqueDisplay}" - IsElectricMaterial="{Binding NativeGaugeFrame.IsElectric}" + IsElectricMaterial="{Binding IsElectricPowertrain}" Maximum="{Binding PowerGaugeMaximum}" LowBrush="{Binding PowerGaugeLowBrush}" MidBrush="{Binding PowerGaugeMidBrush}" @@ -158,7 +158,7 @@ HorizontalAlignment="Left" VerticalAlignment="Top" Visibility="Collapsed" IsHitTestVisible="False" IsTorque="True" Display="{Binding PowerTorqueDisplay}" - IsElectricMaterial="{Binding NativeGaugeFrame.IsElectric}" + IsElectricMaterial="{Binding IsElectricPowertrain}" TorqueUnit="{Binding SelectedTorqueUnit}" Maximum="{Binding TorqueGaugeMaximum}" LowBrush="{Binding TorqueGaugeLowBrush}" diff --git a/src/Wisp.App/PowerTorqueGaugeWindow.cs b/src/Wisp.App/PowerTorqueGaugeWindow.cs index fee5d1d..bdfa829 100644 --- a/src/Wisp.App/PowerTorqueGaugeWindow.cs +++ b/src/Wisp.App/PowerTorqueGaugeWindow.cs @@ -51,7 +51,7 @@ public PowerTorqueGaugeWindow(AppController controller, bool isTorque) IsTorque = isTorque }; gauge.SetBinding(PowerTorqueGaugeView.DisplayProperty, new Binding(nameof(DiagnosticsViewModel.PowerTorqueDisplay))); - gauge.SetBinding(PowerTorqueGaugeView.IsElectricMaterialProperty, new Binding("NativeGaugeFrame.IsElectric")); + gauge.SetBinding(PowerTorqueGaugeView.IsElectricMaterialProperty, new Binding(nameof(DiagnosticsViewModel.IsElectricPowertrain))); gauge.SetBinding(PowerTorqueGaugeView.TorqueUnitProperty, new Binding(nameof(DiagnosticsViewModel.SelectedTorqueUnit))); gauge.SetBinding(PowerTorqueGaugeView.MaximumProperty, new Binding(isTorque ? nameof(DiagnosticsViewModel.TorqueGaugeMaximum) : nameof(DiagnosticsViewModel.PowerGaugeMaximum))); diff --git a/src/Wisp.App/TireTemperatureGaugeWindow.xaml b/src/Wisp.App/TireTemperatureGaugeWindow.xaml index cf3f55b..73290fa 100644 --- a/src/Wisp.App/TireTemperatureGaugeWindow.xaml +++ b/src/Wisp.App/TireTemperatureGaugeWindow.xaml @@ -26,7 +26,7 @@ Display="{Binding TireTemperatureDisplay}" TemperatureUnit="{Binding SelectedTireTemperatureUnit}" ReactiveColors="{Binding TireTemperatureReactiveColors}" - IsElectricMaterial="{Binding NativeGaugeFrame.IsElectric}" + IsElectricMaterial="{Binding IsElectricPowertrain}" LowBrush="{DynamicResource BoostLowBrush}" MidBrush="{DynamicResource BoostMidBrush}" HighBrush="{DynamicResource BoostHighBrush}" /> diff --git a/tests/Wisp.App.Tests/DiagnosticsViewModelTests.cs b/tests/Wisp.App.Tests/DiagnosticsViewModelTests.cs index 8ae42f9..f5766e3 100644 --- a/tests/Wisp.App.Tests/DiagnosticsViewModelTests.cs +++ b/tests/Wisp.App.Tests/DiagnosticsViewModelTests.cs @@ -540,6 +540,37 @@ public void NativeFrameCarriesExtendedPowertrainTelemetry() Assert.Equal(64, viewModel.NativeGaugeFrame.Brake); } + [Fact] + public void ElectricMaterialNotificationFollowsPowertrainTransitions() + { + var viewModel = new DiagnosticsViewModel(new AppSettings()); + var changes = new List(); + var frames = 0; + viewModel.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(DiagnosticsViewModel.IsElectricPowertrain)) + changes.Add(viewModel.IsElectricPowertrain); + if (e.PropertyName == nameof(DiagnosticsViewModel.NativeGaugeFrame)) + frames++; + }; + var state = DrivingState(); + void Update(int cylinders, float rpm) => viewModel.Update( + state with { NumCylinders = cylinders, EngineRpm = rpm }, + new IndicatedSpeed(0, 0, false, false, "Rear (RL + RR)"), + new CalibrationResult(null, 0.3, 0.2, 0, true, string.Empty, false), + default(ReceiverStatistics), TimeSpan.Zero, SpeedUnit.MilesPerHour, 60, + refreshDiagnostics: false, updateGForce: false); + + Update(6, 1200); + Update(0, 1300); + Update(0, 1400); + viewModel.UpdateWaiting(default, TelemetryConnectionState.Waiting, null, 0); + viewModel.UpdateWaiting(default, TelemetryConnectionState.Waiting, null, 0); + + Assert.Equal(new[] { true, false }, changes); + Assert.Equal(4, frames); + } + [Fact] public void WaitingStateClearsRpmEvidence() { diff --git a/tests/Wisp.App.Tests/NativeGaugeDirectResolverTests.cs b/tests/Wisp.App.Tests/NativeGaugeDirectResolverTests.cs index 089a10f..ea1349f 100644 --- a/tests/Wisp.App.Tests/NativeGaugeDirectResolverTests.cs +++ b/tests/Wisp.App.Tests/NativeGaugeDirectResolverTests.cs @@ -30,6 +30,76 @@ public sealed class NativeGaugeDirectResolverTests private static NativeGaugeLayout Layout => NativeHudBuildContract.BuiltIn.NativeGauge!; + [Fact] + public void MissingGaugeSharesRetryAcrossFullAndFastReadsAndResumesNormalSampling() + { + var memory = ValidMemory(1, false); + ConfigureHudReader(memory, Source, 314, 0); + memory.SetUInt64(Outer + Layout.OuterChildOffset, 0); + var clock = new GaugeClock(); + var resolver = new NativeHudMemoryResolver(clock, NativeHudBuildContract.BuiltIn); + var discoveries = 0; + memory.BeforeRead = (address, _) => + { + if (address == Module + Layout.RegistryGlobalRva) discoveries++; + }; + var snapshot = resolver.Resolve(memory, Module, 314, 4_000, 8_000, 1); + Assert.Equal(NativeAssistProviderStatus.Ready, snapshot.Status); + Assert.False(snapshot.HasNativeNeedleState); + Assert.Equal(1, discoveries); + + memory.SetUInt64(Outer + Layout.OuterChildOffset, Child); + for (var ms = 1; ms < 250; ms++) + { + clock.Milliseconds = ms; + snapshot = ms % 16 == 0 + ? resolver.Resolve(memory, Module, 314, 4_000, 8_000, (ulong)ms, forceSourceAudit: true) + : resolver.RefreshNativeGauge(memory, Module, snapshot, (ulong)ms, false); + Assert.Equal(0, snapshot.NativeGaugeObservedTimestamp); + } + Assert.Equal(1, discoveries); + + clock.Milliseconds = 250; + snapshot = resolver.RefreshNativeGauge(memory, Module, snapshot, 250, false); + Assert.True(snapshot.HasNativeNeedleState); + memory.ResetReadCounts(); + Assert.True(resolver.RefreshNativeGauge(memory, Module, snapshot, 251, false).HasNativeNeedleState); + Assert.Equal(3, memory.ReadCount); + + memory.SetUInt64(Outer + Layout.OuterChildOffset, 0); + snapshot = resolver.Resolve(memory, Module, 314, 4_000, 8_000, 252, forceSourceAudit: true); + Assert.Equal(0, snapshot.NativeGaugeObservedTimestamp); + } + + [Theory] + [InlineData("car")] + [InlineData("source")] + [InlineData("session")] + public void NewGaugeContextCanRecoverImmediately(string changed) + { + var clock = new GaugeClock(); + var memory = ValidMemory(1, false); + ConfigureHudReader(memory, Source, 314, 0); + memory.SetUInt64(Outer + Layout.OuterChildOffset, 0); + var resolver = new NativeHudMemoryResolver(clock, NativeHudBuildContract.BuiltIn); + Assert.False(resolver.Resolve(memory, Module, 314, 4_000, 8_000, 1).HasNativeNeedleState); + + var car = changed == "car" ? 315 : 314; + var source = changed == "source" ? Source + 0x10000 : Source; + if (changed == "session") resolver.Reset(); + ConfigureHudReader(memory, source, (uint)car, 0); + memory.SetUInt64(Outer + Layout.OuterSourceOffset, source); + memory.SetUInt64(Outer + Layout.OuterChildOffset, Child); + Assert.True(resolver.Resolve(memory, Module, car, 4_000, 8_000, 2, forceSourceAudit: true).HasNativeNeedleState); + } + + private sealed class GaugeClock : TimeProvider + { + public long Milliseconds { get; set; } + public override long TimestampFrequency => 1000; + public override long GetTimestamp() => Milliseconds; + } + [Fact] public void RuntimeOwnedGaugeTypeRequiresUniqueFullOwnershipAndKeepsGuardedCache() {