diff --git a/CHANGELOG.md b/CHANGELOG.md index b18594b..b798dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Notable changes to Wisp are recorded here. +## 2.5.1 - 2026-09-26 + +- End a Time Attack attempt, as Forza does, when the car stays stopped for five seconds or, once two laps agree on the circuit, stays off it or goes the wrong way for five seconds. +- Keep lap delta reference laps when Wisp restarts or updates while Forza keeps running. +- Show the reference lap's time next to SESSION BEST or PREVIOUS LAP. +- Redraw the lap delta panel and track map only when what they show changes, instead of at the display's refresh rate. +- Save the background particle color, the app border and text colors, and the drift gauge's visibility, size, dark mode and black background in HUD profiles. Profiles saved earlier leave these settings and the lap delta settings as they are; before, a profile from before 2.5 turned lap delta and the track map off and switched Time Attack to race timing. + ## 2.5.0 - 2026-09-26 - Add lap delta (beta): a live delta against your session best or previous lap, compared at the same place on the track, with an optional bar. Race and Rivals laps use the game's lap clocks; Time Attack laps at Legend Island, Hokubu, Soni and Sekibe are timed from start-line crossings. Laps continue through pauses and rewinds. diff --git a/README.md b/README.md index 9d27e21..b15a7eb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Supports Steam and Xbox app / Microsoft Store editions on Windows PC.

Versions before 2.4 can show delayed or choppy tachometer motion on NVIDIA systems when G-SYNC/VRR is enabled. I recommend updating to 2.4. - Download Wisp 2.5 · + Download Wisp 2.5.1 · Website · Changelog · Architecture

@@ -13,6 +13,10 @@ Buy Me a Coffee

+## Wisp 2.5.1 + +Time Attack attempts now end when Forza ends them, reference laps survive a Wisp restart, and the lap delta panel shows the reference lap's time. The lap overlays redraw only when their content changes, and HUD profiles keep more of your colors. [Release notes](docs/releases/Wisp-2.5.1-release-notes.md). + ## Wisp 2.5 Adds **lap delta (beta)**: see how far ahead or behind your session best or previous lap you are, compared at the same place on the track, in races, Rivals and Time Attack. A **live track map (beta)** follows your car around the circuit. Turn both on in Appearance under Lap delta. [Release notes](docs/releases/Wisp-2.5.0-release-notes.md). @@ -170,7 +174,7 @@ An optional quick tour introduces the drift gauge, Display mode, Runs, and Appearance. Start from the welcome banner or choose **Replay the quick tour** in Release Notes. -[Download Wisp 2.5](https://github.com/Views2k/Wisp/releases/download/v2.5.0/Wisp-Setup-2.5.0.zip) · +[Download Wisp 2.5.1](https://github.com/Views2k/Wisp/releases/download/v2.5.1/Wisp-Setup-2.5.1.zip) · [2.5 release notes](docs/releases/Wisp-2.5.0-release-notes.md) · [Changelog](CHANGELOG.md) @@ -294,8 +298,8 @@ require administrator access or a separate .NET runtime. ## Install -1. Open the [Wisp 2.5 release](https://github.com/Views2k/Wisp/releases/tag/v2.5.0). -2. Download and extract [Wisp-Setup-2.5.0.zip](https://github.com/Views2k/Wisp/releases/download/v2.5.0/Wisp-Setup-2.5.0.zip). +1. Open the [Wisp 2.5.1 release](https://github.com/Views2k/Wisp/releases/tag/v2.5.1). +2. Download and extract [Wisp-Setup-2.5.1.zip](https://github.com/Views2k/Wisp/releases/download/v2.5.1/Wisp-Setup-2.5.1.zip). 3. Keep the installer and its `.sha256` file together. 4. Verify the installer checksum, then run the installer. 5. Complete the required setup wizard on first launch. diff --git a/docs/releases/Wisp-2.5.1-release-notes.md b/docs/releases/Wisp-2.5.1-release-notes.md new file mode 100644 index 0000000..9345194 --- /dev/null +++ b/docs/releases/Wisp-2.5.1-release-notes.md @@ -0,0 +1,10 @@ +# Wisp 2.5.1 + +Lap delta (beta) fixes for Time Attack. + +- **Attempts end when Forza ends them.** Stop for five seconds, or, once two laps agree on the circuit, stay off it or go the wrong way for five seconds, and the panel shows START A LAP until you next cross the start line. Before, the delta kept counting after the attempt had ended. +- **Reference laps survive a Wisp restart.** If Wisp restarts or updates while Forza keeps running, it keeps your session best and previous lap, as Forza keeps its Current Best. Changing car or timing mode, leaving the circuit, or Reset reference laps still starts afresh. +- **The panel shows which lap it compares against.** The reference lap's time appears next to SESSION BEST or PREVIOUS LAP. +- **Less drawing.** The lap delta panel and track map redraw only when what they show changes. Before, each redrew at the display's refresh rate, about 240 times a second on a 240 Hz monitor, although their content changes at most with each telemetry packet. + +HUD profiles now also save the background particle color, the app border and text colors, and the drift gauge's visibility, size, dark mode and black background. Profiles saved before 2.5.1 leave these settings, and your lap delta settings, as they are when you apply them. Before, applying a profile saved before 2.5 turned lap delta and the track map off and switched Time Attack to race timing, so no lap started. diff --git a/installer/Wisp.iss b/installer/Wisp.iss index eb5ae15..eb0223b 100644 --- a/installer/Wisp.iss +++ b/installer/Wisp.iss @@ -1,5 +1,5 @@ #define MyAppName "Wisp" -#define MyAppVersion "2.5.0" +#define MyAppVersion "2.5.1" #define MyAppDisplayVersion MyAppVersion #define MyAppOutputVersion MyAppVersion #define MyAppPublisher "Wisp" diff --git a/src/Wisp.App/AppController.Laps.cs b/src/Wisp.App/AppController.Laps.cs index 16b67d6..293a9e1 100644 --- a/src/Wisp.App/AppController.Laps.cs +++ b/src/Wisp.App/AppController.Laps.cs @@ -5,7 +5,7 @@ namespace Wisp.App; public sealed partial class AppController { - private readonly LapDeltaService _lapDelta = new(); + private readonly LapDeltaService _lapDelta = new(LapReferenceStore.ForCurrentUser()); internal LapDeltaService LapDelta => _lapDelta; internal LapDeltaWindow? LapDeltaOverlay { get; private set; } internal LapDeltaWindow? LapMapOverlay { get; private set; } diff --git a/src/Wisp.App/AppController.cs b/src/Wisp.App/AppController.cs index 35d305b..54921dc 100644 --- a/src/Wisp.App/AppController.cs +++ b/src/Wisp.App/AppController.cs @@ -1433,6 +1433,10 @@ public bool TryApplyHudPreset(Guid id, out string error) var previousLayoutMode = Settings.LayoutMode; var previousNativeGaugeMode = Settings.NativeGaugeMode; preset.ApplyTo(Settings); + Settings.NormalizeDriftGaugeSettings(); + DriftGaugeOverlay?.ApplyAppearance(Settings.DriftGaugeScale, Settings.OverlayOpacity); + DriftGaugeOverlay?.SetEnabled(Settings.DriftGaugeEnabled); + SetDriftGaugeStatus(Settings.DriftGaugeEnabled ? "Waiting for driving telemetry" : "Off"); ViewModel.ApplyPowerTorquePresetRange(preset); ViewModel.UpdateGForceColors(Settings); SyncHudPresetToViewModel(); diff --git a/src/Wisp.App/ControlPanelWindow.cs b/src/Wisp.App/ControlPanelWindow.cs index 4d00b7b..d61664d 100644 --- a/src/Wisp.App/ControlPanelWindow.cs +++ b/src/Wisp.App/ControlPanelWindow.cs @@ -474,6 +474,7 @@ private void SetEditorColor(ColorWheelEditor editor, Color color) internal void ApplyHudPresetToControls() { FindControl("LapDeltaSettings").Initialize(_controller); + FindControl("DriftGaugeSettings").Initialize(_controller); var wasLoaded = _loaded; _loaded = false; try diff --git a/src/Wisp.App/HudPreset.cs b/src/Wisp.App/HudPreset.cs index 7de4453..1d49ee0 100644 --- a/src/Wisp.App/HudPreset.cs +++ b/src/Wisp.App/HudPreset.cs @@ -102,6 +102,20 @@ public double TorqueGaugeScale public string? CustomTractionCueColor { get; set; } public string? CustomGForceColor { get; set; } public string? CustomGForceTrailColor { get; set; } + // Saved from 2.5.1, with lap delta settings saved reliably. Applying a profile saved earlier + // leaves these and the lap delta settings as they are: profiles from before 2.5 have none, and + // would otherwise turn lap delta off and switch Time Attack to race timing. + public int Revision { get; set; } + public string? CustomParticleColor { get; set; } + public string? AppBorderColor { get; set; } + public string? AppTextColor { get; set; } + public string? AppMutedTextColor { get; set; } + public bool DriftGaugeEnabled { get; set; } + public double DriftGaugeScale { get; set; } = 1; + public bool DriftGaugeDarkMode { get; set; } + public bool DriftGaugeBackgroundEnabled { get; set; } + public double DriftGaugeBackgroundOpacity { get; set; } = .5; + internal const int CurrentRevision = 1; [JsonIgnore] public string Summary => LayoutMode switch @@ -205,7 +219,17 @@ public static HudPreset Capture(AppSettings settings, string name, Guid? id = nu CustomBoostHighColor = settings.CustomBoostHighColor, CustomTractionCueColor = settings.CustomTractionCueColor, CustomGForceColor = settings.CustomGForceColor, - CustomGForceTrailColor = settings.CustomGForceTrailColor + CustomGForceTrailColor = settings.CustomGForceTrailColor, + Revision = CurrentRevision, + CustomParticleColor = settings.CustomParticleColor, + AppBorderColor = settings.ApplicationStyle?.BorderColor, + AppTextColor = settings.ApplicationStyle?.TextColor, + AppMutedTextColor = settings.ApplicationStyle?.MutedTextColor, + DriftGaugeEnabled = settings.DriftGaugeEnabled, + DriftGaugeScale = settings.DriftGaugeScale, + DriftGaugeDarkMode = settings.DriftGaugeDarkMode, + DriftGaugeBackgroundEnabled = settings.DriftGaugeBackgroundEnabled, + DriftGaugeBackgroundOpacity = settings.DriftGaugeBackgroundOpacity }; } @@ -241,18 +265,6 @@ public void ApplyTo(AppSettings settings) settings.TireTemperatureReactiveColors = TireTemperatureReactiveColors; settings.TireTemperatureUnit = TireTemperatureUnit; settings.TireTemperatureGaugeScale = TireTemperatureGaugeScale; - settings.LapMapEnabled = LapMapEnabled; - settings.LapMapScale = LapMapScale; - settings.LapDeltaAheadColor = LapDeltaAheadColor; - settings.LapDeltaBehindColor = LapDeltaBehindColor; - settings.LapMapTrackColor = LapMapTrackColor; - settings.LapMapCarColor = LapMapCarColor; - settings.LapMapBackgroundColor = LapMapBackgroundColor; - settings.LapTimingMode = LapTimingMode; - settings.LapDeltaEnabled = LapDeltaEnabled; - settings.LapDeltaReference = LapDeltaReference; - settings.LapDeltaShowBar = LapDeltaShowBar; - settings.LapDeltaScale = LapDeltaScale; settings.PowerGaugeEnabled = PowerGaugeEnabled; settings.TorqueGaugeEnabled = TorqueGaugeEnabled; settings.PowerGaugeAttached = PowerGaugeAttached; @@ -293,6 +305,31 @@ public void ApplyTo(AppSettings settings) settings.CustomTractionCueColor = CustomTractionCueColor; settings.CustomGForceColor = CustomGForceColor; settings.CustomGForceTrailColor = CustomGForceTrailColor; + if (Revision < 1) return; + settings.LapMapEnabled = LapMapEnabled; + settings.LapMapScale = LapMapScale; + settings.LapDeltaAheadColor = LapDeltaAheadColor; + settings.LapDeltaBehindColor = LapDeltaBehindColor; + settings.LapMapTrackColor = LapMapTrackColor; + settings.LapMapCarColor = LapMapCarColor; + settings.LapMapBackgroundColor = LapMapBackgroundColor; + settings.LapTimingMode = LapTimingMode; + settings.LapDeltaEnabled = LapDeltaEnabled; + settings.LapDeltaReference = LapDeltaReference; + settings.LapDeltaShowBar = LapDeltaShowBar; + settings.LapDeltaScale = LapDeltaScale; + settings.CustomParticleColor = CustomParticleColor; + var style = (settings.ApplicationStyle ?? new AppStyleSettings()).Clone(); + style.BorderColor = AppBorderColor; + style.TextColor = AppTextColor; + style.MutedTextColor = AppMutedTextColor; + style.Normalize(); + settings.ApplicationStyle = style; + settings.DriftGaugeEnabled = DriftGaugeEnabled; + settings.DriftGaugeScale = DriftGaugeScale; + settings.DriftGaugeDarkMode = DriftGaugeDarkMode; + settings.DriftGaugeBackgroundEnabled = DriftGaugeBackgroundEnabled; + settings.DriftGaugeBackgroundOpacity = DriftGaugeBackgroundOpacity; } public bool Normalize() @@ -328,6 +365,9 @@ public bool Normalize() LapMapCarColor = ColorCustomization.NormalizeGauge(LapMapCarColor); LapMapBackgroundColor = ColorCustomization.NormalizeParticle(LapMapBackgroundColor); LapDeltaScale = NormalizeScale(LapDeltaScale); + CustomParticleColor = ColorCustomization.NormalizeParticle(CustomParticleColor); + DriftGaugeScale = NormalizeScale(DriftGaugeScale); + DriftGaugeBackgroundOpacity = double.IsFinite(DriftGaugeBackgroundOpacity) ? Math.Clamp(DriftGaugeBackgroundOpacity, 0, 1) : .5; if (!Enum.IsDefined(LapTimingMode)) LapTimingMode = global::Wisp.Core.LapTimingMode.GameLaps; if (!Enum.IsDefined(LapDeltaReference)) LapDeltaReference = global::Wisp.Core.LapDeltaReference.SessionBest; PowerTorqueGaugeScale = NormalizeScale(PowerTorqueGaugeScale); diff --git a/src/Wisp.App/Laps/LapDeltaService.cs b/src/Wisp.App/Laps/LapDeltaService.cs index 94e6098..2570c53 100644 --- a/src/Wisp.App/Laps/LapDeltaService.cs +++ b/src/Wisp.App/Laps/LapDeltaService.cs @@ -1,11 +1,13 @@ +using System.IO; using System.Diagnostics; using System.Threading.Channels; using Wisp.Core; namespace Wisp.App.Laps; -internal sealed class LapDeltaService : IDisposable +internal sealed class LapDeltaService(LapReferenceStore? store = null) : IDisposable { + private Task _saving = Task.CompletedTask; private readonly Channel<(int Generation, VehicleState State)> _samples = Channel.CreateBounded<(int, VehicleState)>( new BoundedChannelOptions(512) { SingleReader = true, SingleWriter = true, FullMode = BoundedChannelFullMode.Wait }); private Task _worker = Task.CompletedTask; @@ -72,6 +74,9 @@ internal void Observe(VehicleState? state) private async Task ProcessAsync() { var tracker = new LapDeltaTracker(); + if (store?.Load() is { } kept) tracker.RestoreReferences(kept); + using var recorder = ApplicationVersionInfo.DiagnosticBuildId is null || store is null ? null : + LapDiagnosticsRecorder.Start(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wisp", "LapDiagnostics")); var generation = -1; var resetVersion = -1; await foreach (var sample in _samples.Reader.ReadAllAsync().ConfigureAwait(false)) @@ -85,7 +90,14 @@ private async Task ProcessAsync() resetVersion = reset; generation = current; } var reference = Volatile.Read(ref _reference); - var reading = tracker.Update(sample.State, (LapDeltaReference)reference, (LapTimingMode)Volatile.Read(ref _timing)); + var timing = (LapTimingMode)Volatile.Read(ref _timing); + var reading = tracker.Update(sample.State, (LapDeltaReference)reference, timing); + recorder?.Write(sample.State, timing, (LapDeltaReference)reference, reading); + if (store is not null && tracker.TakeReferenceChange()) + { + var session = tracker.ExportReferences(); + _saving = _saving.ContinueWith(_ => store.Save(session), TaskScheduler.Default); + } var map = Volatile.Read(ref _mapEnabled) != 0 ? tracker.ReadMap(sample.State.ReceivedTimestamp ?? 0) : null; Volatile.Write(ref _publication, new(current, reference, reading, map)); } diff --git a/src/Wisp.App/Laps/LapDiagnosticsRecorder.cs b/src/Wisp.App/Laps/LapDiagnosticsRecorder.cs new file mode 100644 index 0000000..b4dbf25 --- /dev/null +++ b/src/Wisp.App/Laps/LapDiagnosticsRecorder.cs @@ -0,0 +1,62 @@ +using System.IO; +using System.Globalization; +using System.IO.Compression; +using Wisp.Core; + +namespace Wisp.App.Laps; + +// Private test builds record every packet the lap tracker receives, with what it showed, so a +// session can be replayed exactly. Files: %LOCALAPPDATA%\Wisp\LapDiagnostics\laps-*.csv.gz. +internal sealed class LapDiagnosticsRecorder : IDisposable +{ + private const int KeptFiles = 8; + private readonly StreamWriter _writer; + private DateTimeOffset _flushed; + + private LapDiagnosticsRecorder(StreamWriter writer) => _writer = writer; + + internal static LapDiagnosticsRecorder? Start(string directory) + { + try + { + Directory.CreateDirectory(directory); + foreach (var old in Directory.GetFiles(directory, "laps-*.csv.gz").OrderDescending().Skip(KeptFiles - 1)) + File.Delete(old); + var path = Path.Combine(directory, $"laps-{DateTime.Now:yyyyMMdd-HHmmss}.csv.gz"); + var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read, 1 << 16); + var writer = new StreamWriter(new GZipStream(file, CompressionLevel.Fastest), bufferSize: 1 << 16); + writer.WriteLine($"# Wisp {ApplicationVersionInfo.DisplayVersion} {ApplicationVersionInfo.DiagnosticBuildLabel}"); + writer.WriteLine("received_utc_ticks,received_qpc,timestamp_ms,race_on,car,x,y,z,speed,lap_current,lap_last,race_time,lap_number,timing,reference,status,delta,reference_seconds"); + return new(writer); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return null; + } + } + + internal void Write(VehicleState state, LapTimingMode timing, LapDeltaReference reference, LapDeltaReading reading) + { + try + { + var lap = state.Lap; + _writer.WriteLine(string.Create(CultureInfo.InvariantCulture, + $"{state.ReceivedAtUtc.UtcTicks},{state.ReceivedTimestamp},{state.GameTimestampMilliseconds},{(state.IsRaceOn ? 1 : 0)},{state.CarOrdinal}," + + $"{lap?.Position.X:R},{lap?.Position.Y:R},{lap?.Position.Z:R},{state.GroundSpeedMetersPerSecond:R}," + + $"{lap?.CurrentLapSeconds:R},{lap?.LastLapSeconds:R},{lap?.RaceSeconds:R},{lap?.LapNumber}," + + $"{(int)timing},{(int)reference},{(int)reading.Status},{reading.Seconds:R},{reading.ReferenceSeconds:R}")); + if (state.ReceivedAtUtc - _flushed < TimeSpan.FromSeconds(2)) return; + _flushed = state.ReceivedAtUtc; + _writer.Flush(); + } + catch (Exception exception) when (exception is IOException or ObjectDisposedException) + { + } + } + + public void Dispose() + { + try { _writer.Dispose(); } + catch (IOException) { } + } +} diff --git a/src/Wisp.App/Laps/LapReferenceStore.cs b/src/Wisp.App/Laps/LapReferenceStore.cs new file mode 100644 index 0000000..2af8984 --- /dev/null +++ b/src/Wisp.App/Laps/LapReferenceStore.cs @@ -0,0 +1,69 @@ +using System.IO; +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json; +using Wisp.Core; + +namespace Wisp.App.Laps; + +// Keeps reference laps while Wisp restarts during one run of the game, as the game keeps its own +// Current Best for that run. +internal sealed class LapReferenceStore(string path, Func gameRun) +{ + private sealed record Kept(string GameRun, LapReferenceSession Session); + + internal static LapReferenceStore ForCurrentUser() => new(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wisp", "LapReferences.json"), CurrentGameRun); + + // One run of the game: its process and when it started. + internal static string? CurrentGameRun() + { + Process[] processes = []; + try + { + processes = Process.GetProcessesByName("ForzaHorizon6"); + return processes.Length == 1 ? $"{processes[0].Id}:{processes[0].StartTime.ToUniversalTime().Ticks}" : null; + } + catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or NotSupportedException) + { + return null; + } + finally + { + foreach (var process in processes) process.Dispose(); + } + } + + internal LapReferenceSession? Load() + { + try + { + if (gameRun() is not { } run || !File.Exists(path)) return null; + var kept = JsonSerializer.Deserialize(File.ReadAllText(path)); + return kept?.GameRun == run ? kept.Session : null; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException or NotSupportedException) + { + return null; + } + } + + internal void Save(LapReferenceSession? session) + { + try + { + if (session is null || gameRun() is not { } run) + { + File.Delete(path); + return; + } + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporary = path + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(new Kept(run, session))); + File.Move(temporary, path, overwrite: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or NotSupportedException) + { + } + } +} diff --git a/src/Wisp.App/NativeRendering/AnalogHudRenderWorker.cs b/src/Wisp.App/NativeRendering/AnalogHudRenderWorker.cs index 6e13a8e..d6d177d 100644 --- a/src/Wisp.App/NativeRendering/AnalogHudRenderWorker.cs +++ b/src/Wisp.App/NativeRendering/AnalogHudRenderWorker.cs @@ -148,7 +148,8 @@ private void Run() long motionGeneration = 1; bool compositorActive = false, resetCompositor = false; var waitHandles = new WaitHandle[] { _stop, _changed }; - bool announced = false, hasFrame = false, frameReady = false; + // hudShown: the last HUD build reached the screen, so an unchanged scene need not present again. + bool announced = false, hasFrame = false, frameReady = false, hudShown = false; int width = 0, height = 0; float appliedOpacity = float.NaN; float appliedHostX = float.NaN, appliedHostY = float.NaN; @@ -175,6 +176,7 @@ private void Run() playback.Reset(); hudPlayback.Reset(); hasFrame = false; + hudShown = false; pending = null; resetCompositor = true; _reset = false; @@ -206,6 +208,7 @@ private void Run() playback.Reset(); hudPlayback.Reset(); hasFrame = false; + hudShown = false; compositorActive = false; // Acknowledge the applied hide only after native visibility // and pending playback have both been cleared. @@ -240,6 +243,7 @@ private void Run() device.UploadTexture(texture.Id, texture.Width, texture.Height, texture.Stride, texture.Pixels.ToArray()); width = presentation.Width; height = presentation.Height; + hudShown = false; RecordStage(operation, "created", operationStarted); } if (width != presentation.Width || height != presentation.Height) @@ -251,6 +255,7 @@ private void Run() compositorActive = false; width = presentation.Width; height = presentation.Height; + hudShown = false; RecordStage(operation, "resized", operationStarted); } // Host geometry is independent of HUD texture dimensions and @@ -274,6 +279,7 @@ private void Run() if (device.PrepareForResume()) frameReady = false; compositorActive = false; pending = null; + hudShown = false; RecordStage(operation, "resumed", operationStarted); } if (appliedOpacity != presentation.Opacity || !_surfaceVisible) @@ -342,6 +348,14 @@ private void Run() RecordStage("state", "discarded", Stopwatch.GetTimestamp()); pending = null; } + // Layers that change only with their content, such as the lap delta panel and track map, + // wait for new input instead of presenting the same pixels at the refresh rate. + if (pending is null && hudShown && presentation.Hud is not null && + hudPlayback.IdleWhenUnchanged(Stopwatch.GetTimestamp())) + { + WaitHandle.WaitAny(waitHandles, 50); + continue; + } if (pending is null) { _beforeDrawForTest?.Invoke(_stop); @@ -404,6 +418,7 @@ private void Run() } var sceneTicks = measure ? Stopwatch.GetTimestamp() - operationStarted : 0; pending = new(sample, presentation, queuedTimestamp, ++sequence); + hudShown = false; if (ShiftCaptureHub.Current is not null) pending = pending.Value with { @@ -475,6 +490,7 @@ private void Run() AnnounceReady(); if (pending.Value.Presentation.Hud is { } submittedHud) { + hudShown = true; if (TachDiagnostics.IsEnabled) foreach (var diagnostic in hudPlayback.NeedleDiagnostics) Record(diagnostic.Sample, diagnostic.Kind, compositorActive && diagnostic.Kind == "analogue" diff --git a/src/Wisp.App/NativeRendering/HudLayer.cs b/src/Wisp.App/NativeRendering/HudLayer.cs index 8950d19..1105002 100644 --- a/src/Wisp.App/NativeRendering/HudLayer.cs +++ b/src/Wisp.App/NativeRendering/HudLayer.cs @@ -23,6 +23,9 @@ internal DirectCompositionDrawCommand[] Build(long timestamp) } internal abstract void AppendCommands(List commands, long timestamp); internal virtual bool CanReuse(long timestamp) => true; + // A layer whose pixels change only when CanReuse turns false. A window of such layers waits for + // new input instead of presenting the same pixels again at the display's refresh rate. + internal virtual bool IdleWhenUnchanged => false; internal virtual (string Kind, AnalogHudSample Sample)? NeedleDiagnostic => null; internal virtual bool SupportsCompositorNeedle => false; internal virtual bool TryCopyCompositorNeedle(long timestamp, Span points, @@ -78,9 +81,9 @@ internal sealed class HudScenePlayback private readonly Dictionary _layers = []; private readonly List _commands = new(256); private DirectCompositionDrawCommand[] _output = new DirectCompositionDrawCommand[256]; - private HudWindowSnapshot? _snapshot; + private HudWindowSnapshot? _snapshot, _built; private bool _texturesChanged; - internal void Reset() { _layers.Clear(); _snapshot = null; _texturesChanged = true; } + internal void Reset() { _layers.Clear(); _snapshot = _built = null; _texturesChanged = true; } internal void Update(HudWindowSnapshot snapshot, long timestamp) { _snapshot = snapshot; @@ -110,6 +113,11 @@ internal bool RefreshNativeHistory(long timestamp) return invalidated; } internal bool CanReuse(long timestamp) => _layers.Values.All(layer => layer.Playback.CanReuse(timestamp)); + + // Nothing drawn in the last build has changed, and every layer changes only with its content. + internal bool IdleWhenUnchanged(long timestamp) => _snapshot is { } current && _built is { } built && !_texturesChanged && + _layers.Count > 0 && _layers.Values.All(layer => layer.Playback.IdleWhenUnchanged) && + current.CompatibleWith(built) && CanReuse(timestamp); internal IEnumerable<(string Kind, AnalogHudSample Sample)> NeedleDiagnostics => _layers.Values.Select(layer => layer.Playback.NeedleDiagnostic).Where(sample => sample.HasValue).Select(sample => sample!.Value); internal IEnumerable Textures => _snapshot is null ? [] : @@ -142,6 +150,7 @@ internal DirectCompositionDrawCommand[] Build(long timestamp) internal (DirectCompositionDrawCommand[] Commands, int Count) BuildReusable(long timestamp) { _commands.Clear(); + _built = _snapshot; if (_snapshot is null) return (_output, 0); foreach (var layer in _snapshot.Layers) { diff --git a/src/Wisp.App/NativeRendering/LapDeltaHudLayer.cs b/src/Wisp.App/NativeRendering/LapDeltaHudLayer.cs index 224972c..d524c68 100644 --- a/src/Wisp.App/NativeRendering/LapDeltaHudLayer.cs +++ b/src/Wisp.App/NativeRendering/LapDeltaHudLayer.cs @@ -13,6 +13,12 @@ internal static class LapDeltaHudLayer private const uint Base = 90_000; private const string Glyphs = "0123456789+−."; private const int CellWidth = 42, CellHeight = 64; + // The header names the reference and shows its lap time. + private const string TimeGlyphs = "0123456789:."; + private const int TimeCellWidth = 14; + private const double TimeGap = 8; + private static readonly double[] TimeAdvances = new double[TimeGlyphs.Length]; + private static readonly double[] ReferenceLabelWidths = new double[2]; private static IReadOnlyList? _textures; private static readonly double[] Advances = new double[Glyphs.Length]; private static readonly Typeface Face = new(new FontFamily("Bahnschrift SemiCondensed, Bahnschrift, Segoe UI"), @@ -36,12 +42,27 @@ internal static IReadOnlyList LoadTextures() for (var i = 0; i < labels.Length; i++) { var label = labels[i]; + if (i < 2) + { + var text = Format(label, 14); + var width = (int)Math.Ceiling(text.WidthIncludingTrailingWhitespace) + 2; + ReferenceLabelWidths[i] = width; + textures.Add(Raster(Base + 2 + (uint)i, width, 28, dc => dc.DrawText(text, new Point(0, 0)))); + continue; + } textures.Add(Raster(Base + 2 + (uint)i, 280, 28, dc => { - var text = Format(label, i < 2 ? 14 : 19); + var text = Format(label, 19); dc.DrawText(text, new Point((280 - text.Width) / 2, 0)); })); } + for (var i = 0; i < TimeGlyphs.Length; i++) + { + var text = Format(TimeGlyphs[i].ToString(), 14); + TimeAdvances[i] = text.WidthIncludingTrailingWhitespace + .5; + textures.Add(Raster(Base + 30 + (uint)i, TimeCellWidth, 28, + dc => dc.DrawText(text, new Point((TimeCellWidth - text.Width) / 2, 0)))); + } for (var i = 0; i < Glyphs.Length; i++) { var text = Format(Glyphs[i].ToString(), 52); @@ -84,10 +105,20 @@ private sealed class Snapshot(IReadOnlyList textures, LapDelta private sealed class Playback : HudLayerPlayback { private Snapshot? _snapshot; - private int _drawnGeneration; + private (int Generation, LapDeltaStatus Status, long Hundredths, long Bar, long Reference)? _drawn; private LapDeltaReading _reading = LapDeltaReading.Waiting; internal override void Update(HudLayerSnapshot snapshot, long timestamp) => _snapshot = (Snapshot)snapshot; - internal override bool CanReuse(long timestamp) => _snapshot is { } s && _drawnGeneration == s.Service.Generation; + internal override bool IdleWhenUnchanged => true; + internal override bool CanReuse(long timestamp) => + _snapshot is { } s && _drawn is { } drawn && drawn == Shown(s, s.Service.Latest); + + // What the panel shows: its status, the delta in hundredths, the bar to half a pixel and the + // reference lap's time. + private static (int, LapDeltaStatus, long, long, long) Shown(Snapshot snapshot, LapDeltaReading reading) => + (snapshot.Service.Generation, reading.Status, + reading.Seconds is { } delta ? (long)Math.Round(delta * 100) : long.MinValue, + reading.Seconds is { } bar && snapshot.Bar ? (long)Math.Round(Math.Clamp(Math.Abs(bar) / 2, 0, 1) * 280) : 0, + reading.ReferenceSeconds is { } reference ? (long)Math.Round(reference * 1000) : -1); internal override bool RefreshNativeHistory(long timestamp) { var next = _snapshot?.Service.Latest ?? LapDeltaReading.Waiting; @@ -99,8 +130,8 @@ internal override bool RefreshNativeHistory(long timestamp) internal override void AppendCommands(List commands, long timestamp) { if (_snapshot is not { } s) return; - _drawnGeneration = s.Service.Generation; _reading = s.Service.Latest; + _drawn = Shown(s, _reading); Append(commands, _reading, s.Reference, s.Bar, s.Ahead, s.Behind); } } @@ -109,7 +140,7 @@ internal static void Append(List commands, LapDelt LapDeltaReference reference, bool bar, AnalogHudColor? ahead = null, AnalogHudColor? behind = null) { commands.Add(AnalogHudScene.Quad(Base, new(0, 0, Width, Height))); - commands.Add(AnalogHudScene.Quad(Base + (reference == LapDeltaReference.SessionBest ? 2u : 3u), new(20, 9, 280, 28), color: Muted)); + AppendHeader(commands, reference == LapDeltaReference.SessionBest ? 0 : 1, reading.ReferenceSeconds); if (reading.Seconds is not { } delta) { var label = reading.Status switch { LapDeltaStatus.RecordingLap => 5u, LapDeltaStatus.RejoinReference => 6u, _ => 4u }; @@ -134,4 +165,27 @@ internal static void Append(List commands, LapDelt if (length > 0) commands.Add(AnalogHudScene.Quad(Base + 1, new(delta < 0 ? 160 - length : 160, 95, length, 5), color: color)); commands.Add(AnalogHudScene.Quad(Base + 1, new(159, 92, 2, 11), color: White)); } + + // SESSION BEST or PREVIOUS LAP, followed by that lap's time once there is one. + private static void AppendHeader(List commands, int label, double? referenceSeconds) + { + var time = referenceSeconds is > 0 and < 3600 ? LapTime(referenceSeconds.Value) : null; + var width = ReferenceLabelWidths[label] + (time is null ? 0 : TimeGap + time.Sum(character => TimeAdvances[TimeGlyphs.IndexOf(character)])); + var x = (Width - width) / 2; + commands.Add(AnalogHudScene.Quad(Base + 2 + (uint)label, new(x, 9, ReferenceLabelWidths[label], 28), color: Muted)); + if (time is null) return; + x += ReferenceLabelWidths[label] + TimeGap; + foreach (var character in time) + { + var index = TimeGlyphs.IndexOf(character); + commands.Add(AnalogHudScene.Quad(Base + 30 + (uint)index, new(x - (TimeCellWidth - TimeAdvances[index]) / 2, 9, TimeCellWidth, 28), color: White)); + x += TimeAdvances[index]; + } + } + + internal static string LapTime(double seconds) + { + var milliseconds = (long)Math.Round(seconds * 1000); + return $"{milliseconds / 60_000}:{milliseconds / 1000 % 60:00}.{milliseconds % 1000:000}"; + } } diff --git a/src/Wisp.App/NativeRendering/TrackMapHudLayer.cs b/src/Wisp.App/NativeRendering/TrackMapHudLayer.cs index 423c3ac..d82d0ae 100644 --- a/src/Wisp.App/NativeRendering/TrackMapHudLayer.cs +++ b/src/Wisp.App/NativeRendering/TrackMapHudLayer.cs @@ -104,24 +104,22 @@ private sealed class Snapshot(Artwork artwork, LapDeltaService service, AnalogHu private sealed class Playback : HudLayerPlayback { private Snapshot? _snapshot; - private LapTrackOutline? _drawnOutline; - private int _drawnGeneration; - private bool _drawnRecording; + private (int Generation, LapTrackOutline? Outline, bool Recording, bool Shown, long X, long Z)? _drawn; internal override void Update(HudLayerSnapshot snapshot, long timestamp) => _snapshot = (Snapshot)snapshot; - internal override bool CanReuse(long timestamp) - { - if (_snapshot is not { } s) return false; - var map = s.Service.LatestMap; - return _drawnGeneration == s.Service.Generation && ReferenceEquals(_drawnOutline, map?.Outline) && - _drawnRecording == (map?.IsRecording == true); - } + internal override bool IdleWhenUnchanged => true; + internal override bool CanReuse(long timestamp) => + _snapshot is { } s && _drawn is { } drawn && drawn == Shown(s.Service.Generation, s.Service.LatestMap); + + // What the map shows: its outline and label, and the car to half a metre. + private static (int, LapTrackOutline?, bool, bool, long, long) Shown(int generation, LapMapReading? map) => + (generation, map?.Outline, map?.IsRecording == true, map is not null, + map is null ? 0 : (long)Math.Round(map.Position.X * 2), map is null ? 0 : (long)Math.Round(map.Position.Z * 2)); + internal override void AppendCommands(List commands, long timestamp) { if (_snapshot is not { } s) return; - _drawnGeneration = s.Service.Generation; var map = s.Service.LatestMap; - _drawnOutline = map?.Outline; - _drawnRecording = map?.IsRecording == true; + _drawn = Shown(s.Service.Generation, map); Append(commands, s.Artwork, map, s.Track, s.Car, s.Background); } } diff --git a/src/Wisp.App/ReleaseNotesCatalog.cs b/src/Wisp.App/ReleaseNotesCatalog.cs index 99e2b1c..d1f336d 100644 --- a/src/Wisp.App/ReleaseNotesCatalog.cs +++ b/src/Wisp.App/ReleaseNotesCatalog.cs @@ -14,12 +14,28 @@ public static class ReleaseNotesCatalog { public static IReadOnlyList Entries { get; } = [ + new( + "2.5.1", + "September 26, 2026", + "LAP DELTA FIXES", + "Time Attack attempts end when Forza ends them, reference laps survive a Wisp restart, the lap overlays draw less often, and HUD profiles keep more of your colors.", + true, + [ + Group("Lap delta (beta)", + "End a Time Attack attempt when the car stays stopped for five seconds, or, once two laps agree on the circuit, stays off it or goes the wrong way for five seconds. The panel shows START A LAP until the next start-line crossing.", + "Keep your reference laps when Wisp restarts or updates while Forza keeps running, as Forza keeps its Current Best.", + "Show the reference lap's time next to SESSION BEST or PREVIOUS LAP.", + "Redraw the lap delta panel and track map only when what they show changes, instead of at the display's refresh rate."), + Group("HUD profiles", + "Save the background particle color, the app border and text colors, and the drift gauge's visibility, size, dark mode and black background.", + "Profiles saved before 2.5.1 leave these and your lap delta settings as they are when you apply them. Before, a profile from before 2.5 turned lap delta and the track map off and switched Time Attack to race timing.") + ]), new( "2.5", "September 26, 2026", "LAP DELTA BETA", "See how far ahead or behind your reference lap you are as you drive, with a live map of the circuit. Both are in beta.", - true, + false, [ Group("Lap delta (beta)", "Show a live delta against your session best or previous lap, compared at the same place on the track, with an optional bar.", diff --git a/src/Wisp.App/Wisp.App.csproj b/src/Wisp.App/Wisp.App.csproj index dfadbf2..64bd141 100644 --- a/src/Wisp.App/Wisp.App.csproj +++ b/src/Wisp.App/Wisp.App.csproj @@ -11,9 +11,9 @@ Wisp Wisp Wheel-indicated speed and G-force display for Forza Horizon 6. - 2.5.0 - 2.5.0.0 - 2.5.0.0 + 2.5.1 + 2.5.1.0 + 2.5.1.0 diff --git a/src/Wisp.App/app.manifest b/src/Wisp.App/app.manifest index 8121753..e86ca2c 100644 --- a/src/Wisp.App/app.manifest +++ b/src/Wisp.App/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/src/Wisp.Core/LapDelta.cs b/src/Wisp.Core/LapDelta.cs index 4c74217..dcd17d5 100644 --- a/src/Wisp.Core/LapDelta.cs +++ b/src/Wisp.Core/LapDelta.cs @@ -22,6 +22,11 @@ public sealed record LapDeltaReading(LapDeltaStatus Status, double? Seconds = nu public static LapDeltaReading Waiting { get; } = new(LapDeltaStatus.WaitingForLap); } +// Reference laps kept while Wisp restarts and the game keeps running: points as x, y, z, lap time +// and distance. +public sealed record LapReferenceTrace(float Duration, bool Confirmed, float[] Points); +public sealed record LapReferenceSession(LapTimingMode Timing, int Car, int Circuit, LapReferenceTrace? Best, LapReferenceTrace? Previous); + // One consumer owns the tracker. A lap trace is sampled by distance, and each // lookup searches only the nearby, forward part of the reference trace. public sealed class LapDeltaTracker @@ -43,6 +48,11 @@ public sealed class LapDeltaTracker private LapTelemetry? _last; private LapDeltaReading _lastReading = LapDeltaReading.Waiting; private int _held; + // Forza ends a Time Attack attempt that leaves the circuit or goes the wrong way. + private const float AttemptExpiry = 5; + private float? _offCircuitSince; + private LapReferenceSession? _restoring; + private Trace? _savedBest, _savedPrevious; private uint _lastTimestamp; private float _lastSpeed; private DateTimeOffset _lastReceived; @@ -70,6 +80,8 @@ private sealed class Trace(Point[] points, float duration) internal double MatchedTime; internal float MatchedDistance, DrivenAtMatch; internal bool MatchedLast; + // Another lap followed this one, so it is the circuit's path. + internal bool Confirmed; // Which parts of this trace the current lap drove along it, in order. Rejoining further // on, rewinding and driving a stretch again add nothing; only a new lap clears it. private readonly bool[] _covered = new bool[CoverageParts]; @@ -101,6 +113,7 @@ public void Reset() _mapPosition = null; _interrupted = _paused = _retainLearningMap = _isRecording = _gameLapActive = false; _held = 0; + _offCircuitSince = null; _lastReading = LapDeltaReading.Waiting; _gameLapProbe = null; _best = _previous = _challenger = null; @@ -201,6 +214,12 @@ public LapDeltaReading Update(VehicleState state, LapDeltaReference reference, L Interrupt(paused: !state.IsRaceOn); return LapDeltaReading.Waiting; } + if (_restoring is { } kept) + { + _restoring = null; + if (kept.Timing == timing && kept.Car == state.CarOrdinal) Restore(kept); + if ((_best ?? _previous) is not null && !NearCircuit(state.Lap.Position.ToVector())) ForgetCircuit(); + } if (_last is not null && state.CarOrdinal != _car) Reset(); // Being moved far from the circuit (to the festival, another event or a new race) leaves // it: its laps and map no longer apply. A reset or restart on the circuit keeps them. @@ -302,7 +321,73 @@ public LapDeltaReading Update(VehicleState state, LapDeltaReference reference, L _lastReceived = state.ReceivedAtUtc; _lastSpeed = state.GroundSpeedMetersPerSecond; Record(lap); - return _lastReading = Read(lap, reference, state.ReceivedTimestamp ?? 0); + var reading = Read(lap, reference, state.ReceivedTimestamp ?? 0); + // Once two laps agree on the circuit's path, five seconds away from it ends the attempt, as + // leaving the circuit or going the wrong way does in Forza. + if (timing == LapTimingMode.TimeAttack && reading.Status == LapDeltaStatus.RejoinReference && + (reference == LapDeltaReference.PreviousLap ? _previous : _best) is { Confirmed: true }) + { + _offCircuitSince ??= lap.RaceSeconds; + if (lap.RaceSeconds - _offCircuitSince >= AttemptExpiry) + { + _timeAttack.EndAttempt(); + _offCircuitSince = null; + return _lastReading = LapDeltaReading.Waiting; + } + } + else _offCircuitSince = null; + return _lastReading = reading; + } + + // Reference laps can be kept while Wisp restarts and the game keeps running, as the game keeps + // its own best for that run. They apply again in the same car and timing mode, on that circuit. + public LapReferenceSession? ExportReferences() => + _best is null && _previous is null ? null : new(_timingMode, _car, _timeAttack.Circuit, Export(_best), Export(_previous)); + + public void RestoreReferences(LapReferenceSession session) => _restoring = session; + + // True once after the reference laps change. + public bool TakeReferenceChange() + { + if (ReferenceEquals(_best, _savedBest) && ReferenceEquals(_previous, _savedPrevious)) return false; + (_savedBest, _savedPrevious) = (_best, _previous); + return true; + } + + private void Restore(LapReferenceSession kept) + { + _best = Import(kept.Best); + _previous = kept.Previous is { } previous && kept.Best is { } best && previous.Points.AsSpan().SequenceEqual(best.Points) + ? _best : Import(kept.Previous); + _car = kept.Car; + if (kept.Timing == LapTimingMode.TimeAttack) _timeAttack.Resume(kept.Circuit); + (_savedBest, _savedPrevious) = (_best, _previous); + } + + private static LapReferenceTrace? Export(Trace? trace) + { + if (trace is null) return null; + var values = new float[trace.Points.Length * 5]; + for (var i = 0; i < trace.Points.Length; i++) + { + var point = trace.Points[i]; + values[i * 5] = point.Position.X; + values[i * 5 + 1] = point.Position.Y; + values[i * 5 + 2] = point.Position.Z; + values[i * 5 + 3] = point.Time; + values[i * 5 + 4] = point.Distance; + } + return new(trace.Duration, trace.Confirmed, values); + } + + private static Trace? Import(LapReferenceTrace? kept) + { + if (kept?.Points is not { Length: >= 50 } values || values.Length % 5 != 0 || !float.IsFinite(kept.Duration) || + kept.Duration <= 0 || !Array.TrueForAll(values, float.IsFinite)) return null; + var points = new Point[values.Length / 5]; + for (var i = 0; i < points.Length; i++) + points[i] = new(new(values[i * 5], values[i * 5 + 1], values[i * 5 + 2]), values[i * 5 + 3], values[i * 5 + 4]); + return new Trace(points, kept.Duration) { Confirmed = kept.Confirmed }; } private bool HasGameLapTiming(VehicleState state, LapTelemetry lap) @@ -479,6 +564,8 @@ private void Record(LapTelemetry lap) return finish; } _challenger = null; + // A lap that followed the reference confirms the circuit's path. + if (_best is { Followed: true } followed) followed.Confirmed = trace.Confirmed = true; _previous = trace; if (_best is null || duration < _best.Duration) _best = trace; return finish; diff --git a/src/Wisp.Core/TimeAttackClock.cs b/src/Wisp.Core/TimeAttackClock.cs index e99ac52..a00efa7 100644 --- a/src/Wisp.Core/TimeAttackClock.cs +++ b/src/Wisp.Core/TimeAttackClock.cs @@ -36,6 +36,8 @@ internal bool Cross(Vector3 a, Vector3 b, out float fraction) private const double TickSpan = .0166; // Longer than any interval between packets while the game runs. private const double LongInterval = .2; + // Forza ends a Time Attack attempt when the car stays stopped this long. + private const double AttemptExpiry = 5; // How far back an earlier packet's arrival still places a later one, using the car's motion, // which is allowed to understate the time by this much. private const double ArrivalSpan = .07; @@ -49,6 +51,7 @@ internal bool Cross(Vector3 a, Vector3 b, out float fraction) private double _clock, _start = double.NaN; private float _distance, _lastLap; private ushort _lap; + private double _stopped; private Vector3 _heading; // When packets were sent belongs to the packet stream, so it carries across circuits: each // packet's tick, arrival and placing, the quickest delay seen, and the car's motion. @@ -59,11 +62,21 @@ internal bool Cross(Vector3 a, Vector3 b, out float fraction) // through that break, timed by that arrival. private bool _unplaced, _timedBreak; internal bool CircuitChanged { get; private set; } + internal int Circuit => _gate; + + // The attempt ends, as Forza ends it; the next start-line crossing begins one. + internal void EndAttempt() => _start = double.NaN; + + // Continues on a circuit known from before a restart, without starting over there. + internal void Resume(int circuit) + { + if (_gate < 0 && circuit >= 0 && circuit < Gates.Length) _gate = circuit; + } internal void Reset() { _lastState = null; _gate = -1; _clock = 0; _start = double.NaN; - _distance = _lastLap = 0; _lap = 0; _heading = default; _history.Clear(); + _distance = _lastLap = 0; _lap = 0; _stopped = 0; _heading = default; _history.Clear(); _timedBreak = false; } @@ -177,6 +190,8 @@ internal void Reset() if (elapsed <= LongInterval && moved > .05f) _heading = move / moved; _clock += step; _distance += moved; + _stopped = state.GroundSpeedMetersPerSecond < 1 ? _stopped + step : 0; + if (_stopped >= AttemptExpiry) _start = double.NaN; } // A reset, restart or fast travel ends the attempt, and the next start-line crossing begins one. if (!restored && !driving) _start = double.NaN; diff --git a/src/Wisp.Updater/Wisp.Updater.csproj b/src/Wisp.Updater/Wisp.Updater.csproj index cbbdd9b..7b4e9cb 100644 --- a/src/Wisp.Updater/Wisp.Updater.csproj +++ b/src/Wisp.Updater/Wisp.Updater.csproj @@ -12,9 +12,9 @@ Wisp Wisp Update Helper Applies verified Wisp updates after the app exits. - 2.5.0 - 2.5.0.0 - 2.5.0.0 + 2.5.1 + 2.5.1.0 + 2.5.1.0 diff --git a/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs b/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs index 326c4c0..2b4e5b8 100644 --- a/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs +++ b/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs @@ -21,12 +21,12 @@ public void DisplayFormattingDoesNotChangeMachineIdentity(string machine, string [Fact] public void CurrentVersionLabelsShareTheAssemblyVersion() { - Assert.Equal("2.5.0", ApplicationVersionInfo.MachineVersion); - Assert.Equal("2.5", ApplicationVersionInfo.DisplayVersion); + Assert.Equal("2.5.1", ApplicationVersionInfo.MachineVersion); + Assert.Equal("2.5.1", ApplicationVersionInfo.DisplayVersion); Assert.Null(ApplicationVersionInfo.DiagnosticBuildId); Assert.Null(ApplicationVersionInfo.DiagnosticBuildLabel); - Assert.Equal("WHEEL-INDICATED SPEED PANEL 2.5", ApplicationVersionInfo.FooterText); - Assert.Contains("current 2.5 entry covers this release", ApplicationVersionInfo.ReleaseHistoryIntroduction); + Assert.Equal("WHEEL-INDICATED SPEED PANEL 2.5.1", ApplicationVersionInfo.FooterText); + Assert.Contains("current 2.5.1 entry covers this release", ApplicationVersionInfo.ReleaseHistoryIntroduction); Assert.Equal(ApplicationVersionInfo.DisplayVersion, ReleaseNotesCatalog.Entries[0].Version); } } diff --git a/tests/Wisp.App.Tests/HudPresetTests.cs b/tests/Wisp.App.Tests/HudPresetTests.cs index 1c234be..363cb87 100644 --- a/tests/Wisp.App.Tests/HudPresetTests.cs +++ b/tests/Wisp.App.Tests/HudPresetTests.cs @@ -167,7 +167,9 @@ public void PresetDtoContainsOnlyReviewedHudPresentationFields() "TractionCueEnabled", "ColorTheme", "BackgroundTheme", "HudBorderTheme", "BoostGaugeTheme", "CustomAccentColor", "CustomBackgroundColor", "CustomHudBorderColor", "CustomBoostLowColor", "CustomBoostMidColor", "CustomBoostHighColor", "CustomTractionCueColor", - "CustomGForceColor", "CustomGForceTrailColor" + "CustomGForceColor", "CustomGForceTrailColor", + "Revision", "CustomParticleColor", "AppBorderColor", "AppTextColor", "AppMutedTextColor", + "DriftGaugeEnabled", "DriftGaugeScale", "DriftGaugeDarkMode", "DriftGaugeBackgroundEnabled", "DriftGaugeBackgroundOpacity" ]; var writable = typeof(HudPreset).GetProperties() @@ -185,6 +187,74 @@ public void PresetDtoContainsOnlyReviewedHudPresentationFields() property.Name.Contains("Hotkey", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void ProfilesKeepParticleAppTextBorderAndDriftGaugeColors() + { + var source = new AppSettings + { + CustomParticleColor = "#80FF3366", + ApplicationStyle = new AppStyleSettings { BorderColor = "#FF223344", TextColor = "#FFEEDDCC", MutedTextColor = "#FF998877", GlowStrength = 40 }, + DriftGaugeEnabled = true, + DriftGaugeScale = 1.4, + DriftGaugeDarkMode = true, + DriftGaugeBackgroundEnabled = true, + DriftGaugeBackgroundOpacity = .7 + }; + var target = new AppSettings { ApplicationStyle = new AppStyleSettings { GlowStrength = 90 } }; + + HudPreset.Capture(source, "Night").ApplyTo(target); + + Assert.Equal(ColorCustomization.NormalizeParticle(source.CustomParticleColor), target.CustomParticleColor); + Assert.Equal("#FF223344", target.ApplicationStyle.BorderColor); + Assert.Equal("#FFEEDDCC", target.ApplicationStyle.TextColor); + Assert.Equal("#FF998877", target.ApplicationStyle.MutedTextColor); + // Profiles carry the app's colors, not its other style settings. + Assert.Equal(90, target.ApplicationStyle.GlowStrength); + Assert.True(target.DriftGaugeEnabled); + Assert.Equal(1.4, target.DriftGaugeScale); + Assert.True(target.DriftGaugeDarkMode); + Assert.True(target.DriftGaugeBackgroundEnabled); + Assert.Equal(.7, target.DriftGaugeBackgroundOpacity); + } + + [Fact] + public void ProfilesSavedBefore251LeaveTheirNewerSettingsAlone() + { + var saved = HudPreset.Capture(new AppSettings(), "Older"); + var json = JsonSerializer.Serialize(saved); + foreach (var name in new[] { "Revision", "CustomParticleColor", "AppBorderColor", "AppTextColor", "AppMutedTextColor", + "DriftGaugeEnabled", "DriftGaugeScale", "DriftGaugeDarkMode", "DriftGaugeBackgroundEnabled", "DriftGaugeBackgroundOpacity" }) + { + using var document = JsonDocument.Parse(json); + var fields = document.RootElement.EnumerateObject().Where(property => property.Name != name) + .ToDictionary(property => property.Name, property => property.Value.Clone()); + json = JsonSerializer.Serialize(fields); + } + var older = JsonSerializer.Deserialize(json)!; + var target = new AppSettings + { + CustomParticleColor = "#80FF3366", + ApplicationStyle = new AppStyleSettings { TextColor = "#FFEEDDCC" }, + DriftGaugeEnabled = true, + DriftGaugeDarkMode = true, + LapTimingMode = LapTimingMode.TimeAttack, + LapDeltaEnabled = true, + LapMapEnabled = true, + LapDeltaAheadColor = "#FF00FF00" + }; + + older.ApplyTo(target); + + Assert.Equal(LapTimingMode.TimeAttack, target.LapTimingMode); + Assert.True(target.LapDeltaEnabled); + Assert.True(target.LapMapEnabled); + Assert.Equal("#FF00FF00", target.LapDeltaAheadColor); + Assert.Equal("#80FF3366", target.CustomParticleColor); + Assert.Equal("#FFEEDDCC", target.ApplicationStyle.TextColor); + Assert.True(target.DriftGaugeEnabled); + Assert.True(target.DriftGaugeDarkMode); + } + [Fact] public void ProfilesRoundTripWithoutChangingTheSettingsRevision() { diff --git a/tests/Wisp.App.Tests/HudSceneIdleTests.cs b/tests/Wisp.App.Tests/HudSceneIdleTests.cs new file mode 100644 index 0000000..5bc2d0a --- /dev/null +++ b/tests/Wisp.App.Tests/HudSceneIdleTests.cs @@ -0,0 +1,54 @@ +using Wisp.App.NativeRendering; +using Xunit; + +namespace Wisp.App.Tests; + +public sealed class HudSceneIdleTests +{ + private sealed class Layer(bool idle) : HudLayerSnapshot + { + internal bool Changed; + internal override IReadOnlyList Textures { get; } = []; + internal override HudLayerPlayback CreatePlayback() => new Playback(this, idle); + } + + private sealed class Playback(Layer layer, bool idle) : HudLayerPlayback + { + internal override void Update(HudLayerSnapshot snapshot, long timestamp) { } + internal override void AppendCommands(List commands, long timestamp) => layer.Changed = false; + internal override bool CanReuse(long timestamp) => !layer.Changed; + internal override bool IdleWhenUnchanged => idle; + } + + private static HudWindowSnapshot Window(HudLayerSnapshot layer, float x = 0) => + new(320, 112, true, [new HudLayerPlacement(1, layer, x, 0, 1, 0, 0, 1, 1)]); + + [Fact] + public void AnUnchangedSceneOfContentDrivenLayersIdlesAfterItIsBuilt() + { + var layer = new Layer(idle: true); + var scene = new HudScenePlayback(); + scene.Update(Window(layer), 0); + Assert.False(scene.IdleWhenUnchanged(0)); + scene.ConsumeTextureChanges(); + scene.BuildReusable(0); + Assert.True(scene.IdleWhenUnchanged(0)); + layer.Changed = true; + Assert.False(scene.IdleWhenUnchanged(0)); + scene.BuildReusable(0); + Assert.True(scene.IdleWhenUnchanged(0)); + // A moved layer needs drawing again. + scene.Update(Window(layer, 10), 0); + Assert.False(scene.IdleWhenUnchanged(0)); + } + + [Fact] + public void ScenesWithContinuouslyAnimatedLayersNeverIdle() + { + var scene = new HudScenePlayback(); + scene.Update(Window(new Layer(idle: false)), 0); + scene.ConsumeTextureChanges(); + scene.BuildReusable(0); + Assert.False(scene.IdleWhenUnchanged(0)); + } +} diff --git a/tests/Wisp.App.Tests/InstallerPackagingContractTests.cs b/tests/Wisp.App.Tests/InstallerPackagingContractTests.cs index 3228ebe..43ccfec 100644 --- a/tests/Wisp.App.Tests/InstallerPackagingContractTests.cs +++ b/tests/Wisp.App.Tests/InstallerPackagingContractTests.cs @@ -311,7 +311,7 @@ public void PublicInstallerUsesReleaseIdentityAndRequiresNormalUpdateSwitch() Assert.Contains("$artifactVersion = $projectVersion", packaging, StringComparison.Ordinal); Assert.Contains("& $innoExecutable \"/O$stageDirectory\" $innoScript", packaging, StringComparison.Ordinal); Assert.Contains("Write-BuildProvenance $repository $publishFullPath", packaging, StringComparison.Ordinal); - Assert.Contains("#define MyAppVersion \"2.5.0\"", inno, StringComparison.Ordinal); + Assert.Contains("#define MyAppVersion \"2.5.1\"", inno, StringComparison.Ordinal); Assert.Contains("#define MyAppOutputVersion MyAppVersion", inno, StringComparison.Ordinal); Assert.Contains("UpdatingExistingInstallation := UpdateSwitchPresent() and ExistingInstallationPresent();", inno, StringComparison.Ordinal); diff --git a/tests/Wisp.App.Tests/LapReferenceStoreTests.cs b/tests/Wisp.App.Tests/LapReferenceStoreTests.cs new file mode 100644 index 0000000..ce46e0f --- /dev/null +++ b/tests/Wisp.App.Tests/LapReferenceStoreTests.cs @@ -0,0 +1,39 @@ +using Wisp.App.Laps; +using Wisp.App.NativeRendering; +using Wisp.Core; +using Xunit; + +namespace Wisp.App.Tests; + +public sealed class LapReferenceStoreTests +{ + private static LapReferenceSession Session() => new(LapTimingMode.TimeAttack, 42, 2, + new(61.5f, true, Enumerable.Range(0, 100).Select(i => (float)i).ToArray()), null); + + [Fact] + public void KeptLapsReturnOnlyForTheSameRunOfTheGame() + { + var path = Path.Combine(Path.GetTempPath(), $"wisp-laps-{Guid.NewGuid():N}.json"); + var run = "100:1"; + var store = new LapReferenceStore(path, () => run); + store.Save(Session()); + var loaded = store.Load(); + Assert.NotNull(loaded); + Assert.Equal(2, loaded.Circuit); + Assert.Equal(61.5f, loaded.Best!.Duration); + Assert.Equal(Session().Best!.Points, loaded.Best.Points); + run = "200:2"; + Assert.Null(store.Load()); + run = "100:1"; + store.Save(null); + Assert.False(File.Exists(path)); + Assert.Null(store.Load()); + } + + [Theory] + [InlineData(33.034, "0:33.034")] + [InlineData(61.5, "1:01.500")] + [InlineData(119.9996, "2:00.000")] + public void HeaderShowsTheReferenceLapTime(double seconds, string expected) => + Assert.Equal(expected, LapDeltaHudLayer.LapTime(seconds)); +} diff --git a/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs b/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs index 02e0e5a..c3f493a 100644 --- a/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs +++ b/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs @@ -8,10 +8,10 @@ public sealed class ReleaseNotesCatalogTests public void CurrentReleaseAndEarlierVersionsAppearInDescendingOrder() { Assert.Equal( - ["2.5", "2.4.1", "2.4", "2.3.4", "2.3.3", "2.3.2", "2.3.1", "2.3", "2.2", "2.1.2", "2.1.1", "2.1", "2.0.1", "2.0", "1.2.2", "1.2.1", "1.2", "1.1.4", "1.1.3", "1.1.2", "1.1.1", "1.1", "1.0.12", "1.0.11", "1.0.10", "1.0.8", "1.0.7", "1.0.6", "1.0.5", "1.0.4", "1.0.3", "1.0.2", "1.0.1"], + ["2.5.1", "2.5", "2.4.1", "2.4", "2.3.4", "2.3.3", "2.3.2", "2.3.1", "2.3", "2.2", "2.1.2", "2.1.1", "2.1", "2.0.1", "2.0", "1.2.2", "1.2.1", "1.2", "1.1.4", "1.1.3", "1.1.2", "1.1.1", "1.1", "1.0.12", "1.0.11", "1.0.10", "1.0.8", "1.0.7", "1.0.6", "1.0.5", "1.0.4", "1.0.3", "1.0.2", "1.0.1"], ReleaseNotesCatalog.Entries.Select(entry => entry.Version)); Assert.True(ReleaseNotesCatalog.Entries[0].IsCurrent); - Assert.Equal("LAP DELTA BETA", ReleaseNotesCatalog.Entries[0].Label); + Assert.Equal("LAP DELTA FIXES", ReleaseNotesCatalog.Entries[0].Label); var publicChanges = Assert.Single(ReleaseNotesCatalog.Entries.Single(entry => entry.Version == "2.3.2").Groups, group => group.Heading == "Appearance preview"); Assert.Equal(3, publicChanges.Items.Count); diff --git a/tests/Wisp.Core.Tests/TimeAttackTests.cs b/tests/Wisp.Core.Tests/TimeAttackTests.cs index ae4a6ab..dc3802c 100644 --- a/tests/Wisp.Core.Tests/TimeAttackTests.cs +++ b/tests/Wisp.Core.Tests/TimeAttackTests.cs @@ -276,6 +276,73 @@ public void RewindToBeforeTheGateEndsTheAttemptAndKeepsTheCompletedLap() Assert.InRange(sample.CurrentLapSeconds, .49, .51); } + [Theory] + [InlineData(3, LapDeltaStatus.Comparing)] + [InlineData(6, LapDeltaStatus.WaitingForLap)] + public void StoppingForFiveSecondsEndsTheAttempt(int seconds, LapDeltaStatus expected) + { + var tracker = new LapDeltaTracker(); + Reference(tracker); + for (var i = 651; i <= 700; i++) Update(tracker, i / 10d, i / 10d); + LapDeltaReading reading = LapDeltaReading.Waiting; + for (var i = 1; i <= seconds * 10; i++) + reading = tracker.Update(State(70 + i / 10d, 70) with { GroundSpeedMetersPerSecond = 0 }, + LapDeltaReference.SessionBest, LapTimingMode.TimeAttack); + Assert.Equal(expected, reading.Status); + // A new attempt begins at the next start-line crossing. + for (var i = 1; i <= 700; i++) reading = Update(tracker, 70 + seconds + i / 10d, 70 + i / 10d); + Assert.Equal(LapDeltaStatus.Comparing, reading.Status); + } + + [Theory] + [InlineData(1, LapDeltaStatus.RejoinReference)] + [InlineData(2, LapDeltaStatus.WaitingForLap)] + public void LeavingTheCircuitEndsTheAttemptOnceTwoLapsAgree(int laps, LapDeltaStatus expected) + { + var tracker = new LapDeltaTracker(); + for (var i = -1; i <= laps * 600 + 100; i++) Update(tracker, i / 10d, i / 10d); + var center = (Route(0) + Route(30)) / 2; + LapDeltaReading reading = LapDeltaReading.Waiting; + for (var i = 1; i <= 80; i++) + { + var time = laps * 60 + 10 + i / 10d; + var onCircuit = Route(time); + var outward = Vector3.Normalize(onCircuit - center); + reading = UpdateAt(tracker, time, onCircuit + outward * Math.Min(40, i * 1.6f)); + } + Assert.Equal(expected, reading.Status); + } + + [Fact] + public void ReferenceLapsCarryOverARestartOfWisp() + { + var first = new LapDeltaTracker(); + Reference(first); + var kept = first.ExportReferences(); + Assert.NotNull(kept); + var restarted = new LapDeltaTracker(); + restarted.RestoreReferences(kept); + LapDeltaReading reading = LapDeltaReading.Waiting; + // The attempt under way when Wisp restarted is unknown; the next one compares with the kept lap. + for (var i = 700; i <= 1300; i++) reading = Update(restarted, i / 10d, i / 10d); + Assert.Equal(LapDeltaStatus.Comparing, reading.Status); + Assert.Equal(60, reading.ReferenceSeconds!.Value, 1); + Assert.InRange(reading.Seconds!.Value, -.02, .02); + } + + [Fact] + public void KeptReferenceLapsApplyOnlyToTheSameCar() + { + var first = new LapDeltaTracker(); + Reference(first); + var restarted = new LapDeltaTracker(); + restarted.RestoreReferences(first.ExportReferences()!); + LapDeltaReading reading = LapDeltaReading.Waiting; + for (var i = 700; i <= 1300; i++) + reading = restarted.Update(State(i / 10d, i / 10d) with { CarOrdinal = 43 }, LapDeltaReference.SessionBest, LapTimingMode.TimeAttack); + Assert.Equal(LapDeltaStatus.RecordingLap, reading.Status); + } + [Fact] public void DifferentCircuitAndTimingModeClearOldReferences() {