diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7018e61..a3043aa0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
Notable changes to Wisp are recorded here.
+## 2.6.12 - 2026-10-16
+
+- Add Prioritize HUD updates on the GPU, a HUD setting that is off by default so the game keeps the GPU; turn it on to give HUD updates priority when the GPU is under heavy load.
+- Keep tune performance results with the current tune after leaving Forza's Tune screen, as long as the car, parts and settings are unchanged.
+- Report feature pauses from Wisp Tools when they apply and when a policy is rejected or cannot be cached.
+- Let Chat with Views messages carry a subject.
+- Complete application exit promptly even when a renderer thread is busy.
+- Report clip action problems on the Clips page and allow a retry.
+
## 2.6.11 - 2026-10-15
- Chat with Views in a compact popup with a chronological message history, replies and earlier messages.
diff --git a/LICENSES/library-sources-distribution.json b/LICENSES/library-sources-distribution.json
index 480091ed..04e609d1 100644
--- a/LICENSES/library-sources-distribution.json
+++ b/LICENSES/library-sources-distribution.json
@@ -1,8 +1,8 @@
{
"schemaVersion": 1,
- "version": "2.6.11",
+ "version": "2.6.12",
"archive": {
- "filename": "Wisp-2.6.11-library-sources.zip",
+ "filename": "Wisp-2.6.12-library-sources.zip",
"sourceVersion": "2.6.8",
"url": "https://github.com/Views2k/Wisp/releases/download/library-sources-2.6.8/Wisp-2.6.8-library-sources.zip",
"bytes": 879043478,
diff --git a/README.md b/README.md
index 75371fb2..6473e82c 100644
--- a/README.md
+++ b/README.md
@@ -13,11 +13,11 @@
Buy Me a Coffee
-## Wisp 2.6.11
+## Wisp 2.6.12
-Wisp 2.6.11 brings conversation with the developer into a compact chat popup, with message history, replies and optional diagnostic attachments. Available updates stay visible on the dashboard with a working Update action.
+Wisp 2.6.12 adds a HUD setting that decides whether HUD updates take priority over the game on the GPU, off by default, keeps tune performance results with your current tune after you leave Forza's Tune screen, and lets you give a message to the developer a subject. Closing Wisp completes promptly and clip actions report problems on the Clips page.
-[Clips](#clips) · [Tune](#tune) · [Lap review](#lap-review) · [Live map and lap delta](#live-map-and-lap-delta) · [Install](#install) · [2.6.11 release notes](docs/releases/Wisp-2.6.11-release-notes.md)
+[Clips](#clips) · [Tune](#tune) · [Lap review](#lap-review) · [Live map and lap delta](#live-map-and-lap-delta) · [Install](#install) · [2.6.12 release notes](docs/releases/Wisp-2.6.12-release-notes.md)
### Clips
diff --git a/docs/releases/Wisp-2.6.12-release-notes.md b/docs/releases/Wisp-2.6.12-release-notes.md
new file mode 100644
index 00000000..b3429210
--- /dev/null
+++ b/docs/releases/Wisp-2.6.12-release-notes.md
@@ -0,0 +1,22 @@
+# Wisp 2.6.12
+
+[Download the latest Wisp release for Windows](https://github.com/Views2k/Wisp/releases/latest)
+
+## HUD GPU priority
+
+- Prioritize HUD updates on the GPU is a new HUD setting, off by default, so the game keeps the GPU to itself.
+- Turn it on next to CPU rendering to give HUD updates priority when the GPU is under heavy load. It applies immediately.
+
+## Tune performance results
+
+- Performance results read from Forza's Tune screen stay with your current tune after you leave the screen.
+- Refresh the current tune at any time and keep comparing braking, grip, acceleration and top speed.
+
+## Chat with Views
+
+- Add a subject to your messages so they are easy to find later.
+
+## Reliability
+
+- Closing Wisp completes promptly even when a renderer thread is busy.
+- Clip actions report problems on the Clips page and can be retried.
diff --git a/installer/Wisp.iss b/installer/Wisp.iss
index 4daf56df..8345d526 100644
--- a/installer/Wisp.iss
+++ b/installer/Wisp.iss
@@ -1,5 +1,5 @@
#define MyAppName "Wisp"
-#define MyAppVersion "2.6.11"
+#define MyAppVersion "2.6.12"
#define MyAppDisplayVersion MyAppVersion
#define MyAppOutputVersion MyAppVersion
#define MyAppPublisher "Wisp"
diff --git a/src/Wisp.App/App.xaml.cs b/src/Wisp.App/App.xaml.cs
index 57b2b5ba..b7aeb6ab 100644
--- a/src/Wisp.App/App.xaml.cs
+++ b/src/Wisp.App/App.xaml.cs
@@ -292,11 +292,14 @@ protected override void OnExit(ExitEventArgs e)
{
_activationCancellation.Cancel();
_activationEvent?.Set();
- _activationListener?.GetAwaiter().GetResult();
+ _activationListener?.Wait(TimeSpan.FromSeconds(2));
}
- _controller?.DisposeAsync().AsTask().GetAwaiter().GetResult();
- shutdownCompleted = true;
+ // Bounded: a worker stuck in a driver or file call must not keep the process
+ // alive after the user closed Wisp. An expired wait leaves the exit marker
+ // incomplete, which is what happened.
+ var disposal = _controller?.DisposeAsync().AsTask();
+ shutdownCompleted = disposal is null || disposal.Wait(TimeSpan.FromSeconds(10));
}
finally
{
diff --git a/src/Wisp.App/AppController.FeatureControls.cs b/src/Wisp.App/AppController.FeatureControls.cs
index 912bf737..34e0f3f9 100644
--- a/src/Wisp.App/AppController.FeatureControls.cs
+++ b/src/Wisp.App/AppController.FeatureControls.cs
@@ -18,6 +18,8 @@ private bool TryUseHudProfiles(out string error)
internal Task ApplyFeatureControlsAsync(FeatureControlState state)
{
if (_disposed || !FeatureControls.Apply(state)) return Task.CompletedTask;
+ // Tools sees that this installation applied the revision, so a pause can be verified end to end.
+ SupplementaryObservations.Record("feature", "app", state.Disabled.Count > 0 ? "detected" : "none", "apply");
var recording = FeatureAllowed("run-recording");
var clipping = FeatureAllowed("clip-recording");
_tuneCapture?.SetFeatureAvailable(FeatureAllowed("tune-read"));
@@ -72,6 +74,7 @@ internal Task ApplyFeatureControlsAsync(FeatureControlState state)
_ = ConfigureClipShortcut(false, settings.ToggleShortcutEnabled, settings.ToggleShortcut);
_ = ConfigureClipShortcut(true, settings.SaveShortcutEnabled, settings.SaveShortcut);
}
+ ApplyHudGpuPriority();
FeatureControlsChanged?.Invoke(this, EventArgs.Empty);
UpdateShiftCueObservation();
Overlay?.RefreshFeatureControls();
diff --git a/src/Wisp.App/AppController.cs b/src/Wisp.App/AppController.cs
index 515ad1e7..3adba74c 100644
--- a/src/Wisp.App/AppController.cs
+++ b/src/Wisp.App/AppController.cs
@@ -280,6 +280,28 @@ public void SetCpuRenderingEnabled(bool enabled)
ViewModel.UpdateCpuRendering(Settings.CpuRenderingEnabled, saved);
}
+ public void SetHudGpuPriorityEnabled(bool enabled)
+ {
+ if (_disposed) return;
+ var previous = Settings.HudGpuPriority;
+ Settings.HudGpuPriority = enabled;
+ var saved = SaveSettings();
+ if (!saved) Settings.HudGpuPriority = previous;
+ PublishSupplementarySettings();
+ ApplyHudGpuPriority(saved);
+ }
+
+ // Effective only when the user turned it on and Tools has not paused it for this version.
+ internal void ApplyHudGpuPriority(bool saved = true)
+ {
+ var active = Settings.HudGpuPriority && FeatureAllowed("gpu-priority");
+ ViewModel.UpdateHudGpuPriority(Settings.HudGpuPriority, active,
+ !saved ? "Could not save the renderer option. Try again."
+ : Settings.HudGpuPriority && !active
+ ? FeatureControls.Reason("gpu-priority") ?? "GPU priority is temporarily paused."
+ : "");
+ }
+
public async Task SetDebugLoggingEnabledAsync(bool enabled)
{
if (_disposed)
diff --git a/src/Wisp.App/AppSettings.cs b/src/Wisp.App/AppSettings.cs
index e8122e8a..5f23600b 100644
--- a/src/Wisp.App/AppSettings.cs
+++ b/src/Wisp.App/AppSettings.cs
@@ -127,6 +127,8 @@ public double TorqueGaugeScale
public bool AutomaticApplicationUpdateChecks { get; set; } = true;
public DateTimeOffset? LastApplicationUpdateCheckUtc { get; set; }
public bool CpuRenderingEnabled { get; set; }
+ // Off by default: the game keeps the GPU unless the user chooses otherwise.
+ public bool HudGpuPriority { get; set; }
public bool DebugLoggingEnabled { get; set; }
public DateTimeOffset? DebugLoggingExpiresAtUtc { get; set; }
public string ColorTheme { get; set; } = AppColorThemes.DefaultName;
diff --git a/src/Wisp.App/Clips/ClipsViewModel.cs b/src/Wisp.App/Clips/ClipsViewModel.cs
index de8a0947..26caad85 100644
--- a/src/Wisp.App/Clips/ClipsViewModel.cs
+++ b/src/Wisp.App/Clips/ClipsViewModel.cs
@@ -1167,7 +1167,14 @@ private void NotifyDashboardNotice()
private void OnChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new(name));
private ICommand Command(Func execute, Func canExecute)
{
- var command = new ClipCommand(execute, canExecute); _commands.Add(command); return command;
+ var command = new ClipCommand(execute, canExecute, CommandFailed); _commands.Add(command); return command;
+ }
+ private void CommandFailed(Exception error)
+ {
+ if (_disposed) return;
+ ErrorText(error is IOException or UnauthorizedAccessException
+ ? "That clip action could not reach the clip folder. Check the storage status, then try again."
+ : "That clip action did not complete. Try again.");
}
public void Dispose()
{
@@ -1178,11 +1185,21 @@ public void Dispose()
if (_thumbnails is IDisposable ownedThumbnails) ownedThumbnails.Dispose();
// Operations retain the captured token, never access a disposed token source.
}
- private sealed class ClipCommand(Func action, Func canExecute) : ICommand
+ // A failing action reports on the page instead of ending the process, and a running
+ // action cannot be started twice (the Runs and Tunes commands behave the same way).
+ private sealed class ClipCommand(Func action, Func canExecute, Action failed) : ICommand
{
+ private bool _running;
public event EventHandler? CanExecuteChanged;
- public bool CanExecute(object? parameter) => canExecute();
- public async void Execute(object? parameter) { if (CanExecute(parameter)) await action(); }
+ public bool CanExecute(object? parameter) => !_running && canExecute();
+ public async void Execute(object? parameter)
+ {
+ if (!CanExecute(parameter)) return;
+ _running = true; Raise();
+ try { await action(); }
+ catch (Exception error) when (error is not OutOfMemoryException) { failed(error); }
+ finally { _running = false; Raise(); }
+ }
public void Raise() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
diff --git a/src/Wisp.App/ControlPanelWindow.SupportInbox.cs b/src/Wisp.App/ControlPanelWindow.SupportInbox.cs
index 33ac576a..099b1c02 100644
--- a/src/Wisp.App/ControlPanelWindow.SupportInbox.cs
+++ b/src/Wisp.App/ControlPanelWindow.SupportInbox.cs
@@ -60,7 +60,7 @@ private void OpenSupportChat()
if (_chatWindow is null)
{
_chatView = new SupportChatView(
- (message, diagnostics) => Task.Run(() => app.CreateSupplementarySupportPreview("feedback", "Chat with Views", message, diagnostics)),
+ (title, message, diagnostics) => Task.Run(() => app.CreateSupplementarySupportPreview("feedback", title, message, diagnostics)),
(report, cancellation) => app.SubmitSupplementarySupportAsync(report, cancellation, chat: true),
(older, ids) => app.RefreshSupportChatAsync(older, ids));
var frame = _chatView.CreatePopupSurface(() => _chatWindow?.Hide(), () => _chatWindow?.DragMove());
diff --git a/src/Wisp.App/ControlPanelWindow.cs b/src/Wisp.App/ControlPanelWindow.cs
index eeec37d9..7597f3cf 100644
--- a/src/Wisp.App/ControlPanelWindow.cs
+++ b/src/Wisp.App/ControlPanelWindow.cs
@@ -73,6 +73,7 @@ public abstract partial class ControlPanelWindow : Window
private Button OverlayHotkeyCaptureButton => FindControl(nameof(OverlayHotkeyCaptureButton));
private TextBlock PortApplyFeedback => FindControl(nameof(PortApplyFeedback));
private CheckBox CpuRenderingToggle => FindControl(nameof(CpuRenderingToggle));
+ private CheckBox HudGpuPriorityToggle => FindControl(nameof(HudGpuPriorityToggle));
private CheckBox DebugLoggingToggle => FindControl(nameof(DebugLoggingToggle));
private Button DebugLogExportButton => FindControl(nameof(DebugLogExportButton));
private Button DebugLogDeleteButton => FindControl(nameof(DebugLogDeleteButton));
@@ -982,6 +983,12 @@ protected void CpuRenderingToggle_Click(object sender, RoutedEventArgs e)
CpuRenderingToggle.GetBindingExpression(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty)?.UpdateTarget();
}
+ protected void HudGpuPriorityToggle_Click(object sender, RoutedEventArgs e)
+ {
+ _controller.SetHudGpuPriorityEnabled(HudGpuPriorityToggle.IsChecked == true);
+ HudGpuPriorityToggle.GetBindingExpression(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty)?.UpdateTarget();
+ }
+
protected async void DebugLoggingToggle_Click(object sender, RoutedEventArgs e)
{
DebugLoggingToggle.IsEnabled = false;
diff --git a/src/Wisp.App/DiagnosticsViewModel.cs b/src/Wisp.App/DiagnosticsViewModel.cs
index 961cc75e..196cb746 100644
--- a/src/Wisp.App/DiagnosticsViewModel.cs
+++ b/src/Wisp.App/DiagnosticsViewModel.cs
@@ -113,6 +113,9 @@ public sealed partial class DiagnosticsViewModel : INotifyPropertyChanged
private bool _automaticApplicationUpdateChecks;
private bool _cpuRenderingEnabled;
private string _cpuRenderingChangeStatus = "";
+ private bool _hudGpuPriorityEnabled;
+ private bool _hudGpuPriorityActive;
+ private string _hudGpuPriorityChangeStatus = "";
private bool _debugLoggingEnabled;
private string _debugLoggingStatus = "Detailed logging off";
private bool _tractionCueEnabled;
@@ -189,6 +192,8 @@ internal DiagnosticsViewModel(AppSettings settings, Func shiftTimestamp)
_automaticApplicationUpdateChecks = settings.AutomaticApplicationUpdateChecks;
_cpuRenderingEnabled = settings.CpuRenderingEnabled;
ActiveCpuRendering = settings.CpuRenderingEnabled;
+ _hudGpuPriorityEnabled = settings.HudGpuPriority;
+ _hudGpuPriorityActive = settings.HudGpuPriority;
_debugLoggingEnabled = settings.DebugLoggingEnabled;
if (_debugLoggingEnabled && settings.DebugLoggingExpiresAtUtc is { } expiresAtUtc)
{
@@ -295,6 +300,17 @@ internal void UpdateCpuRendering(bool enabled, bool saved)
: enabled != ActiveCpuRendering ? "Restart Wisp to apply this change." : "";
}
+ // Enabled is the saved choice; Active is what the renderer applies (Tools can pause it).
+ public bool HudGpuPriorityEnabled { get => _hudGpuPriorityEnabled; private set => Set(ref _hudGpuPriorityEnabled, value); }
+ public bool HudGpuPriorityActive { get => _hudGpuPriorityActive; private set => Set(ref _hudGpuPriorityActive, value); }
+ public string HudGpuPriorityChangeStatus { get => _hudGpuPriorityChangeStatus; private set => Set(ref _hudGpuPriorityChangeStatus, value); }
+ internal void UpdateHudGpuPriority(bool enabled, bool active, string status)
+ {
+ HudGpuPriorityEnabled = enabled;
+ HudGpuPriorityActive = active;
+ HudGpuPriorityChangeStatus = status;
+ }
+
public bool DebugLoggingEnabled { get => _debugLoggingEnabled; private set => Set(ref _debugLoggingEnabled, value); }
public string DebugLoggingStatus { get => _debugLoggingStatus; private set => Set(ref _debugLoggingStatus, value); }
public string LateralGText { get => _lateralGText; private set => Set(ref _lateralGText, value); }
diff --git a/src/Wisp.App/LegacyMainWindow.xaml b/src/Wisp.App/LegacyMainWindow.xaml
index 82bcfb9b..33cfc44c 100644
--- a/src/Wisp.App/LegacyMainWindow.xaml
+++ b/src/Wisp.App/LegacyMainWindow.xaml
@@ -1591,6 +1591,15 @@
Foreground="{DynamicResource FaintBrush}" TextWrapping="Wrap" FontSize="10" Margin="0,6,0,0" />
+
+
+
diff --git a/src/Wisp.App/MainWindow.xaml b/src/Wisp.App/MainWindow.xaml
index 711148b8..60944dc7 100644
--- a/src/Wisp.App/MainWindow.xaml
+++ b/src/Wisp.App/MainWindow.xaml
@@ -1961,6 +1961,18 @@
+
+
+
diff --git a/src/Wisp.App/NativeRendering/AnalogHudRenderWorker.cs b/src/Wisp.App/NativeRendering/AnalogHudRenderWorker.cs
index c78eb2a7..b9413ff4 100644
--- a/src/Wisp.App/NativeRendering/AnalogHudRenderWorker.cs
+++ b/src/Wisp.App/NativeRendering/AnalogHudRenderWorker.cs
@@ -29,6 +29,7 @@ internal sealed class AnalogHudRenderWorker : IDisposable
private readonly Action? _afterPrepareForTest;
private long _motionGeneration = 1;
private AnalogHudPresentation _presentation;
+ private bool _gpuPriority;
private bool _reset;
private bool _disposed;
private bool _surfaceVisible;
@@ -96,6 +97,17 @@ internal void UpdateHud(HudWindowSnapshot snapshot, long timestamp)
}
}
+ // The user's GPU priority choice; the render thread applies it once a device exists.
+ internal void SetGpuPriority(bool raise)
+ {
+ lock (_gate)
+ {
+ if (_disposed || _gpuPriority == raise) return;
+ _gpuPriority = raise;
+ _changed.Set();
+ }
+ }
+
internal void UpdatePresentation(AnalogHudPresentation presentation)
{
lock (_gate)
@@ -151,6 +163,7 @@ private void Run()
var waitHandles = new WaitHandle[] { _stop, _changed };
// hudShown: the last HUD build was submitted for presentation, so an unchanged scene need not present again.
bool announced = false, hasFrame = false, frameReady = false, hudShown = false;
+ bool gpuPriority = false, appliedGpuPriority = false;
int width = 0, height = 0;
float appliedOpacity = float.NaN;
float appliedHostX = float.NaN, appliedHostY = float.NaN;
@@ -173,6 +186,7 @@ private void Run()
lock (_gate)
{
presentation = _presentation;
+ gpuPriority = _gpuPriority;
motionGeneration = _motionGeneration;
if (_reset)
{
@@ -251,8 +265,18 @@ private void Run()
width = presentation.Width;
height = presentation.Height;
hudShown = false;
+ appliedGpuPriority = false;
RecordStage(operation, "created", operationStarted);
}
+ if (appliedGpuPriority != gpuPriority)
+ {
+ // A fresh device starts at the defaults, so only a raise needs a call
+ // after creation; a later change always does.
+ BeginOperation("state", HealthFailureStage.RendererState);
+ device.SetGpuPriority(gpuPriority);
+ appliedGpuPriority = gpuPriority;
+ RecordStage(operation, "ready", operationStarted);
+ }
if (width != presentation.Width || height != presentation.Height)
{
pending = null;
@@ -560,8 +584,12 @@ void RetryWait(int milliseconds)
{
// Only this background render thread waits for channel teardown.
// The UI remains free to service HWND/COM messages during release.
+ // The wait is bounded: a motion thread stuck in a driver call must
+ // not keep this thread, and with it application exit, alive forever.
_motion?.Dispose();
- _motion?.Completion.GetAwaiter().GetResult();
+ if (_motion is { } motion && !motion.Completion.Wait(TimeSpan.FromSeconds(3)))
+ HealthContextRecorder.Current.RecordRendererFailure(
+ unchecked((int)0x800705B4), HealthFailureStage.RendererState); // ERROR_TIMEOUT
device?.Dispose();
Volatile.Write(ref _surfaceVisible, false);
lock (_gate)
diff --git a/src/Wisp.App/NativeRendering/DirectCompositionAnalogHost.cs b/src/Wisp.App/NativeRendering/DirectCompositionAnalogHost.cs
index 4dd1f326..d9a0e983 100644
--- a/src/Wisp.App/NativeRendering/DirectCompositionAnalogHost.cs
+++ b/src/Wisp.App/NativeRendering/DirectCompositionAnalogHost.cs
@@ -31,6 +31,30 @@ internal DirectCompositionAnalogHost(NativeAnalogSpeedometer owner, OverlayWindo
_status = status;
RefreshPresentation();
_owner.LayoutUpdated += OnLayoutUpdated;
+ _owner.DataContextChanged += OnDataContextChanged;
+ ObserveViewModel(_owner.DataContext as DiagnosticsViewModel);
+ }
+
+ private DiagnosticsViewModel? _viewModel;
+
+ // The GPU priority choice can change while the gauge is idle, so it is not left to
+ // the next layout pass.
+ private void ObserveViewModel(DiagnosticsViewModel? viewModel)
+ {
+ if (ReferenceEquals(_viewModel, viewModel)) return;
+ if (_viewModel is not null) _viewModel.PropertyChanged -= OnViewModelChanged;
+ _viewModel = viewModel;
+ if (_viewModel is not null) _viewModel.PropertyChanged += OnViewModelChanged;
+ }
+
+ private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs args)
+ {
+ if (!_disposed) ObserveViewModel(args.NewValue as DiagnosticsViewModel);
+ }
+
+ private void OnViewModelChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
+ {
+ if (!_disposed && args.PropertyName == nameof(DiagnosticsViewModel.HudGpuPriorityActive)) RefreshPresentation();
}
internal void UpdateFrame(NativeGaugeFrame frame)
@@ -98,13 +122,15 @@ private void RefreshPresentationCore()
_presentation = new(width, height, (float)origin.X, (float)origin.Y,
(float)x.X, (float)x.Y, (float)y.X, (float)y.Y,
(float)Math.Clamp(opacity, 0, 1), true, traction, new(color.R, color.G, color.B, color.A), _layout);
+ var viewModel = _owner.DataContext as DiagnosticsViewModel;
if (_worker is null)
{
var textures = AnalogHudAssets.LoadOnUiThread();
_worker = new AnalogHudRenderWorker(source.Handle, _controlId, textures, _presentation, ReportStatus,
- (_owner.DataContext as DiagnosticsViewModel)?.ActiveCpuRendering == true, _window.NativeNeedleSource);
+ viewModel?.ActiveCpuRendering == true, _window.NativeNeedleSource);
}
else _worker.UpdatePresentation(_presentation);
+ _worker.SetGpuPriority(viewModel?.HudGpuPriorityActive == true);
}
private void OnLayoutUpdated(object? sender, EventArgs args)
@@ -127,6 +153,8 @@ public void Dispose()
if (_disposed) return;
_disposed = true;
_owner.LayoutUpdated -= OnLayoutUpdated;
+ _owner.DataContextChanged -= OnDataContextChanged;
+ ObserveViewModel(null);
_worker?.Dispose();
}
diff --git a/src/Wisp.App/NativeRendering/DirectCompositionDevice.cs b/src/Wisp.App/NativeRendering/DirectCompositionDevice.cs
index 4c263da1..7342c835 100644
--- a/src/Wisp.App/NativeRendering/DirectCompositionDevice.cs
+++ b/src/Wisp.App/NativeRendering/DirectCompositionDevice.cs
@@ -102,8 +102,20 @@ private DirectCompositionDevice(RendererHandle handle, int width, int height)
GpuPriority = priority.ToDiagnostic();
}
- // Immutable initialization results also remain available if logging starts later.
- internal TachGpuPriorityDiagnostic GpuPriority { get; }
+ // Initialization results, refreshed after each priority request, remain available if logging starts later.
+ internal TachGpuPriorityDiagnostic GpuPriority { get; private set; }
+
+ // Render-thread only, like every other call on this device. Returns false when
+ // the renderer ignored the request (WARP).
+ public bool SetGpuPriority(bool raise)
+ {
+ ThrowIfDisposed();
+ var result = Native.SetGpuPriority(_handle, raise ? 1 : 0);
+ Marshal.ThrowExceptionForHR(result);
+ Marshal.ThrowExceptionForHR(Native.GetGpuPriorityStatus(_handle, out var priority));
+ GpuPriority = priority.ToDiagnostic();
+ return result == 0;
+ }
public static DirectCompositionDevice Create(IntPtr hwnd, int width, int height, bool cpuRendering = false,
bool compositorNeedle = false)
@@ -390,6 +402,8 @@ internal static extern int ClearCompositorNeedle(RendererHandle renderer, IntPtr
[DllImport(Library, EntryPoint = "WispRendererGetGpuPriorityStatus", CallingConvention = CallingConvention.Cdecl)]
internal static extern int GetGpuPriorityStatus(RendererHandle renderer, out DirectCompositionGpuPriorityStatus status);
+ [DllImport(Library, EntryPoint = "WispRendererSetGpuPriority", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern int SetGpuPriority(RendererHandle renderer, int raise);
[DllImport(Library, EntryPoint = "WispRendererCreate", CallingConvention = CallingConvention.Cdecl)]
internal static extern int Create(IntPtr hwnd, uint width, uint height, out RendererHandle renderer);
diff --git a/src/Wisp.App/NativeRendering/HudNativeHost.cs b/src/Wisp.App/NativeRendering/HudNativeHost.cs
index e3d0cb74..79af6beb 100644
--- a/src/Wisp.App/NativeRendering/HudNativeHost.cs
+++ b/src/Wisp.App/NativeRendering/HudNativeHost.cs
@@ -241,6 +241,7 @@ private void Capture()
_worker.HudPresented += OnPresented;
_vm?.ReportRendererHost(this, RendererHostState.Starting, "HUD renderer: native starting");
}
+ _worker.SetGpuPriority(_vm?.HudGpuPriorityActive == true);
_worker.UpdateHud(_snapshot, Stopwatch.GetTimestamp());
}
diff --git a/src/Wisp.App/ReleaseNotesCatalog.cs b/src/Wisp.App/ReleaseNotesCatalog.cs
index bc4d6209..f49883b3 100644
--- a/src/Wisp.App/ReleaseNotesCatalog.cs
+++ b/src/Wisp.App/ReleaseNotesCatalog.cs
@@ -14,12 +14,31 @@ public static class ReleaseNotesCatalog
{
public static IReadOnlyList Entries { get; } =
[
+ new(
+ "2.6.12",
+ "October 16, 2026",
+ "GPU PRIORITY AND TUNE RESULTS",
+ "Choose how the HUD shares the GPU with the game, and keep tune performance results in view.",
+ true,
+ [
+ Group("HUD GPU priority",
+ "Prioritize HUD updates on the GPU is a new HUD setting, off by default, so the game keeps the GPU to itself.",
+ "Turn it on next to CPU rendering to give HUD updates priority when the GPU is under heavy load; it applies immediately."),
+ Group("Tune performance results",
+ "Performance results read from Forza's Tune screen stay with your current tune after you leave the screen.",
+ "Refresh the current tune at any time and keep comparing braking, grip, acceleration and top speed."),
+ Group("Chat with Views",
+ "Add a subject to your messages so they are easy to find later."),
+ Group("Reliability",
+ "Closing Wisp completes promptly even when a renderer thread is busy.",
+ "Clip actions report problems on the Clips page and can be retried.")
+ ]),
new(
"2.6.11",
"October 15, 2026",
"CHAT AND UPDATES",
"A compact conversation with Views and clearer dashboard updates.",
- true,
+ false,
[
Group("Chat with Views",
"Open your conversation from Extras to read and reply in a compact chat popup.",
diff --git a/src/Wisp.App/Supplementary/FeatureControlClient.cs b/src/Wisp.App/Supplementary/FeatureControlClient.cs
index d7133cf1..bee1e205 100644
--- a/src/Wisp.App/Supplementary/FeatureControlClient.cs
+++ b/src/Wisp.App/Supplementary/FeatureControlClient.cs
@@ -79,7 +79,7 @@ internal async Task RefreshAsync(CancellationToken cancellation)
var response = await _transport.SendAsync("/api/v1/feature-controls?schema=2", null,
FeatureControlVerifier.MaximumEnvelopeBytes, linked.Token).ConfigureAwait(false);
if (!linked.IsCancellationRequested && response.Status == SupplementaryRequestStatus.Success && response.Body.Length > 0)
- _store.Accept(response.Body, _clock.GetUtcNow());
+ ReportAcceptance(_store.Accept(response.Body, _clock.GetUtcNow()));
}
}
catch (OperationCanceledException) { }
@@ -92,6 +92,19 @@ internal async Task RefreshAsync(CancellationToken cancellation)
}
}
+ private SupplementaryContentAcceptance? _reportedAcceptance;
+
+ // Reported once per change, so Tools can show why a published revision did not take effect here.
+ private void ReportAcceptance(SupplementaryContentAcceptance acceptance)
+ {
+ if (_reportedAcceptance == acceptance) return;
+ _reportedAcceptance = acceptance;
+ if (acceptance == SupplementaryContentAcceptance.Rejected)
+ SupplementaryObservations.Record("feature", "app", "failure", "validation");
+ else if (acceptance == SupplementaryContentAcceptance.CacheUnavailable)
+ SupplementaryObservations.Record("feature", "app", "failure", "recovery");
+ }
+
private void PublishState()
{
var next = State;
diff --git a/src/Wisp.App/Supplementary/FeatureControlPolicy.cs b/src/Wisp.App/Supplementary/FeatureControlPolicy.cs
index 9c5b7526..d996277d 100644
--- a/src/Wisp.App/Supplementary/FeatureControlPolicy.cs
+++ b/src/Wisp.App/Supplementary/FeatureControlPolicy.cs
@@ -12,7 +12,7 @@ internal static class ControlledFeatures
"run-recording", "run-library", "lap-review", "tune-read", "tune-library",
"clip-recording", "clip-playback", "clip-export", "clip-library", "shift-calibration",
"tire-calibration", "shift-guidance", "traction-cue", "background-animation",
- "hud-profiles", "appearance", "diagnostics", "news", "support", "updates");
+ "hud-profiles", "appearance", "diagnostics", "news", "support", "updates", "gpu-priority");
}
internal sealed record FeatureControlRule(string Id, ImmutableArray Features, string Reason,
diff --git a/src/Wisp.App/Supplementary/SupplementaryEnvironment.cs b/src/Wisp.App/Supplementary/SupplementaryEnvironment.cs
index 1c7abeb7..99897a10 100644
--- a/src/Wisp.App/Supplementary/SupplementaryEnvironment.cs
+++ b/src/Wisp.App/Supplementary/SupplementaryEnvironment.cs
@@ -16,6 +16,7 @@ internal sealed record SupplementarySettings(string RenderMode, string Units, st
public double? OverlayWidthScale { get; init; }
public double? OverlayHeightScale { get; init; }
public bool? GameAwareVisibility { get; init; }
+ public bool? HudGpuPriority { get; init; }
public bool? GForceEnabled { get; init; }
public bool? BoostGaugeEnabled { get; init; }
public bool? TireTemperatureGaugeEnabled { get; init; }
@@ -58,6 +59,7 @@ ClipsResolutionHeight is null or 360 or 480 or 720 or 1080 or 1440 or 2160 &&
OverlayWidthScale = Number(settings.OverlayWidthScale, 0.1, 10),
OverlayHeightScale = Number(settings.OverlayHeightScale, 0.1, 10),
GameAwareVisibility = settings.GameAwareVisibility,
+ HudGpuPriority = settings.HudGpuPriority,
GForceEnabled = settings.GForceEnabled,
BoostGaugeEnabled = settings.BoostGaugeEnabled,
TireTemperatureGaugeEnabled = settings.TireTemperatureGaugeEnabled,
diff --git a/src/Wisp.App/SupportChatView.cs b/src/Wisp.App/SupportChatView.cs
index ff16050c..e14a5d32 100644
--- a/src/Wisp.App/SupportChatView.cs
+++ b/src/Wisp.App/SupportChatView.cs
@@ -11,12 +11,17 @@ namespace Wisp.App;
internal sealed class SupportChatView : Grid
{
- private readonly Func> _prepare;
+ private readonly Func> _prepare;
private readonly Func> _submit;
private readonly Func _refresh;
private readonly StackPanel _messages = new() { Margin = new Thickness(18, 8, 18, 8) };
private readonly ScrollViewer _scroll = new() { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled };
internal TextBox Composer { get; } = new() { AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MaxLength = 4000, MinHeight = 64, MaxHeight = 120, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(10) };
+ // An optional subject that Views sees as the title in the inbox; it stays put so follow-ups share it.
+ internal TextBox Subject { get; } = new() { MaxLength = 100, Padding = new Thickness(10, 6, 10, 6), Margin = new Thickness(0, 0, 0, 6) };
+ private readonly TextBlock _subjectLabel = Text("Subject (optional)", true);
+ internal const string DefaultTitle = "Chat with Views";
+ private string SubjectOrDefault => string.IsNullOrWhiteSpace(Subject.Text) ? DefaultTitle : Subject.Text.Trim();
internal CheckBox Diagnostics { get; } = new() { Content = "Attach diagnostics", Margin = new Thickness(0), VerticalAlignment = VerticalAlignment.Center };
internal Button Send { get; } = Button("Send", true);
private readonly Button _edit = Button("Back"), _older = Button("Earlier messages");
@@ -44,7 +49,7 @@ internal Border CreatePopupSurface(Action close, Action drag)
return frame;
}
- internal SupportChatView(Func> prepare,
+ internal SupportChatView(Func> prepare,
Func> submit,
Func refresh)
{
@@ -58,8 +63,11 @@ internal SupportChatView(Func> p
SetRow(bottom, 1); Children.Add(bottom);
_status.Margin = new Thickness(0, 0, 0, 8); _status.Visibility = Visibility.Collapsed;
AutomationProperties.SetLiveSetting(_status, AutomationLiveSetting.Polite);
- bottom.Children.Add(_status); bottom.Children.Add(Composer);
+ _subjectLabel.Margin = new Thickness(0, 0, 0, 4);
+ bottom.Children.Add(_status); bottom.Children.Add(_subjectLabel); bottom.Children.Add(Subject); bottom.Children.Add(Composer);
+ AutomationProperties.SetName(Subject, "Subject for Views");
AutomationProperties.SetName(Composer, "Message to Views");
+ Subject.PreviewKeyDown += (_, e) => { if (e.Key == Key.Enter) { e.Handled = true; Composer.Focus(); } };
Diagnostics.ToolTip = "Review and attach up to 20 minutes of diagnostics.";
Diagnostics.SetResourceReference(StyleProperty, "ToggleSwitchStyle");
Diagnostics.SetResourceReference(Control.ForegroundProperty, "TextBrush");
@@ -167,7 +175,7 @@ private async Task SendAsync()
{
if (_pending is null)
{
- _pending = await _prepare(Composer.Text.Trim(), Diagnostics.IsChecked == true);
+ _pending = await _prepare(SubjectOrDefault, Composer.Text.Trim(), Diagnostics.IsChecked == true);
if (_pending is null) { SetStatus("Message unavailable. Your draft is kept; try again."); return; }
if (Diagnostics.IsChecked == true || _pending.Message != Composer.Text.Trim())
{
@@ -208,7 +216,7 @@ private void Review(bool show)
}
private void UpdateActions()
{
- Composer.IsReadOnly = _busy || _pending is not null;
+ Composer.IsReadOnly = Subject.IsReadOnly = _busy || _pending is not null;
Diagnostics.IsEnabled = !_busy && _pending is null && _enabled;
Send.IsEnabled = !_busy && _enabled && !string.IsNullOrWhiteSpace(Composer.Text);
Send.Content = _busy ? "Sending…" : _receiptPending ? "Retry" : _pending is not null ? "Send" : Diagnostics.IsChecked == true ? "Review & send" : "Send";
diff --git a/src/Wisp.App/Tunes/TuneViewModel.cs b/src/Wisp.App/Tunes/TuneViewModel.cs
index df260ec1..a00484b5 100644
--- a/src/Wisp.App/Tunes/TuneViewModel.cs
+++ b/src/Wisp.App/Tunes/TuneViewModel.cs
@@ -226,7 +226,15 @@ private async Task RefreshCoreAsync(bool selectA = false, bool selectB = false)
{
var result = await _capture(cancellation.Token);
if (_disposed || cancellation.IsCancellationRequested || revision != _refreshRevision || !(IsCurrentMode || IsCompareMode)) return;
- _current = result.Snapshot; _currentValid = result.Snapshot is { } snapshot && _isCurrent(snapshot);
+ var snapshot = result.Snapshot;
+ // Forza exposes its benchmark results only while the Tune screen is open. Once read,
+ // they stay with the current tune until the car, parts or settings change.
+ if (snapshot is { Performance: null } fresh && _current is { Performance: { } retained } previous &&
+ previous.Identity == fresh.Identity &&
+ !previous.Parts.IsDefault && !fresh.Parts.IsDefault && previous.Parts.SequenceEqual(fresh.Parts) &&
+ !previous.Fields.IsDefault && !fresh.Fields.IsDefault && previous.Fields.SequenceEqual(fresh.Fields))
+ snapshot = fresh with { Performance = retained };
+ _current = snapshot; _currentValid = snapshot is { } current && _isCurrent(current);
var selectionUnchanged = selectionRevision == _comparisonSelectionRevision;
RefreshCurrentComparison(selectA && selectionUnchanged, selectB && selectionUnchanged);
_status = result.Snapshot is null ? result.Message : TunePresentation.CaptureStatus(result.Snapshot);
diff --git a/src/Wisp.App/Wisp.App.csproj b/src/Wisp.App/Wisp.App.csproj
index ab2472d0..f17d175f 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.6.11
- 2.6.11.0
- 2.6.11.0
+ 2.6.12
+ 2.6.12.0
+ 2.6.12.0
true
false
false
diff --git a/src/Wisp.App/app.manifest b/src/Wisp.App/app.manifest
index 13231211..f7794721 100644
--- a/src/Wisp.App/app.manifest
+++ b/src/Wisp.App/app.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/src/Wisp.NativeRenderer/NativeContractTests.cpp b/src/Wisp.NativeRenderer/NativeContractTests.cpp
index 83b0fd72..81a7e518 100644
--- a/src/Wisp.NativeRenderer/NativeContractTests.cpp
+++ b/src/Wisp.NativeRenderer/NativeContractTests.cpp
@@ -266,34 +266,74 @@ static_assert(sizeof(WispWaitMetrics) == 48 && sizeof(WispWaitMetricsV2) == 72 &
static bool IsUnattempted(const WispGpuPriorityStatus& status)
{
- return status.attempted == 0 && status.requestedProcessClass == D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH &&
+ return status.attempted == 0 && status.requestedProcessClass == D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL &&
status.processSetStatus == INT32_MIN && status.processReadStatus == INT32_MIN &&
- status.effectiveProcessClass == -1 && status.requestedDevicePriority == 1 &&
+ status.effectiveProcessClass == -1 && status.requestedDevicePriority == 0 &&
status.deviceSetHResult == S_FALSE && status.deviceReadHResult == S_FALSE &&
status.effectiveDevicePriority == INT32_MIN;
}
+// The opt-in raises HIGH / +1 and restores NORMAL / 0; WARP ignores both requests.
+static void CheckGpuPriorityToggle(void* renderer, bool cpuRendering,
+ NTSTATUS beforeProcessRead, D3DKMT_SCHEDULINGPRIORITYCLASS beforeProcessClass)
+{
+ Require(WispRendererSetGpuPriority(nullptr, 1) == E_POINTER, "GPU priority toggle rejects a missing renderer");
+ const HRESULT raised = WispRendererSetGpuPriority(renderer, 1);
+ WispGpuPriorityStatus status{};
+ Require(WispRendererGetGpuPriorityStatus(renderer, &status) == S_OK, "GPU priority status is readable after a raise");
+ auto currentClass = D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL;
+ NTSTATUS currentRead = D3DKMTGetProcessSchedulingPriorityClass(GetCurrentProcess(), ¤tClass);
+ if (cpuRendering)
+ {
+ Require(raised == S_FALSE && IsUnattempted(status), "WARP ignores GPU priority requests");
+ if (beforeProcessRead >= 0 && currentRead >= 0)
+ Require(currentClass == beforeProcessClass, "WARP leaves the process GPU priority unchanged after a request");
+ return;
+ }
+ Require(raised == S_OK && status.requestedProcessClass == D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH &&
+ status.requestedDevicePriority == 1 && status.processSetStatus != INT32_MIN && status.deviceSetHResult != S_FALSE,
+ "a raise requests HIGH and relative plus one and records both set results");
+ if (status.processSetStatus >= 0 && currentRead >= 0)
+ Require(currentClass == D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH &&
+ status.effectiveProcessClass == static_cast(currentClass),
+ "a successful raise is visible to an independent OS readback");
+ if (status.deviceSetHResult == S_OK && status.deviceReadHResult == S_OK)
+ Require(status.effectiveDevicePriority == 1, "a successful device raise reads back as plus one");
+ const HRESULT restored = WispRendererSetGpuPriority(renderer, 0);
+ Require(restored == S_OK && WispRendererGetGpuPriorityStatus(renderer, &status) == S_OK &&
+ status.requestedProcessClass == D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL && status.requestedDevicePriority == 0,
+ "a restore requests the defaults");
+ currentRead = D3DKMTGetProcessSchedulingPriorityClass(GetCurrentProcess(), ¤tClass);
+ if (status.processSetStatus >= 0 && currentRead >= 0)
+ Require(currentClass == D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL, "a successful restore returns the process to NORMAL");
+ if (status.deviceSetHResult == S_OK && status.deviceReadHResult == S_OK)
+ Require(status.effectiveDevicePriority == 0, "a successful device restore reads back as zero");
+ std::printf("gpuPriorityToggleRaise=0x%08lX;restoreProcessSet=0x%08lX\n",
+ static_cast(raised), static_cast(status.processSetStatus));
+}
+
static void CheckGpuPriority(void* renderer, bool cpuRendering,
NTSTATUS beforeProcessRead, D3DKMT_SCHEDULINGPRIORITYCLASS beforeProcessClass)
{
WispGpuPriorityStatus status{};
Require(WispRendererGetGpuPriorityStatus(renderer, &status) == S_OK,
"GPU priority initialization snapshot is available on owner thread");
- Require(status.requestedProcessClass == D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH &&
- status.requestedDevicePriority == 1, "GPU priority requests HIGH and relative plus one only");
+ Require(status.requestedProcessClass == D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL &&
+ status.requestedDevicePriority == 0, "GPU priority never requests more than the defaults");
+ Require(status.processSetStatus == INT32_MIN && status.deviceSetHResult == S_FALSE,
+ "GPU priority setters are never called");
auto currentClass = D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL;
const NTSTATUS currentRead = D3DKMTGetProcessSchedulingPriorityClass(GetCurrentProcess(), ¤tClass);
+ if (beforeProcessRead >= 0 && currentRead >= 0)
+ Require(currentClass == beforeProcessClass, "renderer creation leaves the process GPU priority unchanged");
if (cpuRendering)
{
- Require(IsUnattempted(status), "WARP skips priority setters and readbacks without reporting success");
- if (beforeProcessRead >= 0 && currentRead >= 0)
- Require(currentClass == beforeProcessClass, "WARP leaves existing process GPU priority unchanged");
+ Require(IsUnattempted(status), "WARP skips priority readbacks without reporting success");
}
else
{
- Require(status.attempted == 1 && status.processSetStatus != INT32_MIN &&
- status.processReadStatus != INT32_MIN && status.deviceSetHResult != S_FALSE &&
- status.deviceReadHResult != S_FALSE, "hardware initialization records all four priority call results");
+ Require(status.attempted == 1 && status.processReadStatus != INT32_MIN &&
+ status.deviceReadHResult != S_FALSE, "hardware initialization records both priority readbacks");
if (status.processReadStatus >= 0)
{
Require(status.effectiveProcessClass >= D3DKMT_SCHEDULINGPRIORITYCLASS_IDLE &&
@@ -302,19 +342,11 @@ static void CheckGpuPriority(void* renderer, bool cpuRendering,
if (currentRead >= 0)
Require(status.effectiveProcessClass == static_cast(currentClass),
"stored process priority agrees with independent OS readback");
- if (status.processSetStatus == 0)
- Require(status.effectiveProcessClass == status.requestedProcessClass,
- "successful process priority set and readback agree");
}
else Require(status.effectiveProcessClass == -1, "failed process readback retains unknown class");
if (status.deviceReadHResult == S_OK)
- {
Require(status.effectiveDevicePriority >= -7 && status.effectiveDevicePriority <= 7,
"successful device priority readback is a relative priority");
- if (status.deviceSetHResult == S_OK)
- Require(status.effectiveDevicePriority == status.requestedDevicePriority,
- "successful device priority set and readback agree");
- }
else Require(status.effectiveDevicePriority == INT32_MIN, "failed device readback retains unknown priority");
}
std::printf("gpuPriorityAttempted=%u;processSet=0x%08lX;processRead=0x%08lX;processEffective=%d;deviceSet=0x%08lX;deviceRead=0x%08lX;deviceEffective=%d\n",
@@ -1357,6 +1389,7 @@ int main(int argc, char** argv)
Require(wrongPresentationThread == RPC_E_WRONG_THREAD,
"presentation status respects render-thread ownership");
CheckGpuPriority(renderer, cpuRendering, beforeProcessRead, beforeProcessClass);
+ CheckGpuPriorityToggle(renderer, cpuRendering, beforeProcessRead, beforeProcessClass);
uint32_t initialReady = 99;
WispWaitMetrics waitMetrics{};
Require(SUCCEEDED(WispRendererWaitForFrameMeasured(renderer, 100, nullptr, 1, &initialReady, &waitMetrics)),
diff --git a/src/Wisp.NativeRenderer/Wisp.NativeRenderer.cpp b/src/Wisp.NativeRenderer/Wisp.NativeRenderer.cpp
index 2c968b35..9c97b4f4 100644
--- a/src/Wisp.NativeRenderer/Wisp.NativeRenderer.cpp
+++ b/src/Wisp.NativeRenderer/Wisp.NativeRenderer.cpp
@@ -36,10 +36,13 @@ namespace
constexpr UINT MaximumFrameLatency = 2;
constexpr uint64_t MaximumDialCacheBytes = 16ull * 1024 * 1024;
+ // Wisp never raises its GPU scheduling priority: the game keeps the GPU, and
+ // the overlay accepts whatever time is left. The requested values are the
+ // untouched defaults; only the readbacks are attempted on hardware.
constexpr WispGpuPriorityStatus UnattemptedGpuPriorityStatus() noexcept
{
- return {0, D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH, INT32_MIN, INT32_MIN, -1,
- 1, S_FALSE, S_FALSE, INT32_MIN};
+ return {0, D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL, INT32_MIN, INT32_MIN, -1,
+ 0, S_FALSE, S_FALSE, INT32_MIN};
}
bool ValidSize(uint32_t width, uint32_t height) noexcept
@@ -667,25 +670,49 @@ namespace
pacingStatus = S_OK;
}
- void ConfigureGpuPriority(IDXGIDevice* dxgiDevice) noexcept
+ void ObserveGpuPriority(IDXGIDevice* dxgiDevice) noexcept
{
gpuPriorityStatus.attempted = 1;
- // Best-effort test: neither failure prevents rendering, and no
- // elevation, absolute priority or realtime class is requested.
- gpuPriorityStatus.processSetStatus = D3DKMTSetProcessSchedulingPriorityClass(
- GetCurrentProcess(), D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH);
+ // Read-only: a raised process class or device priority would let the
+ // overlay's presents preempt the game's GPU work on every frame. The
+ // setters are never called; the readbacks only feed diagnostics.
auto processClass = D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL;
gpuPriorityStatus.processReadStatus = D3DKMTGetProcessSchedulingPriorityClass(
GetCurrentProcess(), &processClass);
if (gpuPriorityStatus.processReadStatus >= 0)
gpuPriorityStatus.effectiveProcessClass = static_cast(processClass);
- gpuPriorityStatus.deviceSetHResult = dxgiDevice->SetGPUThreadPriority(1);
INT devicePriority = INT32_MIN;
gpuPriorityStatus.deviceReadHResult = dxgiDevice->GetGPUThreadPriority(&devicePriority);
if (gpuPriorityStatus.deviceReadHResult == S_OK)
gpuPriorityStatus.effectiveDevicePriority = devicePriority;
}
+ // Opt-in from the user's settings. The process class is process-wide, so
+ // every hardware renderer applies the same request; the device priority is
+ // per device. Each call records its results and the independent readbacks.
+ HRESULT SetGpuPriority(bool raise) noexcept
+ {
+ if (gpuPriorityStatus.attempted == 0 || !device) return S_FALSE;
+ ComPtr dxgiDevice;
+ CHECK_HR(device.As(&dxgiDevice));
+ gpuPriorityStatus.requestedProcessClass = raise
+ ? D3DKMT_SCHEDULINGPRIORITYCLASS_HIGH : D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL;
+ gpuPriorityStatus.requestedDevicePriority = raise ? 1 : 0;
+ gpuPriorityStatus.processSetStatus = D3DKMTSetProcessSchedulingPriorityClass(GetCurrentProcess(),
+ static_cast(gpuPriorityStatus.requestedProcessClass));
+ auto processClass = D3DKMT_SCHEDULINGPRIORITYCLASS_NORMAL;
+ gpuPriorityStatus.processReadStatus = D3DKMTGetProcessSchedulingPriorityClass(
+ GetCurrentProcess(), &processClass);
+ gpuPriorityStatus.effectiveProcessClass = gpuPriorityStatus.processReadStatus >= 0
+ ? static_cast(processClass) : -1;
+ gpuPriorityStatus.deviceSetHResult = dxgiDevice->SetGPUThreadPriority(gpuPriorityStatus.requestedDevicePriority);
+ INT devicePriority = INT32_MIN;
+ gpuPriorityStatus.deviceReadHResult = dxgiDevice->GetGPUThreadPriority(&devicePriority);
+ gpuPriorityStatus.effectiveDevicePriority = gpuPriorityStatus.deviceReadHResult == S_OK
+ ? devicePriority : INT32_MIN;
+ return S_OK;
+ }
+
HRESULT CreateTarget()
{
ComPtr buffer;
@@ -850,7 +877,7 @@ namespace
context->ClearRenderTargetView(renderTarget.Get(), clear);
CHECK_HR(InitialPresentationResult(PresentChain(swapChain.Get(), swapChainFlags, 0)));
CHECK_HR(composition->Commit());
- if (!cpuRendering) ConfigureGpuPriority(dxgiDevice.Get());
+ if (!cpuRendering) ObserveGpuPriority(dxgiDevice.Get());
return S_OK;
}
@@ -1363,6 +1390,13 @@ HRESULT __cdecl WispRendererGetGpuPriorityStatus(void* renderer, WispGpuPriority
return S_OK;
});
}
+HRESULT __cdecl WispRendererSetGpuPriority(void* renderer, int raise) noexcept
+{
+ return Invoke(renderer, [&](Renderer& r) {
+ CHECK_HR(r.CheckThread());
+ return r.SetGpuPriority(raise != 0);
+ });
+}
HRESULT __cdecl WispRendererGetPresentationStatus(void* renderer, WispPresentationStatus* status) noexcept
{
if (!status) return E_POINTER;
diff --git a/src/Wisp.NativeRenderer/Wisp.NativeRenderer.h b/src/Wisp.NativeRenderer/Wisp.NativeRenderer.h
index 07cc983b..1b251e9e 100644
--- a/src/Wisp.NativeRenderer/Wisp.NativeRenderer.h
+++ b/src/Wisp.NativeRenderer/Wisp.NativeRenderer.h
@@ -40,7 +40,10 @@ struct WispWaitMetricsV2
// Stored initialization results, not a live query. Process statuses are raw
// NTSTATUS values; INT32_MIN means not attempted. Device statuses are HRESULTs
// (S_FALSE when not attempted). Effective values stay -1/INT32_MIN if unread.
-// attempted is zero for WARP; requested values describe the hardware test only.
+// attempted is zero for WARP. Creation never sets a priority: requested values
+// start at the defaults (NORMAL, 0) and only the readbacks run on hardware. The
+// set statuses stay "not attempted" until WispRendererSetGpuPriority is called,
+// after which every field describes the most recent request.
struct WispGpuPriorityStatus
{
uint32_t attempted;
@@ -130,6 +133,9 @@ WISP_API void __cdecl WispRendererDestroy(void* renderer) noexcept;
// Owner-thread-only; querying never changes priorities. Failed queries return
// the not-attempted sentinel when a non-null output was supplied.
WISP_API HRESULT __cdecl WispRendererGetGpuPriorityStatus(void* renderer, WispGpuPriorityStatus* status) noexcept;
+// Owner thread. raise != 0 requests the HIGH process class and device priority +1;
+// raise == 0 restores NORMAL and 0. WARP renderers return S_FALSE and change nothing.
+WISP_API HRESULT __cdecl WispRendererSetGpuPriority(void* renderer, int raise) noexcept;
WISP_API HRESULT __cdecl WispRendererGetPresentationStatus(void* renderer, WispPresentationStatus* status) noexcept;
// S_OK replaces the swapchain; S_FALSE preserves an already fresh chain.
WISP_API HRESULT __cdecl WispRendererPrepareForResume(void* renderer) noexcept;
diff --git a/src/Wisp.Updater/Wisp.Updater.csproj b/src/Wisp.Updater/Wisp.Updater.csproj
index 20978252..54831955 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.6.11
- 2.6.11.0
- 2.6.11.0
+ 2.6.12
+ 2.6.12.0
+ 2.6.12.0
diff --git a/tests/Wisp.App.Tests/CpuRenderingSettingsTests.cs b/tests/Wisp.App.Tests/CpuRenderingSettingsTests.cs
index cdadf966..36927430 100644
--- a/tests/Wisp.App.Tests/CpuRenderingSettingsTests.cs
+++ b/tests/Wisp.App.Tests/CpuRenderingSettingsTests.cs
@@ -56,14 +56,33 @@ internal static void AssertControllerPersistenceOnCurrentDispatcher()
}
};
bool failSave = false;
- bool? savedCpu = null;
+ bool? savedCpu = null, savedPriority = null;
var controller = new AppController(settings, value =>
{
if (failSave) throw new System.IO.IOException("Test storage unavailable");
savedCpu = value.CpuRenderingEnabled;
+ savedPriority = value.HudGpuPriority;
}, new NoStartupRegistration());
try
{
+ Assert.False(controller.ViewModel.HudGpuPriorityEnabled);
+ Assert.False(controller.ViewModel.HudGpuPriorityActive);
+ controller.SetHudGpuPriorityEnabled(true);
+ Assert.True(savedPriority);
+ Assert.True(settings.HudGpuPriority);
+ Assert.True(controller.ViewModel.HudGpuPriorityEnabled);
+ Assert.True(controller.ViewModel.HudGpuPriorityActive);
+ Assert.Empty(controller.ViewModel.HudGpuPriorityChangeStatus);
+ failSave = true;
+ controller.SetHudGpuPriorityEnabled(false);
+ Assert.True(settings.HudGpuPriority);
+ Assert.True(controller.ViewModel.HudGpuPriorityEnabled);
+ Assert.Contains("Could not save", controller.ViewModel.HudGpuPriorityChangeStatus);
+ failSave = false;
+ controller.SetHudGpuPriorityEnabled(false);
+ Assert.False(savedPriority);
+ Assert.False(controller.ViewModel.HudGpuPriorityActive);
+ Assert.Empty(controller.ViewModel.HudGpuPriorityChangeStatus);
controller.SetCpuRenderingEnabled(true);
Assert.True(savedCpu);
Assert.True(settings.CpuRenderingEnabled);
diff --git a/tests/Wisp.App.Tests/Fixtures/FeatureControls/service-schema-1.json b/tests/Wisp.App.Tests/Fixtures/FeatureControls/service-schema-1.json
index a4a6c881..e93c72cc 100644
--- a/tests/Wisp.App.Tests/Fixtures/FeatureControls/service-schema-1.json
+++ b/tests/Wisp.App.Tests/Fixtures/FeatureControls/service-schema-1.json
@@ -35,7 +35,8 @@
"diagnostics",
"news",
"support",
- "updates"
+ "updates",
+ "gpu-priority"
],
"paused": {
"keyId": "test-key",
diff --git a/tests/Wisp.App.Tests/HudGpuPrioritySettingsTests.cs b/tests/Wisp.App.Tests/HudGpuPrioritySettingsTests.cs
new file mode 100644
index 00000000..e0108f12
--- /dev/null
+++ b/tests/Wisp.App.Tests/HudGpuPrioritySettingsTests.cs
@@ -0,0 +1,60 @@
+using System.Text.Json;
+using Wisp.App;
+using Wisp.App.Supplementary;
+using Xunit;
+
+namespace Wisp.App.Tests;
+
+public sealed class HudGpuPrioritySettingsTests
+{
+ [Fact]
+ public void ExistingSettingsLeaveTheGpuPriorityOff()
+ {
+ var settings = JsonSerializer.Deserialize("{\"SettingsRevision\":9,\"CpuRenderingEnabled\":false}")!;
+ settings.MigrateSettings();
+ Assert.False(settings.HudGpuPriority);
+ Assert.False(new AppSettings().HudGpuPriority);
+ }
+
+ [Fact]
+ public void ChoiceRoundTripsThroughStoredSettings()
+ {
+ var settings = new AppSettings { HudGpuPriority = true };
+ var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(settings))!;
+ restored.MigrateSettings();
+ Assert.True(restored.HudGpuPriority);
+ var viewModel = new DiagnosticsViewModel(restored);
+ Assert.True(viewModel.HudGpuPriorityEnabled);
+ Assert.True(viewModel.HudGpuPriorityActive);
+ Assert.Empty(viewModel.HudGpuPriorityChangeStatus);
+ }
+
+ [Fact]
+ public void ViewModelSeparatesTheSavedChoiceFromWhatTheRendererApplies()
+ {
+ var viewModel = new DiagnosticsViewModel(new AppSettings());
+ viewModel.UpdateHudGpuPriority(true, false, "GPU priority is temporarily paused.");
+ Assert.True(viewModel.HudGpuPriorityEnabled);
+ Assert.False(viewModel.HudGpuPriorityActive);
+ Assert.Contains("paused", viewModel.HudGpuPriorityChangeStatus);
+ viewModel.UpdateHudGpuPriority(true, true, "");
+ Assert.True(viewModel.HudGpuPriorityActive);
+ Assert.Empty(viewModel.HudGpuPriorityChangeStatus);
+ }
+
+ [Fact]
+ public void ToolsSnapshotReportsTheChoice()
+ {
+ var projected = SupplementarySettings.Capture(new AppSettings { HudGpuPriority = true });
+ Assert.True(projected.HudGpuPriority);
+ Assert.True(projected.IsValid);
+ Assert.Contains("\"hudGpuPriority\":true", JsonSerializer.Serialize(projected, SupplementarySchema.Json));
+ Assert.False(SupplementarySettings.Capture(new AppSettings()).HudGpuPriority);
+ }
+
+ [Fact]
+ public void ToolsCanPauseTheFeature()
+ {
+ Assert.Contains("gpu-priority", ControlledFeatures.All);
+ }
+}
diff --git a/tests/Wisp.App.Tests/SupplementaryEnvironmentTests.cs b/tests/Wisp.App.Tests/SupplementaryEnvironmentTests.cs
index 88833830..42753883 100644
--- a/tests/Wisp.App.Tests/SupplementaryEnvironmentTests.cs
+++ b/tests/Wisp.App.Tests/SupplementaryEnvironmentTests.cs
@@ -53,6 +53,7 @@ public void SettingsProjectionCapturesUsefulChoicesWithoutProfilesPathsOrHotkeys
var preferences = new AppSettings
{
CpuRenderingEnabled = true,
+ HudGpuPriority = true,
Smoothing = 0.08,
OverlayWidthScale = 1.5,
LayoutMode = HudLayoutMode.Native,
@@ -77,6 +78,7 @@ public void SettingsProjectionCapturesUsefulChoicesWithoutProfilesPathsOrHotkeys
Assert.Equal(2160, projected.ClipsResolutionHeight);
Assert.True(projected.ClipsLossless);
Assert.True(projected.LapMapEnabled);
+ Assert.True(projected.HudGpuPriority);
Assert.DoesNotContain("fixture-private", JsonSerializer.Serialize(projected, SupplementarySchema.Json));
preferences.Smoothing = double.NaN; preferences.Clips.FrameRate = 900;
projected = SupplementarySettings.Capture(preferences);
diff --git a/tests/Wisp.App.Tests/TuneViewModelTests.cs b/tests/Wisp.App.Tests/TuneViewModelTests.cs
index ff8a5b86..fc813c56 100644
--- a/tests/Wisp.App.Tests/TuneViewModelTests.cs
+++ b/tests/Wisp.App.Tests/TuneViewModelTests.cs
@@ -390,6 +390,26 @@ public void WriteFailurePreservesDialogAndDoesNotClaimSuccess() => OnDispatcher(
Assert.True(model.IsDialogOpen); Assert.NotEmpty(model.DialogError); Assert.Equal("Road", model.DialogName); Assert.Empty(model.Library);
});
+ [Fact]
+ public void PerformanceResultsStayWithTheCurrentTuneAfterTheTuneScreenCloses() => OnDispatcher(async () =>
+ {
+ using var fixture = new Fixture();
+ var performance = new TunePerformance(32f, 90f, 1.1f, 1.2f, 4.5f, 10.5f, 85f, 0.5f, 0.5f, 12f);
+ var captured = Snapshot() with { Performance = performance };
+ var later = captured with { Performance = null, CapturedAtUtc = captured.CapturedAtUtc.AddMinutes(5) };
+ var calls = 0;
+ using var model = new TuneViewModel(fixture.Store,
+ _ => Task.FromResult(new TuneCaptureResult(++calls == 1 ? captured : later, TuneCaptureStatus.Ready, "Ready")),
+ _ => true, Dispatcher.CurrentDispatcher);
+ await model.InitializeAsync(); await model.RefreshAsync();
+ model.SelectedCategory = model.Categories.Single(option => option.Category == TuneCategory.Performance);
+ Assert.Empty(model.PerformanceHint);
+ await model.RefreshAsync();
+ Assert.Equal(2, calls);
+ Assert.Empty(model.PerformanceHint);
+ Assert.True(model.HasSnapshot);
+ });
+
private static TuneSnapshot Snapshot(string name = "miata") => TuneUiTestData.ValidSnapshot(name);
[Fact]
diff --git a/tools/Wisp.UiReview/SupportComposerReview.cs b/tools/Wisp.UiReview/SupportComposerReview.cs
index ea6c0647..00b6b951 100644
--- a/tools/Wisp.UiReview/SupportComposerReview.cs
+++ b/tools/Wisp.UiReview/SupportComposerReview.cs
@@ -34,7 +34,7 @@ internal static int RunChat(string output, Func resources)
foreach (var width in new[] { 360, 480, 640 })
{
var submitted = new List(); var fail = true;
- var view = new SupportChatView((text, diagnostics) => Task.FromResult(new(1, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), now, "2.6.11", "fixture", "stable", "feedback", "Chat", text)),
+ var view = new SupportChatView((title, text, diagnostics) => Task.FromResult(new(1, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), now, "2.6.11", "fixture", "stable", "feedback", title, text)),
(report, _) => { submitted.Add(report.EventId); return Task.FromResult(new SupplementarySupportResult(fail ? SupplementaryRequestStatus.Unavailable : SupplementaryRequestStatus.Success)); },
(_, _) => Task.CompletedTask);
var surface = view.CreatePopupSurface(() => { }, () => { });