From 973af2e19967098407a3cfa43ab0a509e4474ab4 Mon Sep 17 00:00:00 2001 From: Shahrukh Date: Thu, 6 Aug 2026 14:20:09 +0500 Subject: [PATCH 1/6] Add Keep Nvidia Instant Replay on mod Watches ShadowPlay's Instant Replay state and switches it back on whenever something turns it off, so Instant Replay isn't quietly disabled when you needed the clip you didn't get. The state is read from HKCU and watched with RegNotifyChangeKeyValue, so the mod reacts the moment the state changes rather than only on a poll. Changing it is done by replaying the user's configured Toggle Instant Replay shortcut. Optional process lists can pause the mod, or restrict Instant Replay to only be on while chosen programs run. Co-Authored-By: Claude Opus 5 (1M context) --- mods/nvidia-keep-instant-replay-on.wh.cpp | 554 ++++++++++++++++++++++ 1 file changed, 554 insertions(+) create mode 100644 mods/nvidia-keep-instant-replay-on.wh.cpp diff --git a/mods/nvidia-keep-instant-replay-on.wh.cpp b/mods/nvidia-keep-instant-replay-on.wh.cpp new file mode 100644 index 0000000000..0d531982f8 --- /dev/null +++ b/mods/nvidia-keep-instant-replay-on.wh.cpp @@ -0,0 +1,554 @@ +// ==WindhawkMod== +// @id nvidia-keep-instant-replay-on +// @name Keep Nvidia Instant Replay on +// @description Turns Nvidia ShadowPlay's Instant Replay back on whenever something switches it off +// @version 1.0 +// @author Shahrukh +// @github https://github.com/dixxi1208 +// @homepage https://github.com/dixxi1208/NvidiaInstantReplayFix +// @include nvcontainer.exe +// ==/WindhawkMod== + +// ==WindhawkModReadme== +/* +# Keep Nvidia Instant Replay on + +Instant Replay has a habit of quietly switching itself off - after a driver update, a crash, +a stray hotkey, or a game that toggles it - and you only find out when you needed the clip +you didn't get. + +This mod watches the Instant Replay state and switches it straight back on. It's the job +[AlwaysShadow](https://github.com/Verpous/AlwaysShadow) does, except it runs inside +NvContainer, so there's no extra program to install, no tray icon, and nothing to remember +to start. + +It watches the registry key Nvidia stores the state in, so it reacts the moment the state +changes rather than waiting for the next poll. And if it finds itself fighting with something +that keeps switching Instant Replay back off, it backs off instead of flip-flopping forever. + +## Requirements + +The In-Game Overlay must be enabled, and you need a "Toggle Instant Replay" keyboard shortcut +configured - that shortcut is how this mod switches Instant Replay. + +**Change that shortcut away from the Alt+Shift+F10 default.** Alt+Shift is also the Windows +shortcut for cycling the keyboard layout, so leaving it at the default means every automatic +re-enable may also change your input language. Ctrl+Shift+F10 works well. The new shortcut is +picked up automatically, no restart needed. + +## Notes + +Works with the Nvidia App and with the old GeForce Experience. AlwaysShadow prefers a local +HTTP server hosted by NvContainer and only falls back to the shortcut; that server came from +GeForce Experience's NvNode backend, which the Nvidia App doesn't ship, so this mod uses the +shortcut only - it's the path that works on both. + +Windhawk injects into every nvcontainer.exe, but only one of them does the work. The instances +running under other accounts can't read your settings and stay out of the way on their own. +*/ +// ==/WindhawkModReadme== + +// ==WindhawkModSettings== +/* +- pollIntervalSec: 10 + $name: Check interval (seconds) + $description: >- + How often to re-check the Instant Replay state. The state is also re-checked immediately + whenever Nvidia writes to the ShadowPlay registry key, so this is mostly a safety net. +- conflictBackoffSec: 800 + $name: Conflict backoff (seconds) + $description: >- + If Instant Replay keeps turning itself back off, something is fighting us. After 3 + attempts in a row, stop trying for this long instead of flip-flopping. Set to 0 to keep + retrying regardless. +- pauseWhileRunning: [""] + $name: Pause while these programs run + $description: >- + Case-insensitive substrings matched against the full path of every running process, + e.g. netflix.exe. While any of them is running, Instant Replay is left alone. + Leave the single empty entry to disable this. +- onlyWhileRunning: [""] + $name: Only keep it on while these programs run + $description: >- + Same matching. If this list is not empty, Instant Replay is forced ON while at least one + of these is running and forced OFF the rest of the time. + Leave the single empty entry to disable this. +*/ +// ==/WindhawkModSettings== + +#include +#include + +#include +#include +#include +#include +#include + +// Nvidia exposes two things this mod needs, both under the same registry key: +// +// - The Instant Replay state, in the value {1B1D3DAA-...}. The key can be watched with +// RegNotifyChangeKeyValue, so we notice a change the moment it happens. +// - The "Toggle Instant Replay" shortcut, as IRToggleHKeyCount plus one IRToggleHKey +// per key. Pressing it is how the state gets changed. +// +// Everything in the key is stored as 4 byte REG_BINARY rather than REG_DWORD, which is why +// the reads below accept both. + +static PCWSTR kShadowPlayRegKey = L"SOFTWARE\\NVIDIA Corporation\\Global\\ShadowPlay\\NVSPCAPS"; +static PCWSTR kInstantReplayRegValue = L"{1B1D3DAA-601D-49E5-8508-81736CA28C6D}"; + +// Windhawk injects into every nvcontainer.exe, but only one of them should be driving this. +// The name is session local on purpose: the state we read lives in the per user hive. +static PCWSTR kWatchdogMutexName = L"Local\\WindhawkKeepInstantReplayOn"; + +// How many cycles in a row may want a toggle before we decide we're fighting someone. +static const int kMinStreakForConflict = 3; +static_assert(kMinStreakForConflict >= 2, "At least 2 attempts (1 retry) are needed to identify a conflict."); + +// Don't press the shortcut faster than this, no matter how many notifications arrive. +static const DWORD kMinToggleIntervalMs = 3000; + +// Nvidia writes several values when the state changes, so let it settle before reading back. +static const DWORD kRegistryNotifyDebounceMs = 750; + +struct ModSettings { + int pollIntervalSec = 10; + int conflictBackoffSec = 800; + std::vector pauseWhileRunning; + std::vector onlyWhileRunning; +}; + +static std::mutex g_settingsMutex; +static ModSettings g_settings; + +static HANDLE g_watchdogThread = NULL; +static HANDLE g_stopEvent = NULL; // manual reset, tells the watchdog to quit +static HANDLE g_wakeEvent = NULL; // auto reset, tells the watchdog settings changed +static HANDLE g_watchdogMutex = NULL; // held by whichever nvcontainer.exe drives the watchdog + +enum class InstantReplayState { + Off, + On, + Unknown, +}; + +#pragma region Settings + +static void LoadStringListSetting(PCWSTR nameFormat, std::vector* out) { + out->clear(); + + for (int i = 0;; i++) { + PCWSTR value = Wh_GetStringSetting(nameFormat, i); + bool isEmpty = !*value; + if (!isEmpty) { + out->emplace_back(value); + } + Wh_FreeStringSetting(value); + + // An empty entry marks the end of the list, same as every other Windhawk mod. + if (isEmpty) { + break; + } + } +} + +static int Clamp(int value, int low, int high) { + return value < low ? low : (value > high ? high : value); +} + +static void LoadSettings() { + ModSettings settings; + settings.pollIntervalSec = Clamp(Wh_GetIntSetting(L"pollIntervalSec"), 1, 3600); + settings.conflictBackoffSec = Clamp(Wh_GetIntSetting(L"conflictBackoffSec"), 0, 86400); + LoadStringListSetting(L"pauseWhileRunning[%d]", &settings.pauseWhileRunning); + LoadStringListSetting(L"onlyWhileRunning[%d]", &settings.onlyWhileRunning); + + std::lock_guard lock(g_settingsMutex); + g_settings = std::move(settings); +} + +static ModSettings GetSettings() { + std::lock_guard lock(g_settingsMutex); + return g_settings; +} + +#pragma endregion // Settings + +#pragma region Reading the state + +// Returns Unknown when the value can't be read, which is the normal case for the +// nvcontainer.exe instances that don't run as the logged in user - their HKCU is a different +// hive. Treating that as Unknown (rather than as "off") keeps those instances from acting. +static InstantReplayState GetInstantReplayState() { + DWORD type = 0; + DWORD isActive = 0; + DWORD size = sizeof(isActive); + LSTATUS ret = RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, kInstantReplayRegValue, + RRF_RT_ANY, &type, &isActive, &size); + + if (ret != ERROR_SUCCESS) { + return InstantReplayState::Unknown; + } + + if ((type != REG_DWORD && type != REG_BINARY) || size != sizeof(DWORD)) { + return InstantReplayState::Unknown; + } + + return isActive ? InstantReplayState::On : InstantReplayState::Off; +} + +#pragma endregion // Reading the state + +#pragma region Changing the state + +static void AddKeyInput(std::vector* inputs, WORD vkey, bool isDown) { + INPUT input = {}; + input.type = INPUT_KEYBOARD; + input.ki.wVk = vkey; + input.ki.dwFlags = isDown ? 0 : KEYEVENTF_KEYUP; + inputs->push_back(input); +} + +// Note this *toggles*, it doesn't set a state, so only call it when the current state is known. +// The shortcut is read fresh every time, so changing it in the Nvidia App takes effect at once. +static bool ToggleInstantReplayWithHotkey() { + std::vector keys; + + DWORD keyCount = 0; + DWORD size = sizeof(keyCount); + LSTATUS ret = RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, L"IRToggleHKeyCount", + RRF_RT_DWORD, NULL, &keyCount, &size); + + if (ret == ERROR_SUCCESS && keyCount > 0 && keyCount <= 8) { + // Each key of the shortcut is stored in its own value: IRToggleHKey0, IRToggleHKey1, ... + for (DWORD i = 0; i < keyCount; i++) { + std::wstring valueName = L"IRToggleHKey" + std::to_wstring(i); + + DWORD vkey = 0; + size = sizeof(vkey); + if (RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, valueName.c_str(), RRF_RT_DWORD, + NULL, &vkey, &size) != ERROR_SUCCESS) { + Wh_Log(L"Couldn't read %s, can't press the toggle shortcut", valueName.c_str()); + return false; + } + + keys.push_back((WORD)vkey); + } + } else { + // Nvidia's default shortcut. + Wh_Log(L"No toggle shortcut configured, assuming the default Alt+Shift+F10"); + keys = {VK_MENU, VK_SHIFT, VK_F10}; + } + + std::vector inputs; + inputs.reserve(keys.size() * 2); + + for (size_t i = 0; i < keys.size(); i++) { + AddKeyInput(&inputs, keys[i], true); + } + for (size_t i = keys.size(); i > 0; i--) { + AddKeyInput(&inputs, keys[i - 1], false); + } + + UINT sent = SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)); + if (sent != inputs.size()) { + Wh_Log(L"SendInput only sent %u of %zu inputs, error %u", sent, inputs.size(), GetLastError()); + return false; + } + + return true; +} + +#pragma endregion // Changing the state + +#pragma region Process matching + +static bool ContainsNoCase(const std::wstring& haystack, const std::wstring& needle) { + if (needle.empty() || needle.size() > haystack.size()) { + return false; + } + + auto match = std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(), + [](wchar_t a, wchar_t b) { return towlower(a) == towlower(b); }); + return match != haystack.end(); +} + +// AlwaysShadow matches against WMI command lines. Spinning up COM inside NvContainer to do the +// same would be rude, so this matches against the full image path instead, which covers the +// realistic cases (an exe name, or a path fragment) without touching COM. +static bool IsAnyProcessRunning(const std::vector& patterns) { + if (patterns.empty()) { + return false; + } + + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + Wh_Log(L"CreateToolhelp32Snapshot failed, error %u", GetLastError()); + return false; + } + + bool found = false; + PROCESSENTRY32W entry = {}; + entry.dwSize = sizeof(entry); + + if (Process32FirstW(snapshot, &entry)) { + do { + std::wstring candidate = entry.szExeFile; + + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID); + if (process) { + WCHAR path[MAX_PATH]; + DWORD pathLen = ARRAYSIZE(path); + if (QueryFullProcessImageNameW(process, 0, path, &pathLen)) { + candidate.assign(path, pathLen); + } + CloseHandle(process); + } + + for (const std::wstring& pattern : patterns) { + if (ContainsNoCase(candidate, pattern)) { + Wh_Log(L"Process match: '%s' matches '%s'", candidate.c_str(), pattern.c_str()); + found = true; + break; + } + } + } while (!found && Process32NextW(snapshot, &entry)); + } + + CloseHandle(snapshot); + return found; +} + +#pragma endregion // Process matching + +#pragma region Watchdog + +static bool StopRequested(DWORD waitMs) { + return WaitForSingleObject(g_stopEvent, waitMs) == WAIT_OBJECT_0; +} + +// Whichever instance gets here first owns the job; if it exits, the mutex object goes away and +// another instance picks it up on its next cycle. +static bool ClaimWatchdogRole() { + if (g_watchdogMutex) { + return true; + } + + HANDLE mutex = CreateMutexW(NULL, FALSE, kWatchdogMutexName); + if (!mutex) { + Wh_Log(L"CreateMutex for the watchdog role failed, error %u", GetLastError()); + return false; + } + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + CloseHandle(mutex); + return false; + } + + g_watchdogMutex = mutex; + Wh_Log(L"This nvcontainer.exe instance is now the Instant Replay watchdog"); + return true; +} + +static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { + Wh_Log(L"Instant Replay watchdog started"); + + // Waiting on a registry notification means we react the moment something turns Instant + // Replay off, instead of up to a whole poll interval later. + HKEY notifyKey = NULL; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kShadowPlayRegKey, 0, KEY_NOTIFY, ¬ifyKey) != ERROR_SUCCESS) { + notifyKey = NULL; + } + + HANDLE notifyEvent = CreateEventW(NULL, TRUE, FALSE, NULL); + bool notifyArmed = false; + + int toggleStreak = 0; + ULONGLONG conflictUntil = 0; + ULONGLONG nextToggleAllowed = 0; + bool loggedUnreadableState = false; + + for (;;) { + ModSettings settings = GetSettings(); + + if (notifyKey && notifyEvent && !notifyArmed) { + ResetEvent(notifyEvent); + notifyArmed = RegNotifyChangeKeyValue(notifyKey, FALSE, REG_NOTIFY_CHANGE_LAST_SET, + notifyEvent, TRUE) == ERROR_SUCCESS; + } + + HANDLE waitHandles[3]; + DWORD waitCount = 0; + waitHandles[waitCount++] = g_stopEvent; + waitHandles[waitCount++] = g_wakeEvent; + if (notifyArmed) { + waitHandles[waitCount++] = notifyEvent; + } + + DWORD waited = WaitForMultipleObjects(waitCount, waitHandles, FALSE, + (DWORD)settings.pollIntervalSec * 1000); + + if (waited == WAIT_OBJECT_0) { + break; + } + + if (waited == WAIT_FAILED) { + // Shouldn't happen, but don't spin on it if it does. + Wh_Log(L"WaitForMultipleObjects failed, error %u", GetLastError()); + if (StopRequested(1000)) { + break; + } + continue; + } + + if (waited == WAIT_OBJECT_0 + 1) { + // Settings changed. Give the user's new configuration a clean slate. + settings = GetSettings(); + toggleStreak = 0; + conflictUntil = 0; + } else if (waited == WAIT_OBJECT_0 + 2) { + notifyArmed = false; + if (StopRequested(kRegistryNotifyDebounceMs)) { + break; + } + } + + ULONGLONG now = GetTickCount64(); + if (now < conflictUntil) { + continue; + } + + InstantReplayState state = GetInstantReplayState(); + if (state == InstantReplayState::Unknown) { + if (!loggedUnreadableState) { + loggedUnreadableState = true; + Wh_Log(L"Can't read the Instant Replay state from HKCU. Either ShadowPlay isn't " + L"set up for this user, or this nvcontainer.exe runs under another account. " + L"Staying out of the way."); + } + continue; + } + loggedUnreadableState = false; + + if (!ClaimWatchdogRole()) { + continue; + } + + bool isOn = state == InstantReplayState::On; + bool shouldBeOn = settings.onlyWhileRunning.empty() || + IsAnyProcessRunning(settings.onlyWhileRunning); + + if (isOn == shouldBeOn) { + toggleStreak = 0; + continue; + } + + if (IsAnyProcessRunning(settings.pauseWhileRunning)) { + toggleStreak = 0; + continue; + } + + // A burst of registry notifications shouldn't turn into a burst of keystrokes. + if (now < nextToggleAllowed) { + if (StopRequested((DWORD)(nextToggleAllowed - now))) { + break; + } + now = GetTickCount64(); + } + + // If we keep having to fix it, something is fixing it right back. Yield for a while + // rather than flip-flopping forever. + if (++toggleStreak >= kMinStreakForConflict) { + conflictUntil = now + (ULONGLONG)settings.conflictBackoffSec * 1000; + toggleStreak = kMinStreakForConflict - 2; + Wh_Log(L"Something keeps changing Instant Replay back. Backing off for %d seconds.", + settings.conflictBackoffSec); + continue; + } + + nextToggleAllowed = now + kMinToggleIntervalMs; + Wh_Log(L"Instant Replay is %s but should be %s, pressing the toggle shortcut", + isOn ? L"on" : L"off", shouldBeOn ? L"on" : L"off"); + + // Success here only means the input was injected - if Nvidia ignores it the state won't + // change, the next cycles will notice, and the conflict backoff above stops us hammering + // it forever. + if (!ToggleInstantReplayWithHotkey()) { + Wh_Log(L"Failed to send the Instant Replay toggle shortcut"); + } + } + + if (notifyEvent) { + CloseHandle(notifyEvent); + } + if (notifyKey) { + RegCloseKey(notifyKey); + } + + Wh_Log(L"Instant Replay watchdog stopped"); + return 0; +} + +#pragma endregion // Watchdog + +BOOL Wh_ModInit() { + Wh_Log(L"Init"); + LoadSettings(); + return TRUE; +} + +void Wh_ModAfterInit() { + // Started here rather than in Wh_ModInit to keep init itself cheap. + g_stopEvent = CreateEventW(NULL, TRUE, FALSE, NULL); + g_wakeEvent = CreateEventW(NULL, FALSE, FALSE, NULL); + + if (!g_stopEvent || !g_wakeEvent) { + Wh_Log(L"Failed to create the watchdog events, error %u", GetLastError()); + return; + } + + g_watchdogThread = CreateThread(NULL, 0, InstantReplayWatchdogThread, NULL, 0, NULL); + if (!g_watchdogThread) { + Wh_Log(L"Failed to start the watchdog thread, error %u", GetLastError()); + } +} + +void Wh_ModSettingsChanged() { + Wh_Log(L"Settings changed"); + LoadSettings(); + + if (g_wakeEvent) { + SetEvent(g_wakeEvent); + } +} + +void Wh_ModBeforeUninit() { + // The thread must be gone before this DLL is unloaded. + if (g_stopEvent) { + SetEvent(g_stopEvent); + } + + if (g_watchdogThread) { + WaitForSingleObject(g_watchdogThread, INFINITE); + CloseHandle(g_watchdogThread); + g_watchdogThread = NULL; + } +} + +void Wh_ModUninit() { + Wh_Log(L"Uninit"); + + if (g_watchdogMutex) { + CloseHandle(g_watchdogMutex); + g_watchdogMutex = NULL; + } + if (g_wakeEvent) { + CloseHandle(g_wakeEvent); + g_wakeEvent = NULL; + } + if (g_stopEvent) { + CloseHandle(g_stopEvent); + g_stopEvent = NULL; + } +} From 9b2884576e14db54f8e5a2021d2570c80ff5d720 Mon Sep 17 00:00:00 2001 From: Shahrukh Date: Thu, 6 Aug 2026 15:01:29 +0500 Subject: [PATCH 2/6] Convert to a tool mod and harden the process checks Review feedback from the windhawk-mods submission: - Run as a tool mod in a dedicated windhawk.exe process instead of injecting into nvcontainer.exe. The mod installs no hooks and reads no NvContainer state, so there was no reason to be in-process. This removes the hand-rolled single-instance mutex, the per-instance watchdog thread and registry notification in every nvcontainer.exe, and the ambiguity about which user's hive HKCU refers to. - IsAnyProcessRunning now returns std::optional so a failed process enumeration is distinguishable from nothing-matched. Previously a transient CreateToolhelp32Snapshot failure with a non-empty onlyWhileRunning list would make the mod switch Instant Replay off while a game was running. - Backing off no longer blinds the mod: it keeps reading the state and clears the backoff as soon as Instant Replay is where it should be, so a resolved conflict recovers immediately instead of after the full wait. - Backoff is now exponential, 30s doubling to the configured maximum, so a one-off failure costs seconds rather than the full period. - Program lists match the exact file name unless the entry contains a backslash, in which case it is a path substring. Avoids notepad matching notepad++.exe. - Case-insensitive matching uses CompareStringOrdinal instead of towlower, which only folds ASCII under the default locale. - Use WindhawkUtils::StringSetting and std::clamp; close the registry key before the notification event it refers to. Co-Authored-By: Claude Opus 5 (1M context) --- mods/nvidia-keep-instant-replay-on.wh.cpp | 593 ++++++++++++++++------ 1 file changed, 425 insertions(+), 168 deletions(-) diff --git a/mods/nvidia-keep-instant-replay-on.wh.cpp b/mods/nvidia-keep-instant-replay-on.wh.cpp index 0d531982f8..c85db9e3e6 100644 --- a/mods/nvidia-keep-instant-replay-on.wh.cpp +++ b/mods/nvidia-keep-instant-replay-on.wh.cpp @@ -6,7 +6,9 @@ // @author Shahrukh // @github https://github.com/dixxi1208 // @homepage https://github.com/dixxi1208/NvidiaInstantReplayFix -// @include nvcontainer.exe +// @license MIT +// @include windhawk.exe +// @compilerOptions -lshell32 -luser32 -ladvapi32 // ==/WindhawkMod== // ==WindhawkModReadme== @@ -18,14 +20,31 @@ a stray hotkey, or a game that toggles it - and you only find out when you neede you didn't get. This mod watches the Instant Replay state and switches it straight back on. It's the job -[AlwaysShadow](https://github.com/Verpous/AlwaysShadow) does, except it runs inside -NvContainer, so there's no extra program to install, no tray icon, and nothing to remember -to start. +[AlwaysShadow](https://github.com/Verpous/AlwaysShadow) does, except it runs as a Windhawk +mod, so there's no extra program to install, no tray icon, and nothing to remember to start. It watches the registry key Nvidia stores the state in, so it reacts the moment the state changes rather than waiting for the next poll. And if it finds itself fighting with something that keeps switching Instant Replay back off, it backs off instead of flip-flopping forever. +This is a tool mod: it hooks nothing and injects into nothing, it just runs in its own +dedicated process as the logged-in user. + +## How is this different from "Shadowplay anti-disable"? + +They solve opposite halves of the same annoyance and work well together: + +- **Shadowplay anti-disable** stops ShadowPlay from switching *itself* off in the first place, + for one specific cause - the driver refusing to record when it thinks DRM content or a + capture-excluded window is on screen. Use it if your recording stops when you open Netflix + or certain apps. +- **This mod** doesn't care *why* Instant Replay went off. It notices that it did, and turns + it back on. Use it if you keep discovering Instant Replay was silently off. + +Neither interferes with the other. That mod patches the driver inside NvContainer and never +changes the Instant Replay setting; this one runs in its own process, only reads the setting, +and presses your toggle shortcut. + ## Requirements The In-Game Overlay must be enabled, and you need a "Toggle Instant Replay" keyboard shortcut @@ -36,15 +55,19 @@ shortcut for cycling the keyboard layout, so leaving it at the default means eve re-enable may also change your input language. Ctrl+Shift+F10 works well. The new shortcut is picked up automatically, no restart needed. +Because the shortcut is replayed as real keystrokes, whatever window has focus also receives +the combination. That is inherent to this approach - the Nvidia App no longer ships the local +server that GeForce Experience used to expose, so the shortcut is the only way left to switch +Instant Replay from outside. + ## Notes -Works with the Nvidia App and with the old GeForce Experience. AlwaysShadow prefers a local -HTTP server hosted by NvContainer and only falls back to the shortcut; that server came from -GeForce Experience's NvNode backend, which the Nvidia App doesn't ship, so this mod uses the -shortcut only - it's the path that works on both. +Works with the Nvidia App and with the old GeForce Experience. -Windhawk injects into every nvcontainer.exe, but only one of them does the work. The instances -running under other accounts can't read your settings and stay out of the way on their own. +If you use **Only keep it on while these programs run** and then disable the mod at a moment +when none of those programs are running, Instant Replay is left off - the mod turned it off on +purpose and isn't around any more to turn it back on. That's the one case where switching the +mod off doesn't restore what you had before. */ // ==/WindhawkModReadme== @@ -55,34 +78,41 @@ running under other accounts can't read your settings and stay out of the way on $description: >- How often to re-check the Instant Replay state. The state is also re-checked immediately whenever Nvidia writes to the ShadowPlay registry key, so this is mostly a safety net. -- conflictBackoffSec: 800 - $name: Conflict backoff (seconds) +- conflictBackoffSec: 900 + $name: Maximum conflict backoff (seconds) $description: >- - If Instant Replay keeps turning itself back off, something is fighting us. After 3 - attempts in a row, stop trying for this long instead of flip-flopping. Set to 0 to keep - retrying regardless. + If Instant Replay keeps turning itself back off, something is fighting us. After 2 + attempts in a row that don't stick, wait before trying again instead of flip-flopping - + 30 seconds at first, doubling each round up to this limit, and reset as soon as Instant + Replay is in the state it should be. Set to 0 to keep retrying and never back off. - pauseWhileRunning: [""] $name: Pause while these programs run $description: >- - Case-insensitive substrings matched against the full path of every running process, - e.g. netflix.exe. While any of them is running, Instant Replay is left alone. + While any of these is running, Instant Replay is left alone. An entry without a backslash + matches a process by exact file name, e.g. netflix.exe. An entry containing a backslash is + matched as a substring of the full image path, e.g. \Netflix\ - useful for covering a whole + install folder. Matching ignores case either way. Leave the single empty entry to disable this. - onlyWhileRunning: [""] $name: Only keep it on while these programs run $description: >- - Same matching. If this list is not empty, Instant Replay is forced ON while at least one - of these is running and forced OFF the rest of the time. + Same matching. If this list is not empty, Instant Replay is forced ON while at least one of + these is running and forced OFF the rest of the time. Leave the single empty entry to disable this. */ // ==/WindhawkModSettings== -#include #include +#include + +#include +#include #include -#include #include +#include #include +#include #include // Nvidia exposes two things this mod needs, both under the same registry key: @@ -98,13 +128,16 @@ running under other accounts can't read your settings and stay out of the way on static PCWSTR kShadowPlayRegKey = L"SOFTWARE\\NVIDIA Corporation\\Global\\ShadowPlay\\NVSPCAPS"; static PCWSTR kInstantReplayRegValue = L"{1B1D3DAA-601D-49E5-8508-81736CA28C6D}"; -// Windhawk injects into every nvcontainer.exe, but only one of them should be driving this. -// The name is session local on purpose: the state we read lives in the per user hive. -static PCWSTR kWatchdogMutexName = L"Local\\WindhawkKeepInstantReplayOn"; +// How many toggles in a row may fail to stick before we conclude we're fighting someone. +static const int kMaxTogglesBeforeBackoff = 2; -// How many cycles in a row may want a toggle before we decide we're fighting someone. -static const int kMinStreakForConflict = 3; -static_assert(kMinStreakForConflict >= 2, "At least 2 attempts (1 retry) are needed to identify a conflict."); +// Coming out of a backoff, allow one more toggle before concluding we're still in conflict, +// so a conflict that has since resolved recovers on the very next attempt. +static const int kStreakAfterBackoff = kMaxTogglesBeforeBackoff - 1; + +// Where the backoff starts before doubling towards the configured limit. Short enough that a +// one-off failure costs almost nothing, and it only grows if the fight is real. +static const int kInitialBackoffSec = 30; // Don't press the shortcut faster than this, no matter how many notifications arrive. static const DWORD kMinToggleIntervalMs = 3000; @@ -114,18 +147,19 @@ static const DWORD kRegistryNotifyDebounceMs = 750; struct ModSettings { int pollIntervalSec = 10; - int conflictBackoffSec = 800; + int conflictBackoffSec = 900; std::vector pauseWhileRunning; std::vector onlyWhileRunning; }; +// WhTool_ModSettingsChanged runs on Windhawk's thread while the watchdog thread is reading, +// so the settings are swapped under a lock and the watchdog takes a copy per cycle. static std::mutex g_settingsMutex; static ModSettings g_settings; -static HANDLE g_watchdogThread = NULL; -static HANDLE g_stopEvent = NULL; // manual reset, tells the watchdog to quit -static HANDLE g_wakeEvent = NULL; // auto reset, tells the watchdog settings changed -static HANDLE g_watchdogMutex = NULL; // held by whichever nvcontainer.exe drives the watchdog +static HANDLE g_watchdogThread = nullptr; +static HANDLE g_stopEvent = nullptr; // manual reset, tells the watchdog to quit +static HANDLE g_wakeEvent = nullptr; // auto reset, tells the watchdog settings changed enum class InstantReplayState { Off, @@ -139,28 +173,21 @@ static void LoadStringListSetting(PCWSTR nameFormat, std::vector* out->clear(); for (int i = 0;; i++) { - PCWSTR value = Wh_GetStringSetting(nameFormat, i); - bool isEmpty = !*value; - if (!isEmpty) { - out->emplace_back(value); - } - Wh_FreeStringSetting(value); + auto value = WindhawkUtils::StringSetting::make(nameFormat, i); // An empty entry marks the end of the list, same as every other Windhawk mod. - if (isEmpty) { + if (!*value.get()) { break; } - } -} -static int Clamp(int value, int low, int high) { - return value < low ? low : (value > high ? high : value); + out->emplace_back(value.get()); + } } static void LoadSettings() { ModSettings settings; - settings.pollIntervalSec = Clamp(Wh_GetIntSetting(L"pollIntervalSec"), 1, 3600); - settings.conflictBackoffSec = Clamp(Wh_GetIntSetting(L"conflictBackoffSec"), 0, 86400); + settings.pollIntervalSec = std::clamp(Wh_GetIntSetting(L"pollIntervalSec"), 1, 3600); + settings.conflictBackoffSec = std::clamp(Wh_GetIntSetting(L"conflictBackoffSec"), 0, 86400); LoadStringListSetting(L"pauseWhileRunning[%d]", &settings.pauseWhileRunning); LoadStringListSetting(L"onlyWhileRunning[%d]", &settings.onlyWhileRunning); @@ -173,13 +200,13 @@ static ModSettings GetSettings() { return g_settings; } -#pragma endregion // Settings +#pragma endregion // Settings #pragma region Reading the state -// Returns Unknown when the value can't be read, which is the normal case for the -// nvcontainer.exe instances that don't run as the logged in user - their HKCU is a different -// hive. Treating that as Unknown (rather than as "off") keeps those instances from acting. +// Returns Unknown when the value can't be read, which is what happens when ShadowPlay isn't +// installed or has never been configured. Treating that as Unknown rather than as "off" keeps +// the mod from pressing the shortcut on a machine that has nothing to toggle. static InstantReplayState GetInstantReplayState() { DWORD type = 0; DWORD isActive = 0; @@ -198,7 +225,7 @@ static InstantReplayState GetInstantReplayState() { return isActive ? InstantReplayState::On : InstantReplayState::Off; } -#pragma endregion // Reading the state +#pragma endregion // Reading the state #pragma region Changing the state @@ -218,7 +245,7 @@ static bool ToggleInstantReplayWithHotkey() { DWORD keyCount = 0; DWORD size = sizeof(keyCount); LSTATUS ret = RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, L"IRToggleHKeyCount", - RRF_RT_DWORD, NULL, &keyCount, &size); + RRF_RT_DWORD, nullptr, &keyCount, &size); if (ret == ERROR_SUCCESS && keyCount > 0 && keyCount <= 8) { // Each key of the shortcut is stored in its own value: IRToggleHKey0, IRToggleHKey1, ... @@ -227,8 +254,8 @@ static bool ToggleInstantReplayWithHotkey() { DWORD vkey = 0; size = sizeof(vkey); - if (RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, valueName.c_str(), RRF_RT_DWORD, - NULL, &vkey, &size) != ERROR_SUCCESS) { + if (RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, valueName.c_str(), + RRF_RT_DWORD, nullptr, &vkey, &size) != ERROR_SUCCESS) { Wh_Log(L"Couldn't read %s, can't press the toggle shortcut", valueName.c_str()); return false; } @@ -253,31 +280,66 @@ static bool ToggleInstantReplayWithHotkey() { UINT sent = SendInput((UINT)inputs.size(), inputs.data(), sizeof(INPUT)); if (sent != inputs.size()) { - Wh_Log(L"SendInput only sent %u of %zu inputs, error %u", sent, inputs.size(), GetLastError()); + Wh_Log(L"SendInput only sent %u of %zu inputs, error %u", sent, inputs.size(), + GetLastError()); return false; } return true; } -#pragma endregion // Changing the state +#pragma endregion // Changing the state #pragma region Process matching -static bool ContainsNoCase(const std::wstring& haystack, const std::wstring& needle) { +// CompareStringOrdinal rather than towlower, which only folds ASCII under the default locale +// and would quietly fail to match paths containing non-ASCII characters. +static bool EqualsNoCase(std::wstring_view a, std::wstring_view b) { + if (a.size() != b.size()) { + return false; + } + + if (a.empty()) { + return true; + } + + return CompareStringOrdinal(a.data(), (int)a.size(), b.data(), (int)b.size(), TRUE) == + CSTR_EQUAL; +} + +static bool ContainsNoCase(std::wstring_view haystack, std::wstring_view needle) { if (needle.empty() || needle.size() > haystack.size()) { return false; } - auto match = std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(), - [](wchar_t a, wchar_t b) { return towlower(a) == towlower(b); }); - return match != haystack.end(); + for (size_t i = 0; i + needle.size() <= haystack.size(); i++) { + if (EqualsNoCase(haystack.substr(i, needle.size()), needle)) { + return true; + } + } + + return false; } -// AlwaysShadow matches against WMI command lines. Spinning up COM inside NvContainer to do the -// same would be rude, so this matches against the full image path instead, which covers the -// realistic cases (an exe name, or a path fragment) without touching COM. -static bool IsAnyProcessRunning(const std::vector& patterns) { +// A pattern without a backslash is matched against the process file name exactly, so +// "notepad" doesn't quietly match ...\notepad++\notepad++.exe. A pattern with a backslash is +// matched as a substring of the full image path, which covers "everything in this folder". +static bool MatchesProcess(std::wstring_view imagePath, + std::wstring_view fileName, + const std::wstring& pattern) { + if (pattern.find(L'\\') != std::wstring::npos) { + return ContainsNoCase(imagePath, pattern); + } + + return EqualsNoCase(fileName, pattern); +} + +// Returns nullopt when the process list couldn't be read at all. That has to stay +// distinguishable from "nothing matched": with a non-empty onlyWhileRunning list, treating a +// failed enumeration as "nothing matched" would make the mod switch Instant Replay *off* while +// a game is running, which is the exact thing it exists to prevent. CreateToolhelp32Snapshot is +// documented to fail transiently with ERROR_BAD_LENGTH, so this isn't hypothetical. +static std::optional IsAnyProcessRunning(const std::vector& patterns) { if (patterns.empty()) { return false; } @@ -285,42 +347,54 @@ static bool IsAnyProcessRunning(const std::vector& patterns) { HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot == INVALID_HANDLE_VALUE) { Wh_Log(L"CreateToolhelp32Snapshot failed, error %u", GetLastError()); - return false; + return std::nullopt; } - bool found = false; PROCESSENTRY32W entry = {}; entry.dwSize = sizeof(entry); - if (Process32FirstW(snapshot, &entry)) { - do { - std::wstring candidate = entry.szExeFile; + if (!Process32FirstW(snapshot, &entry)) { + Wh_Log(L"Process32FirstW failed, error %u", GetLastError()); + CloseHandle(snapshot); + return std::nullopt; + } - HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID); - if (process) { - WCHAR path[MAX_PATH]; - DWORD pathLen = ARRAYSIZE(path); - if (QueryFullProcessImageNameW(process, 0, path, &pathLen)) { - candidate.assign(path, pathLen); - } - CloseHandle(process); + bool found = false; + + do { + std::wstring imagePath = entry.szExeFile; + + HANDLE process = + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID); + if (process) { + WCHAR path[MAX_PATH]; + DWORD pathLen = ARRAYSIZE(path); + if (QueryFullProcessImageNameW(process, 0, path, &pathLen)) { + imagePath.assign(path, pathLen); } + CloseHandle(process); + } - for (const std::wstring& pattern : patterns) { - if (ContainsNoCase(candidate, pattern)) { - Wh_Log(L"Process match: '%s' matches '%s'", candidate.c_str(), pattern.c_str()); - found = true; - break; - } + std::wstring_view fileName = imagePath; + size_t lastSeparator = fileName.find_last_of(L'\\'); + if (lastSeparator != std::wstring_view::npos) { + fileName = fileName.substr(lastSeparator + 1); + } + + for (const std::wstring& pattern : patterns) { + if (MatchesProcess(imagePath, fileName, pattern)) { + Wh_Log(L"Process match: '%s' matches '%s'", imagePath.c_str(), pattern.c_str()); + found = true; + break; } - } while (!found && Process32NextW(snapshot, &entry)); - } + } + } while (!found && Process32NextW(snapshot, &entry)); CloseHandle(snapshot); return found; } -#pragma endregion // Process matching +#pragma endregion // Process matching #pragma region Watchdog @@ -328,46 +402,26 @@ static bool StopRequested(DWORD waitMs) { return WaitForSingleObject(g_stopEvent, waitMs) == WAIT_OBJECT_0; } -// Whichever instance gets here first owns the job; if it exits, the mutex object goes away and -// another instance picks it up on its next cycle. -static bool ClaimWatchdogRole() { - if (g_watchdogMutex) { - return true; - } - - HANDLE mutex = CreateMutexW(NULL, FALSE, kWatchdogMutexName); - if (!mutex) { - Wh_Log(L"CreateMutex for the watchdog role failed, error %u", GetLastError()); - return false; - } - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - CloseHandle(mutex); - return false; - } - - g_watchdogMutex = mutex; - Wh_Log(L"This nvcontainer.exe instance is now the Instant Replay watchdog"); - return true; -} - static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { Wh_Log(L"Instant Replay watchdog started"); // Waiting on a registry notification means we react the moment something turns Instant // Replay off, instead of up to a whole poll interval later. - HKEY notifyKey = NULL; - if (RegOpenKeyExW(HKEY_CURRENT_USER, kShadowPlayRegKey, 0, KEY_NOTIFY, ¬ifyKey) != ERROR_SUCCESS) { - notifyKey = NULL; + HKEY notifyKey = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kShadowPlayRegKey, 0, KEY_NOTIFY, ¬ifyKey) != + ERROR_SUCCESS) { + notifyKey = nullptr; } - HANDLE notifyEvent = CreateEventW(NULL, TRUE, FALSE, NULL); + HANDLE notifyEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); bool notifyArmed = false; int toggleStreak = 0; + int currentBackoffSec = 0; ULONGLONG conflictUntil = 0; ULONGLONG nextToggleAllowed = 0; bool loggedUnreadableState = false; + bool loggedEnumerationFailure = false; for (;;) { ModSettings settings = GetSettings(); @@ -403,9 +457,9 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { } if (waited == WAIT_OBJECT_0 + 1) { - // Settings changed. Give the user's new configuration a clean slate. - settings = GetSettings(); + // Settings changed. Give the new configuration a clean slate. toggleStreak = 0; + currentBackoffSec = 0; conflictUntil = 0; } else if (waited == WAIT_OBJECT_0 + 2) { notifyArmed = false; @@ -414,38 +468,76 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { } } - ULONGLONG now = GetTickCount64(); - if (now < conflictUntil) { - continue; - } - InstantReplayState state = GetInstantReplayState(); if (state == InstantReplayState::Unknown) { if (!loggedUnreadableState) { loggedUnreadableState = true; - Wh_Log(L"Can't read the Instant Replay state from HKCU. Either ShadowPlay isn't " - L"set up for this user, or this nvcontainer.exe runs under another account. " - L"Staying out of the way."); + Wh_Log(L"Can't read the Instant Replay state. ShadowPlay is probably not set up " + L"on this machine. Staying out of the way."); } continue; } loggedUnreadableState = false; - if (!ClaimWatchdogRole()) { - continue; + bool shouldBeOn = true; + if (!settings.onlyWhileRunning.empty()) { + std::optional running = IsAnyProcessRunning(settings.onlyWhileRunning); + if (!running) { + if (!loggedEnumerationFailure) { + loggedEnumerationFailure = true; + Wh_Log(L"Couldn't enumerate processes, skipping this cycle rather than " + L"guessing at the state Instant Replay should be in"); + } + continue; + } + loggedEnumerationFailure = false; + shouldBeOn = *running; } bool isOn = state == InstantReplayState::On; - bool shouldBeOn = settings.onlyWhileRunning.empty() || - IsAnyProcessRunning(settings.onlyWhileRunning); + // Whatever we were fighting with has stopped, or never existed. Note this is reached + // even while backing off: a backoff only holds off the toggle, it never stops the mod + // noticing that the situation has resolved. if (isOn == shouldBeOn) { toggleStreak = 0; + currentBackoffSec = 0; + conflictUntil = 0; continue; } - if (IsAnyProcessRunning(settings.pauseWhileRunning)) { - toggleStreak = 0; + if (!settings.pauseWhileRunning.empty()) { + std::optional paused = IsAnyProcessRunning(settings.pauseWhileRunning); + if (!paused) { + if (!loggedEnumerationFailure) { + loggedEnumerationFailure = true; + Wh_Log(L"Couldn't enumerate processes, skipping this cycle rather than " + L"acting while a paused program might be running"); + } + continue; + } + loggedEnumerationFailure = false; + if (*paused) { + toggleStreak = 0; + continue; + } + } + + ULONGLONG now = GetTickCount64(); + if (now < conflictUntil) { + continue; + } + + // If our toggles keep failing to stick, something is undoing them. Yield for a while + // rather than flip-flopping forever, growing the wait only as the fight continues. + if (settings.conflictBackoffSec > 0 && toggleStreak >= kMaxTogglesBeforeBackoff) { + currentBackoffSec = currentBackoffSec == 0 + ? std::min(kInitialBackoffSec, settings.conflictBackoffSec) + : std::min(currentBackoffSec * 2, settings.conflictBackoffSec); + conflictUntil = now + (ULONGLONG)currentBackoffSec * 1000; + toggleStreak = kStreakAfterBackoff; + Wh_Log(L"Something keeps changing Instant Replay back. Backing off for %d seconds.", + currentBackoffSec); continue; } @@ -457,65 +549,58 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { now = GetTickCount64(); } - // If we keep having to fix it, something is fixing it right back. Yield for a while - // rather than flip-flopping forever. - if (++toggleStreak >= kMinStreakForConflict) { - conflictUntil = now + (ULONGLONG)settings.conflictBackoffSec * 1000; - toggleStreak = kMinStreakForConflict - 2; - Wh_Log(L"Something keeps changing Instant Replay back. Backing off for %d seconds.", - settings.conflictBackoffSec); - continue; - } - + toggleStreak++; nextToggleAllowed = now + kMinToggleIntervalMs; Wh_Log(L"Instant Replay is %s but should be %s, pressing the toggle shortcut", isOn ? L"on" : L"off", shouldBeOn ? L"on" : L"off"); // Success here only means the input was injected - if Nvidia ignores it the state won't - // change, the next cycles will notice, and the conflict backoff above stops us hammering - // it forever. + // change, the next cycles will notice, and the backoff above stops us hammering it. if (!ToggleInstantReplayWithHotkey()) { Wh_Log(L"Failed to send the Instant Replay toggle shortcut"); } } - if (notifyEvent) { - CloseHandle(notifyEvent); - } + // Close the key first, so the pending notification is unregistered before the event it + // refers to goes away. if (notifyKey) { RegCloseKey(notifyKey); } + if (notifyEvent) { + CloseHandle(notifyEvent); + } Wh_Log(L"Instant Replay watchdog stopped"); return 0; } -#pragma endregion // Watchdog +#pragma endregion // Watchdog -BOOL Wh_ModInit() { +BOOL WhTool_ModInit() { Wh_Log(L"Init"); - LoadSettings(); - return TRUE; -} -void Wh_ModAfterInit() { - // Started here rather than in Wh_ModInit to keep init itself cheap. - g_stopEvent = CreateEventW(NULL, TRUE, FALSE, NULL); - g_wakeEvent = CreateEventW(NULL, FALSE, FALSE, NULL); + LoadSettings(); + g_stopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + g_wakeEvent = CreateEventW(nullptr, FALSE, FALSE, nullptr); if (!g_stopEvent || !g_wakeEvent) { Wh_Log(L"Failed to create the watchdog events, error %u", GetLastError()); - return; + return FALSE; } - g_watchdogThread = CreateThread(NULL, 0, InstantReplayWatchdogThread, NULL, 0, NULL); + g_watchdogThread = + CreateThread(nullptr, 0, InstantReplayWatchdogThread, nullptr, 0, nullptr); if (!g_watchdogThread) { Wh_Log(L"Failed to start the watchdog thread, error %u", GetLastError()); + return FALSE; } + + return TRUE; } -void Wh_ModSettingsChanged() { +void WhTool_ModSettingsChanged() { Wh_Log(L"Settings changed"); + LoadSettings(); if (g_wakeEvent) { @@ -523,8 +608,9 @@ void Wh_ModSettingsChanged() { } } -void Wh_ModBeforeUninit() { - // The thread must be gone before this DLL is unloaded. +void WhTool_ModUninit() { + Wh_Log(L"Uninit"); + if (g_stopEvent) { SetEvent(g_stopEvent); } @@ -532,23 +618,194 @@ void Wh_ModBeforeUninit() { if (g_watchdogThread) { WaitForSingleObject(g_watchdogThread, INFINITE); CloseHandle(g_watchdogThread); - g_watchdogThread = NULL; + g_watchdogThread = nullptr; } -} - -void Wh_ModUninit() { - Wh_Log(L"Uninit"); - if (g_watchdogMutex) { - CloseHandle(g_watchdogMutex); - g_watchdogMutex = NULL; - } if (g_wakeEvent) { CloseHandle(g_wakeEvent); - g_wakeEvent = NULL; + g_wakeEvent = nullptr; } if (g_stopEvent) { CloseHandle(g_stopEvent); - g_stopEvent = NULL; + g_stopEvent = nullptr; + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Windhawk tool mod implementation for mods which don't need to inject to other +// processes or hook other functions. Context: +// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process +// +// The mod will load and run in a dedicated windhawk.exe process. +// +// Paste the code below as part of the mod code, and use these callbacks: +// * WhTool_ModInit +// * WhTool_ModSettingsChanged +// * WhTool_ModUninit +// +// Currently, other callbacks are not supported. + +bool g_isToolModProcessLauncher; +HANDLE g_toolModProcessMutex; + +void WINAPI EntryPoint_Hook() { + Wh_Log(L">"); + ExitThread(0); +} + +BOOL Wh_ModInit() { + DWORD sessionId; + if (ProcessIdToSessionId(GetCurrentProcessId(), &sessionId) && + sessionId == 0) { + return FALSE; + } + + bool isExcluded = false; + bool isToolModProcess = false; + bool isCurrentToolModProcess = false; + int argc; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + if (!argv) { + Wh_Log(L"CommandLineToArgvW failed"); + return FALSE; + } + + for (int i = 1; i < argc; i++) { + if (wcscmp(argv[i], L"-service") == 0 || + wcscmp(argv[i], L"-service-start") == 0 || + wcscmp(argv[i], L"-service-stop") == 0) { + isExcluded = true; + break; + } + } + + for (int i = 1; i < argc - 1; i++) { + if (wcscmp(argv[i], L"-tool-mod") == 0) { + isToolModProcess = true; + if (wcscmp(argv[i + 1], WH_MOD_ID) == 0) { + isCurrentToolModProcess = true; + } + break; + } + } + + LocalFree(argv); + + if (isExcluded) { + return FALSE; } + + if (isCurrentToolModProcess) { + g_toolModProcessMutex = + CreateMutexW(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + if (!g_toolModProcessMutex) { + Wh_Log(L"CreateMutex failed"); + ExitProcess(1); + } + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); + ExitProcess(1); + } + + if (!WhTool_ModInit()) { + ExitProcess(1); + } + + IMAGE_DOS_HEADER* dosHeader = + (IMAGE_DOS_HEADER*)GetModuleHandle(nullptr); + IMAGE_NT_HEADERS* ntHeaders = + (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + + DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; + void* entryPoint = (BYTE*)dosHeader + entryPointRVA; + + Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); + return TRUE; + } + + if (isToolModProcess) { + return FALSE; + } + + g_isToolModProcessLauncher = true; + return TRUE; +} + +void Wh_ModAfterInit() { + if (!g_isToolModProcessLauncher) { + return; + } + + WCHAR currentProcessPath[MAX_PATH]; + switch (GetModuleFileNameW(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { + case 0: + case ARRAYSIZE(currentProcessPath): + Wh_Log(L"GetModuleFileName failed"); + return; + } + + WCHAR + commandLine[MAX_PATH + 2 + + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; + swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, + WH_MOD_ID); + + HMODULE kernelModule = GetModuleHandleW(L"kernelbase.dll"); + if (!kernelModule) { + kernelModule = GetModuleHandleW(L"kernel32.dll"); + if (!kernelModule) { + Wh_Log(L"No kernelbase.dll/kernel32.dll"); + return; + } + } + + using CreateProcessInternalW_t = BOOL(WINAPI*)( + HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, + LPSECURITY_ATTRIBUTES lpProcessAttributes, + LPSECURITY_ATTRIBUTES lpThreadAttributes, WINBOOL bInheritHandles, + DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, + LPSTARTUPINFOW lpStartupInfo, + LPPROCESS_INFORMATION lpProcessInformation, + PHANDLE hRestrictedUserToken); + CreateProcessInternalW_t pCreateProcessInternalW = + (CreateProcessInternalW_t)GetProcAddress(kernelModule, + "CreateProcessInternalW"); + if (!pCreateProcessInternalW) { + Wh_Log(L"No CreateProcessInternalW"); + return; + } + + STARTUPINFOW si{ + .cb = sizeof(STARTUPINFOW), + .dwFlags = STARTF_FORCEOFFFEEDBACK, + }; + PROCESS_INFORMATION pi; + if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, + nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, + nullptr, nullptr, &si, &pi, nullptr)) { + Wh_Log(L"CreateProcess failed"); + return; + } + + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); +} + +void Wh_ModSettingsChanged() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModSettingsChanged(); +} + +void Wh_ModUninit() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModUninit(); + ExitProcess(0); } From ff38ae6a86dcfb2d8a336cbd12b1b3cd6b1c2fbf Mon Sep 17 00:00:00 2001 From: Shahrukh Date: Thu, 6 Aug 2026 16:18:42 +0500 Subject: [PATCH 3/6] Confirm toggles took effect, and bound the retry loop Second round of review feedback from the windhawk-mods submission: - Confirm the state actually changed after pressing the shortcut, with an 8 second WaitForState, instead of relying on a 3 second minimum interval. Starting the Instant Replay ring buffer isn't instant, and a notification for one of the other values in the key could arrive while the state value was still stale, so the mod could press again and undo its own toggle. The toggle streak now counts real failures only. - Remove the never-back-off option. The shortcut can fail in ways the mod cannot detect (overlay off, UIPI silently dropping the input), so an unbounded retry meant injecting keystrokes into the user's session forever. The backoff minimum is now 30s, and after a few rounds at the configured maximum the mod gives up until the state or the settings change. - Don't guess at Nvidia's Alt+Shift+F10 default when no shortcut is configured. That was the one case with no evidence a shortcut exists, and it's the combination the readme tells users to avoid because Alt+Shift cycles the keyboard layout. The mod now presses nothing and logs why. - Only resolve full process paths when a pattern actually needs one. The OpenProcess sweep over every process ran on every cycle even though the exact-file-name form only needs entry.szExeFile. - Validate the virtual-key codes read from the registry before feeding them to SendInput. - Paste the tool mod snippet verbatim from the wiki. The previous copy came from net-toggle.wh.cpp, which carries a locally modified version using the W-suffixed API names. - Retry opening the registry key inside the loop, so configuring ShadowPlay after the mod is running still gets immediate reactions instead of silently degrading to polling. Log when the key or event isn't available. - Simplify GetInstantReplayState to RRF_RT_DWORD, which already accepts the REG_BINARY form and enforces the size. - Minimum check interval raised to 5s. - Document the key-ups releasing physically held keys, that Instant Replay's state persists after the mod is disabled, and that backslash patterns can't match processes the mod can't open. Co-Authored-By: Claude Opus 5 (1M context) --- mods/nvidia-keep-instant-replay-on.wh.cpp | 291 ++++++++++++++++------ 1 file changed, 213 insertions(+), 78 deletions(-) diff --git a/mods/nvidia-keep-instant-replay-on.wh.cpp b/mods/nvidia-keep-instant-replay-on.wh.cpp index c85db9e3e6..ed012be4cb 100644 --- a/mods/nvidia-keep-instant-replay-on.wh.cpp +++ b/mods/nvidia-keep-instant-replay-on.wh.cpp @@ -47,27 +47,31 @@ and presses your toggle shortcut. ## Requirements -The In-Game Overlay must be enabled, and you need a "Toggle Instant Replay" keyboard shortcut -configured - that shortcut is how this mod switches Instant Replay. - -**Change that shortcut away from the Alt+Shift+F10 default.** Alt+Shift is also the Windows -shortcut for cycling the keyboard layout, so leaving it at the default means every automatic -re-enable may also change your input language. Ctrl+Shift+F10 works well. The new shortcut is -picked up automatically, no restart needed. - -Because the shortcut is replayed as real keystrokes, whatever window has focus also receives -the combination. That is inherent to this approach - the Nvidia App no longer ships the local -server that GeForce Experience used to expose, so the shortcut is the only way left to switch -Instant Replay from outside. +The In-Game Overlay must be enabled, and you must have a "Toggle Instant Replay" keyboard +shortcut configured - that shortcut is how this mod switches Instant Replay, and the mod does +nothing at all if one isn't set. + +**Set that shortcut to something without Alt+Shift in it.** Alt+Shift is also the Windows +shortcut for cycling the keyboard layout, so a shortcut containing it means every automatic +re-enable may also change your input language. Ctrl+Shift+F10 works well. The shortcut is +re-read every time, so changing it takes effect immediately. + +Because the shortcut is replayed as real keystrokes, two things follow. Whatever window has +focus also receives the combination. And the key-ups the mod sends will release those keys +even if you happen to be physically holding one at that moment - so a shortcut built from keys +you hold during normal use (Shift, Ctrl) is a poor choice. This is inherent to the approach: +the Nvidia App no longer ships the local server GeForce Experience used to expose, so the +shortcut is the only way left to switch Instant Replay from outside. ## Notes Works with the Nvidia App and with the old GeForce Experience. -If you use **Only keep it on while these programs run** and then disable the mod at a moment -when none of those programs are running, Instant Replay is left off - the mod turned it off on -purpose and isn't around any more to turn it back on. That's the one case where switching the -mod off doesn't restore what you had before. +Instant Replay is a persistent Nvidia setting, so whatever state the mod leaves it in stays +that way after the mod is disabled. In particular, if you use **Only keep it on while these +programs run** and then disable the mod at a moment when none of those programs are running, +Instant Replay is left off - the mod turned it off on purpose and isn't around any more to +turn it back on. */ // ==/WindhawkModReadme== @@ -84,14 +88,16 @@ mod off doesn't restore what you had before. If Instant Replay keeps turning itself back off, something is fighting us. After 2 attempts in a row that don't stick, wait before trying again instead of flip-flopping - 30 seconds at first, doubling each round up to this limit, and reset as soon as Instant - Replay is in the state it should be. Set to 0 to keep retrying and never back off. + Replay is in the state it should be. Once the wait has sat at this limit for a few rounds + the mod stops trying until the state changes, rather than pressing the shortcut forever. - pauseWhileRunning: [""] $name: Pause while these programs run $description: >- While any of these is running, Instant Replay is left alone. An entry without a backslash matches a process by exact file name, e.g. netflix.exe. An entry containing a backslash is matched as a substring of the full image path, e.g. \Netflix\ - useful for covering a whole - install folder. Matching ignores case either way. + install folder, but it can only match processes this mod is allowed to open, so prefer the + file name form unless you need the path. Matching ignores case either way. Leave the single empty entry to disable this. - onlyWhileRunning: [""] $name: Only keep it on while these programs run @@ -122,8 +128,8 @@ mod off doesn't restore what you had before. // - The "Toggle Instant Replay" shortcut, as IRToggleHKeyCount plus one IRToggleHKey // per key. Pressing it is how the state gets changed. // -// Everything in the key is stored as 4 byte REG_BINARY rather than REG_DWORD, which is why -// the reads below accept both. +// Everything in the key is stored as 4 byte REG_BINARY rather than REG_DWORD, which RRF_RT_DWORD +// accepts alongside REG_DWORD, so all the reads below use it. static PCWSTR kShadowPlayRegKey = L"SOFTWARE\\NVIDIA Corporation\\Global\\ShadowPlay\\NVSPCAPS"; static PCWSTR kInstantReplayRegValue = L"{1B1D3DAA-601D-49E5-8508-81736CA28C6D}"; @@ -139,6 +145,18 @@ static const int kStreakAfterBackoff = kMaxTogglesBeforeBackoff - 1; // one-off failure costs almost nothing, and it only grows if the fight is real. static const int kInitialBackoffSec = 30; +// Once the backoff has sat at its maximum for this many rounds, stop pressing the shortcut +// until something changes. A toggle that has failed this persistently - the overlay is off, +// or UIPI is silently dropping the input - is not going to start working on the next attempt, +// and continuing would inject keystrokes into the user's session forever. +static const int kMaxRoundsAtMaxBackoff = 3; + +// How long to let Nvidia catch up after pressing the shortcut. Starting the Instant Replay +// ring buffer isn't instant, and the state value can still read stale for a moment after a +// notification fires for one of the other values in the key. +static const DWORD kToggleConfirmTimeoutMs = 8000; +static const DWORD kToggleConfirmPollMs = 500; + // Don't press the shortcut faster than this, no matter how many notifications arrive. static const DWORD kMinToggleIntervalMs = 3000; @@ -186,8 +204,16 @@ static void LoadStringListSetting(PCWSTR nameFormat, std::vector* static void LoadSettings() { ModSettings settings; - settings.pollIntervalSec = std::clamp(Wh_GetIntSetting(L"pollIntervalSec"), 1, 3600); - settings.conflictBackoffSec = std::clamp(Wh_GetIntSetting(L"conflictBackoffSec"), 0, 86400); + + // A one second poll would mean a full process enumeration every second once either list is + // configured, which isn't worth it when registry notifications already carry the real work. + settings.pollIntervalSec = std::clamp(Wh_GetIntSetting(L"pollIntervalSec"), 5, 3600); + + // No "never back off" option: the shortcut can fail in ways the mod cannot detect, and an + // unbounded retry would inject keystrokes into the user's session indefinitely. + settings.conflictBackoffSec = + std::clamp(Wh_GetIntSetting(L"conflictBackoffSec"), kInitialBackoffSec, 86400); + LoadStringListSetting(L"pauseWhileRunning[%d]", &settings.pauseWhileRunning); LoadStringListSetting(L"onlyWhileRunning[%d]", &settings.onlyWhileRunning); @@ -208,20 +234,15 @@ static ModSettings GetSettings() { // installed or has never been configured. Treating that as Unknown rather than as "off" keeps // the mod from pressing the shortcut on a machine that has nothing to toggle. static InstantReplayState GetInstantReplayState() { - DWORD type = 0; DWORD isActive = 0; DWORD size = sizeof(isActive); LSTATUS ret = RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, kInstantReplayRegValue, - RRF_RT_ANY, &type, &isActive, &size); + RRF_RT_DWORD, nullptr, &isActive, &size); if (ret != ERROR_SUCCESS) { return InstantReplayState::Unknown; } - if ((type != REG_DWORD && type != REG_BINARY) || size != sizeof(DWORD)) { - return InstantReplayState::Unknown; - } - return isActive ? InstantReplayState::On : InstantReplayState::Off; } @@ -239,33 +260,45 @@ static void AddKeyInput(std::vector* inputs, WORD vkey, bool isDown) { // Note this *toggles*, it doesn't set a state, so only call it when the current state is known. // The shortcut is read fresh every time, so changing it in the Nvidia App takes effect at once. +// +// If no shortcut is configured the mod presses nothing. Guessing at Nvidia's Alt+Shift+F10 +// default would mean that the one case where there's no evidence a shortcut exists is also the +// case where the mod repeatedly injects the combination that cycles the keyboard layout. static bool ToggleInstantReplayWithHotkey() { - std::vector keys; - DWORD keyCount = 0; DWORD size = sizeof(keyCount); LSTATUS ret = RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, L"IRToggleHKeyCount", RRF_RT_DWORD, nullptr, &keyCount, &size); - if (ret == ERROR_SUCCESS && keyCount > 0 && keyCount <= 8) { - // Each key of the shortcut is stored in its own value: IRToggleHKey0, IRToggleHKey1, ... - for (DWORD i = 0; i < keyCount; i++) { - std::wstring valueName = L"IRToggleHKey" + std::to_wstring(i); - - DWORD vkey = 0; - size = sizeof(vkey); - if (RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, valueName.c_str(), - RRF_RT_DWORD, nullptr, &vkey, &size) != ERROR_SUCCESS) { - Wh_Log(L"Couldn't read %s, can't press the toggle shortcut", valueName.c_str()); - return false; - } + if (ret != ERROR_SUCCESS || keyCount == 0 || keyCount > 8) { + Wh_Log(L"No usable Toggle Instant Replay shortcut is configured (IRToggleHKeyCount), " + L"so there's nothing to press. Set one in the Nvidia App."); + return false; + } + + std::vector keys; + keys.reserve(keyCount); + + // Each key of the shortcut is stored in its own value: IRToggleHKey0, IRToggleHKey1, ... + for (DWORD i = 0; i < keyCount; i++) { + std::wstring valueName = L"IRToggleHKey" + std::to_wstring(i); + + DWORD vkey = 0; + size = sizeof(vkey); + if (RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, valueName.c_str(), RRF_RT_DWORD, + nullptr, &vkey, &size) != ERROR_SUCCESS) { + Wh_Log(L"Couldn't read %s, can't press the toggle shortcut", valueName.c_str()); + return false; + } - keys.push_back((WORD)vkey); + // Don't feed anything that isn't a plain virtual-key code to SendInput. + if (vkey == 0 || vkey > 0xFE) { + Wh_Log(L"%s is %u, which isn't a virtual-key code, can't press the toggle shortcut", + valueName.c_str(), vkey); + return false; } - } else { - // Nvidia's default shortcut. - Wh_Log(L"No toggle shortcut configured, assuming the default Alt+Shift+F10"); - keys = {VK_MENU, VK_SHIFT, VK_F10}; + + keys.push_back((WORD)vkey); } std::vector inputs; @@ -321,13 +354,17 @@ static bool ContainsNoCase(std::wstring_view haystack, std::wstring_view needle) return false; } +static bool PatternNeedsPath(const std::wstring& pattern) { + return pattern.find(L'\\') != std::wstring::npos; +} + // A pattern without a backslash is matched against the process file name exactly, so // "notepad" doesn't quietly match ...\notepad++\notepad++.exe. A pattern with a backslash is // matched as a substring of the full image path, which covers "everything in this folder". static bool MatchesProcess(std::wstring_view imagePath, std::wstring_view fileName, const std::wstring& pattern) { - if (pattern.find(L'\\') != std::wstring::npos) { + if (PatternNeedsPath(pattern)) { return ContainsNoCase(imagePath, pattern); } @@ -344,6 +381,11 @@ static std::optional IsAnyProcessRunning(const std::vector& return false; } + // Resolving full paths costs an OpenProcess for every process on the system. Only pay it + // when a pattern actually asks for a path - entry.szExeFile is already the file name that + // the exact-match form compares against. + const bool needsPath = std::any_of(patterns.begin(), patterns.end(), PatternNeedsPath); + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot == INVALID_HANDLE_VALUE) { Wh_Log(L"CreateToolhelp32Snapshot failed, error %u", GetLastError()); @@ -364,15 +406,17 @@ static std::optional IsAnyProcessRunning(const std::vector& do { std::wstring imagePath = entry.szExeFile; - HANDLE process = - OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID); - if (process) { - WCHAR path[MAX_PATH]; - DWORD pathLen = ARRAYSIZE(path); - if (QueryFullProcessImageNameW(process, 0, path, &pathLen)) { - imagePath.assign(path, pathLen); + if (needsPath) { + HANDLE process = + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID); + if (process) { + WCHAR path[MAX_PATH]; + DWORD pathLen = ARRAYSIZE(path); + if (QueryFullProcessImageNameW(process, 0, path, &pathLen)) { + imagePath.assign(path, pathLen); + } + CloseHandle(process); } - CloseHandle(process); } std::wstring_view fileName = imagePath; @@ -402,22 +446,52 @@ static bool StopRequested(DWORD waitMs) { return WaitForSingleObject(g_stopEvent, waitMs) == WAIT_OBJECT_0; } +// Returns true if the state reached `expected` before the timeout. `*stop` is set when the mod +// is shutting down, in which case the caller must break out of its loop. +// +// Without this the mod would go straight back to waiting after pressing the shortcut, read a +// state value that hasn't caught up yet, and press again - undoing its own toggle and then +// counting the result as a conflict. +static bool WaitForState(InstantReplayState expected, DWORD timeoutMs, bool* stop) { + ULONGLONG deadline = GetTickCount64() + timeoutMs; + + for (;;) { + if (GetInstantReplayState() == expected) { + return true; + } + + ULONGLONG now = GetTickCount64(); + if (now >= deadline) { + return false; + } + + if (StopRequested((DWORD)std::min(kToggleConfirmPollMs, deadline - now))) { + *stop = true; + return false; + } + } +} + static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { Wh_Log(L"Instant Replay watchdog started"); // Waiting on a registry notification means we react the moment something turns Instant // Replay off, instead of up to a whole poll interval later. HKEY notifyKey = nullptr; - if (RegOpenKeyExW(HKEY_CURRENT_USER, kShadowPlayRegKey, 0, KEY_NOTIFY, ¬ifyKey) != - ERROR_SUCCESS) { - notifyKey = nullptr; - } - HANDLE notifyEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); bool notifyArmed = false; + bool loggedNotifyUnavailable = false; + + if (!notifyEvent) { + Wh_Log(L"CreateEvent for the registry notification failed, error %u. Falling back to " + L"polling only.", + GetLastError()); + } int toggleStreak = 0; int currentBackoffSec = 0; + int roundsAtMaxBackoff = 0; + bool gaveUp = false; ULONGLONG conflictUntil = 0; ULONGLONG nextToggleAllowed = 0; bool loggedUnreadableState = false; @@ -426,10 +500,34 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { for (;;) { ModSettings settings = GetSettings(); + // Opened here rather than once before the loop so that configuring ShadowPlay after the + // mod is already running still gets the immediate-reaction behaviour, instead of + // silently degrading to polling for the rest of the session. + if (notifyEvent && !notifyKey) { + LSTATUS openRet = + RegOpenKeyExW(HKEY_CURRENT_USER, kShadowPlayRegKey, 0, KEY_NOTIFY, ¬ifyKey); + if (openRet != ERROR_SUCCESS) { + notifyKey = nullptr; + if (!loggedNotifyUnavailable) { + loggedNotifyUnavailable = true; + Wh_Log(L"Can't open the ShadowPlay key to watch it (error %d), polling " + L"every %d seconds until it appears", + openRet, settings.pollIntervalSec); + } + } else { + loggedNotifyUnavailable = false; + } + } + if (notifyKey && notifyEvent && !notifyArmed) { ResetEvent(notifyEvent); notifyArmed = RegNotifyChangeKeyValue(notifyKey, FALSE, REG_NOTIFY_CHANGE_LAST_SET, notifyEvent, TRUE) == ERROR_SUCCESS; + if (!notifyArmed) { + // The key may have been deleted from under us; reopen it next time round. + RegCloseKey(notifyKey); + notifyKey = nullptr; + } } HANDLE waitHandles[3]; @@ -457,10 +555,13 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { } if (waited == WAIT_OBJECT_0 + 1) { - // Settings changed. Give the new configuration a clean slate. + // Settings changed. Give the new configuration an immediate retry, but keep the + // backoff ladder where it was so repeated tweaking during a real conflict doesn't + // restart it from the bottom every time. toggleStreak = 0; - currentBackoffSec = 0; conflictUntil = 0; + roundsAtMaxBackoff = 0; + gaveUp = false; } else if (waited == WAIT_OBJECT_0 + 2) { notifyArmed = false; if (StopRequested(kRegistryNotifyDebounceMs)) { @@ -502,7 +603,9 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { if (isOn == shouldBeOn) { toggleStreak = 0; currentBackoffSec = 0; + roundsAtMaxBackoff = 0; conflictUntil = 0; + gaveUp = false; continue; } @@ -523,6 +626,12 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { } } + // Pressing the shortcut has failed often enough that it clearly isn't going to work. + // Stay quiet until the state changes on its own or the settings do. + if (gaveUp) { + continue; + } + ULONGLONG now = GetTickCount64(); if (now < conflictUntil) { continue; @@ -530,14 +639,24 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { // If our toggles keep failing to stick, something is undoing them. Yield for a while // rather than flip-flopping forever, growing the wait only as the fight continues. - if (settings.conflictBackoffSec > 0 && toggleStreak >= kMaxTogglesBeforeBackoff) { + if (toggleStreak >= kMaxTogglesBeforeBackoff) { + bool wasAtMax = currentBackoffSec >= settings.conflictBackoffSec; currentBackoffSec = currentBackoffSec == 0 ? std::min(kInitialBackoffSec, settings.conflictBackoffSec) : std::min(currentBackoffSec * 2, settings.conflictBackoffSec); conflictUntil = now + (ULONGLONG)currentBackoffSec * 1000; toggleStreak = kStreakAfterBackoff; - Wh_Log(L"Something keeps changing Instant Replay back. Backing off for %d seconds.", - currentBackoffSec); + + if (wasAtMax && ++roundsAtMaxBackoff >= kMaxRoundsAtMaxBackoff) { + gaveUp = true; + Wh_Log(L"Pressing the toggle shortcut hasn't worked after repeated attempts at " + L"the maximum backoff. Giving up until the Instant Replay state changes " + L"or the settings do. Check that the In-Game Overlay is on and that a " + L"Toggle Instant Replay shortcut is set."); + } else { + Wh_Log(L"Instant Replay keeps changing back. Backing off for %d seconds.", + currentBackoffSec); + } continue; } @@ -549,15 +668,31 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { now = GetTickCount64(); } - toggleStreak++; nextToggleAllowed = now + kMinToggleIntervalMs; Wh_Log(L"Instant Replay is %s but should be %s, pressing the toggle shortcut", isOn ? L"on" : L"off", shouldBeOn ? L"on" : L"off"); - // Success here only means the input was injected - if Nvidia ignores it the state won't - // change, the next cycles will notice, and the backoff above stops us hammering it. if (!ToggleInstantReplayWithHotkey()) { - Wh_Log(L"Failed to send the Instant Replay toggle shortcut"); + toggleStreak++; + continue; + } + + // Wait for the press to actually take effect before deciding whether it worked. A slow + // but successful toggle then costs nothing, and toggleStreak counts real failures only. + bool stop = false; + InstantReplayState expected = + shouldBeOn ? InstantReplayState::On : InstantReplayState::Off; + if (WaitForState(expected, kToggleConfirmTimeoutMs, &stop)) { + toggleStreak = 0; + currentBackoffSec = 0; + roundsAtMaxBackoff = 0; + } else { + if (stop) { + break; + } + Wh_Log(L"Instant Replay didn't change within %u ms of the toggle shortcut", + kToggleConfirmTimeoutMs); + toggleStreak++; } } @@ -664,7 +799,7 @@ BOOL Wh_ModInit() { bool isToolModProcess = false; bool isCurrentToolModProcess = false; int argc; - LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + LPWSTR* argv = CommandLineToArgvW(GetCommandLine(), &argc); if (!argv) { Wh_Log(L"CommandLineToArgvW failed"); return FALSE; @@ -697,7 +832,7 @@ BOOL Wh_ModInit() { if (isCurrentToolModProcess) { g_toolModProcessMutex = - CreateMutexW(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + CreateMutex(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); if (!g_toolModProcessMutex) { Wh_Log(L"CreateMutex failed"); ExitProcess(1); @@ -738,8 +873,8 @@ void Wh_ModAfterInit() { } WCHAR currentProcessPath[MAX_PATH]; - switch (GetModuleFileNameW(nullptr, currentProcessPath, - ARRAYSIZE(currentProcessPath))) { + switch (GetModuleFileName(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { case 0: case ARRAYSIZE(currentProcessPath): Wh_Log(L"GetModuleFileName failed"); @@ -752,9 +887,9 @@ void Wh_ModAfterInit() { swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, WH_MOD_ID); - HMODULE kernelModule = GetModuleHandleW(L"kernelbase.dll"); + HMODULE kernelModule = GetModuleHandle(L"kernelbase.dll"); if (!kernelModule) { - kernelModule = GetModuleHandleW(L"kernel32.dll"); + kernelModule = GetModuleHandle(L"kernel32.dll"); if (!kernelModule) { Wh_Log(L"No kernelbase.dll/kernel32.dll"); return; @@ -777,8 +912,8 @@ void Wh_ModAfterInit() { return; } - STARTUPINFOW si{ - .cb = sizeof(STARTUPINFOW), + STARTUPINFO si{ + .cb = sizeof(STARTUPINFO), .dwFlags = STARTF_FORCEOFFFEEDBACK, }; PROCESS_INFORMATION pi; From 1fef8f06835cf7be6ff217046989db38d6831e44 Mon Sep 17 00:00:00 2001 From: Shahrukh Date: Thu, 6 Aug 2026 17:19:50 +0500 Subject: [PATCH 4/6] Make the give-up state recoverable, and stop counting failures we caused Third round of review feedback from the windhawk-mods submission: - The give-up latch could not be cleared by the fix its own log message asks for. Configuring a Toggle Instant Replay shortcut, or switching the In-Game Overlay back on, left the mod inert until it was disabled and re-enabled. The shortcut is now read separately, captured when the mod gives up, and compared each cycle, so changing it re-arms immediately. A periodic retry every few hours covers the causes the mod cannot observe. - Don't count failures that were never the mod's fault. A locked session or a UAC secure desktop means injected input can't reach anything, so the cycle is skipped via OpenInputDesktop instead of timing out and driving the backoff ladder toward giving up. - Don't press while the user is holding any of the shortcut's keys or any modifier. The combination Nvidia would see isn't the configured one, so the toggle fails and counts as a conflict, and the key-ups steal keys the user is actually using. - A missing shortcut no longer counts as a failed attempt, since there was no attempt, and its log message is latched like the other recurring ones. - Set wScan and KEYEVENTF_EXTENDEDKEY so shortcuts containing extended keys (arrows, Insert/Delete/Home/End/PgUp/PgDn, right Alt/Ctrl, numpad) describe the right physical key. - ContainsNoCase uses a single FindNLSStringEx call instead of one CompareStringOrdinal per character offset. - QueryFullProcessImageNameW gets a 1024 WCHAR buffer, so paths longer than MAX_PATH no longer fall back silently to the bare file name. - Name the accepted ranges for pollIntervalSec and conflictBackoffSec in their descriptions. Co-Authored-By: Claude Opus 5 (1M context) --- mods/nvidia-keep-instant-replay-on.wh.cpp | 220 ++++++++++++++++------ 1 file changed, 165 insertions(+), 55 deletions(-) diff --git a/mods/nvidia-keep-instant-replay-on.wh.cpp b/mods/nvidia-keep-instant-replay-on.wh.cpp index ed012be4cb..2ce452b516 100644 --- a/mods/nvidia-keep-instant-replay-on.wh.cpp +++ b/mods/nvidia-keep-instant-replay-on.wh.cpp @@ -57,11 +57,12 @@ re-enable may also change your input language. Ctrl+Shift+F10 works well. The sh re-read every time, so changing it takes effect immediately. Because the shortcut is replayed as real keystrokes, two things follow. Whatever window has -focus also receives the combination. And the key-ups the mod sends will release those keys -even if you happen to be physically holding one at that moment - so a shortcut built from keys -you hold during normal use (Shift, Ctrl) is a poor choice. This is inherent to the approach: -the Nvidia App no longer ships the local server GeForce Experience used to expose, so the -shortcut is the only way left to switch Instant Replay from outside. +focus also receives the combination. And the key-ups the mod sends would release those keys if +you were physically holding one - so the mod waits for a cycle when none of the shortcut's keys +and no modifier is held down, and a shortcut built from keys you hold during normal use is +still a poor choice. This is inherent to the approach: the Nvidia App no longer ships the local +server GeForce Experience used to expose, so the shortcut is the only way left to switch +Instant Replay from outside. ## Notes @@ -80,16 +81,18 @@ turn it back on. - pollIntervalSec: 10 $name: Check interval (seconds) $description: >- - How often to re-check the Instant Replay state. The state is also re-checked immediately - whenever Nvidia writes to the ShadowPlay registry key, so this is mostly a safety net. + How often to re-check the Instant Replay state, from 5 to 3600. The state is also + re-checked immediately whenever Nvidia writes to the ShadowPlay registry key, so this is + mostly a safety net. - conflictBackoffSec: 900 $name: Maximum conflict backoff (seconds) $description: >- If Instant Replay keeps turning itself back off, something is fighting us. After 2 attempts in a row that don't stick, wait before trying again instead of flip-flopping - - 30 seconds at first, doubling each round up to this limit, and reset as soon as Instant - Replay is in the state it should be. Once the wait has sat at this limit for a few rounds - the mod stops trying until the state changes, rather than pressing the shortcut forever. + 30 seconds at first, doubling each round up to this limit (from 30 to 86400), and reset as + soon as Instant Replay is in the state it should be. Once the wait has sat at this limit + for a few rounds the mod stops trying, and starts again when the toggle shortcut changes, + when the state changes, or after a few hours. - pauseWhileRunning: [""] $name: Pause while these programs run $description: >- @@ -145,12 +148,16 @@ static const int kStreakAfterBackoff = kMaxTogglesBeforeBackoff - 1; // one-off failure costs almost nothing, and it only grows if the fight is real. static const int kInitialBackoffSec = 30; -// Once the backoff has sat at its maximum for this many rounds, stop pressing the shortcut -// until something changes. A toggle that has failed this persistently - the overlay is off, -// or UIPI is silently dropping the input - is not going to start working on the next attempt, -// and continuing would inject keystrokes into the user's session forever. +// Once the backoff has sat at its maximum for this many rounds, stop pressing the shortcut. +// A toggle that has failed this persistently isn't going to start working on the next attempt, +// and continuing would inject keystrokes into the user's session indefinitely. static const int kMaxRoundsAtMaxBackoff = 3; +// Having given up, try again this long afterwards. The user may have fixed the cause in a way +// the mod cannot observe - switching the In-Game Overlay back on doesn't touch the registry +// key, so it produces no notification to wake us. +static const ULONGLONG kGiveUpRetryMs = 6ULL * 60 * 60 * 1000; + // How long to let Nvidia catch up after pressing the shortcut. Starting the Instant Replay // ring buffer isn't instant, and the state value can still read stale for a moment after a // notification fires for one of the other values in the key. @@ -250,30 +257,19 @@ static InstantReplayState GetInstantReplayState() { #pragma region Changing the state -static void AddKeyInput(std::vector* inputs, WORD vkey, bool isDown) { - INPUT input = {}; - input.type = INPUT_KEYBOARD; - input.ki.wVk = vkey; - input.ki.dwFlags = isDown ? 0 : KEYEVENTF_KEYUP; - inputs->push_back(input); -} - -// Note this *toggles*, it doesn't set a state, so only call it when the current state is known. -// The shortcut is read fresh every time, so changing it in the Nvidia App takes effect at once. +// Reads IRToggleHKeyCount + IRToggleHKey. Empty when no usable shortcut is configured. // -// If no shortcut is configured the mod presses nothing. Guessing at Nvidia's Alt+Shift+F10 -// default would mean that the one case where there's no evidence a shortcut exists is also the -// case where the mod repeatedly injects the combination that cycles the keyboard layout. -static bool ToggleInstantReplayWithHotkey() { +// Nvidia's Alt+Shift+F10 default is deliberately not assumed here: the case where these values +// are missing is the case with no evidence a shortcut exists at all, and guessing would mean +// repeatedly injecting the combination that cycles the keyboard layout. +static std::vector ReadToggleHotkey() { DWORD keyCount = 0; DWORD size = sizeof(keyCount); LSTATUS ret = RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, L"IRToggleHKeyCount", RRF_RT_DWORD, nullptr, &keyCount, &size); if (ret != ERROR_SUCCESS || keyCount == 0 || keyCount > 8) { - Wh_Log(L"No usable Toggle Instant Replay shortcut is configured (IRToggleHKeyCount), " - L"so there's nothing to press. Set one in the Nvidia App."); - return false; + return {}; } std::vector keys; @@ -287,20 +283,60 @@ static bool ToggleInstantReplayWithHotkey() { size = sizeof(vkey); if (RegGetValueW(HKEY_CURRENT_USER, kShadowPlayRegKey, valueName.c_str(), RRF_RT_DWORD, nullptr, &vkey, &size) != ERROR_SUCCESS) { - Wh_Log(L"Couldn't read %s, can't press the toggle shortcut", valueName.c_str()); - return false; + return {}; } // Don't feed anything that isn't a plain virtual-key code to SendInput. if (vkey == 0 || vkey > 0xFE) { - Wh_Log(L"%s is %u, which isn't a virtual-key code, can't press the toggle shortcut", - valueName.c_str(), vkey); - return false; + return {}; } keys.push_back((WORD)vkey); } + return keys; +} + +// The keys that live on the extended part of the keyboard need KEYEVENTF_EXTENDEDKEY, or the +// injected event describes the wrong physical key. +static bool IsExtendedKey(WORD vkey) { + switch (vkey) { + case VK_RCONTROL: + case VK_RMENU: + case VK_INSERT: + case VK_DELETE: + case VK_HOME: + case VK_END: + case VK_PRIOR: + case VK_NEXT: + case VK_LEFT: + case VK_UP: + case VK_RIGHT: + case VK_DOWN: + case VK_NUMLOCK: + case VK_DIVIDE: + case VK_SNAPSHOT: + case VK_LWIN: + case VK_RWIN: + case VK_APPS: + return true; + default: + return false; + } +} + +static void AddKeyInput(std::vector* inputs, WORD vkey, bool isDown) { + INPUT input = {}; + input.type = INPUT_KEYBOARD; + input.ki.wVk = vkey; + input.ki.wScan = (WORD)MapVirtualKeyW(vkey, MAPVK_VK_TO_VSC); + input.ki.dwFlags = (isDown ? 0 : KEYEVENTF_KEYUP) | + (IsExtendedKey(vkey) ? KEYEVENTF_EXTENDEDKEY : 0); + inputs->push_back(input); +} + +// Note this *toggles*, it doesn't set a state, so only call it when the current state is known. +static bool PressToggleHotkey(const std::vector& keys) { std::vector inputs; inputs.reserve(keys.size() * 2); @@ -321,12 +357,45 @@ static bool ToggleInstantReplayWithHotkey() { return true; } +// If the user is physically holding any of these, the combination Nvidia sees isn't the one +// that's configured, so the toggle would fail and be counted as a conflict - and the key-ups +// we send would steal the keys out from under them. +static bool IsAnyRelevantKeyHeld(const std::vector& keys) { + static const WORD kModifiers[] = {VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN}; + + for (WORD vkey : keys) { + if (GetAsyncKeyState(vkey) & 0x8000) { + return true; + } + } + + for (WORD vkey : kModifiers) { + if (GetAsyncKeyState(vkey) & 0x8000) { + return true; + } + } + + return false; +} + +// A locked session or a secure desktop (UAC) means injected input can't reach anything. That's +// not the mod losing a fight, so it mustn't be allowed to drive the backoff. +static bool IsInputDesktopReachable() { + HDESK inputDesktop = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS); + if (!inputDesktop) { + return false; + } + + CloseDesktop(inputDesktop); + return true; +} + #pragma endregion // Changing the state #pragma region Process matching -// CompareStringOrdinal rather than towlower, which only folds ASCII under the default locale -// and would quietly fail to match paths containing non-ASCII characters. +// CompareStringOrdinal / FindNLSStringEx rather than towlower, which only folds ASCII under the +// default locale and would quietly fail to match paths containing non-ASCII characters. static bool EqualsNoCase(std::wstring_view a, std::wstring_view b) { if (a.size() != b.size()) { return false; @@ -345,13 +414,9 @@ static bool ContainsNoCase(std::wstring_view haystack, std::wstring_view needle) return false; } - for (size_t i = 0; i + needle.size() <= haystack.size(); i++) { - if (EqualsNoCase(haystack.substr(i, needle.size()), needle)) { - return true; - } - } - - return false; + return FindNLSStringEx(LOCALE_NAME_INVARIANT, FIND_FROMSTART | NORM_IGNORECASE, + haystack.data(), (int)haystack.size(), needle.data(), + (int)needle.size(), nullptr, nullptr, nullptr, 0) >= 0; } static bool PatternNeedsPath(const std::wstring& pattern) { @@ -410,7 +475,9 @@ static std::optional IsAnyProcessRunning(const std::vector& HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID); if (process) { - WCHAR path[MAX_PATH]; + // Generous enough that a long path doesn't silently fall back to the bare file + // name, which a \folder\ pattern then couldn't match. + WCHAR path[1024]; DWORD pathLen = ARRAYSIZE(path); if (QueryFullProcessImageNameW(process, 0, path, &pathLen)) { imagePath.assign(path, pathLen); @@ -492,10 +559,13 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { int currentBackoffSec = 0; int roundsAtMaxBackoff = 0; bool gaveUp = false; + std::vector hotkeyAtGiveUp; + ULONGLONG giveUpRetryAt = 0; ULONGLONG conflictUntil = 0; ULONGLONG nextToggleAllowed = 0; bool loggedUnreadableState = false; bool loggedEnumerationFailure = false; + bool loggedMissingHotkey = false; for (;;) { ModSettings settings = GetSettings(); @@ -598,8 +668,8 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { bool isOn = state == InstantReplayState::On; // Whatever we were fighting with has stopped, or never existed. Note this is reached - // even while backing off: a backoff only holds off the toggle, it never stops the mod - // noticing that the situation has resolved. + // even while backing off or after giving up: neither state stops the mod noticing that + // the situation has resolved. if (isOn == shouldBeOn) { toggleStreak = 0; currentBackoffSec = 0; @@ -626,13 +696,45 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { } } - // Pressing the shortcut has failed often enough that it clearly isn't going to work. - // Stay quiet until the state changes on its own or the settings do. - if (gaveUp) { + // Nothing we send can land right now, and that isn't a failure on our part. + if (!IsInputDesktopReachable()) { continue; } + std::vector hotkey = ReadToggleHotkey(); + if (hotkey.empty()) { + // Not a failed attempt - there was nothing to attempt - so this doesn't count + // towards the backoff. + if (!loggedMissingHotkey) { + loggedMissingHotkey = true; + Wh_Log(L"No usable Toggle Instant Replay shortcut is configured " + L"(IRToggleHKeyCount), so there's nothing to press. Set one in the " + L"Nvidia App."); + } + continue; + } + loggedMissingHotkey = false; + ULONGLONG now = GetTickCount64(); + + // Pressing the shortcut failed persistently enough that we stopped. Re-arm when the + // user acts on what the log asked for - the shortcut changing is observable, and a + // periodic retry covers the causes that aren't, like the overlay being switched on. + if (gaveUp) { + bool hotkeyChanged = hotkey != hotkeyAtGiveUp; + if (!hotkeyChanged && now < giveUpRetryAt) { + continue; + } + + Wh_Log(L"Trying again after giving up (%s)", + hotkeyChanged ? L"the toggle shortcut changed" : L"periodic retry"); + gaveUp = false; + toggleStreak = 0; + currentBackoffSec = 0; + roundsAtMaxBackoff = 0; + conflictUntil = 0; + } + if (now < conflictUntil) { continue; } @@ -649,10 +751,13 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { if (wasAtMax && ++roundsAtMaxBackoff >= kMaxRoundsAtMaxBackoff) { gaveUp = true; + hotkeyAtGiveUp = hotkey; + giveUpRetryAt = now + kGiveUpRetryMs; Wh_Log(L"Pressing the toggle shortcut hasn't worked after repeated attempts at " - L"the maximum backoff. Giving up until the Instant Replay state changes " - L"or the settings do. Check that the In-Game Overlay is on and that a " - L"Toggle Instant Replay shortcut is set."); + L"the maximum backoff. Pausing until the Instant Replay state changes, " + L"the toggle shortcut changes, or a few hours pass. Check that the " + L"In-Game Overlay is on and that a Toggle Instant Replay shortcut is " + L"set."); } else { Wh_Log(L"Instant Replay keeps changing back. Backing off for %d seconds.", currentBackoffSec); @@ -660,6 +765,11 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { continue; } + // Pressing now would send the wrong combination and steal the keys being held. + if (IsAnyRelevantKeyHeld(hotkey)) { + continue; + } + // A burst of registry notifications shouldn't turn into a burst of keystrokes. if (now < nextToggleAllowed) { if (StopRequested((DWORD)(nextToggleAllowed - now))) { @@ -672,7 +782,7 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { Wh_Log(L"Instant Replay is %s but should be %s, pressing the toggle shortcut", isOn ? L"on" : L"off", shouldBeOn ? L"on" : L"off"); - if (!ToggleInstantReplayWithHotkey()) { + if (!PressToggleHotkey(hotkey)) { toggleStreak++; continue; } From ecb251683869d21e4483d7f1c8983a678cfac3ed Mon Sep 17 00:00:00 2001 From: Shahrukh Date: Mon, 10 Aug 2026 11:58:40 +0500 Subject: [PATCH 5/6] Bound the toggle rate so a slow conflict can't run forever Fourth round of review feedback from the windhawk-mods submission: - The consecutive-failure streak only ever saw a conflict that undid the toggle within the 8 second confirm window. Anything slower - something switching Instant Replay off every minute - confirmed successfully on every attempt, reset the streak, and had the mod pressing the shortcut forever with the backoff ladder pinned at zero. A rolling window now caps toggles at 5 per 10 minutes and feeds the same ladder. The counter is deliberately not cleared when the state matches, since that branch is reached after every successful round and is what hid the case. - The ladder is no longer cleared by a successful toggle either. It clears once a whole window has passed with no toggle needed, so a slow fight accumulates instead of resetting each round. - Give up based on elapsed conflict time rather than rounds spent at the maximum backoff. Lowering conflictBackoffSec previously made the mod give up sooner and then sit out six hours, which is the opposite of what the setting name implies; give-up is now an hour of continuous conflict regardless. - Answer both process lists from a single snapshot. The expensive part is the snapshot and the OpenProcess sweep, which is shared, so this is never worse than the previous two-call form. - Create the wake event signalled, so the first iteration evaluates the state immediately instead of waiting out a poll interval that can be an hour. - Short bounded waits instead of a full poll interval after transient skips: a failed snapshot, an unreachable input desktop, or a held modifier. Co-Authored-By: Claude Opus 5 (1M context) --- mods/nvidia-keep-instant-replay-on.wh.cpp | 244 +++++++++++++--------- 1 file changed, 150 insertions(+), 94 deletions(-) diff --git a/mods/nvidia-keep-instant-replay-on.wh.cpp b/mods/nvidia-keep-instant-replay-on.wh.cpp index 2ce452b516..6d3a74f2ec 100644 --- a/mods/nvidia-keep-instant-replay-on.wh.cpp +++ b/mods/nvidia-keep-instant-replay-on.wh.cpp @@ -25,7 +25,8 @@ mod, so there's no extra program to install, no tray icon, and nothing to rememb It watches the registry key Nvidia stores the state in, so it reacts the moment the state changes rather than waiting for the next poll. And if it finds itself fighting with something -that keeps switching Instant Replay back off, it backs off instead of flip-flopping forever. +that keeps switching Instant Replay back off, it backs off and eventually stops, rather than +trading keystrokes with it forever. This is a tool mod: it hooks nothing and injects into nothing, it just runs in its own dedicated process as the logged-in user. @@ -64,6 +65,12 @@ still a poor choice. This is inherent to the approach: the Nvidia App no longer server GeForce Experience used to expose, so the shortcut is the only way left to switch Instant Replay from outside. +Because those keystrokes are the mod's main cost to you, it also caps how often it is willing +to press at all. If something keeps switching Instant Replay off - even slowly, every few +minutes - the mod backs off progressively and stops after about an hour of that, rather than +trading keystrokes with it indefinitely. It starts again when the state settles, when you +change the toggle shortcut, or after a few hours. + ## Notes Works with the Nvidia App and with the old GeForce Experience. @@ -87,12 +94,12 @@ turn it back on. - conflictBackoffSec: 900 $name: Maximum conflict backoff (seconds) $description: >- - If Instant Replay keeps turning itself back off, something is fighting us. After 2 - attempts in a row that don't stick, wait before trying again instead of flip-flopping - - 30 seconds at first, doubling each round up to this limit (from 30 to 86400), and reset as - soon as Instant Replay is in the state it should be. Once the wait has sat at this limit - for a few rounds the mod stops trying, and starts again when the toggle shortcut changes, - when the state changes, or after a few hours. + If Instant Replay keeps turning itself back off, something is fighting us. Rather than + trading keystrokes with it, the mod waits before trying again - 30 seconds at first, + doubling each round up to this limit (from 30 to 86400). It resets once the state has been + left alone for a while. After about an hour of continuous conflict the mod stops trying, + and starts again when the state settles, when the toggle shortcut changes, or after a few + hours. - pauseWhileRunning: [""] $name: Pause while these programs run $description: >- @@ -144,14 +151,20 @@ static const int kMaxTogglesBeforeBackoff = 2; // so a conflict that has since resolved recovers on the very next attempt. static const int kStreakAfterBackoff = kMaxTogglesBeforeBackoff - 1; +// A conflict slower than kToggleConfirmTimeoutMs confirms fine on every attempt, so the +// consecutive-failure streak never sees it - something switching Instant Replay off every +// minute would otherwise be answered forever. Bound how often we're willing to press at all. +static const int kMaxTogglesPerWindow = 5; +static const ULONGLONG kToggleWindowMs = 10ULL * 60 * 1000; + // Where the backoff starts before doubling towards the configured limit. Short enough that a // one-off failure costs almost nothing, and it only grows if the fight is real. static const int kInitialBackoffSec = 30; -// Once the backoff has sat at its maximum for this many rounds, stop pressing the shortcut. -// A toggle that has failed this persistently isn't going to start working on the next attempt, -// and continuing would inject keystrokes into the user's session indefinitely. -static const int kMaxRoundsAtMaxBackoff = 3; +// Stop pressing once a conflict has gone on this long. Measured as elapsed time rather than +// rounds spent at the maximum backoff, so lowering the maximum makes the mod retry more often +// - which is what the setting's name implies - instead of making it give up sooner. +static const ULONGLONG kGiveUpAfterConflictMs = 60ULL * 60 * 1000; // Having given up, try again this long afterwards. The user may have fixed the cause in a way // the mod cannot observe - switching the In-Game Overlay back on doesn't touch the registry @@ -170,6 +183,10 @@ static const DWORD kMinToggleIntervalMs = 3000; // Nvidia writes several values when the state changes, so let it settle before reading back. static const DWORD kRegistryNotifyDebounceMs = 750; +// Short-lived obstacles - a UAC prompt, a held modifier, a failed snapshot - shouldn't cost a +// whole poll interval to recover from, which the user may have set high deliberately. +static const DWORD kTransientRetryMs = 2000; + struct ModSettings { int pollIntervalSec = 10; int conflictBackoffSec = 900; @@ -426,30 +443,48 @@ static bool PatternNeedsPath(const std::wstring& pattern) { // A pattern without a backslash is matched against the process file name exactly, so // "notepad" doesn't quietly match ...\notepad++\notepad++.exe. A pattern with a backslash is // matched as a substring of the full image path, which covers "everything in this folder". -static bool MatchesProcess(std::wstring_view imagePath, - std::wstring_view fileName, - const std::wstring& pattern) { - if (PatternNeedsPath(pattern)) { - return ContainsNoCase(imagePath, pattern); +static bool MatchesAnyPattern(std::wstring_view imagePath, + std::wstring_view fileName, + const std::vector& patterns) { + for (const std::wstring& pattern : patterns) { + bool matched = PatternNeedsPath(pattern) ? ContainsNoCase(imagePath, pattern) + : EqualsNoCase(fileName, pattern); + if (matched) { + Wh_Log(L"Process match: '%s' matches '%s'", std::wstring(imagePath).c_str(), + pattern.c_str()); + return true; + } } - return EqualsNoCase(fileName, pattern); + return false; } -// Returns nullopt when the process list couldn't be read at all. That has to stay -// distinguishable from "nothing matched": with a non-empty onlyWhileRunning list, treating a -// failed enumeration as "nothing matched" would make the mod switch Instant Replay *off* while -// a game is running, which is the exact thing it exists to prevent. CreateToolhelp32Snapshot is -// documented to fail transiently with ERROR_BAD_LENGTH, so this isn't hypothetical. -static std::optional IsAnyProcessRunning(const std::vector& patterns) { - if (patterns.empty()) { - return false; +struct ProcessListResults { + bool onlyRunning = false; + bool pauseRunning = false; +}; + +// Answers both lists from a single snapshot. Returns nullopt when the process list couldn't be +// read at all, which has to stay distinguishable from "nothing matched": with a non-empty +// onlyWhileRunning list, treating a failed enumeration as "nothing matched" would make the mod +// switch Instant Replay *off* while a game is running, which is the exact thing it exists to +// prevent. CreateToolhelp32Snapshot is documented to fail transiently with ERROR_BAD_LENGTH. +static std::optional EvaluateProcessLists( + const std::vector& onlyList, + const std::vector& pauseList) { + ProcessListResults results; + + const bool needOnly = !onlyList.empty(); + const bool needPause = !pauseList.empty(); + if (!needOnly && !needPause) { + return results; } // Resolving full paths costs an OpenProcess for every process on the system. Only pay it // when a pattern actually asks for a path - entry.szExeFile is already the file name that // the exact-match form compares against. - const bool needsPath = std::any_of(patterns.begin(), patterns.end(), PatternNeedsPath); + const bool needsPath = std::any_of(onlyList.begin(), onlyList.end(), PatternNeedsPath) || + std::any_of(pauseList.begin(), pauseList.end(), PatternNeedsPath); HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot == INVALID_HANDLE_VALUE) { @@ -466,8 +501,6 @@ static std::optional IsAnyProcessRunning(const std::vector& return std::nullopt; } - bool found = false; - do { std::wstring imagePath = entry.szExeFile; @@ -492,17 +525,20 @@ static std::optional IsAnyProcessRunning(const std::vector& fileName = fileName.substr(lastSeparator + 1); } - for (const std::wstring& pattern : patterns) { - if (MatchesProcess(imagePath, fileName, pattern)) { - Wh_Log(L"Process match: '%s' matches '%s'", imagePath.c_str(), pattern.c_str()); - found = true; - break; - } + if (needOnly && !results.onlyRunning) { + results.onlyRunning = MatchesAnyPattern(imagePath, fileName, onlyList); } - } while (!found && Process32NextW(snapshot, &entry)); + if (needPause && !results.pauseRunning) { + results.pauseRunning = MatchesAnyPattern(imagePath, fileName, pauseList); + } + + if ((!needOnly || results.onlyRunning) && (!needPause || results.pauseRunning)) { + break; + } + } while (Process32NextW(snapshot, &entry)); CloseHandle(snapshot); - return found; + return results; } #pragma endregion // Process matching @@ -557,7 +593,9 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { int toggleStreak = 0; int currentBackoffSec = 0; - int roundsAtMaxBackoff = 0; + int togglesInWindow = 0; + ULONGLONG toggleWindowStart = 0; + ULONGLONG conflictSince = 0; bool gaveUp = false; std::vector hotkeyAtGiveUp; ULONGLONG giveUpRetryAt = 0; @@ -624,19 +662,31 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { continue; } + ULONGLONG now = GetTickCount64(); + + // The toggle budget is per rolling window and is deliberately *not* cleared when the + // state matches - that branch is reached after every successful round, which is exactly + // what would hide a slow conflict from the ladder. + if (now - toggleWindowStart >= kToggleWindowMs) { + toggleWindowStart = now; + togglesInWindow = 0; + } + if (waited == WAIT_OBJECT_0 + 1) { - // Settings changed. Give the new configuration an immediate retry, but keep the - // backoff ladder where it was so repeated tweaking during a real conflict doesn't - // restart it from the bottom every time. + // Settings changed. Give the new configuration a clean slate. toggleStreak = 0; + currentBackoffSec = 0; + conflictSince = 0; conflictUntil = 0; - roundsAtMaxBackoff = 0; + togglesInWindow = 0; + toggleWindowStart = now; gaveUp = false; } else if (waited == WAIT_OBJECT_0 + 2) { notifyArmed = false; if (StopRequested(kRegistryNotifyDebounceMs)) { break; } + now = GetTickCount64(); } InstantReplayState state = GetInstantReplayState(); @@ -650,54 +700,49 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { } loggedUnreadableState = false; - bool shouldBeOn = true; - if (!settings.onlyWhileRunning.empty()) { - std::optional running = IsAnyProcessRunning(settings.onlyWhileRunning); - if (!running) { - if (!loggedEnumerationFailure) { - loggedEnumerationFailure = true; - Wh_Log(L"Couldn't enumerate processes, skipping this cycle rather than " - L"guessing at the state Instant Replay should be in"); - } - continue; + std::optional processes = + EvaluateProcessLists(settings.onlyWhileRunning, settings.pauseWhileRunning); + if (!processes) { + if (!loggedEnumerationFailure) { + loggedEnumerationFailure = true; + Wh_Log(L"Couldn't enumerate processes, skipping this cycle rather than guessing " + L"at the state Instant Replay should be in"); } - loggedEnumerationFailure = false; - shouldBeOn = *running; + if (StopRequested(kTransientRetryMs)) { + break; + } + continue; } + loggedEnumerationFailure = false; + bool shouldBeOn = settings.onlyWhileRunning.empty() || processes->onlyRunning; bool isOn = state == InstantReplayState::On; - // Whatever we were fighting with has stopped, or never existed. Note this is reached - // even while backing off or after giving up: neither state stops the mod noticing that - // the situation has resolved. + // Whatever we were fighting with has stopped, or never existed. Reached even while + // backing off or after giving up: neither state stops the mod noticing that things + // resolved. The backoff ladder itself only clears once a whole window has passed + // without any toggle being needed. if (isOn == shouldBeOn) { toggleStreak = 0; - currentBackoffSec = 0; - roundsAtMaxBackoff = 0; conflictUntil = 0; gaveUp = false; + if (togglesInWindow == 0) { + currentBackoffSec = 0; + conflictSince = 0; + } continue; } - if (!settings.pauseWhileRunning.empty()) { - std::optional paused = IsAnyProcessRunning(settings.pauseWhileRunning); - if (!paused) { - if (!loggedEnumerationFailure) { - loggedEnumerationFailure = true; - Wh_Log(L"Couldn't enumerate processes, skipping this cycle rather than " - L"acting while a paused program might be running"); - } - continue; - } - loggedEnumerationFailure = false; - if (*paused) { - toggleStreak = 0; - continue; - } + if (processes->pauseRunning) { + toggleStreak = 0; + continue; } // Nothing we send can land right now, and that isn't a failure on our part. if (!IsInputDesktopReachable()) { + if (StopRequested(kTransientRetryMs)) { + break; + } continue; } @@ -715,8 +760,6 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { } loggedMissingHotkey = false; - ULONGLONG now = GetTickCount64(); - // Pressing the shortcut failed persistently enough that we stopped. Re-arm when the // user acts on what the log asked for - the shortcut changing is observable, and a // periodic retry covers the causes that aren't, like the overlay being switched on. @@ -726,40 +769,46 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { continue; } - Wh_Log(L"Trying again after giving up (%s)", + Wh_Log(L"Trying again after stopping (%s)", hotkeyChanged ? L"the toggle shortcut changed" : L"periodic retry"); gaveUp = false; toggleStreak = 0; currentBackoffSec = 0; - roundsAtMaxBackoff = 0; + conflictSince = 0; conflictUntil = 0; + togglesInWindow = 0; + toggleWindowStart = now; } if (now < conflictUntil) { continue; } - // If our toggles keep failing to stick, something is undoing them. Yield for a while - // rather than flip-flopping forever, growing the wait only as the fight continues. - if (toggleStreak >= kMaxTogglesBeforeBackoff) { - bool wasAtMax = currentBackoffSec >= settings.conflictBackoffSec; - currentBackoffSec = currentBackoffSec == 0 - ? std::min(kInitialBackoffSec, settings.conflictBackoffSec) - : std::min(currentBackoffSec * 2, settings.conflictBackoffSec); + // Two ways to conclude we're in a fight: our toggles keep failing to stick, or they + // keep sticking and something keeps undoing them slowly. Both feed the same ladder. + bool overToggleBudget = togglesInWindow >= kMaxTogglesPerWindow; + if (toggleStreak >= kMaxTogglesBeforeBackoff || overToggleBudget) { + if (currentBackoffSec == 0) { + conflictSince = now; + currentBackoffSec = std::min(kInitialBackoffSec, settings.conflictBackoffSec); + } else { + currentBackoffSec = + std::min(currentBackoffSec * 2, settings.conflictBackoffSec); + } conflictUntil = now + (ULONGLONG)currentBackoffSec * 1000; toggleStreak = kStreakAfterBackoff; - if (wasAtMax && ++roundsAtMaxBackoff >= kMaxRoundsAtMaxBackoff) { + if (now - conflictSince >= kGiveUpAfterConflictMs) { gaveUp = true; hotkeyAtGiveUp = hotkey; giveUpRetryAt = now + kGiveUpRetryMs; - Wh_Log(L"Pressing the toggle shortcut hasn't worked after repeated attempts at " - L"the maximum backoff. Pausing until the Instant Replay state changes, " - L"the toggle shortcut changes, or a few hours pass. Check that the " - L"In-Game Overlay is on and that a Toggle Instant Replay shortcut is " - L"set."); + Wh_Log(L"Instant Replay has been in conflict for over an hour. Pausing until " + L"the state settles, the toggle shortcut changes, or a few hours pass. " + L"Check that the In-Game Overlay is on and that a Toggle Instant Replay " + L"shortcut is set."); } else { - Wh_Log(L"Instant Replay keeps changing back. Backing off for %d seconds.", + Wh_Log(L"Instant Replay keeps changing back (%s). Backing off for %d seconds.", + overToggleBudget ? L"toggling too often" : L"toggles aren't sticking", currentBackoffSec); } continue; @@ -767,6 +816,9 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { // Pressing now would send the wrong combination and steal the keys being held. if (IsAnyRelevantKeyHeld(hotkey)) { + if (StopRequested(kTransientRetryMs)) { + break; + } continue; } @@ -779,6 +831,7 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { } nextToggleAllowed = now + kMinToggleIntervalMs; + togglesInWindow++; Wh_Log(L"Instant Replay is %s but should be %s, pressing the toggle shortcut", isOn ? L"on" : L"off", shouldBeOn ? L"on" : L"off"); @@ -789,13 +842,12 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { // Wait for the press to actually take effect before deciding whether it worked. A slow // but successful toggle then costs nothing, and toggleStreak counts real failures only. + // The backoff ladder is deliberately not cleared here - only a quiet window clears it. bool stop = false; InstantReplayState expected = shouldBeOn ? InstantReplayState::On : InstantReplayState::Off; if (WaitForState(expected, kToggleConfirmTimeoutMs, &stop)) { toggleStreak = 0; - currentBackoffSec = 0; - roundsAtMaxBackoff = 0; } else { if (stop) { break; @@ -827,7 +879,11 @@ BOOL WhTool_ModInit() { LoadSettings(); g_stopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); - g_wakeEvent = CreateEventW(nullptr, FALSE, FALSE, nullptr); + + // Created signalled so the first loop iteration evaluates the state immediately instead of + // waiting out a poll interval, which the user may have set as high as an hour. + g_wakeEvent = CreateEventW(nullptr, FALSE, TRUE, nullptr); + if (!g_stopEvent || !g_wakeEvent) { Wh_Log(L"Failed to create the watchdog events, error %u", GetLastError()); return FALSE; From 767f52a449027b3d093b16e2c31fbaeb0094265a Mon Sep 17 00:00:00 2001 From: Shahrukh Date: Mon, 10 Aug 2026 13:15:31 +0500 Subject: [PATCH 6/6] Release keys on a partial SendInput, and wait in place for held keys Fifth round of review feedback from the windhawk-mods submission: - A partial SendInput could leave modifiers logically held down system-wide until the user physically pressed and released them. The sequence can be interrupted between the key-downs and the key-ups by another thread's input, and the mod only logged and returned. It now releases every key in reverse order on a short send; releasing a key that isn't held is a no-op. - The held-key guard restarted the whole cycle every 2 seconds while a modifier was down, re-enumerating every process each time. Holding Shift or Ctrl for minutes is normal in a game, which is exactly when onlyWhileRunning users depend on this. It now waits in place, polling the keys and the stop event, and logs once if the wait goes on, naming whether it's a key of the shortcut or an unrelated modifier. - Use FindStringOrdinal rather than FindNLSStringEx for the path substring match. These are file paths, so linguistic collation was the wrong tool, and the two matching modes are now consistently ordinal. - Re-evaluate rather than press after the minimum-interval wait. The state and the held keys were read before that sleep and not re-checked afterwards, so the mod could invert a state that had since become correct. - Skip the process snapshot on quiet polls when onlyWhileRunning is empty. The pause list only matters once a mismatch is found, so the enumeration is deferred past that check. - Log the first time Instant Replay is switched off because nothing in onlyWhileRunning is running, so a list that never matches is diagnosable rather than looking like the mod working. - Start the first toggle window at mod start rather than at 10 minutes of system uptime, and describe it as tumbling rather than rolling. - README: state up front that Instant Replay can't be switched off by hand while the mod is enabled and that pauseWhileRunning is the escape hatch, raise the persistent-state caveat out of the last paragraph, and tell users to enable mod logging when it seems to be doing nothing. Co-Authored-By: Claude Opus 5 (1M context) --- mods/nvidia-keep-instant-replay-on.wh.cpp | 235 ++++++++++++++++------ 1 file changed, 178 insertions(+), 57 deletions(-) diff --git a/mods/nvidia-keep-instant-replay-on.wh.cpp b/mods/nvidia-keep-instant-replay-on.wh.cpp index 6d3a74f2ec..6dac49fbfe 100644 --- a/mods/nvidia-keep-instant-replay-on.wh.cpp +++ b/mods/nvidia-keep-instant-replay-on.wh.cpp @@ -31,6 +31,20 @@ trading keystrokes with it forever. This is a tool mod: it hooks nothing and injects into nothing, it just runs in its own dedicated process as the logged-in user. +## Before you install + +Two things are worth knowing up front, because both surprise people: + +- **While this mod is enabled you can't turn Instant Replay off by hand.** Switching it off in + the Nvidia overlay just makes the mod switch it back on within a few seconds - that is the + entire point of it. If you need it off for a while, disable the mod, or list the program you + want it off for under **Pause while these programs run**. +- **Instant Replay is a persistent Nvidia setting, so whatever state this mod leaves it in + survives the mod being disabled.** That matters most with **Only keep it on while these + programs run**: if you disable the mod at a moment when none of those programs are running, + Instant Replay stays off, because the mod turned it off on purpose and is no longer around to + turn it back on. Check the setting in the Nvidia App after turning that option off. + ## How is this different from "Shadowplay anti-disable"? They solve opposite halves of the same annoyance and work well together: @@ -59,11 +73,12 @@ re-read every time, so changing it takes effect immediately. Because the shortcut is replayed as real keystrokes, two things follow. Whatever window has focus also receives the combination. And the key-ups the mod sends would release those keys if -you were physically holding one - so the mod waits for a cycle when none of the shortcut's keys -and no modifier is held down, and a shortcut built from keys you hold during normal use is -still a poor choice. This is inherent to the approach: the Nvidia App no longer ships the local -server GeForce Experience used to expose, so the shortcut is the only way left to switch -Instant Replay from outside. +you were physically holding one - so the mod waits for a moment when none of the shortcut's +keys and no modifier is held down, and a shortcut built from keys you hold during normal use +(Shift, Ctrl) is still a poor choice, because the mod will keep waiting while you hold them. +This is inherent to the approach: the Nvidia App no longer ships the local server GeForce +Experience used to expose, so the shortcut is the only way left to switch Instant Replay from +outside. Because those keystrokes are the mod's main cost to you, it also caps how often it is willing to press at all. If something keeps switching Instant Replay off - even slowly, every few @@ -71,15 +86,14 @@ minutes - the mod backs off progressively and stops after about an hour of that, trading keystrokes with it indefinitely. It starts again when the state settles, when you change the toggle shortcut, or after a few hours. -## Notes +## If it doesn't seem to be doing anything -Works with the Nvidia App and with the old GeForce Experience. +Everything the mod can't recover from - no toggle shortcut configured, the overlay switched +off, giving up after a long conflict - is reported through the mod log, which is off by +default. Turn on logging for this mod in its advanced settings and the reason will be in +there. -Instant Replay is a persistent Nvidia setting, so whatever state the mod leaves it in stays -that way after the mod is disabled. In particular, if you use **Only keep it on while these -programs run** and then disable the mod at a moment when none of those programs are running, -Instant Replay is left off - the mod turned it off on purpose and isn't around any more to -turn it back on. +Works with the Nvidia App and with the old GeForce Experience. */ // ==/WindhawkModReadme== @@ -103,17 +117,21 @@ turn it back on. - pauseWhileRunning: [""] $name: Pause while these programs run $description: >- - While any of these is running, Instant Replay is left alone. An entry without a backslash - matches a process by exact file name, e.g. netflix.exe. An entry containing a backslash is - matched as a substring of the full image path, e.g. \Netflix\ - useful for covering a whole - install folder, but it can only match processes this mod is allowed to open, so prefer the - file name form unless you need the path. Matching ignores case either way. + While any of these is running, Instant Replay is left alone - this is the escape hatch for + when you want to switch it off by hand. An entry without a backslash matches a process by + exact file name, e.g. netflix.exe. An entry containing a backslash is matched as a + substring of the full image path, e.g. \Netflix\ - useful for covering a whole install + folder, but it can only match processes this mod is allowed to open, so prefer the file + name form unless you need the path. Matching ignores case either way. Leave the single empty entry to disable this. - onlyWhileRunning: [""] $name: Only keep it on while these programs run $description: >- Same matching. If this list is not empty, Instant Replay is forced ON while at least one of - these is running and forced OFF the rest of the time. + these is running and forced OFF the rest of the time. Note that an entry which never + matches anything - a typo, or a path form aimed at a process the mod can't open - leaves + Instant Replay switched off, and that it stays off if you disable the mod while nothing in + the list is running. Leave the single empty entry to disable this. */ // ==/WindhawkModSettings== @@ -154,6 +172,7 @@ static const int kStreakAfterBackoff = kMaxTogglesBeforeBackoff - 1; // A conflict slower than kToggleConfirmTimeoutMs confirms fine on every attempt, so the // consecutive-failure streak never sees it - something switching Instant Replay off every // minute would otherwise be answered forever. Bound how often we're willing to press at all. +// The counter resets wholesale when the window elapses rather than expiring entry by entry. static const int kMaxTogglesPerWindow = 5; static const ULONGLONG kToggleWindowMs = 10ULL * 60 * 1000; @@ -177,14 +196,21 @@ static const ULONGLONG kGiveUpRetryMs = 6ULL * 60 * 60 * 1000; static const DWORD kToggleConfirmTimeoutMs = 8000; static const DWORD kToggleConfirmPollMs = 500; +// How long to keep waiting for held keys to be released before giving the outer loop a chance +// to re-evaluate. Holding Shift or Ctrl for minutes at a time is normal in a game, so this +// waits in place rather than restarting the cycle - restarting would re-enumerate every +// process on the system each time round. +static const DWORD kHeldKeyWaitMs = 30000; +static const DWORD kHeldKeyPollMs = 250; + // Don't press the shortcut faster than this, no matter how many notifications arrive. static const DWORD kMinToggleIntervalMs = 3000; // Nvidia writes several values when the state changes, so let it settle before reading back. static const DWORD kRegistryNotifyDebounceMs = 750; -// Short-lived obstacles - a UAC prompt, a held modifier, a failed snapshot - shouldn't cost a -// whole poll interval to recover from, which the user may have set high deliberately. +// Short-lived obstacles - a UAC prompt, a failed snapshot - shouldn't cost a whole poll +// interval to recover from, which the user may have set high deliberately. static const DWORD kTransientRetryMs = 2000; struct ModSettings { @@ -368,24 +394,37 @@ static bool PressToggleHotkey(const std::vector& keys) { if (sent != inputs.size()) { Wh_Log(L"SendInput only sent %u of %zu inputs, error %u", sent, inputs.size(), GetLastError()); + + // The sequence can be interrupted part way through by another thread's input. If it + // stopped after the key-downs, the system now believes those keys are held, system + // wide, until the user physically presses and releases them - a far worse outcome than + // a missed toggle. Releasing a key that isn't held is a no-op, so release them all. + std::vector release; + release.reserve(keys.size()); + for (size_t i = keys.size(); i > 0; i--) { + AddKeyInput(&release, keys[i - 1], false); + } + SendInput((UINT)release.size(), release.data(), sizeof(INPUT)); + return false; } return true; } -// If the user is physically holding any of these, the combination Nvidia sees isn't the one -// that's configured, so the toggle would fail and be counted as a conflict - and the key-ups -// we send would steal the keys out from under them. -static bool IsAnyRelevantKeyHeld(const std::vector& keys) { - static const WORD kModifiers[] = {VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN}; - +static bool IsAnyKeyHeld(const std::vector& keys) { for (WORD vkey : keys) { if (GetAsyncKeyState(vkey) & 0x8000) { return true; } } + return false; +} + +static bool IsAnyModifierHeld() { + static const WORD kModifiers[] = {VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN}; + for (WORD vkey : kModifiers) { if (GetAsyncKeyState(vkey) & 0x8000) { return true; @@ -395,6 +434,13 @@ static bool IsAnyRelevantKeyHeld(const std::vector& keys) { return false; } +// If the user is physically holding any of these, the combination Nvidia sees isn't the one +// that's configured, so the toggle would fail and be counted as a conflict - and the key-ups +// we send would steal the keys out from under them. +static bool IsAnyRelevantKeyHeld(const std::vector& keys) { + return IsAnyKeyHeld(keys) || IsAnyModifierHeld(); +} + // A locked session or a secure desktop (UAC) means injected input can't reach anything. That's // not the mod losing a fight, so it mustn't be allowed to drive the backoff. static bool IsInputDesktopReachable() { @@ -411,8 +457,9 @@ static bool IsInputDesktopReachable() { #pragma region Process matching -// CompareStringOrdinal / FindNLSStringEx rather than towlower, which only folds ASCII under the -// default locale and would quietly fail to match paths containing non-ASCII characters. +// Ordinal comparison throughout: these are file paths, not text, so linguistic collation with +// its ignorable characters and locale folding rules would be the wrong tool. Both matching +// modes stay consistent this way, and neither depends on the user's locale. static bool EqualsNoCase(std::wstring_view a, std::wstring_view b) { if (a.size() != b.size()) { return false; @@ -431,9 +478,8 @@ static bool ContainsNoCase(std::wstring_view haystack, std::wstring_view needle) return false; } - return FindNLSStringEx(LOCALE_NAME_INVARIANT, FIND_FROMSTART | NORM_IGNORECASE, - haystack.data(), (int)haystack.size(), needle.data(), - (int)needle.size(), nullptr, nullptr, nullptr, 0) >= 0; + return FindStringOrdinal(FIND_FROMSTART, haystack.data(), (int)haystack.size(), + needle.data(), (int)needle.size(), TRUE) >= 0; } static bool PatternNeedsPath(const std::wstring& pattern) { @@ -575,6 +621,29 @@ static bool WaitForState(InstantReplayState expected, DWORD timeoutMs, bool* sto } } +// Waits in place for held keys to clear. Returns true when they have. Waiting here rather than +// restarting the outer loop matters: every restart re-enumerates every process on the system, +// and holding a modifier for minutes is normal in a game. +static bool WaitForKeysReleased(const std::vector& keys, bool* stop) { + ULONGLONG deadline = GetTickCount64() + kHeldKeyWaitMs; + + for (;;) { + if (!IsAnyRelevantKeyHeld(keys)) { + return true; + } + + ULONGLONG now = GetTickCount64(); + if (now >= deadline) { + return false; + } + + if (StopRequested((DWORD)std::min(kHeldKeyPollMs, deadline - now))) { + *stop = true; + return false; + } + } +} + static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { Wh_Log(L"Instant Replay watchdog started"); @@ -594,7 +663,7 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { int toggleStreak = 0; int currentBackoffSec = 0; int togglesInWindow = 0; - ULONGLONG toggleWindowStart = 0; + ULONGLONG toggleWindowStart = GetTickCount64(); ULONGLONG conflictSince = 0; bool gaveUp = false; std::vector hotkeyAtGiveUp; @@ -604,6 +673,8 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { bool loggedUnreadableState = false; bool loggedEnumerationFailure = false; bool loggedMissingHotkey = false; + bool loggedHeldKeys = false; + bool loggedForcedOff = false; for (;;) { ModSettings settings = GetSettings(); @@ -664,9 +735,9 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { ULONGLONG now = GetTickCount64(); - // The toggle budget is per rolling window and is deliberately *not* cleared when the - // state matches - that branch is reached after every successful round, which is exactly - // what would hide a slow conflict from the ladder. + // The toggle budget is per window and is deliberately *not* cleared when the state + // matches - that branch is reached after every successful round, which is exactly what + // would hide a slow conflict from the ladder. if (now - toggleWindowStart >= kToggleWindowMs) { toggleWindowStart = now; togglesInWindow = 0; @@ -700,22 +771,29 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { } loggedUnreadableState = false; - std::optional processes = - EvaluateProcessLists(settings.onlyWhileRunning, settings.pauseWhileRunning); - if (!processes) { - if (!loggedEnumerationFailure) { - loggedEnumerationFailure = true; - Wh_Log(L"Couldn't enumerate processes, skipping this cycle rather than guessing " - L"at the state Instant Replay should be in"); - } - if (StopRequested(kTransientRetryMs)) { - break; + // With no onlyWhileRunning list the desired state is always "on", so the pause list is + // the only one that matters - and it only matters once a mismatch is found. Deferring + // the snapshot keeps it off every quiet poll. + std::optional processes; + bool shouldBeOn = true; + + if (!settings.onlyWhileRunning.empty()) { + processes = EvaluateProcessLists(settings.onlyWhileRunning, settings.pauseWhileRunning); + if (!processes) { + if (!loggedEnumerationFailure) { + loggedEnumerationFailure = true; + Wh_Log(L"Couldn't enumerate processes, skipping this cycle rather than " + L"guessing at the state Instant Replay should be in"); + } + if (StopRequested(kTransientRetryMs)) { + break; + } + continue; } - continue; + loggedEnumerationFailure = false; + shouldBeOn = processes->onlyRunning; } - loggedEnumerationFailure = false; - bool shouldBeOn = settings.onlyWhileRunning.empty() || processes->onlyRunning; bool isOn = state == InstantReplayState::On; // Whatever we were fighting with has stopped, or never existed. Reached even while @@ -733,9 +811,28 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { continue; } - if (processes->pauseRunning) { - toggleStreak = 0; - continue; + if (!settings.pauseWhileRunning.empty()) { + if (!processes) { + const std::vector noOnlyList; + processes = EvaluateProcessLists(noOnlyList, settings.pauseWhileRunning); + if (!processes) { + if (!loggedEnumerationFailure) { + loggedEnumerationFailure = true; + Wh_Log(L"Couldn't enumerate processes, skipping this cycle rather than " + L"acting while a paused program might be running"); + } + if (StopRequested(kTransientRetryMs)) { + break; + } + continue; + } + loggedEnumerationFailure = false; + } + + if (processes->pauseRunning) { + toggleStreak = 0; + continue; + } } // Nothing we send can land right now, and that isn't a failure on our part. @@ -814,20 +911,44 @@ static DWORD WINAPI InstantReplayWatchdogThread(LPVOID) { continue; } - // Pressing now would send the wrong combination and steal the keys being held. + // Pressing now would send the wrong combination and steal the keys being held. Wait in + // place rather than restarting the cycle, which would re-enumerate every process. if (IsAnyRelevantKeyHeld(hotkey)) { - if (StopRequested(kTransientRetryMs)) { - break; + bool stop = false; + if (!WaitForKeysReleased(hotkey, &stop)) { + if (stop) { + break; + } + if (!loggedHeldKeys) { + loggedHeldKeys = true; + Wh_Log(L"Waiting for %s to be released before pressing the toggle shortcut. " + L"Instant Replay stays as it is until then.", + IsAnyKeyHeld(hotkey) ? L"a key of the toggle shortcut" + : L"a held modifier"); + } + continue; } - continue; + now = GetTickCount64(); } + loggedHeldKeys = false; - // A burst of registry notifications shouldn't turn into a burst of keystrokes. + // A burst of registry notifications shouldn't turn into a burst of keystrokes. Go round + // again afterwards rather than pressing, so the state and the held keys are re-read - + // either could have changed while we waited. if (now < nextToggleAllowed) { if (StopRequested((DWORD)(nextToggleAllowed - now))) { break; } - now = GetTickCount64(); + continue; + } + + if (!shouldBeOn && !loggedForcedOff) { + loggedForcedOff = true; + Wh_Log(L"Turning Instant Replay off because nothing in 'Only keep it on while these " + L"programs run' is running. If that isn't what you expect, check those " + L"entries - one that never matches keeps Instant Replay off."); + } else if (shouldBeOn) { + loggedForcedOff = false; } nextToggleAllowed = now + kMinToggleIntervalMs;