Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
Supports Steam and Xbox app / Microsoft Store editions on Windows PC.<br>
<br><strong>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.</strong>

<a href="https://github.com/Views2k/Wisp/releases/download/v2.5.0/Wisp-Setup-2.5.0.zip"><strong>Download Wisp 2.5</strong></a> ·
<a href="https://github.com/Views2k/Wisp/releases/download/v2.5.1/Wisp-Setup-2.5.1.zip"><strong>Download Wisp 2.5.1</strong></a> ·
<a href="https://wispoverlay.com/">Website</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
<a href="docs/HOW-WISP-WAS-BUILT.md">Architecture</a><br><br>
Support Wisp: <a href="https://ko-fi.com/views2k">Ko-fi</a> ·
<a href="https://buymeacoffee.com/Views2k">Buy Me a Coffee</a>
</p>

## 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).
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/releases/Wisp-2.5.1-release-notes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion installer/Wisp.iss
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/Wisp.App/AppController.Laps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
4 changes: 4 additions & 0 deletions src/Wisp.App/AppController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/Wisp.App/ControlPanelWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ private void SetEditorColor(ColorWheelEditor editor, Color color)
internal void ApplyHudPresetToControls()
{
FindControl<LapDeltaSettingsControl>("LapDeltaSettings").Initialize(_controller);
FindControl<DriftGaugeSettingsControl>("DriftGaugeSettings").Initialize(_controller);
var wasLoaded = _loaded;
_loaded = false;
try
Expand Down
66 changes: 53 additions & 13 deletions src/Wisp.App/HudPreset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
};
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 14 additions & 2 deletions src/Wisp.App/Laps/LapDeltaService.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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))
Expand All @@ -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));
}
Expand Down
62 changes: 62 additions & 0 deletions src/Wisp.App/Laps/LapDiagnosticsRecorder.cs
Original file line number Diff line number Diff line change
@@ -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) { }
}
}
Loading
Loading