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

Notable changes to Wisp are recorded here.

## 2.5.3 - 2026-09-30

- Place a HUD without a saved position on Forza's monitor when the game is first detected. Do not save the temporary startup position before that detection.
- Make Reset HUD positions move the main HUD to the last detected Forza window's monitor. Existing saved layouts remain unchanged until moved or reset.

## 2.5.2 - 2026-09-29

- Keep the HUD working when Forza restarts while Wisp stays open. When the game closed, Windows cleared the HUD window's owner, and Wisp treated that as a renderer failure that turned the HUD off until Wisp restarted.
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 the latest release.</strong>

<a href="https://github.com/Views2k/Wisp/releases/download/v2.5.2/Wisp-Setup-2.5.2.zip"><strong>Download Wisp 2.5.2</strong></a> ·
<a href="https://github.com/Views2k/Wisp/releases/download/v2.5.3/Wisp-Setup-2.5.3.zip"><strong>Download Wisp 2.5.3</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.3

A new HUD now starts on Forza's monitor when the game is detected. If your HUD is already saved on the wrong screen, focus Forza once, then use **Reset HUD positions** in Wisp. Saved layouts stay unchanged until you move or reset them. [Release notes](docs/releases/Wisp-2.5.3-release-notes.md).

## Wisp 2.5.2

The HUD keeps working when Forza restarts, telemetry survives oversized packets, and saved runs, reference laps, updates and renderer status behave as shown. [Release notes](docs/releases/Wisp-2.5.2-release-notes.md).
Expand Down Expand Up @@ -178,7 +182,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.2](https://github.com/Views2k/Wisp/releases/download/v2.5.2/Wisp-Setup-2.5.2.zip) ·
[Download Wisp 2.5.3](https://github.com/Views2k/Wisp/releases/download/v2.5.3/Wisp-Setup-2.5.3.zip) ·
[2.5 release notes](docs/releases/Wisp-2.5.0-release-notes.md) ·
[Changelog](CHANGELOG.md)

Expand Down Expand Up @@ -302,8 +306,8 @@ require administrator access or a separate .NET runtime.

## Install

1. Open the [Wisp 2.5.2 release](https://github.com/Views2k/Wisp/releases/tag/v2.5.2).
2. Download and extract [Wisp-Setup-2.5.2.zip](https://github.com/Views2k/Wisp/releases/download/v2.5.2/Wisp-Setup-2.5.2.zip).
1. Open the [Wisp 2.5.3 release](https://github.com/Views2k/Wisp/releases/tag/v2.5.3).
2. Download and extract [Wisp-Setup-2.5.3.zip](https://github.com/Views2k/Wisp/releases/download/v2.5.3/Wisp-Setup-2.5.3.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
7 changes: 7 additions & 0 deletions docs/releases/Wisp-2.5.3-release-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Wisp 2.5.3

I fixed first-time HUD placement on systems with multiple monitors. Wisp now puts a HUD without a saved position on Forza's monitor when the game is detected, instead of keeping the temporary startup position on another screen.

If your gauge is already saved on the wrong screen, focus Forza once, then open Wisp and click **Reset HUD positions**. This moves the main HUD to the game's monitor and restores its default position, including the default size for native gauges. You can also use **Edit HUD layout** to drag it where you want it.

Existing saved layouts stay where you placed them unless you move or reset them. Wisp does not continually move the HUD when the game changes screens.
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.2"
#define MyAppVersion "2.5.3"
#define MyAppDisplayVersion MyAppVersion
#define MyAppOutputVersion MyAppVersion
#define MyAppPublisher "Wisp"
Expand Down
81 changes: 46 additions & 35 deletions src/Wisp.App/AppController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public sealed partial class AppController : IAsyncDisposable
private DateTimeOffset _tractionCueUntilUtc = DateTimeOffset.MinValue;
private ReceiverStatistics _cachedStatistics;
private string? _activeOverlayPlacementKey;
private bool _initialOverlayPlacementPending;
private NativeHudPublicationKey _lastNativeHudPublication;
private bool _hasNativeHudPublication;
private bool _debugListenerErrorActive;
Expand Down Expand Up @@ -1573,6 +1574,7 @@ public void SaveOverlayPlacement()
return;
}

_initialOverlayPlacementPending = false;
var key = Overlay.GetDisplayKey();
_activeOverlayPlacementKey = key;
Settings.LastOverlayPlacementKey = key;
Expand Down Expand Up @@ -1646,6 +1648,7 @@ public void RestoreOverlayPlacement()
var key = PreferredOverlayPlacementKey();
if (Settings.Placements.TryGetValue(key, out var placement))
{
_initialOverlayPlacementPending = false;
_activeOverlayPlacementKey = key;
Settings.LastOverlayPlacementKey = key;
ViewModel.OverlayWidthScale = placement.WidthScale;
Expand All @@ -1662,33 +1665,34 @@ public void RestoreOverlayPlacement()
}
else
{
if (Settings.LayoutMode == HudLayoutMode.Native)
{
var referenceScale = Overlay.CurrentNativeReferenceScale();
Settings.OverlayWidthScale = referenceScale;
Settings.OverlayHeightScale = referenceScale;
ViewModel.OverlayWidthScale = referenceScale;
ViewModel.OverlayHeightScale = referenceScale;
Overlay.ApplyLayout(
Settings.LayoutMode,
Settings.NativeGaugeMode,
referenceScale,
referenceScale,
Settings.OverlayOpacity);
}

Overlay.ResetPosition();
_initialOverlayPlacementPending = true;
Settings.LastOverlayPlacementKey = null;
ResetMainOverlayPosition();
_activeOverlayPlacementKey = Overlay.GetDisplayKey();
Settings.LastOverlayPlacementKey = _activeOverlayPlacementKey;
var bounds = Overlay.GetPlacementBounds();
Settings.Placements[_activeOverlayPlacementKey] = new OverlayPlacement(
bounds.Left,
bounds.Top,
Settings.OverlayWidthScale,
Settings.OverlayHeightScale);
// A fallback shown before Forza is known is not a saved user layout.
// Leave it pending across settings saves and restarts until the game
// is confirmed, or the user explicitly drags/resets the HUD.
CompleteInitialOverlayPlacement(_lastConfirmedForzaWindow);
}
}

internal void CompleteInitialOverlayPlacement(IntPtr gameWindow)
{
if (!_initialOverlayPlacementPending || !Settings.OverlayLocked ||
Overlay is null || !Overlay.TryUseMonitorOfWindow(gameWindow)) return;

ResetMainOverlayPosition();
SaveOverlayPlacement();
if (!HasSavedPlacement(Settings.GForcePlacements, Settings.LastGForcePlacementKey)) RestoreGForcePlacement();
if (!HasSavedPlacement(Settings.BoostGaugePlacements, Settings.LastBoostGaugePlacementKey)) RestoreBoostGaugePlacement();
if (!HasSavedPlacement(Settings.TireTemperatureGaugePlacements, Settings.LastTireTemperatureGaugePlacementKey)) RestoreTireTemperatureGaugePlacement();
if (!HasSavedPlacement(Settings.PowerGaugePlacements, Settings.LastPowerGaugePlacementKey)) RestorePowerGaugePlacement();
if (!HasSavedPlacement(Settings.TorqueGaugePlacements, Settings.LastTorqueGaugePlacementKey)) RestoreTorqueGaugePlacement();

static bool HasSavedPlacement(IReadOnlyDictionary<string, OverlayPlacement> placements, string? key) =>
key is not null && placements.ContainsKey(key);
}

public void RestoreGForcePlacement()
{
if (GForceOverlay is null)
Expand Down Expand Up @@ -1788,18 +1792,8 @@ public void RestoreTireTemperatureGaugePlacement()

public void ResetOverlayPosition()
{
if (Overlay is not null && Settings.LayoutMode == HudLayoutMode.Native)
{
var scale = Overlay.CurrentNativeReferenceScale();
Settings.OverlayWidthScale = scale;
Settings.OverlayHeightScale = scale;
ViewModel.OverlayWidthScale = scale;
ViewModel.OverlayHeightScale = scale;
Overlay.ApplyLayout(
Settings.LayoutMode, Settings.NativeGaugeMode, scale, scale, Settings.OverlayOpacity);
}

Overlay?.ResetPosition();
Overlay?.TryUseMonitorOfWindow(_lastConfirmedForzaWindow);
ResetMainOverlayPosition();
SaveOverlayPlacement();
ResetGForcePosition();
SaveGForcePlacement();
Expand All @@ -1814,6 +1808,22 @@ public void ResetOverlayPosition()
}
}

private void ResetMainOverlayPosition()
{
if (Overlay is not null && Settings.LayoutMode == HudLayoutMode.Native)
{
var scale = Overlay.CurrentNativeReferenceScale();
Settings.OverlayWidthScale = scale;
Settings.OverlayHeightScale = scale;
ViewModel.OverlayWidthScale = scale;
ViewModel.OverlayHeightScale = scale;
Overlay.ApplyLayout(
Settings.LayoutMode, Settings.NativeGaugeMode, scale, scale, Settings.OverlayOpacity);
}

Overlay?.ResetPosition();
}

private void ResetGForcePosition()
{
if (GForceOverlay is null)
Expand Down Expand Up @@ -2829,6 +2839,7 @@ private bool UpdateOverlayVisibility(DateTimeOffset now, bool force = false)
? focus.ForegroundWindow
: _lastConfirmedForzaWindow;
var forzaWindowKnown = WindowZOrder.IsWindowAvailable(confirmedForzaWindow);
if (forzaWindowKnown) CompleteInitialOverlayPlacement(confirmedForzaWindow);
if (driftEnabled && focus.IsForzaForeground) RefreshDriftGaugeDisplay(confirmedForzaWindow, now);
if ((lapEnabled || mapEnabled) && focus.IsForzaForeground) RefreshLapDeltaDisplay(confirmedForzaWindow, now);
var telemetryFresh = _freshness.GetState(Stopwatch.GetTimestamp()) == TelemetryConnectionState.Connected;
Expand Down
28 changes: 28 additions & 0 deletions src/Wisp.App/OverlayWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,26 @@ public void ResetPosition()
Top = position.Y;
}

internal bool TryUseMonitorOfWindow(IntPtr referenceWindow)
{
if (!WindowZOrder.IsWindowAvailable(referenceWindow)) return false;
var monitor = MonitorFromWindow(referenceWindow, 0);
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
if (monitor == IntPtr.Zero || !GetMonitorInfo(monitor, ref info)) return false;

var handle = new WindowInteropHelper(this).EnsureHandle();
if (MonitorFromWindow(handle, DefaultToNearestMonitor) == monitor) return true;
if (!GetWindowRect(handle, out var bounds)) return false;

// Move in physical pixels first so Windows updates this HWND's DPI before
// the existing reference-size and default-position calculations run.
const uint flags = 0x0001 | 0x0004 | 0x0010 | 0x0200; // no size, z-order, activation or owner-order change
var x = info.Monitor.Left + ((info.Monitor.Right - info.Monitor.Left) - (bounds.Right - bounds.Left)) / 2;
var y = info.Monitor.Top + ((info.Monitor.Bottom - info.Monitor.Top) - (bounds.Bottom - bounds.Top)) / 2;
return SetWindowPos(handle, IntPtr.Zero, x, y, 0, 0, flags) &&
MonitorFromWindow(handle, DefaultToNearestMonitor) == monitor;
}

private Rect NativePlacementAnchorBounds()
{
var scaleY = Height / RootPanel.Height;
Expand Down Expand Up @@ -659,6 +679,14 @@ public double CurrentNativeReferenceScale()
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo monitorInfo);

[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetWindowRect(IntPtr window, out NativeRectangle bounds);

[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(IntPtr window, IntPtr insertAfter, int x, int y, int width, int height, uint flags);

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MonitorInfo
{
Expand Down
14 changes: 13 additions & 1 deletion src/Wisp.App/ReleaseNotesCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,24 @@ public static class ReleaseNotesCatalog
{
public static IReadOnlyList<ReleaseNoteEntry> Entries { get; } =
[
new(
"2.5.3",
"September 30, 2026",
"MONITOR PLACEMENT HOTFIX",
"Place a new HUD on Forza's monitor and let Reset HUD positions recover an existing wrong-screen placement.",
true,
[
Group("HUD placement",
"Place a HUD without a saved position on Forza's monitor when the game is first detected. A temporary position used before detection is no longer saved as the final layout.",
"Reset HUD positions moves the main HUD to the last detected Forza window's monitor. Focus the game once before using Reset if Wisp has not detected it yet.",
"Keep saved positions unchanged until you move or reset them. You can still use Edit HUD layout to place gauges on another screen.")
]),
new(
"2.5.2",
"September 29, 2026",
"RELIABILITY FIXES",
"The HUD keeps working when Forza restarts, and saved runs, reference laps, updates and renderer status behave as shown.",
true,
false,
[
Group("HUD",
"Keep the HUD working when Forza restarts while Wisp stays open. When the game closed, Windows cleared the HUD window's owner, and Wisp treated that as a renderer failure that turned the HUD off until Wisp restarted.",
Expand Down
6 changes: 3 additions & 3 deletions src/Wisp.App/Wisp.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
<Product>Wisp</Product>
<AssemblyTitle>Wisp</AssemblyTitle>
<Description>Wheel-indicated speed and G-force display for Forza Horizon 6.</Description>
<Version>2.5.2</Version>
<FileVersion>2.5.2.0</FileVersion>
<AssemblyVersion>2.5.2.0</AssemblyVersion>
<Version>2.5.3</Version>
<FileVersion>2.5.3.0</FileVersion>
<AssemblyVersion>2.5.3.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Wisp.Core\Wisp.Core.csproj" />
Expand Down
2 changes: 1 addition & 1 deletion src/Wisp.App/app.manifest
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.5.2.0" name="Wisp.App" />
<assemblyIdentity version="2.5.3.0" name="Wisp.App" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
Expand Down
6 changes: 3 additions & 3 deletions src/Wisp.Updater/Wisp.Updater.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
<Product>Wisp</Product>
<AssemblyTitle>Wisp Update Helper</AssemblyTitle>
<Description>Applies verified Wisp updates after the app exits.</Description>
<Version>2.5.2</Version>
<FileVersion>2.5.2.0</FileVersion>
<AssemblyVersion>2.5.2.0</AssemblyVersion>
<Version>2.5.3</Version>
<FileVersion>2.5.3.0</FileVersion>
<AssemblyVersion>2.5.3.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Wisp.Update\Wisp.Update.csproj" />
Expand Down
8 changes: 4 additions & 4 deletions tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ public void DisplayFormattingDoesNotChangeMachineIdentity(string machine, string
[Fact]
public void CurrentVersionLabelsShareTheAssemblyVersion()
{
Assert.Equal("2.5.2", ApplicationVersionInfo.MachineVersion);
Assert.Equal("2.5.2", ApplicationVersionInfo.DisplayVersion);
Assert.Equal("2.5.3", ApplicationVersionInfo.MachineVersion);
Assert.Equal("2.5.3", ApplicationVersionInfo.DisplayVersion);
var project = ProjectMetadata();
Assert.Equal(project.GetValueOrDefault("WispDiagnosticBuildId"), ApplicationVersionInfo.DiagnosticBuildId);
Assert.Equal(project.GetValueOrDefault("WispDiagnosticBuildLabel"), ApplicationVersionInfo.DiagnosticBuildLabel);
if (ApplicationVersionInfo.DiagnosticBuildId is null)
{
Assert.Null(ApplicationVersionInfo.DiagnosticBuildLabel);
Assert.Equal("WHEEL-INDICATED SPEED PANEL 2.5.2", ApplicationVersionInfo.FooterText);
Assert.Contains("current 2.5.2 entry covers this release", ApplicationVersionInfo.ReleaseHistoryIntroduction);
Assert.Equal("WHEEL-INDICATED SPEED PANEL 2.5.3", ApplicationVersionInfo.FooterText);
Assert.Contains("current 2.5.3 entry covers this release", ApplicationVersionInfo.ReleaseHistoryIntroduction);
}
else
{
Expand Down
2 changes: 1 addition & 1 deletion tests/Wisp.App.Tests/InstallerPackagingContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.2\"", inno, StringComparison.Ordinal);
Assert.Contains("#define MyAppVersion \"2.5.3\"", inno, StringComparison.Ordinal);
Assert.Contains("#define MyAppOutputVersion MyAppVersion", inno, StringComparison.Ordinal);
Assert.Contains("UpdatingExistingInstallation := UpdateSwitchPresent() and ExistingInstallationPresent();", inno,
StringComparison.Ordinal);
Expand Down
Loading
Loading