From ac0f9b56d391a7b7e8ca83e3affdcc619ce7eb1e Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 27 Aug 2026 22:12:01 +0200 Subject: [PATCH] Toggle a game's profile from a hotkey (#143) The issue is a title and nothing else - "[Suggestion] Add reset/toggle keybind" - so the shape was decided rather than transcribed, and every choice below is an assumption a maintainer may want to argue with. Press the key while a configured game holds the foreground and that display flips between the game's level and the Windows level. Press it anywhere else and nothing happens at all. The choice then wins over the automatic behaviour for that game until pressed again or the app restarts, so alt-tabbing back in does not quietly undo it. RegisterHotKey, never a low-level keyboard hook. The readme names CS:GO, and a program installing a system-wide keyboard hook while someone plays a competitive shooter is the exact shape anti-cheat heuristics look for. Nothing here is loaded into, injected into or hooked onto another process. The cost is real and documented: a game can suppress the key by registering raw input against hotkeys, or by installing its own hook after ours. The only mechanism that could reach those cases is the one ruled out, so they are stated rather than chased. The far more common cause of a dead hotkey - another program already owning the combination - is reported inline the moment it is set, where the old behaviour was to store the failure in a boolean and tell nobody. The state is a set of suppressed profile names, deliberately not a set of enabled ones, so an empty set means today's behaviour and none of the existing checks change meaning. It is keyed by name rather than by the setting object, because editing a profile replaces that object. Nothing persists it: the helper has no I/O at all, so a game silently left off cannot survive a restart and be discovered weeks later. Toggling off restores one display through the single-display path, not the one that walks the whole work list and the primary as well. On AMD with the default configuration that path has to widen, because there the automatic apply writes every display - restoring one would leave the others saturated while the notification claimed otherwise. Two things had to change underneath. Restoring one display carries no guard against the Windows level being unknown - that lives a level up - so reaching it directly would have written zero to a display during startup, which is the defect fixed three changes ago arriving through a new door. The check now sits in the decision itself. And AMD's set- saturation call returned void, so there was no way to honour "only report success when the write landed": it now returns whether a display actually matched and every call succeeded, which also distinguishes a name that matched nothing from a write that worked. Fifty-three checks behind a fake registrar, a fake foreground reader and the existing fake devices. No test registers a real hotkey or touches a real display. Every check was proven by breaking the line it guards - including two that were rewritten after review because they exercised a copy of the logic rather than the logic, which the whole suite could not distinguish. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0187tGqyEw4frZzDYPPJfUMd --- vibrance.GUI/AMD/AmdDynamicVibranceProxy.cs | 114 +- vibrance.GUI/AMD/vendor/AmdAdapter32.cs | 31 +- vibrance.GUI/AMD/vendor/AmdAdapter64.cs | 31 +- vibrance.GUI/AMD/vendor/IAmdAdapter.cs | 9 +- .../NVIDIA/NvidiaDynamicVibranceProxy.cs | 83 +- vibrance.GUI/Program.cs | 14 + vibrance.GUI/common/Definitions.cs | 5 + vibrance.GUI/common/HotkeyBinding.cs | 175 ++ vibrance.GUI/common/HotkeyRegistration.cs | 110 ++ .../common/IForegroundWindowReader.cs | 81 + vibrance.GUI/common/IHotkeyRegistrar.cs | 76 + vibrance.GUI/common/ISettingsController.cs | 4 + vibrance.GUI/common/IVibranceProxy.cs | 39 +- vibrance.GUI/common/ProfileToggleFixture.cs | 1607 +++++++++++++++++ vibrance.GUI/common/ProfileToggleHelper.cs | 144 ++ vibrance.GUI/common/SettingsController.cs | 85 + vibrance.GUI/common/StabilityFixture.cs | 5 +- vibrance.GUI/common/VibranceGUI.Designer.cs | 83 +- vibrance.GUI/common/VibranceGUI.cs | 523 +++++- vibrance.GUI/common/VibranceRestoreFixture.cs | 6 +- vibrance.GUI/vibrance.GUI.csproj | 6 + 21 files changed, 3212 insertions(+), 19 deletions(-) create mode 100644 vibrance.GUI/common/HotkeyBinding.cs create mode 100644 vibrance.GUI/common/HotkeyRegistration.cs create mode 100644 vibrance.GUI/common/IForegroundWindowReader.cs create mode 100644 vibrance.GUI/common/IHotkeyRegistrar.cs create mode 100644 vibrance.GUI/common/ProfileToggleFixture.cs create mode 100644 vibrance.GUI/common/ProfileToggleHelper.cs diff --git a/vibrance.GUI/AMD/AmdDynamicVibranceProxy.cs b/vibrance.GUI/AMD/AmdDynamicVibranceProxy.cs index ca4742c..3f78330 100644 --- a/vibrance.GUI/AMD/AmdDynamicVibranceProxy.cs +++ b/vibrance.GUI/AMD/AmdDynamicVibranceProxy.cs @@ -154,6 +154,21 @@ private void OnWinEventHook(object sender, WinEventHookEventArgs e) if (applicationSetting != null) { + if (ProfileToggleHelper.IsSuppressed(applicationSetting.Name)) + { + // Toggled off by hotkey (upstream #143). Ignore this foreground event for + // this game entirely - deliberately NOT a fall-through to the restore branch + // below: the toggle itself already restored this display + // (ToggleForegroundProfile), and re-running the work-list restore on every + // alt-tab into a suppressed game would reach displays this game never even + // touched. + // + // Returns BEFORE "_gameScreen = screen" below: a suppressed game applies + // nothing here, so it must not become the screen a later resolution revert + // reasons about. + return; + } + Screen screen = Screen.FromHandle(e.Handle); _gameScreen = screen; @@ -263,11 +278,16 @@ private void RestoreWindowsVibranceLevel() return; } - // IAmdAdapter has no read-back to confirm a write landed, unlike NVIDIA's + // This restore path still has no read-back to confirm a write landed, unlike NVIDIA's // equalsDVCLevel/setDVCLevel pair - so, unlike NvidiaDynamicVibranceProxy's - // RestoreOneDisplay, every target is written unconditionally and cleared + // RestoreOneDisplay, every target here is written unconditionally and cleared // unconditionally, unable to tell "already correct" from "just fixed" or to retry a - // failure that has no way to be observed here. + // failure that has no way to be observed here. That is no longer true of + // IAmdAdapter.SetSaturationOnDisplay itself (upstream #143 gave it a real ADL_OK-based + // bool return) - it is just that THIS call site, deliberately, still ignores it: doing + // otherwise would make this drain conditionally, changing behaviour the pre-existing + // A1-A6 checks in VibranceRestoreFixture pin. ToggleForegroundProfile below is the one + // call site that actually reads the new return value. List targets = VibranceRestoreHelper.ComposeRestoreTargets(true, VibranceRestoreHelper.GetPrimaryDeviceName()); foreach (string deviceName in targets) { @@ -276,6 +296,94 @@ private void RestoreWindowsVibranceLevel() } } + /// + /// See IVibranceProxy.ToggleForegroundProfile for the full contract. Decide (pure) picks + /// the direction from our own recorded suppression state, never from a display read-back; + /// this method is only the write plus the flip. Unlike RestoreWindowsVibranceLevel above, + /// this DOES read IAmdAdapter.SetSaturationOnDisplay's new bool return - the toggle path + /// is the one place a false success genuinely matters, since flipping suppression on a + /// write that never landed would strand the game at whatever level it was already at + /// while telling the engine (and the user) the opposite. + /// + /// Branches on affectPrimaryMonitorOnly, mirroring OnWinEventHook's own apply branch + /// above - unlike NVIDIA, the AMD apply is NOT single-display with the flag off (the + /// DEFAULT): it writes every attached screen via SetSaturationOnAllDisplays and records + /// all of them. A toggle that only ever touched deviceName would write one display back + /// to the Windows level while every other monitor stayed at the game's saturation - with + /// the balloon claiming the profile was restored - for as long as the user stays in the + /// suppressed game, since the suppression gate returns early on every later event. + /// + public ProfileToggleResult ToggleForegroundProfile(IntPtr foregroundWindow, string processName, string processImagePath) + { + ProfileToggleDecision decision = ProfileToggleHelper.Decide( + _applicationSettings, processName, processImagePath, _vibranceInfo.isWindowsLevelKnown); + + if (decision.Action == ProfileToggleAction.None) + { + return ProfileToggleResult.NoConfiguredGameInForeground; + } + if (decision.Action == ProfileToggleAction.EngineNotReady) + { + return ProfileToggleResult.EngineNotReady; + } + + string deviceName = Screen.FromHandle(foregroundWindow).DeviceName; + string name = decision.Setting.Name; + + if (decision.Action == ProfileToggleAction.ApplyGameLevel) + { + if (_vibranceInfo.affectPrimaryMonitorOnly) + { + if (!_amdAdapter.SetSaturationOnDisplay(decision.Setting.IngameLevel, deviceName)) + { + return ProfileToggleResult.WriteFailed; + } + // Only the game's own screen was written - that is the only display owing a + // restore. + VibranceRestoreHelper.RecordGameLevelApplied(deviceName); + } + else + { + // The identical write SetSaturationOnAllDisplays makes internally + // (AmdAdapter32/64.cs: "SetSaturationOnDisplay(vibranceLevel, null)"), but + // through the named-display overload so the new ADL_OK-based bool return + // survives for this method to actually check - see its own header comment. + if (!_amdAdapter.SetSaturationOnDisplay(decision.Setting.IngameLevel, null)) + { + return ProfileToggleResult.WriteFailed; + } + // This really did write every attached display, not just the game's own - + // every one of them is recorded as owing a restore, mirroring the automatic + // apply branch above. + foreach (Screen attachedScreen in Screen.AllScreens) + { + VibranceRestoreHelper.RecordGameLevelApplied(attachedScreen.DeviceName); + } + } + ProfileToggleHelper.SetSuppressed(name, false); + return ProfileToggleResult.ToggledOn; + } + + if (_vibranceInfo.affectPrimaryMonitorOnly) + { + if (!_amdAdapter.SetSaturationOnDisplay(_vibranceInfo.userVibranceSettingDefault, deviceName)) + { + return ProfileToggleResult.WriteFailed; + } + VibranceRestoreHelper.ClearGameLevelRecord(deviceName); + } + else + { + if (!_amdAdapter.SetSaturationOnDisplay(_vibranceInfo.userVibranceSettingDefault, null)) + { + return ProfileToggleResult.WriteFailed; + } + VibranceRestoreHelper.ClearAllGameLevelRecords(); + } + ProfileToggleHelper.SetSuppressed(name, true); + return ProfileToggleResult.ToggledOff; + } + private void RestoreWindowsColorSettings() { //restores every screen whose gamma ramp this application actually captured a baseline diff --git a/vibrance.GUI/AMD/vendor/AmdAdapter32.cs b/vibrance.GUI/AMD/vendor/AmdAdapter32.cs index 03d4a24..ab97f5a 100644 --- a/vibrance.GUI/AMD/vendor/AmdAdapter32.cs +++ b/vibrance.GUI/AMD/vendor/AmdAdapter32.cs @@ -124,21 +124,48 @@ public void SetSaturationOnAllDisplays(int vibranceLevel) this.SetSaturationOnDisplay(vibranceLevel, null); } - public void SetSaturationOnDisplay(int vibranceLevel, string displayName) + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) { + // matchedAny/allSucceeded are closed over by the handler below, the same way the + // pre-existing lambda already closes over vibranceLevel/displayName - SetSaturation + // itself stays a void-returning Action, only what its handler does with the result + // changes. "No display matched" (matchedAny stays false) must report false, not the + // vacuous "true" an empty loop would otherwise imply - see IAmdAdapter's own comment. + bool matchedAny = false; + bool allSucceeded = true; SetSaturation((adlDisplayInfo, adlAdapterInfo, adapterIndex) => { int infoValue = adlDisplayInfo.DisplayID.DisplayLogicalIndex; bool adapterIsAssociatedWithDisplay = adapterIndex == adlDisplayInfo.DisplayID.DisplayLogicalAdapterIndex; if (adapterIsAssociatedWithDisplay && (adlAdapterInfo.DisplayName == displayName || displayName == null)) { - Adl.AdlDisplayColorSet( + matchedAny = true; + + // Adl.AdlDisplayColorSet can be null - IsFunctionValid (ADLCheckLibrary.cs) + // failed to resolve "ADL_Display_Color_Set" from the driver's DLL. The + // pre-existing call below was unguarded against that (a latent NRE); guarded + // here since this line is already being touched for the status-code fix. + if (Adl.AdlDisplayColorSet == null) + { + allSucceeded = false; + return; + } + + // AdlSuccess (= 0) is ADL_OK - reusing the constant this file already defines + // and already checks every other ADL return code against, rather than adding + // a second name for the same value. + int adlStatus = Adl.AdlDisplayColorSet( adapterIndex, infoValue, Adl.AdlDisplayColorSaturation, vibranceLevel); + if (adlStatus != Adl.AdlSuccess) + { + allSucceeded = false; + } } }); + return matchedAny && allSucceeded; } private void SetSaturation(Action handle) diff --git a/vibrance.GUI/AMD/vendor/AmdAdapter64.cs b/vibrance.GUI/AMD/vendor/AmdAdapter64.cs index 1a614bb..78c9f16 100644 --- a/vibrance.GUI/AMD/vendor/AmdAdapter64.cs +++ b/vibrance.GUI/AMD/vendor/AmdAdapter64.cs @@ -124,21 +124,48 @@ public void SetSaturationOnAllDisplays(int vibranceLevel) this.SetSaturationOnDisplay(vibranceLevel, null); } - public void SetSaturationOnDisplay(int vibranceLevel, string displayName) + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) { + // matchedAny/allSucceeded are closed over by the handler below, the same way the + // pre-existing lambda already closes over vibranceLevel/displayName - SetSaturation + // itself stays a void-returning Action, only what its handler does with the result + // changes. "No display matched" (matchedAny stays false) must report false, not the + // vacuous "true" an empty loop would otherwise imply - see IAmdAdapter's own comment. + bool matchedAny = false; + bool allSucceeded = true; SetSaturation((adlDisplayInfo, adlAdapterInfo, adapterIndex) => { int infoValue = adlDisplayInfo.DisplayID.DisplayLogicalIndex; bool adapterIsAssociatedWithDisplay = adapterIndex == adlDisplayInfo.DisplayID.DisplayLogicalAdapterIndex; if (adapterIsAssociatedWithDisplay && (adlAdapterInfo.DisplayName == displayName || displayName == null)) { - Adl.AdlDisplayColorSet( + matchedAny = true; + + // Adl.AdlDisplayColorSet can be null - IsFunctionValid (ADLCheckLibrary.cs) + // failed to resolve "ADL_Display_Color_Set" from the driver's DLL. The + // pre-existing call below was unguarded against that (a latent NRE); guarded + // here since this line is already being touched for the status-code fix. + if (Adl.AdlDisplayColorSet == null) + { + allSucceeded = false; + return; + } + + // AdlSuccess (= 0) is ADL_OK - reusing the constant this file already defines + // and already checks every other ADL return code against, rather than adding + // a second name for the same value. + int adlStatus = Adl.AdlDisplayColorSet( adapterIndex, infoValue, Adl.AdlDisplayColorSaturation, vibranceLevel); + if (adlStatus != Adl.AdlSuccess) + { + allSucceeded = false; + } } }); + return matchedAny && allSucceeded; } private void SetSaturation(Action handle) diff --git a/vibrance.GUI/AMD/vendor/IAmdAdapter.cs b/vibrance.GUI/AMD/vendor/IAmdAdapter.cs index d812893..1378d78 100644 --- a/vibrance.GUI/AMD/vendor/IAmdAdapter.cs +++ b/vibrance.GUI/AMD/vendor/IAmdAdapter.cs @@ -9,7 +9,14 @@ public interface IAmdAdapter : IDisposable { void SetSaturationOnAllDisplays(int vibranceLevel); - void SetSaturationOnDisplay(int vibranceLevel, string displayName); + /// + /// True only when at least one display actually matched displayName (or, for the + /// SetSaturationOnAllDisplays fan-out, at least one display existed at all) AND every ADL + /// call for a matched display returned ADL_OK. "No display matched, so nothing was even + /// attempted" must report false, not true - see the implementations for why that + /// distinction was previously unbuildable (this method used to return void). + /// + bool SetSaturationOnDisplay(int vibranceLevel, string displayName); bool IsAvailable(); diff --git a/vibrance.GUI/NVIDIA/NvidiaDynamicVibranceProxy.cs b/vibrance.GUI/NVIDIA/NvidiaDynamicVibranceProxy.cs index 86e326e..6abbe84 100644 --- a/vibrance.GUI/NVIDIA/NvidiaDynamicVibranceProxy.cs +++ b/vibrance.GUI/NVIDIA/NvidiaDynamicVibranceProxy.cs @@ -271,6 +271,22 @@ private static void OnWinEventHook(object sender, WinEventHookEventArgs e) if (applicationSetting != null) { + if (ProfileToggleHelper.IsSuppressed(applicationSetting.Name)) + { + // Toggled off by hotkey (upstream #143). Ignore this foreground event for + // this game entirely - deliberately NOT a fall-through to the restore branch + // below: the toggle itself already restored this display + // (ToggleForegroundProfile), and re-running the work-list restore on every + // alt-tab into a suppressed game would reach displays this game never even + // touched (VibranceRestoreHelper.ComposeRestoreTargets is scoped to the + // whole work-list, not to this one game). + // + // Returns BEFORE "_gameScreen = screen" below: a suppressed game applies + // nothing here, so it must not become the screen a later resolution revert + // reasons about. + return; + } + Screen screen = Screen.FromHandle(e.Handle); _gameScreen = screen; @@ -536,33 +552,84 @@ private static bool AllDisplaysAtLevel(INvidiaVibranceDevice device, IList return true; } - private static void RestoreOneDisplay(INvidiaVibranceDevice device, string deviceName, int windowsLevel) + /// + /// Restores deviceName to windowsLevel, exactly as before - returns true only once the + /// level is CONFIRMED landed (already there, or a write just succeeded), false when it is + /// still owed (unresolvable handle, or a failed write). The pre-existing foreach call + /// site in RestoreWindowsVibranceLevel ignores this return value, so that path's own + /// behaviour is unchanged; ToggleForegroundProfile is the new caller that actually reads + /// it, to decide whether the toggle's suppression flip is safe to make. + /// + private static bool RestoreOneDisplay(INvidiaVibranceDevice device, string deviceName, int windowsLevel) { int displayHandle = device.TryResolveDisplayHandle(deviceName); if (displayHandle == -1 || displayHandle == 0) { LogDisplayFailureOnce(deviceName, string.Format( "Could not resolve an NVIDIA display handle for screen {0}, its Windows vibrance level restore will retry on the next foreground change", deviceName)); - return; // stays on the work-list - see the class-level comment above. + return false; // stays on the work-list - see the class-level comment above. } if (device.IsAtLevel(displayHandle, windowsLevel)) { VibranceRestoreHelper.ClearGameLevelRecord(deviceName); ClearDisplayFailureLog(deviceName); - return; + return true; } if (device.SetLevel(displayHandle, windowsLevel)) { VibranceRestoreHelper.ClearGameLevelRecord(deviceName); ClearDisplayFailureLog(deviceName); + return true; } - else + + LogDisplayFailureOnce(deviceName, string.Format( + "Failed to restore the Windows vibrance level for screen {0}, it will retry on the next foreground change", deviceName)); + return false; + } + + /// + /// See IVibranceProxy.ToggleForegroundProfile for the full contract. Decide (pure) picks + /// the direction from our own recorded suppression state, never from a display read-back; + /// this method is only the write plus the flip. The restore direction goes through + /// RestoreOneDisplay - never RestoreWindowsVibranceLevel, which would also walk the whole + /// work-list plus the primary and restore displays this one game never touched. + /// + public ProfileToggleResult ToggleForegroundProfile(IntPtr foregroundWindow, string processName, string processImagePath) + { + ProfileToggleDecision decision = ProfileToggleHelper.Decide( + _applicationSettings, processName, processImagePath, _vibranceInfo.isWindowsLevelKnown); + + if (decision.Action == ProfileToggleAction.None) { - LogDisplayFailureOnce(deviceName, string.Format( - "Failed to restore the Windows vibrance level for screen {0}, it will retry on the next foreground change", deviceName)); + return ProfileToggleResult.NoConfiguredGameInForeground; + } + if (decision.Action == ProfileToggleAction.EngineNotReady) + { + return ProfileToggleResult.EngineNotReady; + } + + string deviceName = Screen.FromHandle(foregroundWindow).DeviceName; + string name = decision.Setting.Name; + + if (decision.Action == ProfileToggleAction.ApplyGameLevel) + { + if (!ApplyGameVibranceLevel(_device, deviceName, decision.Setting.IngameLevel)) + { + return ProfileToggleResult.WriteFailed; + } + VibranceRestoreHelper.RecordGameLevelApplied(deviceName); + ProfileToggleHelper.SetSuppressed(name, false); + return ProfileToggleResult.ToggledOn; + } + + if (!RestoreOneDisplay(_device, deviceName, _vibranceInfo.userVibranceSettingDefault)) + { + return ProfileToggleResult.WriteFailed; } + ProfileToggleHelper.SetSuppressed(name, true); + return ProfileToggleResult.ToggledOff; } private static void LogDisplayFailureOnce(string deviceName, string message) @@ -593,6 +660,10 @@ internal static void ResetForTests(INvidiaVibranceDevice device, VibranceInfo vi _gameScreen = null; _loggedDisplayFailures.Clear(); VibranceRestoreHelper.ResetForTests(); + // The toggle hotkey's own suppression state (upstream #143) - reset here too so a + // fixture check that only calls ResetForTests, without separately remembering to + // call ProfileToggleHelper.ResetForTests() itself, still starts from a clean slate. + ProfileToggleHelper.ResetForTests(); } // The production INvidiaVibranceDevice: the four native calls below against the real diff --git a/vibrance.GUI/Program.cs b/vibrance.GUI/Program.cs index edbceb4..c7aa088 100644 --- a/vibrance.GUI/Program.cs +++ b/vibrance.GUI/Program.cs @@ -31,6 +31,7 @@ static class Program private const string GammaDisplaySelfTestMessageBoxCaption = "vibranceGUI gamma restore hardware self test"; private const string ResolutionSelfTestMessageBoxCaption = "vibranceGUI resolution change self test"; private const string VibranceSelfTestMessageBoxCaption = "vibranceGUI vibrance restore self test"; + private const string HotkeySelfTestMessageBoxCaption = "vibranceGUI toggle hotkey self test"; private const string DisplayDriverUninstallerUrl = "http://www.guru3d.com/files-details/display-driver-uninstaller-download.html"; [STAThread] @@ -134,6 +135,19 @@ static void Main(string[] args) return; } + // Same placement again: ProfileToggleFixture only ever drives HotkeyRegistration + // through a fake IHotkeyRegistrar, and the suppression-gate/toggle-write checks + // through the vendor proxies' own ResetForTests seams and fake devices - so, like + // --selftest-resolution and --selftest-vibrance, there is deliberately no hardware + // variant of this one, and there must never be one. See that fixture's own header + // comment. + if (args.Contains("--selftest-profiletoggle")) + { + MessageBox.Show(string.Join(Environment.NewLine, ProfileToggleFixture.Run().ToArray()), + HotkeySelfTestMessageBoxCaption, MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + // Unlike every self test above, this one can write to a real display's gamma ramp - it // runs the pure half first, then asks for confirmation before touching hardware, and // always restores what it found before returning. Opt in only; not part of diff --git a/vibrance.GUI/common/Definitions.cs b/vibrance.GUI/common/Definitions.cs index cc91d48..51ac8a4 100644 --- a/vibrance.GUI/common/Definitions.cs +++ b/vibrance.GUI/common/Definitions.cs @@ -19,6 +19,11 @@ public struct VibranceInfo public int userVibranceSettingDefault; public int userVibranceSettingActive; public String szGpuName; + // Written by SetShouldRun but never read back anywhere in this codebase - dead plumbing, + // not the toggle hotkey's per-game suppression state. That state is a per-profile set + // (ProfileToggleHelper._suppressedProfileNames, keyed by ApplicationSetting.Name), not a + // single global bool - a whole-engine pause was considered and rejected in favour of a + // per-game toggle, which this single field could never represent correctly. public bool shouldRun; public int sleepInterval; public List displayHandles; diff --git a/vibrance.GUI/common/HotkeyBinding.cs b/vibrance.GUI/common/HotkeyBinding.cs new file mode 100644 index 0000000..2d35419 --- /dev/null +++ b/vibrance.GUI/common/HotkeyBinding.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Windows.Forms; + +namespace vibrance.GUI.common +{ + /// + /// A parsed global hotkey binding: the RegisterHotKey modifier bits (already including + /// MOD_NOREPEAT - see HotkeyBindingParser.TryParse) plus the virtual-key code. Pure data - no + /// P/Invoke, no System.Runtime.InteropServices - so ProfileToggleFixture can construct and + /// compare these freely with no registrar seam involved at all. + /// + internal struct HotkeyBinding + { + internal uint Modifiers; + internal uint VirtualKey; + internal bool IsSet; + + // Same as the struct default (IsSet false) - written out as its own property so a caller + // never has to spell "default(HotkeyBinding)" or "new HotkeyBinding()" to mean "no + // binding configured". + internal static HotkeyBinding None + { + get { return new HotkeyBinding(); } + } + } + + /// + /// Parses/formats a HotkeyBinding to and from its canonical text, "Ctrl+Alt+Shift+Win+ + /// <KeyName>" - modifiers always in that fixed order, <KeyName> the Keys enum's + /// own name (e.g. "F9", "D1"). Round-trips: Format(TryParse(x)) == x for every canonical + /// string TryParse can produce. + /// + internal static class HotkeyBindingParser + { + // MOD_* (user32.h) - RegisterHotKey's own modifier bits. Not pulled from a shared Win32 + // constants file, because this codebase does not have one. + private const uint ModAlt = 0x0001; + private const uint ModControl = 0x0002; + private const uint ModShift = 0x0004; + private const uint ModWin = 0x0008; + + // MOD_NOREPEAT (user32.h, Vista+) - always OR'd into a parsed binding's Modifiers, never + // represented in the formatted text (see Format below). Without it, holding the key down + // fires WM_HOTKEY dozens of times a second and ToggleForegroundProfile flips the matched + // game's suppression state on every one of them. + internal const uint ModNoRepeat = 0x4000; + + private static readonly char[] Separator = { '+' }; + + /// + /// Parses text into binding. False (binding set to HotkeyBinding.None) for null, empty, + /// a binding with no key at all, an unrecognised token (never silently dropped), or a + /// purely numeric key token (Enum.TryParse<Keys>("1") would otherwise succeed as + /// Keys.LButton). Case-insensitive. + /// + internal static bool TryParse(string text, out HotkeyBinding binding) + { + binding = HotkeyBinding.None; + if (string.IsNullOrEmpty(text)) + { + return false; + } + + string[] tokens = text.Split(Separator, StringSplitOptions.None); + uint modifiers = 0; + Keys? key = null; + + foreach (string rawToken in tokens) + { + string token = rawToken.Trim(); + if (token.Length == 0) + { + // Catches both a trailing separator ("Ctrl+") and a doubled one. + return false; + } + + if (string.Equals(token, "Ctrl", StringComparison.OrdinalIgnoreCase)) + { + modifiers |= ModControl; + } + else if (string.Equals(token, "Alt", StringComparison.OrdinalIgnoreCase)) + { + modifiers |= ModAlt; + } + else if (string.Equals(token, "Shift", StringComparison.OrdinalIgnoreCase)) + { + modifiers |= ModShift; + } + else if (string.Equals(token, "Win", StringComparison.OrdinalIgnoreCase)) + { + modifiers |= ModWin; + } + else + { + if (key != null) + { + // A second non-modifier token - "Ctrl+F9+F10" - is an error, not a silent + // overwrite of the first. + return false; + } + + int numericProbe; + if (int.TryParse(token, out numericProbe)) + { + // Enum.TryParse("1") succeeds and yields Keys.LButton, since 1 is + // that value's underlying int - a purely numeric token is never a valid + // key name on its own. "D1"/"NumPad1" name the actual digit keys and are + // unaffected by this guard. + return false; + } + + Keys parsedKey; + if (!Enum.TryParse(token, true, out parsedKey) || !Enum.IsDefined(typeof(Keys), parsedKey)) + { + // An unrecognised token is an error - never silently dropped. + return false; + } + + // Keys packs modifier flags (Keys.Control, Keys.Shift, ...) into the same + // enum alongside KeyCode - masked off here so a key token can never smuggle a + // modifier bit past the fixed-order Modifiers this method already built above. + key = parsedKey & Keys.KeyCode; + } + } + + if (key == null) + { + // Modifiers with no key at all ("Ctrl", "Ctrl+Alt") is not a binding. + return false; + } + + HotkeyBinding parsed = new HotkeyBinding(); + parsed.Modifiers = modifiers | ModNoRepeat; + parsed.VirtualKey = (uint)key.Value; + parsed.IsSet = true; + binding = parsed; + return true; + } + + /// + /// The canonical text for binding, or "" for HotkeyBinding.None. Modifiers always appear + /// in the fixed Ctrl/Alt/Shift/Win order, regardless of the order TryParse originally + /// read them in; MOD_NOREPEAT is never represented here - see its own comment above. + /// + internal static string Format(HotkeyBinding binding) + { + if (!binding.IsSet) + { + return string.Empty; + } + + List parts = new List(); + if ((binding.Modifiers & ModControl) != 0) + { + parts.Add("Ctrl"); + } + if ((binding.Modifiers & ModAlt) != 0) + { + parts.Add("Alt"); + } + if ((binding.Modifiers & ModShift) != 0) + { + parts.Add("Shift"); + } + if ((binding.Modifiers & ModWin) != 0) + { + parts.Add("Win"); + } + parts.Add(((Keys)binding.VirtualKey).ToString()); + + return string.Join("+", parts.ToArray()); + } + } +} diff --git a/vibrance.GUI/common/HotkeyRegistration.cs b/vibrance.GUI/common/HotkeyRegistration.cs new file mode 100644 index 0000000..2490ba2 --- /dev/null +++ b/vibrance.GUI/common/HotkeyRegistration.cs @@ -0,0 +1,110 @@ +using System; + +namespace vibrance.GUI.common +{ + /// + /// Owns the lifecycle of the toggle hotkey's single OS registration. Apply/Release below are + /// the only two operations - deliberately not split into a "SetXxx" plus a separate + /// "RestoreXxx"-shaped pair: a two-call contract here could be invoked out of order, or with + /// the release half skipped, and silently leave a second registration behind. + /// + internal class HotkeyRegistration + { + // RegisterHotKey/UnregisterHotKey's own id parameter. One fixed value is enough - this + // application only ever registers a single hotkey; VibranceGUI.cs's WndProc dispatches + // WM_HOTKEY on wParam == HotkeyId. + internal const int HotkeyId = 1; + + /// + /// The single source of truth for "should a real OS registration exist right now" - + /// VibranceGUI.ApplyToggleHotkey calls this instead of inlining the ternary itself, so a + /// regression test can reach the actual gating expression production code runs (a fixture + /// cannot instantiate a real Form to reflect into ApplyToggleHotkey directly). The + /// checkbox gates registration, not just the presence of a saved binding - a binding can + /// be fully configured (and shown in the textbox) while the checkbox is still unchecked, + /// and must register nothing until the user turns it on. + /// + internal static HotkeyBinding EffectiveBinding(bool enabled, HotkeyBinding binding) + { + return (enabled && binding.IsSet) ? binding : HotkeyBinding.None; + } + + private readonly IHotkeyRegistrar _registrar; + + // The handle Apply last registered against - deliberately cached here rather than read + // back from the owning form when Release runs. See Release's own comment for why: the + // form's Handle is not stable for the form's lifetime. + private IntPtr _registeredHandle = IntPtr.Zero; + private bool _isRegistered; + + internal HotkeyRegistration(IHotkeyRegistrar registrar) + { + _registrar = registrar; + } + + internal bool IsRegistered + { + get { return _isRegistered; } + } + + /// + /// Releases whatever this instance currently has registered, then attempts to bind + /// against hWnd. Never leaves a stale registration behind: on any outcome other than + /// Registered, IsRegistered is false and nothing is registered - even if the PRIOR + /// binding (just released above) had succeeded. + /// + internal HotkeyRegistrationResult Apply(IntPtr hWnd, HotkeyBinding binding) + { + Release(); + + if (!binding.IsSet) + { + return HotkeyRegistrationResult.NotConfigured; + } + + HotkeyRegistrationResult result = _registrar.Register(hWnd, HotkeyId, binding.Modifiers, binding.VirtualKey); + + if (result == HotkeyRegistrationResult.Failed && (binding.Modifiers & HotkeyBindingParser.ModNoRepeat) != 0) + { + // Compatibility retry: some Windows builds reject MOD_NOREPEAT combined with + // certain virtual keys (ERROR_INVALID_PARAMETER, 87). IHotkeyRegistrar has no + // channel for the raw Win32 error code (see its own header comment), so this + // retries once, without the bit, on every generic Failed result - never on + // AlreadyOwnedByAnotherApplication, which is its own outcome and would fail the + // same way again regardless of MOD_NOREPEAT. + uint modifiersWithoutNoRepeat = binding.Modifiers & ~HotkeyBindingParser.ModNoRepeat; + result = _registrar.Register(hWnd, HotkeyId, modifiersWithoutNoRepeat, binding.VirtualKey); + } + + if (result == HotkeyRegistrationResult.Registered) + { + _registeredHandle = hWnd; + _isRegistered = true; + } + + return result; + } + + /// + /// Unregisters against the handle captured AT REGISTRATION TIME - never against a handle + /// passed in here, and never against the owning form's current Handle. Defensive, not a + /// fix for a confirmed live defect: a Form's Handle is not stable for its lifetime in + /// general - any property whose setter forces RecreateHandle (WinForms documents several) + /// would silently orphan a registration made against the old handle if Release ever read + /// a fresh one instead of the one Apply actually used. Caching it here is strictly more + /// correct than reading it fresh, independent of whether anything in THIS codebase + /// currently triggers a recreate. A no-op when nothing is currently registered. + /// + internal void Release() + { + if (!_isRegistered) + { + return; + } + + _registrar.Unregister(_registeredHandle, HotkeyId); + _isRegistered = false; + _registeredHandle = IntPtr.Zero; + } + } +} diff --git a/vibrance.GUI/common/IForegroundWindowReader.cs b/vibrance.GUI/common/IForegroundWindowReader.cs new file mode 100644 index 0000000..5447b58 --- /dev/null +++ b/vibrance.GUI/common/IForegroundWindowReader.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace vibrance.GUI.common +{ + /// + /// The seam between the toggle hotkey's WM_HOTKEY handler and the real foreground window - + /// same shape as IHotkeyRegistrar/IDisplayModeDevice/IGammaDevice: RealForegroundWindowReader + /// (below) is the only production implementation; ProfileToggleFixture never needs a fake of + /// this one at all, because it reflects into the real OnWinEventHook/ToggleForegroundProfile + /// with synthetic WinEventHookEventArgs/IntPtr values directly, the same way + /// VibranceRestoreFixture's N8 already does - unlike the six pre-existing AMD checks in that + /// file, none of ProfileToggleFixture's checks need a "did the real foreground window change + /// mid-test" Skip guard, because none of them read GetForegroundWindow() through this + /// interface at all. + /// + internal interface IForegroundWindowReader + { + bool TryGetForeground(out IntPtr hWnd, out string processName, out string processImagePath); + } + + /// + /// The only production IForegroundWindowReader - mirrors WinEventHook.WinEventProc's own + /// GetForegroundWindow/GetWindowThreadProcessId/PathResolver.TryGetProcessImagePath/ + /// Process.GetProcessById sequence and its same two tolerated exceptions (the process having + /// already exited between the two calls is not this class's problem to solve, only to not + /// crash on). + /// + internal class RealForegroundWindowReader : IForegroundWindowReader + { + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + public bool TryGetForeground(out IntPtr hWnd, out string processName, out string processImagePath) + { + hWnd = GetForegroundWindow(); + processName = null; + processImagePath = null; + + if (hWnd == IntPtr.Zero) + { + return false; + } + + uint processId; + GetWindowThreadProcessId(hWnd, out processId); + + // Same fallback WinEventHook.WinEventProc already applies: a protected or elevated + // process simply has no image path, not a failure worth aborting over. + if (!PathResolver.TryGetProcessImagePath((int)processId, out processImagePath)) + { + processImagePath = null; + } + + try + { + using (Process p = Process.GetProcessById((int)processId)) + { + processName = p.ProcessName; + } + } + catch (InvalidOperationException) + { + // The process property is not defined because the process has exited or it does + // not have an identifier. + return false; + } + catch (ArgumentException) + { + // The process specified by processId is not running. + return false; + } + + return true; + } + } +} diff --git a/vibrance.GUI/common/IHotkeyRegistrar.cs b/vibrance.GUI/common/IHotkeyRegistrar.cs new file mode 100644 index 0000000..604b3cf --- /dev/null +++ b/vibrance.GUI/common/IHotkeyRegistrar.cs @@ -0,0 +1,76 @@ +using System; +using System.Runtime.InteropServices; + +namespace vibrance.GUI.common +{ + /// + /// What Register actually did. Not a bool: a caller needs to tell "another application + /// already owns this exact key combination" apart from every other failure, since only the + /// first one is worth a different message to the user (and, at Apply's own call site, a + /// retry without MOD_NOREPEAT is worth attempting for the other case - see + /// HotkeyRegistration.Apply). + /// + internal enum HotkeyRegistrationResult + { + // No binding was configured at all - Register was never even called. + NotConfigured, + Registered, + AlreadyOwnedByAnotherApplication, + Failed + } + + /// + /// The seam between HotkeyRegistration's lifecycle/retry logic and the real + /// RegisterHotKey/UnregisterHotKey Win32 calls - same shape as IDisplayModeDevice + /// (ResolutionHelper.cs) and IGammaDevice (DeviceGammaRampHelper.cs): RealHotkeyRegistrar + /// (below) is the only production implementation; ProfileToggleFixture drives + /// HotkeyRegistration entirely against its own fake, so the regression suite never calls + /// RegisterHotKey for real - see that fixture's own header comment. + /// + internal interface IHotkeyRegistrar + { + HotkeyRegistrationResult Register(IntPtr hWnd, int id, uint modifiers, uint virtualKey); + void Unregister(IntPtr hWnd, int id); + } + + /// + /// The only production IHotkeyRegistrar - RegisterHotKey/UnregisterHotKey against a real + /// window handle. + /// + internal class RealHotkeyRegistrar : IHotkeyRegistrar + { + // ERROR_HOTKEY_ALREADY_REGISTERED (winerror.h) - another process already owns this exact + // (modifiers, virtualKey) combination. + private const int ErrorHotkeyAlreadyRegistered = 1409; + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + public HotkeyRegistrationResult Register(IntPtr hWnd, int id, uint modifiers, uint virtualKey) + { + bool succeeded = RegisterHotKey(hWnd, id, modifiers, virtualKey); + // Captured on the line immediately after the call, before anything else can + // overwrite the thread's last error - same discipline as Program.cs's + // adapterDetectionWin32Error capture. + int win32Error = Marshal.GetLastWin32Error(); + + if (succeeded) + { + return HotkeyRegistrationResult.Registered; + } + if (win32Error == ErrorHotkeyAlreadyRegistered) + { + return HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication; + } + return HotkeyRegistrationResult.Failed; + } + + public void Unregister(IntPtr hWnd, int id) + { + UnregisterHotKey(hWnd, id); + } + } +} diff --git a/vibrance.GUI/common/ISettingsController.cs b/vibrance.GUI/common/ISettingsController.cs index e62760d..57fa6e0 100644 --- a/vibrance.GUI/common/ISettingsController.cs +++ b/vibrance.GUI/common/ISettingsController.cs @@ -10,6 +10,10 @@ bool SetVibranceSettings(string windowsLevel, string affectPrimaryMonitorOnly, s bool SetVibranceSetting(string szKeyName, string value); GraphicsAdapter ReadGraphicsAdapterPreference(); bool SetGraphicsAdapterPreference(GraphicsAdapter graphicsAdapter); + string ReadToggleHotkey(); + bool SetToggleHotkey(string canonicalText); + bool ReadToggleHotkeyEnabled(); + bool SetToggleHotkeyEnabled(bool enabled); void ReadVibranceSettings(GraphicsAdapter graphicsAdapter, out int vibranceWindowsLevel, out bool affectPrimaryMonitorOnly, out bool neverSwitchResolution, out bool neverChangeColorSettings, out List applicationSettings, out int brightnessWindowsLevel, out int contrastWindowsLevel, out int gammaWindowsLevel); } diff --git a/vibrance.GUI/common/IVibranceProxy.cs b/vibrance.GUI/common/IVibranceProxy.cs index efe55c2..5aa75ff 100644 --- a/vibrance.GUI/common/IVibranceProxy.cs +++ b/vibrance.GUI/common/IVibranceProxy.cs @@ -1,8 +1,33 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using vibrance.GUI.NVIDIA; namespace vibrance.GUI.common { + /// + /// What ToggleForegroundProfile actually did - see IVibranceProxy.ToggleForegroundProfile's + /// own comment for the full contract. Public, not internal, because IVibranceProxy itself is + /// public and every type any of its members exposes has to be at least as accessible + /// (VibranceInfo and GraphicsAdapter are both public for the same reason). + /// + public enum ProfileToggleResult + { + // No configured profile matches the foreground window at all - a silent no-op. + NoConfiguredGameInForeground, + // A profile matched, but userVibranceSettingDefault is not known yet - a silent no-op, + // exactly as if nothing had matched (see VibranceInfo.isWindowsLevelKnown). + EngineNotReady, + // The matched profile is now running its game level again (it was suppressed before this + // call). + ToggledOn, + // The matched profile is now suppressed, forced to the Windows level (it was running + // normally before this call). + ToggledOff, + // A profile matched and was ready, but the write itself failed - suppression state is + // left exactly as it was; the caller may retry by pressing the hotkey again. + WriteFailed + } + public interface IVibranceProxy { void SetApplicationSettings(List refApplicationSettings); @@ -21,5 +46,17 @@ public interface IVibranceProxy void SetWindowsColorBrightness(int brightness); void SetWindowsColorContrast(int contrast); void SetWindowsColorGamma(int gamma); + + /// + /// Looks up whichever configured profile currently owns foregroundWindow + /// (ApplicationSettingMatcher.FindMatch, the same match rule the automatic WinEvent + /// handler uses) and flips it between its game level and the Windows level - see + /// ProfileToggleHelper.Decide for the pure decision this method turns into an actual + /// write. No match, or a profile matched too early for userVibranceSettingDefault to mean + /// anything yet, is a silent no-op: zero writes, suppression state untouched. The write + /// happens BEFORE suppression state ever flips - a failed write never leaves the engine + /// thinking a toggle landed that did not. + /// + ProfileToggleResult ToggleForegroundProfile(IntPtr foregroundWindow, string processName, string processImagePath); } } \ No newline at end of file diff --git a/vibrance.GUI/common/ProfileToggleFixture.cs b/vibrance.GUI/common/ProfileToggleFixture.cs new file mode 100644 index 0000000..0dded6b --- /dev/null +++ b/vibrance.GUI/common/ProfileToggleFixture.cs @@ -0,0 +1,1607 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Serialization; +using System.Windows.Forms; +using vibrance.GUI.AMD; +using vibrance.GUI.AMD.vendor; +using vibrance.GUI.NVIDIA; + +namespace vibrance.GUI.common +{ + /// + /// Regression coverage for the toggle hotkey feature (upstream #143, per-game suppression): + /// HotkeyBindingParser's parse/format round trip, HotkeyRegistration's release-then-register + /// lifecycle (R3 in particular - pins the release-then-register ordering across a handle + /// change, see HotkeyRegistration.Release's own comment for why the handle is cached rather + /// than read fresh), ProfileToggleHelper.Decide's pure decision, the per-game suppression gate both + /// vendor proxies' OnWinEventHook now open with, ToggleForegroundProfile's actual write, and + /// the settings round trip. No GUI, no live GPU driver, and - unlike every check here that + /// reflects into a real OnWinEventHook/ToggleForegroundProfile - this fixture NEVER calls the + /// real RegisterHotKey and must never grow a hardware variant: IHotkeyRegistrar is driven + /// exclusively through FakeHotkeyRegistrar below, exactly as VibranceRestoreFixture never + /// touches a real GPU and ResolutionChangeFixture never touches a real display. Run by + /// vibrance.GUI.exe --selftest-profiletoggle. + /// + public static class ProfileToggleFixture + { + [DllImport("user32.dll")] + private static extern IntPtr GetDesktopWindow(); + + public static List Run() + { + Checklist checklist = new Checklist(); + checklist.Lines.Add("vibranceGUI toggle hotkey self test"); + checklist.Lines.Add(string.Empty); + + RunParseFormatChecks(checklist); + RunRegistrationChecks(checklist); + RunDecideChecks(checklist); + RunToggleEffectChecks(checklist); + RunSuppressionGateChecks(checklist); + RunSuppressionCleanupChecks(checklist); + RunSettingsChecks(checklist); + + checklist.Lines.Add(string.Empty); + checklist.Lines.Add(string.Format("PASSED {0}/{1}", checklist.Passed, checklist.Total)); + return checklist.Lines; + } + + // ------------------------------------------------------------------ + // HotkeyBindingParser - pure, no registrar, no proxy. Unchanged from the discarded global- + // pause design; kept verbatim per the architect's instruction that this half survives. + // ------------------------------------------------------------------ + + private static void RunParseFormatChecks(Checklist checklist) + { + checklist.Lines.Add("HotkeyBindingParser.TryParse/Format (pure):"); + + CheckParseBasic(checklist); + CheckRoundTrip(checklist); + CheckInvalidInputsRejected(checklist); + CheckUnrecognisedTokenRejected(checklist); + CheckNumericTokenRejected(checklist); + CheckFormatNone(checklist); + CheckCaseInsensitiveParseNormalisedFormat(checklist); + CheckNoRepeatNeverFormatted(checklist); + + checklist.Lines.Add(string.Empty); + } + + // H1. Mutation this guards: drop "| ModNoRepeat" from TryParse. + private static void CheckParseBasic(Checklist checklist) + { + HotkeyBinding binding; + bool ok = HotkeyBindingParser.TryParse("Ctrl+Alt+F9", out binding); + const uint expectedModifiers = 0x0002 /* MOD_CONTROL */ | 0x0001 /* MOD_ALT */ | 0x4000 /* MOD_NOREPEAT */; + const uint expectedVirtualKey = 0x78; // VK_F9 + + checklist.Check(ok && binding.IsSet && binding.Modifiers == expectedModifiers && binding.VirtualKey == expectedVirtualKey, + string.Format("H1: \"Ctrl+Alt+F9\" parses to Modifiers=0x{0:X} (MOD_CONTROL|MOD_ALT|MOD_NOREPEAT), VirtualKey=0x{1:X} (VK_F9), got ok={2} Modifiers=0x{3:X} VirtualKey=0x{4:X}", + expectedModifiers, expectedVirtualKey, ok, binding.Modifiers, binding.VirtualKey)); + } + + // H2. Mutation this guards: emit the modifiers in TryParse's own read order instead of a + // fixed Ctrl/Alt/Shift/Win order in Format. + private static readonly string[] CanonicalStrings = + { + "F9", + "Ctrl+F9", + "Alt+F5", + "Shift+F12", + "Win+D1", + "Ctrl+Alt+Shift+Win+F9" + }; + + private static void CheckRoundTrip(Checklist checklist) + { + bool allRoundTrip = true; + string firstMismatch = null; + foreach (string canonical in CanonicalStrings) + { + HotkeyBinding binding; + bool parsed = HotkeyBindingParser.TryParse(canonical, out binding); + string formatted = HotkeyBindingParser.Format(binding); + if (!parsed || formatted != canonical) + { + allRoundTrip = false; + firstMismatch = string.Format("\"{0}\" -> parsed={1}, formatted=\"{2}\"", canonical, parsed, formatted); + break; + } + } + + checklist.Check(allRoundTrip, allRoundTrip + ? "H2: Format(TryParse(s)) == s over all 6 canonical strings" + : "H2: Format(TryParse(s)) == s over all 6 canonical strings - first mismatch: " + firstMismatch); + } + + // H3. Mutation this guards: drop the "no key at all" guard, letting "Ctrl" parse as a + // legal binding. + private static void CheckInvalidInputsRejected(Checklist checklist) + { + HotkeyBinding binding; + bool emptyOk = HotkeyBindingParser.TryParse("", out binding); + bool nullOk = HotkeyBindingParser.TryParse(null, out binding); + bool ctrlOnlyOk = HotkeyBindingParser.TryParse("Ctrl", out binding); + bool trailingSeparatorOk = HotkeyBindingParser.TryParse("Ctrl+", out binding); + + checklist.Check(!emptyOk && !nullOk && !ctrlOnlyOk && !trailingSeparatorOk, + string.Format("H3: \"\", null, \"Ctrl\", \"Ctrl+\" all fail to parse, got empty={0} null={1} ctrlOnly={2} trailingSeparator={3}", + emptyOk, nullOk, ctrlOnlyOk, trailingSeparatorOk)); + } + + // H4. Mutation this guards: silently drop an unrecognised token instead of failing. + private static void CheckUnrecognisedTokenRejected(Checklist checklist) + { + HotkeyBinding binding; + bool ok = HotkeyBindingParser.TryParse("Ctrl+Zzz+F9", out binding); + + checklist.Check(!ok, string.Format( + "H4: \"Ctrl+Zzz+F9\" fails to parse - an unrecognised token is an error, never silently dropped, got {0}", ok)); + } + + // H5. Mutation this guards: drop the numeric-token guard, letting Enum.TryParse + // turn "1" into Keys.LButton. + private static void CheckNumericTokenRejected(Checklist checklist) + { + HotkeyBinding numericBinding; + bool numericOk = HotkeyBindingParser.TryParse("Ctrl+1", out numericBinding); + + HotkeyBinding namedDigitBinding; + bool namedDigitOk = HotkeyBindingParser.TryParse("Ctrl+D1", out namedDigitBinding); + + checklist.Check(!numericOk, + string.Format("H5a: \"Ctrl+1\" fails to parse - a purely numeric token is never a valid key name on its own, got {0}", numericOk)); + checklist.Check(namedDigitOk && namedDigitBinding.VirtualKey == 0x31, + string.Format("H5b: \"Ctrl+D1\" parses with VirtualKey=0x31 (VK_1), got ok={0} VirtualKey=0x{1:X}", namedDigitOk, namedDigitBinding.VirtualKey)); + } + + // H6. Mutation this guards: return a non-empty placeholder for an unset binding instead + // of "". + private static void CheckFormatNone(Checklist checklist) + { + string formatted = HotkeyBindingParser.Format(HotkeyBinding.None); + checklist.Check(formatted == string.Empty, string.Format("H6: Format(HotkeyBinding.None) == \"\", got \"{0}\"", formatted)); + } + + // H7. Mutation this guards: compare tokens with StringComparison.Ordinal (case-sensitive) + // instead of OrdinalIgnoreCase. + private static void CheckCaseInsensitiveParseNormalisedFormat(Checklist checklist) + { + HotkeyBinding binding; + bool ok = HotkeyBindingParser.TryParse("ctrl+alt+f9", out binding); + string formatted = HotkeyBindingParser.Format(binding); + + checklist.Check(ok && formatted == "Ctrl+Alt+F9", + string.Format("H7: \"ctrl+alt+f9\" parses and formats back as the normalised \"Ctrl+Alt+F9\", got ok={0} formatted=\"{1}\"", ok, formatted)); + } + + // H8. Mutation this guards: add MOD_NOREPEAT to the list of modifier parts Format emits. + private static void CheckNoRepeatNeverFormatted(Checklist checklist) + { + HotkeyBinding binding; + HotkeyBindingParser.TryParse("Ctrl+F9", out binding); + bool noRepeatSetInternally = (binding.Modifiers & HotkeyBindingParser.ModNoRepeat) != 0; + string formatted = HotkeyBindingParser.Format(binding); + + checklist.Check(noRepeatSetInternally && formatted == "Ctrl+F9", + string.Format("H8: MOD_NOREPEAT is set internally (Modifiers=0x{0:X}) but the formatted text is exactly \"Ctrl+F9\", got \"{1}\"", + binding.Modifiers, formatted)); + } + + // ------------------------------------------------------------------ + // HotkeyRegistration - against FakeHotkeyRegistrar, never RegisterHotKey. R1-R8 unchanged + // from the discarded design; R9/R10 are new, covering the checkbox-gated registration + // expression VibranceGUI.ApplyToggleHotkey now uses. + // ------------------------------------------------------------------ + + private static void RunRegistrationChecks(Checklist checklist) + { + checklist.Lines.Add("HotkeyRegistration.Apply/Release (via FakeHotkeyRegistrar, never the real RegisterHotKey):"); + + CheckApplyNoneMakesNoRegisterCall(checklist); + CheckApplyValidBindingRegistersOnce(checklist); + CheckApplyOnNewHandleReleasesThePriorOne(checklist); + CheckApplyPropagatesAlreadyOwned(checklist); + CheckReleaseAfterFailedApplyMakesNoUnregisterCall(checklist); + CheckReleaseOnFreshInstanceMakesNoCalls(checklist); + CheckApplyNoneAfterSuccessReleasesWithoutRegistering(checklist); + CheckModNoRepeatCompatibilityRetry(checklist); + CheckCheckboxOffSuppressesRegistrationOfAValidBinding(checklist); + CheckCheckboxOnWithNoBindingMakesNoCall(checklist); + CheckShouldReleaseHotkeyOnFocusTransition(checklist); + + checklist.Lines.Add(string.Empty); + } + + private static HotkeyBinding ParseOrThrow(string canonicalText) + { + HotkeyBinding binding; + if (!HotkeyBindingParser.TryParse(canonicalText, out binding)) + { + throw new InvalidOperationException("Fixture setup error: \"" + canonicalText + "\" failed to parse"); + } + return binding; + } + + // R1. Mutation this guards: call _registrar.Register before checking binding.IsSet. + private static void CheckApplyNoneMakesNoRegisterCall(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, HotkeyBinding.None); + + checklist.Check(result == HotkeyRegistrationResult.NotConfigured && registrar.RegisterCalls.Count == 0, + string.Format("R1: Apply(h, None) returns NotConfigured with zero Register calls, got result={0} registerCalls={1}", + result, registrar.RegisterCalls.Count)); + } + + // R2 (PIN - not regression evidence, the ordinary successful path). + private static void CheckApplyValidBindingRegistersOnce(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + HotkeyBinding binding = ParseOrThrow("Ctrl+Alt+F9"); + + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, binding); + + checklist.Check(result == HotkeyRegistrationResult.Registered && registrar.RegisterCalls.Count == 1 && registration.IsRegistered, + string.Format("R2 (pin): Apply(h, validBinding) makes exactly one Register call and returns Registered, got result={0} registerCalls={1} isRegistered={2}", + result, registrar.RegisterCalls.Count, registration.IsRegistered)); + } + + // R3, the highest-value check in this file. Mutation this guards: have Release() (called + // from inside Apply) read a fresh "hWnd" parameter instead of the handle cached at + // registration time. Pins the release-then-register ordering across a handle change - + // see HotkeyRegistration.Release's own comment for why caching it is the defensive + // choice regardless of whether anything in this codebase currently recreates the handle. + private static void CheckApplyOnNewHandleReleasesThePriorOne(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + IntPtr h1 = (IntPtr)111; + IntPtr h2 = (IntPtr)222; + HotkeyBinding a = ParseOrThrow("Ctrl+F9"); + HotkeyBinding b = ParseOrThrow("Alt+F10"); + + registration.Apply(h1, a); + registration.Apply(h2, b); + + bool sequenceMatches = registrar.RegisterCalls.Count == 2 && + registrar.RegisterCalls[0].HWnd == h1 && + registrar.RegisterCalls[1].HWnd == h2 && + registrar.UnregisterCalls.Count == 1 && + registrar.UnregisterCalls[0] == h1; + + checklist.Check(sequenceMatches, string.Format( + "R3: Apply(h1,a) then Apply(h2,b) records Register(h1), Unregister(h1), Register(h2) in that order - the release-then-register ordering across a handle change - got {0} Register call(s) on [{1}], {2} Unregister call(s) on [{3}]", + registrar.RegisterCalls.Count, DescribeHandles(registrar.RegisterCalls), + registrar.UnregisterCalls.Count, DescribeHandles(registrar.UnregisterCalls))); + } + + private static string DescribeHandles(List calls) + { + List parts = new List(); + foreach (FakeHotkeyRegistrar.RegisterCall call in calls) + { + parts.Add(call.HWnd.ToString()); + } + return string.Join(",", parts.ToArray()); + } + + private static string DescribeHandles(List handles) + { + List parts = new List(); + foreach (IntPtr handle in handles) + { + parts.Add(handle.ToString()); + } + return string.Join(",", parts.ToArray()); + } + + // R4. Mutation this guards: treat AlreadyOwnedByAnotherApplication the same as Registered. + private static void CheckApplyPropagatesAlreadyOwned(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + registrar.QueueResult(HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + HotkeyBinding binding = ParseOrThrow("Ctrl+F9"); + + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, binding); + + checklist.Check(result == HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication && !registration.IsRegistered, + string.Format("R4: AlreadyOwnedByAnotherApplication propagates and IsRegistered stays false, got result={0} isRegistered={1}", + result, registration.IsRegistered)); + } + + // R5. Mutation this guards: drop Release's "if (!_isRegistered) return;" guard. + private static void CheckReleaseAfterFailedApplyMakesNoUnregisterCall(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + registrar.QueueResult(HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + registration.Apply((IntPtr)1, ParseOrThrow("Ctrl+F9")); + + registration.Release(); + + checklist.Check(registrar.UnregisterCalls.Count == 0, + string.Format("R5: Release() after a failed Apply makes zero Unregister calls, got {0}", registrar.UnregisterCalls.Count)); + } + + // R6. Same guard as R5, exercised on a completely untouched instance. + private static void CheckReleaseOnFreshInstanceMakesNoCalls(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + + registration.Release(); + + checklist.Check(registrar.RegisterCalls.Count == 0 && registrar.UnregisterCalls.Count == 0, + string.Format("R6: Release() on a fresh instance makes zero calls, got register={0} unregister={1}", + registrar.RegisterCalls.Count, registrar.UnregisterCalls.Count)); + } + + // R7. Mutation this guards: still call Register with a zeroed binding when applying None. + private static void CheckApplyNoneAfterSuccessReleasesWithoutRegistering(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + IntPtr h = (IntPtr)1; + registration.Apply(h, ParseOrThrow("Ctrl+F9")); + int registerCallsBefore = registrar.RegisterCalls.Count; + + HotkeyRegistrationResult result = registration.Apply(h, HotkeyBinding.None); + + checklist.Check(result == HotkeyRegistrationResult.NotConfigured && + registrar.UnregisterCalls.Count == 1 && + registrar.RegisterCalls.Count == registerCallsBefore, + string.Format("R7: Apply(h, None) after a success makes one Unregister call and zero new Register calls, got result={0} unregisterCalls={1} newRegisterCalls={2}", + result, registrar.UnregisterCalls.Count, registrar.RegisterCalls.Count - registerCallsBefore)); + } + + // R8. Mutation this guards: drop the MOD_NOREPEAT compatibility retry entirely. + private static void CheckModNoRepeatCompatibilityRetry(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + registrar.QueueResult(HotkeyRegistrationResult.Failed); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + HotkeyBinding binding = ParseOrThrow("Ctrl+F9"); + + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, binding); + + bool secondCallDroppedNoRepeat = registrar.RegisterCalls.Count == 2 && + (registrar.RegisterCalls[0].Modifiers & HotkeyBindingParser.ModNoRepeat) != 0 && + (registrar.RegisterCalls[1].Modifiers & HotkeyBindingParser.ModNoRepeat) == 0; + + checklist.Check(result == HotkeyRegistrationResult.Registered && secondCallDroppedNoRepeat, + string.Format("R8: a Failed first Register call (MOD_NOREPEAT set) retries exactly once more without MOD_NOREPEAT and succeeds, got result={0} registerCalls={1}", + result, registrar.RegisterCalls.Count)); + } + + // R9. Mutation this guards: gate registration on binding.IsSet alone, ignoring "enabled" - + // the checkbox-off state VibranceGUI.ApplyToggleHotkey must respect. Drives the REAL + // production gate, HotkeyRegistration.EffectiveBinding - not a fixture-local copy of it; + // VibranceGUI.ApplyToggleHotkey calls this exact static method, so a mutation here is + // visible to the actual code path, not just to a mirror of it. + private static void CheckCheckboxOffSuppressesRegistrationOfAValidBinding(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + HotkeyBinding validBinding = ParseOrThrow("Ctrl+Alt+F9"); + + HotkeyBinding effective = HotkeyRegistration.EffectiveBinding(false, validBinding); + HotkeyRegistrationResult result = registration.Apply((IntPtr)1, effective); + + checklist.Check(result == HotkeyRegistrationResult.NotConfigured && registrar.RegisterCalls.Count == 0, + string.Format("R9: checkbox off + a valid saved combination makes zero Register calls, got result={0} registerCalls={1}", + result, registrar.RegisterCalls.Count)); + } + + // R10. Mutation this guards: throw (e.g. dereferencing a field on HotkeyBinding.None + // incorrectly) instead of cleanly returning NotConfigured when enabled but unbound. Same + // real-gate note as R9 above. + private static void CheckCheckboxOnWithNoBindingMakesNoCall(Checklist checklist) + { + FakeHotkeyRegistrar registrar = new FakeHotkeyRegistrar(); + HotkeyRegistration registration = new HotkeyRegistration(registrar); + + bool threw = false; + HotkeyRegistrationResult result = HotkeyRegistrationResult.Failed; + try + { + HotkeyBinding effective = HotkeyRegistration.EffectiveBinding(true, HotkeyBinding.None); + result = registration.Apply((IntPtr)1, effective); + } + catch (Exception) + { + threw = true; + } + + checklist.Check(!threw && result == HotkeyRegistrationResult.NotConfigured && registrar.RegisterCalls.Count == 0, + string.Format("R10: checkbox on + no combination makes zero calls and never throws, got threw={0} result={1} registerCalls={2}", + threw, result, registrar.RegisterCalls.Count)); + } + + // B1. Drives VibranceGUI.ShouldReleaseHotkeyOnFocusTransition directly (same assembly, + // internal - no reflection needed) rather than a real Form: VibranceGUI's own constructor + // calls getProxy(...), touching a real vendor proxy, and FormatterServices. + // GetUninitializedObject is not safe for a Form-derived type (WinForms/Control internal + // state that only the real constructor sets up). This is the condition + // OnDeactivate/OnActivated both call before touching the hotkey registration - a + // real Form correctly wiring WM_ACTIVATE to those two overrides is a WinForms framework + // fact, not something this harness can verify headlessly, but the DECISION they both make + // is real production code and is what this pins. + // + // Mutation this guards: invert the comparison (or hardcode true/false) so OnDeactivate/ + // OnActivated stop distinguishing "the capture box has focus" from "anything else does". + private static void CheckShouldReleaseHotkeyOnFocusTransition(Checklist checklist) + { + TextBox captureBox = new TextBox(); + TextBox otherControl = new TextBox(); + + bool trueForTheCaptureBoxItself = VibranceGUI.ShouldReleaseHotkeyOnFocusTransition(captureBox, captureBox); + bool falseForADifferentControl = !VibranceGUI.ShouldReleaseHotkeyOnFocusTransition(otherControl, captureBox); + bool falseForNoActiveControlAtAll = !VibranceGUI.ShouldReleaseHotkeyOnFocusTransition(null, captureBox); + + checklist.Check(trueForTheCaptureBoxItself && falseForADifferentControl && falseForNoActiveControlAtAll, + string.Format("B1: ShouldReleaseHotkeyOnFocusTransition is true only when the capture box itself is the active control - got captureBox={0}, otherControl={1}, null={2}", + trueForTheCaptureBoxItself, !falseForADifferentControl, !falseForNoActiveControlAtAll)); + } + + // ------------------------------------------------------------------ + // ProfileToggleHelper.Decide - pure, no device, no Screen, no OS call. + // ------------------------------------------------------------------ + + private static void RunDecideChecks(Checklist checklist) + { + checklist.Lines.Add("ProfileToggleHelper.Decide (pure):"); + + CheckDecideMatchesByInstallDirectory(checklist); + CheckDecideNoMatch(checklist); + CheckDecideEmptyAndNullLists(checklist); + CheckDecideEngineNotReady(checklist); + CheckDecideDirectionBothWays(checklist); + CheckDecideMutatesNothing(checklist); + CheckDecideSuppressionIsCaseInsensitive(checklist); + CheckDecideNameBeatsLongerDirectoryMatch(checklist); + + checklist.Lines.Add(string.Empty); + } + + // D1. Mutation this guards: match on setting.Name alone, ignoring processImagePath - PR + // #153's bug. A profile the game finder only ever confirmed by install directory (a + // guessed executable, or a launcher/anti-cheat shim in the foreground instead of the game + // itself) must be just as reachable by the hotkey as by the automatic apply path. + private static void CheckDecideMatchesByInstallDirectory(Checklist checklist) + { + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "RealGameExeName"; + setting.InstallDirectory = "C:\\Games\\SomeGame"; + setting.IngameLevel = 40; + List settings = new List { setting }; + + ProfileToggleDecision decision = ProfileToggleHelper.Decide( + settings, "launcher", "C:\\Games\\SomeGame\\bin\\launcher.exe", true); + + checklist.Check(decision.Action == ProfileToggleAction.RestoreWindowsLevel && decision.Setting == setting, + string.Format("D1: a process whose name does not match but whose image path sits under the setting's InstallDirectory is still found, got Action={0}", + decision.Action)); + } + + // D2. Mutation this guards: return something other than None (e.g. throw, or default to + // the first setting) when nothing matches at all. + private static void CheckDecideNoMatch(Checklist checklist) + { + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGame"; + List settings = new List { setting }; + + ProfileToggleDecision decision = ProfileToggleHelper.Decide(settings, "UnrelatedProcess", null, true); + + checklist.Check(decision.Action == ProfileToggleAction.None, + string.Format("D2: no match at all -> None, got {0}", decision.Action)); + } + + // D3. Mutation this guards: dereference the list without a null check. + private static void CheckDecideEmptyAndNullLists(Checklist checklist) + { + bool threw = false; + ProfileToggleAction emptyAction = ProfileToggleAction.None; + ProfileToggleAction nullAction = ProfileToggleAction.None; + try + { + emptyAction = ProfileToggleHelper.Decide(new List(), "Anything", null, true).Action; + nullAction = ProfileToggleHelper.Decide(null, "Anything", null, true).Action; + } + catch (Exception) + { + threw = true; + } + + checklist.Check(!threw && emptyAction == ProfileToggleAction.None && nullAction == ProfileToggleAction.None, + string.Format("D3: an empty list and a null list both yield None with no throw, got threw={0} emptyAction={1} nullAction={2}", + threw, emptyAction, nullAction)); + } + + // D4. Mutation this guards: skip the isWindowsLevelKnown check and fall through to a + // real Action even when the Windows level is still the struct default 0 - reopens + // issue #60/#36 through the toggle's own door. + private static void CheckDecideEngineNotReady(Checklist checklist) + { + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGame"; + List settings = new List { setting }; + + ProfileToggleDecision decision = ProfileToggleHelper.Decide(settings, "SomeGame", null, false); + + checklist.Check(decision.Action == ProfileToggleAction.EngineNotReady, + string.Format("D4: a clean Name match with isWindowsLevelKnown false -> EngineNotReady, not a write action, got {0}", decision.Action)); + } + + // D5. Mutation this guards: invert (or hard-code) the direction rule. + private static void CheckDecideDirectionBothWays(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGameD5"; + List settings = new List { setting }; + + ProfileToggleDecision notSuppressed = ProfileToggleHelper.Decide(settings, "SomeGameD5", null, true); + ProfileToggleHelper.SetSuppressed("SomeGameD5", true); + ProfileToggleDecision suppressed = ProfileToggleHelper.Decide(settings, "SomeGameD5", null, true); + + checklist.Check(notSuppressed.Action == ProfileToggleAction.RestoreWindowsLevel && suppressed.Action == ProfileToggleAction.ApplyGameLevel, + string.Format("D5: not suppressed -> RestoreWindowsLevel, suppressed -> ApplyGameLevel, got notSuppressed={0} suppressed={1}", + notSuppressed.Action, suppressed.Action)); + + ProfileToggleHelper.ResetForTests(); + } + + // D6. Mutation this guards: have Decide itself call SetSuppressed (or otherwise mutate + // state) instead of leaving the flip to the caller, after a confirmed write. + private static void CheckDecideMutatesNothing(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGameD6"; + List settings = new List { setting }; + + int countBefore = ProfileToggleHelper.SuppressedCount; + ProfileToggleAction first = ProfileToggleHelper.Decide(settings, "SomeGameD6", null, true).Action; + ProfileToggleAction second = ProfileToggleHelper.Decide(settings, "SomeGameD6", null, true).Action; + ProfileToggleAction third = ProfileToggleHelper.Decide(settings, "SomeGameD6", null, true).Action; + int countAfter = ProfileToggleHelper.SuppressedCount; + + checklist.Check(countBefore == countAfter && first == second && second == third, + string.Format("D6: three identical Decide calls leave SuppressedCount unchanged ({0} -> {1}) and return the same Action every time ({2},{3},{4})", + countBefore, countAfter, first, second, third)); + } + + // D7. Mutation this guards: compare suppressed names with StringComparison.Ordinal. + private static void CheckDecideSuppressionIsCaseInsensitive(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + ApplicationSetting setting = new ApplicationSetting(); + setting.Name = "SomeGameD7"; + List settings = new List { setting }; + + ProfileToggleHelper.SetSuppressed("somegamed7", true); + ProfileToggleDecision decision = ProfileToggleHelper.Decide(settings, "SomeGameD7", null, true); + + checklist.Check(decision.Action == ProfileToggleAction.ApplyGameLevel, + string.Format("D7: suppressing \"somegamed7\" (different case) still suppresses the setting named \"SomeGameD7\", got {0}", decision.Action)); + + ProfileToggleHelper.ResetForTests(); + } + + // D8 (PIN). A Name match beats a longer directory match belonging to a DIFFERENT setting - + // proves Decide's pass-through of ApplicationSettingMatcher.FindMatch's own two-pass rule, + // not a Decide-specific behaviour. + private static void CheckDecideNameBeatsLongerDirectoryMatch(Checklist checklist) + { + ApplicationSetting nameMatch = new ApplicationSetting(); + nameMatch.Name = "TargetProcess"; + nameMatch.InstallDirectory = null; + + ApplicationSetting directoryMatch = new ApplicationSetting(); + directoryMatch.Name = "SomeOtherName"; + directoryMatch.InstallDirectory = "C:\\Games\\SomeGame\\A\\Much\\Longer\\Nested\\Directory"; + + List settings = new List { directoryMatch, nameMatch }; + + ProfileToggleDecision decision = ProfileToggleHelper.Decide( + settings, "TargetProcess", "C:\\Games\\SomeGame\\A\\Much\\Longer\\Nested\\Directory\\TargetProcess.exe", true); + + checklist.Check(decision.Setting == nameMatch, + "D8 (pin): an exact Name match wins over a longer InstallDirectory match belonging to a different setting"); + } + + // ------------------------------------------------------------------ + // ToggleForegroundProfile's actual write - both vendors, via ResetForTests/fake + // adapters/GetUninitializedObject. NVIDIA's ToggleForegroundProfile is an instance method + // purely because IVibranceProxy requires one; it (like OnWinEventHook) touches only this + // class's own static state, so GetUninitializedObject hands back a target to invoke it + // against without ever running the real constructor (which would call vibranceDLL.dll's + // initializeLibrary()). AMD's own state is per-instance, so its checks construct a real + // proxy around a fake IAmdAdapter instead (IAmdAdapter.IsAvailable() returning false keeps + // the constructor from installing a real, process-lifetime SetWinEventHook). + // ------------------------------------------------------------------ + + private static void RunToggleEffectChecks(Checklist checklist) + { + checklist.Lines.Add("ToggleForegroundProfile's write (both vendors, via fakes):"); + + CheckNvidiaToggleWritesOnlyTheGamesOwnDisplay(checklist); + CheckAmdToggleNeverWidensToAllDisplays(checklist); + CheckNvidiaToggleNoMatchMakesNoCallsOrStateChange(checklist); + CheckAmdToggleNoMatchMakesNoCallsOrStateChange(checklist); + CheckNvidiaToggleOnWriteFailureLeavesStateUnchanged(checklist); + CheckAmdToggleOnWriteFailureLeavesStateUnchanged(checklist); + CheckNvidiaToggleOffWriteFailureLeavesStateUnchanged(checklist); + CheckAmdToggleOffWriteFailureLeavesStateUnchanged(checklist); + CheckNvidiaToggleOffThenOnRoundTrip(checklist); + CheckNvidiaToggleOffOfNeverAppliedGameStillSucceeds(checklist); + CheckAmdToggleOffRespectsWideMode(checklist); + CheckAmdToggleOnRespectsWideMode(checklist); + + checklist.Lines.Add(string.Empty); + } + + private static object NewNvidiaInstance() + { + return FormatterServices.GetUninitializedObject(typeof(NvidiaDynamicVibranceProxy)); + } + + private static ProfileToggleResult InvokeNvidiaToggle(IntPtr hWnd, string processName, string processImagePath) + { + MethodInfo m = typeof(NvidiaDynamicVibranceProxy).GetMethod("ToggleForegroundProfile"); + return (ProfileToggleResult)m.Invoke(NewNvidiaInstance(), new object[] { hWnd, processName, processImagePath }); + } + + // T1, the check that matters most here. The work-list is seeded with BOTH dGame and + // dOther as GENUINELY held (VibranceRestoreHelper.RecordGameLevelApplied for both) - + // seeding dOther as a real work-list entry, not an unused decoy, is what makes the + // widening mutation below actually produce a second write; a decoy never on the work-list + // would leave that wrong implementation passing by accident. + // + // Mutation this guards: route the toggle's restore write through + // RestoreWindowsVibranceLevel instead of RestoreOneDisplay - the former walks the WHOLE + // work-list plus the primary, so it would write dOther too. + private static void CheckNvidiaToggleWritesOnlyTheGamesOwnDisplay(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT1"; + matchingSetting.IngameLevel = 50; + List settings = new List { matchingSetting }; + + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + const string dOther = "\\\\.\\DISPLAY_TESTONLY_T1_OTHER"; + VibranceRestoreHelper.RecordGameLevelApplied(dGame); + VibranceRestoreHelper.RecordGameLevelApplied(dOther); + + ProfileToggleResult result = InvokeNvidiaToggle(desktop, "TestGameT1", null); + + bool witness1 = device.SetLevelCalls.Count == 1 && device.SetLevelCalls[0] == device.HandleFor(dGame); + bool witness2 = device.ResolvedDeviceNames.Count == 1 && device.ResolvedDeviceNames[0] == dGame; + bool witness3 = VibranceRestoreHelper.HoldingCount == 1; + + checklist.Check(result == ProfileToggleResult.ToggledOff && witness1 && witness2 && witness3, + string.Format("T1: toggling off writes ONLY the game's own display (1 SetLevel call on dGame, 1 resolved device, dOther left on the work-list -> HoldingCount 1), got result={0} SetLevelCalls={1} ResolvedDeviceNames={2} HoldingCount={3}", + result, device.SetLevelCalls.Count, device.ResolvedDeviceNames.Count, VibranceRestoreHelper.HoldingCount)); + } + + // T2, AMD's counterpart. Mutation this guards: route the toggle's restore write through + // SetSaturationOnAllDisplays (the "likeliest wrong implementation") instead of + // SetSaturationOnDisplay(vibranceLevel, deviceName). + private static void CheckAmdToggleNeverWidensToAllDisplays(Checklist checklist) + { + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT2"; + matchingSetting.IngameLevel = 200; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + // Narrow mode - B2 (off)/(on) below cover the wide-mode branch this proxy also has to + // respect; this check is specifically about the narrow, single-display overload. + proxy.SetAffectPrimaryMonitorOnly(true); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameT2", null); + + checklist.Check(result == ProfileToggleResult.ToggledOff && + adapter.SetSaturationOnAllDisplaysCallCount == 0 && + adapter.SetSaturationOnDisplayNames.Count == 1 && adapter.SetSaturationOnDisplayNames[0] == dGame, + string.Format("T2: toggling off an AMD profile never calls SetSaturationOnAllDisplays (got {0} call(s)) and writes SetSaturationOnDisplay exactly once, to the game's own display, got result={1} perDisplayCalls={2}", + adapter.SetSaturationOnAllDisplaysCallCount, result, adapter.SetSaturationOnDisplayNames.Count)); + } + + // T3n. Mutation this guards: fall through to some write even when Decide returned None. + private static void CheckNvidiaToggleNoMatchMakesNoCallsOrStateChange(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, new List()); + + ProfileToggleResult result = InvokeNvidiaToggle(GetDesktopWindow(), "SomeOtherProcessT3", null); + + checklist.Check(result == ProfileToggleResult.NoConfiguredGameInForeground && device.TotalCallCount == 0 && ProfileToggleHelper.SuppressedCount == 0, + string.Format("T3n: no configured game in the foreground makes zero device calls and zero suppression-state change, got result={0} calls={1} suppressedCount={2}", + result, device.TotalCallCount, ProfileToggleHelper.SuppressedCount)); + } + + // T3a, AMD's counterpart. + private static void CheckAmdToggleNoMatchMakesNoCallsOrStateChange(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, new List()); + proxy.SetVibranceWindowsLevel(90); + + ProfileToggleResult result = proxy.ToggleForegroundProfile(GetDesktopWindow(), "SomeOtherProcessT3", null); + + checklist.Check(result == ProfileToggleResult.NoConfiguredGameInForeground && + adapter.SetSaturationOnAllDisplaysCallCount == 0 && adapter.SetSaturationOnDisplayNames.Count == 0 && + ProfileToggleHelper.SuppressedCount == 0, + string.Format("T3a: no configured game in the foreground makes zero adapter calls and zero suppression-state change, got result={0} allCalls={1} perDisplayCalls={2} suppressedCount={3}", + result, adapter.SetSaturationOnAllDisplaysCallCount, adapter.SetSaturationOnDisplayNames.Count, ProfileToggleHelper.SuppressedCount)); + } + + // T4n (ON-path failure). The game is already suppressed; toggling it back on fails to + // write - must report WriteFailed and leave it suppressed. Mutation this guards: flip + // suppression before checking the write's own return value. + private static void CheckNvidiaToggleOnWriteFailureLeavesStateUnchanged(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT4"; + matchingSetting.IngameLevel = 50; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameT4", true); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + device.FailNextSetLevel(dGame); + + ProfileToggleResult result = InvokeNvidiaToggle(desktop, "TestGameT4", null); + + checklist.Check(result == ProfileToggleResult.WriteFailed && ProfileToggleHelper.IsSuppressed("TestGameT4"), + string.Format("T4n: a failed game-level write on the ON path returns WriteFailed and leaves the profile suppressed, got result={0} isSuppressed={1}", + result, ProfileToggleHelper.IsSuppressed("TestGameT4"))); + + ProfileToggleHelper.ResetForTests(); + } + + // T4a, AMD's counterpart. + private static void CheckAmdToggleOnWriteFailureLeavesStateUnchanged(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT4a"; + matchingSetting.IngameLevel = 210; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + // Narrow mode - B2 (on) already covers the wide-mode write; this check is about the + // write-failure/state-preservation contract, not which overload gets called, so it is + // pinned to the mode whose failure injection targets a specific display name. + proxy.SetAffectPrimaryMonitorOnly(true); + ProfileToggleHelper.SetSuppressed("TestGameT4a", true); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + adapter.FailNextSetSaturationOnDisplay(dGame); + + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameT4a", null); + + checklist.Check(result == ProfileToggleResult.WriteFailed && ProfileToggleHelper.IsSuppressed("TestGameT4a"), + string.Format("T4a: a failed game-level write on the ON path returns WriteFailed and leaves the profile suppressed, got result={0} isSuppressed={1}", + result, ProfileToggleHelper.IsSuppressed("TestGameT4a"))); + + ProfileToggleHelper.ResetForTests(); + } + + // T5n (OFF-path failure). The game is running normally; toggling it off fails to write - + // must report WriteFailed and leave it un-suppressed. + private static void CheckNvidiaToggleOffWriteFailureLeavesStateUnchanged(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT5"; + matchingSetting.IngameLevel = 50; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + device.FailNextSetLevel(dGame); + + ProfileToggleResult result = InvokeNvidiaToggle(desktop, "TestGameT5", null); + + checklist.Check(result == ProfileToggleResult.WriteFailed && !ProfileToggleHelper.IsSuppressed("TestGameT5"), + string.Format("T5n: a failed Windows-level write on the OFF path returns WriteFailed and leaves the profile NOT suppressed, got result={0} isSuppressed={1}", + result, ProfileToggleHelper.IsSuppressed("TestGameT5"))); + } + + // T5a, AMD's counterpart. + private static void CheckAmdToggleOffWriteFailureLeavesStateUnchanged(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT5a"; + matchingSetting.IngameLevel = 210; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + // Narrow mode - see the matching comment in CheckAmdToggleOnWriteFailureLeavesStateUnchanged. + proxy.SetAffectPrimaryMonitorOnly(true); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + adapter.FailNextSetSaturationOnDisplay(dGame); + + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameT5a", null); + + checklist.Check(result == ProfileToggleResult.WriteFailed && !ProfileToggleHelper.IsSuppressed("TestGameT5a"), + string.Format("T5a: a failed Windows-level write on the OFF path returns WriteFailed and leaves the profile NOT suppressed, got result={0} isSuppressed={1}", + result, ProfileToggleHelper.IsSuppressed("TestGameT5a"))); + + ProfileToggleHelper.ResetForTests(); + } + + // T6. A full off-then-on round trip: two writes, two different levels, ends un-suppressed. + private static void CheckNvidiaToggleOffThenOnRoundTrip(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT6"; + matchingSetting.IngameLevel = 55; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 20; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + IntPtr desktop = GetDesktopWindow(); + + ProfileToggleResult offResult = InvokeNvidiaToggle(desktop, "TestGameT6", null); + ProfileToggleResult onResult = InvokeNvidiaToggle(desktop, "TestGameT6", null); + + checklist.Check(offResult == ProfileToggleResult.ToggledOff && onResult == ProfileToggleResult.ToggledOn && !ProfileToggleHelper.IsSuppressed("TestGameT6"), + string.Format("T6: off-then-on returns ToggledOff then ToggledOn and ends un-suppressed, got off={0} on={1} isSuppressed={2}", + offResult, onResult, ProfileToggleHelper.IsSuppressed("TestGameT6"))); + + bool sawBothLevels = device.SetLevelCalls.Count == 2; + checklist.Check(sawBothLevels, + string.Format("T6: exactly two SetLevel calls landed across the round trip (one per direction), got {0}", device.SetLevelCalls.Count)); + } + + // T8. A game that was suppressed WITHOUT ever having its game level actually applied + // (SetLevel never called for it before) still toggles off successfully via the read-back + // alone, when the display already happens to be at the Windows level. + private static void CheckNvidiaToggleOffOfNeverAppliedGameStillSucceeds(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameT8"; + matchingSetting.IngameLevel = 50; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 30; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + IntPtr desktop = GetDesktopWindow(); + string dGame = Screen.FromHandle(desktop).DeviceName; + device.SeedLevel(dGame, 30); // already at the Windows level - as if never actually applied to. + + ProfileToggleResult result = InvokeNvidiaToggle(desktop, "TestGameT8", null); + + checklist.Check(result == ProfileToggleResult.ToggledOff && device.SetLevelCalls.Count == 0 && ProfileToggleHelper.IsSuppressed("TestGameT8"), + string.Format("T8: toggling off a display already at the Windows level makes zero SetLevel calls (confirmed by read-back alone) but still reports ToggledOff and suppresses, got result={0} setLevelCalls={1} isSuppressed={2}", + result, device.SetLevelCalls.Count, ProfileToggleHelper.IsSuppressed("TestGameT8"))); + } + + // B2 (off). AMD's apply is NOT single-display with affectPrimaryMonitorOnly false (the + // DEFAULT) - unlike NVIDIA, it writes every attached screen (OnWinEventHook's own apply + // branch above). Mutation this guards: ignore the flag in the OFF direction and always + // write/clear only the one named display. Witnessed two ways: the adapter call itself + // must be the WIDE overload (SetSaturationOnDisplay(level, null), never + // SetSaturationOnAllDisplays - so the bool return still gets checked), and a work-list + // entry for a display OTHER than the resolved one must still be cleared - the narrow + // ClearGameLevelRecord(deviceName) would leave it behind, only the wide + // ClearAllGameLevelRecords() drains it. + private static void CheckAmdToggleOffRespectsWideMode(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + VibranceRestoreHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameB2Off"; + matchingSetting.IngameLevel = 220; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + // affectPrimaryMonitorOnly left at the struct default (false) - AMD's actual default, + // and the wide mode this check means to exercise. + proxy.SetVibranceWindowsLevel(90); + + const string otherDevice = "\\\\.\\DISPLAY_TESTONLY_B2OFF_OTHER"; + VibranceRestoreHelper.RecordGameLevelApplied(otherDevice); + + IntPtr desktop = GetDesktopWindow(); + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameB2Off", null); + + bool usedWideOverload = adapter.SetSaturationOnAllDisplaysCallCount == 0 && + adapter.SetSaturationOnDisplayNames.Count == 1 && adapter.SetSaturationOnDisplayNames[0] == null && + adapter.SetSaturationOnDisplayLevels[0] == 90; + + checklist.Check(result == ProfileToggleResult.ToggledOff && usedWideOverload && VibranceRestoreHelper.HoldingCount == 0, + string.Format("B2 (off): with affectPrimaryMonitorOnly false, toggling off calls SetSaturationOnDisplay(90, null) - never SetSaturationOnAllDisplays - and drains the WHOLE work-list (a display never named in the call), got result={0} allDisplaysCalls={1} perDisplayCalls={2} lastName={3} HoldingCount={4}", + result, adapter.SetSaturationOnAllDisplaysCallCount, adapter.SetSaturationOnDisplayNames.Count, + adapter.SetSaturationOnDisplayNames.Count > 0 ? (adapter.SetSaturationOnDisplayNames[0] ?? "null") : "(none)", + VibranceRestoreHelper.HoldingCount)); + + ProfileToggleHelper.ResetForTests(); + VibranceRestoreHelper.ResetForTests(); + } + + // B2 (on). The mirror-image defect: toggling a suppressed game back ON with the flag + // false must write (and record as owing a restore) every attached display, exactly like + // the automatic apply branch does - not just the screen the toggle happened to resolve + // foregroundWindow to. Witnessed via the real Screen.AllScreens count, so this passes + // regardless of how many monitors are actually attached to the machine running it. + private static void CheckAmdToggleOnRespectsWideMode(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + VibranceRestoreHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameB2On"; + matchingSetting.IngameLevel = 230; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + ProfileToggleHelper.SetSuppressed("TestGameB2On", true); + + IntPtr desktop = GetDesktopWindow(); + ProfileToggleResult result = proxy.ToggleForegroundProfile(desktop, "TestGameB2On", null); + + bool usedWideOverload = adapter.SetSaturationOnAllDisplaysCallCount == 0 && + adapter.SetSaturationOnDisplayNames.Count == 1 && adapter.SetSaturationOnDisplayNames[0] == null && + adapter.SetSaturationOnDisplayLevels[0] == 230; + int expectedHoldingCount = Screen.AllScreens.Length; + + checklist.Check(result == ProfileToggleResult.ToggledOn && usedWideOverload && VibranceRestoreHelper.HoldingCount == expectedHoldingCount, + string.Format("B2 (on): with affectPrimaryMonitorOnly false, toggling on calls SetSaturationOnDisplay(230, null) - never SetSaturationOnAllDisplays - and records EVERY attached screen as owing a restore ({0} on this machine), got result={1} allDisplaysCalls={2} perDisplayCalls={3} lastName={4} HoldingCount={5}", + expectedHoldingCount, result, adapter.SetSaturationOnAllDisplaysCallCount, adapter.SetSaturationOnDisplayNames.Count, + adapter.SetSaturationOnDisplayNames.Count > 0 ? (adapter.SetSaturationOnDisplayNames[0] ?? "null") : "(none)", + VibranceRestoreHelper.HoldingCount)); + + ProfileToggleHelper.ResetForTests(); + VibranceRestoreHelper.ResetForTests(); + } + + private static AmdDynamicVibranceProxy BuildAmdProxy(FakeAmdAdapter adapter, List settings) + { + Dictionary>> windowsResolutionSettings = + new Dictionary>>(); + AmdDynamicVibranceProxy proxy = new AmdDynamicVibranceProxy(adapter, settings, windowsResolutionSettings); + proxy.SetNeverChangeColorSettings(true); + proxy.SetNeverSwitchResolution(true); + return proxy; + } + + // ------------------------------------------------------------------ + // Suppression gate - the real, private OnWinEventHook by reflection, both vendors. + // ------------------------------------------------------------------ + + private static void RunSuppressionGateChecks(Checklist checklist) + { + checklist.Lines.Add("Per-game suppression gate at the top of the apply branch (real OnWinEventHook via reflection, both vendors):"); + + CheckNvidiaSuppressedGameMakesNoCalls(checklist); + CheckNvidiaGameScreenUnchangedWhenSuppressed(checklist); + CheckNvidiaDifferentUnsuppressedGameStillApplies(checklist); + CheckNvidiaRestoreBranchStillRunsWhileSomethingIsSuppressed(checklist); + CheckNvidiaNotSuppressedAppliesNormally(checklist); + CheckAmdSuppressedGameMakesNoCalls(checklist); + + checklist.Lines.Add(string.Empty); + } + + // ------------------------------------------------------------------ + // Stranded-suppression cleanup - VibranceGUI.ClearSuppressionIfNameChanged, the real + // production method both the "remove program" and "Change executable..." call sites now + // route through (same assembly, internal - no reflection needed). Repro this closes: a + // game A gets suppressed, its profile is later removed (or edited so its Name changes), + // and a brand new, UNRELATED profile that happens to resolve to the same Name would + // otherwise silently start suppressed with no action from that user. + // ------------------------------------------------------------------ + + private static void RunSuppressionCleanupChecks(Checklist checklist) + { + checklist.Lines.Add("Stranded-suppression cleanup (VibranceGUI.ClearSuppressionIfNameChanged, real method, no reflection):"); + + CheckClearSuppressionOnRemoval(checklist); + CheckClearSuppressionOnRename(checklist); + CheckClearSuppressionLeavesADifferentSuppressionAlone(checklist); + + checklist.Lines.Add(string.Empty); + } + + // Mutation this guards: drop the removal call site's use of ClearSuppressionIfNameChanged + // entirely (newName: null models "this profile no longer exists at all"). + private static void CheckClearSuppressionOnRemoval(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + + ApplicationSetting removedSetting = new ApplicationSetting(); + removedSetting.Name = "TestGameCleanupRemoved"; + ProfileToggleHelper.SetSuppressed("TestGameCleanupRemoved", true); + + VibranceGUI.ClearSuppressionIfNameChanged(removedSetting, null); + + checklist.Check(!ProfileToggleHelper.IsSuppressed("TestGameCleanupRemoved"), + "Cleanup-1: removing a suppressed profile (newName: null) clears its suppression - a later, unrelated profile that resolves to the same Name must not silently inherit it"); + + ProfileToggleHelper.ResetForTests(); + } + + // Mutation this guards: compare FileName instead of Name (or skip the comparison and + // never clear at all) when deciding whether "Change executable..." actually moved this + // profile off its old suppression key. + private static void CheckClearSuppressionOnRename(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + + ApplicationSetting oldSetting = new ApplicationSetting(); + oldSetting.Name = "TestGameCleanupOldName"; + ProfileToggleHelper.SetSuppressed("TestGameCleanupOldName", true); + + VibranceGUI.ClearSuppressionIfNameChanged(oldSetting, "TestGameCleanupNewName"); + + checklist.Check(!ProfileToggleHelper.IsSuppressed("TestGameCleanupOldName"), + "Cleanup-2: \"Change executable...\" moving a suppressed profile to a new Name clears the suppression recorded under the OLD Name"); + + ProfileToggleHelper.ResetForTests(); + } + + // Mutation this guards: clear suppression unconditionally (or key it on something other + // than Name), wiping out an unrelated profile's own, legitimate suppression. + private static void CheckClearSuppressionLeavesADifferentSuppressionAlone(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + + ApplicationSetting oldSetting = new ApplicationSetting(); + oldSetting.Name = "TestGameCleanupA"; + ProfileToggleHelper.SetSuppressed("TestGameCleanupA", true); + ProfileToggleHelper.SetSuppressed("TestGameCleanupB", true); + + VibranceGUI.ClearSuppressionIfNameChanged(oldSetting, "TestGameCleanupANewName"); + + checklist.Check(!ProfileToggleHelper.IsSuppressed("TestGameCleanupA") && ProfileToggleHelper.IsSuppressed("TestGameCleanupB"), + "Cleanup-3: clearing profile A's suppression on rename leaves a DIFFERENT profile B's own suppression untouched"); + + ProfileToggleHelper.ResetForTests(); + } + + private static void InvokeNvidiaOnWinEventHook(string processName, IntPtr handle) + { + MethodInfo onWinEventHook = typeof(NvidiaDynamicVibranceProxy).GetMethod( + "OnWinEventHook", BindingFlags.NonPublic | BindingFlags.Static); + WinEventHookEventArgs args = new WinEventHookEventArgs { Handle = handle, ProcessName = processName, ProcessImagePath = null }; + onWinEventHook.Invoke(null, new object[] { null, args }); + } + + private static Screen GetNvidiaGameScreen() + { + FieldInfo f = typeof(NvidiaDynamicVibranceProxy).GetField("_gameScreen", BindingFlags.NonPublic | BindingFlags.Static); + return (Screen)f.GetValue(null); + } + + private static void SetNvidiaGameScreen(Screen value) + { + FieldInfo f = typeof(NvidiaDynamicVibranceProxy).GetField("_gameScreen", BindingFlags.NonPublic | BindingFlags.Static); + f.SetValue(null, value); + } + + // G1. Mutation this guards: remove the suppression gate entirely. + private static void CheckNvidiaSuppressedGameMakesNoCalls(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameG1"; + matchingSetting.IngameLevel = 40; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameG1", true); + + InvokeNvidiaOnWinEventHook("TestGameG1", GetDesktopWindow()); + + checklist.Check(device.TotalCallCount == 0, + string.Format("G1: a suppressed game's own foreground event makes zero device calls, got {0}", device.TotalCallCount)); + + ProfileToggleHelper.ResetForTests(); + } + + // G2. Mutation this guards: place the gate AFTER "_gameScreen = screen;" instead of + // before it - _gameScreen is forced to a known baseline (null) first, so this does not + // depend on how many real monitors are attached. + private static void CheckNvidiaGameScreenUnchangedWhenSuppressed(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameG2"; + matchingSetting.IngameLevel = 40; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameG2", true); + SetNvidiaGameScreen(null); + + InvokeNvidiaOnWinEventHook("TestGameG2", GetDesktopWindow()); + + checklist.Check(GetNvidiaGameScreen() == null, + "G2: _gameScreen is left exactly as it was (null) when the matched game is suppressed - a gate placed AFTER \"_gameScreen = screen\" would leave it non-null instead"); + + ProfileToggleHelper.ResetForTests(); + } + + // G3. Mutation this guards: key the gate on "any suppression exists at all" + // (ProfileToggleHelper.SuppressedCount > 0) instead of THIS setting's own name. + private static void CheckNvidiaDifferentUnsuppressedGameStillApplies(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting suppressedSetting = new ApplicationSetting(); + suppressedSetting.Name = "TestGameG3Suppressed"; + suppressedSetting.IngameLevel = 40; + ApplicationSetting unsuppressedSetting = new ApplicationSetting(); + unsuppressedSetting.Name = "TestGameG3Unsuppressed"; + unsuppressedSetting.IngameLevel = 60; + List settings = new List { suppressedSetting, unsuppressedSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameG3Suppressed", true); + + InvokeNvidiaOnWinEventHook("TestGameG3Unsuppressed", GetDesktopWindow()); + + checklist.Check(device.SetLevelCalls.Count == 1, + string.Format("G3: a DIFFERENT, unsuppressed game still applies normally while another profile is suppressed, got {0} SetLevel call(s)", device.SetLevelCalls.Count)); + + ProfileToggleHelper.ResetForTests(); + } + + // G4. Mutation this guards: gate the WHOLE handler (both branches) on any suppression + // existing, the shape of the discarded global-pause design's gate. + private static void CheckNvidiaRestoreBranchStillRunsWhileSomethingIsSuppressed(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting suppressedSetting = new ApplicationSetting(); + suppressedSetting.Name = "TestGameG4"; + suppressedSetting.IngameLevel = 40; + List settings = new List { suppressedSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + vibranceInfo.isWindowsLevelKnown = true; + vibranceInfo.userVibranceSettingDefault = 25; + // Scopes RestoreWindowsVibranceLevel to the work-list + primary + // (ComposeRestoreTargets/RestoreOneDisplay) - the branch this check actually means to + // exercise. Left at the struct default (false), it takes the OTHER restore branch + // instead, which only ever consults displayHandles (null here) and drains the whole + // work-list via ClearAllGameLevelRecords() without writing anything at all - a false + // "restore branch does nothing" that has nothing to do with the suppression gate. + vibranceInfo.affectPrimaryMonitorOnly = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + ProfileToggleHelper.SetSuppressed("TestGameG4", true); + + const string worklistDevice = "\\\\.\\DISPLAY_TESTONLY_G4"; + VibranceRestoreHelper.RecordGameLevelApplied(worklistDevice); + + // A non-matching process name (desktop, explorer, ...) drives the restore ("else") + // branch, not the apply branch the suppression gate sits in. + InvokeNvidiaOnWinEventHook("explorer", GetDesktopWindow()); + + checklist.Check(device.SetLevelCalls.Count > 0 && VibranceRestoreHelper.HoldingCount == 0, + string.Format("G4: the restore branch still runs (and drains the work-list) while a DIFFERENT profile is suppressed, got {0} SetLevel call(s), HoldingCount={1}", + device.SetLevelCalls.Count, VibranceRestoreHelper.HoldingCount)); + + ProfileToggleHelper.ResetForTests(); + } + + // G5 (PIN). The ordinary, not-suppressed apply path still works once the gate exists. + private static void CheckNvidiaNotSuppressedAppliesNormally(Checklist checklist) + { + FakeNvidiaVibranceDevice device = new FakeNvidiaVibranceDevice(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameG5"; + matchingSetting.IngameLevel = 40; + List settings = new List { matchingSetting }; + VibranceInfo vibranceInfo = new VibranceInfo(); + vibranceInfo.neverChangeResolution = true; + vibranceInfo.neverChangeColorSettings = true; + NvidiaDynamicVibranceProxy.ResetForTests(device, vibranceInfo, settings); + + InvokeNvidiaOnWinEventHook("TestGameG5", GetDesktopWindow()); + + checklist.Check(device.SetLevelCalls.Count == 1, + string.Format("G5 (pin): not suppressed + a matched game applies the ingame level as today, got {0} SetLevel call(s)", device.SetLevelCalls.Count)); + } + + // G6, AMD's counterpart of G1. + private static void CheckAmdSuppressedGameMakesNoCalls(Checklist checklist) + { + ProfileToggleHelper.ResetForTests(); + FakeAmdAdapter adapter = new FakeAmdAdapter(); + ApplicationSetting matchingSetting = new ApplicationSetting(); + matchingSetting.Name = "TestGameG6"; + matchingSetting.IngameLevel = 200; + List settings = new List { matchingSetting }; + AmdDynamicVibranceProxy proxy = BuildAmdProxy(adapter, settings); + proxy.SetVibranceWindowsLevel(90); + ProfileToggleHelper.SetSuppressed("TestGameG6", true); + + MethodInfo onWinEventHook = typeof(AmdDynamicVibranceProxy).GetMethod("OnWinEventHook", BindingFlags.NonPublic | BindingFlags.Instance); + WinEventHookEventArgs args = new WinEventHookEventArgs { Handle = GetDesktopWindow(), ProcessName = "TestGameG6", ProcessImagePath = null }; + onWinEventHook.Invoke(proxy, new object[] { null, args }); + + checklist.Check(adapter.SetSaturationOnAllDisplaysCallCount == 0 && adapter.SetSaturationOnDisplayNames.Count == 0, + string.Format("G6: a suppressed AMD game's own foreground event makes zero adapter calls, got allCalls={0} perDisplayCalls={1}", + adapter.SetSaturationOnAllDisplaysCallCount, adapter.SetSaturationOnDisplayNames.Count)); + + ProfileToggleHelper.ResetForTests(); + } + + // ------------------------------------------------------------------ + // Settings round trip - a temp INI/XML pair, deleted in a finally, NEVER the user's real + // "%APPDATA%\vibranceGUI\vibranceGUI.ini". Both keys: toggleHotkey (canonical text) and + // toggleHotkeyEnabled (bool). + // ------------------------------------------------------------------ + + private static void RunSettingsChecks(Checklist checklist) + { + checklist.Lines.Add("SettingsController round trip, both keys (temp INI, never the user's real one):"); + + CheckSettingsRoundTripBothKeys(checklist); + CheckSettingsMissingKeysReadDefaults(checklist); + CheckSettingsCorruptValuesDoNotThrow(checklist); + + checklist.Lines.Add(string.Empty); + } + + private static string NewTempPath(string extension) + { + return Path.Combine(Path.GetTempPath(), "vibranceGUI-hotkey-fixture-" + Guid.NewGuid().ToString("N") + extension); + } + + private static void DeleteFileIfExists(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception) + { + // Best-effort temp file cleanup only - never let this mask a check's own result. + } + } + + // S1. Mutation this guards: have SetToggleHotkey/SetToggleHotkeyEnabled write under the + // wrong key name, or the readers read a different one. + private static void CheckSettingsRoundTripBothKeys(Checklist checklist) + { + string tempIni = NewTempPath(".ini"); + string tempXml = NewTempPath(".xml"); + try + { + SettingsController controller = new SettingsController(tempIni, tempXml); + bool wroteBinding = controller.SetToggleHotkey("Ctrl+Alt+F9"); + bool wroteEnabled = controller.SetToggleHotkeyEnabled(true); + string readBackBinding = controller.ReadToggleHotkey(); + bool readBackEnabled = controller.ReadToggleHotkeyEnabled(); + + checklist.Check(wroteBinding && wroteEnabled && readBackBinding == "Ctrl+Alt+F9" && readBackEnabled, + string.Format("S1: toggleHotkey and toggleHotkeyEnabled both round-trip byte for byte against a temp INI, got wroteBinding={0} wroteEnabled={1} readBackBinding=\"{2}\" readBackEnabled={3}", + wroteBinding, wroteEnabled, readBackBinding, readBackEnabled)); + } + finally + { + DeleteFileIfExists(tempIni); + DeleteFileIfExists(tempXml); + } + } + + // S2. Mutation this guards: default either reader's missing-key value to something other + // than ""/false. + private static void CheckSettingsMissingKeysReadDefaults(Checklist checklist) + { + string tempIni = NewTempPath(".ini"); + string tempXml = NewTempPath(".xml"); + try + { + SettingsController controller = new SettingsController(tempIni, tempXml); + // Writes a DIFFERENT key first, so the INI file exists but never contains either + // toggle-hotkey key. + controller.SetVibranceSetting("someOtherKey", "someOtherValue"); + + string readBackBinding = controller.ReadToggleHotkey(); + bool readBackEnabled = controller.ReadToggleHotkeyEnabled(); + HotkeyBinding parsedBinding; + bool parsed = HotkeyBindingParser.TryParse(readBackBinding, out parsedBinding); + + checklist.Check(readBackBinding == string.Empty && !parsed && !readBackEnabled, + string.Format("S2: missing keys read back as \"\"/false and never register, got readBackBinding=\"{0}\" parsed={1} readBackEnabled={2}", + readBackBinding, parsed, readBackEnabled)); + } + finally + { + DeleteFileIfExists(tempIni); + DeleteFileIfExists(tempXml); + } + } + + // S3. Mutation this guards: let either reader throw on a value the writers never + // themselves produced. + private static void CheckSettingsCorruptValuesDoNotThrow(Checklist checklist) + { + string tempIni = NewTempPath(".ini"); + string tempXml = NewTempPath(".xml"); + try + { + SettingsController controller = new SettingsController(tempIni, tempXml); + controller.SetToggleHotkey("Ctrl+NotARealKeyName"); + controller.SetVibranceSetting("toggleHotkeyEnabled", "NotABool"); + + bool threw = false; + HotkeyBinding binding = HotkeyBinding.None; + bool enabled = true; + try + { + string readBackBinding = controller.ReadToggleHotkey(); + HotkeyBindingParser.TryParse(readBackBinding, out binding); + enabled = controller.ReadToggleHotkeyEnabled(); + } + catch (Exception) + { + threw = true; + } + + checklist.Check(!threw && !binding.IsSet && !enabled, + string.Format("S3: a corrupt stored binding never throws and yields IsSet == false, and a corrupt bool string defaults to false, got threw={0} isSet={1} enabled={2}", + threw, binding.IsSet, enabled)); + } + finally + { + DeleteFileIfExists(tempIni); + DeleteFileIfExists(tempXml); + } + } + + // Records every (deviceName -> handle) resolution and every (handle -> level) write this + // fake is asked to make, plus a combined call counter (TotalCallCount) the gate checks + // above use for their "zero calls AT ALL" assertions. VibranceRestoreFixture's own + // FakeNvidiaVibranceDevice is a private nested class there and not reachable from this + // file, so this is a second, smaller copy of the same shape. + private class FakeNvidiaVibranceDevice : INvidiaVibranceDevice + { + private readonly Dictionary _handlesByDeviceName = new Dictionary(); + private readonly Dictionary _levelsByHandle = new Dictionary(); + private readonly HashSet _failNextSetLevel = new HashSet(); + private int _nextHandle = 1; + + public readonly List SetLevelCalls = new List(); + public readonly List ResolvedDeviceNames = new List(); + public int TotalCallCount; + + public int HandleFor(string deviceName) + { + return ResolveOrAssign(deviceName); + } + + public void SeedLevel(string deviceName, int level) + { + _levelsByHandle[ResolveOrAssign(deviceName)] = level; + } + + public void FailNextSetLevel(string deviceName) + { + _failNextSetLevel.Add(ResolveOrAssign(deviceName)); + } + + private int ResolveOrAssign(string deviceName) + { + int handle; + if (!_handlesByDeviceName.TryGetValue(deviceName, out handle)) + { + handle = _nextHandle++; + _handlesByDeviceName[deviceName] = handle; + } + return handle; + } + + public bool IsWindowActive(ref IntPtr hWnd) + { + TotalCallCount++; + return true; + } + + public int TryResolveDisplayHandle(string deviceName) + { + TotalCallCount++; + ResolvedDeviceNames.Add(deviceName); + if (string.IsNullOrEmpty(deviceName)) + { + return -1; + } + return ResolveOrAssign(deviceName); + } + + public bool IsAtLevel(int displayHandle, int level) + { + TotalCallCount++; + int current; + return _levelsByHandle.TryGetValue(displayHandle, out current) && current == level; + } + + public bool SetLevel(int displayHandle, int level) + { + TotalCallCount++; + SetLevelCalls.Add(displayHandle); + if (_failNextSetLevel.Remove(displayHandle)) + { + return false; + } + _levelsByHandle[displayHandle] = level; + return true; + } + } + + // Everything IAmdAdapter exposes, none of it touching real hardware - a second, smaller + // copy of VibranceRestoreFixture's own private nested fake, extended with one-shot + // per-display failure injection for T4a/T5a above. + private class FakeAmdAdapter : IAmdAdapter + { + private readonly HashSet _failNextSetSaturationOnDisplay = new HashSet(StringComparer.OrdinalIgnoreCase); + + public int SetSaturationOnAllDisplaysCallCount; + + public readonly List SetSaturationOnDisplayLevels = new List(); + public readonly List SetSaturationOnDisplayNames = new List(); + + public void FailNextSetSaturationOnDisplay(string displayName) + { + _failNextSetSaturationOnDisplay.Add(displayName ?? string.Empty); + } + + public void SetSaturationOnAllDisplays(int vibranceLevel) + { + SetSaturationOnAllDisplaysCallCount++; + } + + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) + { + SetSaturationOnDisplayLevels.Add(vibranceLevel); + SetSaturationOnDisplayNames.Add(displayName); + return !_failNextSetSaturationOnDisplay.Remove(displayName ?? string.Empty); + } + + public bool IsAvailable() + { + return false; + } + + public void Init() + { + } + + public void Dispose() + { + } + } + + // Records every Register/Unregister call this fake is asked to make, with a one-shot + // FIFO queue of forced results - mirrors ResolutionChangeFixture.FakeDisplayModeDevice's + // QueueResult shape. Never touches a real hotkey - see this file's own header comment. + private class FakeHotkeyRegistrar : IHotkeyRegistrar + { + public struct RegisterCall + { + public readonly IntPtr HWnd; + public readonly int Id; + public readonly uint Modifiers; + public readonly uint VirtualKey; + + public RegisterCall(IntPtr hWnd, int id, uint modifiers, uint virtualKey) + { + HWnd = hWnd; + Id = id; + Modifiers = modifiers; + VirtualKey = virtualKey; + } + } + + private readonly Queue _queuedResults = new Queue(); + + public readonly List RegisterCalls = new List(); + public readonly List UnregisterCalls = new List(); + + public void QueueResult(HotkeyRegistrationResult result) + { + _queuedResults.Enqueue(result); + } + + public HotkeyRegistrationResult Register(IntPtr hWnd, int id, uint modifiers, uint virtualKey) + { + RegisterCalls.Add(new RegisterCall(hWnd, id, modifiers, virtualKey)); + if (_queuedResults.Count > 0) + { + return _queuedResults.Dequeue(); + } + return HotkeyRegistrationResult.Registered; + } + + public void Unregister(IntPtr hWnd, int id) + { + UnregisterCalls.Add(hWnd); + } + } + + private class Checklist + { + public readonly List Lines = new List(); + public int Passed; + public int Total; + + public void Check(bool condition, string description) + { + Total++; + if (condition) + Passed++; + Lines.Add(string.Format("[{0}] {1}", condition ? "PASS" : "FAIL", description)); + } + + // Deliberately not counted in Total/Passed - see StabilityFixture.Checklist.Skip for + // the convention this follows. None of the checks above currently need it (unlike the + // pre-existing AMD checks in VibranceRestoreFixture, nothing here reads the real + // GetForegroundWindow()), but it is kept for parity with every other fixture's + // Checklist shape. + public void Skip(string description) + { + Lines.Add(string.Format("[SKIP] {0}", description)); + } + } + } +} diff --git a/vibrance.GUI/common/ProfileToggleHelper.cs b/vibrance.GUI/common/ProfileToggleHelper.cs new file mode 100644 index 0000000..3ab8334 --- /dev/null +++ b/vibrance.GUI/common/ProfileToggleHelper.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace vibrance.GUI.common +{ + /// + /// What the toggle hotkey should do about the profile currently in the foreground, decided + /// with no device, no Screen and no OS call at all - see Decide below. + /// + internal enum ProfileToggleAction + { + // No configured profile matches the foreground window at all. + None, + // This profile is currently suppressed (forced to the Windows level) - the hotkey should + // write the game level and un-suppress it. + ApplyGameLevel, + // This profile is currently running normally - the hotkey should write the Windows level + // and suppress it. + RestoreWindowsLevel, + // A profile matched, but userVibranceSettingDefault is not known yet (SetVibranceWindowsLevel + // has never run this session) - writing either level would be the arbitrary-0 write + // issue #60/#36 was. + EngineNotReady + } + + internal struct ProfileToggleDecision + { + internal ProfileToggleAction Action; + internal ApplicationSetting Setting; + } + + /// + /// The toggle hotkey's own state and pure decision logic (upstream #143, per-game + /// suppression). No device, no Screen, no OS call, no I/O - the vendor proxies' own + /// ToggleForegroundProfile is what turns a ProfileToggleDecision into an actual write. + /// + internal static class ProfileToggleHelper + { + // The set of ApplicationSetting.Name values the user has toggled OFF by hotkey. Empty + // means every profile behaves exactly as it does today. Deliberately a suppression set + // and not an enablement set, so zero-initialised state is current behaviour - the same + // polarity lesson VibranceInfo.shouldRun's own comment describes, applied to a set + // instead of a single flag because this is now a per-game decision, not a global one. + // + // Keyed by Name, OrdinalIgnoreCase: Name is already this codebase's identity for a + // profile - it is what NameMatches compares (ApplicationSettingMatcher.cs:89-94) and + // what AddProgramsBulk dedupes on (VibranceGUI.cs). Deliberately NOT the + // ApplicationSetting reference: listApplications_DoubleClick removes and re-adds a NEW + // object on every edit (VibranceGUI.cs, listApplications_DoubleClick), so a held + // reference would go stale the next time that game's settings are edited. + // + // Everything here runs on the UI thread (WinEvent callbacks and the WM_HOTKEY handler + // both do), like VibranceRestoreHelper's own work-list beside it - deliberately + // unsynchronised for the same reason. + // + // This class has NO persistence code of any kind - there is no read path to review + // because there is no I/O here at all. Persistence of the hotkey binding itself lives in + // SettingsController; suppression state is intentionally NOT persisted (every launch + // starts with every profile un-suppressed, exactly like isPaused's would-have-been + // semantics in the discarded design). + // + // Deliberately NOT derived from VibranceRestoreHelper's own work-list: that drains on + // every alt-tab out of a game (see its own class comment), but a suppression must + // survive exactly that - the whole point is that the automatic restore-on-alt-tab-out + // keeps happening for a suppressed game (see the "restore branch stays ungated" note in + // both proxies' OnWinEventHook) while the apply-on-alt-tab-in does not. Two different + // lifetimes, so two different pieces of state. + private static readonly HashSet _suppressedProfileNames = + new HashSet(StringComparer.OrdinalIgnoreCase); + + internal static bool IsSuppressed(string name) + { + return !string.IsNullOrEmpty(name) && _suppressedProfileNames.Contains(name); + } + + internal static void SetSuppressed(string name, bool suppressed) + { + if (string.IsNullOrEmpty(name)) + { + return; + } + + if (suppressed) + { + _suppressedProfileNames.Add(name); + } + else + { + _suppressedProfileNames.Remove(name); + } + } + + internal static int SuppressedCount + { + get { return _suppressedProfileNames.Count; } + } + + // Exists for ProfileToggleFixture only - production code never needs to blank this out + // mid-run. Mirrors VibranceRestoreHelper.ResetForTests. + internal static void ResetForTests() + { + _suppressedProfileNames.Clear(); + } + + /// + /// The toggle hotkey's whole decision, made with no device, no Screen, no OS call and no + /// side effect - Decide never mutates _suppressedProfileNames itself; the caller flips it + /// only after a confirmed write (see ToggleForegroundProfile in both proxies). + /// + /// Direction comes from OUR OWN recorded intent (IsSuppressed), never from reading the + /// display's current level back - a read-back mis-decides the instant the game level and + /// the Windows level happen to coincide, or an external tool nudges the display between + /// events. + /// + /// Matches with the same overload the automatic WinEvent handlers use + /// (NvidiaDynamicVibranceProxy.OnWinEventHook), including processImagePath - a directory- + /// matched profile (no exact Name match) must be just as reachable by the hotkey as by + /// the automatic path, or a guessed executable is invisible to the toggle even though the + /// automatic apply already recognises it. Matching on Name alone here is PR #153's bug. + /// + internal static ProfileToggleDecision Decide(List settings, + string processName, string processImagePath, bool isWindowsLevelKnown) + { + ProfileToggleDecision decision = new ProfileToggleDecision(); + + ApplicationSetting setting = ApplicationSettingMatcher.FindMatch(settings, processName, processImagePath); + if (setting == null) + { + decision.Action = ProfileToggleAction.None; + return decision; + } + + if (!isWindowsLevelKnown) + { + decision.Action = ProfileToggleAction.EngineNotReady; + return decision; + } + + decision.Setting = setting; + decision.Action = IsSuppressed(setting.Name) ? ProfileToggleAction.ApplyGameLevel : ProfileToggleAction.RestoreWindowsLevel; + return decision; + } + } +} diff --git a/vibrance.GUI/common/SettingsController.cs b/vibrance.GUI/common/SettingsController.cs index 61b919e..a3d758c 100644 --- a/vibrance.GUI/common/SettingsController.cs +++ b/vibrance.GUI/common/SettingsController.cs @@ -38,11 +38,26 @@ private static extern bool WritePrivateProfileString(string lpAppName, const string SzKeyNameContrastWindowsLevel = "contrastWindowsLevel"; const string SzKeyNameGammaWindowsLevel = "gammaWindowsLevel"; const string SzKeyNameGraphicsAdapter = "graphicsAdapter"; + const string SzKeyNameToggleHotkey = "toggleHotkey"; + const string SzKeyNameToggleHotkeyEnabled = "toggleHotkeyEnabled"; private string _fileName = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData).ToString() + "\\vibranceGUI\\vibranceGUI.ini"; private string _fileNameApplicationSettings = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData).ToString() + "\\vibranceGUI\\applicationData.xml"; + public SettingsController() + { + } + + // Lets ProfileToggleFixture round-trip SetToggleHotkey/ReadToggleHotkey against a temp + // INI, never the user's real "%APPDATA%\vibranceGUI\vibranceGUI.ini" - see that fixture's + // own header comment. + internal SettingsController(string fileName, string applicationSettingsFileName) + { + _fileName = fileName; + _fileNameApplicationSettings = applicationSettingsFileName; + } + public bool SetVibranceSettings(string windowsLevel, string affectPrimaryMonitorOnly, string neverSwitchResolution, string neverChangeColorSettings, List applicationSettings, string brightnessWindowsLevel, string contrastWindowsLevel, string gammaWindowsLevel) @@ -138,6 +153,76 @@ public bool SetGraphicsAdapterPreference(GraphicsAdapter graphicsAdapter) return SetVibranceSetting(SzKeyNameGraphicsAdapter, graphicsAdapter.ToString()); } + /// + /// The toggle hotkey's canonical text (HotkeyBindingParser.Format's own output, e.g. + /// "Ctrl+Alt+F9"), or "" when the INI holds no binding - which is what every existing + /// installation looks like. Modelled exactly on ReadGraphicsAdapterPreference: read on + /// its own, not folded into ReadVibranceSettings' 8-parameter signature, since that + /// signature is shared by every existing call site and this feature has nothing to do + /// with vibrance levels. + /// + public string ReadToggleHotkey() + { + if (!IsFileExisting(_fileName)) + { + return string.Empty; + } + + StringBuilder szValueToggleHotkey = new StringBuilder(1024); + GetPrivateProfileString(SzSectionName, + SzKeyNameToggleHotkey, + "", + szValueToggleHotkey, + Convert.ToUInt32(szValueToggleHotkey.Capacity), + _fileName); + + return szValueToggleHotkey.ToString().Trim(); + } + + /// + /// Stores the toggle hotkey's canonical text. SetVibranceSetting is already a single-key + /// writer, so this is a thin, named wrapper over it - the same shape as + /// SetGraphicsAdapterPreference. + /// + public bool SetToggleHotkey(string canonicalText) + { + return SetVibranceSetting(SzKeyNameToggleHotkey, canonicalText ?? string.Empty); + } + + /// + /// Whether the toggle hotkey checkbox was checked, or false when the INI holds no + /// preference - which is what every existing installation looks like. A missing/corrupt + /// value defaults to false (disabled), the safer of the two: an unexpectedly-active + /// global hotkey is a worse first impression than one the user has to turn on themselves. + /// + public bool ReadToggleHotkeyEnabled() + { + if (!IsFileExisting(_fileName)) + { + return false; + } + + StringBuilder szValueToggleHotkeyEnabled = new StringBuilder(1024); + GetPrivateProfileString(SzSectionName, + SzKeyNameToggleHotkeyEnabled, + "False", + szValueToggleHotkeyEnabled, + Convert.ToUInt32(szValueToggleHotkeyEnabled.Capacity), + _fileName); + + bool enabled; + return bool.TryParse(szValueToggleHotkeyEnabled.ToString().Trim(), out enabled) && enabled; + } + + /// + /// Stores whether the toggle hotkey checkbox was checked - the same single-key writer + /// shape as SetToggleHotkey beside it. + /// + public bool SetToggleHotkeyEnabled(bool enabled) + { + return SetVibranceSetting(SzKeyNameToggleHotkeyEnabled, enabled.ToString()); + } + private bool PrepareFile() { if (!IsFileExisting(_fileName)) diff --git a/vibrance.GUI/common/StabilityFixture.cs b/vibrance.GUI/common/StabilityFixture.cs index d37be63..5680813 100644 --- a/vibrance.GUI/common/StabilityFixture.cs +++ b/vibrance.GUI/common/StabilityFixture.cs @@ -238,11 +238,14 @@ public void SetSaturationOnAllDisplays(int vibranceLevel) LastSetSaturationOnAllDisplaysLevel = vibranceLevel; } - public void SetSaturationOnDisplay(int vibranceLevel, string displayName) + // Always reports success (upstream #143 gave the real interface a bool return) - + // none of this file's own checks need a failure path, so behaviour here is unchanged. + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) { SetSaturationOnDisplayCallCount++; LastSetSaturationOnDisplayLevel = vibranceLevel; LastSetSaturationOnDisplayName = displayName; + return true; } public bool IsAvailable() diff --git a/vibrance.GUI/common/VibranceGUI.Designer.cs b/vibrance.GUI/common/VibranceGUI.Designer.cs index 5aac4ed..db4026c 100644 --- a/vibrance.GUI/common/VibranceGUI.Designer.cs +++ b/vibrance.GUI/common/VibranceGUI.Designer.cs @@ -66,6 +66,11 @@ private void InitializeComponent() this.groupBox10 = new System.Windows.Forms.GroupBox(); this.labelBrightness = new System.Windows.Forms.Label(); this.trackBarBrightness = new System.Windows.Forms.TrackBar(); + this.checkBoxToggleHotkeyEnabled = new System.Windows.Forms.CheckBox(); + this.labelToggleHotkey = new System.Windows.Forms.Label(); + this.textBoxToggleHotkey = new System.Windows.Forms.TextBox(); + this.buttonClearToggleHotkey = new System.Windows.Forms.Button(); + this.labelToggleHotkeyStatus = new System.Windows.Forms.Label(); this.contextMenuStrip.SuspendLayout(); this.groupBoxSettings.SuspendLayout(); this.groupBox3.SuspendLayout(); @@ -143,6 +148,11 @@ private void InitializeComponent() this.groupBoxSettings.Controls.Add(this.checkBoxPrimaryMonitorOnly); this.groupBoxSettings.Controls.Add(this.groupBox3); this.groupBoxSettings.Controls.Add(this.checkBoxAutostart); + this.groupBoxSettings.Controls.Add(this.checkBoxToggleHotkeyEnabled); + this.groupBoxSettings.Controls.Add(this.labelToggleHotkey); + this.groupBoxSettings.Controls.Add(this.textBoxToggleHotkey); + this.groupBoxSettings.Controls.Add(this.buttonClearToggleHotkey); + this.groupBoxSettings.Controls.Add(this.labelToggleHotkeyStatus); this.groupBoxSettings.Location = new System.Drawing.Point(15, 47); this.groupBoxSettings.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5); this.groupBoxSettings.Name = "groupBoxSettings"; @@ -213,7 +223,73 @@ private void InitializeComponent() this.trackBarWindowsLevel.Size = new System.Drawing.Size(196, 69); this.trackBarWindowsLevel.TabIndex = 0; this.trackBarWindowsLevel.Scroll += new System.EventHandler(this.trackBarWindowsLevel_Scroll); - // + // + // checkBoxToggleHotkeyEnabled + // + this.checkBoxToggleHotkeyEnabled.AutoSize = true; + this.checkBoxToggleHotkeyEnabled.Location = new System.Drawing.Point(300, 32); + this.checkBoxToggleHotkeyEnabled.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5); + this.checkBoxToggleHotkeyEnabled.Name = "checkBoxToggleHotkeyEnabled"; + this.checkBoxToggleHotkeyEnabled.Size = new System.Drawing.Size(248, 24); + this.checkBoxToggleHotkeyEnabled.TabIndex = 20; + this.checkBoxToggleHotkeyEnabled.Text = "Toggle game profile by hotkey"; + this.toolTip.SetToolTip(this.checkBoxToggleHotkeyEnabled, "When checked, the key combination below toggles the foreground game\'s profile b" + + "etween its game level and your Windows level."); + this.checkBoxToggleHotkeyEnabled.UseVisualStyleBackColor = true; + this.checkBoxToggleHotkeyEnabled.CheckedChanged += new System.EventHandler(this.checkBoxToggleHotkeyEnabled_CheckedChanged); + // + // labelToggleHotkey + // + this.labelToggleHotkey.AutoSize = true; + this.labelToggleHotkey.Location = new System.Drawing.Point(300, 60); + this.labelToggleHotkey.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + this.labelToggleHotkey.Name = "labelToggleHotkey"; + this.labelToggleHotkey.Size = new System.Drawing.Size(153, 20); + this.labelToggleHotkey.TabIndex = 21; + this.labelToggleHotkey.Text = "Toggle profile hotkey:"; + // + // textBoxToggleHotkey + // + this.textBoxToggleHotkey.Location = new System.Drawing.Point(300, 86); + this.textBoxToggleHotkey.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5); + this.textBoxToggleHotkey.Name = "textBoxToggleHotkey"; + this.textBoxToggleHotkey.ReadOnly = true; + this.textBoxToggleHotkey.Size = new System.Drawing.Size(240, 26); + this.textBoxToggleHotkey.TabIndex = 22; + this.toolTip.SetToolTip(this.textBoxToggleHotkey, "Click here, then press a key combination to bind a global hotkey that toggles t" + + "he foreground game\'s profile between its game level and your Windows level. A b" + + "are key with no modifier is legal but steals it from the game system-wide."); + this.textBoxToggleHotkey.Enter += new System.EventHandler(this.textBoxToggleHotkey_Enter); + this.textBoxToggleHotkey.KeyDown += new System.Windows.Forms.KeyEventHandler(this.textBoxToggleHotkey_KeyDown); + this.textBoxToggleHotkey.Leave += new System.EventHandler(this.textBoxToggleHotkey_Leave); + // + // buttonClearToggleHotkey + // + this.buttonClearToggleHotkey.Location = new System.Drawing.Point(548, 85); + this.buttonClearToggleHotkey.Margin = new System.Windows.Forms.Padding(4, 5, 4, 5); + this.buttonClearToggleHotkey.Name = "buttonClearToggleHotkey"; + this.buttonClearToggleHotkey.Size = new System.Drawing.Size(44, 28); + this.buttonClearToggleHotkey.TabIndex = 23; + this.buttonClearToggleHotkey.Text = "X"; + this.toolTip.SetToolTip(this.buttonClearToggleHotkey, "Clears the toggle hotkey binding."); + this.buttonClearToggleHotkey.UseVisualStyleBackColor = true; + this.buttonClearToggleHotkey.Click += new System.EventHandler(this.buttonClearToggleHotkey_Click); + // + // labelToggleHotkeyStatus + // + this.labelToggleHotkeyStatus.AutoSize = true; + this.labelToggleHotkeyStatus.Location = new System.Drawing.Point(300, 118); + this.labelToggleHotkeyStatus.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + // A non-zero MaximumSize.Width makes an AutoSize label WRAP instead of growing past + // it - defense against the longest status string ("No modifier: steals the key from + // the game.") clipping past groupBoxSettings' own right edge (this label starts at + // x=300 in a 600-wide box, leaving 292px before the border). + this.labelToggleHotkeyStatus.MaximumSize = new System.Drawing.Size(288, 0); + this.labelToggleHotkeyStatus.Name = "labelToggleHotkeyStatus"; + this.labelToggleHotkeyStatus.Size = new System.Drawing.Size(0, 20); + this.labelToggleHotkeyStatus.TabIndex = 24; + this.labelToggleHotkeyStatus.Text = ""; + // // statusLabel // this.statusLabel.AutoSize = true; @@ -576,6 +652,11 @@ private void InitializeComponent() private System.Windows.Forms.GroupBox groupBox10; private System.Windows.Forms.Label labelBrightness; private System.Windows.Forms.TrackBar trackBarBrightness; + private System.Windows.Forms.CheckBox checkBoxToggleHotkeyEnabled; + private System.Windows.Forms.Label labelToggleHotkey; + private System.Windows.Forms.TextBox textBoxToggleHotkey; + private System.Windows.Forms.Button buttonClearToggleHotkey; + private System.Windows.Forms.Label labelToggleHotkeyStatus; } } diff --git a/vibrance.GUI/common/VibranceGUI.cs b/vibrance.GUI/common/VibranceGUI.cs index 52ea23b..e48cb29 100644 --- a/vibrance.GUI/common/VibranceGUI.cs +++ b/vibrance.GUI/common/VibranceGUI.cs @@ -5,6 +5,7 @@ using System.Drawing; using System.IO; using System.Linq; +using System.Media; using System.Runtime.InteropServices; using System.Threading; using System.Windows.Forms; @@ -55,6 +56,44 @@ public partial class VibranceGUI : Form private const string ToolTipExecutableUnconfirmed = "Not detected yet. vibranceGUI has not seen this executable in the foreground, so this may be the wrong file. Double-click to change the executable."; + // WM_HOTKEY (winuser.h) - WndProc below dispatches on this with wParam == + // HotkeyRegistration.HotkeyId, the toggle hotkey's own fixed registration id. + private const int WmHotkey = 0x0312; + + // RegisterHotKey/WndProc, never a low-level keyboard hook - see HotkeyRegistration's own + // header comment for why. Constructed with the real registrar; ProfileToggleFixture + // drives HotkeyRegistration directly against a fake instead of through this form at all. + private readonly HotkeyRegistration _hotkeyRegistration = new HotkeyRegistration(new RealHotkeyRegistrar()); + private readonly IForegroundWindowReader _foregroundWindowReader = new RealForegroundWindowReader(); + private HotkeyBinding _toggleBinding = HotkeyBinding.None; + + // The checkbox's own state - the binding's presence is deliberately NOT the enable flag + // here (unlike the discarded global-pause design): a per-game toggle is significant + // enough, and a mis-hit hotkey costly enough (it suppresses a specific game's profile), + // that turning it on is its own explicit step. ApplyToggleHotkey only ever registers a + // real binding when this is true AND _toggleBinding.IsSet. + private bool _toggleHotkeyEnabled; + + // Guards the one-time balloon ApplyToggleHotkey raises for a registration failure that + // was not caused by an interactive user action (i.e. the settings-read registration + // point) - without this, a binding that keeps failing (another application owns it) would + // re-balloon on every settings reload. + private bool _hasShownHotkeyFailureBalloon; + + // Set around the ReadVibranceSettings-time "checkBoxToggleHotkeyEnabled.Checked = ..." + // assignment (below) so its own CheckedChanged handler - when the stored value happens to + // differ from the designer default and the setter actually raises the event - does not + // re-persist the exact value it was just given. Not needed for correctness (writing the + // same value back is harmless), only to avoid an INI write on every single startup. + private bool _isLoadingToggleHotkeyEnabled; + + // Same one-set-per-key dedup convention as NvidiaDynamicVibranceProxy's own + // _loggedDisplayFailures/LogDisplayFailureOnce - a no-op toggle press (no configured game + // in the foreground, or the engine is not ready yet) logs once per distinct process name, + // not once per press, so leaning on the key (even with MOD_NOREPEAT, which only throttles + // WM_HOTKEY's own repeat rate, not repeated presses) cannot spam vibranceGUI.log. + private readonly HashSet _loggedNoOpTogglePresses = new HashSet(StringComparer.OrdinalIgnoreCase); + public VibranceGUI( Func, Dictionary>>, IVibranceProxy> getProxy, GraphicsAdapter graphicsAdapter, @@ -133,6 +172,92 @@ public void SetAllowVisible(bool value) _allowVisible = value; } + /// + /// The first of the toggle hotkey's four registration points - see HotkeyRegistration's + /// own header comment and ApplyToggleHotkey below for the rest. The handle exists here + /// even under "-minimized" (SetVisibleCore above calls CreateHandle() when !_allowVisible), + /// but the binding itself is never set yet at this point (ReadVibranceSettings has not run) + /// - this call always returns NotConfigured without ever reaching RegisterHotKey. + /// + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + ApplyToggleHotkey(false); + } + + /// + /// One of the three layers that guarantee the toggle hotkey is unregistered - see + /// HotkeyRegistration's own header comment. Runs before the handle is actually destroyed, + /// which is what lets Release() unregister against the still-valid handle it cached at + /// registration time (see Release's own comment on why it never reads a fresh one). + /// + protected override void OnHandleDestroyed(EventArgs e) + { + _hotkeyRegistration.Release(); + base.OnHandleDestroyed(e); + } + + protected override void WndProc(ref Message m) + { + if (m.Msg == WmHotkey && m.WParam == (IntPtr)HotkeyRegistration.HotkeyId) + { + OnToggleHotkeyPressed(); + } + base.WndProc(ref m); + } + + /// + /// Closes the real gap textBoxToggleHotkey's own Enter/Leave pair leaves open, measured + /// directly rather than assumed: Leave does NOT fire when another window takes activation + /// while the textbox still has focus, nor on Hide() (the minimise-to-tray path), nor on + /// WindowState = Minimized, nor on Close() with the textbox focused - only a focus change + /// to a SIBLING control raises it. Concretely: open settings, click the capture box to + /// look at the current binding (Enter releases it), then alt-tab away or minimise to tray + /// - without this override the hotkey stays unregistered for the rest of the session, + /// silently, while labelToggleHotkeyStatus keeps claiming "Hotkey registered.". + /// ApplyToggleHotkey(false), not (true): a deactivating form must not write the inline + /// status label - showInline is for a live edit, and this form is not being edited when + /// something else takes activation out from under it. + /// + protected override void OnDeactivate(EventArgs e) + { + if (ShouldReleaseHotkeyOnFocusTransition(this.ActiveControl, this.textBoxToggleHotkey)) + { + ApplyToggleHotkey(false); + } + base.OnDeactivate(e); + } + + /// + /// The other half of OnDeactivate above - also measured directly: after deactivate then + /// reactivate (or Hide() then Show()), textBoxToggleHotkey.Enter does NOT fire again, + /// because ActiveControl never actually changed. Re-applying on deactivation without + /// re-releasing here on activation would leave the hotkey live while the capture box has + /// focus, reopening the exact rebinding defect (PR #153's third one) the Enter/Leave pair + /// exists to fix in the first place. + /// + protected override void OnActivated(EventArgs e) + { + base.OnActivated(e); + if (ShouldReleaseHotkeyOnFocusTransition(this.ActiveControl, this.textBoxToggleHotkey)) + { + _hotkeyRegistration.Release(); + } + } + + /// + /// The condition both OnDeactivate and OnActivated above share, pulled out so + /// ProfileToggleFixture can call it directly (same assembly, internal) rather than + /// reflecting into a live Form - which this codebase deliberately never constructs in a + /// self test (VibranceGUI's own constructor calls getProxy(...), touching a real vendor + /// proxy). Pure: no WinForms focus system involved, just the one comparison both + /// overrides need to agree on. + /// + internal static bool ShouldReleaseHotkeyOnFocusTransition(Control activeControl, Control toggleHotkeyTextBox) + { + return activeControl == toggleHotkeyTextBox; + } + private void Form1_Load(object sender, EventArgs e) { SetGuiEnabledFlag(false); @@ -415,9 +540,346 @@ private void SetGuiEnabledFlag(bool flag) this.buttonRemoveProgram.Enabled = flag; this.checkBoxNeverChangeResolutions.Enabled = flag; this.checkBoxNeverChangeColorSettings.Enabled = flag; + this.checkBoxToggleHotkeyEnabled.Enabled = flag; + // AND'd with the checkbox's own state, not just flag alone - otherwise this + // would force the capture controls back on even while the user has left the + // checkbox unchecked (see checkBoxToggleHotkeyEnabled_CheckedChanged). + this.textBoxToggleHotkey.Enabled = flag && _toggleHotkeyEnabled; + this.buttonClearToggleHotkey.Enabled = flag && _toggleHotkeyEnabled; }); } + // ------------------------------------------------------------------ + // Toggle hotkey (upstream #143) - a global RegisterHotKey binding that flips the + // foreground game's profile between its game level and the Windows level. See + // HotkeyRegistration/HotkeyBinding/IHotkeyRegistrar for the seams this wiring drives, and + // ProfileToggleFixture for their regression coverage. + // ------------------------------------------------------------------ + + [DllImport("user32.dll")] + private static extern short GetKeyState(int nVirtKey); + + private const int VkLWin = 0x5B; + private const int VkRWin = 0x5C; + + // KeyEventArgs exposes Control/Alt/Shift directly (e.Control/e.Alt/e.Shift) but has no + // equivalent for the Windows key - Control.ModifierKeys does not cover it either. This is + // the same "high bit of GetKeyState" read Windows itself uses to answer "is this key down + // right now", scoped to just the two Win virtual-key codes. + private static bool IsWinKeyDown() + { + return (GetKeyState(VkLWin) & 0x8000) != 0 || (GetKeyState(VkRWin) & 0x8000) != 0; + } + + /// + /// Applies _toggleBinding against the form's own handle - see the class-level comment + /// above for the four points this is called from, and HotkeyRegistration.Apply for the + /// release-then-register contract underneath it. showInline routes a non-Registered + /// result to the settings-window status label (a live edit) instead of a one-time tray + /// balloon (an unattended registration point, e.g. the settings-read call site). + /// + private HotkeyRegistrationResult ApplyToggleHotkey(bool showInline) + { + // _v.GetVibranceInfo().isInitialized settles once, during the proxy's own + // constructor, and never flips back - a proxy that failed to initialize (bad driver, + // etc.) never will. Registering a hotkey the engine can never act on would still + // intercept that key combination system-wide, stealing it from a game that might + // legitimately want it, for a feature that can only ever no-op (see + // OnToggleHotkeyPressed's own guard) - not worth it just because a binding happens to + // be configured. + if (this.IsDisposed || !this.IsHandleCreated || _v == null || !_v.GetVibranceInfo().isInitialized) + { + return HotkeyRegistrationResult.NotConfigured; + } + + // The checkbox gates registration, not just the presence of a saved binding - a + // binding can be fully configured (and shown in the textbox) while the checkbox is + // still unchecked, and must register nothing until the user turns it on. + // HotkeyRegistration.EffectiveBinding is the real gate, called from here rather than + // inlined, so a fixture that cannot instantiate this Form still reaches the actual + // expression production code runs, not a copy of it. + HotkeyBinding effective = HotkeyRegistration.EffectiveBinding(_toggleHotkeyEnabled, _toggleBinding); + HotkeyRegistrationResult result = _hotkeyRegistration.Apply(this.Handle, effective); + + if (showInline) + { + ApplyToggleHotkeyStatusLabel(result); + } + else if ((result == HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication || + result == HotkeyRegistrationResult.Failed) && !_hasShownHotkeyFailureBalloon) + { + _hasShownHotkeyFailureBalloon = true; + this.notifyIcon.BalloonTipIcon = ToolTipIcon.Warning; + this.notifyIcon.BalloonTipText = result == HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication + ? string.Format("Could not register the toggle hotkey ({0}) - it is already in use by another application.", HotkeyBindingParser.Format(_toggleBinding)) + : string.Format("Could not register the toggle hotkey ({0}).", HotkeyBindingParser.Format(_toggleBinding)); + this.notifyIcon.ShowBalloonTip(250); + } + + return result; + } + + /// + /// The inline, synchronous feedback ApplyToggleHotkey(showInline: true) shows next to the + /// textbox. A successfully registered binding with no real modifier bit set (only + /// MOD_NOREPEAT, which is never user-visible - see HotkeyBindingParser) still shows a + /// warning instead of the plain success text: it is a legal binding, but one that steals + /// the key from the game system-wide the moment it is bound. + /// + private void ApplyToggleHotkeyStatusLabel(HotkeyRegistrationResult result) + { + switch (result) + { + case HotkeyRegistrationResult.NotConfigured: + this.labelToggleHotkeyStatus.ForeColor = SystemColors.ControlText; + this.labelToggleHotkeyStatus.Text = string.Empty; + return; + case HotkeyRegistrationResult.AlreadyOwnedByAnotherApplication: + this.labelToggleHotkeyStatus.ForeColor = Color.Red; + this.labelToggleHotkeyStatus.Text = "Already in use by another application."; + return; + case HotkeyRegistrationResult.Failed: + this.labelToggleHotkeyStatus.ForeColor = Color.Red; + this.labelToggleHotkeyStatus.Text = "Could not register this hotkey."; + return; + } + + if ((_toggleBinding.Modifiers & ~HotkeyBindingParser.ModNoRepeat) == 0) + { + this.labelToggleHotkeyStatus.ForeColor = Color.DarkOrange; + this.labelToggleHotkeyStatus.Text = "No modifier: steals the key from the game."; + return; + } + + this.labelToggleHotkeyStatus.ForeColor = Color.Green; + this.labelToggleHotkeyStatus.Text = "Hotkey registered."; + } + + /// + /// Persists _toggleBinding's canonical text on its own, single-key write - deliberately + /// not routed through ForceSaveVibranceSettings/SaveVibranceSettings' debounced, + /// 8-parameter round trip, which this feature has nothing to do with. + /// + private void SaveToggleHotkeySetting() + { + try + { + new SettingsController().SetToggleHotkey(HotkeyBindingParser.Format(_toggleBinding)); + } + catch (Exception ex) + { + Log(ex); + } + } + + /// + /// Persists the checkbox's own checked state - the same single-key write shape as + /// SaveToggleHotkeySetting beside it, and for the same reason not routed through + /// ForceSaveVibranceSettings/SaveVibranceSettings. + /// + private void SaveToggleHotkeyEnabledSetting() + { + try + { + new SettingsController().SetToggleHotkeyEnabled(_toggleHotkeyEnabled); + } + catch (Exception ex) + { + Log(ex); + } + } + + /// + /// Disables (never hides) the capture controls when unchecked - PR #153 hides them + /// instead, which makes the layout jump and conceals the parked key combination from a + /// user who might just want to glance at what is currently bound. + /// + private void checkBoxToggleHotkeyEnabled_CheckedChanged(object sender, EventArgs e) + { + _toggleHotkeyEnabled = this.checkBoxToggleHotkeyEnabled.Checked; + this.textBoxToggleHotkey.Enabled = _toggleHotkeyEnabled; + this.buttonClearToggleHotkey.Enabled = _toggleHotkeyEnabled; + + if (_isLoadingToggleHotkeyEnabled) + { + // ReadVibranceSettings' own explicit ApplyToggleHotkey(false) call is what applies + // this on load - see its comment. Saving here too would just write back the exact + // value this handler was given, on every single startup. + return; + } + + SaveToggleHotkeyEnabledSetting(); + ApplyToggleHotkey(true); + } + + // Releases the live registration the moment the textbox gains focus, so the CURRENT + // binding stops intercepting keystrokes meant for this field - closes PR #153's third + // defect (you could not rebind to anything containing the key combination already bound, + // because pressing it fired WM_HOTKEY - and toggled the engine - instead of the textbox's + // own KeyDown). + private void textBoxToggleHotkey_Enter(object sender, EventArgs e) + { + _hotkeyRegistration.Release(); + } + + // The other half of the same fix: re-applies _toggleBinding on the way out, whether or + // not KeyDown below ever actually changed it - if the user just clicked in and back out + // again with no key pressed, Enter's Release() above would otherwise leave the ORIGINAL + // binding unregistered with nothing left to restore it. + private void textBoxToggleHotkey_Leave(object sender, EventArgs e) + { + ApplyToggleHotkey(true); + } + + private void textBoxToggleHotkey_KeyDown(object sender, KeyEventArgs e) + { + e.SuppressKeyPress = true; + e.Handled = true; + + Keys keyCode = e.KeyCode; + // A bare modifier press (Ctrl/Alt/Shift/Win alone, before the real key follows) is + // not a complete binding yet - wait for the key that follows it instead of parsing + // "Ctrl" on its own. + if (keyCode == Keys.ControlKey || keyCode == Keys.Menu || keyCode == Keys.ShiftKey || + keyCode == Keys.LWin || keyCode == Keys.RWin) + { + return; + } + + List parts = new List(); + if (e.Control) parts.Add("Ctrl"); + if (e.Alt) parts.Add("Alt"); + if (e.Shift) parts.Add("Shift"); + if (IsWinKeyDown()) parts.Add("Win"); + parts.Add(keyCode.ToString()); + + HotkeyBinding parsedBinding; + if (!HotkeyBindingParser.TryParse(string.Join("+", parts.ToArray()), out parsedBinding)) + { + // A token this handler itself just built (a real Keys.KeyCode name) should never + // fail to parse - defensive only. Leaves the field showing whatever was bound + // before, rather than clearing a working binding over a key this widget cannot + // recognise. + return; + } + + _toggleBinding = parsedBinding; + this.textBoxToggleHotkey.Text = HotkeyBindingParser.Format(_toggleBinding); + SaveToggleHotkeySetting(); + ApplyToggleHotkey(true); + } + + private void buttonClearToggleHotkey_Click(object sender, EventArgs e) + { + _toggleBinding = HotkeyBinding.None; + this.textBoxToggleHotkey.Text = string.Empty; + SaveToggleHotkeySetting(); + ApplyToggleHotkey(true); + } + + /// + /// The WM_HOTKEY handler WndProc dispatches to. Guarded the same way the trackbar/checkbox + /// handlers above are (see e.g. checkBoxPrimaryMonitorOnly_CheckedChanged): _v can be null, + /// or not yet initialized, for the whole span between the handle existing and + /// backgroundWorker_DoWork actually finishing - a hotkey press in that window is a no-op, + /// not a null-reference crash. A failed foreground read (_foregroundWindowReader) is the + /// same kind of no-op - nothing to name in a log line, so nothing is logged for it either. + /// + private void OnToggleHotkeyPressed() + { + if (_v == null || !_v.GetVibranceInfo().isInitialized) + { + return; + } + + IntPtr hWnd; + string processName; + string processImagePath; + if (!_foregroundWindowReader.TryGetForeground(out hWnd, out processName, out processImagePath)) + { + return; + } + + ProfileToggleResult result = _v.ToggleForegroundProfile(hWnd, processName, processImagePath); + ApplyProfileToggleFeedback(result, processName, hWnd); + } + + /// + /// Everything ToggleForegroundProfile's single return value drives, in one place - the + /// tray presentation derives from this one function, not a second .ico that does not + /// exist. A no-op result (no configured game in the foreground, or the engine is not + /// ready yet) is deliberately silent - no balloon, no sound - so a hotkey pressed while + /// browsing the desktop does not interrupt anything; it is still logged once per distinct + /// process name, so "why didn't my hotkey do anything" is answerable from the log without + /// needing a UI signal that would otherwise fire on every ordinary alt-tab. foregroundWindow + /// is resolved to a device name only in the WriteFailed case, which is the only one that + /// needs it - Screen.FromHandle is a real Win32 call, not worth paying on every no-op + /// press (by far the most common outcome: everything that is not a configured game). + /// + private void ApplyProfileToggleFeedback(ProfileToggleResult result, string processName, IntPtr foregroundWindow) + { + switch (result) + { + case ProfileToggleResult.NoConfiguredGameInForeground: + LogNoOpToggleOnce(processName, string.Format( + "Toggle hotkey pressed while \"{0}\" was in the foreground, which has no configured profile - ignored.", processName)); + return; + case ProfileToggleResult.EngineNotReady: + LogNoOpToggleOnce(processName, string.Format( + "Toggle hotkey pressed while \"{0}\" was in the foreground, but vibranceGUI has not finished starting up yet - ignored.", processName)); + return; + case ProfileToggleResult.WriteFailed: + string deviceName = Screen.FromHandle(foregroundWindow).DeviceName; + this.notifyIcon.BalloonTipIcon = ToolTipIcon.Warning; + this.notifyIcon.BalloonTipText = string.Format("Could not toggle \"{0}\"'s profile on display {1}.", processName, deviceName); + this.notifyIcon.ShowBalloonTip(250); + return; + } + + bool toggledOn = result == ProfileToggleResult.ToggledOn; + + // Deliberately does NOT write notifyIcon.Text: a per-game "vibranceGUI - X OFF" tray + // tooltip has nothing that ever resets it, so it would keep asserting X's state long + // after X exits (or after a different game takes the foreground). The balloon below + // is the right place for "X just toggled" - it is transient by nature, so it cannot + // go stale the way a durable tray tooltip would. + this.notifyIcon.BalloonTipIcon = ToolTipIcon.Info; + this.notifyIcon.BalloonTipText = toggledOn + ? string.Format("\"{0}\"'s profile is running again.", processName) + : string.Format("\"{0}\"'s profile is suppressed - back at your Windows level until toggled again.", processName); + this.notifyIcon.ShowBalloonTip(250); + + // Ship it, on, no setting - SystemSounds respects whatever sound scheme (including + // "No Sounds") the user already has picked in Windows, so the opt-out already exists + // at OS level. Plays asynchronously, never blocks WndProc. + // + // NOT Exclamation/Asterisk: checked against this machine's actual sound scheme via + // the registry (HKCU\AppEvents\Schemes\Apps\.Default\\.Current) rather than + // assumed, and SystemAsterisk and SystemExclamation both resolve to the exact same + // file ("Windows Background.wav") there - two "distinct" sounds that would actually + // be identical, exactly the failure mode to check for before shipping this. Hand + // resolves to a different file ("Windows Foreground.wav") on the same machine, so + // Hand/Asterisk is the pair actually used here. Re-verify on the machine this ships + // to if the two ever sound the same again - schemes vary. + if (toggledOn) + { + SystemSounds.Asterisk.Play(); + } + else + { + SystemSounds.Hand.Play(); + } + } + + private void LogNoOpToggleOnce(string processName, string message) + { + string key = processName ?? string.Empty; + if (_loggedNoOpTogglePresses.Add(key)) + { + Program.LogSafely(message); + } + } + private void CleanUp() { try @@ -443,6 +905,10 @@ private void CleanUp() // comment for why leaving it subscribed leaks this form and can fault at shutdown. SystemEvents.DisplaySettingsChanged -= OnDisplaySettingsChanged; ResolutionHelper.ResolutionChangeFailed -= OnResolutionChangeFailed; + // One of the three layers that guarantee the toggle hotkey is unregistered - see + // HotkeyRegistration's own header comment. Idempotent: OnHandleDestroyed (below, + // via the form's own Dispose chain) may already have done this. + _hotkeyRegistration.Release(); } } @@ -622,6 +1088,33 @@ private void ReadVibranceSettings(out int vibranceWindowsLevel, out bool affectP checkBoxPrimaryMonitorOnly.Checked = affectPrimaryMonitorOnly; checkBoxNeverChangeResolutions.Checked = neverSwitchResolution; checkBoxNeverChangeColorSettings.Checked = neverChangeColorSettings; + + // The real registration point for the toggle hotkey - OnHandleCreated's own call + // always finds _toggleBinding still at HotkeyBinding.None (this is the first time + // it is ever read from disk). backgroundWorker_DoWork busy-waits on + // "!IsHandleCreated" and only tests InvokeRequired afterward (see its own comment), + // so the handle is guaranteed to already exist on every path that reaches here - + // the InvokeRequired-without-a-handle trap this.IsHandleCreated guards elsewhere in + // this class cannot bite in this block. + HotkeyBinding parsedToggleBinding; + _toggleBinding = HotkeyBindingParser.TryParse(settingsController.ReadToggleHotkey(), out parsedToggleBinding) + ? parsedToggleBinding + : HotkeyBinding.None; + textBoxToggleHotkey.Text = HotkeyBindingParser.Format(_toggleBinding); + + _toggleHotkeyEnabled = settingsController.ReadToggleHotkeyEnabled(); + // Setting Checked to a value equal to its current (designer-default, unchecked) + // value does not raise CheckedChanged at all - the explicit ApplyToggleHotkey(false) + // call below is what actually applies it on that common path, not this line. When + // the stored value IS true, the setter does raise it - _isLoadingToggleHotkeyEnabled + // is what keeps that from writing the same value straight back to the INI. + _isLoadingToggleHotkeyEnabled = true; + checkBoxToggleHotkeyEnabled.Checked = _toggleHotkeyEnabled; + _isLoadingToggleHotkeyEnabled = false; + textBoxToggleHotkey.Enabled = _toggleHotkeyEnabled; + buttonClearToggleHotkey.Enabled = _toggleHotkeyEnabled; + ApplyToggleHotkey(false); + foreach (ApplicationSetting application in _applicationSettings.ToList()) { if (!File.Exists(application.FileName)) @@ -997,6 +1490,11 @@ private void listApplications_DoubleClick(object sender, EventArgs e) if (result == DialogResult.OK) { ApplicationSetting newSetting = settingsWindow.GetApplicationSetting(); + // "Change executable..." (or any edit that changes Name - Name is + // Path.GetFileNameWithoutExtension of the executable, VibranceSettings. + // resolveApplicationName) moves this profile off the Name the toggle hotkey's + // suppression set is keyed by. + ClearSuppressionIfNameChanged(actualSetting, newSetting.Name); RemoveSettingByFileName(originalFileName); RemoveSettingByFileName(newSetting.FileName); _applicationSettings.Add(newSetting); @@ -1046,13 +1544,36 @@ private void buttonRemoveProgram_Click(object sender, EventArgs e) listApplications.Items[i].ImageIndex--; removeApplicationListItem(eachItem); - _applicationSettings.Remove(_applicationSettings.FirstOrDefault(x => x.FileName.Equals(eachItem.Tag.ToString()))); + ApplicationSetting removedSetting = _applicationSettings.FirstOrDefault(x => x.FileName.Equals(eachItem.Tag.ToString())); + if (removedSetting != null) + { + ClearSuppressionIfNameChanged(removedSetting, null); + _applicationSettings.Remove(removedSetting); + } } RefreshUnconfirmedCache(); ForceSaveVibranceSettings(); } + /// + /// Clears any toggle-hotkey suppression recorded under oldSetting's own Name whenever + /// this profile is removed outright (newName: null) or edited such that Name no longer + /// matches (e.g. "Change executable..."). Without this, a stale suppression under the OLD + /// Name would silently apply to whatever unrelated profile happens to get that Name later + /// (e.g. two different games both shipping a "launcher.exe") - with no action from that + /// user and nothing in the UI explaining why it starts at the Windows level instead of + /// its own. Extracted so ProfileToggleFixture can call it directly (same assembly, + /// internal) instead of reflecting into either private UI handler. + /// + internal static void ClearSuppressionIfNameChanged(ApplicationSetting oldSetting, string newName) + { + if (oldSetting != null && !string.Equals(oldSetting.Name, newName, StringComparison.OrdinalIgnoreCase)) + { + ProfileToggleHelper.SetSuppressed(oldSetting.Name, false); + } + } + private void removeApplicationListItem(ListViewItem item) { Image img = this.listApplications.LargeImageList.Images[item.ImageIndex]; diff --git a/vibrance.GUI/common/VibranceRestoreFixture.cs b/vibrance.GUI/common/VibranceRestoreFixture.cs index 96234da..1c4b197 100644 --- a/vibrance.GUI/common/VibranceRestoreFixture.cs +++ b/vibrance.GUI/common/VibranceRestoreFixture.cs @@ -1015,10 +1015,14 @@ public void SetSaturationOnAllDisplays(int vibranceLevel) LastSetSaturationOnAllDisplaysLevel = vibranceLevel; } - public void SetSaturationOnDisplay(int vibranceLevel, string displayName) + // Always reports success (upstream #143 gave the real interface a bool return) - none + // of this file's own checks need a failure path (ProfileToggleFixture has its own, + // separate fake for that), so behaviour here is unchanged. + public bool SetSaturationOnDisplay(int vibranceLevel, string displayName) { SetSaturationOnDisplayLevels.Add(vibranceLevel); SetSaturationOnDisplayNames.Add(displayName); + return true; } public bool IsAvailable() diff --git a/vibrance.GUI/vibrance.GUI.csproj b/vibrance.GUI/vibrance.GUI.csproj index e5ce99f..b5800ff 100644 --- a/vibrance.GUI/vibrance.GUI.csproj +++ b/vibrance.GUI/vibrance.GUI.csproj @@ -172,6 +172,12 @@ + + + + + +