Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,29 @@ Versions before 1.0.0 are pre-release: features and defaults may still change.

## [Unreleased]

> **⚠️ Important update — please upgrade right away.** This release fixes a bug that
> could interrupt your **mouse and keyboard and stutter system performance about every
> 30 seconds** while GamerGuardian was running (a monitored setting was being
> re-applied on a loop). If you're on an earlier version, updating now is strongly
> recommended.
> **⚠️ Important update — please upgrade right away.** This is the real fix for the
> **mouse and keyboard stuttering about every 30 seconds** while GamerGuardian runs.
> The earlier 0.1.56 fix addressed a related auto-apply loop but not this cause, so if
> you're still seeing the hitch, update to this build.

### Fixed
- **Mouse and keyboard no longer hitch every ~30 seconds.** A monitored, auto-applied
setting that Windows kept reverting was re-applied on every poll — and when the app
runs without admin rights each re-apply raised a UAC prompt (and display settings
reconfigured the screen), seizing input. A new circuit breaker stops re-applying a
setting Windows keeps fighting after a few tries and leaves it notify-only for a
cooldown (logged as `[CIRCUIT]` in the change log), so the worst case is one
interruption every several minutes instead of one every 30 seconds.
- **Mouse and keyboard hitch every ~30 seconds — the real cause.** The Dynamic Refresh
Rate (DRR) "is this display capable?" check was running on every 30-second poll for
each monitor, and that check briefly re-validates the display configuration — which on
many GPUs stalls the mouse and keyboard for a moment. DRR capability never changes
while you're using the PC, so it's now checked **once** per display instead of every
poll, and skipped entirely for displays you aren't monitoring. (This ran regardless of
whether you were even monitoring DRR, which is why turning settings off didn't help.)

## [0.1.56] - 2026-06-20

### Fixed
- **Mouse and keyboard no longer hitch every ~30 seconds (auto-apply loop).** A
monitored, auto-applied setting that Windows kept reverting was re-applied on every
poll — and when the app runs without admin rights each re-apply raised a UAC prompt
(and display settings reconfigured the screen), seizing input. A circuit breaker now
stops re-applying a setting Windows keeps fighting after a few tries and leaves it
notify-only for a cooldown (logged as `[CIRCUIT]` in the change log).

### Changed
- **Far less background polling.** Only the display settings (HDR, refresh rate,
Expand Down
9 changes: 8 additions & 1 deletion src/GamerGuardian/Monitors/DrrMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,18 @@ public IEnumerable<DriftItem> CheckDrift(AppConfig config)
var active = DisplayHelper.EnumerateActiveDisplays();
foreach (var display in active)
{
// Resolve the preference first and bail before touching the display
// config when DRR isn't being monitored. CheckDrift runs on every poll
// and the support probe below calls SetDisplayConfig, which can stall
// input -- so we must not run it for displays the user isn't watching
// (the drift would be filtered out by IsMonitored anyway).
var pref = DisplayPreferenceResolver.Resolve(config, display, active);
if (!pref.Drr.Monitor) continue;

var read = DrrInterop.ReadState(display.AdapterId, display.TargetId);
if (!read.Found) continue;
if (!DrrInterop.IsSupported(display.AdapterId, display.TargetId)) continue;

var pref = DisplayPreferenceResolver.Resolve(config, display, active);
bool current = read.Enabled;
bool desired = pref.Drr.DesiredOn;
if (current == desired) continue;
Expand Down
31 changes: 29 additions & 2 deletions src/GamerGuardian/Native/DrrInterop.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using static GamerGuardian.Native.DisplayConfig;

namespace GamerGuardian.Native;
Expand Down Expand Up @@ -59,9 +60,35 @@ public static ReadResult ReadState(LUID adapterId, uint targetId)
catch { return new ReadResult(false, false); }
}

/// <summary>True when DRR can be set on this target (the driver/panel accepts
/// the boost-refresh-rate flag under SDC_VALIDATE).</summary>
// Whether a target supports DRR is a static property of the panel + driver +
// OS -- it does not change between polls. The probe below is NOT free: it calls
// SetDisplayConfig (SDC_VALIDATE), which on some GPU/driver combos re-evaluates
// the display pipeline and briefly stalls the mouse/keyboard. Running it on
// every 30s drift poll (DrrMonitor.CheckDrift -> IsSupported) is what caused the
// periodic input hitch, so the result is cached per target and the probe runs
// at most once per display until the topology changes.
private static readonly ConcurrentDictionary<(uint low, int high, uint target), bool> _supportCache = new();

/// <summary>
/// The actual support probe. Exposed as a settable delegate ONLY so tests can
/// substitute a non-native counter (the native probe needs a real display and
/// would itself disturb it). Production never reassigns this.
/// </summary>
public static Func<LUID, uint, bool> SupportProbe { get; set; } = ProbeSupportedNative;

/// <summary>True when DRR can be set on this target. Cached: the underlying
/// SDC_VALIDATE probe runs once per display, not on every poll.</summary>
public static bool IsSupported(LUID adapterId, uint targetId)
=> _supportCache.GetOrAdd(
(adapterId.LowPart, adapterId.HighPart, targetId),
_ => SupportProbe(adapterId, targetId));

/// <summary>Drops the cached support results so they are re-probed once. Call
/// when the display topology changes (monitor hot-plug, driver change) -- DRR
/// support can differ for a newly attached panel.</summary>
public static void ClearSupportCache() => _supportCache.Clear();

private static bool ProbeSupportedNative(LUID adapterId, uint targetId)
{
try
{
Expand Down
8 changes: 7 additions & 1 deletion src/GamerGuardian/Services/MonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,13 @@ private void OnSessionSwitch(object? sender, SessionSwitchEventArgs e)
}

private void OnDisplaySettingsChanged(object? sender, EventArgs e)
=> ScheduleCheck(tier: MonitorTier.Volatile, delayMs: 1500);
{
// The display topology may have changed (monitor hot-plug / driver) -- a
// newly attached panel can have different DRR support, so drop the cached
// support results and let the next check re-probe each display once.
GamerGuardian.Native.DrrInterop.ClearSupportCache();
ScheduleCheck(tier: MonitorTier.Volatile, delayMs: 1500);
}

/// <summary>
/// Called by the SettingsWindow's Apply / Save &amp; close path to seed our
Expand Down
83 changes: 82 additions & 1 deletion tests/GamerGuardian.Tests/DrrInteropTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
using System;
using System.Collections.Generic;
using GamerGuardian.Native;
using static GamerGuardian.Native.DisplayConfig;
using Xunit;

namespace GamerGuardian.Tests;

public class DrrInteropTests
public class DrrInteropTests : IDisposable
{
// These tests substitute the native DRR support probe; reset it (and the
// cache) around each test so static state never leaks between tests.
private readonly Func<LUID, uint, bool> _originalProbe = DrrInterop.SupportProbe;

public DrrInteropTests() => DrrInterop.ClearSupportCache();

public void Dispose()
{
DrrInterop.SupportProbe = _originalProbe;
DrrInterop.ClearSupportCache();
}

private static LUID Luid(uint low, int high = 0) => new() { LowPart = low, HighPart = high };

[Fact]
public void IsDrrEnabled_TrueWhenBoostFlagSet()
{
Expand All @@ -27,4 +44,68 @@ public void IsDrrEnabled_IgnoresUnrelatedBits()
// Only unrelated bits set -> not enabled.
Assert.False(DrrInterop.IsDrrEnabled(0x08 | 0x01));
}

// ---- Support-probe caching (the input-stall fix) ----------------------

[Fact]
public void IsSupported_ProbesEachTargetOnce_NotEveryCall()
{
var calls = new Dictionary<(uint, int, uint), int>();
DrrInterop.SupportProbe = (a, t) =>
{
var k = (a.LowPart, a.HighPart, t);
calls[k] = calls.GetValueOrDefault(k) + 1;
return true;
};

// Simulate the calls DrrMonitor.CheckDrift makes across many 30s polls.
for (int poll = 0; poll < 10; poll++)
{
Assert.True(DrrInterop.IsSupported(Luid(1), 100));
Assert.True(DrrInterop.IsSupported(Luid(1), 200));
}

// The disruptive SetDisplayConfig(SDC_VALIDATE) probe must run once per
// target, not on every poll. Before the cache this was 10 -- the bug.
Assert.Equal(1, calls[(1u, 0, 100u)]);
Assert.Equal(1, calls[(1u, 0, 200u)]);
}

[Fact]
public void IsSupported_CachesTheResultValue()
{
DrrInterop.SupportProbe = (_, _) => false;
Assert.False(DrrInterop.IsSupported(Luid(7), 1));
// A later probe that would say true must not override the cached value.
DrrInterop.SupportProbe = (_, _) => true;
Assert.False(DrrInterop.IsSupported(Luid(7), 1));
}

[Fact]
public void ClearSupportCache_ForcesReprobe()
{
int calls = 0;
DrrInterop.SupportProbe = (_, _) => { calls++; return true; };

DrrInterop.IsSupported(Luid(5), 1);
DrrInterop.IsSupported(Luid(5), 1);
Assert.Equal(1, calls);

DrrInterop.ClearSupportCache(); // e.g. a monitor hot-plug
DrrInterop.IsSupported(Luid(5), 1);
Assert.Equal(2, calls);
}

[Fact]
public void DifferentTargetsAndAdapters_ProbedIndependently()
{
int calls = 0;
DrrInterop.SupportProbe = (_, _) => { calls++; return true; };

DrrInterop.IsSupported(Luid(1, 0), 1);
DrrInterop.IsSupported(Luid(1, 0), 2); // same adapter, different target
DrrInterop.IsSupported(Luid(2, 0), 1); // different adapter low part
DrrInterop.IsSupported(Luid(1, 9), 1); // different adapter high part
Assert.Equal(4, calls);
}
}