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