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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

Notable changes to Wisp are recorded here.

## 1.2.0 - 2026-09-09

- Record drives locally from Dashboard or Runs, with optional shortcuts, a start countdown, timed stops and markers for moments to review.
- Review measured findings, readable charts and selected-interval statistics inside Wisp. Open all eight graph views from one Show graphs button.
- Compare two saved runs over time or through a shared speed range, including starting-condition differences and capture gaps. Separate run colors and line styles carry through graphs, legends, markers and exported images.
- Inspect power and torque against RPM with gear and throttle filters, a G-force plot, or front/rear tire-temperature changes.
- Export a readable report image, raw telemetry CSV, or a Wisp recording that can be imported for comparison.
- Keep raw run capture and post-run analysis separate from the live HUD renderer and preserve existing HUD profiles.

## 1.1.4 - 2026-09-08

- Move the live combustion Analogue HUD to a dedicated Direct3D11 and DirectComposition renderer to address tachometer stutter while Forza is focused.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@
<a href="docs/HOW-WISP-WAS-BUILT.md">Architecture</a>
</p>

## New in Wisp 1.2

Record a drive and compare it with another run without leaving Wisp. Start from
Dashboard or Runs, or set a recording shortcut. Add a countdown, choose a timed
stop, and mark moments while driving. Runs stay on your PC, with names, tune
labels and notes.

Open **Show graphs** to review speed, driver inputs, engine readings, G-force
and tire temperatures. Compare power and torque against RPM, inspect individual
readings, or select a section for its own statistics. Run A and Run B use
different colors and line styles in both Wisp and exported images.

Compare whole recordings or match an acceleration speed range. Findings explain
what the telemetry shows, with starting conditions and missing data called out.
Export a report image, raw CSV, or a Wisp run file that someone else can open.
Recordings are limited to ten minutes; Wisp does not identify tunes automatically
or record gameplay video.

[Download the latest version](https://github.com/Views2k/Wisp/releases/latest) ·
[1.2 release notes](docs/releases/Wisp-1.2.0-release-notes.md)

## New in Wisp 1.1.4

Addresses choppy Analogue tachometer motion while Forza is focused. The live
Expand Down
26 changes: 26 additions & 0 deletions docs/releases/Wisp-1.2.0-release-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Wisp 1.2

Record a drive, see what happened, and compare it with another run inside Wisp.
Recordings stay on your PC and can include a run name, tune label and notes.

- Start and stop from Dashboard or Runs, or assign a keyboard shortcut. Choose
a countdown or timed stop, and mark moments to return to after driving.
- Open **Show graphs** for eight views covering speed, driver inputs, engine
readings, G-force and tire temperatures. Inspect individual readings or
select a section to see its statistics.
- Compare two saved runs over time or through the same acceleration speed
range. Run A and Run B have separate colors and line styles throughout the
graphs, legends, markers and report images.
- Plot power and torque against RPM, with gear and throttle filters. Review
cornering and acceleration together in a G-force plot, or compare starting
and ending tire temperatures.
- Read findings based on recorded measurements, with starting conditions and
missing data shown alongside the results.
- Export a report image, raw telemetry CSV, or a Wisp recording that another
person can import and compare. Removed runs can be restored.

Runs are limited to ten minutes. Tune names are your own labels; Forza does not
provide a tune identifier. This records telemetry, not gameplay video. Existing
HUD rendering, profiles and tire calibration are preserved.

The installer is `Wisp-Setup-1.2.0.exe`. Install over your current Wisp version.
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 "1.1.4"
#define MyAppVersion "1.2.0"
#define MyAppDisplayVersion MyAppVersion
#define MyAppOutputVersion MyAppVersion
#define MyAppPublisher "Wisp"
Expand Down
24 changes: 24 additions & 0 deletions src/Wisp.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public partial class App : Application
private DispatcherTimer? _forzaStartupTimer;
private StartupTrayIcon? _startupTray;
private OverlayHotkeyService? _overlayHotkey;
private OverlayHotkeyService? _recordingHotkey;
private OverlayHotkeyService? _markerHotkey;
private bool _runtimeActive;
private bool _applicationUpdateHandoffActive;
private bool _exiting;
Expand Down Expand Up @@ -54,6 +56,24 @@ protected override async void OnStartup(StartupEventArgs e)
_overlayHotkey = new OverlayHotkeyService();
_overlayHotkey.Pressed += (_, _) => _controller?.ToggleManualOverlayHidden();
_controller.SetOverlayHotkeyRegistration(_overlayHotkey.Apply);
_recordingHotkey = new OverlayHotkeyService();
_recordingHotkey.Pressed += async (_, _) =>
{
if (_controller is not null && _runtimeActive && !_controller.Runs.ShortcutCaptureActive)
{
await _controller.Runs.ToggleRecordingAsync();
}
};
_controller.SetRecordingHotkeyRegistration(_recordingHotkey.Apply);
_markerHotkey = new OverlayHotkeyService();
_markerHotkey.Pressed += (_, _) =>
{
if (_controller is not null && _runtimeActive && !_controller.Runs.ShortcutCaptureActive)
{
_controller.Runs.MarkMoment();
}
};
_controller.SetMarkerHotkeyRegistration(_markerHotkey.Apply);
if (ApplicationUpdateLauncher.TryConsumeResult(out var updateResult))
{
_controller.ViewModel.UpdateApplicationUpdateStatus(
Expand Down Expand Up @@ -178,6 +198,10 @@ protected override void OnExit(ExitEventArgs e)
{
_overlayHotkey?.Dispose();
_overlayHotkey = null;
_recordingHotkey?.Dispose();
_recordingHotkey = null;
_markerHotkey?.Dispose();
_markerHotkey = null;
_activationCancellation?.Dispose();
_activationEvent?.Dispose();
_instanceMutex?.Dispose();
Expand Down
123 changes: 123 additions & 0 deletions src/Wisp.App/AppController.Runs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System.Diagnostics;
using System.IO;
using Wisp.App.Runs;
using Wisp.Core;

namespace Wisp.App;

public sealed partial class AppController
{
private RunRecordingService _runRecording = null!;
private Func<bool, OverlayHotkeyChord, OverlayHotkeyRegistrationResult>? _recordingHotkeyRegistration;
private Func<bool, OverlayHotkeyChord, OverlayHotkeyRegistrationResult>? _markerHotkeyRegistration;
private bool _runsInitialized;
private DrivetrainType? _runCalibrationDrivetrain;

public RunsViewModel Runs { get; private set; } = null!;

private void InitializeRuns(string? directory)
{
// Tests and preview hosts without a SettingsService never open the real library.
directory ??= Path.Combine(Path.GetTempPath(), "Wisp", "RunReview", Guid.NewGuid().ToString("N"));
_runRecording = new RunRecordingService(_receiver, directory);
Runs = new RunsViewModel(_runRecording, Settings, _dispatcher);
Runs.BeforeStart = () => PublishRunContext(force: true);
Runs.PreferencesChanged += (_, _) => ScheduleSettingsSave();
}

internal void SetRecordingHotkeyRegistration(
Func<bool, OverlayHotkeyChord, OverlayHotkeyRegistrationResult> registration)
{
_recordingHotkeyRegistration = registration;
Runs.SetHotkeyHandler(ConfigureRecordingShortcut);
}

private string? ConfigureRecordingShortcut(bool enabled, OverlayHotkeyChord chord)
{
if (_disposed || _runtimeSuspended || Settings.RequiresSetup)
{
return "The recording shortcut becomes available when Wisp is running.";
}
var result = _recordingHotkeyRegistration?.Invoke(enabled, chord)
?? new OverlayHotkeyRegistrationResult(false, "the shortcut service is unavailable");
if (!result.Succeeded)
{
return result.Error;
}
Settings.RecordingShortcutEnabled = enabled;
Settings.RecordingShortcutModifiers = chord.Modifiers;
Settings.RecordingShortcutKey = chord.Key;
ScheduleSettingsSave();
return null;
}

private void StartRunsUi()
{
Runs.RefreshHotkey();
Runs.RefreshMarkerHotkey();
if (!_runsInitialized && ControlPanel is not null)
{
_runsInitialized = true;
_ = Runs.InitializeAsync();
}
}

private void SuspendRunShortcut()
{
_ = _recordingHotkeyRegistration?.Invoke(false,
new OverlayHotkeyChord(Settings.RecordingShortcutModifiers, Settings.RecordingShortcutKey));
Runs.SuspendHotkeyStatus();
_ = _markerHotkeyRegistration?.Invoke(false,
new OverlayHotkeyChord(Settings.MarkerShortcutModifiers, Settings.MarkerShortcutKey));
Runs.SuspendMarkerHotkeyStatus();
}

internal void SetMarkerHotkeyRegistration(
Func<bool, OverlayHotkeyChord, OverlayHotkeyRegistrationResult> registration)
{
_markerHotkeyRegistration = registration;
Runs.SetMarkerHotkeyHandler(ConfigureMarkerShortcut);
}

private string? ConfigureMarkerShortcut(bool enabled, OverlayHotkeyChord chord)
{
if (_disposed || _runtimeSuspended || Settings.RequiresSetup)
{
return "The marker shortcut becomes available when Wisp is running.";
}
var result = _markerHotkeyRegistration?.Invoke(enabled, chord)
?? new OverlayHotkeyRegistrationResult(false, "the shortcut service is unavailable");
if (!result.Succeeded) return result.Error;
Settings.MarkerShortcutEnabled = enabled;
Settings.MarkerShortcutModifiers = chord.Modifiers;
Settings.MarkerShortcutKey = chord.Key;
ScheduleSettingsSave();
return null;
}

private void PublishRunContext(bool force = false)
{
if (!force && !_runRecording.IsRecording)
{
return;
}
var state = _receiver.Latest;
if (state is null)
{
return;
}
var now = Stopwatch.GetTimestamp();
var snapshot = _nativeHudProcessService.SnapshotFor(state.CarOrdinal);
var visibility = EvaluateNativeGameplayVisibility(snapshot, now);
var fresh = state.ReceivedTimestamp is { } received && now >= received &&
Stopwatch.GetElapsedTime(received, now) <= TelemetryTimeout;
var radii = _hasDebugDerivedTelemetry && _debugDerivedCarOrdinal == state.CarOrdinal &&
_runCalibrationDrivetrain == state.Drivetrain &&
_debugCalibration.IsTrusted && fresh ? _debugCalibration.TrustedRadii : null;
_runRecording.UpdateContext(new RunRecordingContext(
now, state.CarOrdinal, state.Drivetrain,
fresh && state.IsRaceOn && visibility.Fresh && visibility.Visibility == NativeGameplayVisibility.Visible,
radii?.FrontMeters, radii?.RearMeters,
visibility.Fresh ? snapshot.VisibilityObservedTimestamp + NativeVisibilityFreshnessTicks : now));
}
}
26 changes: 23 additions & 3 deletions src/Wisp.App/AppController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace Wisp.App;

public sealed record ApplicationUpdateDetails(string Version, string ReleaseSummary);

public sealed class AppController : IAsyncDisposable
public sealed partial class AppController : IAsyncDisposable
{
private static readonly TimeSpan ConnectedTimerInterval = TimeSpan.FromMilliseconds(50);
private static readonly TimeSpan IdleTimerInterval = TimeSpan.FromSeconds(1);
Expand Down Expand Up @@ -103,7 +103,8 @@ public AppController(AppSettings settings, SettingsService settingsService)
settings,
settingsService.Save,
new StartupRegistrationService(),
settingsService.SaveCompletedSetup)
settingsService.SaveCompletedSetup,
runsDirectory: Path.Combine(settingsService.DataDirectory, "Runs"))
{
}

Expand All @@ -112,7 +113,8 @@ internal AppController(
Action<AppSettings> saveSettings,
IStartupRegistrationService startupRegistrationService,
Action<AppSettings>? saveCompletedSetup = null,
Func<Version, CancellationToken, Task<UpdateRelease?>>? checkForApplicationUpdate = null)
Func<Version, CancellationToken, Task<UpdateRelease?>>? checkForApplicationUpdate = null,
string? runsDirectory = null)
{
Settings = settings;
_saveSettings = saveSettings ?? throw new ArgumentNullException(nameof(saveSettings));
Expand Down Expand Up @@ -145,6 +147,7 @@ internal AppController(
SetTachDiagnosticsEnabled(_debugLog.IsEnabled);
ViewModel = new DiagnosticsViewModel(settings);
_dispatcher = Dispatcher.CurrentDispatcher;
InitializeRuns(runsDirectory);
_debugHealthMonitor = new DebugHealthMonitor(
_receiver,
_nativeHudProcessService,
Expand Down Expand Up @@ -339,6 +342,7 @@ public async Task StartAsync()

_uiTimer.Start();
ApplyOverlayHotkeyRegistration();
StartRunsUi();
}

internal bool InitializeStartupRegistration()
Expand Down Expand Up @@ -384,6 +388,8 @@ internal async Task SuspendForForzaAsync()
// Closing to the opt-in companion releases UDP and native demand.
// Queued packet/compositor callbacks cannot restart a suspended session.
_runtimeSuspended = true;
SuspendRunShortcut();
var stoppedRun = _runRecording.StopAsync("Forza session ended");
_manualOverlayHidden = false;
_ = _overlayHotkeyRegistration?.Invoke(
false,
Expand All @@ -407,6 +413,7 @@ internal async Task SuspendForForzaAsync()
SaveSettings();
}
await _receiver.StopAsync().ConfigureAwait(false);
await stoppedRun.ConfigureAwait(false);
}

public void CompleteSetup(SetupPreferences preferences)
Expand Down Expand Up @@ -831,6 +838,7 @@ public async Task RestartListenerAsync(int port)

try
{
await _runRecording.StopAsync("Telemetry listener restarted");
await _receiver.RestartAsync(port);
}
catch
Expand Down Expand Up @@ -1024,6 +1032,7 @@ private void ApplyViewOptions(

UpdateOverlayVisibility(DateTimeOffset.UtcNow, force: true);
ScheduleSettingsSave();
Runs.RefreshStatus();
if (!previousAutomaticApplicationUpdateChecks && Settings.AutomaticApplicationUpdateChecks)
{
BeginStartupApplicationUpdateCheck();
Expand Down Expand Up @@ -1712,6 +1721,8 @@ public bool RelearnCurrentTires()
}

_calibration.ResetProfile(carOrdinal);
_runCalibrationDrivetrain = null;
PublishRunContext();
_capturedCalibrationProfiles.Remove(carOrdinal);
Settings.Calibrations = _calibration.ExportSnapshots().ToList();
_speedModel.Reset();
Expand Down Expand Up @@ -1801,6 +1812,9 @@ public async ValueTask DisposeAsync()
}

_disposed = true;
_ = _runRecording.StopAsync("Wisp closed before the run finished");
SuspendRunShortcut();
Runs.Dispose();
SetTachDiagnosticsEnabled(false);
_compatibilityLifetime.Cancel();
_applicationUpdateLifetime.Cancel();
Expand Down Expand Up @@ -1848,6 +1862,7 @@ public async ValueTask DisposeAsync()
{
// Optional local logging must never prevent clean application shutdown.
}
await _runRecording.DisposeAsync().ConfigureAwait(false);
await _nativeHudProcessService.DisposeAsync().ConfigureAwait(false);
await _receiver.DisposeAsync().ConfigureAwait(false);
_compatibilityLifetime.Dispose();
Expand Down Expand Up @@ -1986,6 +2001,9 @@ private void ProcessUiUpdate(bool processLatestPacket = true)
{
_nextStatisticsAtUtc = now + TimeSpan.FromMilliseconds(250);
_cachedStatistics = _receiver.GetStatistics(now);
_runRecording.RefreshStatus();
PublishRunContext();
if (Runs.IsCountingDown) Runs.RefreshStatus();
}

var latest = _receiver.Latest;
Expand Down Expand Up @@ -2149,7 +2167,9 @@ private void ProcessUiUpdate(bool processLatestPacket = true)
_debugDerivedCarOrdinal = current.CarOrdinal;
_debugIndicatedSpeed = indicated;
_debugCalibration = calibration;
_runCalibrationDrivetrain = current.Drivetrain;
_hasDebugDerivedTelemetry = true;
PublishRunContext();
var detachedBoostWasEnabled = IsDetachedBoostGaugeEnabled;
var detachedTireTemperatureWasEnabled = IsDetachedTireTemperatureGaugeEnabled;
ViewModel.Update(
Expand Down
Loading
Loading