From bb94d220df027ec2badac38105bf829075a6db41 Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Wed, 30 Sep 2026 16:38:12 -0500
Subject: [PATCH 1/3] Fix first-time HUD monitor placement for Wisp 2.5.3
---
CHANGELOG.md | 5 +
README.md | 12 +-
docs/releases/Wisp-2.5.3-release-notes.md | 7 +
installer/Wisp.iss | 2 +-
src/Wisp.App/AppController.cs | 77 +++++----
src/Wisp.App/OverlayWindow.xaml.cs | 28 ++++
src/Wisp.App/ReleaseNotesCatalog.cs | 14 +-
src/Wisp.App/Wisp.App.csproj | 6 +-
src/Wisp.App/app.manifest | 2 +-
src/Wisp.Updater/Wisp.Updater.csproj | 6 +-
.../ApplicationVersionInfoTests.cs | 8 +-
.../InstallerPackagingContractTests.cs | 2 +-
.../OverlayMonitorPlacementTests.cs | 155 ++++++++++++++++++
.../ReleaseNotesCatalogTests.cs | 4 +-
tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs | 1 +
15 files changed, 274 insertions(+), 55 deletions(-)
create mode 100644 docs/releases/Wisp-2.5.3-release-notes.md
create mode 100644 tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 653fad7..a1a086f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 1370f08..5cc765e 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 the latest release.
- Download Wisp 2.5.2 ·
+ Download Wisp 2.5.3 ·
Website ·
Changelog ·
Architecture
@@ -13,6 +13,10 @@
Buy Me a Coffee
+## 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).
@@ -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)
@@ -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.
diff --git a/docs/releases/Wisp-2.5.3-release-notes.md b/docs/releases/Wisp-2.5.3-release-notes.md
new file mode 100644
index 0000000..bf52f07
--- /dev/null
+++ b/docs/releases/Wisp-2.5.3-release-notes.md
@@ -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.
diff --git a/installer/Wisp.iss b/installer/Wisp.iss
index 0698fbc..c2ef5ed 100644
--- a/installer/Wisp.iss
+++ b/installer/Wisp.iss
@@ -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"
diff --git a/src/Wisp.App/AppController.cs b/src/Wisp.App/AppController.cs
index 875c319..8644669 100644
--- a/src/Wisp.App/AppController.cs
+++ b/src/Wisp.App/AppController.cs
@@ -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;
@@ -1573,6 +1574,7 @@ public void SaveOverlayPlacement()
return;
}
+ _initialOverlayPlacementPending = false;
var key = Overlay.GetDisplayKey();
_activeOverlayPlacementKey = key;
Settings.LastOverlayPlacementKey = key;
@@ -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;
@@ -1662,33 +1665,30 @@ 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;
+ 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 (Settings.GForcePlacements.Count == 0) RestoreGForcePlacement();
+ if (Settings.BoostGaugePlacements.Count == 0) RestoreBoostGaugePlacement();
+ if (Settings.TireTemperatureGaugePlacements.Count == 0) RestoreTireTemperatureGaugePlacement();
+ if (Settings.PowerGaugePlacements.Count == 0) RestorePowerGaugePlacement();
+ if (Settings.TorqueGaugePlacements.Count == 0) RestoreTorqueGaugePlacement();
+ }
+
public void RestoreGForcePlacement()
{
if (GForceOverlay is null)
@@ -1788,18 +1788,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();
@@ -1814,6 +1804,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)
@@ -2829,6 +2835,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;
diff --git a/src/Wisp.App/OverlayWindow.xaml.cs b/src/Wisp.App/OverlayWindow.xaml.cs
index e7ab533..7af4fab 100644
--- a/src/Wisp.App/OverlayWindow.xaml.cs
+++ b/src/Wisp.App/OverlayWindow.xaml.cs
@@ -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() };
+ 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;
@@ -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
{
diff --git a/src/Wisp.App/ReleaseNotesCatalog.cs b/src/Wisp.App/ReleaseNotesCatalog.cs
index b59f7ed..4df18db 100644
--- a/src/Wisp.App/ReleaseNotesCatalog.cs
+++ b/src/Wisp.App/ReleaseNotesCatalog.cs
@@ -14,12 +14,24 @@ public static class ReleaseNotesCatalog
{
public static IReadOnlyList 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.",
diff --git a/src/Wisp.App/Wisp.App.csproj b/src/Wisp.App/Wisp.App.csproj
index 28528b4..c27eb0c 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.2
- 2.5.2.0
- 2.5.2.0
+ 2.5.3
+ 2.5.3.0
+ 2.5.3.0
diff --git a/src/Wisp.App/app.manifest b/src/Wisp.App/app.manifest
index 609b16c..7e87230 100644
--- a/src/Wisp.App/app.manifest
+++ b/src/Wisp.App/app.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/src/Wisp.Updater/Wisp.Updater.csproj b/src/Wisp.Updater/Wisp.Updater.csproj
index 971fbd0..89ae785 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.2
- 2.5.2.0
- 2.5.2.0
+ 2.5.3
+ 2.5.3.0
+ 2.5.3.0
diff --git a/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs b/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs
index 1dfa6f9..d92ada9 100644
--- a/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs
+++ b/tests/Wisp.App.Tests/ApplicationVersionInfoTests.cs
@@ -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
{
diff --git a/tests/Wisp.App.Tests/InstallerPackagingContractTests.cs b/tests/Wisp.App.Tests/InstallerPackagingContractTests.cs
index 6ce699c..3e9f26d 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.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);
diff --git a/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs b/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs
new file mode 100644
index 0000000..31cf63f
--- /dev/null
+++ b/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs
@@ -0,0 +1,155 @@
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Windows;
+using System.Windows.Interop;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+internal static class OverlayMonitorPlacementTests
+{
+ internal static void AssertOnCurrentDispatcher()
+ {
+ var monitors = new List();
+ Assert.True(EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero,
+ (IntPtr monitor, IntPtr dc, ref Rectangle bounds, IntPtr data) =>
+ {
+ monitors.Add(bounds);
+ return true;
+ }, IntPtr.Zero));
+ Assert.NotEmpty(monitors);
+ var foreground = GetForegroundWindow();
+ var reference = new Window { Width = 160, Height = 120, ShowActivated = false, ShowInTaskbar = false };
+ try
+ {
+ var referenceHandle = new WindowInteropHelper(reference).EnsureHandle();
+ foreach (var monitor in monitors)
+ {
+ Assert.True(SetWindowPos(referenceHandle, IntPtr.Zero,
+ monitor.Left + 40, monitor.Top + 40, 160, 120, 0x0014));
+ foreach (var layout in Enum.GetValues())
+ {
+ AssertPlacement(referenceHandle, monitor, layout, NativeGaugeMode.Digital);
+ if (layout == HudLayoutMode.Native)
+ AssertPlacement(referenceHandle, monitor, layout, NativeGaugeMode.Analogue);
+ }
+ }
+ Assert.Equal(foreground, GetForegroundWindow());
+ }
+ finally { reference.Close(); }
+ }
+
+ private static void AssertPlacement(IntPtr reference, Rectangle monitor, HudLayoutMode layout, NativeGaugeMode mode)
+ {
+ var settings = new AppSettings
+ {
+ LayoutMode = layout,
+ NativeGaugeMode = mode,
+ OverlayLocked = true,
+ StartWithWindows = false,
+ StartWithForza = false,
+ AutomaticApplicationUpdateChecks = false,
+ DebugLoggingEnabled = false
+ };
+ var controller = new AppController(settings, _ => { }, new NoStartupRegistration());
+ var overlay = new OverlayWindow(controller);
+ controller.Overlay = overlay;
+ try
+ {
+ controller.RestoreOverlayPlacement();
+ controller.CompleteInitialOverlayPlacement(IntPtr.Zero);
+ controller.CompleteInitialOverlayPlacement(new IntPtr(-1));
+ Assert.Empty(settings.Placements);
+ Assert.Null(settings.LastOverlayPlacementKey);
+ var reloaded = JsonSerializer.Deserialize(JsonSerializer.Serialize(settings))!;
+ Assert.Empty(reloaded.Placements);
+ Assert.Null(reloaded.LastOverlayPlacementKey);
+
+ // Editing must not jump under the pointer when Forza becomes known.
+ settings.OverlayLocked = false;
+ var editing = overlay.GetPlacementBounds();
+ controller.CompleteInitialOverlayPlacement(reference);
+ Assert.Equal(editing, overlay.GetPlacementBounds());
+ Assert.Empty(settings.Placements);
+
+ settings.OverlayLocked = true;
+ controller.CompleteInitialOverlayPlacement(reference);
+ var handle = new WindowInteropHelper(overlay).Handle;
+ Assert.Equal(MonitorFromWindow(reference, 2), MonitorFromWindow(handle, 2));
+ Assert.True(GetWindowRect(handle, out var placed));
+ Assert.InRange(placed.Left, monitor.Left - 1, monitor.Right);
+ Assert.InRange(placed.Top, monitor.Top - 1, monitor.Bottom);
+ Assert.InRange(placed.Right, monitor.Left, monitor.Right + 1);
+ Assert.InRange(placed.Bottom, monitor.Top, monitor.Bottom + 1);
+ Assert.Single(settings.Placements);
+ var key = settings.LastOverlayPlacementKey!;
+ Assert.Equal(overlay.GetDisplayKey(), key);
+
+ // Saving a manual position cancels automatic placement, including
+ // after another restore; scale and position must survive unchanged.
+ overlay.RestorePosition(80, 90);
+ controller.SaveOverlayPlacement();
+ var saved = settings.Placements[settings.LastOverlayPlacementKey!];
+ var savedBounds = overlay.GetPlacementBounds();
+ controller.RestoreOverlayPlacement();
+ controller.CompleteInitialOverlayPlacement(reference);
+ var restored = overlay.GetPlacementBounds();
+ Assert.Equal(savedBounds.TopLeft, restored.TopLeft);
+ // WPF rounds HWND sizes to physical pixels when restoring layout.
+ var dpi = System.Windows.Media.VisualTreeHelper.GetDpi(overlay);
+ Assert.InRange(Math.Abs(savedBounds.Width - restored.Width), 0, 1 / dpi.DpiScaleX);
+ Assert.InRange(Math.Abs(savedBounds.Height - restored.Height), 0, 1 / dpi.DpiScaleY);
+ Assert.Equal(saved.WidthScale, settings.OverlayWidthScale);
+ Assert.Equal(saved.HeightScale, settings.OverlayHeightScale);
+
+ // The existing reset action is the recovery path for old installs
+ // whose wrong-screen default was already saved by an earlier build.
+ typeof(AppController).GetField("_lastConfirmedForzaWindow", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .SetValue(controller, reference);
+ controller.ResetOverlayPosition();
+ Assert.Equal(MonitorFromWindow(reference, 2), MonitorFromWindow(handle, 2));
+ Assert.NotEqual(savedBounds, overlay.GetPlacementBounds());
+ Assert.Equal(overlay.GetPlacementBounds().Left, settings.Placements[settings.LastOverlayPlacementKey!].Left);
+ Assert.Equal(overlay.GetPlacementBounds().Top, settings.Placements[settings.LastOverlayPlacementKey!].Top);
+ }
+ finally
+ {
+ overlay.Close();
+ controller.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+
+ // A manual drag before the first game detection must also win.
+ var manualController = new AppController(new AppSettings { OverlayLocked = true }, _ => { }, new NoStartupRegistration());
+ var manualOverlay = new OverlayWindow(manualController);
+ manualController.Overlay = manualOverlay;
+ try
+ {
+ manualController.RestoreOverlayPlacement();
+ manualOverlay.RestorePosition(80, 90);
+ manualController.SaveOverlayPlacement();
+ var bounds = manualOverlay.GetPlacementBounds();
+ manualController.CompleteInitialOverlayPlacement(reference);
+ Assert.Equal(bounds, manualOverlay.GetPlacementBounds());
+ }
+ finally
+ {
+ manualOverlay.Close();
+ manualController.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+ }
+
+ private sealed class NoStartupRegistration : IStartupRegistrationService
+ {
+ public void Apply(bool startWithWindows, bool startWithForza) { }
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Rectangle { public int Left, Top, Right, Bottom; }
+ private delegate bool MonitorCallback(IntPtr monitor, IntPtr dc, ref Rectangle bounds, IntPtr data);
+ [DllImport("user32.dll")] private static extern bool EnumDisplayMonitors(IntPtr dc, IntPtr clip, MonitorCallback callback, IntPtr data);
+ [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr window, uint flags);
+ [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
+ [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr window, out Rectangle bounds);
+ [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr window, IntPtr after, int x, int y, int width, int height, uint flags);
+}
diff --git a/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs b/tests/Wisp.App.Tests/ReleaseNotesCatalogTests.cs
index d561769..0491447 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", "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"],
+ ["2.5.3", "2.5.2", "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("RELIABILITY FIXES", ReleaseNotesCatalog.Entries[0].Label);
+ Assert.Equal("MONITOR PLACEMENT HOTFIX", 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.App.Tests/WpfStyleRuntimeTests.cs b/tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs
index 6976617..33ec261 100644
--- a/tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs
+++ b/tests/Wisp.App.Tests/WpfStyleRuntimeTests.cs
@@ -79,6 +79,7 @@ void Check(string name, Action action)
{
application = new ResourceOnlyApplication { ShutdownMode = ShutdownMode.OnExplicitShutdown };
application.Resources = LoadApplicationResources();
+ Check(nameof(OverlayMonitorPlacementTests), OverlayMonitorPlacementTests.AssertOnCurrentDispatcher);
Check(nameof(AnalogHudSceneTests), AnalogHudSceneTests.AssertOnCurrentDispatcher);
var hudChecks = new Action[]
{
From 3273652b5910443257db594f220e579ac337adb5 Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Wed, 30 Sep 2026 16:47:43 -0500
Subject: [PATCH 2/3] Preserve previous layouts while game monitor placement is
pending
---
src/Wisp.App/AppController.cs | 1 +
.../OverlayMonitorPlacementTests.cs | 15 +++++++++++++++
2 files changed, 16 insertions(+)
diff --git a/src/Wisp.App/AppController.cs b/src/Wisp.App/AppController.cs
index 8644669..c600748 100644
--- a/src/Wisp.App/AppController.cs
+++ b/src/Wisp.App/AppController.cs
@@ -1666,6 +1666,7 @@ public void RestoreOverlayPlacement()
else
{
_initialOverlayPlacementPending = true;
+ Settings.LastOverlayPlacementKey = null;
ResetMainOverlayPosition();
_activeOverlayPlacementKey = Overlay.GetDisplayKey();
// A fallback shown before Forza is known is not a saved user layout.
diff --git a/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs b/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs
index 31cf63f..68becc6 100644
--- a/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs
+++ b/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs
@@ -131,6 +131,21 @@ private static void AssertPlacement(IntPtr reference, Rectangle monitor, HudLayo
var bounds = manualOverlay.GetPlacementBounds();
manualController.CompleteInitialOverlayPlacement(reference);
Assert.Equal(bounds, manualOverlay.GetPlacementBounds());
+
+ var previousKey = manualController.Settings.LastOverlayPlacementKey!;
+ var previousPlacement = manualController.Settings.Placements[previousKey];
+ var previousValue = (previousPlacement.Left, previousPlacement.Top, previousPlacement.WidthScale, previousPlacement.HeightScale);
+ var nextLayout = manualController.Settings.LayoutMode == HudLayoutMode.Minimal
+ ? HudLayoutMode.Combined : HudLayoutMode.Minimal;
+ manualController.Settings.LayoutMode = nextLayout;
+ manualOverlay.ApplyLayout(nextLayout, NativeGaugeMode.Digital, 1, 1, 1);
+ manualController.RestoreOverlayPlacement();
+ // ApplyViewOptions updates placement scales after restoring a new style.
+ // The pending style must not keep the previous style's save target.
+ typeof(AppController).GetMethod("UpdateCurrentPlacementScales", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(manualController, null);
+ Assert.Null(manualController.Settings.LastOverlayPlacementKey);
+ Assert.Equal(previousValue, (previousPlacement.Left, previousPlacement.Top, previousPlacement.WidthScale, previousPlacement.HeightScale));
}
finally
{
From f796ec3e1d0d646b603bdf79ca67fab022c46bb8 Mon Sep 17 00:00:00 2001
From: Views2k <29cm8vshsm@privaterelay.appleid.com>
Date: Wed, 30 Sep 2026 17:13:18 -0500
Subject: [PATCH 3/3] Respect active saved auxiliary positions during initial
monitor placement
---
src/Wisp.App/AppController.cs | 13 +++---
.../OverlayMonitorPlacementTests.cs | 42 +++++++++++++++++++
2 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/src/Wisp.App/AppController.cs b/src/Wisp.App/AppController.cs
index c600748..17cee3e 100644
--- a/src/Wisp.App/AppController.cs
+++ b/src/Wisp.App/AppController.cs
@@ -1683,11 +1683,14 @@ internal void CompleteInitialOverlayPlacement(IntPtr gameWindow)
ResetMainOverlayPosition();
SaveOverlayPlacement();
- if (Settings.GForcePlacements.Count == 0) RestoreGForcePlacement();
- if (Settings.BoostGaugePlacements.Count == 0) RestoreBoostGaugePlacement();
- if (Settings.TireTemperatureGaugePlacements.Count == 0) RestoreTireTemperatureGaugePlacement();
- if (Settings.PowerGaugePlacements.Count == 0) RestorePowerGaugePlacement();
- if (Settings.TorqueGaugePlacements.Count == 0) RestoreTorqueGaugePlacement();
+ 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 placements, string? key) =>
+ key is not null && placements.ContainsKey(key);
}
public void RestoreGForcePlacement()
diff --git a/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs b/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs
index 68becc6..19608fe 100644
--- a/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs
+++ b/tests/Wisp.App.Tests/OverlayMonitorPlacementTests.cs
@@ -34,12 +34,54 @@ internal static void AssertOnCurrentDispatcher()
if (layout == HudLayoutMode.Native)
AssertPlacement(referenceHandle, monitor, layout, NativeGaugeMode.Analogue);
}
+ AssertGForcePlacement(referenceHandle, preserveSaved: false);
+ AssertGForcePlacement(referenceHandle, preserveSaved: true);
}
Assert.Equal(foreground, GetForegroundWindow());
}
finally { reference.Close(); }
}
+ private static void AssertGForcePlacement(IntPtr reference, bool preserveSaved)
+ {
+ var settings = new AppSettings
+ {
+ LayoutMode = HudLayoutMode.SeparateBoxes,
+ OverlayLocked = true,
+ GForceEnabled = true,
+ GForceAttached = false,
+ StartWithWindows = false,
+ StartWithForza = false,
+ AutomaticApplicationUpdateChecks = false
+ };
+ // An unrelated monitor's history must not block the current default.
+ settings.GForcePlacements["Other-1280x720-GForceV2"] = new(60, 70, 1, 1);
+ var controller = new AppController(settings, _ => { }, new NoStartupRegistration());
+ var overlay = new OverlayWindow(controller);
+ var gForce = new GForceWindow(controller);
+ controller.Overlay = overlay;
+ controller.GForceOverlay = gForce;
+ try
+ {
+ controller.RestoreOverlayPlacement();
+ gForce.RestorePosition(80, 90);
+ if (preserveSaved) controller.SaveGForcePlacement();
+ var before = new Point(gForce.Left, gForce.Top);
+ controller.CompleteInitialOverlayPlacement(reference);
+ var expected = preserveSaved ? before : OverlayPlacementGeometry.PlaceAdjacentHorizontally(
+ overlay.CurrentMonitorPlacementArea(), overlay.GetPlacementBounds(), new Size(gForce.Width, gForce.Height));
+ Assert.Equal(expected.X, gForce.Left, 5);
+ Assert.Equal(expected.Y, gForce.Top, 5);
+ Assert.True(settings.GForcePlacements.ContainsKey("Other-1280x720-GForceV2"));
+ }
+ finally
+ {
+ gForce.Close();
+ overlay.Close();
+ controller.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+ }
+
private static void AssertPlacement(IntPtr reference, Rectangle monitor, HudLayoutMode layout, NativeGaugeMode mode)
{
var settings = new AppSettings