From 4edb896842e3b113df419e3e2d4bbaf3f25f3285 Mon Sep 17 00:00:00 2001 From: Views2k <292889387+Views2k@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:05:26 +0000 Subject: [PATCH] Retry a missing stock gauge every 250 ms and notify electric-material bindings once When the game's own speedometer is hidden, the direct gauge reader found no child object on every HUD tick and ran its discovery again each time. The resolver now remembers the missing gauge for the same module, source, car and powertrain and retries after 250 ms; a new car, source or session reads at once. Gauge views bind IsElectricPowertrain, which changes only when the powertrain does, instead of re-evaluating the whole native frame. --- src/Wisp.App/BoostGaugeWindow.xaml | 2 +- src/Wisp.App/DiagnosticsViewModel.cs | 5 ++ src/Wisp.App/NativeHudMemoryResolver.cs | 24 ++++++- src/Wisp.App/OverlayWindow.xaml | 6 +- src/Wisp.App/PowerTorqueGaugeWindow.cs | 2 +- src/Wisp.App/TireTemperatureGaugeWindow.xaml | 2 +- .../DiagnosticsViewModelTests.cs | 31 ++++++++ .../NativeGaugeDirectResolverTests.cs | 70 +++++++++++++++++++ 8 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/Wisp.App/BoostGaugeWindow.xaml b/src/Wisp.App/BoostGaugeWindow.xaml index 0e8cb521..468af61b 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 4095308c..e255c548 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 f3141dc5..b0da36f6 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 e36204cd..4d6e6f47 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 fee5d1d3..bdfa8295 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 cf3f55b8..73290fab 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 8ae42f97..f5766e3b 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 089a10ff..ea1349f3 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() {